基金转换 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:
2026-09-10 18:28:48 +08:00
parent e80098b32f
commit 5e6fa06d03
12 changed files with 1383 additions and 22 deletions
+49
View File
@@ -430,3 +430,52 @@ skill `design-doc-selfcheck` → **v1.2.0**:新增 **「二补 · 格式契约
`.workbuddy/memory/MEMORY.md`(远程铁律条**重写**,含 7 条证据 + 绕过方案)·
`docs/memory/MEMORY.md`(交接清单**第 7 问重写** + 顺手订正验证条目里过期的「516 passed」→ **697** + 第 73 行旧表述订正)· 本条。
---
## T-10(普通申赎批次维护 FR-C16)完成(2026-09-10 · 用户「进入 T10 阶段」)
**开工前基线快照**(计划硬要求):`pytest -q` → **697 passed / 3 skipped**
(开发计划 §8 写的「当前 516」是撰写期快照,已过期 → 已在文档中订正)。
**改码 4 处**:`gateway_repository.py`(+`insert_share_lots`/`deduct_share_lots`,各单事务、走 `rw`,
**不含分配逻辑**)· `share_lot_repository.py`(`__init__` 增可选 `core_ro` → 复用**同一份** FIFO 口径)·
`trade_gateway.py`(+`_nav_as_of`/`_maintain_lots`,在落流水后、引擎前调用,整段 try 包住)·
**新增 `scripts/core/rebuild_lots.py`**(快照重建批次 L-7,与网关 D8 同调 `bootstrap_lots`,`role="admin"`)。
**两条执行期裁定(计划未点明,已留痕)**
1. **`redeem` 份额折算 = `amount ÷ 净值`(2 位 HALF_UP)** —— 计划只写「FIFO 扣减」,
未说扣减的**份额**从哪来(赎回请求体给的是金额)。取不到净值 → 降级跳过。
> **⚠️ 2026-09-10 18:10 订正(联网查证 4 家管理人业务规则后)**:本条初稿写「按未知价法」,
> **定性错误**。真实行业铁律 = 「**金额申购、份额赎回**」—— 投资者以**份额**申报,
> 登记机构按 T 日净值**算金额**(睿远 §65 / 华泰保兴 §69 / 东方基金 §57 / 国投瑞银(货基)§33,
> **无一家公募支持「按金额赎回」**)。本项目是**简化建模**(`TradeRequest.amount` 为申赎共用
> 入参,`app/api/simulate.py:60`),方向与行业相反 → 已记入 **PRD §10.1 已知差异 ①②**。
> 另发现:**T 日净值当日不可得**(T+1 公告),`_nav_as_of` 实取 **T−1 净值** →
> **折算份额只是估算值**,与最终确认份额有偏差。折算逻辑**保留**,定性已同步订正
> (`trade_gateway.py` 注释 / 开发计划 §8 / 交接文档 §B / PRD §10.1)。
2. **`rebuild_lots.py` 默认只补建无批次行**(幂等、不误删转换转入的真实批次),`--force` 才先删后建。
**R-c 两条落地**:R-c(1) 降级(整段 try,源数据缺失只 warning,**绝不阻断交易**)→
既有 `env`(无净值无持仓)自然走降级,**§12 R16 `test_redeem_accepted_without_alert` 零改动通过**;
R-c(2) 覆盖率补偿(真实路径自建完整种子,**不得让降级充当覆盖**)→ `test_share_lot.py` 新增 14 条。
**验证**:`pytest -q` → **714 passed / 3 skipped**(**+17**,零回归);
**新增 `scripts/dev/verify_convert_lots.py` 真库 20/20**(隔离 `CUST-LOTT`/`PROD-LOTT*`,跑完零残留);
T-6 `24/24` · T-7 `35/35` 复跑零回归;**突变验证 2 组**(切断接线 → 精准 2 条红;关 D8 兜底 → 精准 2 条红含 D18),
均已恢复、无残留。
**★ 真库脚本的核心价值**:扣减 SQL 的 `(:q + 0.0)` 是**为绕开 sqlite 的 TEXT 绑定坑**而加(§B.8 第 11 条);
它在 **MySQL 上是否同样生效,sqlite 侧无从证明** —— 若 MySQL 未隐式转数值,扣减会静默失效
(批次永不减少 → convert 超扣)。**这正是用户 2026-09-10 定的「涉及方言语义的任务必须上真库」的意义**。
**D18 双保险**:除「同一持仓两侧算出的批次逐字段相等」外,另加**机制断言**
(`rebuild.bootstrap_lots is bootstrap_lots`)—— 防「两侧各写一份但碰巧同值」的假绿。
**未顺手改(已上报)**:`core_holding` 不随普通申赎更新(T-10 只维护 `core_share_lot`,计划范围即如此)。
**文档回写**:开发计划 §8(DoD 全勾 + 执行记录 + 2 条裁定 + 基线订正)· `交接文档.md`(§0 导航 / §B **v2.1** /
B.1 状态 / B.6 拓扑与基线 / **新增 B.6.3**)· `docs/memory/{TODO,MEMORY}` · `.workbuddy/memory/2026-09-10.md` · 本条。
**下一步 = T-11**(`core_tools.query_recent_trades` 汇总走 `_amount_view` FR-C15 + 持仓过滤 `qty <= 0` +
`core_ro.sum_trades_on_date` 加 convert 去重 **R-d**;DoD 见开发计划 §7.3)。
+2 -2
View File
@@ -199,8 +199,8 @@ RBAC 联调账号:scripts/dev/rbac-seed-reference.md
2. 改动属于 api / service / tool / repository 哪一层?
3. 是否需 customer_id 归属与 JWT RBAC?
4. Core 是模拟库只读还是 agent 库读写?
5. 如何验证?(`python -m pytest` 全量(当前 **697 passed / 3 skipped**,基线 510)· uvicorn 启动 + /health · SQL / sync 脚本 · 对照 REQUIREMENTS 验收列)
6. **当前有哪两条并行线?**(① 风控/架构改进线:**已结项**(510 基线绿、§7.2 七项冒烟 7/7 PASS、`037ce7e` 已核实早已推送);② **基金转换线**:设计闭环,**第 5 步进行中 —— T-0~T-9 已完成(697 passed),下一步 = T-10 / T-11**)——动代码前先确认自己属于哪条线,别混淆前置条件。
5. 如何验证?(`python -m pytest` 全量(当前 **714 passed / 3 skipped**,基线 510)· uvicorn 启动 + /health · SQL / sync 脚本 · 对照 REQUIREMENTS 验收列)
6. **当前有哪两条并行线?**(① 风控/架构改进线:**已结项**(510 基线绿、§7.2 七项冒烟 7/7 PASS、`037ce7e` 已核实早已推送);② **基金转换线**:设计闭环,**第 5 步进行中 —— T-0~T-10 已完成(714 passed),下一步 = T-11**)——动代码前先确认自己属于哪条线,别混淆前置条件。
7. **远程分支到底还在不在?**(**在**。`git ls-remote --heads origin` 实测 `refs/heads/risk-control-agent` = `fffb78a`。⚠️ **判断远程存亡只能用 `git ls-remote`** —— 2026-09-10 **同日误报两次**,勿再踩。)
**本仓特有异常(2026-09-10 深挖确认,别误判为 stale ref)**:`.git/refs/remotes/**` **写入不落盘** ——
`git update-ref refs/remotes/origin/X <sha>` 返回 0,但松散引用消失,**且整个 `refs/remotes/origin/` 目录被删**
+2 -2
View File
@@ -7,7 +7,7 @@
**阶段一 AL-01~AL-08 与阶段二 C4~C6 均已完成(2026-09-07)**:全量 pytest **482 passed 0 failed 0 skipped**(真库集成)✓ · uvicorn 冒烟三端点 ✓ · 接口实调验收 ✓(2026-09-07:suitability/check 阻断+放行、simulate/trade 阻断、三条鉴权边界 401/403/403,契约零偏差)· risk-m1~m4 tag 齐。**合并 main 已移交合并执行人**(操作手册:《docs/项目框架设计/合并注意事项-风控模块并入main.md》,随分支上传),后续模块侧待办见下方。
**⚡ 并行新线 · 基金转换(convert)**(2026-09-10):**设计 + 开发计划均已闭环** —— PRD **v0.9.2** + 架构 **v1.0.1** + 独立评审 13 条 **0 悬空**(接受 10 / 修正性接受 3 / 驳回 0),门控 **M-7 已满足**;**第 4 步开发计划 v1.0 已产出并经独立审核**(4 条意见全接受、**驳回 0**,含新增 2 条回归面 R15/R16 + R-c 双条修订);**第 5 步进行中**:**T-0 ~ T-9 均已于 2026-09-10 完成**(**697 passed / 3 skipped**;T-1 断言 **8/8 PASS**、T-2 纯函数 **93 用例**、T-2b 实算 **15/15 一致**、T-6 真库 **24/24**、T-7 真库 **35/35**、**T-8 真库 31/31**、**T-9 真 MySQL 集成 8 条 + 4 组突变验证 + 展示位数修复**),**下一步 = T-10(普通申赎批次维护 · 回归风险最大)/ T-11(`core_tools` 与 `sum_trades_on_date` 汇总去重 · 依赖 T-8 已解锁)**。两个阻断前置(**T-0** sqlite/MySQL 列名统一 + 建库自校验 · **T-0b** DB 账号分离 D20:`xh_core_ro`/`xh_core_rw`/`xh_agent_rw`)**均已落地**。**入口:项目根 `交接文档.md` §B(三线合并版唯一入口,读这一节即可开工)**;**开工前必读开发计划 §1.4(15 条代码事实)+ §1.5(8 条实现级裁定 R-a~R-h)+ §12(16 条回归面)**。
**⚡ 并行新线 · 基金转换(convert)**(2026-09-10):**设计 + 开发计划均已闭环** —— PRD **v0.9.2** + 架构 **v1.0.1** + 独立评审 13 条 **0 悬空**(接受 10 / 修正性接受 3 / 驳回 0),门控 **M-7 已满足**;**第 4 步开发计划 v1.0 已产出并经独立审核**(4 条意见全接受、**驳回 0**,含新增 2 条回归面 R15/R16 + R-c 双条修订);**第 5 步进行中**:**T-0 ~ T-10 均已于 2026-09-10 完成**(**714 passed / 3 skipped**;T-1 断言 **8/8 PASS**、T-2 纯函数 **93 用例**、T-2b 实算 **15/15 一致**、T-6 真库 **24/24**、T-7 真库 **35/35**、**T-8 真库 31/31**、**T-9 真 MySQL 集成 8 条 + 4 组突变验证 + 展示位数修复**、**T-10 新增 17 用例 + 真库 20/20 + 2 组突变验证**),**下一步 = T-11(`core_tools` 与 `sum_trades_on_date` 汇总去重 · 依赖 T-8 已解锁)**。两个阻断前置(**T-0** sqlite/MySQL 列名统一 + 建库自校验 · **T-0b** DB 账号分离 D20:`xh_core_ro`/`xh_core_rw`/`xh_agent_rw`)**均已落地**。**入口:项目根 `交接文档.md` §B(三线合并版唯一入口,读这一节即可开工)**;**开工前必读开发计划 §1.4(15 条代码事实)+ §1.5(8 条实现级裁定 R-a~R-h)+ §12(16 条回归面)**。
### 基金转换线待办(推荐顺序)
@@ -21,7 +21,7 @@
- [ ] **【待用户裁定】幂等重放响应的数值位数偏差**(T-9 执行期发现,**未顺手改**):首次响应 2 位(`calc` 量化)vs 重放响应 4 位(`core_trade` `DECIMAL(18,4)` 直读)→ `"53456.95"` vs `"53456.9500"`,**数值相等**,违反 PRD「对外一律 2 位」展示契约,属 T-7 `_rebuild_quote` 范畴。集成测试已「比数值不比字符串」并钉住偏差
> **第 0~2 批结果(2026-09-10)**:基线 **510 passed** → 批 0 后 **516 passed / 3 skipped**(+3 T-0 用例 +3 T-0b 引擎用例)→ 批 1(T-1)后**仍 516 passed / 3 skipped**(只加表与种子,未加用例 → **零回归**);**T-1 数据层断言 8/8 PASS**(含新增断言 ⑧:费率档 ↔ `product_type` 匹配,越档即 FAIL)→ 批 2(T-2 + T-2b)后 **609 passed / 3 skipped**(**+93 纯函数用例**,零回归);T-2b 实算脚本 15/15 与 PRD §5.3 一致(退出码 0)。
> **下一步 = T-10**(普通申赎批次维护 · FR-C16 + D8 兜底补建 + `rebuild_lots.py`;**改 `trade_gateway` 主流程,回归风险最大 → 先跑基线再动**;DoD 见开发计划 §8)。**可并行**:T-11(`core_tools` / `sum_trades_on_date` 汇总去重 · 依赖 T-8 已解锁)。
> **下一步 = T-11**(`core_tools.query_recent_trades` 汇总走 `_amount_view` 去重 FR-C15 + 持仓查询过滤 `qty <= 0` + `core_ro.sum_trades_on_date` 加 convert 去重条件 **R-d**;DoD 见开发计划 §7.3)。基线 **714 passed / 3 skipped**。
> ⚠️ **基金转换的 T-0b 与下方「架构改进第 3/4 批」的 `core_ro` 只读账号是同一件事** —— 已由本线定案为 D20,**不再挂在架构改进线**(该线原「不要做」清单已更新)。