107 KiB
开发计划 · 基金转换交易(v2.0 · T+1 受理/确认分离模型)
文档状态:v2.0 整体重写 · ✅ 三轮独立审核闭环(第一轮 2 阻断 B1/B2 + 5 重要 + 3 可选;第二轮验证型 12 项零残留;第三轮白纸重审 C1
C5+O1O4 全修订 + 第三轮验证型复核 22/22 落位、D-1 残留已修复)· 已定稿(2026-09-11 用户批准)(批准后可进入第 5 步 todo 开发)代码基线:分支
risk-control-agent· pytest 基线 830 passed / 7 skipped(T-13 后;T-0~T-13 完成,v1.0convert_fund已退役)· push 由用户定上游依据:PRD-基金转换交易.md v1.1(§2 模型 / §5 接口 / §6 引擎 / §9 验收 31 条 / Q1
Q15 拍板)· 架构设计-基金转换交易.md v2.1(D1D30 · §15 任务映射 T-0~T-17)任务编号:沿用架构 v2.1 §15,不重编(T-0 ✅ / T-0b ✅ 已完成;T-1
T-17 按下表重排)。与旧 v1.0 计划的 T-1T-13 语义不同:v1.0 是两阶段实时模型的任务,v1.1/v2.1 已整体换成 T+1 受理/确认分离模型,旧计划作废。
审核记录
第一轮(独立子代理 · agent-eca13b6e · 2026-09-11)
| # | 审核意见 | 档位 | 本轮处理 |
|---|---|---|---|
| B1 | convert_cutoff_time 配置缺失,PRD §2.6.1「不得硬编码」,R-5 写死 15:00 |
阻断 | 接受:§1.3 裁定 10 + T-1 改法 8/DoD 2 + R-5/R-9 读 settings + F-17 补字段 + 风险 13 |
| B2 | get_nav_as_of 为 nav_date <= :d 回退语义(core_ro.py:509-512),R-8「无行→nav_pending」永不触发,静默降级违反 FR-C27 |
阻断 | 接受:F-18/F-24 纠偏 + R-8 改 get_nav_on(product_id, T) 精确匹配 + T-3 新增 get_nav_on(DoD 2)+ T-7 注禁回退 + 风险 14 |
| I1 | 回归面缺 test_share_lot / test_concentration_c4 / test_db / test_demo_scripts / test_locks_redis(实测 tests/ 18 文件含 convert) | 重要 | 接受:§5 补 R-回归 12~16;遗漏检查法注明已补 |
| I2 | R-9 与 PRD §5.5 撤单范围「冲突」 | 重要 | 修订性接受:复核后确认 PRD §5.5 :626 原文为「accepted / nav_pending → 200」,状态机 :178/:181 亦同——两处口径一致、矛盾不存在;第一轮误判「PRD 内部矛盾」系引述不实。§1.3 裁定 9 已订正为「PRD 原表述保留、实现按窗口语义收紧为 accepted」(第三轮发现,见下方「第三轮」记录) |
| I3 | T-8/T-16/T-17 与 T-7/T-6 同文件编辑未标强耦合 | 重要 | 接受:§0.3 并行组重构(P5/P6)+ 依赖列改 T-7/T-6 |
| I4 | 架构 §15「新增 5 表/8 列」陈旧(实测 3 表/4 列),计划正确 | 重要 | 接受:F-14/F-15 标注「架构口径陈旧,计划以实测为准」 |
| I5 | 事实表行号偏差(F-20 rebuild_lots 170 行、F-18 _nav_as_of 在 trade_gateway 不在 nav.py、F-11 engine def 在 250) |
重要 | 接受:F-11/F-18/F-20 已逐条纠偏 |
| O1 | 交叉引用失效(回归面引 F-25、事实表只到 F-23) | 可选 | 接受:F-24/F-25 已新增入表 |
| O2 | T-9 SUPPORTED_TRADE_TYPES 加 CONVERT 冗余/双分派 |
可选 | 接受:F-25 记录网关现状(:50 常量已存在但元组不含),T-9 改「维持 2 类型、走独立分派」+ DoD 相应改 |
| O3 | F-17 settings 枚举漏 convert_deadlock_retry_base_ms |
可选 | 接受:F-17 已补全字段清单 |
第二轮(验证型审核 · agent-add9837f · 2026-09-11)
验证型提问:逐条打开文件核实第一轮 10 条处置是否真的落到正文。
| 核实项 | 结论 | 证据 |
|---|---|---|
| B1 cutoff 配置 | 已落实,出现 ≥6 处语义一致,T-1/R-5/R-9 均读 settings,PRD §2.6.1 被引用 | :19/:122/:146/:168/:200/:357/:751/:778 |
| B2 get_nav_on 精确匹配 | 已落实,新增方法(非改既有)、DoD 含「仅 T−1 → None」、禁回退语义三处齐 | :273/:287/:388/:153 |
| I1 回归面 5 文件 | 已落实,R-回归 12~16 各 1 行且处置非空 | :687~691 |
| I2 撤单范围 | 已落实,裁定 9 + R-9 一致;第三轮 C1 订正为「矛盾不存在」——PRD §5.5 原表述(accepted/nav_pending)保留,实现按撤单窗口语义收紧为 accepted(nav_pending 分支时序不可达) |
:121/:168 |
| I3 同文件依赖 | 已落实,T-8→T-7、T-16→T-7、T-17→含 T-6,P1 剔除 T-8 | :54/:62/:63/:70 |
| I4 架构计数陈旧标注 | 已落实,F-14/F-15 明示以实测 3 表/4 列为准 | :143/:144 |
| I5 行号纠偏 | 已落实,F-11/F-18/F-20 均校正 | :140/:147/:149 |
| O1 交叉引用 | 已落实,事实表扩至 F-25,T-9/R-回归 1 引用可解析 | :154/:458/:469 |
| O2 SUPPORTED 不动 | 已落实,T-9 改法 + DoD 均「维持 2 类型」 | :458/:468 |
| O3 字段补全 | 已落实,F-17 含 convert_deadlock_retry_base_ms | :146 |
| 交叉一致性 | 审核记录与正文一致,头部标注「首轮已闭环、待第二轮验证」 | :3/:13/:30 |
结论:12 项逐条核实全部落实、零「声明已改/实际未改」残留 → 本版可作为第 5 步(todo 开发)依据,收敛。
✅ 原「PRD §5.5 待回填」遗留项已撤销(第三轮 C1 订正):PRD §5.5 :626 原表述(
accepted / nav_pending可撤)与状态机 :178/:181 口径一致、不存在矛盾,PRD 无需修正;实现按撤单窗口(T 日 15:00 前)收紧为accepted,nav_pending 分支时序不可达。可选项(非阻塞):若后续希望 PRD 文字精确化,可在第 5 步触及 PRD 时加一行「nav_pending 分支实际不可达」注解。
第三轮(白纸重审 · 全新上下文 agent-91bd8bdd · 2026-09-11 17:xx)
用户训示「是否只改一次就通过、有没有仔细审核」——第二轮为验证型(验第一轮改没改),缺一次不预设结论的全新重审。本轮补齐:全新上下文子代理,不看既有审核记录,独立通读计划 + 上游 + 代码。
| # | 审核意见 | 档位 | 本轮处理 |
|---|---|---|---|
| C1 | R-9 / 裁定 9 引述不实:PRD §5.5 :626 原文是「accepted / nav_pending → 200」,并非「仅 accepted 可撤」;「PRD 内部矛盾」是误判(I2 已连带订正,见上) | 重要 | 接受:§1.3 裁定 9 原文改写,标注「矛盾不存在、实现按窗口语义收紧」 |
| C2 | §6 验收表缺 FR-C30(资金流中转 D29,P0)对应行:§6 31 行无 T-16 / 资金流/中转 标注;FR-C29 亦无独立行 | 重要 | 接受:§6 增补 C30 行(映射 T-16)与 C29 行(映射 T-10);§9 自检锚点 7 补「FR-C29/C30 有独立行」 |
| C3 | 回归面遗漏 test_share_lot.py 的 redeem 用例:6 处 _maintain(trade_type="redeem", amount=...)(:441-550)会被 D26 打穿(redeem 入参改份额、移除 amount 反算),但 R-回归 12 只提 convert 用例 |
重要 | 接受:R-回归 12 增补「D26 后 6 处 redeem 用例改 qty 入参」;§9 锚点 3 补「redeem 用例」 |
| C4 | R-回归 6 引用了 0 命中文件:grep -c convert tests/test_risk_api.py = 0,R-回归 6 误列 test_risk_api.py(应只有 test_integration_risk.py) |
重要 | 接受:R-回归 6 删 test_risk_api.py,改「T-9 网关受理 202 后其 400 断言自然失效」留痕 |
| C5 | T-7 与 T-16 的 core_cash_flow 双重归属:T-7 改法⑥+DoD 已写「确认事务写 cash_flow 2 条(验收 T-16 前置)」,T-16 又写「确认事务补 2 条」→ 同文件同事务重复 | 重要 | 接受:T-7 改法⑥与 DoD 删 cash_flow,改「归 T-16」;T-16 改法①保留为确认事务唯一写入点 |
| O1 | F-11 行号偏差(engine _run 实为 :142,计划写 :150-153) |
可选 | 接受:F-11 行号改 engine.py:142 |
| O2 | §6 编号与 PRD FR-C1~C31 不一一对应(部分散落任务 DoD) | 可选 | 接受:§6 表头注明「PRD FR 编号见承载任务」 |
| O3 | T-13 性能/紧池 DoD 为「记录型」非硬门禁 | 可选 | 接受:DoD 补「记录值 + 是否达设计目标」双输出 |
| O4 | R-2 状态机允许 nav_pending→cancelled 与 R-9 禁撤不一致 | 可选 | 接受:R-2 状态机迁移表注明「nav_pending→cancelled 受 R-9 撤单窗口约束(T+1 已过窗口,实际不可达)」 |
第三轮结论:C1C5 + O1O4 全部修订闭环(判定表见上)→ 经第三轮白纸重审,本版达到「可作第 5 步依据」标准。
第三轮验证(验证型复核 · 全新上下文 agent-a673e184 · 2026-09-11 17:4x)
用户继续追问「确定这次是独立 AI 审核通过吗」——第三轮为「白纸重审」但修订动作由主代理自证,缺独立方逐条核实声明是否真的落位。本轮补齐验证型复核:全新子代理逐条打开文件核实。
| 判定项 | 结果 |
|---|---|
| C1 |
22/22 全部落实,无「声明已改/实际未改」、无「只改一半」 |
| 清单外残留 D-1 | 发现 1 处重要残留并已修复:T-9 节 :488/:509/:511 仍把 test_risk_api.py 列为 convert 改造目标,与 R-回归 6(C4)「0 引用无关」自相矛盾——已改为「test_risk_api 无 convert 断言、零改动(C4)」三处 |
| 悬空引用 | 已修:第一轮 I2 行「见 §13」→「见下方第三轮记录」(本文件无 §13) |
第三轮验证结论:22/22 声明落位 + D-1 修复,零「声明已改/实际未改」残留 → 独立验证确认本版达到「可作第 5 步依据」标准,收敛。
0. 结论速览
0.1 一句话
把 convert 从「实时两阶段(受理即扣份额)」整体重做为「T+1 受理/确认分离」:T 日受理只校验 + 落受理单(Core 库 core_convert_request 6 态权威状态机,202 回执),T+1 批处理串行确认(扣批次 + 两条流水 + 两端持仓 + 资金流中转),T+2 可赎回;撤单/确认/查询接口补齐;普通申赎同步对齐(redeem 份额申报 D26、批次全生命周期 FR-C16、T+2 校验 FR-C25)。
0.2 任务矩阵(架构 v2.1 §15 权威映射)
| 任务 | 内容 | 依赖 | 类型 | 关键风险 |
|---|---|---|---|---|
| T-0 ✅ | 列名统一 + 启动断言 | 无 | 已完成 | — |
| T-0b ✅ | DB 账号分离(D20) | 无 | 已完成 | — |
| T-1 | DDL + 种子:新增 core_convert_request / core_trade_calendar / core_share_rule 3 表;core_product 补 share_class / allow_ac_convert / rounding_mode / share_digits 4 列;新增 11-seed-share-rule.sql;10-seed-trade-calendar 入 reset |
T-0/T-0b | 新增 | DDL 方言(sqlite conftest 同步) |
| T-2 | calc.py 纯函数扩展:D27 产品舍入入口 / D28 partial 辅助 / D26 redeem_amount |
无 | 扩展 | 不动既有 round2 行为 |
| T-2b | calc_convert_demo.py 实算回填(随真实净值) |
T-2 | 示例 | 与验收 20 强绑定 |
| T-3 | core_ro 新方法 + share_lot_repository 扩展 + 新增 convert_request_repository |
T-1 | 新增 | 在途占用 LEFT JOIN 正确性 |
| T-4 | convert_repository 语义收窄(只做进度/审计,扫 core_convert_request) |
T-1 | 改写 | 状态机字段迁移 |
| T-5 | run_locked 复用(受理/确认/撤单统一锁原语) |
无 | 复用 | 锁键设计 |
| T-6 | accept_convert 受理事务(6 态状态机 + uk_idem 幂等 + 15:00 顺延 + 占用推导) |
T-1/T-3 | 核心新增 | 幂等窗口 / 事务边界 |
| T-7 | convert_service 三段编排 + 新增 confirm_service(T+1 批处理) |
T-2~T-6 | 核心重写 | 串行确认 / nav_pending / partial |
| T-8 | rules.amount_view + engine.process_convert_event 时机改(确认事务提交后跑一次) |
T-7(同文件,须 T-7 先合入) | 时机改 | 三处消费方同步 |
| T-9 | api 层:simulate.py redeem 份额申报 + 撤单/确认/查询接口 + 网关分派 |
T-7 | 新增 | API 契约变更 |
| T-10 | 普通申赎批次维护(redeem 份额申报 + 扣批次哨兵)+ T+2 校验 + rebuild_lots |
T-3 | 扩展 | 打穿 test_trade_gateway |
| T-11 | core_tools 汇总去重 + 持仓过滤 |
T-8 | 沿用 | — |
| T-12 | 补偿脚本:cleanup_pending_convert 改写扫 core_convert_request |
T-4/T-7 | 改写 | 扫描对象迁移 |
| T-13 | 全量回归 + 集成 + 并发 + 性能补录 | 全部 | 收尾 | 基线重估 |
| T-14 | D27:core_share_rule seed + calc 真正接入产品舍入 |
T-1/T-2 | 新增 | 全局 round2 行为切换 |
| T-15 | D28:部分成交专项测试 + 边界补强 | T-7 | 新增 | 验收 30 覆盖 |
| T-16 | D29:确认事务写 core_cash_flow(注册登记账户中转建模) |
T-7(改确认事务,须 T-7 先合入) | 新增 | 流水语义 |
| T-17 | D30:share_class + allow_ac_convert,同基金 A/C 互转 |
T-1/T-6(受理校验前置)/T-9 | 新增 | 校验放行规则 |
0.3 关键路径与并行组
关键路径:T-1 → T-3 → T-6 → T-7 → T-9 → T-10 → T-13(8 步,T-7 为最大单点;T-7 之后并联 T-8)
并行组(强耦合约束:T-8 改 convert_service.py 引擎调用点、T-16 改确认事务、T-17 改受理校验,三者分别与 T-7 / T-7 / T-6 同文件 → 同文件任务必须串行合入,先核心后扩展):
- P1(并行开工):
{T-1, T-2, T-5}—— T-1 不必等 T-2(calc 纯函数不查库) - P2:
{T-2b, T-3, T-4}(依赖 T-1/T-2) - P3:
{T-6}(依赖 T-1/T-3/T-5) - P4:
{T-7}(依赖 T-2~T-6) - P5(T-7 合入后并行):
{T-8, T-9, T-10}(各自与 T-7 衔接;T-8 依赖 T-7 已合) - P6(后置):
{T-16(←T-7), T-17(←T-6/T-9)}
0.4 硬门禁
- 每任务 DoD 可机械验证(有明确命令/断言),禁止「确认正确」类表述
- pytest 全绿:基线 739 只减不增原则——新增可以加用例,既有用例除非被 v2.1 口径推翻(见 §5 回归面),否则不许删
- 高风险任务(T-6/T-7/T-9/T-10):先跑基线快照 → 改造 → 全量回归
- 真库验证一律走
scripts/dev/verify_convert_*.py模式(隔离前缀、清理、DoD 断言、退出码 1=失败) - 方言红线:sqlite 主回归保留;core_convert_request 表在 sqlite conftest DDL 与 MySQL 01-ddl.sql 双份同步(T-1 DoD 含一致性断言)
1. 背景与约束
1.1 上游依据与决策链
- PRD v1.1 定模型:§2.1
2.6(受理/确认/净值/占用/撤单/部分成交)· Q1Q15 拍板(Q11 交易日历、Q13 T+1 适当性复核、Q14 SLA=2 交易日、Q15 redeem 份额申报) - 架构 v2.1 定结构:目录划分 §2 · 状态机 §4(6 态)· 时序 §5 · 事务/并发 §7 · D1~D30 · 任务映射 §15
- 「下游同步令」:本计划落实 架构 v2.1 §15 的全部 T-1~T-17;旧 v1.0 计划按 v0.x 模型整体作废(两阶段实时模型)
- 已落地复用资产:D20 账号分离(T-0b)、列名统一+启动断言(T-0)、纯函数包(calc/fee/nav/lot_bootstrap)、金额去重口径(amount_view 三处同步)、死锁重试外壳(43cf2a1)
1.2 硬约束(红线)
| # | 约束 | 出处 |
|---|---|---|
| 1 | convert 流水 trade_type 仍是 redeem/subscribe(R-b),不写 convert ENUM;core_trade.trade_type 无 convert 值 |
红线 · 01-ddl.sql:161 |
| 2 | 金额聚合去重口径只此一处:rules.amount_view + core_ro.sum_trades_on_date SQL 等价条件,改口径三处同步 |
红线 · rules.py:94-138 |
| 3 | 展示位数经 convert_service._q 单点量化;禁裸 str(Decimal) 出网;Decimal 恒显式 ROUND_HALF_UP |
红线 |
| 4 | 批次补建规则唯一副本 lot_bootstrap.lot_bootstrap_lots;rebuild_lots.py 与 trade_gateway 同调,禁另写 |
红线 · rebuild_lots.py:10-14 |
| 5 | conftest 四处 engine 须 role="admin"(R-e);DB 账号 xh_core_ro 读 / xh_core_rw 仅 4 表写 / xh_agent_rw 仅 audit INSERT |
红线 R-e · D20 |
| 6 | 验证一律上真 MySQL 8.0.46:ENUM 严校验 / JSON+LIKE / DATETIME(3) / DECIMAL / gap lock 真库才可见 | 红线 |
| 7 | 仅本人或 risk_demo 可交易、代理人只查(查询 scope 与交易 owner 两道闸门不可合并) | 红线 |
| 8 | 规则引擎跑在确认事务提交后(FR-C28),失败落 engine_error 审计不阻断交易(D17) |
PRD FR-C28 |
1.3 文档口径差异裁定(PRD vs 架构,逐条定「以哪份为准 + 理由」)
| # | 差异点 | PRD v1.1 | 架构 v2.1 | 裁定 | 理由 |
|---|---|---|---|---|---|
| 1 | 受理响应 | 受理只回 202 + 受理单号(Q2:不返回折算金额) | 同 | 以 PRD 为准 | 目标一致,无冲突 |
| 2 | 确认触发 | §5.7 运维/批处理(可指定业务日) | §15 T-7 含 confirm_service 批处理 | 以架构为准:批处理入口 confirm_convert_daily.py --as-of |
架构给了任务归属 |
| 3 | SLA | Q14:2 个交易日(convert_confirm_sla_days) |
§15 T-12 扫 core_convert_request | 以 PRD 为准:settings 新增 convert_confirm_sla_days=2(交易日),弃用 convert_confirm_offset_days 自然日偏移 |
用户拍板 Q14 |
| 4 | 部分成交 | §2.6.5 D28,投资者层 | §15 T-15 依赖 T-7 | 以架构为准:实现对在 T-7,专测在 T-15 | 任务切分 |
| 5 | 资金流中转 | §2.7 D29 core_cash_flow | §15 T-16 依赖 T-6 | 以架构为准:T-16 落在确认事务内 | 同上 |
| 6 | 净值挂起 | FR-C27 nav_pending 不降级 | 同 | 以 PRD 为准:删除 _nav_as_of 取 T−1 旧行为 |
口径一致但实现必改 |
| 7 | 占用实现 | FR-C21 不新增冻结列,由受理单推导 | 同 | 以 PRD 为准:SQL LEFT JOIN 未终态受理单聚合 | 无冲突 |
| 8 | 引擎时机 | FR-C28 确认后跑 | §15 T-8 | 以 PRD 为准:T-8 改时机 | 无冲突 |
| 9 | 撤单范围 | 状态机表「cancelled ↔ accepted / nav_pending」(PRD :178/:181)与 §5.5 撤单接口(PRD :626:「T 日 15:00 前且状态 accepted / nav_pending → 200 置 cancelled」)口径一致——两者都允许 nav_pending 撤单。但 nav_pending 只在 T+1 确认时缺净值才出现,彼时 T 日 15:00 撤单窗口已过 |
架构任务 T-9(对接 R-9) | 裁定:仅 accepted 可撤(R-9);nav_pending 出现于 T+1,撤单窗口(T 日 15:00 前)早已关闭,PRD §5.5 所列条件在业务时序上不可达,保留 PRD 原表述但实现按 R-9 收紧为 accepted |
功能结果与「T 日 15:00 前」语义一致;第一轮审核曾误判「PRD 内部矛盾」(本行已订正:矛盾不存在,仅实现侧按窗口语义收紧) |
| 10 | 15:00 截点 | PRD §2.6.1「convert_cutoff_time 为配置项(默认 15:00),不得硬编码」 |
架构任务 R-5(R-5 初稿写死 before_cutoff(dt, 15:00)) |
以 PRD 为准:T-1 settings 新增 convert_cutoff_time="15:00";TradingCalendar.before_cutoff 读 settings,禁硬编码 |
审核 B1 |
| 11 | T-9 三个接口路径 | §5.5 撤单 POST /api/simulate/trade/convert/{convert_group_id}/cancel;§5.7 确认 POST /api/admin/convert/confirm?accept_date=…、查询 GET /api/simulate/trade/convert/{convert_group_id} |
架构 §15 T-9 未给路径;本计划 T-9 初稿自拟 POST /api/convert/{gid}/cancel / POST /api/convert/confirm / GET /api/convert/{gid} |
以 PRD 为准(2026-09-11 用户拍板):T-9 按 PRD 三条路径实施,本计划初稿的自拟路径作废 | PRD 是对外需求契约;其路径延续 /api/simulate/trade/... 与 /api/admin/... 既有风格,而初稿的 /api/convert/... 是新的顶格前缀、与全仓路由风格(/api/risk·/api/simulate·/api/admin·/api/chat)不一致。根因留痕(审查为何未发现):计划的三轮独立审查均未发现本差异 —— §9 审核清单 9 条只对「架构 §15 / 代码事实行号 / tests 回归面 / PRD §9 验收条目」四类,无一条对「对外契约逐字」;且 §1.4 代码事实核对对的是现有代码、§1.3 差异裁定对的是「PRD vs 架构」,都不覆盖「PRD vs 本计划自身」。审查实际做的是「计划有没有覆盖 PRD 的要求」,而非「计划有没有擅自改 PRD 的承诺」—— 接口路径属后者,落在盲区。已补 §9 第 10 条 + 本表第 11/12 条 |
| 12 | 确认接口参数 accept_date 的名与义 |
§5.7 代码块注释写「按受理日批量确认」、参数名 accept_date;但同节表格口径写「可指定业务日」—— PRD 内部字面冲突 |
R-1:confirm_batch(as_of) 的 as_of 是业务确认日(T+1),捞单窗口 = [as_of 上推 sla_days 个交易日, as_of] 的受理日 |
裁定(2026-09-11):参数名保留 PRD 的 accept_date(对外契约字面不动),语义取同节表格口径「业务日」,直接映射 confirm_batch(as_of=accept_date);接口响应回显实际生效业务日 |
PRD 表格口径与 R-1 实现一致(业务日);若按参数名取「受理日」语义,需给 confirm_batch 增加「精确单日受理」能力 —— 会改动 T-7 已验收的捞单窗口逻辑,超出 T-9 范围,且无业务必要(窗口上推 SLA 本就是为消化净值晚公告造成的积压)。遗留(已在接口 docstring 标注):窗口是「上推 SLA」的批量语义,非「精确某受理日」 |
1.4 开工前代码事实核对表(§2 产物 · 全部带 文件:行号)
每条为「现状事实 + 对 v2.1 的意义」。计数以实测为准(grep 输出见各条注释)。
| # | 事实(现状代码) | 文件:行号 | 对 v2.1 的意义 |
|---|---|---|---|
| F-1 | _maintain_lots 按 amount 反算 qty(qty = round2(amount / nav))维护普通申赎批次 |
trade_gateway.py:161, 182-183, 220, 248, 285 | v1.0 遗留与 D26 冲突:T-9/T-10 改 redeem 份额申报 |
| F-2 | _maintain_lots 中 redeem 走 qty = round2(amount / nav) 反算后批量扣减;deduct_share_lots 无 remain_qty>=:q 哨兵 |
trade_gateway.py:248, 285 · gateway_repository.py | 普通赎回超扣风险 → T-10 加哨兵(对齐 convert 严格校验) |
| F-3 | CONFIRM_BASIS="natural_day_approx";in_confirmed_at = now + timedelta(days=settings.convert_confirm_offset_days)(自然日偏移=1) |
convert_service.py · settings.py:99 | 违反 Q11(交易日历)→ T-7 换 core_trade_calendar 推导 |
| F-4 | 幂等锚点在 agent 库 risk_convert_detail(status pending/completed/failed/expired),非 Core 库 |
convert_repository.py | 违反 D21(受理单下沉 Core)→ T-3/T-4/T-6 迁移 |
| F-5 | convert 请求模型:from_product_id+to_product_id+qty;未抢到执行权 → 202 + {convert_group_id, status} |
simulate.py:44-65, 72-87, 99, 130-131 | T-9 保留 202 语义但改为「受理成功」+ 受理单号 |
| F-6 | app/api/ 现有 8 文件,无 convert 独立 API(实测 ls:admin/audit_middleware/auth_adapter/chat/deps/knowledge/risk/simulate) |
app/api/ 目录 | 撤单/确认/查询接口全新(FR-C22/C23)→ T-9 |
| F-7 | app/service/convert/ 现有 7 文件,无 confirm_service(实测 ls:calc/convert_service/errors/fee/lot_bootstrap/nav/types) |
app/service/convert/ | T+1 批处理全新 → T-7 |
| F-8 | calc.py:round2/lot_amount/lot_fee/convert_amount/in_qty/rounding_diff/diff_fee(B/A)/hold_days/plan_lots/ensure_batch_limit;全显式 ROUND_HALF_UP;plan_lots 零剩余不触发(R-h) |
calc.py | T-2 扩展:D27 产品舍入 / D28 partial / D26 redeem_amount;既有 round2 行为不动 |
| F-9 | core_ro.py convert 读方法齐全:get_nav_as_of/get_redeem_fee_rules/list_share_lots/sum_remain_qty/get_holding/has_convert_trades/list_convert_trades/list_convert_lot_details;sum_trades_on_date 已含 convert 去重(R-d);list_holdings 过滤 qty>0 |
core_ro.py | T-3 新增:core_convert_request 读写 + core_trade_calendar 查询 + 在途占用量 |
| F-10 | rules.amount_view 三处消费方:①run_rules RISK-002/005 ②core_tools.query_recent_trades ③core_ro.sum_trades_on_date(SQL 等价条件 (convert_group_id IS NULL OR ='' OR trade_type='redeem'),rules.py:116-117);_eligible 只放行 confirmed 的 subscribe/redeem(rules.py:81-86) |
rules.py:94-138 | T-8 只改「引擎时机」;去重口径原样不动(R-b 两流水仍是 redeem/subscribe,不受 _eligible 拦截) |
| F-11 | process_convert_event(阶段 1.5 入口):定义于 engine.py:250,转出端主流水、related_trades=[转出,转入]、on_error_hook 预留、异常不阻断(D17);_run 共用实现于 engine.py:142 |
engine.py:142, 250-281 | T-8 把调用点从「受理后」移到「确认事务提交后」(FR-C28) |
| F-12 | core_tools.py 注释:convert 转出全部份额时批次归零、holding.qty 记 0 |
core_tools.py:82 | T-11 沿用;注意「归零 + 持仓更新」口径与 T-7 一致 |
| F-13 | convert_repository.py 用 get_engine(settings.mysql_database, "rw");状态 pending/completed/failed/expired |
convert_repository.py | T-4 语义收窄:进度/审计表不动,状态机权威迁 Core |
| F-14 | 01-ddl.sql 现有 15 表(实测 CREATE TABLE 清单:core_risk_grade/industry/suitability_rule/staff/customer/customer_risk/customer_advisor/product/holding/trade/cash_flow/product_nav/fee_rule/share_lot/convert_lot_detail);缺 core_convert_request / core_trade_calendar / core_share_rule 3 表;core_cash_flow 已建(:181-195)但无 convert 中转数据。架构 §15 仍写「新增 5 表 + 2 种子」(陈旧口径,计划以实测 3 表为准) |
01-ddl.sql:5-258 | T-1 新增 3 表 + T-16 复用 core_cash_flow |
| F-15 | core_product convert 扩展 8 列已建(can_subscribe/can_redeem/min_hold_qty/min_redeem_qty/min_hold_action/subscribe_fee_rate/fund_company/ta_code),缺 share_class/allow_ac_convert/rounding_mode/share_digits 4 列。架构 §15 写「8 列」(陈旧,计划以实测 4 列为准) |
01-ddl.sql:124-132 | T-1 补 4 列(rounding_mode/share_digits 走 T-14;share_class/allow_ac_convert 走 T-17) |
| F-16 | 10-seed-trade-calendar.sql 已存在:503 交易日(2026-01-01~2027-12-31,依上交所公告),文件头注释「表建好前不要加入 reset 清单」;2027 休市未公告 |
scripts/core/10-seed-trade-calendar.sql | T-1 把表落 DDL + 加入 reset;2027 风险记 §7 |
| F-17 | settings convert 配置齐全(convert_nav_stale_days=3/convert_lock_ttl_seconds=30/convert_batch_max_lots=200/convert_diff_fee_mode="amount_diff"/convert_confirm_offset_days=1/convert_compensate_sla_hours=24/convert_deadlock_retry_max=2/convert_deadlock_retry_base_ms=50);缺 convert_confirm_sla_days 与 convert_cutoff_time | settings.py:91-107 | T-1 加 convert_confirm_sla_days=2(交易日)+ convert_cutoff_time="15:00"(B1);T-7 弃用自然日偏移 |
| F-18 | nav.py 净值取数:注释明言「本模块不查库」,core_ro.get_nav_as_of 实为 nav_date <= :d ORDER BY nav_date DESC LIMIT 1(即缺 T 日净值会回退到 T−1);convert_service.py:263 调 core.get_nav_as_of(to_pid, trade_date) |
core_ro.py:500-514 · convert_service.py:263 | 违反 FR-C3/C27(B2):确认时须严格取 nav_date == T;缺则 nav_pending,回退旧净值行为全部移除 |
| F-19 | cleanup_pending_convert.py 扫 agent 库 risk_convert_detail 超 SLA → expired(标记不硬删 S2);文件 103 行 |
cleanup_pending_convert.py:1-103 | T-12 改写扫描对象为 core_convert_request(accepted/nav_pending 超 2 交易日 → expired) |
| F-20 | rebuild_lots.py 按 core_holding 快照重建(D18 单一副本 lot_bootstrap);文件 170 行 |
rebuild_lots.py:1-170 | T-10 扩展后仍可用;不动补建规则 |
| F-21 | scripts/dev/ 现有 verify_convert_*.py ×7 + calc_convert_demo.py;scripts/core/ 现有 06-seed-nav.sql(60 个真实净值日)/ 10-seed-trade-calendar.sql |
scripts/dev/ · scripts/core/ | T-13 全量回归重估基线;T-2b 示例随真实净值重算 |
| F-22 | convert 测试面:tests/ 下 test_convert_{calc,concurrency,core,engine,integration,repository,service}.py ×7 + test_core_ro_sum / test_core_tools / test_trade_gateway / test_risk_* |
tests/ | 回归面清单见 §5;T-13 汇总 |
| F-23 | conftest 四处 engine 已 role="admin"(R-e 已落地):conftest.py:185,186,236,237;sqlite 建表由 create_sqlite_engine 统一(R-g) |
tests/conftest.py | T-1 新表必须双份同步(R-g 要求:落 create_sqlite_engine 的统一 DDL) |
| F-24 | get_nav_as_of 回退语义是 B2 的根源:nav_date <= :d LIMIT 1 恒有旧净值,nav_pending 永不触发 → T-3 须新增 get_nav_on(product_id, trade_date)(nav_date == :d 精确匹配)或改造现有方法加 strict 参数 |
core_ro.py:509-512 | T-3 DoD 2 守护;R-8 改用它 |
| F-25 | 网关分派现状:CONVERT="convert" 常量已在 trade_gateway.py:50,但 SUPPORTED_TRADE_TYPES=(SUBSCRIBE,REDEEM) 不含它(:53),convert 走 _submit_convert 独立分支(:105)+ submit_trade 的 if trade_type == CONVERT 分派。T-9 不应把 CONVERT 加入 SUPPORTED_TRADE_TYPES(那会改变「网关直写 core_trade」的申赎路径语义),保持独立分支即可 |
trade_gateway.py:50-53, 105 | O2 采纳:T-9 改法改为「保留独立分支、SUPPORTED_TRADE_TYPES 不动」,DoD 相应改 |
1.5 实现级裁定(架构留白 → 实现细节 · 每条:问题/裁定/理由/被驳回的替代方案)
| # | 问题(架构留白) | 裁定 | 理由 | 被驳回的替代方案 |
|---|---|---|---|---|
| R-1 | T+1 确认批处理怎么触发 | scripts/core/confirm_convert_daily.py --as-of <业务日>:调 convert_service.confirm_batch(as_of);内部按受理日分组串行处理(组内循环调用 confirm_one),Redis 锁键 convert:confirm:{as_of} 防双跑 |
PRD §5.7 运维/批处理,可指定业务日;串行满足 FR-C23;锁键复用 run_locked | 定时器常驻进程(过度;本地模拟环境以脚本触发为准) |
| R-2 | 受理单状态机迁移守卫 | 六态 accepted / nav_pending / confirmed / rejected / cancelled / expired 各自只允许合法迁移(accepted→{confirmed,rejected,cancelled,expired,nav_pending};nav_pending→{confirmed,rejected,cancelled,expired}——其中 nav_pending→cancelled 仅理论上存在,受 R-9 撤单窗口约束(nav_pending 只在 T+1 出现、窗口早已关闭,实际不可达,保留迁移权以防误封;终态不可再迁),core_convert_request 加 status + updated_at,迁移用条件 UPDATE WHERE status=:expect 守卫 |
防并发撤单/确认双写(FR-C18 同哲学);条件 UPDATE 天然幂等 | 应用层 if 判断(有竞态窗口) |
| R-3 | 在途占用(FR-C21)如何实现「不新增冻结列」 | 可用份额视图:剩余份额 = core_share_lot.remain_qty − Σ(core_convert_request.qty WHERE customer+product+status IN ('accepted','nav_pending'));撤单/rejected/expired 自动释放(终态不再计入);查询入口放 share_lot_repository.available_qty,供受理校验与 T-10 普通赎回共用 |
PRD FR-C21 明言不新增冻结列;由受理单推导 | 冻结列(PRD 否决) |
| R-4 | T+2 可赎回(FR-C25)「不加列」怎么实现 | 转入批次 confirmed_at = 确认日(T+1);可用判断:core_trade_calendar 上前移一个交易日 → trading_calendar.previous_biz_date(业务日) 对比 confirmed_at(即 T+2 起可用)。普通赎回/再转换的可扣批次查询加此过滤 |
不加列、按日历推导,语义精确 | 加 available_from 列(违反 FR-C25「不加列」) |
| R-5 | 交易日历接入点 | 新纯函数包 app/service/convert/trading_calendar.py:is_biz_day / next_biz_day / previous_biz_day / before_cutoff(dt, cutoff);数据源 core_ro 读 core_trade_calendar;受理入口(accept_convert)判定受理日(非交易日/超截点 → 下一交易日 FR-C24)+ 确认入口(confirm 取受理日 T 净值);截点值取自 settings.convert_cutoff_time(默认 "15:00",禁硬编码,PRD §2.6.1);15:00 判定仅对「当日提交」生效,批处理回放历史不受限 |
Q11 拍板按交易日历真模拟;B1 | 自然日近似(v1.0 已用,违反 Q11,弃) |
| R-6 | redeem 份额申报(D26)普通赎回侧怎么改 | trade_gateway._maintain_lots redeem 分支:入参改 qty;赎回金额 = qty × T 日净值 − 赎回费(复用 fee.py 分档);扣批次加 remain_qty>=:q 哨兵 UPDATE,扣不足抛错回滚 |
PRD FR-C29 + 防超扣(F-2) | 保留 amount 反算(违反 Q15 拍板,弃) |
| R-7 | 引擎时机(FR-C28)具体改哪 | convert_service 确认段:confirm_one 事务 commit 之后调 engine.process_convert_event(out_trade, in_trade);异常落 engine_error 审计继续返回(D17 沿用);受理段不再触引擎(删 v1.0 阶段 1.5 调用) |
FR-C28 明言;引擎只跑一次且在所有权益变更之后 | 受理时跑(v1.0,违反 FR-C28) |
| R-8 | 缺 T 日净值(FR-C27) | 确认段第 1 步取 T 日 净值:新增 core_ro.get_nav_on(product_id, T)(nav_date == T 精确匹配,F-24);无行 → 状态置 nav_pending 返回(不 reject、不降级取旧净值);补数(fetch_nav 重跑或补插 nav 行)后重跑 confirm_one → 继续确认。既有 get_nav_as_of(nav_date <= T 回退)确认路径禁用于净值判定(B2:回退会让 nav_pending 永不触发) |
PRD FR-C27 明言不降级;B2 实测 core_ro.py:509-512 回退语义 | 取最近净值降级(v1.0 _nav_as_of 行为,违反 C27,全删) |
| R-9 | 撤单窗口判定(FR-C22) | 仅 status='accepted' 且业务日 = 受理日且提交时刻 ≤ 截点(settings.convert_cutoff_time,B1)可撤;否则 409 CANCEL_NOT_ALLOWED;撤单事务:条件 UPDATE accepted→cancelled + 占用自然释放(R-3 推导) |
PRD §5.5;nav_pending 在 T+1 才出现,撤单窗口 T 日 15:00 前已过,不可撤(§1.3 裁定 9) | 允许 nav_pending 撤(业务上窗口已过,驳回) |
| R-10 | 部分成交(D28)怎么落库 | core_convert_request 加 actual_qty DECIMAL(18,4) NULL;确认时 available = min(申请 qty, 当前可用);actual_qty < qty → 落 actual_qty、状态仍 confirmed、remark='partial';未确认部分占用随终态自动释放 |
状态机不加 partial 态(6 态权威);记 actual_qty 差异,响应/查询可展示 | 加 partial 第七态(复杂化,驳回;PRD 六态) |
| R-11 | D29 资金流中转怎么建模 | 确认事务内写 2 条 core_cash_flow:转出产品 flow_subtype='redeem'(out)+ 转入产品 flow_subtype='subscribe'(in),remark 带 convert_group_id;channel/金额对齐两条 core_trade;幂等:与流水同事务天然保证 |
D29 注册登记账户中转 = 转出方出账 + 转入方入账;同事务防半写 | 单条汇总(丢失中转语义,驳回) |
| R-12 | D30 A/C 互转校验规则 | 放行条件:两端 fund_company 相同 + ta_code 相同 + allow_ac_convert=1(任一为 1 即两向放行)+ 产品不同但 share_class 不同;不满足 → 400 CROSS_ENTITY_NOT_SUPPORTED 语义沿用(换提示) |
A/C 互转 = 同管理人同 TA 不同份额类别;D30 定义 | 放宽同产品(违反转换定义,驳回) |
| R-13 | D27 产品舍入接入点 | core_share_rule(product_id, business_type) 给 share_digits(默认 2);calc.round2 不动,新增 product_round(value, product_id):查规则取位数 → quantize;接入点 = in_qty(转入份额)与普通申赎 qty 落库处;T-14 才切换(T-2 只建入口不接) |
避免 T-2 动全局行为导致回归爆炸;T-14 一次性切换 + 全量回归 | T-2 直接接管 round2(回归爆炸,驳回) |
| R-14 | 补偿脚本改写后「重跑确认」语义 | cleanup_pending_convert.py 改为扫 core_convert_request:accepted/nav_pending 且受理日 < 当前业务日 − 2 个交易日 → expired(标记不硬删,S2 沿用);rebuild_alerts.py 继续仅凭 Core 侧数据(含 core_convert_lot_detail.nav/nav_date)补 agent 进度/审计 |
Q14 拍板 SLA=2 交易日;验收 17 | 沿用 SLA 24h 自然小时(Q14 推翻,弃) |
| R-15 | convert_confirm_offset_days 处置 |
废弃(保留字段+COMMENT 标记 deprecated,不删防破坏既有 env);T-7 全程改用交易日历;同法处置新增 convert_cutoff_time(默认 "15:00")与 convert_confirm_sla_days=2 |
自然日近似作废(Q11);cutoff 硬化(B1);不删字段保测试环境兼容 | 直接删除(破坏既有 env 引用,驳回) |
2~N. 分批次任务
每个任务:目标 / 涉及文件 / 改法(逐条)/ 依赖 / DoD(可机械勾选)/ 风险 / 回滚点。 约定:所有新增 SQL 先写 MySQL 01-ddl.sql,同时按 R-g 同步到 sqlite 统一建表源(
tests/conftest.py或create_sqlite_engine),两条 DDL 用一致性断言守护。
T-1 · DDL + 种子(3 新表 + 4 新列 + 2 种子)
目标:Core 库具备 T+1 模型的全部表结构。
涉及文件:scripts/core/01-ddl.sql(追加)· scripts/core/11-seed-share-rule.sql(新)· scripts/core/reset.ps1(加入 10/11 号 seed)· sqlite 建表源(R-g)。
改法:
- 01-ddl.sql 追加
core_convert_request:- 列:
convert_group_id VARCHAR(64) PK、client_request_id VARCHAR(64) NULL、customer_id、from_product_id、to_product_id、qty DECIMAL(18,4)、actual_qty DECIMAL(18,4) NULL(R-10)、status ENUM('accepted','nav_pending','confirmed','rejected','cancelled','expired') NOT NULL、requested_at DATETIME(3)、confirmed_at DATETIME(3) NULL、coupon/remark等 - 索引:
UNIQUE KEY uk_idem (client_request_id)(D21 幂等锚点;NULL 可多行)·KEY idx_customer_status (customer_id, status)·KEY idx_status_asof (status, requested_at) - FK:customer / from_product / to_product
- 列:
- 01-ddl.sql 追加
core_trade_calendar(与 10-seed-trade-calendar.sql 头注释给出的 DDL 逐字一致):cal_date DATE PK、is_open TINYINT(1)、remark VARCHAR(64) NULL - 01-ddl.sql 追加
core_share_rule:id PK、product_id、business_type ENUM('subscribe','redeem','convert')、share_digits TINYINT UNSIGNED DEFAULT 2、rounding_mode VARCHAR(16) DEFAULT 'half_up'、KEY uk_product_biz (product_id, business_type)、FK product(R-13) core_productALTER 补 4 列:share_class VARCHAR(16) NULL、allow_ac_convert TINYINT(1) DEFAULT 0(T-17 用)、rounding_mode VARCHAR(16) DEFAULT 'half_up'、share_digits TINYINT UNSIGNED DEFAULT 2(T-14 用;与 core_share_rule 二选一优先,见 T-14 DoD)- 新
11-seed-share-rule.sql:给主要产品按 business_type 落share_digits(默认 2;个别指数/货基可 4 位,种子给值并注释依据),含use core_share_rule先决 reset.ps1执行序追加10-seed-trade-calendar.sql、11-seed-share-rule.sql- sqlite 建表源同步 3 表 + 4 列,一致性断言(表/列清单 diff)
- settings 新增
convert_cutoff_time="15:00"(PRD §2.6.1,B1)与convert_confirm_sla_days=2(Q14)
依赖:T-0、T-0b(✅ 已完)
DoD:
数据库 reset 一次(reset.ps1 全量重灌)成功,SHOW TABLES见 18 表python -c "from app.config.settings import settings; print(settings.convert_confirm_sla_days, settings.convert_cutoff_time)"输出2 15:00(settings 同步加两字段)- sqlite/MySQL 建表源一致性断言通过(列清单逐列 diff,0 差异)
10-seed-trade-calendar.sql可执行:SELECT COUNT(*) FROM core_trade_calendar WHERE is_open=1= 503(2026 区间)11-seed-share-rule.sql可执行:SELECT COUNT(*) FROM core_share_rule≥ 产品数- pytest 基线仍 739/10 全绿(纯 DDL 不动代码)
风险:DDL 方言(sqlite 均支持;ENUM 用 VARCHAR 等价定义保持两端一致)· 双份漂移(DoD 第 3 条守护) 回滚点:新增表/列无破坏性;reset 重灌即可还原
T-2 · calc.py 纯函数扩展(D27 入口 / D28 辅助 / D26 redeem_amount)
目标:纯函数层备齐 T+1 模型需要的算法,不接主链路(避免回归爆炸)。
涉及文件:app/service/convert/calc.py(扩展)· tests/test_convert_calc.py
改法:
- 新增
product_round(value: Decimal, digits: int) -> Decimal:显式ROUND_HALF_UPquantize 到digits位(T-14 由它接 core_share_rule;T-2 只提供函数) - 新增
redeem_amount(qty: Decimal, nav: Decimal, fee: Decimal) -> Decimal:qty × nav − fee(2 位 HALF_UP;D26 公式) - 新增
partial_qty(requested: Decimal, available: Decimal) -> Decimal:min(requested, available)+diff = requested − actual(R-10 辅助) - 新增
next_biz_day/sec 相关纯函数?—— 不:日历查询依赖库表,放 trading_calendar.py(R-5),calc 只放无 IO 算法;若 trading_calendar 的判定逻辑可拆纯函数(如「15:00 判定」「周末判定」)则放 calc 供两端测 - 既有函数零改动(round2 等行为不变)
依赖:无
DoD:
- 新增函数各 ≥1 单测(含边界:redeem_amount 费用>金额、partial diff 归零、product_round 4 位/0 位)
pytest tests/test_convert_calc.py全绿(既有 + 新增)- 全量 pytest 基线 739/10 不变(纯新增)
风险:函数签名被 T-14 误用(DoD 1 守护行为) 回滚点:纯新增无破坏
T-2b · calc_convert_demo 实算回填
目标:示例随真实净值重算,满足验收 20 强绑定。
涉及文件:scripts/dev/calc_convert_demo.py · docs/PRD/PRD-基金转换交易.md §5.3.2 示例 · tests/test_convert_integration.py:278
改法:
- 按 T-2 新增公式重算示例数字(T 日净值 = 06-seed-nav.sql 真实净值)
- 三处同步(demo / PRD 示例 / 集成测试断言),不一致即失败
依赖:T-2
DoD:
python scripts/dev/calc_convert_demo.py输出与 PRD §5.3.2、test_convert_integration.py:278断言逐字节一致- 验收 20 专用用例存在且绿
风险:净值种子刷新后漂移(DoD 1 守护) 回滚点:重算回填
T-3 · core_ro 新方法 + share_lot_repository 扩展 + convert_request_repository(新)
目标:数据访问层备齐 T+1 模型读写。
涉及文件:app/repository/core_ro.py(扩展)· app/repository/share_lot_repository.py(扩展)· app/repository/convert_request_repository.py(新)· tests/test_core_ro_sum.py / tests/test_convert_repository.py(扩展)
改法:
core_ro新增(全role="ro"):get_convert_request(group_id)/list_convert_requests(status=None, as_of=None)sum_inflight_qty(customer_id, product_id):SELECT COALESCE(SUM(qty),0) FROM core_convert_request WHERE customer_id=:c AND product_id=:p AND status IN ('accepted','nav_pending')(R-3 占用推导)get_nav_on(product_id, trade_date):nav_date == :d精确匹配(F-24/B2,确认段净值判定专用;不得用get_nav_as_of回退语义)get_trade_calendar(start, end)/is_open(cal_date)(R-5 数据源)get_share_rule(product_id, business_type)(R-13 数据源)
share_lot_repository可用份额拆分(R-3):新增available_qty_with_inflight(Σ remain_qty − inflight);既有available_qty保留为「物理余量」(两方法并存,调用点显式选,避免误用;渲染两方法用途注释)- 新
convert_request_repository(role="rw",仅core_convert_request表 DML + 无 DELETE):insert(request)(首插,撞 uk_idem → IntegrityError 上抛由 service 转幂等 202)transition_status(group_id, from_status, to_status):条件 UPDATEWHERE status=:from,rowcount 0 → 并发冲突(R-2)list_pending_by_biz_date(as_of):accepted/nav_pending且受理日 ≤ as_of − 1 交易日,供批处理捞单(T-7)mark_actual(group_id, actual_qty)(R-10)
依赖:T-1
DoD:
sum_inflight_qty用例:受理 50000 → 占用 50000;撤单/rejected → 归 0(真库 verify 脚本)get_nav_on用例:有 T 日行 → 返回该行;仅 T−1 有 → 返回 None(nav_pending 前置)(B2)available_qty_with_inflight与「物理余量」双方法各 ≥2 用例(含占用归零边界)transition_status并发用例:两线程同迁一个单,恰一个 rowcount=1(真库,CONVERT_STRESS 模式)- 既有 test_core_ro_sum / test_convert_repository 全绿
- 全量 pytest 739/10 不变
风险:占用 LEFT JOIN 语义错(缺失 status 过滤导致把 rejected 也占住)→ DoD 1 守护;conftest 权限(新增写方法须 admin,R-e)
回滚点:纯新增方法;available_qty 改名属破坏性 → 用「新增方法 + 旧名保留别名」过渡
T-4 · convert_repository 语义收窄(进度/审计)
目标:agent 库只留「进度镜像 + 审计」,状态机权威迁 Core。
涉及文件:app/repository/convert_repository.py · tests/test_convert_repository.py
改法:
- 现状:操作
risk_convert_detail六字段(含 status)→ 改为语义收窄:本表status仅表示 agent 侧写入进度三值(pending/completed/failed;cancelled/expired为建表保留值,不写),由唯一写入口sync_mirror一次性整行覆盖,不再承载状态迁移。 ⚠️ 列集与表定义严格对齐(scripts/项目框架设计/表设计/02-mysql-agent专用.sql;sqlite 侧tests/_ddl.py:140-151)—— 实测两库均无updated_at列(2026-09-11 T-6 执行期核对),故sync_mirror只写 status / client_request_id / out_trade_id / in_trade_id / related_trade_id / nav / nav_date / fee_amount / hold_days_min / hold_days_max / nav_stale - 删除/停用
mark_pending → completed/failed/expired的自主迁移逻辑;唯一写入口 = 受理/确认/撤单/补偿结果回写(由 convert_service 调) list_expired_candidates语义移交 T-12(扫 Core 库)
依赖:T-1
DoD:
risk_convert_detail写入口数量 grep 结果 = 预期的 4 处(受理/确认/撤单/补偿),每处有调用方注释- 状态迁移逻辑全仓 grep 无残留「自主判定」分支(除 convert_request_repository.transition_status)
- test_convert_repository 改后全绿
风险:双写漂移(Core 权威、agent 镜像,时序上先 Core 后 agent)→ DoD 2 守护 回滚点:保留表结构与既有写路径注释;git 回退该文件即还原
T-5 · run_locked 复用(统一锁原语)
目标:受理/确认/撤单共用锁,防并发双跑。
涉及文件:app/service/risk/locks.py(现状已实现)· app/service/convert/convert_service.py(接入)
改法:
- 现状核对:
run_locked(name, ttl, fn)/try_lock(Redis 主 + 进程内备,locks.py 已实测) - 定义锁键前缀:
convert:req:{customer_id}(受理/撤单同客户串行,防同客户并发双单)、convert:confirm:{as_of}(R-1 批处理防双跑) - 锁异常降级:Redis 不可达时进程内锁兜底(locks.py 已备);锁获取失败 → 现 202 语义(受理)或直接抛
CONCURRENT_CONFLICT(撤单/确认)
依赖:无
DoD:
- 三处调用(受理/撤单/确认)各 ≥1 并发用例:同键双线程恰一个成功(真库)
- Redis 不可达模拟用例:降级进程锁仍互斥(既有 test_risk_locks 扩展)
风险:锁键粒度错(同客户锁过粗/过细)→ 用例守护 回滚点:锁是外加层,移除不影响数据正确性(幂等守卫仍在)
T-6 · accept_convert 受理事务(核心新增)
目标:T 日受理:校验 + 落受理单 + 202;不扣份额、不折算、不写流水(FR-C19)。
涉及文件:app/service/convert/convert_service.py(新增 accept_convert 段)· 新 convert_request_repository(T-3 产物)· trading_calendar.py(R-5)· tests/test_convert_service.py(新增受理用例)
改法(事务顺序):
- 锁:
run_locked('convert:req:'+customer_id, ttl=30, fn=...)(T-5) - 幂等:
client_request_id入参非空 →convert_request_repository.get_by_client_request_id;命中非终态 → 返回既有受理单(202);命中终态 → 返回终态(不重开);uk_idem 兜底:插入撞唯一键 → 转幂等返回(R-2 守卫) - 现校验(既有逻辑保留):
- 可转出/可转入(can_redeem/can_subscribe,F-15)
- 同管理人 + 同 TA(fund_company/ta_code,跨机构 →
CROSS_ENTITY_NOT_SUPPORTED) - min_hold_qty / min_redeem_qty / 最低持有动作(force_transfer 强制全转记 qty;R-h plan_lots 零剩余不触发)
- 风险适当性(唯一阻断点,受理时不过 → 400 blocked,受理单都不落,PRD 验收 4)
- 受理日判定(R-5):非交易日 / 超过截点(
settings.convert_cutoff_time,B1;仅对当日提交生效)→next_biz_day;受理单requested_at按此落 - 可用份额校验(R-3):
available_qty = 物理余量 − inflight;qty > available→ 400INSUFFICIENT_SHARES(验收 22) - Insert
core_convert_request(status='accepted', qty, actual_qty=NULL)(Core 单库事务) - 回写
risk_convert_detail镜像(T-4)+ 审计(agent 附加写入,失败走补偿 T-12) - 返回:
202 + {convert_group_id, status: 'accepted', requested_at}(Q2:不返回折算金额)
依赖:T-1 / T-3 / T-5
DoD:真库 verify 脚本 verify_convert_accept.py:
- 受理成功:
core_convert_request.status='accepted'、core_share_lot.remain_qty不变、core_trade无新行、HTTP 202(验收 21) - 同
client_request_id重复提交:第二次返回同一convert_group_id,core_convert_request仍 1 行(验收 12) - 占用:受理 50000 后
available_qty= 余量 − 50000;再超量申请 → 400INSUFFICIENT_SHARES(验收 22) - 非交易日 / 超过截点提交:
requested_at= 下一交易日(验收 28) - 适当性不匹配:400 blocked 且受理单 0 行(验收 4)
- 强制全转:
qty记实际全转量(验收 13 前半) - pytest 新增受理用例绿;全量回归 739+ 绿
风险:幂等窗口(INSERT 撞 uk_idem 竞态)→ DoD 2 守护;事务边界(Core 单库事务,agent 失败不阻断 Core 已提交,走补偿) 回滚点:受理单可撤(T-9 撤单接口)→ 数据自愈
T-7 · convert_service 三段编排 + confirm_service(T+1 批处理 · 核心重写)
目标:受理/确认/撤单三段 + confirm_service 批处理;确认事务 = 扣批次 + 两条流水 + 两端持仓 + 资金流中转(T-16 落地于此)+ 部分成交。
涉及文件:app/service/convert/convert_service.py(重写编排)· app/service/convert/confirm_service.py(新)· app/service/convert/trading_calendar.py · app/service/convert/nav.py(改 T 日净值)· convert_core_repository.py(确认事务重写)· tests/test_convert_service.py / test_convert_core.py / test_convert_integration.py(重写)· settings.py(废弃自然日偏移字段 R-15)
改法:
convert_service拆三入口:accept_convert(T-6)/cancel_convert(T-9 接口调用,逻辑在此)/confirm_batch(as_of)+confirm_one(group_id)- 确认段(confirm_one,Core 单库事务,顺序不可换):
- ① 取受理日 T 净值(R-8/B2:
core_ro.get_nav_on(product, T)精确匹配,不用get_nav_as_of回退语义):缺 → 置nav_pending返回(不 reject 不降级);重跑继续 - ② T+1 适当性复核(D25,验收 4 后半):复核不通过 →
rejected+ 占用释放 + 审计留痕(验收 26) - ③ FIFO 扣批次(既有 apply_convert 逻辑复用):条件 UPDATE 哨兵(冗余校验 + 并发兜底)
- ④ 部分成交(R-10):
actual = min(qty, available);actual < qty → 记 actual_qty + remark='partial'(验收 30) - ⑤ 折算:转入
in_qty = product_round(转出金额净值折算);补差费按convert_diff_fee_mode(默认 amount_diff,D29 相关费用随资金流) - ⑥ 落两条
core_trade(转出 redeem / 转入 subscribe,同 convert_group_id,R-b)+core_convert_lot_detail(资金流中转 core_cash_flow 2 条由 T-16 承担,本任务只落流水与明细) - ⑦ 更新两端
core_holding(qty/cost_amount/as_of/pnl_pct) - ⑧ 置
status='confirmed'(条件 UPDATE 守卫) - ⑨ commit 后:
engine.process_convert_event(out, in)(R-7,只跑一次)+ agent 回写 + 审计
- ① 取受理日 T 净值(R-8/B2:
confirm_batch(as_of):run_locked('convert:confirm:'+as_of)→list_pending_by_biz_date(as_of)→ 串行逐单confirm_one(组内串行 FR-C23)→ 汇总结果- 净值取数:
nav.py删_nav_as_of的「取 T−1」路径,全部改「取指定日 T」(F-18) - settings:
convert_confirm_sla_days=2启用;convert_confirm_offset_days标记 deprecated(R-15) - 删除 v1.0「阶段零占位 pending + 阶段一实时 apply + 阶段二回写」三阶段(旧幂等锚点 agent 库)→ 由 T-6 受理单替代
依赖:T-2 ~ T-6
DoD:真库 verify 脚本 verify_convert_confirm.py:
- 确认:
core_share_lot.remain_qty扣减、core_trade恰 2 行同 gid、两端core_holding更新、状态confirmed(验收 2/10/24) - 重复触发 confirm_batch 同 as_of:第二次 0 处理,流水不重复(验收 24 后半 / 15)
- 缺 T 日净值:状态
nav_pending、无流水、remain_qty不变;补 nav 后重跑 →confirmed(验收 25) - T+1 复核不通过 →
rejected+ 占用释放(验收 26) - 部分成交:占用被抢 →
actual_qty < qty、remark='partial'、未确认部分占用释放(验收 30) - 跨批次:各批按持有期计费、明细落
core_convert_lot_detail(验收 8) - 确认事务内不写
core_cash_flow(资金流写入唯一归属 T-16,反双重归属;若 T-16 未落地此断言恒守住) - 引擎恰好跑一次:
risk_alert1 条、RISK-002 不翻倍(验收 5/7/29) - pytest 重写用例全绿;全量回归 739+(含 test_convert_integration 改写)
风险:确认事务变长(扣批次 + 流水 + 持仓 + 资金流)→ 死锁概率↑(沿用 1213 重试外壳 43cf2a1);nav_pending 无限挂起(SLA 兜底 T-12);并发双跑(锁 + 条件 UPDATE 双守卫) 回滚点:受理单可撤(确认前);本任务改动面大,先基线快照再改
执行记录(2026-09-11 · 提交 048f1a9)
真库 verify_convert_confirm.py 83 项一致 / 0 不一致;全量 pytest 827 passed / 10 skipped(T-6 基线 798)。
基准日取日历中位开市日(本次实跑 T=2027-01-14 / T+1=2027-01-15),并用 PRD §5.3.2 示例输入在真库上
逐字节重放:68020.00 / 612.18 / 67407.82 / 333.36 / 67074.46 / 34945.54 / -0.0049。
与计划的差异与裁定(均已在代码注释与本节留痕):
- 确认段不做最低持有重判(计划缺口 · 本次新发现)。计划 ①-⑧ 未指明确认段是否重跑
plan_lots的最低持有判定。实测暴露:受理段已把qty收敛为实际全转量(申请 48000 → 落库 50000),确认段若重判会因leftover = 0得出forced_full_transfer = False(漏报); 更危险的是 T→T+1 之间可用份额变化(部分成交 / 他人申赎 / 产品参数改)会让重判得出与 受理承诺不一致的结论 —— 例如可用涨到 60000 后反被判「留 12000 < 新阈值」而擅自 把客户指令扩大成全转。裁定:强制全转是受理段决策,受理段把结论落受理单remark='full_transfer'(新增常量REMARK_FULL_TRANSFER),确认段只继承、不重判 (plan_lots不传min_hold_qty)。remark支持多标记;连接, 故部分成交的强制全转单记full_transfer;partial。用例守护:test_confirm_does_not_promote_to_full_transfer。 - 新增三个共用模块(计划未列):
format.py(D2/D4/new_id/q展示规格唯一出口)、audit.py(write_convert_audit唯一审计写入点)、engine_call.py(run_convert_engine引擎唯一调用点)。理由:受理段与确认段共用同一套量化 / 审计 / 引擎口径,若两段各写一份必然漂移。engine_call.py的引入完成了 T-8「引擎时机」的 实现部分(调用点已迁到确认事务 commit 之后,且全仓恰 1 处调用)。 ⚠️ 订正(2026-09-11,T-8 执行期发现):本行原写「T-8 剩余工作仅为rules.amount_view与三处消费方同步」——错。F-10 已明确 T-8 不动去重口径(amount_view三处消费方 原样不动,属 T-11 的范围)。T-8 剩余的是验证面:调用点唯一性守护 + 受理不触引擎用例- 确认链路真引擎出单 / RISK-002 不翻倍的真库断言。详见下方 T-8「执行记录」。
nav.py未改:确认段直接调core_ro.get_nav_on(product, T)(T-3 已新增的精确匹配口径), 无需经nav.py。计划第 4 条要删的「取 T−1 路径」(_nav_as_of)属 v1.0convert_fund, 该函数已标⛔ Deprecated,随 T-9 一并删除。test_convert_integration.py未改写:其现状依赖 v1.0convert_fund全链路; T-7 另起tests/test_convert_confirm.py(24 例)覆盖确认段。integration 改写与本计划 「test_convert_service 重写」一并在 T-9(api 层落地后) 做,届时端到端才有真接口可打。- 确认事务原子性口径收窄:计划 ⑧「条件 UPDATE 置 confirmed」在实现中并入
apply_convert同一事务(ConvertApplyInput.convert_request_id+ gateway 内_confirm_request),rowcount != 1→ 抛ConfirmConflict整事务回滚。若按计划放在 事务外,会出现「批次已扣、受理单未置位」的中间态(超扣风险)。 - MySQL
rowcount= changed rows 陷阱:nav_pending重试时SET status='nav_pending' WHERE status='nav_pending'返回 0,直接复用transition_status的冲突判定会把正常重试误判为并发冲突。已修为「状态未变则不迁移」+ 返回transitioned标志(sqlite 返回 1、MySQL 返回 0 的方言差异由此抹平)。
T-8 · 引擎时机改(确认后跑一次)
目标:FR-C28:引擎从受理后移到确认事务提交后;去重口径不动。
涉及文件:app/service/convert/convert_service.py(调用点迁移)· app/service/risk/engine.py(签名沿用)· tests/test_convert_engine.py / test_risk_engine.py
改法:
process_convert_event保持现有签名/语义(转出端主流水、related_trades 两条、on_error_hook 预留,F-11)convert_service删除受理段引擎调用;在确认事务 commit 后插入唯一调用点(R-7)amount_view/sum_trades_on_date/core_tools零改动(三处同步口径红线 F-10)- 引擎异常 →
engine_error审计 + 不阻断(D17 既有行为保留)
依赖:无(引擎时机改动不与 T-7 强绑定前置,但联调在 T-7 DoD 覆盖)
DoD:
- grep:
process_convert_event(全仓恰 1 处调用(确认段) - 受理路径用例:受理后
risk_alert0 新增(验收 29 前半) - 确认路径用例:引擎跑一次、RISK-002 计一次(验收 5/7)
- RISK-001/003 仍见两条(去重未删行,验收 6)
- pytest 全绿
风险:调用点漏清(旧受理段残留)→ DoD 1 守护 回滚点:单点改动,git 回退即还原
执行记录(2026-09-11 · 提交 57f16a5)
真库 verify_convert_engine.py 39 项一致 / 0 不一致(A0/A/B/C/D/E/F 七组);
全量 pytest 829 passed / 10 skipped(T-7 基线 827,+2);复跑 T-6 verify_convert_accept.py
37/37、T-7 verify_convert_confirm.py 83/83、v1.0 链路 verify_convert_service.py 35/35,均零失败。
本轮实际范围(与任务书字面的差异,已留痕):
- 引擎时机的「实现」在 T-7 已完成,本轮只做「守护 + 端到端验证」。T-7 抽出
engine_call.run_convert_engine时即把调用点放在确认事务 commit 之后,且全仓仅此一处;accept_convert自 T-6 实现起就不含引擎调用(阶段 1.5 属 v1.0convert_fund,不在受理段内)。 故 R-7 的两条要求(受理不触引擎 / 确认后唯一调用点)在 T-7 已成立,本轮补的是可回归的守护。 - DoD 1 的 grep 改用 AST 实现。字符串匹配会被
engine_call.py的说明段 (「process_convert_event(全仓应恰 1 处调用」)误判为第二处调用 —— 该守护若用 grep 写, 会恒红或被迫加脆弱的排除规则。改用 AST 统计Call节点,并同时断言宿主函数为run_convert_engine(防「文件对但函数被搬走」)。已做突变验证: 临时追加第二处调用 → 断言变红 → 还原。 convert_fund的阶段 1.5 调用点本轮不动。该函数是 v1.0 实时模型的全流程入口 (已被 4 个测试文件 + gateway 依赖),T-9 切换 API 后整体删除。本轮若只摘掉它的引擎调用, 会让过渡函数变成「扣了份额但不出预警单」——风控缺失比时机错更危险,故保留, 由 T-9 随函数一并清除。- 确认链路的真引擎断言采用「正证 + 反证」双断言。仅断言「RISK-002 不命中」无法区分 「去重生效」与「引擎根本没跑」;故正证(阈值夹逼 → 不命中)之外补反证 (阈值下调 → 命中),并做突变验证(禁用引擎调用 → 变红)。
- 真库脚本踩坑(已钉进脚本注释):
confirm_one(thresholds=...)必须显式传阈值, 缺省退回RiskThresholds.from_settings();settings 的daily_total一旦低于单条金额, RISK-002 就会「命中」,症状看着像去重失效,实为阈值口径没传(首轮实跑被 B 组断言捕获)。
T-9 · api 层(redeem 份额申报 + 撤单/确认/查询接口 + 网关分派)
目标:对外契约对齐 T+1 模型。
涉及文件:app/api/simulate.py(改)· app/api/convert_admin.py(新)· app/gateway/trade_gateway.py(分派)· app/service/convert/errors.py(错误码)· tests/test_integration_risk.py(改写;test_risk_api.py 实测 0 处 convert 引用,C4 无关不改)
改法:
simulate.pyconvert 分支:返回改202 + {convert_group_id, status: 'accepted', requested_at}(F-5 语义升级:202 从「未抢到执行权」改为「受理成功」;「未抢到执行权」仍 202 但并存 status 区分——两类 202 需分辨:status='accepted'=受理成功 /status='processing'=并发执行中,PRD §8.3)- 新增
app/api/convert_admin.py(路径以 PRD 为准,见 §1.3 裁定 11/12):POST /api/simulate/trade/convert/{convert_group_id}/cancel(PRD §5.5):撤单(R-9;accepted + 窗口内 → cancelled;否则 409CANCEL_NOT_ALLOWED);鉴权:本人可撤(交易 owner 闸门,红线 7)POST /api/admin/convert/confirm?accept_date=<业务日>(PRD §5.7):触发批处理(R-1 →confirm_service.confirm_batch(as_of=accept_date);运维接口,鉴权同 admin 级);参数名沿用 PRDaccept_date、语义取 PRD §5.7 表格口径「业务日」(裁定 12)GET /api/simulate/trade/convert/{convert_group_id}(PRD §5.7):查询状态 + 折算结果(确认后才有折算金额,Q2;未确认 → status + 无金额);鉴权:本人/代理人可查(查询 scope 闸门)
trade_gateway.py:SUPPORTED_TRADE_TYPES不动(F-25/O2)——convert 走既有独立_submit_convert分派(trade_gateway.py:105),把内部实时确认改调accept_convert(受理),不把 CONVERT 加进支持元组(避免改变申赎路径语义)errors.py:新增CANCEL_NOT_ALLOWED(409)/CONCURRENT_CONFLICT(锁冲突);既有错误码保留- redeem 改份额申报(D26 · R-6):
TradeRequestredeem 分支qty入参(替代 amount 反算);网关 redeem 调_maintain_lots新签名
依赖:T-7
DoD:
- API 契约用例:受理 → 202 + accepted;撤单窗口内成功(cancelled + 占用释放)、窗口外 409(验收 23);确认后撤 → 409
- 查询接口:确认前无折算金额字段、确认后有(Q2)
- 两类 202 分辨用例存在
SUPPORTED_TRADE_TYPES维持 2 类型(SUBSCRIBE/REDEEM,O2);convert 走独立分派不再 400(验收 1)- redeem 份额申报用例:
qty入参与amount反算路径断言的既有用例改写后绿(回归面 F-25→R-回归 1) - 撤单/查询接口鉴权用例:本人可撤/可查、代理人可查不可撤、他人 403(红线 7)
- pytest 全量绿(test_integration_risk 中旧 400 断言已改为受理 202;test_risk_api 无 convert 断言、零改动,C4)
✅ T-9 完成(2026-09-11):app/api/convert_admin.py(路径以 PRD 为准,裁定 11/12)三接口 + simulate.py convert 分支 202 + trade_gateway 分派改调 accept_convert(SUPPORTED_TRADE_TYPES 维持 2 类型)+ redeem 份额申报 + 错误码新增 CANCEL_NOT_ALLOWED(409)/CONCURRENT_CONFLICT(409)。验证:① 端到端 tests/test_convert_integration.py 重写为 T+1 两段链路 + 新增 API 契约/鉴权用例(13 passed);② 全量 pytest 834 passed / 10 skipped(基线 829 + 新增 5);③ 真库新脚本 verify_convert_api.py(HTTP 全链路)88/88,T-6/T-7/T-8/v1.0 四脚本复跑零回归(37/37 · 83/83 · 39/39 · 35/35);④ 3 组突变验证(受理回执/未确认查询泄漏折算字段、顾问误放行撤单)全部命中后还原。执行期发现并修正 2 处口径缺陷:① _rebuild_quote 的 out_nav 原从「金额÷份额」反推(0.005 档 HALF_UP 舍入致 1.3604 → 1.3600 漂移)→ 新增 _out_nav_rebuild 优先直读 core_convert_lot_detail.nav(与确认段首次折算同源,缺失才回退反推);② _accept_idempotent 字段面扩展与受理回执对齐(补 requested_qty/qty/forced_full_transfer/accept_date/confirm_date/available_date/cancel_deadline/actual_qty,原仅 qty/actual_qty/requested_at → 幂等重试客户端否则拿不到预计确认日)。
风险:契约变更打穿 test_integration_risk 的 convert→400 断言 → 回归面 §5 处置(test_risk_api 无 convert 断言、非风险项,C4) 回滚点:API 加字段不删旧字段(兼容期);撤单接口新增无破坏
T-10 · 普通申赎批次维护 + T+2 校验 + rebuild_lots
目标:FR-C16 份额批次全生命周期(普通申购落批次、普通赎回 FIFO 扣批次 + 哨兵)+ FR-C25 T+2 可赎回 + redeem 份额申报(D26 在普通赎回侧落地)。
涉及文件:app/gateway/trade_gateway.py(_maintain_lots 重写)· app/gateway/gateway_repository.py(哨兵)· app/repository/share_lot_repository.py(T+2 过滤)· scripts/core/rebuild_lots.py(回归验证)· tests/test_trade_gateway.py
改法:
_maintain_lotssubscribe 分支保持(amount → qty 落新批次,F-1 中正确的一半)- redeem 分支重写(R-6):
qty入参 → 校验可用(含在途占用与 T+2 过滤,R-3/R-4)→ FIFO 扣批次带哨兵UPDATE ... SET remain_qty = remain_qty - :q WHERE lot_id=:id AND remain_qty >= :q(gateway_repository 补哨兵,对齐 convert 严格校验) - T+2 过滤(R-4):
list_share_lots(..., available_from=业务日前一交易日);普通赎回/再转换可扣批次仅含 confirmed_at ≤ 该日的批次 rebuild_lots.py回归验证(D18 单一副本不动)test_trade_gateway.py改写:amount 反算断言 → 份额申报断言(回归面 §5)
依赖:T-3
DoD:
- 普通申购 → 新批次;普通赎回按 FIFO 扣批次,
remain_qty不出现负数(哨兵触发回滚用例存在)——test_redeem_deducts_fifo_across_lots+test_deduct_sentinel_blocks_overdraw - T+2 用例:T+1 确认的转入批次在 T+2 前不可扣、T+2 起可扣(验收 27)——
test_redeem_t2_not_yet_available_blocks_t1_lot(申报 150 > 可赎 100 区分度)+test_redeem_t2_available_includes_t1_lot(半开区间双向) - redeem 份额申报金额公式用例:
qty × T 净值 − 赎回费(D26)——test_redeem_amount_formula_qty_times_nav_minus_fee断言1000×1.0−5.0=995.00 - 转换后普通赎回:批次与持仓一致、不超扣(验收 9)——
test_redeem_after_convert_leaves_no_overdraw rebuild_lots.py --dry-run:无漂移报告 —— 真库 58 持仓行 0 补建 0 写入- pytest 全量绿(test_trade_gateway 改写 + 既有共享)—— 844 passed / 10 skipped(基线 834 + 新增 11,零回归)
✅ T-10 完成(普通申赎批次维护 + T+2 校验 + rebuild_lots · 2026-09-11):trade_gateway 主流程改 —— 新增 _t2_available_from(trading_calendar.previous_biz_day,日历缺行 try/except ValueError → None 不过滤,金额/扣减同口径);_redeem_quote 走 available_qty_with_inflight 硬校验(R-3,申报超可赎抛 InsufficientShares,不静默裁剪改写客户指令);_maintain_lots redeem 分支三重裁剪(T+2 过滤 → D8 补建 → 在途占用 → 哨兵 FIFO 扣减);core_ro.list_share_lots/share_lot_repository.select_for_convert 加 available_from T+2 半开区间过滤(R-4,confirmed_at < 前一交易日+1天);gateway_repository 扣批次哨兵 remain_qty >= :q。验证:tests/test_share_lot.py +10 + tests/test_trade_gateway.py +1 → 全量 844 passed / 10 skipped(834+11 零回归);真库 verify_convert_api.py 89/89(H 节提前到受理前执行 + 原位补 H2「受理占满在途后 redeem 申报抛 InsufficientShares」真库断言,88+1);rebuild_lots.py --dry-run 无漂移。突防验证:临时移除 select_for_convert 的 available_from 透传 → test_*_t2_* 变红(区分度成立,非假绿)→ 还原。断点与口径:① AttributeError: TextClause.format → 改为字符串 .format(extra=...) 再 text(...);② 金额侧初版用 plan_lots 全量算、扣减侧裁剪到 avail → 改硬校验同口径;③ 在途占用(R-3)放普通赎回侧 = 同一份份额不能既等转换又已赎回。
风险:打穿 test_trade_gateway 大量断言(amount 反算)→ 回归面 §5 逐条处置;哨兵缺位致超扣(DoD 1 守护) 回滚点:_maintain_lots 独立函数,git 回退即还原
T-11 · core_tools 汇总去重 + 持仓过滤
目标:对话侧金额汇总不翻倍(验收 11)。
涉及文件:app/tool/core_tools.py(沿用)· tests/test_core_tools.py
改法:
query_recent_trades的sum_amount已同调amount_view(F-10)→ 确认不回归:补一条 convert 组用例- 持仓过滤
qty>0(F-9,core_ro.list_holdings 已实现)→ 补 convert 全转归零用例(F-12 场景:转出全部份额后 holding.qty=0 行不在持仓列表)
依赖:T-8(口径三处同调链)
DoD:
- convert 两流水场景:
sum_amount计一次(验收 11)——test_confirm_then_sum_amount_counts_once(走真实链路:受理 →confirm_one落两条同 gid 流水后,sum_amount= 转出端 40812.00 + 无组普通 100000 = 140812.00;total_count仍 3 = 明细全量;并以sum_trades_on_date(T+1)复验跨口径一致) - 全转归零:
list_holdings不含 qty=0 行 ——test_confirm_full_transfer_holding_zero_row_excluded(确认段场景:先直查 SQL 证明归零行仍在库里(台账留痕,D 决策),再断言list_holdings只剩转入端且 qty 与res["in_qty"]逐字节一致,query_holdings(Tool 层)同口径;既有test_query_holdings_excludes_zero_qty(口径级)与test_concentration_profile_filters_zero_qty(集中度读取方)保留不动) - pytest 全量绿 —— 846 passed / 10 skipped(基线 844 + 新增 2,零回归)
✅ T-11 完成(core_tools 汇总去重 + 持仓过滤 · 确认段场景补用例 · 2026-09-12):
生产代码零改动(计划即判「风险低;仅补用例」)—— 口径三处同调(F-10:rules.amount_view ① RISK-002/005 ② query_recent_trades.sum_amount ③ sum_trades_on_date SQL 条件)与 list_holdings 的 SQL 层 qty > 0 过滤均已就位(v0.x T-11 产物 + F-9/F-10 实测),本轮增量 = 把既有「直接插 SQL 模拟流水」的口径用例升级为 T+1 真实链路场景(R-回归 8「保留 + 补用例」/ R-回归 13「保留断言 + 补确认段场景」——T+1 模型下流水与持仓只在确认段产生,既有用例覆盖不了这一点)。落地:tests/test_core_tools.py +2(新增 t1_env fixture:seed_suitability_matrix + 最小受理/确认种子;_quiet_th() 全阈值推高让引擎静默,避免依赖 settings 与 conftest autouse 隔离)。
执行期裁定 2 条(留痕):① 转出端流水 amount = 费前转出金额(40812.00 = 30000×1.3604,与 PRD §5.3.2 示例 68020.00 同语义;赎回费 204.06 单列 redeem_fee 字段)—— 初版锚点误按「费后 40607.94」断言被真跑抓出,已在用例注释钉死口径;② t1_env 必须调 _ddl.seed_suitability_matrix(C×R 矩阵是 check_suitability 的 L0 权威,裸 create_sqlite_engine() 缺矩阵 → 受理全量 forbidden)—— 与 conftest sqlite_engine fixture 同源(自检第 13 问)。
验证:pytest tests/test_core_tools.py 5/5 → 全量 846 passed / 10 skipped(844+2 零回归);突变 2 组防假绿:① 去掉 list_holdings 的 qty > 0 → 精准 3 红(新确认段用例 + 既有归零行用例 + 集中度跨口径一致性断言);② query_recent_trades 绕开 amount_view 直加全量 → 精准 3 红(新确认段用例 + 既有不翻倍用例 + 跨口径一致性断言)—— 均已还原,git status 仅测试文件改动、grep MUTATION 无残留;真库 verify_convert_tools.py 复跑 14/14、退出码 0、零残留(零回归佐证)。
风险:低;仅补用例 回滚点:无代码改动风险
T-12 · 补偿脚本改写
目标:SLA 超时兜底 + agent 附加写入补偿,扫描对象迁 Core。
涉及文件:scripts/agent/cleanup_pending_convert.py(改写)· scripts/agent/rebuild_alerts.py(沿用)· scripts/core/rebuild_lots.py(沿用)
改法:
cleanup_pending_convert.py重写(R-14):扫core_convert_request中status IN ('accepted','nav_pending')且受理日 < 当前业务日 − 2 交易日 → 置expired(标记不硬删 S2,条件 UPDATE 守卫);--hours参数改为--days(交易日数)或直接取convert_confirm_sla_daysrebuild_alerts.py:仅凭 Core 侧数据(含core_convert_lot_detail.nav/nav_date)补 agent 进度/审计(F-19 已具备补偿数据源)- 全仓 grep 确认无其它处扫描 agent 库占位
依赖:T-4 / T-7
DoD:
- 造数:accepted 超 2 交易日 → 脚本执行后
expired(真库,仿 verify_convert_compensate.py) - nav_pending 超 SLA →
expired(不早于确认窗口) - 第二次执行 0 处理(幂等)
rebuild_alerts.py从 Core 净数据补出的 detail 与流水一致(验收 17)
风险:SLA 判定用交易日而非自然日(Q14)→ 用 trading_calendar.previous_biz_day 链式计算 回滚点:脚本只改状态不硬删(S2),天然可逆
✅ T-12 完成(补偿 + SLA 清理切 T+1 链路 · 2026-09-12):
三个改动面:① scripts/agent/cleanup_pending_convert.py 整体重写:扫描对象迁 Core(list_inflight_before 扫 accepted/nav_pending 且 requested_at 严格早于 cutoff)→ 逐单 transition_status 条件 UPDATE 置 expired(S2 不硬删;并发双跑天然幂等,不加锁);SLA 边界 _slab_cutoff = 当前业务日(今天开市取今天否则 previous_biz_day 回退)上推 convert_confirm_sla_days 个交易日的 00:00,日历数据缺失降级自然日并告警(宁可少清);--days/--dry-run 参数、退出码 0/1、不动 agent 镜像(T-4 契约:镜像不推 cancelled/expired)。② compensate_convert(convert_service)从 __all__ 的 v1.0 Deprecated 组救回在役:详情侧数据源 = Core 三件套前置校验(流水 ≥2 条 → 受理单 confirmed → 计费明细非空,任一不满足 state=missing 零写入不硬补),补写与确认段第⑧步同口径(sync_mirror(completed) + decision='confirmed' 审计 phase='confirm-compensate'、engine_error 如实标记);convert_fund/rebuild_convert_response 仍留 Deprecated(T-13 退役)。③ ConvertRequestRepository 新增 list_inflight_before(T-12 专用捞单,复用 R-2 条件 UPDATE 守卫,不复制 SQL)。
勘误:原「涉及文件 scripts/agent/rebuild_alerts.py」路径有误,实际在 scripts/demo/;该脚本零改动(薄封装委托 compensate_convert,签名未变)。
联网核实(用户铁律 2026-09-12):① 「基金转换申请失效」真实口径——多家基金公司《开放式基金业务规则》(华安/富国/嘉实/太平/德邦等):转出不可赎回或转入不可申购 → 无效;TA T+1 确认、T+2 可查;无效申请资金退回原账户。映射本项目:rejected(T+1 复核不通过)= 真实「无效申请」,expired 定位为运维兜底(释放占用 = 份额退回效果)——设计贴近真实 ✓;② A 股连续休市历史最长 = 2020 年春节 10 个自然日(国务院办公厅延长假期;上交所《关于2020年春节延长休市相关业务衔接安排的通知》:休市延至 2 月 2 日、2 月 3 日恢复开市)——trading_calendar._MAX_SCAN_DAYS=256 的量级依据成立,结论留痕进 verify 脚本头注释;verify 造数的受理日/SLA 边界全部改为 previous_biz_day 链式走真库交易日历,不猜自然日(对任意假期安排鲁棒)。
T-12 真库脚本暴露并修复 1 处三链路不一致(本轮最重要产出):confirm_one 第⑦步引擎异常只 log 不留痕(engine_result=None → confirmed 审计/return dict 的 engine_error=False)——全仓唯一不落痕路径(v1.0 阶段 1.5 落 decision='engine_error' 审计、trade_gateway 落 risk_engine_error),且补偿侧 has_engine_error_audit 的「人工核对」保护对 T+1 主链路永远失效。修复:confirm_service 第⑦步 except 分支补写 decision='engine_error' 审计 + engine_error 标志贯穿 confirmed 审计与 return dict(D17「不阻断」语义保留,测试注释同步钉死「不阻断但留痕 + 标记」)。
测试:tests/test_convert_service.py 补偿用例 4 条改 T+1 断言 + 新增 2(missing_when_request_not_confirmed / inserts_mirror_when_row_absent);tests/test_demo_scripts.py cleanup 段重写(真实日历路径 + 幂等 + dry-run + 自定义 SLA 方向断言)+ 新增 test_cleanup_window_matches_confirm_batch_window(行为化等价:窗口下界日 confirm_batch 能捞到且 cleanup 不清,前一交易日两边都排除);tests/test_convert_concurrency.py 并发补偿改 T+1 造数 + 真库清场补 core_convert_request(FK 1451)。全量 849 passed / 10 skipped(846+3,零回归)。
真库:scripts/dev/verify_convert_compensate.py 整体重写为 T+1 链路(受理 → confirm_one 第⑦⑧步双失败真实落痕 → Core 三件套对表 → 补偿 rebuilt / 复跑 skipped / 真 JSON 列 LIKE / cleanup ENUM·窗口同口径·dry-run·幂等·镜像不推),56/56 退出码 0、残留 0(含幂等重跑)。真库断言口径修正 1 条:decision='confirmed' 审计真链路下恰 2 条(确认段 phase='confirm' + 补偿 phase='confirm-compensate' 各 1,各司其职)——v1.0「主审计 1 条」是单测造数(未跑 confirm_one)造成的盲区,真库端到端才验得出。
突变 4 组防假绿(全部被抓并还原):① cleanup 窗口方向反转(previous→next_biz_day)→ 5 红(含窗口等价用例);② compensate 前置 confirmed 守卫删除 → 精准 1 红;③ 真库 expired→cancelled → 2 红(ENUM/状态断言);④ 真库镜像 nav_stale=True → 1 红。
待办移交 T-13:convert_fund/rebuild_convert_response 退役 + verify_convert_service.py 处置 + 全量门禁。
T-13 · 全量回归 + 集成 + 并发 + 性能补录 + convert_fund 退役
目标:第 6 步前的门禁总校验 + v1.0 过渡函数收尾。
涉及文件:全部 · scripts/dev/verify_convert_*.py 套件 · app/service/convert/convert_service.py · docs/项目框架设计/开发计划-基金转换交易.md(本文件基线更新)
改法:
- 全量 pytest:
739 基线 + 新增 convert 用例,目标 739 + N 全绿 - 真库 verify 套件全跑:accept / confirm / compensate / engine / lots / seed / service / tools(现有 7 个 + 新增 verify_convert_accept / verify_convert_confirm)
- 并发:CONVERT_STRESS=1 用例全跑(受理并发 + 确认批处理并发双跑)
- 性能补录(验收 18,不得改数据迁就指标):受理/确认事务耗时实测记录;端到端响应 < 2s
- 紧池成功率重测(验收 31,设计目标 100%):构造需求=供给的紧池场景,批量受理 → 全部 accepted;确认后无超卖
convert_fund退役(用户 2026-09-11 拍板并入 T-13):v1.0 两阶段实时过渡函数已无生产调用方(T-9 已把路由/网关/引擎套件切到accept_convert/confirm_one)→ 本轮删除函数体convert_service.convert_fund,同步清理其依赖:向verify_convert_service.py(v1.0 链路脚本)提供替代验证路径或按 v1.0 模型留档;约 25 处测试改写为直接不发 v1.0 链路(R-回归 2/7 已有 T-6/T-7 的替代断言);迁移完成后跑全仓 pytest + 真库套件确认无游离引用
依赖:全部任务
DoD:
pytest -q全绿(计数记录到本文件头)—— 830 passed / 7 skipped 退出码 0- 7+2 个真库 verify 脚本全部退出码 0(失败=1)—— 现存 9/9 退出码 0(accept 37 / confirm 83 / engine 39 / api 89 / compensate 56 / apply 24 / lots 20 / seed 全 PASS / tools 14;verify_convert_service.py 已随 v1.0 退役删除)
- CONVERT_STRESS 并发 9/9 通过 —— 6/6 通过退出码 0(3b/3c 占位竞态 2 例 + 1 例拆并随 v1.0 占位语义退役,映射表见执行记录)
- 性能耗时实测定格进文档(受理/确认事务、端到端;端到端 < 2s 为硬门禁,超限须归因后回填)—— 端到端 P95 110.3ms / max 114.7ms,余量约 17 倍
- 紧池场景成功率实测记录(目标 100%:成功率 < 100% → 记录 P50/P95/max 与瓶颈归因,作为第 6 步验收 31 前置数据;超卖不变量继续成立 = 硬门禁)—— 受理 50/50 + 确认 50/50 = 100%(v1.0 同场景 80%),扣减=池守恒、无超卖
convert_fund及其 v1.0 依赖链移除后全仓 pytest + 真库套件零回归(grepconvert_fund无生产调用残留)—— 仅余 4 处注释性历史说明,零调用
风险:基线重估引入新增失败 → 逐条归因(归因铁律:先复跑真库脚本确认非环境噪音再改代码);convert_fund 删除可能打穿 verify_convert_service.py 与约 25 处测试 → 需一并改写(R-回归 2/7 已计划)
回滚点:无(纯验证 + v1.0 过渡函数删除,代码库有 git 历史可恢复)
✅ T-13 完成(全量门禁 + convert_fund 退役 · 2026-09-12):
① 退役落地(convert_service.py 净删 468 行):删除 convert_fund(八步编排)+ 其 v1.0 专属依赖 _plan_and_quote / _pick_rate(死代码)/ _finalize_from_core / _write_main_audit + 17 个孤儿 import;模块 docstring 重写为 T+1 编排说明,__all__ 收敛为 6 个在役符号(accept_convert / cancel_convert / t1_t2_dates / compensate_convert / rebuild_convert_response / PROCESSING)。按实际修订 1 处:开发计划原文「rebuild_convert_response T-13 退役」不成立——它是 T-9 查询接口 confirmed 分支的在役读路径,保留在役(PROCESSING 仅作 simulate 网关 202 判断常量,同样保留)。verify_convert_service.py(v1.0 链路真库脚本)git rm,git 历史留档。
② 测试面改写(约 25 处映射落地):test_convert_service.py 全量重写(805→约 440 行):v1.0 十组用例退役,替代断言分布 = T-6/T-7/T-8 既有用例 + 补偿组 6 用例保留 + 新增 2(confirm_mirror_write_failure_keeps_core_confirmed 镜像失败不回滚 Core / confirm_rejects_too_many_lots 批次上限 rejected 零流水);显式依赖注入踩坑留痕:confirm_one 的 core_writer/request_repo 缺省会连真 MySQL(症状分别 = 写侧 FK 1452、rejected 被真库 rowcount=0 误判 skipped)——_services helper 全部五仓储钉同一 sqlite 引擎。test_convert_concurrency.py 全量重写为 T+1 并发面(v1.0 九用例 → 六用例):50 并发扣减争抢 → 并发 confirm_one 不超卖(受理段允许 R-3 TOCTOU 超授、确认段 FIFO 哨兵兜底,硬不变量钉在确认后);紧池 80% → 紧池 100%(受理全 accepted + confirm_batch 串行);同键并发单次转换 → 同键并发受理单唯一(uk_idem + 客户级锁;3b/3c 占位竞态确定性交错用例随 v1.0 占位语义退役,无 T+1 对应物);死锁重试用例改多客户并发 confirm_one;性能探针改两段计量。trade_id 生成走 format.new_id(与 convert_service._new_id 两个符号),monkeypatch 罩不到 → _confirm 显式传 id_factory。
③ 全量 pytest:830 passed / 7 skipped(基线 849 → 830:v1.0 用例组退役净减,skipped 10 → 7 = 并发文件 stress 标记 7 → 4);全仓真库套件 9/9 退出码 0。
④ 全量复跑抓出并修复过期脚本 1 处(本轮计划外产出):verify_convert_lots.py(T-10 产物)B/C/D 组仍按 v1.0「金额赎回」传 amount= —— T-9 已按 D26/R-6 改份额申报(qty=),_maintain_lots redeem 分支遇 qty is None 静默跳过(尽力而为语义)→ B/C FIFO 扣减失效、D 组兜底补建断言红(A 组申购不受影响,故首轮仅此脚本红)。修正为 qty= 申报后 20/20 退出码 0。「金额申购、份额赎回」行业铁律(2026-09-10 联网查证)的又一处落地。同轮补修(用户指出硬编码问题后全仓排查):verify_convert_lots.py 与 verify_convert_apply.py 的造数日期(原 NAV_DATE=date(2026,9,4) 等 9 处字面日期)违反 T-12 立下的「造数日期走真库交易日历、不猜自然日」模式 → 全部改 previous_biz_day 链式回推动态锚定(hold_days 自然日相对量保持不变,档位断言不受影响),复跑 20/20 + 24/24 退出码 0(动态生效自证:confirmed_at 随回推日漂移、断言同源跟随)。全仓硬编码排查结论:生产代码 app/ 零字面日期、cutoff/SLA/死锁重试全走 settings(B1/R-14 整改完整);T-6T-12 五个真库脚本本就零字面日期;T-13 全部完成;剩余 T-14~T-17(产品舍入接入 / 已知偏差收口 / 文档终稿 / 全链路验收)。verify_convert_seed.py 的 AS_OF 与种子 SQL 强绑定、calc_convert_demo.py 的示例日为 PRD 验收 20 强制锚定(改动态反而违约)——均属有意固定,保留;sqlite 单测固定日期+自插配套数据为确定性测试惯例,保留。
⑤ 性能实测(n=59,剔除首个连接冷启动样本;本机 MySQL 8.0.46 / 模拟库 / 单线程顺序 / 每笔 1 份 / 空引擎 hook):受理段 P50 37.9 / P95 53.8 / max 57.9 ms(校验+落单+镜像+审计);确认事务(apply_convert)P50 10.0 / P95 25.3 / max 25.5 ms;确认全程 P50 41.4 / P95 61.3 / max 74.4 ms(折算+复核+扣减+流水+镜像+审计);端到端(受理+确认两段)P50 79.2 / P95 110.3 / max 114.7 / mean 83.0 ms —— PRD §9 第 18 条 < 2s 硬门禁达成(余量约 17 倍),数字已回填 PRD §9。
⑥ 紧池重测(验收 31 载体):50 并发受理各 1000、池恰 50000(需求=供给)→ 受理 50/50(100%)(R-3 占用校验:即使完全串行第 i 笔 available = 50000−(i−1)×1000 ≥ 1000,全 accepted 是确定性结果)→ confirm_batch 串行确认 50/50(100%)、扣减恰=池、守恒无超卖。v1.0 同场景实测 80%(40/50,批次碎片争抢)→ T+1 100%,验收 31 设计目标达成;v1.0 病根(确认前就扣份额 + 批次碎片争抢)由「受理不扣份额 + 确认串行(FR-C23)」结构性消除。
⑦ 并发确认兜底(S2):50 路并发受理各 2000(允许 R-3 占用读数滞后超授,实测 25/50 accepted、其余 InsufficientShares 拦截)→ 并发 confirm_one 争抢 → 扣减 40000 ≤ 池 50000 + 守恒 + 每组恰 2 流水;LotConflict 让路单(退避 3 次耗尽)受理单保持 accepted(占用保持,下轮批处理可再确认,零扣减不碰资金安全)——并发 confirm_one 本身是超纲场景(生产由批处理锁保证串行),本用例证明的是「即使绕过锁,资金安全仍有兜底」。
⑧ 残留确认(DoD 6):grep convert_fund 全仓仅 4 处注释性历史说明(trade_gateway 演进说明 / convert_service 模块头 / test_convert_service 退役说明),零调用;redeem 传 amount= 的过期调用全仓零残留(verify_convert_lots.py 修复后)。
结论:T-13 六条 DoD 全勾,T-1
T-14 · D27 产品舍入真正接入
目标:core_share_rule seed + calc 接入(R-13,验收 10 兜底)。
涉及文件:app/service/convert/calc.py(product_round 接入)· app/repository/core_ro.py(get_share_rule 已备)· scripts/core/11-seed-share-rule.sql · tests/test_convert_calc.py
改法:
in_qty与普通申赎 qty 落库处改用product_round(value, share_digits)(digits 来源:core_share_rule.business_type 匹配,缺省 2)- 覆盖率补偿:新测试自建 core_share_rule 种子(4 位产品)测真实路径,不得让降级路径当覆盖(skill 反面教训 3)
- 种子给 2 位默认 + 个别 4 位注释依据
依赖:T-1 / T-2
DoD:
- 4 位产品用例:转入份额 quantize 到 4 位(非降级)
- 缺规则产品:回退 2 位(显式降级用例存在但非唯一覆盖)
- 与
round2默认行为一致性断言(2 位路径逐字节一致) - pytest 全绿
风险:全局切舍入炸既有断言 → 与 round2 一致性断言守护 回滚点:单点切换(product_round 封装)
T-15 · D28 部分成交专项测试 + 边界补强
目标:验收 30 全路径覆盖。
涉及文件:tests/test_convert_integration.py / tests/test_convert_concurrency.py(新增用例)· app/service/convert/convert_service.py(若边界暴露则补强)
改法:
- 用例矩阵:占用被抢(另一单先确认)/ 部分确认后剩余占用释放 / 撤单与确认竞态 / actual_qty=0 边界(占用全被抢 → ?裁定:
actual_qty=0视为确认失败 →rejected,R-10 补强) - 并发:
CONVERT_STRESS下两单争同一批 → 先到先得
依赖:T-7
DoD:
- 验收 30 每条子场景 ≥1 用例绿(真库)
actual_qty=0边界:状态rejected(不产生零额确认)- pytest 全绿
风险:竞态不可复现 → 用真库 + 并发钩子确定性复现(skill 并发缺陷铁律) 回滚点:补强点独立
T-16 · D29 资金流中转建模
目标:确认事务写 core_cash_flow(R-11,本任务是 core_cash_flow 唯一写入点——T-7 确认流程不写、防双重归属;金额与流水一致,验收 2/10/24 的资金流侧)。
涉及文件:app/gateway/convert_core_repository.py(确认事务内补写)· scripts/core/01-ddl.sql(core_cash_flow 已建,F-14,不需 DDL)· tests/test_convert_core.py
改法:
- 确认事务内、两条 core_trade 之后补 2 条
core_cash_flow(R-11:redeem out + subscribe in,remark 带 gid,channel/金额对齐) - 幂等:与流水同事务天然保证(重跑确认的 0 处理由 T-7 守卫)
依赖:T-6
DoD:
- 确认后
core_cash_flow恰 2 条、金额与流水一致(验收 T-16) - 确认失败回滚:core_cash_flow 0 条(同事务)
- pytest 全绿
风险:流水语义漂移(flow_subtype ENUM 值已含 redeem/subscribe,F-14 无需 ALTER) 回滚点:确认事务整体回滚保护
T-17 · D30 share_class + A/C 互转
目标:同基金 A/C 份额互转(R-12)。
涉及文件:app/service/convert/calc.py 或 convert_service.py(校验放行)· scripts/core/01-ddl.sql(4 列已补 T-1)· scripts/core/02-seed-base.sql / 03-seed-customers.sql(种子 share_class / allow_ac_convert)· tests/test_convert_service.py
改法:
- 校验放行(R-12):
fund_company同 +ta_code同 +(allow_ac_convert=1)+ 产品不同;满足则允许,错误码沿用CROSS_ENTITY_NOT_SUPPORTED语义(提示换「A/C 互转不支持」) - 种子:至少一对 A/C 产品(同管理人同 TA、allow_ac_convert=1)与一对不允许的产品做对照
- 转出/转入金额折算同既有公式(A/C 净值不同,按各自 T 净值)
依赖:T-1 / T-9
DoD:
- A/C 互转用例:同管理人同 TA + 开关开 → 成功受理
- 开关关 / 跨管理人 / 相同 share_class → 400(对照)
- pytest 全绿
风险:校验放行规则与「同产品禁转」冲突 → R-12 已裁定(share_class 不同即放行) 回滚点:校验是前置分支,git 回退即还原
5. 回归面清单(必然打穿的既有断言 · 全 tests/ 扫)
处置原则:口径被 v2.1 推翻 → 改写断言并注明上游依据;口径仍成立 → 断言语义保留只改数据路径;误伤 → 保留断言。
| # | 位置 | 现有断言 | 为何失效 | 处置 |
|---|---|---|---|---|
| R-回归 1 | tests/test_trade_gateway.py | redeem 按 amount 反算 qty 扣批次 |
D26 改份额申报(Q15 拍板) | 改写:断言 qty 入参 + qty×T净值−赎回费 公式(T-9/T-10) |
| R-回归 2 | tests/test_convert_service.py | 两阶段实时编排(apply_convert 同步确认 + 阶段零占位 pending) | T+1 模型受理/确认分离(Q1) | 重写:受理 202 + 确认批处理两段(T-6/T-7) |
| R-回归 3 | tests/test_convert_core.py | 确认时同步扣批次、写流水、持仓 | 拆分受理/确认后确认段保留此语义但入口变批处理 | 改写入口:断言移至 confirm_one(T-7) |
| R-回归 4 | tests/test_convert_engine.py | 引擎在「受理后阶段 1.5」触发 | FR-C28 移到确认后(Q13 拍板) | 改写:受理后 0 预警、确认后恰 1 预警(T-8) |
| R-回归 5 | tests/test_convert_repository.py | risk_convert_detail 自主状态迁移(pending→completed/failed/expired) |
T-4 状态机权威迁 Core | 改写:agent 侧只验证镜像写(T-4) |
| R-回归 6 | tests/test_integration_risk.py | convert → 400(网关不认) | T-9 网关接受 convert → 202 受理 | 改写:受理 202 + 两类 202 分辨(T-9;test_risk_api.py 实测 0 处 convert 引用,C4 无关) |
| R-回归 7 | tests/test_convert_concurrency.py | 同键竞态以「实时扣批次」为断言底座 | 受理段不再扣份额(占用由受理单推导) | 改写:受理并发(uk_idem + 锁)与确认批处理并发两组(T-6/T-7) |
| R-回归 8 | tests/test_core_ro_sum.py / test_core_tools.py | 跨口径一致性断言(amount_view 三处同调) | 口径不变;仅新增 convert 组 | 保留 + 补用例:sum_amount 计一次(T-11) |
| R-回归 9 | tests/test_risk_engine.py / test_risk_rules.py | _eligible 只看 confirmed subscribe/redeem |
不被打穿(R-b 两流水仍是 redeem/subscribe) | 保留;补「确认后触发」场景(T-8) |
| R-回归 10 | tests/test_convert_calc.py | 纯函数行为(round2 等) | T-2/T-14 新增函数不动既有 | 保留;新增 product_round/redeem_amount/partial_qty 用例(T-2/T-14) |
| R-回归 11 | tests/test_convert_integration.py | 端到端按 v1.0 两阶段流 | 全链路 T+1 化 | 重写(含 :278 示例断言,验收 20 强绑定) |
| R-回归 12 | tests/test_share_lot.py | select_for_convert(FIFO 选批、不足额返回全部可用、零额空)与 bootstrap_lots;另有 6 处 _maintain(trade_type="redeem", amount=...)(:441-550)经网关 _maintain_lots |
T-3 改造 available_qty 为双方法 + T-10 扣批次哨兵;批次可用口径变化;D26/Q15 后 redeem 入参改 qty(份额)并移除 amount 反算(:220/:248),这 6 处 redeem 用例打断 |
改写:断言改为 available_qty_with_inflight;select_for_convert 加 T+2 过滤参数后补可用边界;6 处 redeem amount="120" 改 qty= 份额入参 + 断言原生份额语义(I1 + C3,T-3/T-9/T-10) |
| R-回归 13 | tests/test_concentration_c4.py:161 | convert 转出全部份额后 qty=0 行不构成持仓(集中度读取方) |
T+1 模型持仓仅在确认段更新;T-11 需保持归零行过滤 | 保留断言 + 补确认段场景(T-11;I1 实测) |
| R-回归 14 | tests/test_db.py:83 | 列名一致性(sqlite quantity vs MySQL qty 的历史失配护栏) |
T-1 新增 3 表 + 4 列 → 该测试断言清单需含新列 | 改扩展:列清单断言纳入 T-1 新表/新列(I1) |
| R-回归 15 | tests/test_demo_scripts.py | 演示脚本接线(cleanup/rebuild 等) | T-12 改写 cleanup_pending_convert 扫描对象 | 改写:脚本调用签名/输出断言更新(I1,T-12) |
| R-回归 16 | tests/test_locks_redis.py:141-172 | 锁键(convert 相关锁已存在) | T-5 新增 convert:req:{customer} / convert:confirm:{as_of} 锁键 |
补用例:新锁键粒度/互斥用例(I1,T-5) |
遗漏检查法(skill 反面教训 2):写完后对 tests/ 全目录 grep -rn "convert" 逐个文件过一遍是否存在表中未列的引用。首轮独立审核已补(I1):test_share_lot.py / test_concentration_c4.py / test_db.py / test_demo_scripts.py / test_locks_redis.py 已在表中;test_risk_chat_tools.py / test_risk_repository.py / test_risk_api.py 实测 0 处 convert 引用。第三轮白纸重审补充(C3):仅 grep convert 会漏掉 test_share_lot.py 中 redeem 子测试(不依赖 convert 字样)——D26 后同样被打穿,已补 R-回归 12
6. 验收清单(PRD §9 31 条 → 承载任务 → 证据)
行 1
31 对应 PRD §9 验收 131;FR-C29(redeem 份额申报)/ FR-C30(资金流中转)为 v1.1 新增的 PRD §9 之外条目,列于行 32/33(PRD FR 编号依承载任务回溯)。
| 验收 | 内容 | 承载任务 | 证据 |
|---|---|---|---|
| 1 | pytest 全绿(739 基线 + 新增) | T-13 | pytest -q 输出 |
| 2 | 两条流水同事务同 gid | T-7 | verify_convert_confirm DoD 1 |
| 3 | 折算按 T 日净值 | T-7/R-8 | verify_convert_confirm DoD 3 |
| 4 | 受理 blocked / T+1 复核 rejected | T-6 / T-7(D25) | verify DoD 5 / DoD 3 |
| 5 | RISK-002 计一次 | T-8 | verify DoD 7 |
| 6 | RISK-001/003 见两条(不删行) | T-8 | test_risk_engine 用例 |
| 7 | 一条预警事件 | T-8 | verify DoD 7 |
| 8 | 跨批次各自计费 | T-7 | verify DoD 6 |
| 9 | 普通赎回后再 convert 不超扣 | T-10 | verify DoD 4 |
| 10 | 两端持仓更新 | T-7 | verify DoD 1 |
| 11 | core_tools 不翻倍 | T-11 | test_core_tools 新用例 |
| 12 | client_request_id 幂等 | T-6 | verify_convert_accept DoD 2 |
| 13 | 强制全转 actual_qty | T-6/T-7 | verify DoD 6 |
| 14 | CROSS_ENTITY_NOT_SUPPORTED 覆盖 | T-1/T-6 | test 用例 |
| 15 | 幂等窗口闭合(确认失败重跑不双写) | T-7/T-12 | verify DoD 2 + R-14 |
| 16 | 全额转出豁免(6000 vs min 10000) | T-6(R-h 已实现) | test 用例保留 |
| 17 | agent 附加写入可补偿 | T-12 | rebuild_alerts 真库实测 |
| 18 | 性能(第 6 步实测补录) | T-13 | 耗时记录 |
| 19 | 补差费非零覆盖(110022 0.30% vs 003095 0.80%) | T-2/T-2b | demo + 集成断言 |
| 20 | 示例与种子强绑定 | T-2b | 三处一致 DoD |
| 21 | T 日受理不扣份额 | T-6 | verify_convert_accept DoD 1 |
| 22 | 在途占用 + 超量 400 + 撤单恢复 | T-6/R-3 | verify DoD 3 |
| 23 | 撤单窗口内 / 窗口外 409 | T-9/R-9 | convert_admin 用例 |
| 24 | T+1 串行确认 + 重复触发 0 双写 | T-7 | verify DoD 1/2 |
| 25 | 缺净值 nav_pending 不降级 | T-7/R-8 | verify DoD 3 |
| 26 | 确认失败 rejected + 释放 + 留痕 | T-7 | verify DoD 4 |
| 27 | T+2 可赎回 | T-10/R-4 | test_trade_gateway 用例 |
| 28 | 交易日判定 / 截点顺延 | T-6/R-5 | verify_convert_accept DoD 4 |
| 29 | 引擎确认后只跑一次 | T-8 | verify DoD 7 |
| 30 | 部分成交 D28 | T-7/T-15 | verify DoD 5 |
| 31 | 紧池成功率 100%(设计目标) | T-13 | 紧池场景实测记录 |
| 32 | 资金流中转 FR-C30(D29):确认后 core_cash_flow 恰 2 条、flow_subtype=redeem/subscribe、金额与流水一致、remark 带 gid |
T-16 | verify DoD(T-16) |
| 33 | redeem 份额申报 FR-C29(D26):redeem 入参改 qty(份额)、移除 amount 反算;calc.redeem_amount |
T-10 | T-9/P-1 用例 + test_share_lot 改写 |
7. 风险登记与门禁总则
| # | 风险 | 级别 | 前移到任务 DoD |
|---|---|---|---|
| 1 | DDL 双份漂移(sqlite/MySQL) | 高 | T-1 DoD 3(列清单 diff 断言) |
| 2 | 幂等窗口竞态(uk_idem 撞键) | 高 | T-6 DoD 2(真库并发) |
| 3 | 确认事务变长死锁 | 中 | T-7(沿用 1213 重试外壳 43cf2a1) |
| 4 | nav_pending 无限挂起 | 中 | T-12 DoD 2(SLA expired) |
| 5 | 2027 休市安排未公告 | 低 | T-1 DoD 4(seed 注释 + 重跑 gen_trade_calendar) |
| 6 | 普通赎回超扣 | 高 | T-10 DoD 1(哨兵) |
| 7 | 引擎时机残留旧调用 | 中 | T-8 DoD 1(grep 恰 1 处) |
| 8 | 占用推导漏 status 过滤 | 高 | T-3 DoD 1(归零用例) |
| 9 | 舍入切换炸既有断言 | 中 | T-14 DoD 3(与 round2 一致性) |
| 10 | 部分成交竞态不可复现 | 中 | T-15 DoD 2(真库并发钩子) |
| 11 | conftest 权限连锁 | 中 | 各任务 DoD(R-e 红线) |
| 12 | 旧 v1.0 文档残留误导 | 低 | 本文件头部已声明作废 + 交接文档同步 |
| 13 | cutoff 硬编码(B1) | 中 | T-1 DoD 2 输出 15:00 + convert_cutoff_time;R-5/R-9 读 settings |
| 14 | 净值回退静默降级(B2,nav_pending 永不触发) | 高 | T-3 DoD 2(get_nav_on 精确匹配用例);R-8/T-7 禁 get_nav_as_of 回退语义 |
门禁总则:每个任务合入前 pytest -q 不得低于前值;高危任务(T-6/T-7/T-9/T-10)真实库 verify 脚本退出码 0 才可进下一步;审核收敛后才允许进入第 5 步(todo 开发)。
8. 待确认(需用户拍板;其余一律自行裁定并标注)
| # | 待确认项 | 建议 |
|---|---|---|
| 1 | T-13 紧池成功率验收 31 是否在本线(第 4 步)范围外、交由第 6 步集成测试实测 | 建议:第 4 步只建场景与标记,第 6 步实测(PRD 验收 31 已注明「待第 6 步重测」) |
| 2 | 撤单接口鉴权级别(admin 或本人可撤) | 建议:本人可撤(交易 owner 闸门),查询仅本人/代理人(已按此写进 T-9,待用户确认) |
| 3 | core_convert_request 是否需要 coupon/手续费展示位以外字段 |
建议:按 PRD 六态最小集,后续需求再加 |
9. 给审核 AI 的检查清单(本计划的自检锚点)
- 任务编号是否与架构 v2.1 §15 严格一致(T-1~T-17 一一对应,无重编)
- 代码事实核对表每条是否带 文件:行号、计数是否以实测为准(F-6/F-7/F-14 是目录实测)
- 回归面清单是否覆盖全 tests/(已补 I1 的 5 个文件;
test_risk_chat_tools/test_risk_repository/test_risk_api已确认 0 引用;C3:仅 grepconvert会漏 redeem 子测试 →test_share_lot6 处 redeem 用例已补 R-回归 12) - 降级裁定是否成对配覆盖率补偿(R-8 缺净值降级 vs 验收 25;T-14 缺规则回退 vs 自建种子;B2 净值回退已改
get_nav_on精确匹配) - DoD 是否全部机械可验证(有无「确认正确」类模糊表述)
- 依赖排序是否与架构 §15 冲突(P1 已剔除 T-8;T-16/T-17 依赖已改 T-7/T-6)
- 验收 31 条是否无落空(对照 §6 表逐条;C2b:FR-C29/C30 已补独立行 32/33)
- R-b / 红线 1/2/3 是否被无意违反(trade_type 仍是 redeem/subscribe;amount_view 三处同步;_q 单点量化)
- B1/B2 是否已闭环(
convert_cutoff_time配置全链读 settings;确认段净值判定用get_nav_on精确匹配、禁get_nav_as_of回退) - 对外契约是否逐条与 PRD 比对(T-9 实测补 · 2026-09-11):凡本计划中新出现或改写的对外契约 —— API 路径 / 参数名 / 响应字段名 / HTTP 状态码 / 错误码 —— 必须回到 PRD 原文逐条对齐,并注明 PRD 出处(章节或行号)。
- 为什么单列一条:T-9 初稿自拟的三个接口路径与 PRD §5.5/§5.7 给出的路径完全不同,而三轮独立审查(挑毛病 / 验证型 / 白纸重审)全部未发现 —— 原因是本清单前 9 条对的是「架构 §15 / 代码事实行号 / tests 回归面 / PRD §9 验收条目」,没有一条对接口契约本身;审查做的是「计划是否覆盖 PRD 的要求」,而非「计划是否擅自改 PRD 的承诺」,契约改写恰好落在盲区。
- 做法:对本计划所有
/api/...字符串、FIELD_NAME常量、409/422等状态码做一次grep,逐条问「PRD 原文怎么写的?出处是哪一节?」,自拟而无出处的必须标出并请用户裁定。 - 同源规律(与铁律 10 一致):带「文件:行号」的断言会被逐条验;不带出处的自由书写没有锚点 → 必漏。