Files
group_xinghuo_jinrong/docs/PRD/PRD-基金转换交易.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

66 KiB
Raw Blame History

PRD · 基金转换(convert)交易

版本:v1.1(精简最终态 · T+1 受理/确认分离模型) · 2026-09-11 分支:risk-control-agent 阶段:第 2 步产出(v1.1 精简重写已通过独立 AI 联网审查,已定稿(2026-09-11 用户批准)) 关联:docs/项目框架设计/架构设计-基金转换交易.md(v2.1) 合规基准:证监会公告〔2025〕22 号《公开募集证券投资基金销售费用管理规定》(2026-01-01 施行) + 汇添富/中欧/鹏华/国投瑞银/东海/华商/金鹰/财通/中银/东方证券资管 10 家转换业务公告(来源清单见架构 §0.5)

1. 背景与目标

1.1 现状

trade_type 一期只接受 subscribe/redeem,convert 在 app/gateway/trade_gateway.py:112 被显式拒绝(HTTP 400)。数据库已支持:core_trade.trade_type ENUM 含 convert,qty 字段已预留。属能力收窄。

1.2 为什么要放开

真实业务中转换是常见业务,项目要贴近真实业务(用户拍板)。

1.3 目标

按真实基金转换业务语义实现:份额申请、净值折算、逐批次赎回费 + 申购补差费、转出视同赎回、转入视同申购、先进先出、持有期重置、持仓同步;受理/确认分离(T 日受理不扣份额、T+1 批量确认、T+2 可用)。

1.4 非目标

项 说明
跨 TA / 跨销售机构转换 真实业务支持,一期不做
基金层巨额赎回同比例确认 需基金级现金头寸,本期不做;仅做投资者层部分成交(§2.6.5)
后端收费模式补差费 只做前端
司法 / 有权机关冻结 真实业务由人工柜台办理(华安 §90 / 富达 §46),非系统自动行为,明确不做
普通赎回计费 本期普通赎回只维护批次、不驱动赎回费计费;转换转出才计费(§4.4 口径差异)

2. 业务口径

2.1 计算链路

转出金额 = Σ(各批次份额 × 转出基金成交净值)          ← 逐批次
赎回费   = Σ(各批次金额 × 该批次适用赎回费率)        ← 逐批次
转换金额 = 转出金额 − 赎回费
补差费   = 价外法两端差(口径 B,§2.1.1)
转入金额 = 转换金额 − 补差费
转入份额 = 转入金额 ÷ 转入基金成交净值(按产品舍入,§2.5)

与真实业务公告口径一致:转出视同赎回、转入视同申购、未知价法(以申请受理当日 T 日净值为基准)、单笔计算法(当日多笔转换各算各的,不合并)。

2.1.1 补差费口径

本项目采用**口径 B(价外法两端差)**为默认:

口径 公式 使用方
B · 价外法两端差(默认) 补差费 = Max[转换金额 × 转入费率 ÷(1+转入费率) − 转换金额 × 转出费率 ÷(1+转出费率), 0] 国投瑞银、东海、华商、金鹰、财通证券资管、鹏华(新版)
A · 费率差法(配置保留) 补差费率 = max(转入申购费率 − 转出申购费率, 0);补差费 = 转换金额 × 补差费率 ÷(1+补差费率) 汇添富、中欧、财通基金、东方证券资管

为什么选 B:中国公募申购费的标准算法本身就是价外法(申购费 = 申购金额 × 费率 ÷(1+费率))。「两端申购费之差」按 B 计算才与申购费算法自洽;B 亦为新近公告的主流写法。A 以配置项 convert_diff_fee_mode = "rate_diff" 保留,两种口径均有用例覆盖。

数值差异(必须知晓):转换金额 10000、转入 1.5%、转出 0.8% 时,A = 69.51,B = 68.41(差 ~1.6%)。

2.1.2 补差费率来源

申购费率取自 core_product.subscribe_fee_rate(§4.2 新增列,单一费率 · 模拟假设),运行时计算,不落 core_fee_rule。core_fee_rule 的 fee_type='subscribe' 金额分档本期不启用,仅为真实 Core 接入后的扩展位预留。

⚠️ 补差费由两端产品的 subscribe_fee_rate 决定,不得取 fee_rate(后者是管理费率,语义不同,见 §4.2 的 COMMENT)。

2.1.3 赎回费分档

持有期(自然日) 赎回费率 依据
< 7 日 1.50% 22 号文 §10 法定下限
7 日(含)–30 日 1.00% 22 号文 §10 法定下限
30 日(含)–180 日 0.50% 22 号文 §10 法定下限
180 日(含)–365 日 0.25% 基金合同约定(法规无强制下限;真实常见档)
≥ 365 日 0 基金合同约定(真实多数基金满 1 年免赎回费)

区间口径:左闭右开 [min, max) —— 满 7 日归 1.0% 档,满 30 日归 0.5% 档,满 180 日归 0.25% 档。上表 <7/7–30/30–180 三档为 22 号文 §10 下限(法定最低),真实产品可高于下限;本 seed 取下限演示。hold_days 定义见 §12。

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

2.2 净值取值

受理环节完全不取净值,净值只在 T+1 确认时取用 —— 与真实业务「T 日净值 T+1 才公告」吻合。

环节 是否取净值 口径
T 日受理 ❌ 不取 只校验份额/产品/适当性;响应不含任何金额与净值字段
T+1 确认 ✅ 取 T 日净值(转出端、转入端各一只) 命中 core_product_nav (product_id, nav_date = T)
缺 T 日净值 — 受理单置 nav_pending,不 reject、不降级取旧净值;净值补齐后继续确认(§2.6.5)

不回退取最近一条:未知价法的成交价就是 T 日净值,拿 T−1 净值折算的金额是错误的成交价。真实业务里净值没公告就不确认,而不是用旧值先算一个。

种子可用性:06-seed-nav.sql 为联网下载的真实净值序列(由 scripts/core/fetch_nav.py 生成,覆盖 2026-06-18 起 60 个净值发布日),可用同一脚本增量补拉(真实净值只到抓取当日,之后运行需重跑)。

2.3 逐批次计费与汇总口径

一次转换可能跨多个批次,各批次持有天数不同。

  • 逐批次计算持有天数与赎回费,明细落 core_convert_lot_detail
  • 响应返回:redeem_fee(总额)+ lot_count(批次数)+ convert_group_id(明细指针);不返回汇总 hold_days(跨批次无单一值)

2.4 资金流特征

转出基金赎回款 → 注册登记账户 → 转入基金,不经投资者账户。这是「权益层记 2 笔、资金层计 1 笔」的依据。

模拟层建模:模拟库无真实注册登记账户,Core 侧建 core_cash_flow 中转表还原中转语义(一次转换 1 笔资金划转,direction='NET',amount = in_amount;「一次划转」为建模假设,非法规明文)。规则层通过 _amount_view 去重,使 RISK-002 等金额聚合类规则只计一次(§6.3)。

2.5 舍入顺序与精度

逐批次先舍入、后求和(贴合真实 TA:每笔费用独立计算入账)。

2.5.1 计算精度(决定数值)

对象 精度 舍入方式 依据
每批次 amount / fee_amount 2 位 四舍五入 真实公告:转出金额、赎回费、补差费均保留 2 位、第三位四舍五入
汇总 redeem_fee — Σ(各批次已舍入的 fee_amount) 逐批舍入后求和
转入份额 in_qty 按产品 按产品(§2.5.3) 主流 2 位四舍五入,差异产品按配置

存储精度仍为 DECIMAL(18,4)(真实 TA 内部精度高于展示位),计算与对外展示按 §2.5.2 分类。

2.5.2 展示位数(决定响应字符串)

按字段分类集中定义,实现见 app/service/convert/convert_service.py::_q(单一格式出口,响应/审计/日志共用)。

字段类(响应字段) 位数 舍入 现实依据
金额:out_amount / redeem_fee / convert_amount / diff_fee / in_amount / 逐批 fee_amount 2 四舍五入 「转出金额以四舍五入的方式保留至小数点后两位」
份额:requested_qty / actual_qty / in_qty / 逐批 qty 2(可被产品配置覆盖) 四舍五入 / 按产品 「申请转换份额精确到小数点后两位」(中银);主流 2 位四舍五入,差异见 §2.5.3
净值:out_nav / in_nav / 逐批 nav 4 四舍五入 份额净值保留 4 位、第 5 位四舍五入(中欧、国泰)
费率:out_subscribe_fee_rate / in_subscribe_fee_rate / 逐批 fee_rate 4 四舍五入 公告以百分比 2 位表示(0.30% ↔ 0.0030)
份额尾差:rounding_diff 4 四舍五入 需与净值同级才能表达尾差

同一响应的两条产出路径(首次按公式算 / 按库 DECIMAL(18,4) 重建)必须逐字节一致。

2.5.3 舍入差异按产品配置(D27)

真实业务不统一:主流转入份额 2 位四舍五入;易方达 ETF 场外取整数位;南方基金两位后截断(非四舍五入)。本项目按产品配置:

  • 新增 core_share_rule(product_id + business_type) 表(11-seed-share-rule.sql,逐产品列 (share_digits, rounding_mode) 组合,如南方=2 位+TRUNCATE、易方达 ETF 场外=整数位、东方赎回费=TRUNCATE,禁错配)
  • rounding_mode ∈ {HALF_UP, TRUNCATE(=ROUND_DOWN)}(东方/南方截断=ROUND_DOWN,须与种子一致)
  • calc 按产品取真实规则;老产品缺规则时回退主流 2 位 HALF_UP(显式标注)

⚠️ 实现红线:Python Decimal.quantize() 默认是 ROUND_HALF_EVEN(银行家舍入),与"四舍五入"不符——所有 quantize 必须显式传 rounding=ROUND_HALF_UP(或产品配置的 TRUNCATE)。

尾差处理:舍入产生的误差在基金资产中列支(不是"归基金资产"的单向归属)。本期不建「基金资产」表,响应可选返回 rounding_diff = 理论份额 − 实际份额(可正可负:正 = 客户少得、负 = 客户多得),仅供校验。

2.6 受理 / 确认时序

2.6.1 交易日与受理日判定(15:00 截点 + 非交易日顺延)

申请时刻 受理日 T 依据
交易日 ≤ 15:00 当日 15:00 为当日受理截点
交易日 > 15:00 下一交易日 超过交易时间 → 顺延(不判失败,免客户重试)
非交易日 下一交易日 真实业务只在开放日受理
  • 交易日来源:新增 core_trade_calendar(§4.1),依上交所《休市安排》生成(730 天 / 503 交易日,与真实净值发布日交叉校验 0 偏差)。
  • 15:00 为配置项 convert_cutoff_time(默认 15:00),不得硬编码。
  • 受理日 T 一经确定即写入受理单,后续 T+1 / T+2 全部以它为基准(按交易日历推算,不用自然日近似)。

2.6.2 生命周期与状态机

              T 日                      T+1                    T+2
  申请 ──► [accepted 已受理] ──► [confirmed 已确认] ──► 转入份额可赎回
              │  ▲                    │
              │  │ 缺 T 日净值        │ 校验失败
              │  └── [nav_pending] ───┤
              │                       ▼
  撤单 ───────┴──► [cancelled]   [rejected 原份额不变]
状态 含义 是否占用份额 终态
accepted T 日已受理,待 T+1 确认 ✅ 占用 否
nav_pending T+1 确认时缺 T 日净值,挂起等净值 ✅ 占用 否
confirmed 权益已变更(转出批次已扣、转入批次已登记) ❌(已转为真实扣减) ✅
rejected 确认失败,占用释放、原份额不变 ❌ ✅
cancelled T 日 15:00 前撤单,占用释放 ❌ ✅
expired 超 SLA 仍未确认(兜底清理,标记不硬删) ❌ ✅

2.6.3 在途占用与可用份额(不新增冻结列)

口径:

可用份额(客户, 产品) = Σ core_share_lot.remain_qty − Σ(未终态受理单的 requested_qty)

占用由受理单推导,不写进批次表。

术语澄清:真实 TA 业务规则里的「冻结」是司法 / 有权机关冻结(华安 §90 / 富达 §46:人工柜台业务,只受理有权机关申请),与「申请未确认」是两回事。客户端看到的「冻结份额」只是在途未确认申请推导出的可用份额扣减。故本项目不引入 reserved_qty 之类的冻结列,避免与司法冻结术语混淆。

并发安全:

v0.x(T 日实时扣减,已弃) 本项目(受理/确认分离)
多笔转换同日争抢同一批次:条件 UPDATE 反复失败 → 退避成功率 80%(紧池病根) T 日受理不扣批次,只在「客户 + 产品」维度短临界区内校验可用份额并落受理单;T+1 批处理单线程串行确认,同一时刻只有一个确认者改批次 → 无争抢
  • T 日受理为 Core 单库事务(校验 + 落单),冲突窗口极短;
  • T+1 确认的串行性由「批处理单线程 + convert:confirm:{T} 锁」双保险保证(架构定实现)。

2.6.4 T+2 可赎回

  • 转入新批次 confirmed_at = T+1(确认日),持有期自该日起算;
  • 可赎回日 = 确认日之后的第 1 个交易日(F1:「投资者自 T+2 日起有权赎回转入部分的基金份额」);
  • 不加列:由 confirmed_at + core_trade_calendar 推算;赎回时校验该批次 confirmed_at 的下一交易日 ≤ 今日。

2.6.5 确认的失败与挂起

情形 处理 依据
缺 T 日净值 置 nav_pending,不 reject、不降级取旧净值;净值补齐后继续确认 净值 T+1 才公告,未公告即不确认
转入基金变为不可申购 / 转出变为不可赎回 rejected + 释放占用 + 原份额不变 F5
投资者可用份额不足(部分成交,D28) 确认段支持部分成交:实际 = min(申请, 可用) + 未确认部分占用释放;记录 partial 标记(不顺延至后续批处理,属本项目投资者层建模取舍;真实基金层巨额赎回为「部分延期赎回/顺延」,二者不可混淆) 真实规则:可用份额不足部分成交
批次在 T+1 已不可扣(理论不应发生) rejected(防御性)+ 告警 数据异常,需人工介入
超 SLA(默认 2 个交易日,配置项)仍未确认 置 expired + 释放占用 + 告警 兜底,防在途占用悬空

适当性 T+1 复核(D25):确认段复核转入端适当性(含测评版本号),不通过 → rejected(份额不变)。受理时校验一次留痕,确认时二次复核。

3. 功能需求

编号 需求 优先级
FR-C1 网关接受 convert,不再 400 P0
FR-C2 请求以份额为单位,携带转出 / 转入两个产品 P0
FR-C3 折算在 T+1 确认时进行,取受理日 T 的两只产品净值(§2.2) P0
FR-C4 落两条 core_trade,共享 convert_group_id,同事务(确认事务) P0
FR-C5 适当性:转入端完整校验且为唯一阻断点;T+1 复核(D25) P0
FR-C6 赎回费按持有期分档,逐批次计费 P0
FR-C7 FIFO 扣减份额批次 P1
FR-C8 最低持有份额:转出后余额低于下限则强制全转,记 actual_qty P1
FR-C9a 产品可申购 / 可赎回校验 P1
FR-C10 审计:一次转换落 2 条(受理 + 确认),以 convert_group_id 关联 P0
FR-C11 规则引擎:只跑一次,金额聚合去重但不删行 P0
FR-C12 转入份额持有期重置 P1
FR-C13 转换后同步更新 core_holding 两端持仓(qty / cost_amount / as_of / pnl_pct) P0
FR-C14 逐批次计费明细落 core_convert_lot_detail P0
FR-C15 core_tools.query_recent_trades 汇总去重 P1
FR-C16 份额批次全生命周期维护:普通申购落新批次、普通赎回 FIFO 扣批次 P0
FR-C17 幂等:支持 client_request_id,防重复提交产生第二组流水 P0
FR-C18 批次扣减用条件 UPDATE 乐观并发控制 P0
FR-C19 受理/确认分离:T 日受理只校验 + 落受理单,不扣批次、不折算 P0
FR-C20 申请以份额为单位(requested_qty) P0
FR-C21 在途占用:可用份额 = 批次余量 − Σ(未终态受理单份额);不新增冻结列 P0
FR-C22 撤单:T 日 15:00 前可撤销,占用释放、状态 cancelled P0
FR-C23 T+1 批量串行确认:批处理任务(可指定业务日)+ 幂等 P0
FR-C24 交易日历:受理日判定 / T+1 / T+2 / 15:00 顺延全部按 core_trade_calendar P0
FR-C25 T+2 可赎回:转入批次自 T+2 起计入可用份额(不加列) P1
FR-C26 确认失败 → rejected:占用释放、原份额不变、审计留痕 P0
FR-C27 缺 T 日净值 → nav_pending 挂起,补齐后继续确认;不降级取旧净值 P0
FR-C28 规则引擎移到确认事务提交后跑,仍只跑一次 P0
FR-C29 redeem 份额申报(D26):qty 入参,赎回金额 = qty × T日净值 − 赎回费;加 T+2 可赎回校验 P0
FR-C30 资金流中转(D29):确认事务写 core_cash_flow(注册登记账户中转建模) P0
FR-C31 A/C 份额互转(D30):share_class + allow_ac_convert 开关,同基金 A/C 互转 P1

4. 数据模型

4.1 新增表

core_fee_rule —— 费率规则

字段 类型 说明
id BIGINT PK
product_id VARCHAR(64)
fee_type ENUM('subscribe','redeem')
min_hold_days / max_hold_days INT / INT NULL 仅 redeem:持有期分档
min_amount / max_amount DECIMAL(18,2) NULL 仅 subscribe:金额分档(本期不启用)
rate DECIMAL(6,4) 费率(redeem:0.0150/0.0100/0.0050/0.0025/0)
to_fund_ratio DECIMAL(5,4) NOT NULL DEFAULT 1.0 赎回费计入基金财产比例。22 号文 §10:全额计入基金财产,恒 1.0(旧规 75%/50%/25% 分级已废止)。本期不参与计算,仅留痕

core_share_lot —— 份额批次

字段 类型 说明
lot_id VARCHAR(64) PK
customer_id / product_id VARCHAR(64) KEY(customer_id, product_id, confirmed_at)
qty / remain_qty DECIMAL(18,4) 原始 / 剩余
nav DECIMAL(10,4) 成交净值
confirmed_at DATETIME(3) 持有期起算日
source_trade_id VARCHAR(64) NULL

core_convert_lot_detail —— 转换批次明细(Core 库)

字段 类型 说明
id BIGINT PK
convert_group_id VARCHAR(64) NOT NULL KEY
lot_id VARCHAR(64) NOT NULL
qty DECIMAL(18,4) 本批转出份额
hold_days INT 本批持有天数
amount DECIMAL(18,2) 本批金额(已舍入)
fee_rate / fee_amount DECIMAL(6,4) / DECIMAL(18,2) 本批费率与费用(已舍入)
nav / nav_date DECIMAL(10,4) / DATE 本批成交净值与净值日期

nav / nav_date 放 Core 侧使 Core 自包含折算输入 —— agent 附加写入失败后 rebuild_alerts 才能补出完整详情(§7.1)。

core_convert_request —— 转换受理单(Core 库 · 权威状态机)

CREATE TABLE core_convert_request (
    id                  BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    convert_group_id    VARCHAR(64)     NOT NULL COMMENT '一次转换的全局 id(受理时预生成)',
    client_request_id   VARCHAR(64)     NULL     COMMENT '幂等键(选填)',
    customer_id         VARCHAR(64)     NOT NULL,
    out_product_id      VARCHAR(64)     NOT NULL COMMENT '转出',
    in_product_id       VARCHAR(64)     NOT NULL COMMENT '转入',
    requested_qty       DECIMAL(18,4)   NOT NULL COMMENT '申请转出份额(份额申报)',
    status              ENUM('accepted','nav_pending','confirmed','rejected','cancelled','expired')
                        NOT NULL COMMENT 'accepted=已受理待确认;nav_pending=缺T日净值挂起;confirmed=已确认;rejected=确认失败份额不变;cancelled=T日撤单;expired=超SLA兜底',
    accept_date         DATE            NOT NULL COMMENT '受理日 T(经 15:00 截点与日历判定)',
    confirm_date        DATE            NULL     COMMENT '确认日 T+1(交易日历推得)',
    available_date      DATE            NULL     COMMENT '可赎回日 T+2(交易日历推得)',
    accepted_at         DATETIME(3)     NOT NULL,
    confirmed_at        DATETIME(3)     NULL,
    cancelled_at        DATETIME(3)     NULL,
    reject_reason       VARCHAR(64)     NULL     COMMENT '拒绝原因码(§5.6)',
    actor_id            VARCHAR(64)     NULL     COMMENT '发起主体(客户本人 / risk_demo)',
    created_at          DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    updated_at          DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
    UNIQUE KEY uk_group (convert_group_id),
    UNIQUE KEY uk_idem  (client_request_id),
    KEY idx_confirm_queue (status, accept_date) COMMENT 'T+1 批处理捞待确认单',
    KEY idx_cust_prod_status (customer_id, out_product_id, status) COMMENT '在途占用计算'
) ENGINE=InnoDB COMMENT='基金转换受理单(Core 侧 · 权威状态机)';

为什么受理单放 Core 库:① 在途占用必须与 core_share_lot 一起算可用份额 —— 同库才能单条 SQL 闭环;② T+1 确认要扣批次 + 写 core_trade —— 同库才能单事务;③ 受理与确认各自都是 Core 单库事务,不再需要跨库三阶段 + 补偿,agent 侧(详情 / 审计 / 预警)降级为附加写入,失败走补偿。

core_trade_calendar —— A 股交易日历(Core 库)

CREATE TABLE core_trade_calendar (
    cal_date DATE        NOT NULL PRIMARY KEY COMMENT '日历日',
    is_open  TINYINT(1)  NOT NULL DEFAULT 1    COMMENT '1=交易日 0=休市',
    remark   VARCHAR(64) NULL                  COMMENT '休市事由(周末/元旦/春节/…)'
) ENGINE=InnoDB COMMENT='A股交易日历(依据上交所《休市安排》)';

数据来源(非人造):上交所官网《休市安排》(2026 年 7 个休市区间),由 scripts/core/gen_trade_calendar.py 生成种子 10-seed-trade-calendar.sql(730 天 / 503 交易日)。⚠️ 2027 年休市安排截至 2026-09-10 尚未公布,2027 年仅按周一至周五生成、不含节假日;公布后须补齐重跑。交叉校验:用 510300 的真实净值发布日(区间内 168 个)反查,0 个落在休市日。

core_cash_flow —— 资金流中转(Core 库,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_convert_request(Core) 权威 业务状态:accepted / nav_pending / confirmed / rejected / cancelled / expired
risk_convert_detail(agent) 从属 只表示 agent 侧详情写入进度(pending / completed / failed),不再承载业务状态与幂等

risk_convert_detail —— 转换详情(agent 库)

CREATE TABLE risk_convert_detail (
    id                  BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    convert_group_id    VARCHAR(64)     NOT NULL,
    status              ENUM('pending','completed','failed','cancelled','expired') NOT NULL DEFAULT 'pending'
                        COMMENT '仅表示 agent 侧详情写入进度——pending=待写入;completed=详情与审计已写;failed=写入失败待补偿;cancelled/expired=保留值(免 ALTER,本版不使用)。业务状态一律以 core_convert_request.status 为准',
    out_trade_id        VARCHAR(64)     NULL COMMENT '确认后写入(受理阶段为 NULL)',
    in_trade_id         VARCHAR(64)     NULL COMMENT '确认后写入(受理阶段为 NULL)',
    nav                 DECIMAL(10,4)   NULL COMMENT '成交净值',
    nav_date            DATE            NULL,
    fee_amount          DECIMAL(18,2)   NULL COMMENT '本笔费用',
    hold_days_min       INT             NULL COMMENT '跨批次时最短持有天数',
    hold_days_max       INT             NULL COMMENT '跨批次时最长持有天数',
    client_request_id   VARCHAR(64)     NULL COMMENT '仅留痕;幂等锚点在 core_convert_request.uk_idem',
    created_at          DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    UNIQUE KEY uk_group (convert_group_id),
    KEY idx_out (out_trade_id),
    KEY idx_in  (in_trade_id)
) ENGINE=InnoDB COMMENT='基金转换详情(Agent 侧)';

唯一写入方 = app/repository/convert_repository.py,只写「agent 侧详情写入进度」,业务状态不落此表。任何响应均不返回本表状态。

4.2 修改表

core_trade:加 1 列 convert_group_id VARCHAR(64) NULL + KEY idx_convert_group (convert_group_id)(重试判定 SELECT 1 FROM core_trade WHERE convert_group_id = :gid LIMIT 1 不能全表扫)。

core_product 新增列(合计 8 列,详见 01-ddl.sql):

  • can_subscribe / can_redeem(TINYINT DEFAULT 1,永不为 NULL)
  • min_hold_qty / min_redeem_qty
  • subscribe_fee_rate(申购费率,独立列;22 号文 §8 上限内种子显式给值)
  • fund_company / ta_code
  • share_class(A/C 份额标识,D30)
  • allow_ac_convert(TINYINT DEFAULT 0,A/C 互转开关,D30)
  • rounding_mode / share_digits(舍入方式/位数,D27;亦可走 core_share_rule 表)

core_product.fee_rate 补 COMMENT(不得改名/改值):

ALTER TABLE core_product
  MODIFY COLUMN fee_rate DECIMAL(6,4) NULL COMMENT '管理费率(年化),模拟库未参与任何计算;申购费率请用 subscribe_fee_rate,禁止取本列';

该列数值特征为管理费率(货基 0.0010 < 债基 0.0030 < 指数 0.0050 < 主动股混 0.0150),与申购费率是两回事——货基申购费为 0,而种子里货基 fee_rate=0.0010,可反证。不写死定义,后续实现者极易把它当申购费率填进补差费公式(算出来看着合理,语义全错)。

core_holding:不新增列,转换后按 §4.4 更新。

4.3 种子数据

  • 06-seed-nav.sql(已有):真实净值(联网下载,60 个净值发布日)
  • 07-seed-fee-rule.sql:赎回费持有期分档(0.0150 / 0.0100 / 0.0050 / 0.0025 / 0,to_fund_ratio=1.0)
  • 08-seed-share-lot.sql:按持仓反推批次,按客户/产品错开持有期以覆盖多档费率
  • 09-seed-org.sql:补齐 fund_company / ta_code(全部非 NULL,跨机构校验可测)+ subscribe_fee_rate(22 号文 §8 上限内)
  • 10-seed-trade-calendar.sql:交易日历
  • 11-seed-share-rule.sql(新增,D27):真实产品舍入/位数规则,逐产品列 (share_digits, rounding_mode) 组合

申购费率种子(22 号文 §8 上限内,分类必须与 product_type 匹配):

产品 类型 subscribe_fee_rate 依据
PROD-110022(示例转出) bond 0.0030 债券型 ≤0.3%
PROD-005827 mixed(其他混合型) 0.0050 其他混合型 ≤0.5%
PROD-003095(示例转入) stock(主动偏股型) 0.0080 主动偏股型 ≤0.8%
PROD-000001(同费率对照) money 0.0000 货基 0
PROD-510300(同费率对照) index 0.0030 指数型 ≤0.3%

⚠️ 费率档由产品类型决定,不得为凑数值差把 mixed 产品按「主动偏股」取 0.8%(曾犯的分类错误,自检第 12 问)。 示例转出 PROD-110022 与转入 PROD-003095 同属华夏模拟基金 / TA-CN-001(同管理人 + 同 TA,转换前置硬约束)。§5.3 主示例即取这两只产品(补差 0.50%)。

4.4 份额批次生命周期

批次表由交易统一出入口维护,覆盖全部交易类型:

交易 批次动作
subscribe 新增批次(qty = 确认份额,confirmed_at = 交易日 T)
转换转入 新增批次(qty = 确认份额,confirmed_at = 确认日 T+1)
redeem / 转换转出 FIFO 扣减 remain_qty(按 confirmed_at 升序),扣完置 0
转换 转出端 FIFO 扣 + 转入端新增

转换转入新批次 confirmed_at = T+1:持有期自确认日起算(比自 T 起算少 1 天、费率档更严,更贴近真实)。普通申购新批次维持 T(不动现有持仓与既有用例)。

计费口径差异(须知):本期普通赎回只维护批次、不驱动计费(沿用现有行为:amount 由请求传入,不查费率表、不写费用);转换转出才计赎回费。同一批份额普通赎回不计费、转换转出计费,是模拟简化(现有系统普通赎回本就不计费),真实业务两端都应计费。统一计费列二期,代码注释须写明此差异。

历史数据兜底:scripts/core/rebuild_lots.py 按 core_holding 重建批次(qty = 持仓份额,confirmed_at 按错开规则生成)。语义澄清:是「批次快照重建,不是交易回滚」——只重算 core_share_lot 使其与 core_holding 一致,不撤销任何已落库交易流水。

并发控制(FR-C18):批次扣减用条件 UPDATE,禁止先 SELECT 后 UPDATE:

UPDATE core_share_lot SET remain_qty = remain_qty - :q
WHERE lot_id = :lot AND remain_qty >= :q

检查 rowcount == 1,否则视为并发冲突,返回 409 LOT_CONFLICT 并重试。

5. 接口协议

5.1 请求

POST /api/simulate/trade
{
  "customer_id": "CUST-9527",
  "trade_type": "convert",
  "from_product_id": "PROD-110022",
  "to_product_id":   "PROD-003095",
  "qty": 50000.00,                          // 申请转出份额(份额申报)
  "client_request_id": "CLI-20260909-0001"  // 幂等键,选填
}
交易类型 入参 说明
convert qty(份额) 忽略 amount
subscribe amount(金额) 金额申购(行业铁律)
redeem qty(份额,D26) 份额申报;赎回金额 = qty × T日净值 − 赎回费

受理响应语义:convert 受理成功返回 HTTP 202 Accepted,响应体为受理回执(§5.3.1),不含任何金额 / 净值 / 折算字段(T 日净值尚未公告)。折算结果在 T+1 确认后通过查询接口获取(§5.4)。

5.2 网关改造清单

文件 改造
trade_gateway.py:112 移除 convert 拒绝(400)
trade_gateway.py:119 convert 分支只吃 qty,不再 amount ÷ 净值 反算份额
trade_gateway.py convert 分支 改为「校验 → 受理(落受理单,202)」,折算后移到 T+1 确认
trade_gateway.py subscribe/redeem 分支 维护批次(FR-C16);redeem 改份额申报 + T+2 可赎回校验(FR-C25)
api/simulate.py convert 校验 from/to_product_id,响应状态改 202
新增 撤单接口(§5.5)、确认触发接口(§5.7)、受理单查询(§5.4)

5.3 响应

5.3.1 受理回执(HTTP 202 Accepted)

{
  "convert_group_id": "CNV-20260909-A1B2C3D4",
  "client_request_id": "CLI-20260909-0001",
  "status": "accepted",
  "requested_qty": "50000.00",
  "out_product_id": "PROD-110022",
  "in_product_id":  "PROD-003095",
  "accept_date":    "2026-09-09",
  "confirm_date":   "2026-09-10",
  "available_date": "2026-09-11",
  "cancel_deadline":"2026-09-09 15:00:00",
  "accepted_at":    "2026-09-09 10:12:33.456"
}
字段 说明 位数
status 恒为 accepted(幂等命中已确认单时返回其当前状态) —
requested_qty 申请转出份额(客户申报,原样回显) 2 位
accept_date / confirm_date / available_date T / T+1 / T+2,全部按交易日历推算 —
cancel_deadline 撤单截止时刻(T 日 convert_cutoff_time,默认 15:00) —

幂等:同 client_request_id 重复提交 → 返回首次受理回执,不产生第二张受理单;锚点在 core_convert_request.uk_idem。

5.3.2 确认结果(T+1 后查询 · 脚本实算)

示例自证:每个派生值都由本块数据算出(口径 B · 生产函数 calc.py 实算,禁止手算)。输入取 06-seed-nav.sql 真实净值在 T = 2026-09-09 的两条记录:转出 PROD-110022 = 1.3604、转入 PROD-003095 = 1.9194。

步 计算 结果
批次 1 30000 份 × 1.3604 amount 40812.00;赎回费 40812.00 × 0.5% = 204.06
批次 2 20000 份 × 1.3604 amount 27208.00;赎回费 27208.00 × 1.5% = 408.12
转出金额 40812.00 + 27208.00 68020.00
赎回费 204.06 + 408.12(逐批舍入后求和) 612.18
转换金额 68020.00 − 612.18 67407.82
转入端申购费 67407.82 × 0.80% ÷ 1.008 q2 534.98
转出端申购费 67407.82 × 0.30% ÷ 1.003 q2 201.62
diff_fee(B) Max(534.98 − 201.62, 0) 333.36
in_amount 67407.82 − 333.36 67074.46
in_qty 67074.46 ÷ 1.9194 = 34945.5351 q2 34945.54(2 位四舍五入)
rounding_diff 理论 34945.5351 − 实际 34945.54 -0.0049(负 = 客户多得)

两端申购费率:转出 PROD-110022(bond)0.30%、转入 PROD-003095(stock,主动偏股型)0.80%(种子显式给定,22 号文 §8 上限内)。 口径 A 对照:同输入下 A = 335.36、in_qty = 34944.49(与 B 差约 0.6%),故必须显式选口径(§2.1.1)。 同费率对照:两端均为 0.30% 时补差 = 0(diff_fee = "0.00"),由 §9 用例单独覆盖。

⚠️ 申购费率不得取 core_product.fee_rate(管理费率,§4.2)。 ⚠️ 示例与种子强绑定:净值种子刷新(重跑 fetch_nav.py)后本表须重新实算,同步 scripts/dev/calc_convert_demo.py 与 tests/test_convert_integration.py:278(硬断言本节数字)。

确认结果 JSON:

{
  "convert_group_id": "CNV-20260909-A1B2C3D4",
  "client_request_id": "CLI-20260909-0001",
  "status": "confirmed",
  "blocked": false,
  "requested_qty": "50000.00",
  "actual_qty": "50000.00",
  "forced_full_transfer": false,
  "accept_date":    "2026-09-09",
  "confirm_date":   "2026-09-10",
  "available_date": "2026-09-11",
  "out_trade_id": "TRD-20260910-XXXXXXXX",
  "out_nav": "1.3604",
  "nav_date": "2026-09-09",
  "out_amount": "68020.00",
  "lot_count": 2,
  "lot_breakdown": [
    {"qty": "30000.00", "hold_days": 100, "fee_rate": "0.0050", "fee_amount": "204.06"},
    {"qty": "20000.00", "hold_days": 3,   "fee_rate": "0.0150", "fee_amount": "408.12"}
  ],
  "redeem_fee": "612.18",
  "in_trade_id": "TRD-20260910-YYYYYYYY",
  "in_nav": "1.9194",
  "convert_amount": "67407.82",
  "diff_fee": "333.36",
  "in_amount": "67074.46",
  "in_qty": "34945.54",
  "rounding_diff": "-0.0049",
  "out_subscribe_fee_rate": "0.0030",
  "in_subscribe_fee_rate":  "0.0080",
  "triggered_rules": [],
  "alert_ids": [],
  "aml_hit": false
}

字段语义去重(三处金额易混):

  • out_amount = 原始转出额(份额 × 转出净值,未扣任何费)= 68020.00
  • convert_amount = 扣赎回费后可用于转入的金额 = out_amount − redeem_fee = 67407.82
  • in_amount = 扣补差费后实际买入金额 = convert_amount − diff_fee = 67074.46

in_amount ÷ in_nav 才是 in_qty;三者不可混用。

字段类型约定:所有金额、费率、份额字段一律 JSON 字符串(对应后端 Decimal,Pydantic v2 默认字符串化)。字符串的位数不自由:按 §2.5.2 展示位数表分类。同一响应的两条产出路径(公式算 / 库 DECIMAL(18,4) 重建)必须逐字节一致;实现集中在 convert_service._q。

5.3.3 状态枚举对外口径

对外 status 含义 客户可见话术
accepted 已受理,待确认 「您的转换申请已受理,将于 T+1 确认」
nav_pending 待净值公告 「等待基金净值公告,公告后自动确认」
confirmed 已确认 「转换成功,T+2 起可查询/赎回」
rejected 确认失败 「转换未成功,原基金份额不变」+ reject_reason
cancelled 已撤单 「申请已撤销,份额已释放」
expired 超期未确认 「申请已失效,份额已释放,请重新提交」

5.4 错误码

场景 HTTP error_code 说明
转出不可赎回 400 PRODUCT_NOT_REDEEMABLE
转入不可申购 400 PRODUCT_NOT_SUBSCRIBABLE
可用份额不足(含在途占用) 400 INSUFFICIENT_SHARES 判据 = 可用份额(§2.6.3),非批次余量
低于最低转出份额 400 BELOW_MIN_QTY 全额豁免除外(§12)
同一产品自转换 400 SAME_PRODUCT
非同一管理人 / 同 TA 400 CROSS_ENTITY_NOT_SUPPORTED 含跨 share_class 同基金判定(D30)
超过 convert_batch_max_lots(默认 200 批次) 400 TOO_MANY_LOTS 400 为 HTTP 状态码,阈值=200
非受理时间(无可用交易日,防御) 400 NO_TRADING_DAY
撤单超窗口 / 已确认不可撤 409 CANCEL_NOT_ALLOWED
重复提交(幂等命中 / 在飞) 202 返回首次受理回执或 {convert_group_id, status:"processing"},不重复落单
适当性不匹配(转入端) 200 + blocked 沿用 R-02 受理时校验;T+1 复核不通过 → rejected
确认时批次冲突(防御性) 409 LOT_CONFLICT 确认阶段条件 UPDATE 哨兵

5.5 撤单接口(FR-C22)

POST /api/simulate/trade/convert/{convert_group_id}/cancel
条件 结果
当前时间在 T 日 15:00 前 且状态为 accepted / nav_pending 200,置 cancelled,释放占用
已过 T 日 15:00 / 已终态 409 CANCEL_NOT_ALLOWED
  • 撤单为 Core 单库事务:状态置终态 + 占用自然释放(占用由未终态受理单推导,无需额外扣减动作)。
  • 撤单落审计(decision='convert_cancelled'),审计表只 INSERT。

5.6 拒绝原因码(reject_reason)

reject_reason 触发条件
IN_PRODUCT_NOT_SUBSCRIBABLE T+1 确认时转入基金已不可申购
OUT_PRODUCT_NOT_REDEEMABLE T+1 确认时转出基金已不可赎回
SUITABILITY_FAILED T+1 适当性复核不通过(D25)
LOT_UNAVAILABLE 批次在 T+1 已不可扣(理论不应发生,防御 + 告警)
NAV_TIMEOUT 超 SLA 仍无 T 日净值 → 转 expired(占用释放)

共同语义:rejected / expired 一律释放占用、原份额不变(F5)。

5.7 确认触发(FR-C23 · 运维/批处理)

POST /api/admin/convert/confirm?accept_date=2026-09-09      # 按受理日批量确认
GET  /api/simulate/trade/convert/{convert_group_id}          # 查询受理单与确认结果
项 口径
触发方式 批处理任务,可指定业务日(演示/验证无需真等一天);串行执行
幂等 已 confirmed 的受理单重复触发 → 跳过,不产生第二组流水
锁 convert:confirm:{accept_date},防并发批处理双跑
缺 T 日净值 置 nav_pending,本轮跳过,不 reject、不降级
结果 每条确认 = 一个 Core 单库事务(扣批次 + 写两条流水 + 更新两端持仓 + 置 confirmed)

6. 规则引擎口径

6.1 两条流水都进事件线

转出 redeem、转入 subscribe 均为标准类型 → core_ro.py 写死的 trade_type IN ('subscribe','redeem') 无需改动。

6.2 引擎只跑一次

一次转换只调用一次 process_convert_event(),传入两条流水,在引擎内部用两条视角跑规则,出一条预警事件。不会出现同一规则重复命中(如 RISK-001 被转出 / 转入各命中一次),符合「一次投资动作」语义。

6.3 去重只在金额聚合处,绝不删行

run_rules() 收到全量两条;_amount_view() 只给金额聚合类规则用。

规则 视图 理由
RISK-001 单笔大额 全量 逐笔判定
RISK-002 当日累计 _amount_view 防翻倍
RISK-003 频繁交易 全量 分产品各归各
RISK-004 试探模式 全量 逐笔判定笔数
RISK-005 先小后大 _amount_view 防重复计入铺垫
RISK-006 集中度 不变 读持仓快照

6.4 对话侧汇总去重(FR-C15)

core_tools 读到两条是对的(真实业务两笔权益变动),但汇总金额必须用 _amount_view。对话侧对转换的任何状态展示 / 去重判断,一律以 core_convert_request.status 为权威,禁止用 risk_convert_detail.status 判断业务状态。

7. 事务、合规与审计

7.0 完整执行顺序

7.0.1 T 日 · 受理段(同步,HTTP 请求内完成)

步 内容 失败/阻断时
1 参数与产品校验(同产品、可申购/可赎回、同管理人/TA、share_class 同基金判定 D30) 4xx,不落单
2 受理日判定:15:00 截点 + 交易日历 → 得出 T(§2.6.1) 无可用交易日 → 400 NO_TRADING_DAY
3 可用份额校验:Σ remain_qty − Σ(未终态受理单)(§2.6.3)+ 最低份额(含全额豁免) 400 INSUFFICIENT_SHARES / BELOW_MIN_QTY,不落单
4 适当性校验(转入端) blocked → 出 R-02 预警 + 审计,return,不落单
5 受理事务(Core 单库):INSERT core_convert_request(accepted,含 accept/confirm/available_date) 撞 uk_idem → 返回首次受理回执(幂等);其他失败 → 整体回滚
6 返回 202 + 受理回执(§5.3.1) —

关键:1~4 步纯计算/校验,不产生任何持久化;受理段不取净值、不折算、不扣批次。

7.0.2 T+1 · 确认段(批处理,逐单串行)

步 内容 失败/阻断时
1 捞 accept_date = T 且 status ∈ (accepted, nav_pending) 的受理单(按受理先后排序) —
2 取两端 T 日净值 缺失 → 置 nav_pending,本轮跳过(不 reject、不降级)
3 复核产品可申赎状态 + 适当性 T+1 复核(D25) 不满足 → rejected + reject_reason,占用释放、份额不变
4 plan_lots(FIFO + 最低持有处置)+ 折算(calc.py,口径 B) 超批次上限 → rejected(TOO_MANY_LOTS)
4b 部分成交(D28):若申请份额 > 可用份额,实际 = min(申请, 可用),未确认部分占用释放,记录 partial 标记(此为受理段③之外的二次兜底,覆盖 T 日~T+1 间其它在途单抢占致可用份额变少的场景) —
5 确认事务(Core 单库):扣批次(条件 UPDATE 哨兵)+ 写两条流水 + core_convert_lot_detail + 更新两端 core_holding + 写 core_cash_flow(中转,D29)+ 置 confirmed 整体回滚 → 保持 accepted,下轮重试(幂等)
6 确认后同步跑规则引擎(一次) 引擎异常落 engine_error + 本地日志,不阻断
7 agent 侧写详情 + 主审计(附加写入) 失败不回滚 Core,落待补偿(SLA 24h)

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

7.0.3 撤单段(T 日 15:00 前,同步)

步 内容
1 校验受理单存在 + 状态为 accepted / nav_pending
2 校验当前时间 ≤ T 日 convert_cutoff_time(15:00)
3 Core 单库事务:置 cancelled + 写 cancelled_at;占用自然释放
4 落审计 decision='convert_cancelled'

7.1 事务边界

两个 Core 单库事务 + agent 附加写入(双库无法单事务:core_trade/core_share_lot/core_holding 在 jinrong_core,risk_convert_detail/audit_log 在 jinrong_agent)。

段 库 事务内容 失败处理
T 日受理 jinrong_core(单库事务) INSERT core_convert_request(accepted);幂等锚点 uk_idem 同库生效 整体回滚;撞幂等键 → 返回首次回执
T+1 确认 jinrong_core(单库事务) 扣 core_share_lot(条件 UPDATE 哨兵)+ 新增转入批次 + 写两条 core_trade + core_convert_lot_detail + core_cash_flow + 更新两端 core_holding + 置 confirmed 整体回滚 → 保持 accepted,下轮批处理自动重试(天然幂等)
agent 附加 jinrong_agent 写 risk_convert_detail 详情 + 主审计(§7.3) 失败不回滚 Core,落待补偿(rebuild_alerts.py,SLA 24h)

补偿:确认段整体是 Core 单库事务,失败即回滚、受理单仍为 accepted → 下轮批处理自动重试即可,无需人工补偿。只有 agent 侧详情 / 审计写入失败才需要 rebuild_alerts.py 补写(Core 侧含 core_convert_lot_detail.nav/nav_date 可自包含重建)。

本地日志兜底:agent 附加写入失败时,"落失败审计"自身也可能失败。必须 logger.exception() 输出 convert_group_id + 全部折算参数到本地日志,保证即使审计写不进去仍有可人工恢复的记录。

补偿项 口径
触发者 risk_convert_detail.status='failed' 的巡检/告警 + 人工判断
补偿工具 scripts/demo/rebuild_alerts.py —— 按 convert_group_id 从 Core 侧重建详情与预警
SLA 失败记录须在 24h 内完成补偿或人工确认;cleanup_pending_convert.py 将超 SLA 的异常单置 expired(标记不硬删,留痕供对账)

7.2 适当性(FR-C5)

北京金融法院判例「转换即销售」:对转入基金必须重新履行适当性义务,不得沿用历史测评。转入端跑完整 suitability_check,forbidden / risk_expired 即阻断。只有转入端能阻断。T+1 确认段再复核一次(D25),审计留存测评版本号与拒绝原因。

7.3 审计

一次转换落 2 条审计(受理一条、确认一条),以 convert_group_id 关联。审计表只 INSERT。

时点 decision input_summary 内容
T 日受理 convert_accepted 两端 product_id、requested_qty、T/T+1/T+2 三个日期、convert_group_id
T+1 确认 convert_confirmed 上述 + actual_qty、四段金额、两端净值、nav_date、lot_count、out/in_trade_id
T 日撤单 convert_cancelled 受理摘要 + cancelled_at
确认拒绝 convert_rejected 受理摘要 + reject_reason

关联主键:input_summary 以 convert_group_id 为主键关联(一次转换 = 一组审计),并附 out_trade_id / in_trade_id 作明细指针;避免「按单条 trade_id 关联 → 一次转换被审计成两条」。

受理失败处理:

情形 处理
校验类 4xx 不落受理单、不落审计
适当性 blocked 落 R-02 预警 + 审计,不落受理单
受理事务本身失败(DB 异常) 不落审计(事务回滚),返回 5xx,客户端可重试
未带 client_request_id 无幂等语义,按现有逻辑执行

7.4 幂等与执行权(FR-C17)

幂等锚点前移到 Core 受理单(与流水同库):uk_idem 与流水同库,从结构上杜绝「幂等查不到 → 重跑产生第二组流水」。

阶段 库 动作
T 日受理 core 预生成 convert_group_id;INSERT core_convert_request(含 client_request_id)
T+1 确认 core 同一 convert_group_id 下写两条流水;重复触发时先查是否已有流水,有则跳过

同键并发的两个竞态子窗口:

  • 子窗口 A:T2 落在 T1「受理单已落、尚未确认」→ 判为在飞,返回 202,不重跑;
  • 子窗口 B:T2 与 T1 同时 INSERT 受理单 → 撞 uk_idem → 让路,返回 202(不是 5xx)。
  • 判据:状态为 accepted / nav_pending 一律视为在飞,绝不重跑(T-13 双扣事故的根因)。

执行权:复用 app/service/risk/locks.py 双层锁(Redis SET NX EX 为主,进程内线程锁为备):

run_locked(f"convert:idem:{client_request_id}", lambda acquired: ...)
抢锁结果 处理
抢到(acquired=True) 查受理单,按下表判定
未抢到 有并发请求正在受理 → 直接返回 202 + 已有 convert_group_id;不查受理单、不落单

uk_idem 保留为兜底:即使锁退化,受理单 INSERT 撞唯一键的一方必须让路返回 202,不得重跑。

重试判定逻辑(T 日受理 · 仅在取得执行权后):

查受理单结果 处理
无受理单 全新请求 → 走受理流程
状态 ∈ accepted / nav_pending 在飞 → 返回该单回执(202),不重跑
状态 ∈ confirmed / rejected / cancelled / expired 终态 → 返回该单当前状态与结果(幂等命中,不落新单)

确认段幂等(T+1):先查该 convert_group_id 是否已有流水,有则跳过。

SELECT 1 FROM core_trade WHERE convert_group_id = :gid LIMIT 1;
  • 所在库:jinrong_core(与受理单同库);索引:core_trade.convert_group_id 必须建 KEY(§4.2),否则全表扫;批处理为串行,判据是 convert_group_id,杜绝第二组流水。

受理单生命周期(谁清理):

场景 处置
T+1 确认成功 confirmed
确认业务拒绝 rejected + reject_reason(占用释放、份额不变)
T 日 15:00 前撤单 cancelled
缺 T 日净值 nav_pending(等净值,不清理)
超 SLA(convert_confirm_sla_days,默认 2 个交易日)仍非终态 expired(标记不硬删,留痕供对账);由 scripts/agent/cleanup_pending_convert.py 扫描 core_convert_request 执行

⚠️ 占用与终态强绑定:在途占用 = Σ(未终态受理单的份额)。任何非终态单长期滞留都会持续占用客户份额,故 expired 清理是功能正确性要求,不是可选的运维优化。

8. 风险与取舍

风险 说明 应对
DDL 变更 新增 5 表 + 改 2 表 reset.ps1 重灌
批次表改造面扩大 普通申赎也要维护批次 FR-C16 + rebuild_lots.py 兜底
在途占用滞留 非终态受理单持续占用客户份额 expired 清理(SLA 2 个交易日)+ 告警;功能正确性要求
批处理可靠性 T+1 批处理未跑/中断 → 受理单停在 accepted 监控告警 + 手动触发(§5.7)+ 幂等(重复触发安全)
净值依赖外部数据 真实净值只到抓取当日,之后 T 日净值缺失 → 全挂 nav_pending 重跑 fetch_nav.py 增量补拉;不降级取旧净值
交易日历跨年 2027 年休市安排未公布 公布后补齐重跑生成脚本(§4.1 已标注)
撤单时点争议 15:00 截点以服务器时间为准 配置项 convert_cutoff_time;一期不做时钟同步
redeem 改份额申报打穿现有用例 D26 全改 redeem 接口/网关/折算 现有申赎用例重跑基线
部分确认语义复杂 D28 投资者层部分成交 + 占用释放 单测覆盖 + 显式标注与基金层巨额赎回顺延的区别

9. 验收标准

  1. pytest 全绿(基线 739 + 新增 convert 用例)
  2. 一次转换在 T+1 确认事务内落两条流水,同 convert_group_id,同事务(确认失败则都不落)
  3. 折算金额与 §2.1 逐项吻合(以 T 日真实净值折算,§5.3.2 示例)
  4. 转入端适当性不匹配 → 受理时 blocked,受理单都不落;T+1 复核不通过 → rejected(D25)
  5. RISK-002 对转换只计一次,不翻倍
  6. RISK-001 / RISK-003 仍能分别看到两条(验证去重未删行)
  7. 一次转换只产生一条预警事件(验证引擎只跑一次)
  8. 跨批次转换:各批按各自持有期计费,明细落库
  9. 普通赎回后再 convert,批次与持仓一致,不超扣
  10. 转换后 core_holding 两端已更新
  11. core_tools 汇总不翻倍
  12. 同一 client_request_id 重复提交不产生第二组流水
  13. 强制全转时 actual_qty != requested_qty 且响应与审计均记录
  14. CROSS_ENTITY_NOT_SUPPORTED 有用例覆盖
  15. 幂等窗口闭合:确认事务失败(受理单仍为 accepted)后重跑确认 / 带同一键重试 → 均不产生第二组流水,且 RISK-002 当日累计不翻倍
  16. 全额转出豁免:持有 6000、申请全转 6000、min_redeem=10000 → 受理成功,不得返回 BELOW_MIN_QTY
  17. agent 附加写入失败可补偿:rebuild_alerts.py 能仅凭 Core 侧数据(含 core_convert_lot_detail.nav/nav_date)补出完整详情
  18. 性能(第 6 步实测补录,不得改数据迁就指标):一次转换端到端响应 < 2s;确认事务 / 受理事务均预估 < 100ms,实测补录真实耗时,超阈值需优化索引/锁策略后再定阈值
  19. 补差费非零场景有覆盖:存在两端 subscribe_fee_rate 不同的产品对(PROD-110022 0.30% / PROD-003095 0.80%,同管理人 + 同一 TA),验证 diff_fee > 0 时 §2.1 公式成立;另有一条同费率对照(diff_fee = "0.00")
  20. 示例与种子强绑定:净值种子刷新后 §5.3.2 示例数字须重新实算,且 calc_convert_demo.py 与 test_convert_integration.py:278 同步更新,三者不一致即失败
  21. T 日受理不扣份额:受理成功后 core_share_lot.remain_qty 不变、无 core_trade 流水、状态 accepted、HTTP 202
  22. 在途占用:受理 50000 份后可用份额 = 余量 − 50000;再申请超出 → 400 INSUFFICIENT_SHARES;撤单后恢复
  23. 撤单:T 日 15:00 前撤单成功(cancelled + 占用释放);15:00 后或已确认 → 409 CANCEL_NOT_ALLOWED
  24. T+1 确认:批处理按受理日串行确认——扣批次 + 两条流水 + 两端持仓更新 + confirmed;重复触发不产生第二组流水
  25. 缺 T 日净值:确认时无 T 日净值 → nav_pending、不 reject、不取旧净值;补齐后重跑 → confirmed
  26. 确认失败:转入基金不可申购 → rejected + 占用释放、原份额不变 + 审计留痕
  27. T+2 可赎回:转入批次在 T+2 之前不计入可用份额,T+2 起计入
  28. 交易日判定:非交易日 / 15:00 后提交 → 受理日顺延至下一交易日
  29. 引擎时序:确认后跑引擎且只跑一次;RISK-002 不翻倍
  30. 部分成交(D28):T 日~T+1 间可用份额被其它在途单抢占 → 确认时实际 = min(申请, 可用),未确认部分占用释放,记 partial
  31. ⭐ 紧池成功率(本次改造核心收益):需求份额 = 供给份额的紧池场景下受理成功率目标 100%(v0.x 实时扣减实测 80%;本模型为设计目标,待第 6 步重测);不超卖硬不变量继续成立

10. 待确认项(已拍板)

拍板状态(2026-09-10 ~ 09-11):Q1/Q2/Q11(T+1 模型、受理响应不返回折算金额、按交易日历真模拟)、Q13(适当性 T+1 复核)、Q15(redeem 份额申报)均经用户拍板「按真实业务走」;Q8/Q9/Q10/Q12/Q14 已定稿。

# 问题 定稿结论
Q1 是否模拟 T+1 权益登记延迟? 真模拟:T 日受理不扣份额,T+1 批量确认才做权益变更,T+2 可用(§2.6)
Q2 响应返回折算金额? 受理响应不返回(T 日净值未公告);确认后由查询接口返回(§5.3)
Q3 批次 confirmed_at 如何反推? 按客户/产品错开持有期
Q4 跨机构校验? 保留:补 09-seed-org.sql 种子,错误码可测
Q5 巨额赎回比例确认? 基金层不做(无现金头寸);投资者层部分成交(D28,§2.6.5)
Q6 关联字段放哪 Core 加 convert_group_id,lot 明细及其净值落 Core,其余落 agent 库
Q7 幂等键是否强制? 选填,未带则按现有逻辑
Q8 新增列 subscribe_fee_rate 与 fee_rate 关系 新增独立列,种子显式给值;fee_rate 保持原义并补 COMMENT = 管理费率
Q9 补差费口径选 A 还是 B? B(价外法两端差)为默认;A 以 convert_diff_fee_mode='rate_diff' 配置保留
Q10 份额精度保留几位? 主流 2 位四舍五入;按产品 core_share_rule 配置覆盖(D27,§2.5.3)
Q11 T+1 确认时序怎么建模? 按交易日历真模拟:T+1 = 受理日后第 1 个交易日(core_trade_calendar)
Q12 最低持有余额触发后怎么处置? 两种都做:min_hold_action ∈ {force_transfer, force_redeem}
Q13 适当性是否 T+1 复核? 复核(确认段第 2 步,D25)—— 用户拍板推翻「不复核」默认
Q14 受理单超 SLA 多久置 expired? 2 个交易日(配置项 convert_confirm_sla_days)
Q15 redeem 是否改份额申报? 本期改为份额申报(D26)—— 用户拍板推翻「本版不做」默认

11. 并发控制(FR-C18)

场景 机制
T 日受理:可用份额校验 + 落单 「客户 + 产品」维度短临界区(run_locked(f"convert:avail:{customer_id}:{out_product_id}"))+ 单库事务
T+1 确认:批次扣减 条件 UPDATE WHERE remain_qty >= :q,rowcount != 1 判冲突;批处理串行,此处冲突即数据异常信号,需告警
批处理串行 run_locked(f"convert:confirm:{accept_date}"),防两个批处理同时跑同一受理日
重复提交 core_convert_request.uk_idem(与流水同库)
幂等执行权 run_locked(f"convert:idem:{client_request_id}", ...),未抢到返回 202(§7.4)
agent 详情补写 必须 run_locked(f"convert:rerun:{convert_group_id}", ...);并发补跑会审计双写(审计表只 INSERT 无法去重)
锁实现 复用 locks.py 双层锁(Redis 主 + 进程内备)

锁 TTL 风险:locks.py 的 Redis 锁为 SET NX EX,30 秒自动过期。受理段临界区不含折算/批次扣减,比旧模型更短;确认段批处理整体可能超过 30s → 锁提前释放会让第二个批处理进入。缓解:① 每条确认前先查该 convert_group_id 是否已有流水,有则跳过(幂等);② 抢到锁后重新查受理单状态(锁 + 状态二次校验双保险);③ 批处理默认单线程串行。

⚠️ 可用份额校验禁止「先 SELECT 后 UPDATE」:校验与落单必须在同一临界区 + 同一事务内完成,否则并发两笔会同时通过校验 → 超额占用。

12. 边界口径

项 口径
hold_days 定义 = (赎回确认日 T+1 − 被赎批次 confirmed_at).days(不含申请日);第一参必须传本次转换受理单的确认日 T+1,严禁传受理日 T,亦严禁传被赎批次的申购 confirmed_at——否则边界少算 1 天(如真实满 7 日算成 6 日 → 误收 1.5%);分档左闭右开 [min, max);该公式仅适用于转换转入新批次,存量批次 / 普通申购以交易日 T 为准
舍入顺序 逐批次先舍入后求和(§2.5)
金额舍入 2 位,四舍五入(ROUND_HALF_UP)
份额舍入 2 位四舍五入为主,按产品 core_share_rule 覆盖(D27);尾差在基金资产列支
份额足够判据 可用份额 = Σ core_share_lot.remain_qty − Σ(未终态受理单 requested_qty);不用 core_holding.qty。批次表是权威源,两表失配时以批次为准并 logger.warning 记差异
校验优先级 ① 份额足够 → ② 最低转出份额(申请 == 全部可转份额时豁免 min_redeem)→ ③ 转出后余额低于最低持有则强制全转
全额豁免理由 真实业务全额赎回/转出豁免最低份额门槛
fund_company/ta_code NULL 种子补齐后全量非 NULL;未来扩展产品未补全时,残余 NULL 才跳过跨机构校验放行
can_subscribe/can_redeem 永不为 NULL(DEFAULT 1),不适用 NULL 跳过规则
持有期起算日 转换转入新批次以确认日 T+1 为准(比 T 起算少 1 天、费率档更严);普通申购新批次与存量批次以交易日 T 为准
同产品自转换 400 SAME_PRODUCT
T+2 可赎回判据 转入批次 confirmed_at 的下一交易日 ≤ 今日才计入可用份额;由 core_trade_calendar 推算,不加列

强制全转判断流程:

① 可用份额足够?  可用份额 = Σ(remain_qty) − Σ(未终态受理单份额) >= 申请份额
   ├─ 否 → 400 INSUFFICIENT_SHARES
   └─ 是 ↓
② 最低转出份额?  申请份额 >= min_redeem_qty
   ├─ 否,且 申请份额 == 全部可转份额 → 豁免通过(全额转出豁免)
   ├─ 否,且 申请份额 <  全部可转份额 → 400 BELOW_MIN_QTY
   └─ 是 ↓
③ 余额 = 全部可转份额 − 申请份额;  余额 < min_hold_qty ?
   ├─ 否 → 正常转出「申请份额」
   └─ 是 → 强制处置:actual_qty = 全部可转份额,forced_full_transfer = true
            (min_hold_action = 'force_transfer' → 强制全转;= 'force_redeem' → 强制赎回剩余)

触发条件严格为 余额 < min_hold_qty(不是 ≤),否则「恰好等于下限」会被误判为强制全转。例:持 6000、申请 5000、min_hold_qty = 1000 → 余额 = 1000,不触发,正常转 5000。

13. 评审记录

设计文档的自检与独立审查方法论见用户级 skill design-doc-selfcheck(13 问 + 格式契约三问 + 独立 AI 联网审查协议)。本 PRD 的逐轮审查意见、判定与修订对照归档在 docs/项目框架设计/基金转换-审查意见处置表.md;本节仅保留结论性记录。

轮次 时间 结论
立项与多轮审查 ≤2026-09-10 五轮(v0.7→v0.8)+ 架构校准轮 33 条(接受 27 / 修正性接受 5 / 驳回 0)+ 自查:补差费率来源、持有期重置、资金流简化、批次生命周期、事务边界、幂等执行权、引擎只跑一次、nav 下沉 Core 等全部收敛(逐轮意见归档于处置表)
v1.0 立项轮 2026-09-10 用户逐项拍板「按真实业务走」10 项;独立子代理审查 4 重要全修
v1.0 第 2 轮收口 2026-09-11 独立子代理 ×2:9 项修订确认 + 新增重要 2 + 可选 2 全修;达到可定稿标准
架构 v2.0 决策回填 2026-09-11 依架构 v2.0(D25~D30)回填本 PRD:适当性 T+1 复核、redeem 份额申报、按产品舍入、部分成交、资金流中转、A/C 互转
v1.1 精简重写 2026-09-11 按用户要求整体重写为最终态:正文剥除全部历史版本堆叠与覆盖注(历史轮次仅在本节留结论),口径与架构 v2.1 一致;已通过独立 AI 联网审查,已定稿(2026-09-11 用户批准)

14. 自检清单

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

二补 · 格式契约三问:

# 问 答案
补-1 展示位数/精度写在哪? §2.5.2 分类规格表 + convert_service._q():金额/份额 2 位(按产品覆盖)、净值/费率/尾差 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 释放占用(功能正确性要求)。