基金转换 T-10:普通申赎批次维护(FR-C16)
让 core_trade 的普通申赎同步维护 core_share_lot 批次,为 convert 提供权威份额口径。 落地(改码 3 处 + 新增 2 个脚本) - gateway_repository:新增 insert_share_lots / deduct_share_lots,各自单事务、走 rw 账号; 只落已分配结果、不含分配逻辑,避免出现第二份 FIFO 口径 - share_lot_repository:__init__ 增可选 core_ro 入参,让 trade_gateway 复用同一份 FIFO 选批口径(D18) - trade_gateway:新增 _nav_as_of / _maintain_lots,在落流水后、规则引擎前调用 · subscribe:取 T 日(含)前最新净值 → qty = amount ÷ 净值(2 位 HALF_UP)→ 建批次(confirmed_at = T) · redeem:FIFO 扣减;无批次但有持仓 → D8 兜底补建后再扣;两者皆无 → 降级跳过 · 整段 try 包住,任何异常只 warning,绝不阻断交易(R-c(1)) - 新增 scripts/core/rebuild_lots.py:按 core_holding 快照重建批次(L-7),与网关 D8 同调 bootstrap_lots; 默认只补建无批次的持仓行(幂等),--force 才先删后建,--dry-run 只报告 - 新增 scripts/dev/verify_convert_lots.py:真库验证脚本(MySQL 8.0.46) 口径订正(联网查证 4 家管理人业务规则后) - redeem 的 amount ÷ 净值 折算属本项目简化建模,不是行业标准 - 行业铁律是「金额申购、份额赎回」:投资者以份额申报,登记机构按 T 日净值算金额 (睿远业务规则 §65 / 华泰保兴 §69 / 东方基金 §57 / 国投瑞银 §33;无一家公募支持按金额赎回) - 派生风险:未知价法下 T 日净值当日不可得(T+1 公告),_nav_as_of 实取 T−1 净值, 故此处算出的份额只是估算值 - 已记入 PRD §10.1 已知差异清单;trade_gateway 注释同步订正 验证 - pytest -q → 714 passed / 3 skipped(基线 697 加 17,零回归) - R16 test_redeem_accepted_without_alert 零改动通过(由 R-c(1) 降级保住) - 真库 20/20;T-6 24/24、T-7 35/35 复跑零回归;真库隔离数据零残留 - 突变验证 2 组:切断接线 → 精准 2 条红;关掉 D8 兜底 → 精准 2 条红(含 D18 同源断言)
This commit is contained in:
@@ -1222,16 +1222,77 @@ sqlite 无 gap lock,故该分支由 `tests/test_convert_core.py` 用注入点
|
||||
- 无批次时**补建再扣**,**不跳过、不阻断**
|
||||
|
||||
**DoD**
|
||||
- [ ] 开工前:全量全绿基线已记录(当前 **516**)
|
||||
- [ ] 正常赎回后再 convert,**批次与持仓一致、不超扣**(验收 9)
|
||||
- [ ] **真实路径全覆盖**(R-c(2)):`test_share_lot.py` 自建种子 → `subscribe` 建批次(`qty = amount ÷ nav`,2 位 HALF_UP,`confirmed_at = T`)· `redeem` FIFO 扣减 · 无批次有持仓 → **D8 兜底补建后再扣** · `rebuild_lots.py` 入口
|
||||
- [ ] 降级路径单测(R-c(1)):`subscribe` 无净值 → warning + 不建批次 + 不抛异常;`redeem` 无持仓无批次 → warning + 跳过扣减
|
||||
- [ ] D18 同源断言绿(两侧 `confirmed_at` 逐一相等)
|
||||
- [ ] `pytest -q` **510 + 新增全绿**
|
||||
- [ ] `test_trade_gateway.py` 既有断言按 §12 处置完毕(**R16 应零改动通过**)
|
||||
- [x] 开工前:全量全绿基线已记录(实测 **697 passed / 3 skipped**,非计划写作时的 516 —— 计划 §8 的基线数字是撰写期快照)
|
||||
- [x] 正常赎回后再 convert,**批次与持仓一致、不超扣**(验收 9)
|
||||
- [x] **真实路径全覆盖**(R-c(2)):`test_share_lot.py` 自建种子 → `subscribe` 建批次(`qty = amount ÷ nav`,2 位 HALF_UP,`confirmed_at = T`)· `redeem` FIFO 扣减 · 无批次有持仓 → **D8 兜底补建后再扣** · `rebuild_lots.py` 入口
|
||||
- [x] 降级路径单测(R-c(1)):`subscribe` 无净值 → warning + 不建批次 + 不抛异常;`redeem` 无持仓无批次 → warning + 跳过扣减
|
||||
- [x] D18 同源断言绿(两侧 `confirmed_at` 逐一相等)
|
||||
- [x] `pytest -q` 全绿(**714 passed / 3 skipped** = 697 + 17)
|
||||
- [x] `test_trade_gateway.py` 既有断言按 §12 处置完毕(**R16 零改动通过**,实测)
|
||||
|
||||
**依赖**:T-3(排期置于 T-7 之后)
|
||||
|
||||
**执行记录(2026-09-10 · 已完成)**
|
||||
|
||||
*改码 4 处*
|
||||
|
||||
| 文件 | 动作 |
|
||||
| --- | --- |
|
||||
| `app/gateway/gateway_repository.py` | 【改】+模块级常量 `_SHARE_LOT_INSERT` / `_SHARE_LOT_DEDUCT`;+`insert_share_lots(lots)` / `deduct_share_lots(deductions)`(各单事务,走 `role="rw"`)。**不含任何分配逻辑** —— 只把已分配结果落库,避免出现第二份 FIFO 口径 |
|
||||
| `app/repository/share_lot_repository.py` | 【改】`__init__` 增可选 `core_ro` 入参(传则复用该实例,忽略 `engine`)—— 让 `trade_gateway` 能复用**同一份** FIFO 选批口径(D18 / 自检第 13 问),而非另写一份贪心 |
|
||||
| `app/gateway/trade_gateway.py` | 【改】+`SUBSCRIBE`/`REDEEM`/`SUBSCRIBE_LOT_PREFIX` 常量;+`_nav_as_of` / `_maintain_lots`;`submit_trade` 在 `insert_trade` 之后、引擎之前调用(整段 try 包住 → 绝不阻断交易) |
|
||||
| `scripts/core/rebuild_lots.py` | 【新增】按 `core_holding` 快照重建批次(L-7)。**与网关 D8 兜底同调 `lot_bootstrap.bootstrap_lots`**(D18);默认「仅补建无批次的持仓行」(幂等),`--force` 先删后建,`--dry-run` 只报告;写库复用 `GatewayRepository.insert_share_lots`(INSERT 语句全仓只一份);走 `role="admin"`(`--force` 需 DELETE,业务账号刻意无) |
|
||||
|
||||
*实施级裁定 2 条(开发计划未点明,执行期定并留痕)*
|
||||
|
||||
1. **`redeem` 的份额折算 = `amount ÷ T 日净值`(2 位 HALF_UP)**。计划 §8 只写「FIFO 扣减」,
|
||||
未说明扣减的**份额**从哪来 —— 而普通赎回请求体给的是**金额**、批次扣的是**份额**。
|
||||
故必须折算,与 `subscribe` 对称;**取不到净值 → 降级跳过**(不猜、不阻断)。
|
||||
批次只决定 FIFO 顺序与持有期,不参与定价。
|
||||
|
||||
> **⚠️ 2026-09-10 订正(联网查证 4 家管理人业务规则后)**:本条初稿写「按未知价法裁定」,
|
||||
> **定性错误**。行业铁律是「**金额申购、份额赎回**」—— 投资者以**份额**申报,登记机构按
|
||||
> T 日净值反算金额:`赎回金额 = 赎回份额 × T日净值 − 费用`。依据:睿远业务规则 §65
|
||||
> 「以份额方式提出赎回申请」/ 华泰保兴 §69 / 东方基金 §57「按实际确认的有效赎回份额计算
|
||||
> 赎回金额」/ 永赢招募书;货基同理(国投瑞银 §33)。**无一家公募支持「按金额赎回」**。
|
||||
> 因此 `amount ÷ nav` 系**本项目侧简化建模**(`TradeRequest.amount` 为申赎共用入参,
|
||||
> `app/api/simulate.py:60`),**不是行业做法** → 已记入 **PRD §10.1 已知差异第 ① 条**。
|
||||
> **派生风险**:T 日净值当日不可得(T+1 公告),`_nav_as_of` 实取 **T−1 净值** →
|
||||
> **折算份额只是估算值**,与最终确认份额有偏差(行业无此问题,因份额由投资者申报)。
|
||||
> **处置**:折算逻辑**保留**(批次扣份额必须折算),但定性改为「项目简化」,
|
||||
> `trade_gateway.py` 注释与本文档已同步订正。
|
||||
2. **`rebuild_lots.py` 默认只补建「无批次」的持仓行,`--force` 才先删后建**。
|
||||
计划只写「按 `core_holding` 重建批次(快照重建)」,未说是否覆盖已有批次。
|
||||
默认保守(幂等、不误删转换转入的真实批次),需要全量对齐时才用 `--force`。
|
||||
两者都调 `bootstrap_lots`,确定性 `lot_id` 天然防重复补建。
|
||||
|
||||
*测试与验证*
|
||||
|
||||
- `tests/test_share_lot.py` **+14 用例**(原 15 → 29):真实路径 5(申购建批次 / HALF_UP 舍入 /
|
||||
赎回 FIFO 跨批 / 不足额只扣可用 / D8 兜底补建后再扣)+ 降级 3(无净值 / 无批次无持仓 / 持仓为 0)+
|
||||
异常兜底 1 + `rebuild_lots` 3(默认补建 / `--force` / `--dry-run`)+ **D18 同源断言 1** +
|
||||
**机制断言 1**(`rebuild.bootstrap_lots is bootstrap_lots`,防「两侧碰巧算出同值」的假绿)
|
||||
- `tests/test_trade_gateway.py` **+3 用例**(原 29 → 32):申购建批次端到端 / 赎回扣减端到端 /
|
||||
**阻断路径不写批次**
|
||||
- `pytest -q` → **714 passed / 3 skipped**(基线 697 **+17**,**零回归**;§12 **R16 零改动通过**)
|
||||
- **新增 `scripts/dev/verify_convert_lots.py` 真库验证 → 20/20 一致(退出码 0)**:
|
||||
A 申购建批次(含 nav 4 位精度、`confirmed_at = T`)· B/C **赎回 FIFO 扣减真库生效 + 归零行保留 + 不超扣**
|
||||
· D D8 兜底补建 · E `rebuild_lots` 真库跑通(幂等 + 补建 + **D18 同源**);
|
||||
隔离前缀 `CUST-LOTT`/`PROD-LOTT*`,跑完**零残留**(已复核 5 张表计数全 0)。
|
||||
**★ 本脚本的核心价值**:确认 `(:q + 0.0)` 这一**为绕开 sqlite 坑而加的写法在 MySQL 同样生效** ——
|
||||
若 MySQL 上字符串绑定未被隐式转数值,扣减会静默失效(批次永不减少 → convert 超扣),**sqlite 侧看不出来**。
|
||||
- **突变验证(2 组,防假绿)**:① 切断 `submit_trade` 里的 `_maintain_lots` 接线 →
|
||||
**精准 2 条**(两条端到端用例)红;② 关掉 `redeem` 的 D8 兜底落库 → **精准 2 条**
|
||||
(`test_redeem_bootstraps_lot_from_holding_then_deducts` + `test_d18_gateway_bootstrap_and_rebuild_lots_agree`)红。
|
||||
两处均已恢复,`grep MUTATION-TEST` 无残留。
|
||||
- **回归复跑**:T-6 `verify_convert_apply.py` **24/24** · T-7 `verify_convert_service.py` **35/35**(零回归)
|
||||
|
||||
*未顺手改(不在本任务范围)*
|
||||
|
||||
- **`core_holding` 不随普通申赎更新** —— T-10 只维护 `core_share_lot`(计划范围即如此)。
|
||||
两者可能短暂失配,正是 `rebuild_lots.py` 的定位(快照重建)。是否让普通申赎也同步持仓,
|
||||
属新范围,留待用户决定。
|
||||
|
||||
---
|
||||
|
||||
## 9. 第 6 批 · 补偿脚本(T-12)
|
||||
|
||||
Reference in New Issue
Block a user