diff --git a/tests/test_core_tools.py b/tests/test_core_tools.py index 6115588..f117b64 100644 --- a/tests/test_core_tools.py +++ b/tests/test_core_tools.py @@ -21,9 +21,16 @@ from decimal import Decimal import pytest from sqlalchemy import text -from _ddl import create_sqlite_engine +from _ddl import create_sqlite_engine, seed_suitability_matrix +from app.gateway.convert_core_repository import ConvertCoreRepository +from app.repository.convert_repository import ConvertRepository +from app.repository.convert_request_repository import ConvertRequestRepository from app.repository.core_ro import CoreReadOnlyRepository +from app.repository.risk_repository import RiskRepository +from app.service.convert.confirm_service import confirm_one +from app.service.convert.convert_service import accept_convert +from app.service.risk.rules import RiskThresholds from app.tool.core_tools import query_holdings, query_recent_trades CUST = "CUST-T11" @@ -105,3 +112,223 @@ def test_query_holdings_excludes_zero_qty(seed): # 合计不含归零行(PB 的 market_value 为 0,即使计入也不变; # 故断言**条数**才是真正能区分对错的判据) assert res["sum_market_value"] == 1200.0 + + +# ── T+1 确认段场景(v2.0 T-11 · 开发计划 R-回归 8 / R-回归 13 的补用例项)──────── +# 既有 3 条用例以直接插 SQL 的方式模拟 convert 流水,锁定**口径本身**; +# 下面两条改走真实链路(accept_convert → confirm_one),锁定「确认事务真实落库 +# 之后」口径依然成立 —— T+1 模型下流水与持仓**只在确认段产生**,这是两条新用例 +# 相对既有用例的独立价值(不是重复覆盖): +# ① 确认落两条同 gid 流水 → sum_amount 只计转出端一次(验收 11) +# ② 全转归零 → core_holding.qty=0 行保留(台账留痕)但不在持仓查询结果(F-12) + +PROD_T11_OUT = "PROD-T11-OUT" +PROD_T11_IN = "PROD-T11-IN" +COMPANY = "华夏模拟基金" +TA = "TA-CN-001" + +T_DAY = _dt.date(2026, 9, 4) # 受理日 T(周五) +T1_DAY = _dt.date(2026, 9, 7) # 确认业务日 T+1(下周一) +SUBMIT_AT = _dt.datetime(2026, 9, 4, 10, 0) +CONFIRM_AT = _dt.datetime(2026, 9, 7, 9, 0) + +OUT_NAV = Decimal("1.3604") +IN_NAV = Decimal("1.9194") +FEE_TIERS = [ + (0, 7, "0.0150"), (7, 30, "0.0100"), (30, 180, "0.0050"), + (180, 365, "0.0025"), (365, None, "0.0000"), +] +# 全转 30000 份的折算锚点:转出金额 30000×1.3604 = 40812.00(费前,与 confirm 返回 +# out_amount 同源),持有期落 (30,180) 档 → 赎回费 40812.00×0.50% = 204.06 单列 redeem_fee。 +# core_trade 转出端流水 amount = 费前 40812.00(R-b 口径,与 PRD §5.3.2 示例 68020.00 同语义)。 +OUT_AMOUNT = Decimal("40812.00") +REDEEM_FEE = Decimal("204.06") +PLAIN_AMOUNT = Decimal("100000") + + +@pytest.fixture() +def t1_env(): + """T+1 链路最小种子:C3 客户、转出 R2 债基(1 批 30000 份 + 持仓 30000)、转入 R4 股基。 + + 两端同管理人同 TA(受理段硬约束);批次 confirmed_at=2026-07-01 → 持有期落 + (30,180) 档 0.50%,赎回费数值确定(见 OUT_AMOUNT 注释),断言可逐字节对账。 + """ + engine = create_sqlite_engine() + seed_suitability_matrix(engine) # C×R 矩阵是 check_suitability 的 L0 权威,缺失即全量 forbidden + with engine.begin() as conn: + conn.execute( + text( + "INSERT INTO core_customer (customer_id, display_name, age, is_active)" + " VALUES (:c, 'T11客户', 40, 1)" + ), + {"c": CUST}, + ) + conn.execute( + text( + "INSERT INTO core_customer_risk (customer_id, risk_code, evaluated_at, expires_at)" + " VALUES (:c, 'C3', :t, :e)" # C3 → 转入 R4 放行;有效期覆盖 T 与 T+1(D25 复核) + ), + {"c": CUST, "t": SUBMIT_AT - _dt.timedelta(days=30), "e": SUBMIT_AT + _dt.timedelta(days=300)}, + ) + conn.execute( + text( + "INSERT INTO core_product (product_id, product_name, min_risk_code, product_type," + " can_subscribe, can_redeem, subscribe_fee_rate, min_redeem_qty, min_hold_qty," + " fund_company, ta_code) VALUES" + " (:po, 'T11转出债基', 'R2', 'bond', 1, 1, 0.0030, 0, 0, :co, :ta)," + " (:pi, 'T11转入股基', 'R4', 'stock', 1, 1, 0.0080, 0, 0, :co, :ta)" + ), + {"po": PROD_T11_OUT, "pi": PROD_T11_IN, "co": COMPANY, "ta": TA}, + ) + for lo, hi, rate in FEE_TIERS: + conn.execute( + text( + "INSERT INTO core_fee_rule (product_id, fee_type, min_hold_days," + " max_hold_days, rate) VALUES (:p, 'redeem', :a, :b, :r)" + ), + {"p": PROD_T11_OUT, "a": lo, "b": hi, "r": float(rate)}, + ) + conn.execute( + text( + "INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) VALUES" + " (:po, :no, 0, :d), (:pi, :ni, 0, :d)" + ), + {"po": PROD_T11_OUT, "no": float(OUT_NAV), "pi": PROD_T11_IN, "ni": float(IN_NAV), "d": T_DAY}, + ) + conn.execute( + text( + "INSERT INTO core_share_lot (lot_id, customer_id, product_id, qty, remain_qty," + " nav, confirmed_at) VALUES (:l, :c, :p, 30000, 30000, 1.0300, :cat)" + ), + {"l": "LOT-T11-1", "c": CUST, "p": PROD_T11_OUT, "cat": _dt.datetime(2026, 7, 1, 10, 0, 0)}, + ) + conn.execute( + text( + "INSERT INTO core_holding (customer_id, product_id, qty, cost_amount," + " market_value, pnl_pct, as_of) VALUES (:c, :p, 30000, 30000, 40812.0, 0, :d)" + ), + {"c": CUST, "p": PROD_T11_OUT, "d": T_DAY}, + ) + cursor = _dt.date(2026, 9, 1) + for _ in range(60): + if cursor.weekday() < 5: # 周一~周五开市;周末不插行 = 休市 + conn.execute( + text("INSERT INTO core_trade_calendar (cal_date, is_open, remark) VALUES (:d, 1, 't')"), + {"d": cursor}, + ) + cursor += _dt.timedelta(days=1) + yield engine + engine.dispose() + + +def _t11_services(engine) -> dict: + return dict( + core_ro=CoreReadOnlyRepository(engine=engine), + risk_repo=RiskRepository(engine=engine), + convert_repo=ConvertRepository(engine=engine), + request_repo=ConvertRequestRepository(engine=engine), + ) + + +def _quiet_th() -> RiskThresholds: + """全阈值推到不可达:本组用例只关心 core_tools 查询口径,不让引擎出单添噪。""" + big = Decimal("999999999") + return RiskThresholds( + large_amount=big, daily_total=big, freq_count=999, probe_window_minutes=5, + probe_count=999, probe_amount=big, small_amount=Decimal("0.01"), small_count=999, + concentration_threshold=1.01, + ) + + +def _accept_full(engine) -> dict: + """受理**全转**(qty = 批次总量 30000):确认后转出端持仓必然归零(F-12 场景)。""" + return accept_convert( + { + "customer_id": CUST, + "from_product_id": PROD_T11_OUT, + "to_product_id": PROD_T11_IN, + "qty": Decimal("30000"), + "client_request_id": "T11-REQ-1", + }, + now=SUBMIT_AT, + **_t11_services(engine), + ) + + +def _confirm(engine, gid: str) -> dict: + return confirm_one( + gid, + now=CONFIRM_AT, + as_of=T1_DAY, + core_writer=ConvertCoreRepository(engine=engine), + thresholds=_quiet_th(), + **_t11_services(engine), + ) + + +def _t11_rows(engine, sql: str, **params) -> list[dict]: + with engine.connect() as conn: + return [dict(r) for r in conn.execute(text(sql), params).mappings()] + + +def test_confirm_then_sum_amount_counts_once(t1_env): + """验收 11(确认段场景):真实受理→确认落两条同 gid 流水后,sum_amount 只计转出端。""" + engine = t1_env + gid = _accept_full(engine)["convert_group_id"] + res = _confirm(engine, gid) + assert res["status"] == "confirmed" + assert res["out_amount"] == "40812.00" # 折算锚点逐字节对账(30000×1.3604,费前) + assert res["redeem_fee"] == "204.06" # 持有期落 (30,180) 档 0.50% 的证明 + + # 同一确认日再落一笔无组普通赎回(无 convert_group_id → 金额全额计入) + with engine.begin() as conn: + conn.execute( + text( + "INSERT INTO core_trade (trade_id, customer_id, product_id, trade_type, amount," + " qty, convert_group_id, trade_status, traded_at) VALUES" + " ('TRD-T11-PLAIN', :cid, :pid, 'redeem', 100000.0, 80.0, NULL, 'confirmed', :t)" + ), + {"cid": CUST, "pid": PROD_T11_OUT, "t": CONFIRM_AT}, + ) + + repo = CoreReadOnlyRepository(engine=engine) + out = query_recent_trades(CUST, days=30, core_ro=repo) + + # 明细全量:convert 两条(redeem+subscribe)+ 普通赎回 1 条 —— 一次转换两条是真实的 + assert out["total_count"] == 3 + assert sorted(r["trade_type"] for r in out["items"]) == ["redeem", "redeem", "subscribe"] + + # 汇总只计转出端:40812.00 + 100000 = 140812.00; + # 转入端 in_amount 若被计入即翻倍(差额恰为一条转入流水)—— 用具体值才区分得开 + assert Decimal(str(out["sum_amount"])) == OUT_AMOUNT + PLAIN_AMOUNT + + # 跨口径一致性(T+1 确认场景):SQL 侧同日累计与 Tool 汇总一致(流水 traded_at = T+1) + assert repo.sum_trades_on_date(CUST, T1_DAY) == OUT_AMOUNT + PLAIN_AMOUNT + + +def test_confirm_full_transfer_holding_zero_row_excluded(t1_env): + """F-12 / R-回归 13(确认段场景):全转后 `core_holding.qty=0` 行保留但不是持仓。""" + engine = t1_env + gid = _accept_full(engine)["convert_group_id"] + res = _confirm(engine, gid) + assert res["status"] == "confirmed" + + # 台账留痕行仍在(D 决策:转出归零保留 qty=0 行,不物理删除)—— + # 先证明行存在,后面的「不在持仓结果」断言才有区分度(否则可能是行被删了) + rows = _t11_rows( + engine, + "SELECT qty FROM core_holding WHERE customer_id = :c AND product_id = :p", + c=CUST, p=PROD_T11_OUT, + ) + assert len(rows) == 1 and Decimal(str(rows[0]["qty"])) == 0 + + repo = CoreReadOnlyRepository(engine=engine) + # SQL 层(core_ro.list_holdings,`qty > 0` 过滤):只剩转入端持仓 + listed = repo.list_holdings(CUST) + assert [r["product_id"] for r in listed] == [PROD_T11_IN] + assert Decimal(str(listed[0]["qty"])) == Decimal(res["in_qty"]) # 确认段两端持仓同事务落对 + + # Tool 层(core_tools.query_holdings)与 SQL 层同口径 + holdings = query_holdings(CUST, core_ro=repo) + assert holdings["total_count"] == 1 + assert [r["product_id"] for r in holdings["items"]] == [PROD_T11_IN]