基金转换 T+1 模型:T-11 core_tools 汇总去重 + 持仓过滤(确认段场景补用例,生产代码零改动)

This commit is contained in:
2026-09-12 09:35:13 +08:00
parent 5a784dc185
commit b2d8a90ad2
+228 -1
View File
@@ -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]