Files
group_xinghuo_jinrong/docs/项目框架设计/架构设计-基金转换交易.md
T
GaoYiYuan_0626 f056fcd007 基金转换 T+1 模型:T-1~T-6 落地(DDL/数据层/语义收窄/锁/受理事务)
AIcoding 第 5 步 todo 开发(开发计划 v2.0)前半段:

- T-1 3 张新表 DDL(core_convert_request 6 态 ENUM / core_trade_calendar /
  core_share_rule)+ 种子 + sqlite 单一事实源同步 + 列清单断言
- T-2/T-2b calc 扩展(product_round/redeem_amount/partial_qty)+ 真实净值实算回填
- T-3 convert_request_repository(6 态 + 条件 UPDATE 守卫)+ core_ro 三读方法
  + share_lot_repository.available_qty_with_inflight(R-3 在途占用推导)
- T-4 convert_repository.sync_mirror 成为 risk_convert_detail 唯一进度镜像写入口
  (旧三方法标 Deprecated,T-7 后删)
- T-5 locks.py 锁键构造器 convert_req_lock_key / convert_confirm_lock_key
- T-6 convert_service.accept_convert 受理事务(八步:锁→幂等→校验→受理日顺延
  →在途占用校验→落单→镜像+审计→202;不扣份额/不折算/不写流水)
  + tests/test_convert_accept.py(16 用例)
  + scripts/dev/verify_convert_accept.py(真库 36/36 一致)
  + trading_calendar.py 纯函数包(R-5)+ 21 用例

T-6 真库实测暴露并修复:confirm_eta 在日历数据边界抛 ValueError,会让已落库
的受理单在调用方眼里变 500;改为展示性字段容错 + 单测守护。

基线:798 passed / 10 skipped,零回归。
2026-09-11 19:21:15 +08:00

46 KiB
Raw Blame History

架构设计说明书 · 基金转换(convert)交易

版本:v2.1(精简最终态 · T+1 受理/确认分离模型) · 2026-09-11 · 已通过独立 AI 联网审查(0 阻断级),已定稿(2026-09-11 用户批准) 上游:docs/PRD/PRD-基金转换交易.md(v1.1) 分支:risk-control-agent · 关联:docs/项目框架设计/架构设计-风控模块.md 技术选型不变:FastAPI + SQLAlchemy Core(text SQL) + MySQL 双库 + Redis + LangGraph + DeepSeek。 本文定义目录划分、模块职责、核心时序、技术决策、事务与并发、测试策略、任务映射(第 4 步输入)。


0. 合规与真实业务基准

来源:证监会公告〔2025〕22 号《公开募集证券投资基金销售费用管理规定》(2026-01-01 施行)+ 汇添富/中欧/鹏华/国投瑞银/东海/华商/金鹰/财通/中银/东方证券资管 10 家转换业务公告。清单见 §0.5。

0.1 法规硬约束

项 22 号文规定 本项目取值 落点
申购费率上限 主动偏股 ≤0.8% / 其他混合 ≤0.5% / 指数·债券 ≤0.3% / 货基 0(22号文 §8) 种子 0.0080 / 0.0050 / 0.0030 / 0 PRD §4.3
赎回费下限 <7 日 ≥1.5% / 7–30 日 ≥1.0% / 30–180 日 ≥0.5%(22号文 §10) 档表见 §7(+180–365 0.25% / ≥365 0,合同约定) PRD §2.1.3
赎回费归属 全额计入基金财产(22号文 §10,旧规 75%/50%/25% 分级已废止) core_fee_rule.to_fund_ratio = 1.0(D16,留痕、不参与计算) PRD §4.1

豁免不启用:22 号文 §10 允许个人持满 7 日的指数/债基、机构持满 30 日的债基另约赎回费。本项目不走豁免,保留完整费率梯度以便演示与测试。

0.2 行业惯例

项 真实做法(多份公告一致) 本项目
份额精度 转入份额四舍五入保留 2 位(主流);个别差异:易方达 ETF 场外整数位、南方截断 主流 2 位 HALF_UP;按产品 core_share_rule(product_id+business_type) 配置(D27)
金额精度 转出金额/赎回费/补差费 2 位四舍五入(主流);个别差异:东方赎回费 2 位后截断 主流 HALF_UP;按产品 rounding_mode 配置(D27)
T+1 确认 T 申请(未知价法)→ T+1 权益登记 → T+2 可用;持有期自确认日起算 转入新批次 confirmed_at = T+1(交易日历推算)
先进先出 / 逐批计费 注册日期在前先转出;不同持有期分别计费 FIFO + 逐批计费
单笔计算法 当日多笔转换单笔计费,不合并 逐笔
未知价法 以申请受理当日净值为基准 确认时取 T 日净值
转出可赎回 / 转入可申购 双方状态校验 FR-C9a
同机构限制 同一销售机构 + 同一管理人 + 同一 TA CROSS_ENTITY_NOT_SUPPORTED
冻结份额 / 撤单 冻结不可转(司法冻结为人工柜台);T 日结束前可撤 在途占用由受理单推导;撤单已实现
赎回申报方向 份额赎回——投资者以份额申报,登记机构按 T 日净值算金额 redeem 份额申报(D26)
巨额赎回 转出与赎回同优先级、可全额或部分转出;真实条款为部分延期赎回(未确认部分顺延至下一开放日) 投资者层部分成交(可用份额不足时实际=min(申请,可用))+ 占用释放(不顺延,属本项目投资者层建模取舍,与基金层顺延不同)(D28)
资金流 赎回款经注册登记账户中转,一次转换一次资金划转 Core 侧建 core_cash_flow 中转建模(D29)
A/C 份额互转 同一基金 A/C 类可互转(由管理人公告决定,非所有基金支持) core_product 加 share_class + per-product 开关 allow_ac_convert(D30)

0.3 已拍板的多口径决策

项 拍板结论 落点
补差费口径 B(价外法两端差)为默认;A 以 convert_diff_fee_mode='rate_diff' 保留 D13 / §7 / §11
最低持有余额处置 两种都做:min_hold_action ∈ {force_transfer, force_redeem} D15 / §7
P1 普通申赎无批次 不跳过(批次表覆盖全部交易类型;遇历史持仓无批次兜底补建) D8 / §7
P2 转出归零 保留(remain_qty/qty = 0),不删行 D9 / §7
P3 Core 侧明细 仅补偿,不对外查询 D6 / §7

0.4 与真实业务全量对齐(D26~D30)

所有与真实业务不符的简化本期全部收敛:redeem 份额申报、按产品舍入、投资者层部分成交、资金流中转、A/C 互转。唯一硬限制 = 模拟库不是真实登记机构底座;但以下差异在模拟层内均可建模对齐真实语义。

# 原简化 真实对齐落地 决策
① redeem 金额反算份额 份额申报;赎回金额 = qty × T日净值 − 赎回费 D26
② redeem 折算份额估算 随①收敛(份额来自申报,T+1 取 T 日真实净值) D26
③ 赎回费统一 HALF_UP 按产品 rounding_mode(东方截断等) D27
④ 转入份额统一 2 位 按产品 share_digits(易方达整数/南方截断等) D27
⑤ 巨额赎回不做部分成交 确认段部分成交(投资者可用份额不足时实际=min(申请,可用))+ 占用释放(不顺延,投资者层取舍;基金层巨额赎回为顺延) D28
⑥ 资金流不建模中转 Core 侧 core_cash_flow 中转建模 D29
⑦ 无 share_class core_product 加 share_class + seed 标注,放开 A/C 互转 D30
⑧ 赎回费归属不参与 22 号文全额计入(to_fund_ratio=1.0)数值已对齐 D16

0.5 来源清单

# 来源 关键结论
1 证监会公告〔2025〕22 号(2026-01-01 施行) 申购费上限 22号文 §8、赎回费下限与全额计入基金财产 22号文 §10
2 上交所 501048 号公告(汇添富) 口径 A 补差费公式 + 完整算例;0.1 份阈值强制全转
3 中国证券报 2015-11-24(中欧) 口径 A + T+1 权益登记、T+2 可查询
4 中国证券报 2026-08-19(鹏华) 口径 B 外扣法 + 同销售机构 + 持有期自确认日重算
5 中国证券报 2026-07-08(国投瑞银) 口径 B 价外法两端差 + 单笔计算法 + 逐批计费
6 中国金融信息网(泰信/东海) 未知价法、先进先出、2 位四舍五入、误差在基金资产列支
7 上海证券报 2026-07-30 巨额赎回时转出与赎回同优先级;余额不足最低持有 → 强制赎回
8 中国证券报 2026-09-01(中银) 申请份额精确到 2 位、单笔 ≥1000 份、余额低于 1000 份须全部转出
9 深交所 43da8375(东方证券资管) 口径 A + 赎回费部分计入基金财产(旧规,已被 22 号文取代)
10 睿远 §65 / 华泰保兴 §69 / 东方 §57 / 国投瑞银 §33 赎回 = 份额赎回(投资者以份额申报)
11 农银汇理 / 泰信 / 华宝兴业「巨额赎回」条款 转出与赎回同优先级、可全额或部分转出、未确认部分顺延至下一开放日
12 东方 §59 个别管理人赎回费 2 位后截断(非四舍五入)

1. 设计原则

继承既有(不可违反):

  1. 分层 api(薄)→ service(业务)→ repository/tool;唯一例外 app/gateway/(模拟外部 Core 交易系统,仅此层可写 jinrong_core)
  2. 规则即纯函数(rules.py),便于单测与阈值调整
  3. 双库无跨库事务(jinrong_core / jinrong_agent 各自 engine)
  4. 审计表只 INSERT;trace_id 用 contextvars 贯通
  5. 不新增 ORM(app/model/entities.py 不动),沿用 text SQL + 返回 dict

本期新增:

  1. 计算与编排分离:折算/费率/舍入做成无 IO 纯函数(service/convert/calc.py),编排层只取数、调纯函数、管事务
  2. 受理/确认/撤单三段编排:受理段(Core 单库落单)、确认段(Core 单库批量串行扣减)、撤单段(Core 单库),各自独立事务
  3. 批次表必存 + 兜底补建:core_share_lot 覆盖全部交易类型;普通申赎有批次则 FIFO 扣/增,无批次则兜底补建(按 core_holding.as_of 建初始批次,lot_bootstrap 单点)
  4. 锁原语:统一 locks.run_locked(等待 + 降级双层锁)
  5. Decimal 一律显式 quantize,禁止裸浮点:金额/份额 2 位 ROUND_HALF_UP(默认),按产品 core_share_rule 的 rounding_mode/share_digits 可覆盖(D27);舍入误差在基金资产列支,响应可选回传 rounding_diff
    • ⚠️ Python Decimal.quantize() 默认 ROUND_HALF_EVEN(银行家舍入),必须显式传舍入方式
  6. 响应金额全部 str() 化 + 单点量化:库内一律 DECIMAL(18,4),对外必经 convert_service._q() 单点量化(金额/份额 2 位、净值/费率/尾差 4 位),响应、审计 summary、日志共用同一出口
  7. 单次转换最多跨 convert_batch_max_lots 个批次(默认 200):超限返回 HTTP 400 TOO_MANY_LOTS,防事务膨胀
  8. Core 读写账号物理分离:jinrong_core 拆只读账号与最小写权限账号;get_engine(database, role) 缓存键由「库名」改为「(库名, 角色)」(D20)
  9. 全差异按真实业务走:所有与真实不符的简化本期全部收敛(①⑧,§0.4 / D26D30)

2. 目录与文件划分

app/
├── api/
│   ├── simulate.py                      # 【改】TradeRequest 按 trade_type 分支校验;
│   │                                    #       subscribe=金额申购 / redeem=份额申报(D26) / convert=份额申报;
│   │                                    #       convert 调 convert_service;错误码映射 ApiError
│   └── convert_admin.py                 # 【新增】运维接口:确认触发 FR-C23 / 受理单查询 / 撤单
│
├── gateway/                             # 【core 库写侧唯一出口】
│   ├── trade_gateway.py                 # 【改】convert 分支:校验 → 受理(落 core_convert_request,202),折算移到确认段;
│   │                                    #       redeem 分支:份额申报 qty 入参,赎回金额=qty×净值−费;
│   │                                    #       subscribe/redeem 分支:增补批次维护(FR-C16)+ T+2 可赎回校验(FR-C25)
│   ├── gateway_repository.py            # 【改】insert_trade 增 qty / convert_group_id / cash_flow 两列写入
│   └── convert_core_repository.py       # 【改】仅 confirm_convert(确认事务:扣批次+写流水+lot_detail+holding+中转+cash_flow+置confirmed);
│                                        #       accept/cancel 归 convert_request_repository(受理单权威状态机)
│
├── service/
│   ├── convert/                         # 【新增包】
│   │   ├── types.py                     # 【类型】Lot / FeeRule / PlanResult / ShareRule,@dataclass(frozen=True)
│   │   ├── calc.py                      # 【纯函数】FIFO 分配 / 逐批计费 / 补差费(A/B)/ 份额折算 / 舍入(按产品)
│   │   ├── nav.py                       # 【纯函数】净值口径:确认时取 T 日净值(不回退)
│   │   ├── fee.py                       # 【纯函数】持有期→赎回费率查表(输入规则行,不查库)
│   │   ├── lot_bootstrap.py             # 【纯函数】批次补建规则(D8):gateway 兜底补建 与 rebuild_lots.py 同源
│   │   ├── errors.py                    # 【新增】convert 专属异常(继承 ApiError,§8.3)
│   │   ├── convert_service.py           # 【编排】受理段 + 确认段(含部分确认 D28)+ 撤单段 + 幂等 + 执行权 + 补偿日志
│   │   └── confirm_service.py           # 【新增】T+1 批处理确认编排(串行捞受理单 → 逐单 confirm_convert)
│   └── risk/
│       ├── rules.py                     # 【改】_amount_view();RISK-002/005 走金额视图
│       ├── engine.py                    # 【改】process_convert_event(out, in),确认事务提交后调用
│       ├── alert_service.py             # 【改】record_trade_alerts 增 events 可选参数
│       └── locks.py                     # 【改】仅 run_locked(双层锁)
│
├── repository/
│   ├── core_ro.py                       # 【改】增 get_nav_as_of / get_redeem_fee_rules / list_share_lots /
│   │                                    #       sum_remain_qty / get_holding / get_trade_calendar / get_share_rule
│   ├── share_lot_repository.py          # 【新增】core_share_lot 读:FIFO 选批 + 汇总
│   ├── convert_request_repository.py    # 【新增】core_convert_request 权威状态机仓储:accept_convert(受理事务 INSERT)/
│   │                                    #       cancel_convert(撤单事务置 cancelled)/ query_request(状态查询)
│   ├── convert_repository.py            # 【改】agent 库 risk_convert_detail:仅写「agent 侧详情写入进度」
│   └── risk_repository.py               # 【不改】复用 insert_audit_log
│
├── tool/
│   └── core_tools.py                    # 【改】query_recent_trades 汇总走 _amount_view;持仓查询过滤 qty <= 0
│
├── config/
│   └── settings.py                      # 【改】§11 全部 convert_* 配置项 + §11.1 四个 DB 账号(D20)
└── utils/
    └── db.py                            # 【改】get_engine(database, role="rw")(D20)

scripts/
├── core/
│   ├── 00-grant.sql                     # 【新增】3 应用账号 + 最小权限(D20)
│   ├── 01-ddl.sql                       # 【改】core_fee_rule / core_share_lot / core_convert_lot_detail /
│   │                                    #       core_convert_request(新增) / core_trade_calendar(新增) / core_cash_flow(新增 D29)
│   │                                    #       + core_product 加 share_class(D30) / rounding_mode / share_digits(D27) 等 8 列
│   ├── 07-seed-fee-rule.sql             # 【新增】赎回费持有期分档(5 档)
│   ├── 08-seed-share-lot.sql            # 【新增】按持仓反推批次
│   ├── 09-seed-org.sql                  # 【新增】fund_company / ta_code / subscribe_fee_rate
│   ├── 11-seed-share-rule.sql           # 【新增】真实产品舍入/位数规则,逐产品列 (share_digits, rounding_mode) 组合(D27)
│   ├── rebuild_lots.py                  # 【新增】按 core_holding 重建批次(快照重建,非交易回滚)
│   └── reset.ps1                        # 【改】$files 追加 07/08/09/10/11
├── agent/
│   └── cleanup_pending_convert.py       # 【改】扫描 core_convert_request 超 SLA 的非终态单(accepted/nav_pending)置 expired
├── dev/
│   └── calc_convert_demo.py             # 【改】与生产同一套 calc.py 实算主示例/同费率对照,回填 PRD §5.3 与验收断言
└── demo/
    └── rebuild_alerts.py                # 【改】支持 --convert-group 按 Core 侧数据补写详情(D6)

不新增:ORM 实体、app/service/risk/ 新规则(RISK-001~006 口径不变,仅金额视图去重)。


3. 核心时序(受理段 + 确认段 + 撤单段)

POST /api/simulate/trade  {trade_type: "convert", from_product_id, to_product_id, qty, client_request_id?}
  │
  ├─ ①  参数与产品校验(不落库):from != to · can_redeem / can_subscribe · fund_company+ta_code 同机构(含 share_class 同基金判定 D30)
  ├─ ②  受理日判定(15:00 截点 + 交易日历 → T)
  ├─ ③  可用份额校验(不落库):Σ remain_qty − Σ(未终态受理单 requested_qty) + 最低份额(含全额豁免)
  ├─ ④  适当性校验(转入端,受理时校验一次留痕含测评版本号)
  ├─ ⑤  受理事务(Core 单库):INSERT core_convert_request(status='accepted',含 accept_date/confirm_date/available_date)
  │      撞 uk_idem → 返回首次受理回执(幂等);其他失败 → 整体回滚
  └─ ⑥  返回 202 + 受理回执(不含任何金额/净值/折算字段)

POST /api/simulate/trade/convert/{gid}/cancel   (T 日 15:00 前)
  │
  ├─ ①  校验受理单存在 + 状态 accepted/nav_pending
  ├─ ②  当前时间 ≤ T 日 convert_cutoff_time(15:00)
  ├─ ③  撤单事务(Core 单库):置 cancelled + cancelled_at;占用自然释放
  └─ ④  落审计 decision='convert_cancelled'

POST /api/simulate/trade/convert/confirm?accept_date=T   (运维/批处理 FR-C23,指定业务日)
  │
  ├─ run_locked("convert:confirm:{T}")   # 批处理串行锁(D22)
  ├─ 捞 core_convert_request WHERE accept_date=T AND status∈(accepted, nav_pending) ORDER BY accepted_at
  └─ 逐单 confirm_convert(见下)

confirm_convert(单笔确认事务,Core 单库):
  ├─ 1  取两端 T 日净值;缺失 → 置 nav_pending,本轮跳过(不 reject、不降级)
  ├─ 2  复核产品可申赎状态 + 适当性(转入端,D25)→ 不满足 → rejected + 释放占用
  ├─ 3  plan_lots(FIFO + 最低持有处置)+ 折算(calc.py,口径 B)
  ├─ 4  【可用份额不足 D28】若申请份额 > 可用份额(部分成交场景):实际成交 = min(申请, 可用);
  │       未确认部分占用释放(不顺延至后续批处理,属投资者层建模取舍;基金层巨额赎回为顺延);记录 partial 标记
  │       (部分成交 = 受理段③占用校验之外的二次兜底,覆盖 T 日~T+1 间其它在途单抢占致可用份额变少的场景)
  ├─ 5  确认事务(Core 单库):扣批次(条件 UPDATE 哨兵)+ 写两条流水 + core_convert_lot_detail
  │      + 更新两端 core_holding + 写 core_cash_flow(中转,D29)+ 置 confirmed
  │      失败 → 整体回滚 → 保持 accepted,下轮重试(幂等)
  ├─ 6  确认后同步跑规则引擎(一次):process_convert_event(out, in);异常落 engine_error + 本地日志,不阻断
  └─ 7  agent 侧写详情 + 主审计(附加写入):失败不回滚 Core,落待补偿(SLA 24h)

引擎时机:受理时无金额(净值未公告),规则引擎金额类规则无输入;确认后跑,读到已更新持仓,集中度规则(RISK-006)连带效应仍被覆盖。引擎只调用一次,传入两条流水。


4. 关键技术决策(D1~D30)

# 决策点 选型 备选 理由
D1 折算计算放哪 service/convert/calc.py 纯函数 内联在 service 金额逻辑最易错,必须可穷举单测
D2 批次读写分工 读 → share_lot_repository;写 → convert_core_repository(确认段同事务) 全塞一个 repository 写必须与两条流水同事务;读需在事务外预检
D3 两段事务实现 受理事务 convert_request_repository.accept_convert + 确认事务 convert_core_repository.confirm_convert,各 with engine.begin() 单库单方法 单事务实时扣减 受理单下沉 Core 后两段各自同库,单事务闭合(PRD §7.1)
D4 锁原语 统一 run_locked(双层锁,抢不到返回 202+已有 group_id) 一次性 try_lock 语义与「抢不到立即 202」一致
D5 引擎只跑一次 process_convert_event(out, in),确认后调用 给现有函数加参数 语义清晰
D6 补偿数据来源 core_convert_lot_detail 含 nav/nav_date,rebuild_alerts 仅凭 Core 侧补写 靠 agent 库残留 附加写入失败 agent 库可能为空,Core 侧必须自包含
D7 金额去重 rules._amount_view():同 convert_group_id 组内保留转出端 删行/求和 组内无 redeem 时取第一条(防御)
D8 普通申赎批次维护 兜底补建:无批次 → 按 core_holding.as_of 补建 跳过/报错 P1 已拍板
D9 core_holding 同步 UPSERT;转出后 qty=0 保留行 删除行 P2 已拍板
D10 净值查询 core_ro.get_nav_as_of(pid, trade_date)(nav_date <= :d 降序取 1) 改 get_latest_nav 现有方法无条件取最新,改它动既有调用方
D11 费率来源 运行时读 core_fee_rule + core_product.subscribe_fee_rate 硬编码 数据驱动
D12 请求模型 扩展 TradeRequest(trade_type 分支校验) 新增 ConvertRequest 端点契约不变
D13 补差费公式 口径 B(价外法两端差)为默认,A 配置保留 只用 A 已拍板
D14 T+1 确认 confirmed_at = T+1 交易日(core_trade_calendar 推算),不用自然日近似 自然日近似 真实业务 T+1 为工作日
D15 最低持有余额处置 两种都实现(force_transfer/force_redeem),默认 force_transfer 只做强制全转 已拍板
D16 赎回费归属 to_fund_ratio = 1.0(22 号文 §10 全额计入),不参与计算 不建模 字段留痕
D17 规则引擎时机 确认事务提交后同步跑(受理时无金额) 受理后跑 受理段无金额,引擎无输入(§3)
D18 批次补建规则复用 纯函数 lot_bootstrap.bootstrap_lots(),gateway 与 rebuild_lots 同调 两处各写 防漂移
D19 引擎异常钩子预留 process_convert_event(..., on_error_hook=None) 写死 二期可插拔
D20 Core 读写账号分离 xh_core_ro/xh_core_rw/xh_agent_rw + get_engine(db, role) 单 root 主架构 C1 已拍板
D21 受理单落 Core 库 core_convert_request(权威状态机)落 jinrong_core 落 agent 库 在途占用须与 core_share_lot 同库单 SQL 闭环;幂等锚点 uk_idem 与流水同库消除「重跑第二组流水」
D22 批处理串行锁 run_locked("convert:confirm:{accept_date}") 无锁/各单并发 防两个批处理同时跑同一受理日;锁 + 状态二次校验双保险
D23 全量重写 删「T 日实时扣减」路径,无双模型开关 双模型并存 用户拍板:全量重写 T+1,口径统一无漂移
D24 确认触发 运维接口 FR-C23 + 脚本(手动指定业务日),语义批量串行;不引 apscheduler(模拟库无真实时钟,二期可接) 引入调度器 用户拍板「按真实业务」:登记机构语义=批量串行自动确认;落地为手动接口+脚本模拟批处理
D25 适当性 T+1 复核 确认段第 2 步加 suitability_check(转入端),不通过 → rejected(份额不变) 不复核 用户拍板「T+1 复核」
D26 redeem 份额申报 redeem 入参改 qty(份额);赎回金额 = qty × T日净值 − 赎回费;加 T+2 可赎回校验(FR-C25) 金额反算份额(与真实相反) 用户拍板「按真实业务走」:真实=份额赎回(睿远 §65 等)
D27 按产品舍入/位数 core_share_rule(product_id + business_type) 配置(易方达 FOF 转换转入 2 位、ETF 场外整数位,须产品级区分);rounding_mode ∈ {HALF_UP, TRUNCATE(=ROUND_DOWN)}(东方/南方截断=ROUND_DOWN,须与种子一致);11-seed-share-rule.sql 逐产品列 (share_digits, rounding_mode) 组合,禁错配 统一 HALF_UP/2 位 用户拍板「都按真实业务走」:真实不统一,主流口径不算「对齐真实」
D28 可用份额不足部分确认 确认段支持部分成交(实际=min(申请,可用))+ 未确认部分占用释放(不顺延至后续批处理,属投资者层建模取舍;真实基金层巨额赎回为「部分延期赎回/顺延」,不可混淆) 不做(无份额池) 用户拍板:真实规则(可用份额不足部分成交)不可因模拟简化「就算了」;基金层同比例确认需另引现金头寸,本期不做
D29 资金流中转建模 Core 侧建 core_cash_flow(注册登记账户中转),一次转换 1 笔资金划转 不建模(core_trade 记两份金额) 用户拍板:真实资金流经中转账户,模拟层须还原语义
D30 A/C 份额互转 core_product 加 share_class 列 + allow_ac_convert TINYINT DEFAULT 0 + seed 真实产品标注;放开同基金 A/C 互转(须 allow_ac_convert=1,易方达 FOF 等公告暂不开通) 无 share_class 不做 用户拍板:真实产品有 A/C 份额,是 seed 未建列,非真实业务没有

5. 事务、并发与补偿

5.1 三段 + 引擎 + SQL 清单

段 库 入口 SQL / 动作
受理 core convert_request_repository.accept_convert 1×INSERT core_convert_request(uk_idem 兜底)
确认 core convert_core_repository.confirm_convert(单事务) 扣批次(条件 UPDATE)+ 2×INSERT core_trade + core_convert_lot_detail + 2×UPSERT core_holding + core_cash_flow(中转)+ SET confirmed
撤单 core convert_request_repository.cancel_convert 1×UPDATE core_convert_request SET cancelled
引擎 — engine.process_convert_event(out, in) 确认事务提交后;异常落 engine_error,不阻断

受理/确认/撤单任一 SQL 失败 → 整体回滚(with engine.begin() 异常即 rollback)。

5.2 重试判定(仅在取得执行权后)

-- 受理段:查受理单状态
SELECT status FROM core_convert_request WHERE convert_group_id = :gid;
-- 确认段:查是否已有流水(幂等,不产生第二组)
SELECT 1 FROM core_trade WHERE convert_group_id = :gid LIMIT 1;

走 core_ro(只读)。依赖索引(idx_confirm_queue / core_trade.idx_convert_group),否则全表扫。

5.3 锁原语(统一 run_locked)

run_locked(f"convert:avail:{cid}:{out_pid}", lambda acquired: ...)   # 受理段可用份额短临界区
run_locked(f"convert:idem:{client_request_id}", lambda acquired: ...) # 幂等执行权(抢不到返回 202)
run_locked(f"convert:confirm:{accept_date}", lambda acquired: ...)    # 批处理串行
run_locked(f"convert:rerun:{gid}", lambda acquired: ...)              # agent 详情补写串行

确认段扣批次哨兵(并发安全):必须用条件 UPDATE + rowcount 断言,禁止先 SELECT 后 UPDATE:

UPDATE core_share_lot SET remain_qty = remain_qty - :q
 WHERE lot_id = :id AND remain_qty >= :q;   -- rowcount == 1 才成功;否则 LOT_CONFLICT(409) 让路,不重扣

5.4 补偿(一期:监控告警 + 人工脚本,SLA 24h)

项 口径
触发者 risk_convert_detail.status='failed' 或 core_convert_request 超 SLA 非终态的巡检/告警
补偿工具 scripts/demo/rebuild_alerts.py --convert-group(Core 侧自包含补写)
SLA convert_confirm_sla_days=2(交易日,T+1 必确认 + 净值延迟宽容)
兜底清理 cleanup_pending_convert.py 把超 SLA 的 accepted/nav_pending 置 expired(标记不硬删,释放占用)

幂等锚点:convert 补偿以转出端 out_trade_id 为锚点(复用现有 find_alerts_by_trade)。


6. 规则引擎改造(FR-C11 / FR-C15)

6.1 _amount_view

def _amount_view(trades: list[dict]) -> list[dict]:
    """同 convert_group_id 组内只保留转出端(trade_type='redeem')。无 gid 原样通过。"""

run_rules 内部:eligible 供 RISK-001/003/004;amount_view(eligible) 供 RISK-002/005。RISK-006 读持仓快照(确认后已更新)。

6.2 一次转换 = 一条预警事件

def process_convert_event(out_trade, in_trade, *, core_ro=None, risk_repo=None,
                          thresholds=None, on_error_hook=None) -> dict:
  • 确认事务提交后调用,传入两条流水
  • record_trade_alerts(primary=out_trade, hits, events=[event_of(out), event_of(in)]) → 一张单、payload.events 两条

7. 计算口径落地(纯函数签名)

类型定义:Lot / FeeRule / PlanResult / ShareRule 统一放 service/convert/types.py,@dataclass(frozen=True)。

# service/convert/calc.py
def plan_lots(lots, requested_qty, min_hold_qty, min_hold_action) -> PlanResult: ...
def lot_amount(qty, nav) -> Decimal:                      # 2 位(默认,可由 share_digits 覆盖)
def lot_fee(amount, rate, rounding_mode="HALF_UP") -> Decimal:  # 按产品 rounding_mode(D27)
def pick_fee_rate(rules, hold_days) -> Decimal:           # 分档匹配(左闭右开)
def convert_amount(out_amount, redeem_fee) -> Decimal: ...
def diff_fee(conv_amount, out_rate, in_rate, mode="amount_diff") -> Decimal:  # 口径 B 默认(D13)
def in_qty(in_amount, in_nav, share_digits=2) -> Decimal: # 按产品 share_digits(D27)
def hold_days(redeem_confirm_date, confirmed_at) -> int: ...  # 持有期按自然日计(赎回确认日−申购确认日),非交易日;
    # 第一参须传本次转换受理单的确认日 T+1(即赎回 leg 被确认之日),严禁传受理日 T,亦严禁传被赎批次的
    # 申购 confirmed_at——否则边界少算 1 天(如真实满 7 日算成 6 日→误收 1.5%);口径详见 PRD §2.1.3;
    # 单测须覆盖满 7/30/180 日边界
def rounding_diff(in_amount, in_nav, actual_in_qty, places=4) -> Decimal: ...
def ensure_batch_limit(plan, max_lots) -> None: ...
def redeem_amount(qty, nav, fee_rate, rounding_mode="HALF_UP") -> Decimal:  # D26:份额赎回金额
def partial_confirm(requested, available) -> tuple[Decimal, bool]:          # D28:部分确认

赎回费分档(区间左闭右开 [min_hold_days, max_hold_days))

档 min max rate 依据
< 7 日 0 7 0.0150 22 号文 §10 下限
7–30 日 7 30 0.0100 22 号文 §10 下限
30–180 日 30 180 0.0050 22 号文 §10 下限
180–365 日 180 365 0.0025 合同约定
≥ 365 日 365 NULL 0.0000 合同约定

费率档说明:<7/7–30/30–180 三档为 22 号文 §10 下限(法定最低),真实产品可高于下限;本 seed 取下限演示(to_fund_ratio=1.0 全额计入)。180 日以上为合同约定档。

core_holding 更新口径(PRD FR-C13)

端 qty cost_amount market_value as_of pnl_pct
转出 qty − actual_qty cost × (1 − actual/原qty) 新qty × out_nav nav_date (mv−cost)/cost
转入 qty + in_qty cost + in_amount 新qty × in_nav nav_date 同上

FIFO 选批 SQL(确定性 tiebreaker)

SELECT lot_id, confirmed_at, remain_qty
  FROM core_share_lot
 WHERE customer_id = :cid AND product_id = :pid AND remain_qty > 0
 ORDER BY confirmed_at ASC, lot_id ASC
 LIMIT :max_lots;

8. 接口契约落地

8.1 请求模型(api/simulate.py)

class TradeRequest(BaseModel):
    customer_id: str
    product_id: str | None = None              # subscribe 必填
    trade_type: str                             # subscribe | redeem | convert
    amount: Decimal | None = None               # subscribe 必填(金额申购)
    from_product_id: str | None = None          # convert 必填
    to_product_id: str | None = None            # convert 必填
    qty: Decimal | None = None                  # convert 必填(份额申报);redeem 必填(份额赎回,D26)
    client_request_id: str | None = None        # 幂等键(选填)
    @model_validator(mode="after")

subscribe = 金额申购;redeem = 份额申报(D26);convert = 份额申报。

8.2 响应

convert 受理成功返回 HTTP 202 Accepted(受理回执,不含金额/净值);确认结果 T+1 后由查询接口返回。redeem 成功返回确认结果(份额申报,金额由 qty × 净值 − 费 算出)。

8.3 错误码映射

异常 HTTP error_code
ProductNotRedeemable 400 PRODUCT_NOT_REDEEMABLE
ProductNotSubscribable 400 PRODUCT_NOT_SUBSCRIBABLE
InsufficientShares 400 INSUFFICIENT_SHARES(含在途占用)
BelowMinQty 400 BELOW_MIN_QTY
SameProduct 400 SAME_PRODUCT
CrossEntityNotSupported 400 CROSS_ENTITY_NOT_SUPPORTED(含跨 share_class 同基金判定 D30)
TooManyLots 400 TOO_MANY_LOTS
NoTradingDay 400 NO_TRADING_DAY
CancelNotAllowed 409 CANCEL_NOT_ALLOWED(撤单超窗口/已确认)
LotConflict 409 LOT_CONFLICT(确认段条件 UPDATE 哨兵)
IdempotencyUnavailable 503 IDEMPOTENCY_UNAVAILABLE
未抢到执行权 202 返回 {convert_group_id, status: "processing"}
幂等命中 completed 200 返回首次结果

异常类定义在 app/utils/exceptions.py + service/convert/errors.py。

reject_reason 取值(SUITABILITY_FAILED 等,确认段失败原因)见 PRD §5.6;本节 error_code 为 HTTP 层错误码,两层不混用。

8.4 确认触发接口(FR-C23,运维/批处理)

POST /api/simulate/trade/convert/confirm?accept_date=2026-09-09

指定业务日,串行捞 accepted/nav_pending 受理单逐单确认。手动触发(D24),模拟登记机构批处理。

8.5 撤单接口(FR-C22)

POST /api/simulate/trade/convert/{convert_group_id}/cancel

T 日 15:00 前 + 状态 accepted/nav_pending → 200 置 cancelled 释放占用;否则 409 CANCEL_NOT_ALLOWED。


9. 数据模型落点清单(DDL 与种子)

对象 落点文件 动作
core_fee_rule 01-ddl.sql 新建(to_fund_ratio)
core_share_lot 01-ddl.sql 新建
core_convert_lot_detail 01-ddl.sql 新建(nav/nav_date;完整列集含 hold_days/fee_rate/lot_count 等,详见 PRD §4.1)
core_convert_request 01-ddl.sql 新建(权威状态机,6 态 ENUM)
core_trade_calendar 01-ddl.sql 新建(交易日历)
core_cash_flow 01-ddl.sql 新建(D29,注册登记账户中转);列:convert_group_id / from_account / to_account / amount DECIMAL(18,4) / direction(枚举 OUT_TO_TA/TA_TO_IN/NET,落地单笔 NET 行 amount=in_amount)/ trade_date;「一次转换 1 笔资金划转」为建模假设(非法规明文);写入方=confirm_convert,读取方=报表/重建(core_ro)
core_trade.convert_group_id + KEY 01-ddl.sql 改表
core_product 01-ddl.sql 加列(合计 8 列,含 share_class(D30) + allow_ac_convert TINYINT DEFAULT 0(D30 开关) + rounding_mode/share_digits(D27) + can_subscribe/can_redeem + min_hold_qty/min_redeem_qty + subscribe_fee_rate + fund_company/ta_code + fee_rate COMMENT)
risk_convert_detail 02-mysql-agent专用.sql 新建(status ENUM 5 值,语义收窄为 agent 写入进度)
core_share_rule 11-seed-share-rule.sql 新建(D27,key=product_id+business_type,逐产品 (share_digits, rounding_mode) 组合)
赎回费分档种子 07-seed-fee-rule.sql 新建
份额批次种子 08-seed-share-lot.sql 新建
机构 + 申购费率种子 09-seed-org.sql 新建(含 share_class 标注 D30)
reset 流程 reset.ps1 $files 追加 07/08/09/10/11

D25 数据落点(适当性 T+1 复核):投资者风险等级 + 测评版本号取自既有投资者信息表(非新建表);落地前须确认该表已含 risk_level + assess_version 列,否则在 01-ddl.sql 补建并写入本清单。

种子文件版本注释约定(PRD L-6):新增种子 SQL 文件头带版本注释块。

sqlite 测试 DDL 同步(tests/_ddl.py):5 新表 + core_product 新列;test_db.py::test_core_holding_columns 启动期列名断言。


10. 测试策略

层 文件 覆盖点 预计用例
纯函数 test_convert_calc.py FIFO / 分档费率 / 补差费双口径 / 舍入(含按产品 rounding_mode/share_digits D27)/ 部分确认 D28 / T+1 起算 ~40
受理编排 test_convert_accept.py 受理段 6 步 / 可用份额校验(含在途占用)/ 幂等 / 202 回执 ~15
确认编排 test_convert_confirm.py 确认段 7 步 / 批量串行 / 部分确认 D28 / nav_pending 挂起 / 适当性 T+1 复核 D25 / 资金流中转 D29 ~25
撤单 test_convert_cancel.py 15:00 前撤单 / 超窗口 409 / 占用释放 ~8
赎回份额申报 test_redeem_qty.py redeem 份额申报(D26)+ T+2 校验 FR-C25 ~10
并发 test_convert_concurrency.py 可用份额短临界区 / 批处理串行 / 同键 202 / 50 并发压测 ~10
批次 test_share_lot.py 普通申赎批次维护 + 兜底补建 + rebuild_lots ~12
集成 test_convert_integration.py 真 MySQL,端到端折算与 §7 calc.py 纯函数签名 + calc_convert_demo 实算回填吻合 ~6
回归 test_trade_gateway.py 补批次维护 + redeem 份额申报断言;现有用例全绿 +10

基线:现有全量 → 预计 650~700。

50 并发压测:同一 (customer, product) 上 50 并发争抢可用份额(短临界区),断言不超卖 + confirm 串行无重复流水。


11. 配置新增(app/config/settings.py)

项 默认 说明
convert_cutoff_time "15:00" 受理截点(不得硬编码)
convert_confirm_sla_days 2 超 SLA 置 expired(交易日)
convert_batch_max_lots 200 超 200 触发;返回 HTTP 400 TOO_MANY_LOTS(400 为 HTTP 状态码、非阈值,阈值=200)
convert_diff_fee_mode "amount_diff" 补差费口径 B 默认(D13)
convert_lock_ttl_seconds 30 锁 TTL
convert_nav_stale_days 3 退役(不再回退取旧净值)
mysql_core_ro_user / mysql_core_rw_user / mysql_agent_user "" D20 三账号(空回退 root)

11.1 DB 账号与最小权限(D20)

xh_core_ro(SELECT)/ xh_core_rw(4 表写,扩展至 core_convert_request/core_cash_flow)/ xh_agent_rw(audit 仅 INSERT);get_engine(db, role)。


12. 风险与应对

# 风险 应对
1 sqlite/MySQL DDL 列名失配 T-0 先统一 + 启动断言
2 批次补建规则写两处 → 漂移 D18 抽纯函数同调
3 Decimal 默认 HALF_EVEN 所有 quantize 显式传舍入(按产品 rounding_mode 覆盖)
4 引擎异常不丢预警 不阻断、落 engine_error + 本地日志(D19 钩子预留)
5 批次数超限 400 不自动分拆
6 双库不一致 两段 Core 单库事务 + agent 附加 + 补偿
7 锁 TTL 被确认段超时突破 锁 + 状态二次校验双保险;批处理串行
8 redeem 改份额申报打穿现有用例 D26:redeem 接口/网关/折算全改,现有申赎用例重跑基线
9 部分确认语义复杂 D28:确认段部分成交 + 占用释放 + 不顺延,单测覆盖
10 按产品舍入/位数配置 D27:core_share_rule(product_id+business_type) 表 + calc 按产品取,老产品缺规则回退主流 2 位 HALF_UP(显式标注)

13. 拍板结论与溯源

13.1 关键拍板(2026-09-11)

# 事项 拍板 依据
1 模型策略 全量重写 T+1(T 日受理/确认分离) 用户拍板(D23)
2 确认触发 运维接口 + 脚本(不引调度器) 用户拍板「按真实业务」(D24)
3 适当性 T+1 复核 复核 用户拍板(D25)
4 SLA 2 交易日 用户拍板「按真实业务」(D24 派生)
5 redeem 份额申报 本期收敛(份额赎回) 用户拍板「按真实业务走」(D26)
6 全差异收敛 ①~⑧ 全部本期对齐真实 用户拍板「都按真实业务走」(D26~D30)

13.2 独立评审结论

架构 v2.1 精简重写版已通过独立 AI 联网审查(0 阻断级,5 项外部事实联网核验全部与文档相符),已定稿(2026-09-11 用户批准)。第 4 步开发计划 v2.0 已按本版 §15 任务映射落地(docs/项目框架设计/开发计划-基金转换交易.md,经三轮独立审核闭环:挑毛病 / 验证型 / 白纸重审 + 终验)。


14. 设计自检十三问复核

# 问 本期答案
1 新增表/字段谁写? core_convert_request(convert_request_repository)/ core_cash_flow(confirm_convert)/ core_share_lot(确认段+普通申赎)/ risk_convert_detail(agent 写入进度)/ share_class/rounding_mode/share_digits(seed 写入,代码只读)
2 谁读? core_ro(批次/净值/费率/持仓/日历/产品规则)/ rules._amount_view / core_tools / rebuild_alerts / cleanup_pending_convert
3 枚举/常量 DDL? core_convert_request.status ENUM 6 值(建表即全量);risk_convert_detail.status ENUM 5 值(agent 进度);audit_log.decision VARCHAR
4 事务跨库? 两段 Core 单库事务 + agent 附加(补偿)
5 种子数据? 06~11 种子(含真实产品舍入规则 D27、share_class D30)
6 并发安全? 可用份额短临界区(run_locked)+ 确认条件 UPDATE 哨兵 + 批处理串行锁 + 幂等锚点同库
7 汇总语义唯一? _amount_view 取转出端;out/convert/in_amount 语义分立
8 向后兼容? core_trade 加可空列;core_product 新列有 DEFAULT;get_engine(role) 默认回退
9 示例自证? §7 纯函数签名(calc_convert_demo 实算回填,禁手算)
10 外部事实核验? §0.1/§0.2/§0.5 列 22 号文 + 12 个来源条目;份额赎回/巨额赎回/资金流/舍入差异均有出处
11 重置类伴随数据? cleanup_pending_convert 扫描 core_convert_request;reset.ps1 显式清单
12 类型与口径匹配? 申购费率按 product_type 取档(PRD §4.3);share_class 同基金判定(D30)
13 同一规则/公式单副本? 折算/费率/舍入抽 calc.py 纯函数;lot_bootstrap 单点;格式出口 _q 单点

二补 · 格式契约三问

# 问 答案
补-1 展示位数/精度写在哪? §1 设计原则#10/#11 + §7 calc.py + convert_service._q():金额/份额 2 位(按产品 share_digits 覆盖 D27)、净值/费率/尾差 4 位
补-2 同一逻辑数据产出路径几条? 首次公式算 / 库 DECIMAL(18,4) 重建 → 均经 _q() 单点,逐字节一致
补-3 格式出口单点? 响应/审计/日志共用 _q();禁止裸 str(Decimal) 出网

新持久对象谁清理:core_convert_request 生命周期 = accepted → confirmed/rejected/cancelled/nav_pending → expired;非终态超 SLA 由 cleanup_pending_convert.py 置 expired 释放占用(功能正确性要求,非可选运维)。


15. 与第 4 步的衔接(任务映射)

任务 内容 依赖 说明
T-0 ✅ 列名统一 + 启动断言 无 已完成
T-0b ✅ DB 账号分离(D20) 无 已完成
T-1 DDL + 种子(含 core_convert_request / core_trade_calendar / core_cash_flow / share_class / rounding_mode / share_digits / 11-seed-share-rule) T-0+T-0b 新增 5 表 + 8 列 + 2 种子
T-2 service/convert/ 纯函数(含 D27 按产品舍入/位数、D28 partial_confirm、D26 redeem_amount) 无 扩展 calc.py
T-2b calc_convert_demo 实算回填 T-2 示例随真实净值重算
T-3 core_ro 新方法 + share_lot_repository + convert_request_repository T-1 新增受理单仓储
T-4 convert_repository(agent 进度) T-1 语义收窄
T-5 run_locked 复用 无 统一锁原语
T-6 accept_convert(受理事务) T-1/T-3 受理单 INSERT
T-7 convert_service 编排(受理/确认/撤单三段)+ confirm_service 批处理 T-2~T-6 核心编排
T-8 rules._amount_view + engine.process_convert_event(确认后跑) 无 引擎时机改
T-9 api/simulate.py 模型(redeem 份额申报 D26)+ 撤单/确认接口 + trade_gateway 分派 T-7 redeem 改份额申报
T-10 普通申赎批次维护 + T+2 校验(FR-C25)+ rebuild_lots T-3 扩展
T-11 core_tools 汇总去重 + 持仓过滤 T-8 沿用
T-12 补偿脚本 + cleanup_pending_convert(扫 core_convert_request) T-4/T-7 改写扫描对象
T-13 全量回归 + 集成 + 50 并发 + 性能补录 全部 基线重估
T-14 D27 按产品 core_share_rule(product_id+business_type) 舍入/位数 seed + calc 接入 T-1/T-2 新增
T-15 D28 投资者层部分确认 T-7 新增
T-16 D29 资金流中转 core_cash_flow 建模 T-6 新增
T-17 D30 share_class + A/C 互转 T-1/T-9 新增

收尾说明:本版落实 PRD v1.1「下游同步令」+ 用户四项拍板(全量重写 / 确认触发 / T+1 适当性复核 / SLA)+「全差异按真实业务走」硬指令(①~⑧ 收敛)。已落地且复用的资产(D20 / T-0 / T-0b / 纯函数包)继续生效。第 4 步开发计划 v2.0 已按本表 §15 落地(docs/项目框架设计/开发计划-基金转换交易.md,三轮独立审核闭环);本架构 v2.1 经独立 AI 联网审查通过,已定稿(2026-09-11 用户批准)。