Files
group_xinghuo_jinrong/docs/项目框架设计/开发计划-基金转换交易.md
T

107 KiB
Raw Blame History

开发计划 · 基金转换交易(v2.0 · T+1 受理/确认分离模型)

文档状态:v2.0 整体重写 · ✅ 三轮独立审核闭环(第一轮 2 阻断 B1/B2 + 5 重要 + 3 可选;第二轮验证型 12 项零残留;第三轮白纸重审 C1C5+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.0 convert_fund 已退役)· push 由用户定

上游依据:PRD-基金转换交易.md v1.1(§2 模型 / §5 接口 / §6 引擎 / §9 验收 31 条 / Q1Q15 拍板)· 架构设计-基金转换交易.md v2.1(D1D30 · §15 任务映射 T-0~T-17)

任务编号:沿用架构 v2.1 §15,不重编(T-0 ✅ / T-0b ✅ 已完成;T-1T-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 审核通过吗」——第三轮为「白纸重审」但修订动作由主代理自证,缺独立方逐条核实声明是否真的落位。本轮补齐验证型复核:全新子代理逐条打开文件核实。

判定项 结果
C1C5 + O1O4 + 版本号 22 条声明的落位核实 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 硬门禁

  1. 每任务 DoD 可机械验证(有明确命令/断言),禁止「确认正确」类表述
  2. pytest 全绿:基线 739 只减不增原则——新增可以加用例,既有用例除非被 v2.1 口径推翻(见 §5 回归面),否则不许删
  3. 高风险任务(T-6/T-7/T-9/T-10):先跑基线快照 → 改造 → 全量回归
  4. 真库验证一律走 scripts/dev/verify_convert_*.py 模式(隔离前缀、清理、DoD 断言、退出码 1=失败)
  5. 方言红线:sqlite 主回归保留;core_convert_request 表在 sqlite conftest DDL 与 MySQL 01-ddl.sql 双份同步(T-1 DoD 含一致性断言)

1. 背景与约束

1.1 上游依据与决策链

  • PRD v1.1 定模型:§2.12.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)。

改法:

  1. 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
  2. 01-ddl.sql 追加 core_trade_calendar(与 10-seed-trade-calendar.sql 头注释给出的 DDL 逐字一致):cal_date DATE PK、is_open TINYINT(1)、remark VARCHAR(64) NULL
  3. 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)
  4. core_product ALTER 补 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)
  5. 新 11-seed-share-rule.sql:给主要产品按 business_type 落 share_digits(默认 2;个别指数/货基可 4 位,种子给值并注释依据),含 use core_share_rule 先决
  6. reset.ps1 执行序追加 10-seed-trade-calendar.sql、11-seed-share-rule.sql
  7. sqlite 建表源同步 3 表 + 4 列,一致性断言(表/列清单 diff)
  8. 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

改法:

  1. 新增 product_round(value: Decimal, digits: int) -> Decimal:显式 ROUND_HALF_UP quantize 到 digits 位(T-14 由它接 core_share_rule;T-2 只提供函数)
  2. 新增 redeem_amount(qty: Decimal, nav: Decimal, fee: Decimal) -> Decimal:qty × nav − fee(2 位 HALF_UP;D26 公式)
  3. 新增 partial_qty(requested: Decimal, available: Decimal) -> Decimal:min(requested, available) + diff = requested − actual(R-10 辅助)
  4. 新增 next_biz_day/sec 相关纯函数?—— 不:日历查询依赖库表,放 trading_calendar.py(R-5),calc 只放无 IO 算法;若 trading_calendar 的判定逻辑可拆纯函数(如「15:00 判定」「周末判定」)则放 calc 供两端测
  5. 既有函数零改动(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

改法:

  1. 按 T-2 新增公式重算示例数字(T 日净值 = 06-seed-nav.sql 真实净值)
  2. 三处同步(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(扩展)

改法:

  1. 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 数据源)
  2. share_lot_repository 可用份额拆分(R-3):新增 available_qty_with_inflight(Σ remain_qty − inflight);既有 available_qty 保留为「物理余量」(两方法并存,调用点显式选,避免误用;渲染两方法用途注释)
  3. 新 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):条件 UPDATE WHERE 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

改法:

  1. 现状:操作 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
  2. 删除/停用 mark_pending → completed/failed/expired 的自主迁移逻辑;唯一写入口 = 受理/确认/撤单/补偿结果回写(由 convert_service 调)
  3. 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(接入)

改法:

  1. 现状核对:run_locked(name, ttl, fn) / try_lock(Redis 主 + 进程内备,locks.py 已实测)
  2. 定义锁键前缀:convert:req:{customer_id}(受理/撤单同客户串行,防同客户并发双单)、convert:confirm:{as_of}(R-1 批处理防双跑)
  3. 锁异常降级: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(新增受理用例)

改法(事务顺序):

  1. 锁:run_locked('convert:req:'+customer_id, ttl=30, fn=...)(T-5)
  2. 幂等:client_request_id 入参非空 → convert_request_repository.get_by_client_request_id;命中非终态 → 返回既有受理单(202);命中终态 → 返回终态(不重开);uk_idem 兜底:插入撞唯一键 → 转幂等返回(R-2 守卫)
  3. 现校验(既有逻辑保留):
    • 可转出/可转入(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)
  4. 受理日判定(R-5):非交易日 / 超过截点(settings.convert_cutoff_time,B1;仅对当日提交生效)→ next_biz_day;受理单 requested_at 按此落
  5. 可用份额校验(R-3):available_qty = 物理余量 − inflight;qty > available → 400 INSUFFICIENT_SHARES(验收 22)
  6. Insert core_convert_request(status='accepted', qty, actual_qty=NULL)(Core 单库事务)
  7. 回写 risk_convert_detail 镜像(T-4)+ 审计(agent 附加写入,失败走补偿 T-12)
  8. 返回: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;再超量申请 → 400 INSUFFICIENT_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)

改法:

  1. convert_service 拆三入口:accept_convert(T-6)/ cancel_convert(T-9 接口调用,逻辑在此)/ confirm_batch(as_of) + confirm_one(group_id)
  2. 确认段(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 回写 + 审计
  3. confirm_batch(as_of):run_locked('convert:confirm:'+as_of) → list_pending_by_biz_date(as_of) → 串行逐单 confirm_one(组内串行 FR-C23)→ 汇总结果
  4. 净值取数:nav.py 删 _nav_as_of 的「取 T−1」路径,全部改「取指定日 T」(F-18)
  5. settings:convert_confirm_sla_days=2 启用;convert_confirm_offset_days 标记 deprecated(R-15)
  6. 删除 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_alert 1 条、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。

与计划的差异与裁定(均已在代码注释与本节留痕):

  1. 确认段不做最低持有重判(计划缺口 · 本次新发现)。计划 ①-⑧ 未指明确认段是否重跑 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。
  2. 新增三个共用模块(计划未列):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「执行记录」。
  3. nav.py 未改:确认段直接调 core_ro.get_nav_on(product, T)(T-3 已新增的精确匹配口径), 无需经 nav.py。计划第 4 条要删的「取 T−1 路径」(_nav_as_of)属 v1.0 convert_fund, 该函数已标 ⛔ Deprecated,随 T-9 一并删除。
  4. test_convert_integration.py 未改写:其现状依赖 v1.0 convert_fund 全链路; T-7 另起 tests/test_convert_confirm.py(24 例)覆盖确认段。integration 改写与本计划 「test_convert_service 重写」一并在 T-9(api 层落地后) 做,届时端到端才有真接口可打。
  5. 确认事务原子性口径收窄:计划 ⑧「条件 UPDATE 置 confirmed」在实现中并入 apply_convert 同一事务(ConvertApplyInput.convert_request_id + gateway 内 _confirm_request),rowcount != 1 → 抛 ConfirmConflict 整事务回滚。若按计划放在 事务外,会出现「批次已扣、受理单未置位」的中间态(超扣风险)。
  6. 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

改法:

  1. process_convert_event 保持现有签名/语义(转出端主流水、related_trades 两条、on_error_hook 预留,F-11)
  2. convert_service 删除受理段引擎调用;在确认事务 commit 后插入唯一调用点(R-7)
  3. amount_view / sum_trades_on_date / core_tools 零改动(三处同步口径红线 F-10)
  4. 引擎异常 → engine_error 审计 + 不阻断(D17 既有行为保留)

依赖:无(引擎时机改动不与 T-7 强绑定前置,但联调在 T-7 DoD 覆盖)

DoD:

  • grep:process_convert_event( 全仓恰 1 处调用(确认段)
  • 受理路径用例:受理后 risk_alert 0 新增(验收 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,均零失败。

本轮实际范围(与任务书字面的差异,已留痕):

  1. 引擎时机的「实现」在 T-7 已完成,本轮只做「守护 + 端到端验证」。T-7 抽出 engine_call.run_convert_engine 时即把调用点放在确认事务 commit 之后,且全仓仅此一处; accept_convert 自 T-6 实现起就不含引擎调用(阶段 1.5 属 v1.0 convert_fund,不在受理段内)。 故 R-7 的两条要求(受理不触引擎 / 确认后唯一调用点)在 T-7 已成立,本轮补的是可回归的守护。
  2. DoD 1 的 grep 改用 AST 实现。字符串匹配会被 engine_call.py 的说明段 (「process_convert_event( 全仓应恰 1 处调用」)误判为第二处调用 —— 该守护若用 grep 写, 会恒红或被迫加脆弱的排除规则。改用 AST 统计 Call 节点,并同时断言宿主函数为 run_convert_engine(防「文件对但函数被搬走」)。已做突变验证: 临时追加第二处调用 → 断言变红 → 还原。
  3. convert_fund 的阶段 1.5 调用点本轮不动。该函数是 v1.0 实时模型的全流程入口 (已被 4 个测试文件 + gateway 依赖),T-9 切换 API 后整体删除。本轮若只摘掉它的引擎调用, 会让过渡函数变成「扣了份额但不出预警单」——风控缺失比时机错更危险,故保留, 由 T-9 随函数一并清除。
  4. 确认链路的真引擎断言采用「正证 + 反证」双断言。仅断言「RISK-002 不命中」无法区分 「去重生效」与「引擎根本没跑」;故正证(阈值夹逼 → 不命中)之外补反证 (阈值下调 → 命中),并做突变验证(禁用引擎调用 → 变红)。
  5. 真库脚本踩坑(已钉进脚本注释):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 无关不改)

改法:

  1. simulate.py convert 分支:返回改 202 + {convert_group_id, status: 'accepted', requested_at}(F-5 语义升级:202 从「未抢到执行权」改为「受理成功」;「未抢到执行权」仍 202 但并存 status 区分——两类 202 需分辨:status='accepted'=受理成功 / status='processing'=并发执行中,PRD §8.3)
  2. 新增 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;否则 409 CANCEL_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 级);参数名沿用 PRD accept_date、语义取 PRD §5.7 表格口径「业务日」(裁定 12)
    • GET /api/simulate/trade/convert/{convert_group_id}(PRD §5.7):查询状态 + 折算结果(确认后才有折算金额,Q2;未确认 → status + 无金额);鉴权:本人/代理人可查(查询 scope 闸门)
  3. trade_gateway.py:SUPPORTED_TRADE_TYPES 不动(F-25/O2)——convert 走既有独立 _submit_convert 分派(trade_gateway.py:105),把内部实时确认改调 accept_convert(受理),不把 CONVERT 加进支持元组(避免改变申赎路径语义)
  4. errors.py:新增 CANCEL_NOT_ALLOWED(409)/ CONCURRENT_CONFLICT(锁冲突);既有错误码保留
  5. redeem 改份额申报(D26 · R-6):TradeRequest redeem 分支 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

改法:

  1. _maintain_lots subscribe 分支保持(amount → qty 落新批次,F-1 中正确的一半)
  2. 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 严格校验)
  3. T+2 过滤(R-4):list_share_lots(..., available_from=业务日前一交易日);普通赎回/再转换可扣批次仅含 confirmed_at ≤ 该日的批次
  4. rebuild_lots.py 回归验证(D18 单一副本不动)
  5. 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

改法:

  1. query_recent_trades 的 sum_amount 已同调 amount_view(F-10)→ 确认不回归:补一条 convert 组用例
  2. 持仓过滤 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(沿用)

改法:

  1. cleanup_pending_convert.py 重写(R-14):扫 core_convert_request 中 status IN ('accepted','nav_pending') 且受理日 < 当前业务日 − 2 交易日 → 置 expired(标记不硬删 S2,条件 UPDATE 守卫);--hours 参数改为 --days(交易日数)或直接取 convert_confirm_sla_days
  2. rebuild_alerts.py:仅凭 Core 侧数据(含 core_convert_lot_detail.nav/nav_date)补 agent 进度/审计(F-19 已具备补偿数据源)
  3. 全仓 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(本文件基线更新)

改法:

  1. 全量 pytest:739 基线 + 新增 convert 用例,目标 739 + N 全绿
  2. 真库 verify 套件全跑:accept / confirm / compensate / engine / lots / seed / service / tools(现有 7 个 + 新增 verify_convert_accept / verify_convert_confirm)
  3. 并发:CONVERT_STRESS=1 用例全跑(受理并发 + 确认批处理并发双跑)
  4. 性能补录(验收 18,不得改数据迁就指标):受理/确认事务耗时实测记录;端到端响应 < 2s
  5. 紧池成功率重测(验收 31,设计目标 100%):构造需求=供给的紧池场景,批量受理 → 全部 accepted;确认后无超卖
  6. 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 + 真库套件零回归(grep convert_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 五个真库脚本本就零字面日期;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-1T-13 全部完成;剩余 T-14~T-17(产品舍入接入 / 已知偏差收口 / 文档终稿 / 全链路验收)。


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

改法:

  1. in_qty 与普通申赎 qty 落库处改用 product_round(value, share_digits)(digits 来源:core_share_rule.business_type 匹配,缺省 2)
  2. 覆盖率补偿:新测试自建 core_share_rule 种子(4 位产品)测真实路径,不得让降级路径当覆盖(skill 反面教训 3)
  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(若边界暴露则补强)

改法:

  1. 用例矩阵:占用被抢(另一单先确认)/ 部分确认后剩余占用释放 / 撤单与确认竞态 / actual_qty=0 边界(占用全被抢 → ?裁定:actual_qty=0 视为确认失败 → rejected,R-10 补强)
  2. 并发: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

改法:

  1. 确认事务内、两条 core_trade 之后补 2 条 core_cash_flow(R-11:redeem out + subscribe in,remark 带 gid,channel/金额对齐)
  2. 幂等:与流水同事务天然保证(重跑确认的 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

改法:

  1. 校验放行(R-12):fund_company 同 + ta_code 同 + (allow_ac_convert=1) + 产品不同;满足则允许,错误码沿用 CROSS_ENTITY_NOT_SUPPORTED 语义(提示换「A/C 互转不支持」)
  2. 种子:至少一对 A/C 产品(同管理人同 TA、allow_ac_convert=1)与一对不允许的产品做对照
  3. 转出/转入金额折算同既有公式(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 条 → 承载任务 → 证据)

行 131 对应 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 的检查清单(本计划的自检锚点)

  1. 任务编号是否与架构 v2.1 §15 严格一致(T-1~T-17 一一对应,无重编)
  2. 代码事实核对表每条是否带 文件:行号、计数是否以实测为准(F-6/F-7/F-14 是目录实测)
  3. 回归面清单是否覆盖全 tests/(已补 I1 的 5 个文件;test_risk_chat_tools / test_risk_repository / test_risk_api 已确认 0 引用;C3:仅 grep convert 会漏 redeem 子测试 → test_share_lot 6 处 redeem 用例已补 R-回归 12)
  4. 降级裁定是否成对配覆盖率补偿(R-8 缺净值降级 vs 验收 25;T-14 缺规则回退 vs 自建种子;B2 净值回退已改 get_nav_on 精确匹配)
  5. DoD 是否全部机械可验证(有无「确认正确」类模糊表述)
  6. 依赖排序是否与架构 §15 冲突(P1 已剔除 T-8;T-16/T-17 依赖已改 T-7/T-6)
  7. 验收 31 条是否无落空(对照 §6 表逐条;C2b:FR-C29/C30 已补独立行 32/33)
  8. R-b / 红线 1/2/3 是否被无意违反(trade_type 仍是 redeem/subscribe;amount_view 三处同步;_q 单点量化)
  9. B1/B2 是否已闭环(convert_cutoff_time 配置全链读 settings;确认段净值判定用 get_nav_on 精确匹配、禁 get_nav_as_of 回退)
  10. 对外契约是否逐条与 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 一致):带「文件:行号」的断言会被逐条验;不带出处的自由书写没有锚点 → 必漏。