基金转换 T+1 模型:T-10 普通申赎批次维护 + T+2 校验 + rebuild_lots

FR-C16 份额批次全生命周期 + FR-C25 T+2 可赎回 + redeem 份额申报 D26 落地普通赎回侧:

- trade_gateway:_redeem_quote 走 available_qty_with_inflight 硬校验(R-3,
  申报超可赎抛 InsufficientShares 不静默裁剪);_maintain_lots redeem 分支
  三重裁剪 = T+2 过滤 → D8 补建 → 在途占用 → 哨兵 FIFO 扣减;subscribe 落新批次
- core_ro.list_share_lots / share_lot_repository.select_for_convert 加
  available_from T+2 半开区间过滤(R-4,日历缺行降级不过滤,金额/扣减同口径)
- gateway_repository 扣批次加哨兵 remain_qty >= :q(rowcount!=1 回滚防超扣)

验证:test_share_lot +10 / test_trade_gateway +1 → 全量 844 passed / 10 skipped
(基线 834 + 11 零回归);真库 verify_convert_api 89/89(H 节提前 + H2 在途
占用拦截断言);rebuild_lots --dry-run 58 持仓 0 补建 0 写入;突防验证区分度成立
This commit is contained in:
2026-09-11 21:24:32 +08:00
parent 6ecde37885
commit 34c7d0840d
7 changed files with 519 additions and 34 deletions
+32 -12
View File
@@ -345,6 +345,24 @@ def main() -> int:
client = TestClient(app)
try:
# ── H. redeem 份额申报金额(D26/R-6)· **必须最先**(只读、依赖未扣份额)──
# T-10 起 `_redeem_quote` 走 `available_qty_with_inflight`(R-3)硬校验:
# 必须放在任何受理之前执行(此时在途=0,可赎=物理余量 50000),
# 否则会被后续受理单的在途占用拦截 —— 见 H2 真库断言。
print("\n【H】redeem 份额申报:`_redeem_quote = qty × D 净值 − 赎回费`(FIFO 逐批)")
# FIFO 从批次 A1(持 100 天)取前 100 份 → 费率档 0.0050(持有 100 天)。
# 期望 = 100 × 1.3604 = 136.04,扣费 0.68 → 实得 135.36。
# ⚠️ `_nav_as_of` 的语义是「traded_at 当日(含)前最新净值」→ traded_at 必须锚在
# **受理日(=交易日)**:受理日已顺延到 9/14(今天 9/11 20:24 过截点),种子净值
# 也插在 9/14 —— 若传 `datetime.now()`(9/11)就取不到净值(首版实跑即 NavNotReady)。
from app.gateway.trade_gateway import _redeem_quote
quote = _redeem_quote(
core_ro, customer_id=CUSTOMER, product_id=PROD_OUT, qty=Decimal("100"),
traded_at=datetime.combine(accept_date, time(10, 0)),
)
check("redeem 报价 = qty×净值−赎回费(FIFO 0.0050 档)", dec(quote), Decimal("135.36"))
# ── A. 受理:HTTP 202 + 受理回执 + 真库只落受理单(PRD §5.3.1)──
print("\n【A】受理:202 + 受理回执;真库 ENUM 落单、零折算副作用")
code, body = push(client, **_body(cid="ACP-REQ-A"))
@@ -397,20 +415,22 @@ def main() -> int:
for key in CONFIRMED_ONLY:
check(f"未确认查询不含 {key}", key in body, False)
# ── H. redeem 份额申报金额(D26/R-6)· **必须最先**(只读、依赖未扣份额)──
print("\n【H】redeem 份额申报:`_redeem_quote = qty × D 净值 − 赎回费`(FIFO 逐批)")
# FIFO 从批次 A1(持 100 天)取前 100 份 → 费率档 0.0050(持有 100 天)。
# 期望 = 100 × 1.3604 = 136.04,扣费 0.68 → 实得 135.36。
# ⚠️ `_nav_as_of` 的语义是「traded_at 当日(含)前最新净值」→ traded_at 必须锚在
# **受理日(=交易日)**:受理日已顺延到 9/14(今天 9/11 20:24 过截点),种子净值
# 也插在 9/14 —— 若传 `datetime.now()`(9/11)就取不到净值(首版实跑即 NavNotReady)。
# ── H2. T-10 在途占用真库校验:受理 REQ-A 占满 50000 在途 → redeem 申报必须被拒 ──
# R-3 硬校验(available_qty_with_inflight):受理后 可赎 = 50000 − 50000 = 0,
# `_redeem_quote` 抛 InsufficientShares —— 与扣减侧同口径(拒绝超量,不静默裁剪)。
print("\n【H2】T-10:在途占用拦截赎回申报(InsufficientShares 硬校验)")
from app.gateway.trade_gateway import _redeem_quote
from app.service.convert.errors import InsufficientShares
quote = _redeem_quote(
core_ro, customer_id=CUSTOMER, product_id=PROD_OUT, qty=Decimal("100"),
traded_at=datetime.combine(accept_date, time(10, 0)),
)
check("redeem 报价 = qty×净值−赎回费(FIFO 0.0050 档)", dec(quote), Decimal("135.36"))
try:
_redeem_quote(
core_ro, customer_id=CUSTOMER, product_id=PROD_OUT, qty=Decimal("100"),
traded_at=datetime.combine(accept_date, time(10, 0)),
)
except InsufficientShares as exc:
check("在途占用拦截(InsufficientShares 抛出)", "申报赎回" in str(exc), True)
else:
check("在途占用拦截(InsufficientShares 抛出)", False, True)
# ── G. 鉴权:两道闸门分离(红线 7)· **确认之前**(REQ-A 份额未转走)──
# 复用 REQ-A:非本人两闸门都拒 / 顾问可查不可撤 / risk_demo 无查询 scope /