docs: 基金转换(convert)设计阶段成果入库
此前 AIcoding 第 1~3 步的产出仅存在于本地磁盘、未纳入版本库,本次一次性入库, 补齐「设计可追溯」。本提交为纯文档 + 一个实算脚本,**代码零改动**,接口契约与表结构不变。 入库 6 个文件: - docs/PRD/PRD-基金转换交易.md(v0.9 · 需求权威:FR-C1~C16 / §4 表结构 / §5 接口 / §9 验收) - docs/项目框架设计/架构设计-基金转换交易.md(v1.0 · 实现依据:D1~D20 / §5 事务补偿 / §11.1 DB 账号分离 / §15 任务映射 T-0~T-13 与 §15.1 依赖拓扑) - docs/项目框架设计/基金转换-审查意见处置表.md(33 条外审处置 + §九 用户拍板记录) - docs/项目框架设计/评审待办-风控主架构与基金转换.md(评审逐条判定 + 核实证据) - docs/交接文档-基金转换.md(本线交接入口,读完即可开工) - scripts/dev/calc_convert_demo.py(PRD §5.3 主示例实算回填脚本——派生值禁手算) 门控:PRD 已定稿并经独立评审闭环(M-7 已满足);架构 v1.0 独立评审 13 条建议 0 悬空。 下一步:AIcoding 第 4 步「产出开发计划」,输入为架构 §15 / §15.1。
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,187 @@
|
|||||||
|
# 交接文档 · 基金转换(convert)交易线
|
||||||
|
|
||||||
|
> **版本**:2026-09-10 **v1.0(设计阶段完结,交接给开发会话)**
|
||||||
|
> **用途**:**给接手本线的 AI / 人**。读完本文即可开工,**不需要重读全部代码**。
|
||||||
|
> **代码基线**:分支 `risk-control-agent`(HEAD 以 `git log -1` 为准);pytest **510 绿**。
|
||||||
|
> ⚠️ **本线代码尚未改动一行** —— 当前只完成了「讨论需求 → PRD → 架构设计」三步(AIcoding 流程第 1~3 步),**下一步是第 4 步开发计划**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚡ 当前状态(一句话)
|
||||||
|
|
||||||
|
| 项 | 状态 |
|
||||||
|
| --- | --- |
|
||||||
|
| 第 1 步 讨论需求 | ✅ 完成(多轮讨论 + 外部事实核验) |
|
||||||
|
| 第 2 步 PRD | ✅ **v0.9 定稿**(33 条外审闭环 + 2 处架构回填) |
|
||||||
|
| 第 3 步 架构设计 | ✅ **v1.0 定稿**(独立评审通过,13 条建议 0 悬空) |
|
||||||
|
| 门控 M-7(PRD 未定稿不得进第 4 步) | ✅ **已满足** |
|
||||||
|
| 第 4 步 开发计划 | ⬜ **← 下一步做这个** |
|
||||||
|
| 第 5 步 todo 开发 / 第 6 步 集成测试 | ⬜ 未开始 |
|
||||||
|
| **代码改动** | ⬜ **零**(架构里的 T-1 之后才有代码) |
|
||||||
|
|
||||||
|
**开工前必须先做的两件阻断前置**:**T-0**(sqlite/MySQL 列名统一)与 **T-0b**(DB 账号分离)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 五分钟背景
|
||||||
|
|
||||||
|
### 1.1 项目是什么
|
||||||
|
|
||||||
|
XingHuo 智能财富管家:金融四 Agent(客户财富 / 代理人 / 数据分析 / 风控)共用数据层与合规底座,**四 Agent 不互调 LLM**。
|
||||||
|
|
||||||
|
技术栈:Python 3.13 + FastAPI + SQLAlchemy Core(**text SQL,无 ORM**)+ MySQL 双库(`jinrong_core` 模拟 Core / `jinrong_agent` 业务)+ Redis + Milvus + Neo4j。
|
||||||
|
|
||||||
|
### 1.2 本线要做什么
|
||||||
|
|
||||||
|
`docs/PRD/PRD-风控监测Agent.md` 的 **FR-1 一期显式拒收 `convert`**(`trade_gateway` 里直接 400 拒绝)。
|
||||||
|
本线把它**放开为可交易**:新增基金转换(转出 A 基金 → 转入 B 基金)的完整链路。
|
||||||
|
|
||||||
|
### 1.3 业务本质(看懂这段才不会写错)
|
||||||
|
|
||||||
|
**一次基金转换 = 一次赎回(转出)+ 一次申购(转入)**,但**不是两笔独立交易**:
|
||||||
|
|
||||||
|
- **未知价法**:按申请当日净值计价,T 日申请 → **T+1 权益登记**(扣转出、登转入)→ T+2 可用
|
||||||
|
- **逐批次计费**:按 `core_share_lot` 的批次,**每批按各自持有期**查赎回费率(<7 日 1.5% / 7–30 日 1.0% / 30–180 日 0.5% …)
|
||||||
|
- **先进先出(FIFO)**:注册日期在前的批次先转出
|
||||||
|
- **单笔计算法**:当日多笔转换**不合并**计费
|
||||||
|
- **补差费**:转入费率高于转出费率时补差额(**口径 B 为默认**,见 §3)
|
||||||
|
- **份额/金额一律 2 位四舍五入**,误差「在基金资产列支」(`rounding_diff` 可正可负)
|
||||||
|
|
||||||
|
**合规基准**:证监会公告〔2025〕22 号《公开募集证券投资基金销售费用管理规定》(2026-01-01 施行)
|
||||||
|
—— 申购费率上限(主动偏股 ≤0.8% / 其他混合 ≤0.5% / 指数·债券 ≤0.3% / 货基 0)、赎回费下限(<7 日 ≥1.5% …)、赎回费**全额计入基金财产**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 设计资产(读这四份就够,不用翻代码)
|
||||||
|
|
||||||
|
| 顺序 | 文档 | 看什么 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **1** | 本文 | 全局、状态、坑、禁止事项 |
|
||||||
|
| **2** | `docs/项目框架设计/架构设计-基金转换交易.md`(**v1.0**) | **怎么实现**:§2 目录 / §3 时序 / §4 决策 D1~D20 / §5 事务补偿 / §7 计算口径 / §8 契约 / §9 DDL 落点 / §10 测试 / §11 配置 / §12 风险门禁 / §15 任务映射 |
|
||||||
|
| **3** | `docs/PRD/PRD-基金转换交易.md`(**v0.9**) | 需求权威:场景 FR-C1~C16 / §4 表结构 / §5 接口 / §7 流程与事务 / §9 验收 |
|
||||||
|
| 4 | `docs/项目框架设计/评审待办-风控主架构与基金转换.md` | 评审逐条判定与核实证据(§二)+ 交叉点(§三)+ 执行期风险(§四) |
|
||||||
|
| 5 | `docs/项目框架设计/基金转换-审查意见处置表.md` | 33 条外审处置 + **§九 用户拍板记录** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 已拍板的关键决策(**不能改**,改了会破坏已验证方案)
|
||||||
|
|
||||||
|
| # | 决策 | 口径 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 补差费 | **口径 B(价外法两端差)为默认**,A(费率差法)以 `convert_diff_fee_mode='rate_diff'` 保留 |
|
||||||
|
| 份额精度 | **2 位 `ROUND_HALF_UP`**(不是 4 位、不是向下舍);金额同 |
|
||||||
|
| T+1 确认 | **建模**:`confirmed_at = T+1 自然日`(近似,注释标明真实为工作日);持有期自确认日起算 |
|
||||||
|
| 最低持有余额 | **两种都做**:`min_hold_action ∈ {force_transfer, force_redeem}`,默认 `force_transfer` |
|
||||||
|
| 普通申赎无批次 | **不跳过** → 按 `core_holding.as_of` **兜底补建**初始批次(与 `rebuild_lots.py` **同调** `lot_bootstrap.bootstrap_lots`,D18) |
|
||||||
|
| 转出归零 | **保留 `qty=0` 行**,不删;持仓查询过滤 `qty <= 0` |
|
||||||
|
| Core 侧明细 | **仅补偿,不对外查询**(`core_convert_lot_detail`) |
|
||||||
|
| 引擎时序 | **阶段 1.5**:阶段一提交后、阶段二之前**同步**跑 `process_convert_event`;失败 **不阻断交易**(D17) |
|
||||||
|
| 引擎异常钩子 | `on_error_hook=None` 预留(D19),二期注入 Redis 事件/补偿队列;hook 异常不得反噬主流程 |
|
||||||
|
| **DB 账号分离** | **D20**:`xh_core_ro`(SELECT)/ `xh_core_rw`(4 表写,无 DELETE/DDL)/ `xh_agent_rw`(`audit_log` 只授 INSERT);见 §11.1 |
|
||||||
|
| 批次上限 | `convert_batch_max_lots=200`,超限 400 `TOO_MANY_LOTS`,**不自动分拆**;**单笔 = 单事务 = 单 `convert_group_id`** |
|
||||||
|
| 交易发起主体 | **仅客户本人或 `risk_demo`**;**代理人只能查、不能交易**(`simulate.py:42` 现状即如此,convert 沿用) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 开工前置(⛔ 阻断,两个都要绿)
|
||||||
|
|
||||||
|
| 任务 | 内容 | 门禁 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **T-0** | 以 MySQL 为准统一 sqlite DDL:`tests/_ddl.py` 的 `core_holding` 改成 `qty`/`cost_amount`/`as_of`/`pnl_pct` **+ 主键**(现状 `tests/_ddl.py:50-54` 只有 `market_value`/`quantity` 且**无 PK**);`conftest.py` 加启动期列名断言 | `pytest tests/test_db.py::test_core_holding_columns` **必绿**才准跑 T-1 之后 |
|
||||||
|
| **T-0b** | DB 账号分离(D20):`scripts/core/00-grant.sql` 建 3 账号授权;`settings.py` 加 3 组账号;`db.py` 改 `get_engine(database, role="rw")`(缓存键改 `(库名, 角色)`);`core_ro` 走 `ro`、**gateway 写路径走 `rw`(读仍 `ro`)** | 3 个真 MySQL 权限断言:ro 账号 `INSERT` 被拒 · `audit_log` 改/删被拒 · rw 账号改第 5 张表被拒 |
|
||||||
|
|
||||||
|
> **账号未配置时回退单账号**(`settings.mysql_user`)——本地开发不被阻塞,但**真 MySQL 集成测试必须跑在拆分账号下**。
|
||||||
|
> **sqlite 测试路径零影响**(无账号概念),现有 510 用例**一行都不用改**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 任务清单(架构 §15 已定稿,可直接作为开发计划输入)
|
||||||
|
|
||||||
|
```text
|
||||||
|
T-0 (⛔ 列名统一 + 门禁)─┐
|
||||||
|
T-0b(⛔ DB 账号分离 D20)─┴─► T-1(DDL/种子 00-grant·01-ddl·07·08·09·02-agent + reset.ps1)
|
||||||
|
└─► T-2(纯函数 types/calc/fee/nav/lot_bootstrap)─► T-2b(calc_convert_demo 实算回填)
|
||||||
|
├─► T-3 ─┐
|
||||||
|
├─► T-4 ─┼─► T-6(阶段一事务)─► T-7(编排 · 关键路径)
|
||||||
|
└─► T-5 ─┘ ├─► T-9(API + gateway 分派)
|
||||||
|
└─► T-12(补偿脚本)
|
||||||
|
T-8(引擎改造 · 可全程并行)─► T-11(工具汇总去重)
|
||||||
|
T-10(批次维护 · 回归风险最大:先跑 510 基线再动)
|
||||||
|
└─► T-13(全量回归 + 50 并发压测 + 性能补录)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **并行组 A**:T-3 / T-4 / T-5(只依赖 T-1,可与 T-2 同时开工)
|
||||||
|
- **并行组 B**:T-8 与 T-2 之后任意阶段并行
|
||||||
|
- **关键路径**:T-0 → T-1 → T-2 → T-6 → T-7 → T-13
|
||||||
|
- **高风险任务**:**T-10**(改 `trade_gateway` 主流程,直接影响现有 510 用例)
|
||||||
|
- **基线**:510 → 预计 **580~600** 用例
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 执行期风险(用户 2026-09-10 提供,架构 §12 已并入三列表)
|
||||||
|
|
||||||
|
| # | 风险 | 必须守住的验收点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | T-0 未完成就跑 T-1~ | CI 门禁 `test_core_holding_columns` 必绿 |
|
||||||
|
| 2 | 批次补建规则写两处 → 漂移 | D18 抽 `lot_bootstrap` 纯函数,两侧同调;单测断言同源 |
|
||||||
|
| 3 | `Decimal.quantize` 默认 `ROUND_HALF_EVEN` | `calc/fee/nav` **所有量化处**显式传 `ROUND_HALF_UP`;`.5` 边界断言 |
|
||||||
|
| 4 | 阶段 1.5 引擎异常丢预警 | D19 预留 `on_error_hook`;hook 抛异常不得反噬主流程 |
|
||||||
|
| 5 | `convert_batch_max_lots=200` 超限 | 400 错误体带 `batch_count`/`max_lots`;前端提示「请拆分多笔申请」 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 本线特有的坑
|
||||||
|
|
||||||
|
1. **sqlite 与 MySQL 列名不同名**(T-0 根因):`tests/_ddl.py:50-54` 的 `core_holding` 用 `market_value`/`quantity`,MySQL 用 `qty`/`cost_amount`/`as_of`/`pnl_pct`,且**无 PK**。手工同步无迁移工具,**不先统一一定在集成测试期才炸**。
|
||||||
|
2. **`Decimal.quantize()` 默认是银行家舍入**(`ROUND_HALF_EVEN`),必须显式传 `ROUND_HALF_UP`,否则 `.5` 边界错。
|
||||||
|
3. **`core_ro.py:390` / `:432` 写死了 `AND trade_type IN ('subscribe','redeem')`** —— 不改的话 convert 在 RISK-001/002/003 聚合里**完全隐形且不报错**(架构 D7 `_amount_view` 就是为此)。
|
||||||
|
4. **改路由必须同步** `tests/test_main.py::test_all_routers_mounted` 的路径清单,否则必红。
|
||||||
|
5. **测试/演示库重灌两步缺一不可**:`scripts/core/reset.ps1` + `scripts/demo/prepare_risk_demo.sql`(只跑第一步 → 27/33 客户风评过期,是种子设计不是 bug)。
|
||||||
|
6. **用系统 Python 3.13.14** 跑测试:`C:/Users/YUAN/AppData/Local/Programs/Python/Python313/python.exe`(managed 3.13.12 未装依赖)。
|
||||||
|
7. **sqlite 不支持绑定 `Decimal`**,测试插 `core_trade.amount` 须转 float。
|
||||||
|
8. **双库无跨库事务**(`jinrong_core` / `jinrong_agent` 各自 engine)→ 所以有「三阶段 + 补偿」,不要试图写一个跨库事务。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 禁止事项
|
||||||
|
|
||||||
|
**项目五条红线**(碰了就是事故):Core 表只读 · 审计表只 INSERT · 代理人草稿不外发 · **仅 R-02 可阻断交易** · 四 Agent 不互调 LLM。
|
||||||
|
|
||||||
|
**本线纪律**:
|
||||||
|
|
||||||
|
- ❌ 不改表结构 / 接口协议**而不经用户确认**(改表需确认是项目硬规则)
|
||||||
|
- ❌ 不做清单外的改动(看到别的问题**记下来**,别顺手改)
|
||||||
|
- ❌ 不动 `tests/_ddl.py` 之外的第二份 sqlite DDL(单一事实源)
|
||||||
|
- ❌ 不新增第三方依赖
|
||||||
|
- ❌ 所有阈值**不得硬编码**(走 `settings.py`,架构 §11)
|
||||||
|
- ❌ 提交前必须 `pytest` 全绿;**推送前须用户目视验收**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 另一条线的遗留(**别混淆,不在本线范围**)
|
||||||
|
|
||||||
|
**架构改进与稳定性加固线**(T-101~T-202)**2026-09-10 已全部收尾闭环**:
|
||||||
|
|
||||||
|
1. **§7.2 七项手工冒烟已补跑完毕,7/7 PASS**(含「停 Redis → 退回进程内锁」「调换装饰器 → T-202 守卫变红」两项)
|
||||||
|
2. ~~`037ce7e` 已 commit 未 push~~ → **本文原表述已过期**:实测 `git ls-remote`,远程 `risk-control-agent` = `fffb78a` = 本地 HEAD,`037ce7e` 早在祖先链上,**该线全部提交已推送,无待推内容**
|
||||||
|
3. 冒烟产生的演示库残留已按 SOP §2 重灌清除,全量 **510 passed** 复绿
|
||||||
|
|
||||||
|
明细与入口见 **`docs/交接文档-架构改进.md`**。本线 **T-0b 与其无关**(那是基金转换自己的前置)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 完成后要回写的文档
|
||||||
|
|
||||||
|
1. `docs/memory/TODO.md` —— 勾选进度
|
||||||
|
2. `docs/memory/MEMORY.md` §0 —— 进度与仓库地图
|
||||||
|
3. `docs/项目框架设计/开发计划-基金转换交易.md` —— 第 4 步产出(**待创建**)
|
||||||
|
4. `docs/memory/YYYY-MM-DD.md` —— 当日工作日志
|
||||||
|
5. 本文件 —— 状态块与任务清单勾选
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 有问题怎么办
|
||||||
|
|
||||||
|
- **发现文档与代码不符** → 以代码为准,并在 TODO 备注记下差异(先核实,别急着改文档)
|
||||||
|
- **对设计有疑问** → 查架构对应章节;仍不明确则**停下问用户**,不要猜
|
||||||
|
- **评审意见与自己的判断冲突** → 先核实证据(文件行号),**不盲从也不护短**;驳回要给出核实过的证据
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# 基金转换 · 外部审查意见处置表
|
||||||
|
|
||||||
|
> 日期:2026-09-10 · 审查结论原文:**❌ 不建议进入编码**(33 条:B×5 / I×7 / M×7 / L×7 / S×3 + 自检九问复核)
|
||||||
|
> 处置原则(用户红线):**逐条判定 = 接受 / 修正性接受 / 驳回**;驳回与修正性接受必须给出核实过的证据。
|
||||||
|
> 判定基准:**贴近真实业务**(证监会公告〔2025〕22 号 + 10 家基金公司转换公告,见架构 §0.5 来源清单)
|
||||||
|
> 结果:**接受 27 · 修正性接受 5 · 驳回 0 · 合并 1**(S-1 并入 B-1),另**自查补 2 条**(X-1 / X-2)
|
||||||
|
> 后续:本表结论 → PRD v0.8 → 架构设计 v1.0 → 独立 AI 评审 → 第 4 步开发计划
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、阻断级(B-1~B-5)
|
||||||
|
|
||||||
|
| # | 审查意见 | 判定 | 处置与证据 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| B-1 | PRD「已定稿」与架构 §0 合规查证矛盾 | **接受** | PRD 前言改「**v0.7 已冻结**(架构校准发现合规问题),待出 v0.8」;架构 v0.2 加冻结声明;新增门控:PRD v0.8 未产出不得进第 4 步 |
|
||||||
|
| B-2 | 申购费率种子全超法定上限 | **接受** | 按架构 §13.3 重定:货基 0 / 债基·指数 **0.0030** / 其他混合 **0.0050** / 主动偏股 **0.0080**。依据 22 号文 §8。§5.3 示例、§9 第 19 条同步重算 |
|
||||||
|
| B-3 | 赎回费 7–30 日档低于法定下限、边界与 180 日不符 | **接受** | 新档:`<7 日 1.5%` / `7–30 日 1.0%` / `30–180 日 0.5%` / `180–365 日 0.25%` / `≥365 日 0`。依据 22 号文 §10。PRD §2.1、07 种子、§5.3、§9 同步 |
|
||||||
|
| B-4 | 份额 4 位向下舍去,与真实(2 位四舍五入)不符 | **接受** | 改 **2 位 `ROUND_HALF_UP`**;`rounding_diff` 语义改「可正可负,在基金资产列支」。`core_share_lot.remain_qty` 存储精度保持 DECIMAL(18,4)(真实 TA 内部精度高于展示),**计算与展示按 2 位** |
|
||||||
|
| B-5 | T+1 确认时序与真实不符 | **修正性接受**(范围需收窄,方向需澄清) | **只改「转入端新批次」的 `confirmed_at = T+1`**。证据:PRD §4.4 表格已区分三行(转入新批次 / 普通申购新批次 / 转出·赎回不产新批次);转出批次是**历史日期**,`hold_days = (交易日 − confirmed_at).days` 恒为正,不受影响。<br>**方向澄清**:审查称「当前设计使持有期比真实**多** 1 天」——实际相反。真实持有期**自确认日(T+1)起算**,比从 T 起算**少** 1 天(持有期更短、费率档更严格)。PRD Q1 精确化为:「不模拟 T+1 的**权益登记延迟**(转入份额立即可用),但 `confirmed_at` 取 T+1」 |
|
||||||
|
|
||||||
|
## 二、重要级(I-1~I-7)
|
||||||
|
|
||||||
|
| # | 审查意见 | 判定 | 处置与证据 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| I-1 | 补差费口径未拍板(A 费率差 vs B 价外法两端差) | **接受** | PRD 新增 **Q9**:明确本期选 **B(价外法两端差)**,理由:中国申购费标准算法即价外法(净申购额 = 金额/(1+费率)),B 与之数学自洽,且为国投瑞银/东海/华商/金鹰/鹏华新版主流;A 以 `convert_diff_fee_mode` 配置保留,两口径均有单测。PRD §2.1 补双口径公式与数值差异示例 |
|
||||||
|
| I-2 | `hold_days` 缺边界定义 | **接受** | PRD §12 补:`hold_days = (交易日 − confirmed_at).days`(**不含申请日**);分档**左闭右开** `[min, max)`;**满 7 日归 7–30 日档(1.0%)**,满 30 日归 30–180 日档(0.5%)。与架构 §7 同步 |
|
||||||
|
| I-3 | 阶段二补偿自动/手动未声明 | **接受** | PRD §7.1 补:一期为「**监控告警 + 人工脚本补偿**」(可接受简化),SLA 24h;`cleanup_pending_convert.py` 由定时/手动触发。**另补审查未提的缺口 X-1:引擎执行时序**——阶段一提交后、阶段二之前同步跑引擎;引擎失败沿用现有 `engine_error` 模式(架构 §X) |
|
||||||
|
| I-4 | 强制全转触发条件不清晰 | **接受** | PRD §12 补判断流程图:① 份额足够(Σ remain_qty)→ ② 最低转出份额(申请 == 全部可转份额时**豁免** min_redeem)→ ③ **余额 < min_hold_qty 才触发强制全转**。审查例(持 6000、申请 5000、min_hold_qty=1000):余额 1000 **不低于**下限 → **不触发**,正常转 5000 |
|
||||||
|
| I-5 | `min_hold_action` 两种模式 PRD 未回应 | **接受** | 两种都做:`core_product` 加 `min_hold_action ENUM('force_transfer','force_redeem') DEFAULT 'force_transfer'`。依据:阈值大(100/500/1000 份)→ 强制赎回(财通/汇添富);阈值小(0.1/1 份)→ 强制全转(中银/东海) |
|
||||||
|
| I-6 | 份额精度对现有 510 用例影响未评估 | **接受(已实测,影响 = 0)** | 实测 `grep`:现有测试仅 **2 个文件**触碰 `core_holding`(`test_chat_tools.py`、`test_concentration_c4.py`),且只断言 `market_value` / `quantity`,**无任何份额精度断言** → 改动影响为 0。<br>**但实测发现真坑**:sqlite `_ddl.py:53` 列名 `quantity`,MySQL DDL 为 `qty`——convert 首次要 `UPDATE core_holding.qty`,该失配会在本次开发**第一次暴露**。T-1 必须补 `qty`/`cost_amount`/`as_of`/`pnl_pct` |
|
||||||
|
| I-7 | RISK-006 转换后集中度未定义 | **接受** | 明确:引擎在阶段一**提交后**执行,`concentration_profile` 读到的是**已更新**持仓(含转入端新持仓、已扣减的转出端)→ 转换的集中度连带效应天然被覆盖,无需新增规则 |
|
||||||
|
|
||||||
|
## 三、中等级(M-1~M-7)
|
||||||
|
|
||||||
|
| # | 意见 | 判定 | 处置 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| M-1 | 重试判定 SQL 依赖 `idx_convert_group` | 接受 | 开发计划门控:**T-1(DDL 含索引)必须先于 T-7(幂等重试逻辑)** |
|
||||||
|
| M-2 | 409 `LOT_CONFLICT` 重试策略缺失 | 接受 | PRD §5.4 增行:调用方自行重试,建议最多 3 次,间隔 100/200/400ms |
|
||||||
|
| M-3 | 幂等键格式未指定 | 接受 | 客户端生成、格式自由、**≤64 字符**、建议含时间戳便于排查 |
|
||||||
|
| M-4 | `out_amount` 与 `convert_amount` 语义重叠 | 接受 | §5.3 注释分组:`out_amount` = 原始转出额;`convert_amount` = 扣赎回费后可用于转入的金额 |
|
||||||
|
| M-5 | `convert_batch_max_lots=200` 超限无错误码 | 接受 | 超限返回 **400 `TOO_MANY_LOTS`** |
|
||||||
|
| M-6 | `pnl_pct` 更新口径未同步 | 接受 | PRD FR-C13 补:`pnl_pct = (market_value − cost_amount) / cost_amount`,`cost_amount = 0` 时置 0 |
|
||||||
|
| M-7 | PRD 与架构同步依赖未管理 | 接受 | 硬性门控写入 PRD v0.8 前言 + 架构冻结声明(已加) |
|
||||||
|
|
||||||
|
## 四、低优先级(L-1~L-7)
|
||||||
|
|
||||||
|
| # | 意见 | 判定 | 处置 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| L-1 | 架构校准结论未回流 PRD 评审记录 | 接受 | PRD v0.8 §13 新增「**架构校准轮(2026-09-10)**」记录,附本处置表索引 |
|
||||||
|
| L-2 | 主审计应关联组而非单条 | 接受 | `input_summary` **以 `convert_group_id` 为主键关联**,附 `out_trade_id` / `in_trade_id` |
|
||||||
|
| L-3 | 响应示例字段过多 | 接受(可选) | §5.3 按「基础 / 转出端 / 转入端 / 规则引擎 / 审计」分组注释 |
|
||||||
|
| L-4 | 「仅 R-02 可阻断」表述歧义 | 接受 | 加注:**503 / 409 为技术故障码,不适用「仅 R-02 可阻断」业务规则** |
|
||||||
|
| L-5 | 撤单未预留字段 | 接受 | `risk_convert_detail` **建表即加** `status ENUM(...,'cancelled')` 与 `cancelled_at`(新表,零 ALTER 成本) |
|
||||||
|
| L-6 | 种子版本管理 | **修正性接受** | 不单独建 CHANGELOG 文件(维护成本高、易与 SQL 漂移);改为**每个种子 SQL 文件头加版本注释块**(版本/日期/依据/变更摘要) |
|
||||||
|
| L-7 | `rebuild_lots.py`「回滚」措辞误导 | 接受 | 改措辞为「**批次快照重建,不是交易回滚**」 |
|
||||||
|
|
||||||
|
## 五、结构问题(S-1~S-3)
|
||||||
|
|
||||||
|
| # | 意见 | 判定 | 处置 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| S-1 | 前言「已定稿」不符 | — | **并入 B-1** 处理 |
|
||||||
|
| S-2 | 评审记录过长(≈200 行,占 22%) | **修正性接受** | **不移到附录**。理由:用户红线要求「PRD 必须经独立 AI 审查、评审闭环可追溯」,删详录会破坏可追溯性。折中:正文前加「**阅读导航**」(新读者路径 + 每轮 3 行决策摘要表),详录保留原处 |
|
||||||
|
| S-3 | 待确认项需新增 | 接受 | 新增 **Q9 补差费口径 / Q10 份额精度 / Q11 T+1 建模 / Q12 min_hold_action** |
|
||||||
|
|
||||||
|
## 六、自查补充(审查未覆盖,本次一并收口)
|
||||||
|
|
||||||
|
| # | 发现 | 判定 | 处置 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| X-1 | **引擎执行时序未定义**——PRD §7.0 七步里没有「跑规则引擎」这一步 | 补充 | 插入为第 6.5 步:阶段一(Core 提交)→ **阶段 1.5:同步跑引擎(读全量两条流水 + 更新后持仓)** → 阶段二(回写 + 审计)。引擎异常沿用 `engine_error` + 本地日志,不阻断已成立的交易 |
|
||||||
|
| X-2 | **两条流水的 `trade_status`**——真实 T 日申请为「待确认」,T+1 才 confirmed;而 `_eligible` 只认 confirmed | 补充 | 定:两条流水**直接写 `confirmed`**(沿用模拟库现有行为,否则规则引擎读不到),折算结果以 `estimated=true` 标识;PRD 注明此为「不模拟 T+1 权益登记延迟」的连带简化,与 Q1 一致 |
|
||||||
|
|
||||||
|
## 七、自检九问复核(审查评价 + 本轮补正)
|
||||||
|
|
||||||
|
| # | 问 | 审查评价 | 本轮补正 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1~4 | 谁写 / 谁读 / 枚举 DDL / 跨库事务 | ✓ | 第 4 问「完整但补偿路径为手动」→ I-3 已明确一期手动 + SLA 24h |
|
||||||
|
| 5 | 种子数据 | ⚠️ 费率超上限 | B-2 / B-3 已重定合规值 |
|
||||||
|
| 6~8 | 并发 / 汇总语义 / 向后兼容 | ✓ | — |
|
||||||
|
| 9 | 示例自证 | ⚠️ 数字基于错误口径 | **T-2b:`scripts/dev/calc_convert_demo.py` 用生产同一套 `calc.py` 实算回填,禁止手算** |
|
||||||
|
| **10** | **(新增)外部事实核验** | — | 本次 4 项合规硬伤全部源于「只做内部自洽、未做外部核验」。新增第 10 问:涉及**费率 / 时限 / 资质 / 精度**等**行业有明文规定**的量,必须联网核对现行法规与真实业务公告并列来源 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、PRD v0.8 修订范围(下一步执行清单)
|
||||||
|
|
||||||
|
| 序 | 修订点 | 对应条目 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | 前言状态改「v0.7 已冻结,待 v0.8」+ 门控声明 | B-1 / M-7 |
|
||||||
|
| 2 | §1.4 非目标:撤单改「二期,字段已预留」 | L-5 |
|
||||||
|
| 3 | §2.1 补差费:双口径公式 + 选 B 的理由 + 数值差异例 | I-1 |
|
||||||
|
| 4 | §2.1 赎回费档位按 22 号文重排 | B-3 |
|
||||||
|
| 5 | §2.5 份额 2 位 `ROUND_HALF_UP`,误差在基金资产列支,`rounding_diff` 可正可负 | B-4 |
|
||||||
|
| 6 | §4.1 `risk_convert_detail` 加 `cancelled` / `cancelled_at`;`core_fee_rule` 加 `to_fund_ratio` | L-5 / D16 |
|
||||||
|
| 7 | §4.2 `core_product` 加 `min_hold_action` | I-5 |
|
||||||
|
| 8 | §4.3 种子费率改合规值;赎回费种子改新档 | B-2 / B-3 |
|
||||||
|
| 9 | §4.4 / Q1:`confirmed_at = T+1`(仅新批次),精确化表述 | B-5 |
|
||||||
|
| 10 | §5.3 响应:示例由脚本实算回填 + 分组注释 + 字段语义 | B-2 / M-4 / L-3 / T-2b |
|
||||||
|
| 11 | §5.4 增 `TOO_MANY_LOTS` + 409 重试策略 + 503/409 非业务阻断注 | M-2 / M-5 / L-4 |
|
||||||
|
| 12 | §7.0 插入「阶段 1.5 跑引擎」 | X-1 |
|
||||||
|
| 13 | §7.1 补偿路径:一期手动 + SLA 24h + 触发者 | I-3 |
|
||||||
|
| 14 | §7.3 审计关联 `convert_group_id` | L-2 |
|
||||||
|
| 15 | §10 新增 Q9~Q12 | S-3 |
|
||||||
|
| 16 | §12 补 `hold_days` 定义 + 强制全转判断流程图 | I-2 / I-4 |
|
||||||
|
| 17 | §13 新增「架构校准轮」评审记录 | L-1 |
|
||||||
|
| 18 | 种子 SQL 文件头加版本注释块 | L-6 |
|
||||||
|
| 19 | FR-C13 补 `pnl_pct` 口径;`rebuild_lots.py` 措辞订正 | M-6 / L-7 |
|
||||||
|
| 20 | 全文:示例数字按新费率/精度/时序重算(脚本实算) | B-2~B-5 |
|
||||||
|
|
||||||
|
> **执行状态(2026-09-10)**:以上 **20 项已全部落于 PRD v0.8**。
|
||||||
|
> 示例数字由 `scripts/dev/calc_convert_demo.py` 实算回填(口径 B:`diff_fee = 252.40`、`in_qty = 53456.95`、`rounding_diff = -0.0026`;口径 A 对照 `253.91` / `53455.36`)。
|
||||||
|
> **下一步**:架构设计 v0.2 按本表结论转 **v1.0** → 送独立 AI 评审 → 第 4 步开发计划。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、用户拍板记录(2026-09-10)
|
||||||
|
|
||||||
|
行业多口径 2 项 + 架构取舍 3 项,用户确认「**按建议**」:
|
||||||
|
|
||||||
|
| # | 事项 | 拍板结果 | 依据 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | 补差费默认口径 | **口径 B(价外法两端差)** | 与申购费价外法数学自洽;A 属近似写法,保留为 `convert_diff_fee_mode` 配置项 |
|
||||||
|
| 2 | 最低持有余额处置 | **两种都做**(`min_hold_action` ENUM 两值 + 种子按产品设定) | **法规无强制**(22 号文不涉及最低持有余额),两种均为真实基金合同约定 |
|
||||||
|
| 3 | P1 普通申赎无批次是否跳过 | **不跳过**:批次表覆盖全部交易类型;遇历史持仓无批次 → **兜底补建**,保证 FIFO 可执行、两表一致 | 跳过会导致批次表与持仓表永久失配 |
|
||||||
|
| 4 | P2 转出归零保留还是删行 | **保留(`remain_qty = 0`),不删行** | 与「审计只 INSERT」精神一致,保可追溯;避免 `core_convert_lot_detail` 断链 |
|
||||||
|
| 5 | P3 Core 侧明细是否对外查询 | **仅补偿,不对外查询** | 保持 Core 只读边界最小;对话侧走 agent 库粗粒度详情 |
|
||||||
|
|
||||||
|
> **落地位置**:第 1、2 项属业务口径 → 已落 **PRD v0.8**(§2.1.1 / §4.2 / §10 Q9·Q12,§10 已加拍板状态说明);
|
||||||
|
> 第 3~5 项属架构层 → **将在架构设计 v1.0 落实**(对应架构 v0.2 的 P1~P3 待拍板项)。
|
||||||
@@ -0,0 +1,761 @@
|
|||||||
|
# 架构设计说明书 · 基金转换(convert)交易
|
||||||
|
|
||||||
|
> 版本:**v1.0(PRD v0.9 配套 · 已过独立评审)** · 日期:2026-09-10
|
||||||
|
> 上游:`docs/PRD/PRD-基金转换交易.md`(**v0.9** —— v0.8 经 33 条外审闭环;v0.9 为架构评审回填 2 处契约)
|
||||||
|
> 分支:`risk-control-agent` · 关联:`docs/项目框架设计/架构设计-风控模块.md`
|
||||||
|
> 技术选型不变:FastAPI + SQLAlchemy Core(text SQL) + MySQL 双库 + Redis + LangGraph + DeepSeek。
|
||||||
|
> 本文定义**目录划分、模块职责、核心时序、技术决策、事务与并发、测试策略、任务映射**。
|
||||||
|
>
|
||||||
|
> ✅ **门控(M-7)已满足**:**独立评审通过**(2026-09-10,接受 10 / 修正性接受 3 / 驳回 0),
|
||||||
|
> 唯一硬前置 **T-0(R1:sqlite/MySQL 列名统一 + 启动断言)**;可进第 4 步开发计划。
|
||||||
|
> 逐条判定见 `docs/项目框架设计/评审待办-风控主架构与基金转换.md` §二。
|
||||||
|
> ✅ **评审建议已全部闭合**(R2~R5 / S1~S5 / Q4·Q6·Q10 共 13 条,落点索引见 **§13.2**,0 条悬空)。
|
||||||
|
>
|
||||||
|
> **版本演进**:v0.1(初稿)→ v0.2(真实业务校准版,2026-09-10 因外审 B-1 冻结)→ **v1.0(本版:按 PRD v0.8 + 处置表结论全面重写)**。
|
||||||
|
>
|
||||||
|
> **v1.0 内三次增补(同日)**:① 评审 13 条落地 → **§13.2 索引**;② 执行期风险 5 条 + **D18/D19** + **§15.1 依赖拓扑**;
|
||||||
|
> ③ **DB 账号分离 D20 / §11.1 / T-0b**(主架构评审 C1 · 用户拍板「按真实项目走」)。
|
||||||
|
>
|
||||||
|
> **v0.2 → v1.0 重写要点**
|
||||||
|
>
|
||||||
|
> | # | 变更 | 来源 |
|
||||||
|
> | --- | --- | --- |
|
||||||
|
> | 1 | 上游由 PRD v0.7 改为 **v0.8**;§0 由「校准报告」改为「**基准声明**」(问题已在 PRD 修复) | B-1 / M-7 |
|
||||||
|
> | 2 | 补差费**口径 B 定稿**(A 保留配置);最低持有**两种都做** | 处置表 §九 |
|
||||||
|
> | 3 | **新增阶段 1.5(同步跑规则引擎)** 到时序与事务 | PRD X-1 / D17 |
|
||||||
|
> | 4 | 补偿明确「**一期手动 + SLA 24h**」 | PRD I-3 |
|
||||||
|
> | 5 | `in_qty` 由「4 位向下」修正为 **2 位 ROUND_HALF_UP**(v0.2 §7 与 §1 原则 10 **自相矛盾**) | B-4 / P7 |
|
||||||
|
> | 6 | 种子 SQL **文件头版本注释约定** | PRD L-6 |
|
||||||
|
> | 7 | §13 P1~P8 **全部转为已拍板结论**;§14 自检扩为**十问** | 处置表 §九 |
|
||||||
|
> | 8 | `core_product` 新列数勘误:**8 列**(v0.2 §9 误写「7 列 + min_hold_action」) | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 合规与真实业务基准
|
||||||
|
|
||||||
|
> 本节是**基准声明**:v0.2 查证发现的合规问题**已在 PRD v0.8 全部修复**,此处固化口径供开发直接引用。
|
||||||
|
> 来源:证监会公告〔2025〕22 号《公开募集证券投资基金销售费用管理规定》(2026-01-01 施行)
|
||||||
|
> + 汇添富/中欧/鹏华/国投瑞银/东海/华商/金鹰/财通/中银/东方证券资管 10 家转换业务公告。清单见 **§0.5**。
|
||||||
|
|
||||||
|
### 0.1 法规硬约束(已落 PRD v0.8)
|
||||||
|
|
||||||
|
| 项 | 22 号文规定 | 本项目取值 | 落点 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 申购费率上限 | 主动偏股 ≤**0.8%** / 其他混合 ≤**0.5%** / **指数·债券 ≤0.3%** / 货基 0(§8) | 种子 **0.0080 / 0.0050 / 0.0030 / 0** | PRD §4.3 |
|
||||||
|
| 赎回费下限 | <7 日 ≥**1.5%** / 7–30 日 ≥**1.0%** / 30–180 日 ≥**0.5%**(§10) | 档表见 **§7**(+180–365 0.25% / ≥365 0) | PRD §2.1.3 |
|
||||||
|
| 赎回费归属 | **全额计入基金财产**(§10,旧规 75%/50%/25% 分级已废止) | `core_fee_rule.to_fund_ratio` = **1.0**(D16,留痕、不参与计算) | PRD §4.1 |
|
||||||
|
|
||||||
|
> **豁免不启用**:22 号文 §10 允许个人持满 7 日的指数/债基、机构持满 30 日的债基另约赎回费。
|
||||||
|
> 本项目**不走豁免**,保留完整费率梯度以便演示与测试。
|
||||||
|
|
||||||
|
### 0.2 行业惯例(已落 PRD v0.8)
|
||||||
|
|
||||||
|
| 项 | 真实做法(多份公告一致) | 本项目 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 份额精度 | 转入份额**四舍五入保留 2 位**,「误差在基金资产中列支」 | `in_qty` **2 位 ROUND_HALF_UP**(§7) |
|
||||||
|
| 金额精度 | 转出金额/赎回费/补差费**保留 2 位、四舍五入** | 同(逐批先舍入后求和) |
|
||||||
|
| T+1 确认 | T 申请(未知价法)→ **T+1 权益登记** → T+2 可用;**持有期自确认日起算** | 转入新批次 `confirmed_at = T+1`(D14;不模拟权益登记延迟,份额立即可用) |
|
||||||
|
| 先进先出 / 逐批计费 | 注册日期在前先转出;不同持有期分别计费 | FIFO + 逐批计费 |
|
||||||
|
| 单笔计算法 | 当日多笔转换**单笔**计费,不合并 | 逐笔 |
|
||||||
|
| 未知价法 | 以申请受理**当日**净值为基准 | PRD §2.2 |
|
||||||
|
| 转出可赎回 / 转入可申购 | 双方状态校验 | FR-C9a |
|
||||||
|
| 同机构限制 | 同一销售机构 + 同一管理人 + 同一 TA | `CROSS_ENTITY_NOT_SUPPORTED` |
|
||||||
|
| 冻结份额 / 撤单 | 冻结不可转;T 日结束前可撤 | 一期不做(撤单**字段已预留**,PRD §4.1) |
|
||||||
|
|
||||||
|
### 0.3 已拍板的多口径决策(处置表 §九 · 2026-09-10)
|
||||||
|
|
||||||
|
| 项 | 拍板结论 | 落点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 补差费口径 | **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 |
|
||||||
|
|
||||||
|
> ⚠️ **法规澄清(防误判)**:22 号文**不涉及「最低持有余额」**——它只规范销售费用。
|
||||||
|
> 「转出后余额低于最低持有份额怎么处理」属**基金合同/招募说明书约定**,法规**无强制**,故**不存在「按法律走」的答案**。
|
||||||
|
|
||||||
|
### 0.4 本期建模取舍(模拟库现实约束)
|
||||||
|
|
||||||
|
| 真实机制 | 是否建模 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| T+1 权益登记 | **是(简化)** | `confirmed_at = T+1 自然日`(无交易日历,自然日近似并注释);转入份额**立即可用**(不建模在途份额) |
|
||||||
|
| 交易日历 / 非交易日顺延 | 否 | 需日历表,模拟库无;标注为真实 Core 接入后补齐 |
|
||||||
|
| 赎回费归入基金财产比例 | 否(留字段) | 全额计入(恒 1.0),收益低;预留 `to_fund_ratio`。**二期启用时**:`fee.py` 增加「赎回费 ≠ 基金财产留存额」的分录,同时改 §7 计算链与审计字段(评审 R5,本期不参与计算) |
|
||||||
|
| 巨额赎回比例确认 | 否 | PRD 非目标;真实为「转出与赎回同优先级、同比例确认、**未确认部分不予顺延**」,写入注释 |
|
||||||
|
| 同一基金 A/C 份额互转 | 否 | 产品表无 `share_class` |
|
||||||
|
| 转换撤单 | 否 | 字段已预留(`risk_convert_detail.status` 含 `cancelled` + `cancelled_at`),二期启用 |
|
||||||
|
|
||||||
|
### 0.5 来源清单
|
||||||
|
|
||||||
|
| # | 来源 | 关键结论 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | 证监会公告〔2025〕22 号《公开募集证券投资基金销售费用管理规定》(2026-01-01 施行) | 申购费上限 §8、赎回费下限与全额计入基金财产 §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 号文取代) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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**
|
||||||
|
|
||||||
|
**本期新增(7 条):**
|
||||||
|
|
||||||
|
6. **计算与编排分离**:折算/费率/舍入做成**无 IO 纯函数**(`service/convert/calc.py`),编排层只取数、调纯函数、管事务。金额算错最难查,纯函数可穷举单测
|
||||||
|
7. **阶段一事务收口在单一仓储**:阶段一涉 5 类 core 写入,跨 repository 无法共享事务,故集中在 `convert_core_repository.apply_convert()` 的**一个方法 + 一个 connection** 内(D3)
|
||||||
|
8. **批次表必存 + 兜底补建(P1 已拍板)**:`core_share_lot` 覆盖**全部交易类型**;普通申赎有批次则 FIFO 扣/增,**无批次则兜底补建**(按 `core_holding.as_of` 建初始批次,与 `rebuild_lots.py` 同源规则),**不跳过、不阻断**。理由:真实 TA 份额必有注册批次;两表口径必须一致
|
||||||
|
9. **锁原语只增不改**:`locks.run_locked`(等待 + 降级)语义不动,新增 `try_lock`(单次尝试),避免影响现有 3 处调用点
|
||||||
|
10. **`Decimal` 一律显式 `quantize`,禁止裸浮点**:**金额 2 位、份额 2 位,均 `ROUND_HALF_UP`**;
|
||||||
|
舍入误差**在基金资产列支**,响应可选回传 `rounding_diff`(**可正可负**)。
|
||||||
|
⚠️ Python `Decimal.quantize()` 默认 `ROUND_HALF_EVEN`(银行家舍入),**必须显式传 `ROUND_HALF_UP`**(D·红线)
|
||||||
|
11. **响应金额全部 `str()` 化**:路由未声明 `response_model` 时 FastAPI `jsonable_encoder` 会把 `Decimal` 转 float(精度风险)。convert 分支返回前全部显式 `str()`
|
||||||
|
12. **单次转换最多跨 `convert_batch_max_lots` 个批次**(默认 200):超限返回 400 `TOO_MANY_LOTS`,防事务膨胀。
|
||||||
|
一期**不做自动分拆**(一次请求 = 一个 `convert_group_id` = 一个 core 事务);自动拆成多笔留二期(评审 R4)
|
||||||
|
13. **Core 读写账号物理分离**(主架构评审 **C1** / §三 交叉点 · **已拍板「按真实项目走」**):
|
||||||
|
`jinrong_core` 拆**只读账号**(Agent 侧全部读路径)与**最小写权限账号**(**仅 `app/gateway/`** 持有);
|
||||||
|
`get_engine(database, role)` 缓存键由「库名」改为「**(库名, 角色)**」。
|
||||||
|
**应用账号一律不持有 DDL / DELETE / GRANT 权限**,DDL 由 `scripts/core/01-ddl.sql` 经管理员账号执行。
|
||||||
|
这是「Core 只读」从**代码约定**升级为 **DB 级强制**的唯一手段(详见 D20)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 目录与文件划分
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── api/
|
||||||
|
│ └── simulate.py # 【改】TradeRequest 按 trade_type 分支校验;
|
||||||
|
│ # convert 分支调 convert_service;错误码映射 ApiError
|
||||||
|
│
|
||||||
|
├── gateway/ # 【core 库写侧唯一出口】
|
||||||
|
│ ├── trade_gateway.py # 【改】三处并列改动:
|
||||||
|
│ │ # (1) 移除 convert 的 400 拒绝(一期显式拒收 convert,本次放开)
|
||||||
|
│ │ # (2) subscribe/redeem 分支:增补批次维护(FR-C16,含兜底补建 D8)
|
||||||
|
│ │ # (3) 新增 convert 分派(调 convert_service)
|
||||||
|
│ ├── gateway_repository.py # 【改】insert_trade 增 qty / convert_group_id 两列写入
|
||||||
|
│ └── convert_core_repository.py # 【新增】阶段一 core 单库事务(D3):
|
||||||
|
│ # 2×INSERT core_trade + N×条件 UPDATE share_lot
|
||||||
|
│ # + 1×INSERT share_lot + 2×UPSERT core_holding
|
||||||
|
│ # + N×INSERT core_convert_lot_detail
|
||||||
|
│
|
||||||
|
├── service/
|
||||||
|
│ ├── convert/ # 【新增包】
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── types.py # 【类型】Lot / FeeRule / PlanResult,@dataclass(frozen=True)(评审 S3)
|
||||||
|
│ │ ├── calc.py # 【纯函数】FIFO 分配 / 逐批计费 / 补差费(A/B 双口径)/ 份额折算 / 舍入
|
||||||
|
│ │ ├── nav.py # 【纯函数】净值口径:stale 判定 + NAV_NOT_READY 判定
|
||||||
|
│ │ ├── fee.py # 【纯函数】持有期→赎回费率查表(输入规则行,不查库)
|
||||||
|
│ │ ├── lot_bootstrap.py # 【纯函数】批次补建规则(D18):gateway 兜底补建 与 rebuild_lots.py 同源
|
||||||
|
│ │ ├── errors.py # 【新增】convert 专属异常(继承 ApiError,§8.3)
|
||||||
|
│ │ └── convert_service.py # 【编排】§3 八步顺序 + 执行权 + 幂等 + 三阶段 + 阶段1.5 + 补偿日志
|
||||||
|
│ └── risk/
|
||||||
|
│ ├── rules.py # 【改】新增 _amount_view();RISK-002/005 改走金额视图
|
||||||
|
│ ├── engine.py # 【改】新增 process_convert_event(out, in),与
|
||||||
|
│ │ # process_trade_event 共用内部 _run()
|
||||||
|
│ ├── alert_service.py # 【改】record_trade_alerts 增 events 可选参数(一次入多事件)
|
||||||
|
│ └── locks.py # 【改】新增 try_lock()(单次尝试,不等待、不降级)
|
||||||
|
│
|
||||||
|
├── repository/
|
||||||
|
│ ├── core_ro.py # 【改】增 get_nav_as_of / get_redeem_fee_rules /
|
||||||
|
│ │ # list_share_lots / sum_remain_qty / get_holding(均纯 SELECT)
|
||||||
|
│ ├── share_lot_repository.py # 【新增】core_share_lot 读:FIFO 选批 + 汇总
|
||||||
|
│ ├── convert_repository.py # 【新增】agent 库 risk_convert_detail:占位 / 回写 / 查询 / 清理
|
||||||
|
│ └── risk_repository.py # 【不改】复用 insert_audit_log
|
||||||
|
│
|
||||||
|
├── tool/
|
||||||
|
│ └── core_tools.py # 【改】query_recent_trades 汇总走 _amount_view(FR-C15);
|
||||||
|
│ # 持仓查询过滤 qty <= 0(P2:归零行保留,需过滤)
|
||||||
|
│
|
||||||
|
├── config/
|
||||||
|
│ └── settings.py # 【改】§11 全部 convert_* 配置项 + §11.1 四个 DB 账号(D20)
|
||||||
|
└── utils/
|
||||||
|
└── db.py # 【改】get_engine(database, role="rw")(D20):
|
||||||
|
# 缓存键由「库名」改为「(库名, 角色)」
|
||||||
|
|
||||||
|
scripts/
|
||||||
|
├── core/
|
||||||
|
│ ├── 00-grant.sql # 【新增】建 3 个应用账号 + 最小权限 GRANT(D20,管理员执行)
|
||||||
|
│ │ # xh_core_ro(SELECT)/ xh_core_rw(4 表写)/ xh_agent_rw(audit 仅 INSERT)
|
||||||
|
│ ├── 01-ddl.sql # 【改】core_fee_rule / core_share_lot / core_convert_lot_detail 三新表
|
||||||
|
│ │ # + core_trade 增 convert_group_id + idx
|
||||||
|
│ │ # + core_product 增 8 列 + fee_rate COMMENT
|
||||||
|
│ ├── 07-seed-fee-rule.sql # 【新增】赎回费持有期分档(5 档)※文件头带版本注释块(§9)
|
||||||
|
│ ├── 08-seed-share-lot.sql # 【新增】按持仓反推批次,持有期错开覆盖多档 ※同上
|
||||||
|
│ ├── 09-seed-org.sql # 【新增】fund_company / ta_code / subscribe_fee_rate ※同上
|
||||||
|
│ ├── rebuild_lots.py # 【新增】按 core_holding 重建批次(**快照重建,非交易回滚**,PRD L-7)
|
||||||
|
│ └── reset.ps1 # 【改】$files 追加 07 / 08 / 09
|
||||||
|
├── agent/
|
||||||
|
│ └── cleanup_pending_convert.py # 【新增】超 24h 的 pending 占位 → 置 status='expired'
|
||||||
|
# (**标记不硬删**,留痕供对账;评审 S2)
|
||||||
|
├── dev/
|
||||||
|
│ └── calc_convert_demo.py # 【新增】与生产同一套 calc.py 纯函数实算主示例/同费率对照,
|
||||||
|
│ # 输出回填 PRD §5.3 与验收断言(禁止手算)
|
||||||
|
└── demo/
|
||||||
|
└── rebuild_alerts.py # 【改】支持 --convert-group 按 Core 侧数据补写阶段二(D6)
|
||||||
|
|
||||||
|
docs/项目框架设计/表设计/
|
||||||
|
└── 02-mysql-agent专用.sql # 【改】追加 risk_convert_detail 建表(PRD §4.1 DDL 原文)
|
||||||
|
|
||||||
|
tests/
|
||||||
|
├── _ddl.py # 【改】新增 4 表;core_trade 补 qty/convert_group_id;
|
||||||
|
│ # core_product 补 8 列;core_holding 补 qty/cost_amount/as_of/pnl_pct
|
||||||
|
├── test_db.py # 【新增】**T-0 门禁**:`test_core_holding_columns` 断言 sqlite 列名/PK
|
||||||
|
│ # 与 MySQL 一致(CI 强制,未绿不得跑后续集成测试)
|
||||||
|
├── test_convert_calc.py # 【新增】纯函数:FIFO / 分档费率 / 补差费双口径 / 舍入 / 边界
|
||||||
|
├── test_convert_service.py # 【新增】编排:八步顺序 / 幂等 / 执行权 / 阶段二失败补偿
|
||||||
|
├── test_convert_concurrency.py # 【新增】批次条件 UPDATE 冲突 → 409 LOT_CONFLICT
|
||||||
|
├── test_share_lot.py # 【新增】普通申赎批次维护(含兜底补建)+ rebuild_lots
|
||||||
|
├── test_convert_integration.py # 【新增】真 MySQL(CNV-TEST- 前缀隔离)
|
||||||
|
└── test_trade_gateway.py # 【改】补批次维护断言(回归)
|
||||||
|
```
|
||||||
|
|
||||||
|
**不新增**:ORM 实体、`app/service/risk/` 新规则(RISK-001~006 口径不变,仅金额视图去重)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 核心时序(对齐 PRD §7.0 固定顺序)
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/simulate/trade {trade_type: "convert", from_product_id, to_product_id, qty, client_request_id?}
|
||||||
|
│
|
||||||
|
├─ ① 参数与产品校验(不落库)
|
||||||
|
│ from != to(SAME_PRODUCT)· can_redeem / can_subscribe · fund_company+ta_code 同机构
|
||||||
|
│ → 4xx
|
||||||
|
├─ ② 份额校验(不落库)
|
||||||
|
│ Σ share_lot.remain_qty(权威源,非 core_holding.qty)· 最低转出份额(全额豁免)
|
||||||
|
│ · 余额 < min_hold_qty → 按 min_hold_action 强制处置(D15)
|
||||||
|
│ → 4xx INSUFFICIENT_SHARES / BELOW_MIN_QTY
|
||||||
|
├─ ③ 净值取数与折算(不落库,纯函数 calc.py)
|
||||||
|
│ 无净值记录 → 503 NAV_NOT_READY
|
||||||
|
├─ ④ 适当性校验(转入端,唯一业务阻断点)
|
||||||
|
│ suitability_check(customer, to_product) → blocked → R-02 预警 + 审计 → return(不占位)
|
||||||
|
│
|
||||||
|
├─ ⑤ 阶段零(agent 库):预生成 convert_group_id + 占位 INSERT(仅带 client_request_id 时)
|
||||||
|
│ 执行权:try_lock("convert:idem:{cid_req}") 未抢到 → 202 + group_id,不进阶段一
|
||||||
|
│ 占位失败(带键)→ 503 不放行;未带键 → 免占位直跑
|
||||||
|
│ 重试判定:completed → 返回首次结果;pending/failed → SELECT 1 FROM core_trade WHERE gid
|
||||||
|
├─ ⑥ 阶段一(core 库 · 单事务):2 流水 + 批次扣/增 + lot 明细 + 两端 holding
|
||||||
|
│ 失败 → 整体回滚 + 占位标记 failed + 4xx/5xx
|
||||||
|
├─ ⑦ 阶段 1.5(同步跑规则引擎 · 不参与 core 事务)
|
||||||
|
│ process_convert_event(out, in):读全量两条流水 + 已更新持仓 → 出 1 条预警事件
|
||||||
|
│ 失败 → engine_error + 本地日志,**不阻断已成立的交易**(D17)
|
||||||
|
└─ ⑧ 阶段二(agent 库):回写 risk_convert_detail completed + 详情 + 主审计(+ nav_stale 副审计)
|
||||||
|
失败 → 不回滚 Core,落 decision='convert_detail_write_failed' + logger.exception 本地兜底
|
||||||
|
补跑阶段二必须 try_lock("convert:rerun:{gid}")
|
||||||
|
```
|
||||||
|
|
||||||
|
> **阶段 1.5 的位置(D17)**:**阶段一提交后、阶段二之前**同步执行——此时两条流水已持久化、
|
||||||
|
> `core_holding` 已更新,集中度规则(RISK-006)读到的是**已更新**持仓,转换的连带效应天然被覆盖。
|
||||||
|
> 引擎只调用**一次** `process_convert_event()`,传入两条流水(PRD §6.2)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 关键技术决策(D1~D17)
|
||||||
|
|
||||||
|
| # | 决策点 | 选型 | 备选 | 理由 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| D1 | 折算计算放哪 | `service/convert/calc.py` 纯函数 | 内联在 service | 金额逻辑最易错,必须可穷举单测 |
|
||||||
|
| D2 | 批次读写分工 | 读(选批/汇总)→ `share_lot_repository`;写(扣减/新增)→ `convert_core_repository`(阶段一同事务) | 全塞一个 repository | 写必须与两条流水同事务;读需在事务外预检(步骤②) |
|
||||||
|
| D3 | 阶段一事务实现 | `convert_core_repository.apply_convert()`,内部 `with engine.begin() as conn` 串行执行全部 SQL | 各 repository 各开事务 / 传 conn | 现有 repository 均自建连接,无法共享事务;集中一个方法边界最清晰,不改既有签名 |
|
||||||
|
| D4 | 幂等执行权 | 新增 `try_lock`(单次尝试) | 复用 `run_locked` | `run_locked` 会等 2s 并降级 `fn(False)`,与本场景「抢不到立即 202」语义相反;改它影响现有 3 处调用 |
|
||||||
|
| D5 | 引擎只跑一次 | 新增 `process_convert_event(out, in)`,与 `process_trade_event` 共用内部 `_run()` | 给现有函数加 `sibling_trade` 参数 | 语义清晰、现有函数零风险 |
|
||||||
|
| D6 | 补偿数据来源 | `core_convert_lot_detail` 含 `nav`/`nav_date`,`rebuild_alerts.py --convert-group` 仅凭 Core 侧补写;**Core 明细仅补偿、不对外查询(P3)** | 靠 agent 库残留 | 阶段二失败时 agent 库可能为空,Core 侧必须自包含 |
|
||||||
|
| D7 | 金额去重 | `rules._amount_view()`:同 `convert_group_id` 组内**保留转出端(`trade_type='redeem'`)**那条 | 删行 / 求和 | PRD §6.3「绝不删行」;转出端 = 客户实际减少的权益额,语义唯一。组内无 redeem 时取第一条(防御) |
|
||||||
|
| D8 | 普通申赎批次维护 | **兜底补建**:有批次则 FIFO 扣/增;无批次 → 按 `core_holding.as_of` 补建初始批次再扣(与 `rebuild_lots.py` 同源规则),**不跳过、不阻断** | 跳过 + warning / 报错 | **P1 已拍板**:真实 TA 份额必有注册批次;跳过会让批次表长期空洞、convert 永不触发 FIFO;报错会打穿存量种子与 510 用例 |
|
||||||
|
| D9 | `core_holding` 同步 | UPSERT(`INSERT ... ON DUPLICATE KEY UPDATE`);转出后 `qty=0` **保留行** | 删除行 / 先 SELECT 后 UPDATE | **P2 已拍板**:保留行避免 UNIQUE 冲突与历史查询断裂;UPSERT 原子。持仓查询需过滤 `qty <= 0`。⚠️ sqlite ≥3.24 支持该语法 |
|
||||||
|
| D10 | 净值查询 | 新增 `core_ro.get_nav_as_of(pid, trade_date)`(`nav_date <= :d` 降序取 1) | 改 `get_latest_nav` | 现有方法无条件取最新(可能取到未来日期),改它会动既有调用方 |
|
||||||
|
| D11 | 费率来源 | 运行时读 `core_fee_rule`(redeem 分档)+ `core_product.subscribe_fee_rate`(补差) | 硬编码 | 数据驱动便于演示调档;`core_fee_rule.fee_type='subscribe'` 本期不启用 |
|
||||||
|
| D12 | 请求模型 | 扩展现有 `TradeRequest`(`product_id` 改 Optional + 加 4 个可选字段)+ `model_validator` 按 `trade_type` 校验 | 新增 `ConvertRequest` + 分端点 | 端点契约不变;分端点让调用方多记一个 URL |
|
||||||
|
| D13 | **补差费公式** | **口径 B(价外法两端差)为默认**,A 用配置保留 | 只用 A | **已拍板**:B 与申购费价外法数学自洽、为新公募主流;实现为 `convert_diff_fee_mode`,两口径均有单测 |
|
||||||
|
| D14 | **T+1 确认建模** | `confirmed_at = T+1`(自然日近似,注释标明真实为**工作日** + T+2 可用) | 沿用「不模拟 T+1」 | **已拍板**:真实为 T+1 权益登记、持有期自确认日起算;只改一个日期字段 |
|
||||||
|
| D15 | **最低持有余额处置** | 两种都实现:`min_hold_action ∈ {force_transfer, force_redeem}`,默认 `force_transfer` | 只做强制全转 | **已拍板**:两种都真实(阈值大→强制赎回、阈值小→强制全转);加一列即可 |
|
||||||
|
| D16 | **赎回费归属** | `core_fee_rule.to_fund_ratio` 置 **1.0**(22 号文 §10),本期不参与计算 | 不建模 | 字段留痕;注释写明旧规 75%/50%/25% 已废止 |
|
||||||
|
| **D17** | **规则引擎执行时序** | **阶段 1.5**:阶段一提交后、阶段二之前**同步**跑 `process_convert_event`;失败落 `engine_error` + 本地日志,**不参与 core 事务、不阻断交易** | 放阶段二之后 / 异步 | v0.2 时序里**完全没有跑引擎这一步**(PRD X-1);放在阶段一后保证 RISK-006 读到已更新持仓 |
|
||||||
|
| **D18** | **批次补建规则复用**(执行期风险 #2 新增) | 抽**纯函数** `service/convert/lot_bootstrap.py::bootstrap_lots(holding_row) -> list[Lot]`;**`trade_gateway` 兜底补建(D8)与 `scripts/core/rebuild_lots.py` 同调此函数** | 两处各写一份规则 | 「按 `core_holding.as_of` 反推初始批次」若在兜底补建与重建脚本各写一遍,**必然漂移**(同一持仓两处算出不同 `confirmed_at`);抽纯函数后两侧共用 + 可单测 |
|
||||||
|
| **D19** | **引擎异常钩子预留**(执行期风险 #4 新增) | `process_convert_event(..., on_error_hook=None)` —— 一期 `None`(仅落 `engine_error` 审计),二期可注入 Redis 事件 / 补偿队列,**不写死** | 直接写死二期逻辑 / 完全不留口 | 二期补偿增强(R3)若届时改函数签名,会牵动 T-8 全部调用方;一个**可选关键字参数**零成本预留 |
|
||||||
|
| **D20** | **Core 读写账号分离**(主架构 **C1** · 用户拍板「按真实项目走」) | `jinrong_core` 两账号:**`xh_core_ro`**(`GRANT SELECT`)+ **`xh_core_rw`**(SELECT/INSERT/UPDATE,**限 4 张表**,无 DELETE、无 DDL);`jinrong_agent` 另配 `xh_agent_rw`,**`audit_log` 只授 INSERT**;`get_engine(db, role)` 按 **`(db, role)`** 缓存 | ① 单 root 账号(现状)② 仅代码层 SQL 白名单 | 现状 `settings.py:17` 单账号 + `db.py:23` 仅按库名缓存 → `core_ro.py:55` 与 `gateway_repository.py:24` **共用同一 root 连接池**,「只读」纯属约定;代码白名单(评审 B 方案)易被注释/子查询/存储过程绕过,**评审已否决**。DB 级授权是唯一「不可逆」保障 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 事务、并发与补偿(落地版)
|
||||||
|
|
||||||
|
### 5.1 三阶段 + 阶段 1.5 与 SQL 清单
|
||||||
|
|
||||||
|
| 阶段 | 库 | 入口 | SQL / 动作 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 零 | agent | `convert_repository.insert_placeholder` | 1×`INSERT risk_convert_detail`(`uk_idem` 兜底) |
|
||||||
|
| 一 | core | `convert_core_repository.apply_convert`(**单事务**) | 2×`INSERT core_trade`(同 `convert_group_id`)· N×`UPDATE core_share_lot SET remain_qty=remain_qty-:q WHERE lot_id=:lot AND remain_qty>=:q` · 1×`INSERT core_share_lot`(转入新批)· 2×`UPSERT core_holding` · N×`INSERT core_convert_lot_detail` |
|
||||||
|
| **1.5** | — | `engine.process_convert_event(out, in)` | **规则引擎(无 SQL 写)**:读全量两条流水 + 已更新持仓 → 预警事件;异常落 `engine_error`,**不阻断** |
|
||||||
|
| 二 | agent | `convert_repository.complete_convert` | 1×`UPDATE risk_convert_detail SET status='completed'` · 1~2×`INSERT audit_log` |
|
||||||
|
|
||||||
|
阶段一**任一 SQL 失败 → 整体回滚**(`with engine.begin()` 异常即 rollback),占位在 `finally` 侧标记 `failed`。
|
||||||
|
|
||||||
|
### 5.2 阶段一之外的一次读(重试判定)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 取得执行权之后执行;有行 = 阶段一已成,只需补跑阶段二
|
||||||
|
SELECT 1 FROM core_trade WHERE convert_group_id = :gid LIMIT 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
走 `core_ro`(只读)。依赖 `core_trade.idx_convert_group`(§9),否则全表扫。
|
||||||
|
|
||||||
|
### 5.3 锁原语扩展
|
||||||
|
|
||||||
|
```python
|
||||||
|
# app/service/risk/locks.py 新增(不动 run_locked)
|
||||||
|
def try_lock(key: str, ttl_seconds: int = LOCK_TTL_SECONDS) -> _Token | None:
|
||||||
|
"""单次尝试抢锁:抢到返回可释放的 token,抢不到立即返回 None(不等、不降级)。
|
||||||
|
Redis 不可用 → 退回进程内 threading.Lock.acquire(blocking=False),语义一致。
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
用法:返回 `_NoLock` 哨兵对象,`__enter__` 给 `False`,避免 `with None` 报错。
|
||||||
|
|
||||||
|
### 5.4 补偿(一期:监控告警 + 人工脚本,SLA 24h)
|
||||||
|
|
||||||
|
| 项 | 口径 |
|
||||||
|
| --- | --- |
|
||||||
|
| 触发者 | `risk_convert_detail.status='failed'`(或 `pending` 超时)的巡检/告警 + 人工判断 |
|
||||||
|
| 补偿工具 | `scripts/demo/rebuild_alerts.py --convert-group CNV-xxx` —— 从 `core_trade` + `core_convert_lot_detail` 重算详情并回写 → `completed`(**必须 `try_lock("convert:rerun:{gid}")`**) |
|
||||||
|
| **SLA** | 失败记录须在 **24h** 内完成补偿或人工确认;`cleanup_pending_convert.py` 把超 24h 的 `pending` 孤儿置 **`status='expired'`**(**标记不硬删**,留痕供对账;评审 S2) |
|
||||||
|
| 兜底 | 阶段二失败 → `decision='convert_detail_write_failed'` 审计;写审计本身失败 → `logger.exception` 输出 group_id + 全部折算参数到本地日志 |
|
||||||
|
| 为何不自动化 | 自动补跑需解决「补跑 vs 客户端重试」并发(已由 `convert:rerun:` 锁串行);一期以人工介入降低误补风险,**二期评估自动化** |
|
||||||
|
|
||||||
|
> **幂等锚点(评审 Q4)**:convert 补偿**以转出端 `out_trade_id` 为幂等锚点** —— 复用现有
|
||||||
|
> `scripts/demo/rebuild_alerts.py:54-66` 的 `find_alerts_by_trade(trade_id)`,命中即 `skipped`。
|
||||||
|
> **该脚本本就幂等**(见其 `:8-10` docstring),评审 Q4 提出的「再加 `uk_idem`」**不必要**;
|
||||||
|
> 注意一次转换有**两条流水**,故只认转出端,避免重复出单。
|
||||||
|
>
|
||||||
|
> **`rounding_diff` 落审计(评审 Q10)**:舍入尾差在审计中记**正负向与金额**,供二期对账
|
||||||
|
> (尾差由基金资产列支,PRD §2.5)。
|
||||||
|
>
|
||||||
|
> **二期补偿增强(评审 R3)**:阶段 1.5 引擎失败时,除现有 `engine_error` 审计外,可增发 Redis 事件 /
|
||||||
|
> 写补偿队列表,并让 `cleanup_pending_convert.py` **顺带巡检补跑**,降低人工介入频次。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 规则引擎改造(FR-C11 / FR-C15)
|
||||||
|
|
||||||
|
### 6.1 `_amount_view`
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _amount_view(trades: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""金额聚合视图:同 convert_group_id 的组内只保留转出端(trade_type='redeem')。
|
||||||
|
无 gid 的交易原样通过 —— 非 convert 场景下本函数恒等,现有 510 用例零影响。
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
`run_rules` 内部:`eligible`(全量)供 RISK-001/003/004;`amount_view(eligible)` 供 RISK-002/005。
|
||||||
|
RISK-006 读持仓快照(阶段 1.5 时已更新),不受影响。
|
||||||
|
|
||||||
|
### 6.2 一次转换 = 一条预警事件
|
||||||
|
|
||||||
|
```python
|
||||||
|
def process_convert_event(out_trade, in_trade, *, core_ro=None, risk_repo=None,
|
||||||
|
thresholds=None, on_error_hook=None) -> dict:
|
||||||
|
```
|
||||||
|
|
||||||
|
> **`on_error_hook`(D19 · 执行期风险 #4)**:**一期传 `None`** —— 引擎异常时仅落 `engine_error` 审计 + 本地日志;
|
||||||
|
> 参数本身存在的意义是**把二期的「Redis 事件 / 补偿队列」留成可插拔点**,避免届时改签名牵动 T-8 全部调用方。
|
||||||
|
> 一期实现里 hook 调用必须包 `try/except`(hook 自身失败**不得**反噬主流程,与「阶段 1.5 不阻断交易」同原则)。
|
||||||
|
|
||||||
|
- 取当日全量流水(已含两条),跑 `run_rules`(内部按 §6.1 分流)
|
||||||
|
- `record_trade_alerts(primary=out_trade, hits, events=[event_of(out), event_of(in)])`
|
||||||
|
→ **一张单、payload.events 两条**
|
||||||
|
- `alert_service.record_trade_alerts` 新增可选参数 `events: list[dict] | None = None`,
|
||||||
|
缺省 `None` → 退化为现有「单 event」行为,**现有调用零改动**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 计算口径落地(纯函数签名)
|
||||||
|
|
||||||
|
> **类型定义(评审 S3)**:`Lot` / `FeeRule` / `PlanResult` 统一放 `service/convert/types.py`,
|
||||||
|
> 用 `@dataclass(frozen=True)`(或 `TypedDict`)定义,**不用裸 dict** —— 防键名拼写错误
|
||||||
|
> (呼应主架构 `entities.py` 31 个 ORM 类闲置、全链路 dict 传参的问题)。
|
||||||
|
|
||||||
|
```python
|
||||||
|
# service/convert/calc.py
|
||||||
|
def plan_lots(lots: list[Lot], requested_qty: Decimal, min_hold_qty: Decimal,
|
||||||
|
min_hold_action: str) -> PlanResult:
|
||||||
|
"""FIFO 分配 + 最低持有处置判定(actual_qty / forced_full_transfer / action)"""
|
||||||
|
def lot_amount(qty: Decimal, nav: Decimal) -> Decimal: # 2 位 ROUND_HALF_UP
|
||||||
|
def lot_fee(amount: Decimal, rate: Decimal) -> Decimal: # 2 位 ROUND_HALF_UP
|
||||||
|
def pick_fee_rate(rules: list[FeeRule], hold_days: int) -> Decimal: # 分档匹配(左闭右开)
|
||||||
|
def convert_amount(out_amount: Decimal, redeem_fee: Decimal) -> Decimal
|
||||||
|
def diff_fee(conv_amount: Decimal, out_rate: Decimal, in_rate: Decimal,
|
||||||
|
mode: str = "amount_diff") -> Decimal:
|
||||||
|
"""补差费,双口径(D13):
|
||||||
|
mode="amount_diff"(默认,口径 B):Max[conv*in/(1+in) − conv*out/(1+out), 0]
|
||||||
|
mode="rate_diff" (口径 A):conv*max(in−out,0)/(1+max(in−out,0))
|
||||||
|
结果 2 位 ROUND_HALF_UP。"""
|
||||||
|
def in_qty(in_amount: Decimal, in_nav: Decimal) -> Decimal: # 2 位 ROUND_HALF_UP(v1.0 修正)
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **v1.0 勘误**:v0.2 此处写的是 `4 位 ROUND_FLOOR`,与 §1 原则 10「份额 2 位 ROUND_HALF_UP」**直接矛盾**,
|
||||||
|
> 属 v0.7 残留。PRD v0.8 已定 **2 位四舍五入**,v1.0 一并修正。示例数字由 `scripts/dev/calc_convert_demo.py` 实算回填:
|
||||||
|
> `in_qty = 53456.95`(口径 B)/ `53455.36`(口径 A)。
|
||||||
|
|
||||||
|
**赎回费分档(区间左闭右开 `[min_hold_days, max_hold_days)`)**
|
||||||
|
|
||||||
|
| 档 | min_hold_days | max_hold_days | rate | 依据 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| < 7 日 | 0 | 7 | **0.0150** | 22 号文 §10 法定下限 1.5% |
|
||||||
|
| 7–30 日 | 7 | 30 | **0.0100** | 22 号文 §10 法定下限 1% |
|
||||||
|
| 30–180 日 | 30 | 180 | **0.0050** | 22 号文 §10 法定下限 0.5% |
|
||||||
|
| 180–365 日 | 180 | 365 | 0.0025 | 合同约定(法规无下限) |
|
||||||
|
| ≥ 365 日 | 365 | NULL | 0.0000 | 合同约定(真实多数基金满 1 年免赎回费) |
|
||||||
|
|
||||||
|
匹配 SQL:`WHERE product_id=:pid AND fee_type='redeem' AND min_hold_days <= :d AND (max_hold_days IS NULL OR :d < max_hold_days)`
|
||||||
|
—— 同批次 `hold_days = (交易日 − confirmed_at).days`(自然日,**不含申请日**;满 7 日归 7–30 档)。
|
||||||
|
|
||||||
|
**`core_holding` 更新口径**(PRD FR-C13,含 M-6 的 `pnl_pct`)
|
||||||
|
|
||||||
|
| 端 | qty | cost_amount | market_value | as_of | pnl_pct |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 转出 | `qty − actual_qty` | `cost × (1 − actual/原qty)`(等比例结转成本) | `新qty × out_nav` | nav_date | `(mv − cost)/cost`,cost=0 置 0 |
|
||||||
|
| 转入 | `qty + in_qty` | `cost + in_amount` | `新qty × in_nav` | nav_date | 同上 |
|
||||||
|
|
||||||
|
> 转入端**首次**建行 → `INSERT`;已有行 → `UPDATE`。转出端 `qty` 归零**保留行**(D9/P2)。
|
||||||
|
|
||||||
|
**FIFO 选批 SQL(评审 S1 · 确定性 tiebreaker)**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
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 -- confirmed_at 为主序;lot_id 仅作同行确定性兜底
|
||||||
|
LIMIT :max_lots; -- = convert_batch_max_lots (§11)
|
||||||
|
```
|
||||||
|
|
||||||
|
> 排序**主依据是 `confirmed_at`**(注册日期,PRD §4.1 已建 `KEY(customer_id, product_id, confirmed_at)`),
|
||||||
|
> **`lot_id` 只当同 `confirmed_at` 的确定性 tiebreaker** —— 否则同一注册日内多批次的先后
|
||||||
|
> 由存储引擎决定、**不可复现**(重跑结果漂移,`test_convert_calc.py` 会间歇性失败)。
|
||||||
|
> 评审 S1 建议的「`lot_id` 改 BIGINT + UNIQUE」**不采纳**:`lot_id` **已是主键(天然唯一)**,
|
||||||
|
> 且真实 TA 注册登记批次号是字符串,改类型反而偏离真实 Core。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 接口契约落地
|
||||||
|
|
||||||
|
### 8.1 请求模型(`api/simulate.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
class TradeRequest(BaseModel):
|
||||||
|
customer_id: str
|
||||||
|
product_id: str | None = None # subscribe/redeem 必填
|
||||||
|
trade_type: str # subscribe | redeem | convert
|
||||||
|
amount: Decimal | None = None # subscribe/redeem 必填
|
||||||
|
from_product_id: str | None = None # convert 必填
|
||||||
|
to_product_id: str | None = None # convert 必填
|
||||||
|
qty: Decimal | None = None # convert 必填(>0)
|
||||||
|
client_request_id: str | None = None # 幂等键(选填);复用 app/main.py:44 的
|
||||||
|
# _TRACE_ID_PATTERN(^[A-Za-z0-9._-]{1,64}$)
|
||||||
|
# 校验,越界/非法字符 → 422(评审 S4)
|
||||||
|
|
||||||
|
@model_validator(mode="after") # 按 trade_type 分派校验,失败抛 ValueError → 422
|
||||||
|
```
|
||||||
|
|
||||||
|
> 422 由 FastAPI 统一错误体处理(T-02 已覆盖);convert 语义错误走 `ApiError`(§8.3)。
|
||||||
|
>
|
||||||
|
> **`client_request_id` 校验(评审 S4)**:**不自造正则** —— 直接复用 `app/main.py:44` 既有的
|
||||||
|
> `_TRACE_ID_PATTERN`(`^[A-Za-z0-9._-]{1,64}$`)。该正则同时约束 trace 头与幂等键,
|
||||||
|
> 口径统一、避免两套白名单漂移。
|
||||||
|
|
||||||
|
### 8.2 响应
|
||||||
|
|
||||||
|
`convert` 分支返回**纯 dict,全部 Decimal 已 `str()`**(§1 原则 11),字段与 PRD §5.3 逐项对齐,
|
||||||
|
含 `lot_breakdown` / `out_amount` / `convert_amount` / `in_amount` / `in_qty` / `rounding_diff` 等。
|
||||||
|
另含 **`batch_count`**(本次实际跨越批次数)与 `max_lots`(上限),供前端在接近上限时提示「请拆分多笔申请」。
|
||||||
|
|
||||||
|
### 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` |
|
||||||
|
| `TooManyLots` | 400 | `TOO_MANY_LOTS`(超 `convert_batch_max_lots=200`;**自动分拆多笔留二期**,评审 R4) |
|
||||||
|
| `NavNotReady` | 503 | `NAV_NOT_READY` |
|
||||||
|
| `LotConflict` | 409 | `LOT_CONFLICT`(调用方重试,建议 ≤3 次、间隔 100/200/400ms) |
|
||||||
|
| `IdempotencyUnavailable` | 503 | `IDEMPOTENCY_UNAVAILABLE` |
|
||||||
|
| 未抢到执行权 | **202** | 返回 `{convert_group_id, status: "processing"}` |
|
||||||
|
| 幂等命中 completed | **200** | 返回首次结果 |
|
||||||
|
|
||||||
|
> **非业务阻断声明**:`NAV_NOT_READY`(503)、`LOT_CONFLICT`(409) 属**技术故障码**,
|
||||||
|
> **不适用「仅 R-02 可阻断交易」业务铁律**——它们是系统瞬时状态,恢复后重试即可成功。
|
||||||
|
|
||||||
|
> **`TOO_MANY_LOTS` 响应体与文案(执行期风险 #5)**:400 错误体携带
|
||||||
|
> `{"error_code":"TOO_MANY_LOTS","batch_count":<实际>,"max_lots":200}`(`batch_count` 为**继续转换所需批次数**,
|
||||||
|
> 可能大于 `max_lots`),前端据此提示「**本次转换需跨 N 个批次,超过上限 200,请拆分多笔申请**」。
|
||||||
|
> **语义铁律**:一期**单笔请求 = 单事务 = 单 `convert_group_id`**,不做自动分拆(§1 原则 12)。
|
||||||
|
> 该字段由 `plan_lots()` 的 `PlanResult` 提供 —— 即**先规划再判上限**,避免半途失败。
|
||||||
|
|
||||||
|
异常类定义在 `app/utils/exceptions.py`(复用现有 `ApiError`),convert 专属异常放 `service/convert/errors.py`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 数据模型落点清单(DDL 与种子)
|
||||||
|
|
||||||
|
| 对象 | 落点文件 | 动作 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `core_fee_rule` | `scripts/core/01-ddl.sql` | 新建(含 `to_fund_ratio DECIMAL(5,4) DEFAULT 1.0`,D16) |
|
||||||
|
| `core_share_lot` | `scripts/core/01-ddl.sql` | 新建 |
|
||||||
|
| `core_convert_lot_detail` | `scripts/core/01-ddl.sql` | 新建(含 `nav`/`nav_date`,D6) |
|
||||||
|
| `core_trade.convert_group_id` + `KEY idx_convert_group` | `scripts/core/01-ddl.sql` | 改表(NULL,向后兼容) |
|
||||||
|
| `core_product` **8** 新列 + `fee_rate` COMMENT | `scripts/core/01-ddl.sql` | 改表(均有 DEFAULT,老 INSERT 不受影响)。8 列 = `can_subscribe`/`can_redeem`/`min_hold_qty`/**`min_hold_action`**/`min_redeem_qty`/`subscribe_fee_rate`/`fund_company`/`ta_code` |
|
||||||
|
| `risk_convert_detail` | `docs/项目框架设计/表设计/02-mysql-agent专用.sql` | 新建(PRD §4.1 DDL 原文,`status` ENUM 含 `pending`/`completed`/`failed`/`cancelled`/**`expired`** + `cancelled_at`;**建表即含全部 5 值,零 ALTER**,评审 S2) |
|
||||||
|
| 赎回费分档种子 | `scripts/core/07-seed-fee-rule.sql` | 新建 |
|
||||||
|
| 份额批次种子 | `scripts/core/08-seed-share-lot.sql` | 新建 |
|
||||||
|
| 机构 + 申购费率种子 | `scripts/core/09-seed-org.sql` | 新建 |
|
||||||
|
| reset 流程 | `scripts/core/reset.ps1` | `$files` 追加 07/08/09 |
|
||||||
|
|
||||||
|
**种子文件版本注释约定(PRD L-6)**:`07`/`08`/`09` 三个新增种子 SQL 的**文件头必须带版本注释块**
|
||||||
|
(`版本 / 日期 / 依据 / 变更摘要`),不单独维护 CHANGELOG 文件(避免与 SQL 漂移)。
|
||||||
|
模板见 PRD §4.3。
|
||||||
|
|
||||||
|
**sqlite 测试 DDL 同步(`tests/_ddl.py`,四张新表 + 列并集)**:
|
||||||
|
`core_trade` 补 `qty`/`convert_group_id`;`core_product` 补 **8 列**;`core_holding` 补 `qty`/`cost_amount`/`as_of`/`pnl_pct`;
|
||||||
|
新增 `core_fee_rule`/`core_share_lot`/`core_convert_lot_detail`/`risk_convert_detail`。
|
||||||
|
配套 **`tests/test_db.py::test_core_holding_columns`**(断言列名/PK,T-0 门禁用例)。
|
||||||
|
|
||||||
|
> ⚠️ **已知坑 · 独立评审 R1(升级为 T-0 阻断前置)**:现有 sqlite 版 `core_holding` **只有** `market_value`/`quantity`
|
||||||
|
> (实测 `tests/_ddl.py:50-54`,且**无 PK / 唯一约束**),MySQL 用 `qty`/`cost_amount`/`as_of`/`pnl_pct`,**列名不同名**。
|
||||||
|
> sqlite 与 MySQL DDL 靠人工同步(无迁移工具),**列名不一致会在集成测试期才炸**——`rebuild_lots.py` / 批次维护 / `core_ro` 查询**全链路受影响**。
|
||||||
|
> **处置(评审 R1)**:独立为 **T-0、先于 T-1** —— 以 MySQL 为准统一 sqlite DDL(含 PK),并在 `conftest.py` 加**启动期列名断言**(缺列即 fail fast,不留到集成测试)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 测试策略
|
||||||
|
|
||||||
|
| 层 | 文件 | 覆盖点 | 预计用例 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 纯函数 | `test_convert_calc.py` | FIFO 分配 / 跨批次计费 / **补差费双口径(A/B 各一组)** / 舍入边界(HALF_UP vs HALF_EVEN)· **份额 2 位** / 分档边界(6/7/29/30/**179/180**/364/365 天)/ **强制全转 + 强制赎回双动作** / T+1 起算 | ~35 |
|
||||||
|
| 编排 | `test_convert_service.py` | 八步顺序(blocked 不占位)/ 三阶段 + 阶段 1.5 / 幂等命中 / 阶段二失败补偿 / nav_stale 审计条数 | ~22 |
|
||||||
|
| 并发 | `test_convert_concurrency.py` | 条件 UPDATE rowcount=0 → 409;同键并发 → 202;补跑阶段二加锁;**50 并发压测(评审 Q6)** | ~10 |
|
||||||
|
| 批次 | `test_share_lot.py` | 普通申赎批次维护 / **无批次兜底补建** / `rebuild_lots.py` | ~12 |
|
||||||
|
| 集成 | `test_convert_integration.py` | 真 MySQL,`CNV-TEST-`/`TRD-TEST-` 前缀隔离,端到端折算与 §5.3 示例逐项吻合 | ~6 |
|
||||||
|
| 回归 | `test_trade_gateway.py` | 补批次维护断言;现有 510 用例**必须全绿** | +5 |
|
||||||
|
|
||||||
|
基线:510 → 预计 **580~600**。
|
||||||
|
|
||||||
|
> **50 并发压测口径(评审 Q6)**:同一 `(customer_id, product_id)` 上 **50 个并发请求**争抢同一批份额,
|
||||||
|
> 断言三件事 —— ①`LotConflict`(409) 命中数与 `剩余可转份额` 一致(**不许超卖**);
|
||||||
|
> ②按 `100/200/400ms` 退避重试 ≤3 次后**最终成功率**(验证 §8.3 建议间隔是否够);
|
||||||
|
> ③`core_trade` 中 `convert_group_id` **无重复**、`core_share_lot.remain_qty` 之和 = 初始值 − 实际成交份额。
|
||||||
|
> 该用例**不进 CI 常规门禁**(需真 MySQL + 并发,耗时长),归入 T-13 实测性能补录一并跑。
|
||||||
|
|
||||||
|
**测试隔离**:convert 集成测试用 `CNV-TEST-` 前缀 group_id + `TRD-TEST-` 前缀 trade_id,
|
||||||
|
teardown 按前缀清理 `core_trade`/`core_share_lot`/`core_convert_lot_detail`/`risk_convert_detail`/`audit_log`,
|
||||||
|
沿用 `tests/conftest.py::risk_demo_env` 的时间窗清理约定。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 配置新增(`app/config/settings.py`)
|
||||||
|
|
||||||
|
| 项 | 默认 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `convert_nav_stale_days` | `3` | `nav_date` 距交易日超过此值 → 额外落 `nav_stale` 审计(PRD §2.2)。**全局单阈值即可**:模拟库 14 只产品均**日频净值**、无频次差异;真实 Core 接入后按产品类型(货基/债基/股基)分档,届时把本项由 `int` 改为 `dict[str,int]`(评审 S5,一期不做) |
|
||||||
|
| `convert_lock_ttl_seconds` | `30` | 幂等执行权锁 TTL |
|
||||||
|
| `convert_batch_max_lots` | `200` | 单次转换最多跨越批次数,超限 400 `TOO_MANY_LOTS` |
|
||||||
|
| `convert_diff_fee_mode` | `"amount_diff"` | 补差费口径(D13):`amount_diff` = 价外法两端差(**默认 B**)· `rate_diff` = 费率差法(A) |
|
||||||
|
| `convert_confirm_offset_days` | `1` | T+N 确认偏移(D14):真实为 T+1 **工作日**,模拟库无日历故用自然日,注释写明 |
|
||||||
|
| `convert_compensate_sla_hours` | `24` | 阶段二失败补偿 SLA(§5.4),`cleanup_pending_convert.py` 据此清理孤儿占位 |
|
||||||
|
|
||||||
|
### 11.1 DB 账号与最小权限(D20 · 主架构评审 C1 · 用户拍板「按真实项目走」)
|
||||||
|
|
||||||
|
**问题**:现状 `settings.py:13-18` 只有单账号 `mysql_user='root'`,`db.py:23` 仅按**库名**缓存
|
||||||
|
→ `core_ro.py:55` 与 `gateway_repository.py:24` **共用同一 root 连接池**,「Core 只读」纯属代码约定,
|
||||||
|
没有任何 DB 级强制。任何持有 core engine 的代码都能 `INSERT/UPDATE`。
|
||||||
|
|
||||||
|
**账号矩阵(`scripts/core/00-grant.sql`,管理员执行)**
|
||||||
|
|
||||||
|
| 账号 | 库 | 授权 | 使用者 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `xh_core_ro` | `jinrong_core` | `SELECT`(全库) | `core_ro` · `core_tools` · `deps` 归属校验 · `risk` 扫描 · **`trade_gateway` 的校验读** |
|
||||||
|
| `xh_core_rw` | `jinrong_core` | `SELECT/INSERT/UPDATE`,**限 4 表**:`core_trade` / `core_share_lot` / `core_holding` / `core_convert_lot_detail`;**无 DELETE、无 DDL、无 GRANT** | **仅 `app/gateway/` 的写路径**(`gateway_repository.insert_trade` · `convert_core_repository.apply_convert`) |
|
||||||
|
| `xh_agent_rw` | `jinrong_agent` | `SELECT/INSERT/UPDATE` 业务表;**`audit_log` 只授 `INSERT`** | Agent 侧全部(`risk_repository` / `session_repository` / `convert_repository`) |
|
||||||
|
| `root`(管理员) | 两库 | 全权 | **仅** `00-grant.sql` / `01-ddl.sql` / `reset.ps1`,**应用运行时不持有** |
|
||||||
|
|
||||||
|
> ⚠️ **关键边界**:**gateway 的读走 `ro`、写走 `rw`**,两个 engine 并存 ——
|
||||||
|
> `trade_gateway.py:105` 现有的 `core_ro or CoreReadOnlyRepository()`(校验读)**保持只读账号不变**,
|
||||||
|
> 只有 `convert_core_repository` / `gateway_repository.insert_trade` 换用 `rw`。
|
||||||
|
> 否则 gateway 会顺带获得全库读权限,「最小权限」落空。
|
||||||
|
|
||||||
|
**配置(`settings.py` 新增,默认空 = 向后兼容)**
|
||||||
|
|
||||||
|
| 项 | 默认 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `mysql_core_ro_user` / `mysql_core_ro_password` | `""` | 空 → **回退** `mysql_user`(渐进启用,**不阻塞开发**) |
|
||||||
|
| `mysql_core_rw_user` / `mysql_core_rw_password` | `""` | 同上 |
|
||||||
|
| `mysql_agent_user` / `mysql_agent_password` | `""` | 同上 |
|
||||||
|
|
||||||
|
**`db.py` 改造**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_engine(database: str, role: str = "rw") -> Engine:
|
||||||
|
"""按 (库名, 角色) 缓存单例 Engine;role ∈ {"ro","rw","admin"}。
|
||||||
|
对应角色账号未配置 → 回退 settings.mysql_user(行为与现状一致)。"""
|
||||||
|
```
|
||||||
|
|
||||||
|
> `dispose_engines()` 语义不变(清空全部缓存);`tests/conftest.py:183-233` 的两处调用
|
||||||
|
> 保持单参数默认值即可,**现有 510 用例零改动**。
|
||||||
|
|
||||||
|
**验收(并入 T-0b,真 MySQL 才跑)**
|
||||||
|
|
||||||
|
| 用例 | 断言 |
|
||||||
|
| --- | --- |
|
||||||
|
| `test_db.py::test_core_ro_account_is_readonly` | 用 **ro 账号**执行 `INSERT` → **权限被拒**(证明「只读」真的生效,而非注释) |
|
||||||
|
| `test_db.py::test_audit_log_append_only` | 对 `audit_log` 执行 `UPDATE` / `DELETE` → **被拒**(把项目红线「审计表只 INSERT」从约定**升级为 DB 强制**) |
|
||||||
|
| `test_db.py::test_core_rw_scope` | `rw` 账号对**第 5 张表**(如 `core_product`)执行 `UPDATE` → **被拒**(证明「4 表」边界生效) |
|
||||||
|
|
||||||
|
> **sqlite 测试路径零影响**:`tests/conftest.py` 走 sqlite + `tests/_ddl.py`,**无账号概念**;
|
||||||
|
> 账号分离只在真 MySQL(集成测试 / 生产)生效,不改变任何现有用例的连接方式。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 风险与应对
|
||||||
|
|
||||||
|
| # | 风险 | 应对 | **验收点(开发中必须守住)** |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | sqlite/MySQL DDL 列名失配(评审 **R1 · 阻断**) | **T-0 先统一**(以 MySQL 为准)+ `conftest.py` 启动期列名断言;先于 T-1 | **强制 CI 门禁**:`pytest tests/test_db.py::test_core_holding_columns`(断言列名/PK)**必绿**才允许跑 T-1 之后的集成测试;T-0 完成前该用例全绿 = 通过 |
|
||||||
|
| 2 | **批次补建规则写两处 → 漂移**(执行期风险 #2) | **D18**:抽纯函数 `lot_bootstrap.bootstrap_lots()`,`trade_gateway` 兜底补建与 `rebuild_lots.py` **同调此函数** | `test_share_lot.py` 覆盖 `rebuild_lots.py` 入口;断言**同一 `core_holding` 行经两侧算出的批次完全一致**(同源) |
|
||||||
|
| 3 | `Decimal` 默认 `ROUND_HALF_EVEN` 与「四舍五入」不符 | §1 原则 10:`calc.py`/`fee.py`/`nav.py` **所有量化处**显式传 `rounding=ROUND_HALF_UP`(含逐批先舍入后求和的中间步骤) | `test_convert_calc.py` 专门断言 `.5` 边界(HALF_UP vs HALF_EVEN 结果不同处必须命中 HALF_UP) |
|
||||||
|
| 4 | **阶段 1.5 引擎异常不丢预警**(执行期风险 #4) | 不阻断交易,落 `engine_error` + 本地日志(D17);**D19 预留 `on_error_hook` 可插拔**,二期注入 Redis 事件/补偿队列 | `engine.py` 存在 `on_error_hook` 参数且一期传 `None`;hook 自身抛异常**不得**反噬主流程(有单测);确认 `alert_service` 异步补算不丢预警 |
|
||||||
|
| 5 | `convert_batch_max_lots=200` 超限场景(执行期风险 #5) | 一期**返回 400、不自动分拆**(§1 原则 12 / §8.3);错误体带 `batch_count`/`max_lots`,前端提示「请拆分多笔申请」 | §8.3 契约含这两个字段;`TOO_MANY_LOTS` 有单测;文档与响应均明确 **「单笔 = 单事务 = 单 `convert_group_id`」** |
|
||||||
|
| 6 | 批次表改造打穿现有 510 用例 | D8 兜底补建 + `rebuild_lots.py`;先跑全量基线确认 | **T-10 开工前先跑 510 全绿**,作为回归基线快照 |
|
||||||
|
| 7 | 双库不一致 | 三阶段 + 占位 + 补偿脚本 + 本地日志兜底(§5.4) | 阶段二失败 → `convert_detail_write_failed` 审计存在;`rebuild_alerts --convert-group` 可补跑至 `completed` |
|
||||||
|
| 8 | 锁 TTL 30s 被阶段一超时突破 | 阶段一单库短事务、无外部 IO;抢到锁后**二次校验**占位与 Core 流水 | 并发用例覆盖「锁过期后重入」:二次校验拦住重复扣减 |
|
||||||
|
| 9 | 合并 main 前引入 DDL | 用户已拍板现在做;DDL 全部追加式(新表 + 可空列 + DEFAULT),无破坏性变更 | 老 INSERT 语句(不带新列)跑通不回退 |
|
||||||
|
| 10 | **Core「只读」仅代码约定、无 DB 级强制**(主架构评审 **C1**) | **D20 双账号分离**:`xh_core_ro`(`SELECT`)/ `xh_core_rw`(4 表写);`scripts/core/00-grant.sql` 建号授权;DDL 走管理员账号 | `test_core_ro_account_is_readonly` + `test_audit_log_append_only` + `test_core_rw_scope` 在**真 MySQL** 断言权限被拒;账号未配置时**回退单账号**(不阻塞本地开发) |
|
||||||
|
|
||||||
|
> **执行期门禁总则**:上表 #1 是**硬门禁**(T-0 未绿不得继续),#2/#3/#4/#5 是**开发中持续守**的
|
||||||
|
> ——#2 靠「同源单测」、#3 靠「`.5` 边界断言」、#4 靠「接口预留 + hook 不反噬单测」、#5 靠「契约字段 + 单测 +
|
||||||
|
> 文案」三重约束。**#10 是开工前置**(T-0b,与 T-0 并列)。任意一条失守都在**集成测试期才暴露**(成本最高),
|
||||||
|
> 故前移到各自任务的完成标准里。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 拍板结论与溯源(P1~P8 · 全部已拍板)
|
||||||
|
|
||||||
|
> 2026-09-10 用户确认「按建议」,记录见 `docs/项目框架设计/基金转换-审查意见处置表.md` **§九**。
|
||||||
|
|
||||||
|
| # | 事项 | 拍板结论 | 依据 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **P1** | 普通申赎无批次 | **不跳过**:批次表覆盖全部交易类型;遇历史持仓无批次 → **兜底补建** | 真实 TA 份额必有注册批次;跳过致两表失配 |
|
||||||
|
| **P2** | 转出后归零 | **保留 `qty=0` 行**;持仓查询过滤 `qty <= 0` | 真实账户归零后仍存在;避免 UNIQUE 冲突与历史断裂 |
|
||||||
|
| **P3** | Core 明细是否对外 | **仅补偿,不对外查询** | 保持 Core 只读边界最小;对话侧走 agent 库粗粒度详情 |
|
||||||
|
| **P4** | 补差费口径 | **B 为默认**,A 保留 `convert_diff_fee_mode` | 与申购费价外法自洽;A 属近似写法 |
|
||||||
|
| **P5** | 最低持有处置 | **两种都做**(`min_hold_action`) | 两种均真实合同约定;法规无强制 |
|
||||||
|
| **P6** | 申购费率 / 赎回费档 | **按 22 号文改**(合规硬约束) | §0.1 |
|
||||||
|
| **P7** | 份额精度 | **2 位 ROUND_HALF_UP** | §0.2;`rounding_diff` 可正可负 |
|
||||||
|
| **P8** | T+1 确认 | **建模**(`confirmed_at = T+1`,自然日近似) | §0.2 / D14 |
|
||||||
|
|
||||||
|
### 13.1 独立评审结论(2026-09-10)
|
||||||
|
|
||||||
|
外部独立评审(全新上下文、只报告不改码)结论:**✅ 通过(M-7 满足),允许进第 4 步开发计划**。
|
||||||
|
判定:**接受 10 · 修正性接受 3 · 驳回 0**;唯一**硬性前置 = T-0(R1 列名统一 + 启动断言)**。
|
||||||
|
其余 R2~R5 / S1~S5 / Q4·Q6·Q10 已在同一版本内**全部落地**(索引见 §13.2),不阻塞启动。
|
||||||
|
逐条判定与核实证据见 `docs/项目框架设计/评审待办-风控主架构与基金转换.md` §二。
|
||||||
|
|
||||||
|
### 13.2 评审建议落地清单(R2~R5 · S1~S5 · Q4/Q6/Q10 · 全部闭环)
|
||||||
|
|
||||||
|
> **13 条建议全部落点可查**(R1 走 T-0 阻断前置,其余 12 条落本文各节 + PRD v0.9),
|
||||||
|
> 本表即「评审 → 架构/PRD」的对应索引;**0 条悬空**。
|
||||||
|
|
||||||
|
| # | 建议(简述) | 判定 | 落点 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **R1** | sqlite/MySQL 列名统一 | 接受(**升级 T-0 阻断**) | §9 末「已知坑」段 · §12 风险表 · §15 任务 T-0 |
|
||||||
|
| R2 | T+1 自然日近似需声明 | 接受 | PRD v0.9 §5.3 响应 `confirm_basis="natural_day_approx"` · §0.4 建模取舍表 |
|
||||||
|
| R3 | 引擎异常不阻断/不重试 | 接受(备注) | §5.4 「二期补偿增强」段 |
|
||||||
|
| R4 | 批次超限 400 不自动分拆 | 接受 | §1 原则 12 · §8.3 `TOO_MANY_LOTS` 行 |
|
||||||
|
| R5 | `to_fund_ratio` 不参与计算 | 接受 | §0.4 建模取舍表 · D16 |
|
||||||
|
| **S1** | `lot_id` 主键类型/排序 | 修正性接受(**建议不采纳**) | §7 「FIFO 选批 SQL」段(`ORDER BY confirmed_at, lot_id` tiebreaker;主键类型不动) |
|
||||||
|
| **S2** | cleanup 不硬删 | 接受 | §9 `risk_convert_detail` 行 · §2 `cleanup_pending_convert.py` 注释 · §5.4 SLA 行 · PRD v0.9 §4.1 ENUM |
|
||||||
|
| **S3** | `PlanResult` 用结构化类型 | 接受 | §7 开头「类型定义」引块(`service/convert/types.py`) |
|
||||||
|
| **S4** | `client_request_id` 加正则 | 接受(**复用现成**) | §8.1 字段注释 + 引块(复用 `app/main.py:44` `_TRACE_ID_PATTERN`) |
|
||||||
|
| **S5** | nav stale 按产品类型 | 修正性接受(**一期不做**) | §11 `convert_nav_stale_days` 行 |
|
||||||
|
| **Q4** | 补偿幂等 | 修正性接受(**原意见不成立**) | §5.4 「幂等锚点」引块(以转出端 `out_trade_id` 锚定;`rebuild_alerts.py` 本就幂等) |
|
||||||
|
| **Q6** | 重试间隔需压测验证 | 接受 | §10 「50 并发压测口径」段 · 并发用例 ~10 条 |
|
||||||
|
| **Q10** | `rounding_diff` 归属 | 接受 | §5.4 「`rounding_diff` 落审计」引块 |
|
||||||
|
|
||||||
|
> 另:主架构评审 **C1(`core_ro` 无 DB 级只读账号)** 属**跨线交叉项**(不在上表 13 条内),
|
||||||
|
> 已单独落 **D20 / §11.1 / T-0b**,并与 `开发计划-架构改进.md §5.3` 对齐(评审前置结论:推荐独立只读账号)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 设计自检十问复核(项目强制)
|
||||||
|
|
||||||
|
| # | 问 | 本期答案 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | 新增表/字段**谁写**? | `core_share_lot`:`convert_core_repository`(convert)+ `trade_gateway`(普通申赎,**含兜底补建** D8,其规则单点来自 `lot_bootstrap.bootstrap_lots`,D18)· `core_convert_lot_detail`:阶段一同事务 · `risk_convert_detail`:`convert_repository`(阶段零/二)· `min_hold_action`/`to_fund_ratio`:种子写入,代码只读 |
|
||||||
|
| 2 | **谁读**? | `core_ro`(批次/净值/费率/持仓)· `rules._amount_view`(消费 `convert_group_id`)· `core_tools.query_recent_trades`(汇总去重)· `rebuild_alerts.py`(补偿)· `cleanup_pending_convert.py`(超时置 `expired`) |
|
||||||
|
| 3 | **枚举/常量 DDL**? | `risk_convert_detail.status` **ENUM 5 值**(`pending`/`completed`/`failed`/`cancelled`/**`expired`**,建表即全量、零 ALTER,S2)· `core_trade.trade_type` ENUM **已含 convert** · `audit_log.decision` **VARCHAR(64)**,`nav_stale`/`convert_detail_write_failed`/`engine_error` 加值不改 DDL |
|
||||||
|
| 4 | **事务跨库**? | 跨 → 三阶段 + 阶段 1.5(不参与事务)+ 补偿(一期手动、SLA 24h) |
|
||||||
|
| 5 | **种子数据**? | 07/08/09 三新种子(**文件头带版本注释块**);`subscribe_fee_rate` 造出**合规上限内**的不同费率对(债基 0.0030 vs 主动偏股 0.0080),否则补差费恒 0;赎回费种子覆盖 5 档且 7–30 日档 ≥1% |
|
||||||
|
| 6 | **并发安全**? | 批次条件 UPDATE + rowcount 校验 · 执行权 `try_lock` · `uk_idem` 兜底 · 补跑阶段二加锁 |
|
||||||
|
| 7 | **汇总语义唯一**? | 响应不返回汇总 `hold_days` · `_amount_view` 组内取转出端 · `out_amount`/`convert_amount`/`in_amount` 三者语义分立(PRD §5.3) |
|
||||||
|
| 8 | **向后兼容**? | `core_trade` 加可空列 + 索引 · `core_product` 8 列均有 DEFAULT · `get_latest_nav` 不动改新增 · `record_trade_alerts` 新增可选参数缺省退化 · **`get_engine(db, role="rw")` 新参数带默认值、角色账号未配置回退 `mysql_user`**(D20,现有调用点零改动) |
|
||||||
|
| 9 | **示例自证 / 步骤可执行**? | §7 给出全部纯函数签名与分档表 · §5.2 给出重试判定 SQL · §9 给出 DDL 落点清单 · `scripts/dev/calc_convert_demo.py` 实算回填示例(禁手算) · §0.5 每条真实口径均附来源 |
|
||||||
|
| **10** | **外部事实核验**?(v1.0 新增) | §0.1/§0.2/§0.5 列出 22 号文 + 9 条公告;申购费率上限、赎回费下限、份额精度、T+1 四处的取值**均有法规条款或公告原文对应**,非「看起来合理」 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 与第 4 步的衔接(任务映射 · 已定稿,可直接作为开发计划输入)
|
||||||
|
|
||||||
|
| 任务 | 内容 | 依赖 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **T-0** | **列名统一 + 启动断言(评审 R1 · 阻断前置)**:以 MySQL 为准重写 `tests/_ddl.py` 的 `core_holding`(`qty`/`cost_amount`/`as_of`/`pnl_pct` + PK),`conftest.py` 加启动期列名断言,新增 **`tests/test_db.py::test_core_holding_columns`** | 无 |
|
||||||
|
| **T-0b** | **DB 账号分离(D20 · 主架构 C1 · 阻断前置)**:`scripts/core/00-grant.sql` 建 3 账号授权;`settings.py` 加 3 组账号;`db.py` 改 `get_engine(db, role)`;`core_ro` 走 `ro`、gateway 写路径走 `rw`(读仍 `ro`);新增 3 个权限断言用例(§11.1) | 无(可与 T-0 并行) |
|
||||||
|
| T-1 | DDL + 种子(**00-grant** / 01-ddl / 07 / 08 / 09 / 02-agent)+ reset.ps1 | **T-0 + T-0b(门禁绿)** |
|
||||||
|
| T-2 | `service/convert/` 纯函数:`types.py` + `calc.py` + `fee.py` + `nav.py` + `lot_bootstrap.py`(D18) | 无 |
|
||||||
|
| T-2b | `scripts/dev/calc_convert_demo.py` 实算主示例 + 同费率对照,**回填 PRD §5.3 与验收断言** | T-2 |
|
||||||
|
| T-3 | `core_ro` 五个新方法 + `share_lot_repository` | T-1 |
|
||||||
|
| T-4 | `convert_repository`(agent 侧占位/回写/查询/清理) | T-1 |
|
||||||
|
| T-5 | `locks.try_lock` + 单测 | 无 |
|
||||||
|
| T-6 | `convert_core_repository.apply_convert`(阶段一事务) | T-1/T-3 |
|
||||||
|
| T-7 | `convert_service` 编排(八步 + 执行权 + 幂等 + 三阶段 + 阶段 1.5) | T-2~T-6 |
|
||||||
|
| T-8 | `rules._amount_view` + `engine.process_convert_event`(含 **D19 `on_error_hook` 预留**)+ `alert_service.events` 参数 | 无(可与 T-2 并行) |
|
||||||
|
| T-9 | `api/simulate.py` 模型与错误码(含 `TOO_MANY_LOTS`)+ `trade_gateway` 分派 | T-7 |
|
||||||
|
| T-10 | 普通申赎批次维护(FR-C16,**含兜底补建 D8,调 `lot_bootstrap`**)+ `rebuild_lots.py`(同调,D18)。**开工前先跑 510 全绿基线** | T-3(排期置于 T-7 后) |
|
||||||
|
| T-11 | `core_tools.query_recent_trades` 汇总去重(FR-C15)+ 持仓过滤 `qty <= 0` | T-8 |
|
||||||
|
| T-12 | 补偿脚本 `rebuild_alerts --convert-group` + `cleanup_pending_convert.py` | T-4/T-7 |
|
||||||
|
| T-13 | 全量回归 + 集成测试 + **50 并发压测(评审 Q6)** + 实测性能补录(PRD §9 第 18 条) | 全部 |
|
||||||
|
|
||||||
|
### 15.1 依赖拓扑与并行分组
|
||||||
|
|
||||||
|
```text
|
||||||
|
T-0 (⛔ 阻断:列名统一 + 门禁 test_core_holding_columns)─┐
|
||||||
|
T-0b(⛔ 阻断:DB 账号分离 D20 + 3 个权限断言)─────────────┴─► T-1(DDL / 种子)
|
||||||
|
└─► T-2(纯函数 types/calc/fee/nav/lot_bootstrap)─► T-2b(示例实算回填)
|
||||||
|
├─► T-3 ─┐
|
||||||
|
├─► T-4 ─┼─► T-6(阶段一事务)─► T-7(编排 · 关键路径)
|
||||||
|
└─► T-5 ─┘ ├─► T-9(API + gateway 分派)
|
||||||
|
└─► T-12(补偿脚本)
|
||||||
|
T-8(引擎改造 · 可全程与上述并行)─► T-11(工具汇总去重)
|
||||||
|
T-10(批次维护 · 回归风险最大:先跑 510 基线再动)
|
||||||
|
└─► T-13(全量回归 + 50 并发压测 + 性能补录)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **并行组 A**:T-3 / T-4 / T-5(T-1 完成后可同时开工)
|
||||||
|
- **并行组 B**:T-8 与 T-2 之后任意阶段并行(无依赖,仅需 `run_rules` 既有接口)
|
||||||
|
- **关键路径**:T-0 → T-1 → T-2 → T-6 → T-7 → T-13
|
||||||
|
- **高风险任务**:**T-10**(改 `trade_gateway` 主流程,510 用例直接受影响)→ 单独结项、先基线后改造
|
||||||
|
- **门禁**:**T-0 与 T-0b 双双绿**才允许启动 T-1 及之后(CI 强制);
|
||||||
|
账号未配置时回退单账号(本地开发不被阻塞),但**真 MySQL 集成测试必须跑在拆分账号下**
|
||||||
|
- **基线**:510 → 预计 **580~600** 用例(§10)
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# 评审待办 · 风控主架构 + 基金转换架构
|
||||||
|
|
||||||
|
> **用途**:两份架构评审意见**合并处理**的执行清单。
|
||||||
|
> **用户指示(2026-09-10)**:「等基金架构的评审也出来了一起改」。
|
||||||
|
> **当前状态**:**基金转换架构 v1.0 评审 13 条已全部落地**(落点索引见架构 §13.2,0 条悬空,2026-09-10);
|
||||||
|
> **P0-1 账号方案已拍板**(§三 → 采纳独立账号方案,落 D20 / T-0b);其余风控主架构 **P0/P1 待处置**(见 §一)。
|
||||||
|
> **关联文档**:`架构设计-基金转换交易.md`(v1.0)· `架构设计-风控模块.md` · `基金转换-审查意见处置表.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、风控主架构评审(2026-09-10 收到 · 已抽查核实)
|
||||||
|
|
||||||
|
### P0 · 安全与正确性底线
|
||||||
|
|
||||||
|
| # | 风险点 | 核实结论 | 建议修复 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | Core 只读靠约定、**无 DB 级强制** | **✅ 属实**(`settings.py:17` 单 root 账号;`db.py:23` 仅按库名缓存 → `core_ro:55` 与 `gateway_repository:24` 共用同一连接池) | **✅ 已拍板并落地设计**:**D20 双账号分离**(见 §三 + 架构 §11.1)—— `xh_core_ro`(SELECT) / `xh_core_rw`(4 表写) / `xh_agent_rw`(audit 仅 INSERT);`get_engine(db, role)`;DDL 走管理员。**T-0b 阻断前置** |
|
||||||
|
| 2 | `agent_message(seq_no)` 并发重号 | **✅ 属实**:`表设计/01-mysql-共用底座.sql:47` 是普通 `KEY idx_session_seq`,非 UNIQUE | 加 `UNIQUE(session_id, seq_no)` + `insert_turn` 捕获 `IntegrityError` 重试 |
|
||||||
|
| 3 | dev debug 通道双闸门 | **⚠️ 已实现,且外审行号引错**:双闸门实际在 `app/api/deps.py:258`(非 237-238) | 属**加固**:非 development 或配了公钥时加显式启动断言 |
|
||||||
|
| 4 | `scoring.py` 仅预留桩,L3 `risk_score` 恒 NULL | **✅ 属实,但是设计意图**:`scoring.py:18` 抛 `NotImplementedError`,docstring 明写「FR-7 本期静态映射、评分模型后置」,签名已冻结 | 列入下一里程碑实现(PRD R-05) |
|
||||||
|
| 5 | 跨库最终一致无自动补偿 | 未实证(按架构判断基本属实) | `rebuild_alerts.py` 纳入 cron + 监控告警 |
|
||||||
|
|
||||||
|
### P1 · 可维护性债务
|
||||||
|
|
||||||
|
| # | 问题 | 核实结论 | 建议 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | C5 红线权限逻辑分散三处(`deps.py` 矩阵 / `chat.py:122` / `tool_service.py:138`) | 未核 | 抽 `PermissionPolicy` 类集中,或至少加文档交叉引用清单 |
|
||||||
|
| 2 | 意图识别仅关键词 | 属实(架构已声明为有意取舍) | 先做同义词扩展表(低成本),再评估 LLM 路由(会损失可复现性) |
|
||||||
|
| 3 | `entities.py` 31 个 ORM 类闲置 | **✅ 属实**(实测 `^class ` 计数 = 31),查询走 text SQL + dict | 渐进引入 TypedDict,先从高频 Tool 入手 |
|
||||||
|
| 4 | 注入词表硬编码 45 条 | 未核 | 配置化(表或 `.env` 多行)+ 热加载 |
|
||||||
|
| 5 | 日志基建缺失 | **✅ 属实**:`app/utils/logger.py` 全文仅一行 `"""日志模块。"""` | 接入 structlog + `logging.config.dictConfig`,统一 JSON 落盘 |
|
||||||
|
|
||||||
|
### 无需反悔(外审已确认的既有架构决策)
|
||||||
|
|
||||||
|
四 Agent 差异表达(一张图 + 数据驱动)· Tool 与 LLM 流式(Tool 同步跑完再流式)· 意图识别(关键词,可复现可单测)· 适当性矩阵(查表数据驱动)· 降级策略(限流 fail-open、Embedding fail-closed)· 阻断逻辑(网关专用分支,物理隔离防误扩权,**仅 R-02 可阻断**)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、基金转换架构 v1.0 独立评审(2026-09-10 已出 · 已逐条判定 · **已全部落地**)
|
||||||
|
|
||||||
|
**评审结论:✅ 通过(M-7 满足),唯一硬前置 = R1。**
|
||||||
|
**判定结果:接受 10 · 修正性接受 3 · 驳回 0。**
|
||||||
|
**落地状态:✅ 13 条全部闭合**(R2~R5 / S1~S5 / Q4·Q6·Q10 落架构正文各节 + 架构 §13.2 索引表;
|
||||||
|
R1 升级为 T-0 阻断前置;S2 另回填 PRD v0.9 枚举;R2 回填 PRD v0.9 响应字段)。
|
||||||
|
|
||||||
|
### 硬风险(R1~R5)
|
||||||
|
|
||||||
|
| # | 意见 | 判定 | 处置与证据 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **R1** | sqlite/MySQL `core_holding` 列名不同名 | **接受(升级为 T-0 阻断前置)** | **已核实属实**:`tests/_ddl.py:50-54` 只有 `market_value`/`quantity`,且**无 PK/唯一约束**(额外发现)。处置:**T-0 先于 T-1** —— 以 MySQL 为准统一 + `conftest.py` 启动期列名断言 |
|
||||||
|
| R2 | T+1 自然日近似 vs 真实工作日 | **接受** | 响应与审计加 `confirm_basis: "natural_day_approx"`;真实 Core 接入后补交易日历 |
|
||||||
|
| R3 | 阶段 1.5 异常不阻断、不重试 | **接受(一期备注)** | 一期人工 SLA 24h;评审建议的「补偿队列 / Redis 事件 + cleanup 顺带巡检补跑」记**二期** |
|
||||||
|
| R4 | `convert_batch_max_lots=200` 超限 400 | **接受** | 保留 400 `TOO_MANY_LOTS` + 注释「自动分拆多笔留二期」 |
|
||||||
|
| R5 | `to_fund_ratio=1.0` 不参与计算 | **接受** | 已在 §0.4 / D16 注明「本期不参与计算」;二期启用时同步改 `fee.py` |
|
||||||
|
|
||||||
|
### 设计建议(S1~S5)
|
||||||
|
|
||||||
|
| # | 建议 | 判定 | 处置与证据 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| S1 | `lot_id` 改 BIGINT + UNIQUE | **修正性接受** | **理由部分不成立**:FIFO 排序依据是 `confirmed_at`(架构 §7 + PRD §4.1 已建 `KEY(customer_id,product_id,confirmed_at)`),**不是 `lot_id`**;且 `lot_id` 已是 PK(唯一)。处置:`ORDER BY confirmed_at, lot_id` 作确定性 tiebreaker,**主键类型不动**(真实 TA 注册登记批次号是字符串) |
|
||||||
|
| S2 | cleanup 改 `status='expired'`,不硬删 | **接受** | 建表即含 `'expired'`(新表零 ALTER);同步改 PRD §4.1 ENUM |
|
||||||
|
| S3 | `PlanResult` 加 dataclass/TypedDict | **接受** | 防 dict 键拼写错误;呼应主架构 `entities.py` 闲置问题 |
|
||||||
|
| S4 | `client_request_id` 加正则 | **接受** | **有现成参照**:`app/main.py:44` `_TRACE_ID_PATTERN = re.compile(r"^[A-Za-z0-9._-]{1,64}$")`,直接复用 |
|
||||||
|
| S5 | nav stale 按产品类型区分 | **修正性接受** | 模拟库 14 产品均日频、无频次差异;真实 Core 接入后再按产品类型分档 |
|
||||||
|
|
||||||
|
### 自检十问追问(Q4/Q6/Q10)
|
||||||
|
|
||||||
|
| # | 追问 | 判定 | 处置与证据 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Q4 | `rebuild_alerts.py` 是否幂等 | **修正性接受** | **已幂等**:`scripts/demo/rebuild_alerts.py:8-10` docstring + `:54-66` 的 `find_alerts_by_trade(trade_id)` 命中即 `skipped`,**不需要「再加 uk_idem」**。但 convert 补偿须以**转出端 `out_trade_id` 为幂等锚点**(一次转换两条流水) |
|
||||||
|
| Q6 | 并发重试间隔是否够 | **接受** | §10 增「50 并发压力用例」验证重试成功率 |
|
||||||
|
| Q10 | `rounding_diff` 谁承担 | **接受** | 审计落 `rounding_diff` 正负向与金额,供二期对账 |
|
||||||
|
|
||||||
|
### 任务优先级
|
||||||
|
|
||||||
|
**接受**:插入 **T-0(列名统一 + 启动断言)** 于 T-1 之前。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、交叉点(✅ 已拍板 · 2026-09-10)
|
||||||
|
|
||||||
|
**P0-1 / C1 · Core 读写账号分离** —— 用户拍板结论:**「按真实项目走,贴近真实项目做法」= 采纳独立账号方案**。
|
||||||
|
|
||||||
|
**历史脉络(重要 · 勿重复设计)**:本项在主架构线**已评审过** ——
|
||||||
|
`改进方案评审-问题清单与对比.md` **§C1**(`core_ro` 无 DB 级只读账号,红线相关,**推荐方案 A:独立只读用户 GRANT SELECT ONLY**,
|
||||||
|
否决 B「代码层 SQL 白名单」:易被注释/子查询绕过),并已挂进 `开发计划-架构改进.md **§5.3**`(标注「需运维配合」,**未实施**)。
|
||||||
|
本次由基金转换线**接手落地**。
|
||||||
|
|
||||||
|
**落地清单** → `架构设计-基金转换交易.md` **§11.1**(账号矩阵 / 配置 / `db.py` 改造 / 3 个权限断言)+ **D20** + **T-0b**:
|
||||||
|
|
||||||
|
| 账号 | 授权 | 使用者 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `xh_core_ro` | `SELECT` 全库 | `core_ro` / `core_tools` / `deps` / risk / **gateway 的校验读** |
|
||||||
|
| `xh_core_rw` | 4 表 `SELECT/INSERT/UPDATE`,**无 DELETE / DDL** | **仅 gateway 写路径** |
|
||||||
|
| `xh_agent_rw` | 业务表读写 + **`audit_log` 只授 INSERT** | Agent 侧全部 |
|
||||||
|
| `root` | 全权 | **仅** `00-grant.sql` / `01-ddl.sql` / `reset.ps1` |
|
||||||
|
|
||||||
|
> **对基金转换的意义**:`app/gateway/convert_core_repository.py` 是**唯一写 `jinrong_core` 的层** →
|
||||||
|
> 必须持有 `rw` 账号。若按「只读连接池」一刀切,T-1/T-6 会直接返工 —— **该风险已由 D20 消除**。
|
||||||
|
> 附带收益:把项目红线「**审计表只 INSERT**」从代码约定升级为 **DB 级强制**(`audit_log` 回收 UPDATE/DELETE)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、处理时机
|
||||||
|
|
||||||
|
| 阶段 | 动作 | 状态 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 基金转换评审出来 | 两份**合并**,逐条判定 → 更新各自架构文档 | ✅ 已完成(13 条落地,架构 §13.2 索引) |
|
||||||
|
| 基金转换开工前(T-1 之前) | **必须定 P0-1 账号方案**(见 §三) | ✅ **已拍板**:按真实项目走 = 独立账号方案(D20 / T-0b) |
|
||||||
|
| 基金转换 T-0 | sqlite/MySQL 列名统一 + `conftest.py` 启动断言(R1) | ⏳ 待开发 |
|
||||||
|
| 基金转换 T-0b | DB 账号分离:`00-grant.sql` + `db.py(db, role)` + 3 权限断言(D20) | ⏳ 待开发 |
|
||||||
|
| 下一迭代 | P0-4 `scoring.py` 实现 · P1-5 日志基建 · P1-2 同义词表 · §一其余 P0/P1 | ⏳ 排期 |
|
||||||
|
|
||||||
|
> **收口结论**:基金转换线**文档阶段已闭环**(PRD v0.9 + 架构 v1.0 + 评审 0 悬空),
|
||||||
|
> 门控 M-7 满足、可进第 4 步开发计划;**唯一开工前置**是 §三 的 gateway 可写账号方案(用户拍板)+ T-0(开发首个任务)。
|
||||||
|
|
||||||
|
### 补充:执行期风险 5 条(用户 2026-09-10 提供 · 已并入架构)
|
||||||
|
|
||||||
|
| # | 风险 | 架构落点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | T-0 未完成就跑 T-1~ | 架构 §12 #1(CI 门禁 `tests/test_db.py::test_core_holding_columns`)+ §15 T-0 依赖 |
|
||||||
|
| 2 | 批次补建规则写两处漂移 | 架构 **D18**(抽 `lot_bootstrap.bootstrap_lots()` 纯函数)+ §12 #2 + §15 T-2/T-10 |
|
||||||
|
| 3 | `Decimal` 量化未传 `ROUND_HALF_UP` | 架构 §1 原则 10 + §12 #3(`.5` 边界断言) |
|
||||||
|
| 4 | 阶段 1.5 异常不丢预警 | 架构 **D19**(`on_error_hook` 可插拔)+ §6.2 + §12 #4 |
|
||||||
|
| 5 | `convert_batch_max_lots` 超限 | 架构 §1 原则 12 + §8.2 `batch_count`/§8.3 响应体 + §12 #5(前端文案) |
|
||||||
|
|
||||||
|
> 另:用户提供的任务拓扑已落 **架构 §15.1**(并行组 A/B、关键路径、高风险任务 T-10、门禁与基线)。
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""基金转换主示例实算(PRD §5.3 数值来源)。
|
||||||
|
|
||||||
|
用途:PRD §5.3 响应示例中的**所有派生金额/份额**,必须由本脚本算出后回填,
|
||||||
|
**禁止手算**——v0.7 曾两次因手算与口径不符被外部审查质疑(见处置表 T-2b / 自检第 9 问)。
|
||||||
|
|
||||||
|
公式与 PRD §2.1 / §2.1.1 / §2.1.3 / §2.5 完全一致:
|
||||||
|
转出金额 = Σ(各批次份额 × 转出净值)
|
||||||
|
赎回费 = Σ(各批次金额 × 该批适用赎回费率) # 逐批次先舍入、后求和
|
||||||
|
转换金额 = 转出金额 − 赎回费
|
||||||
|
补差费(B) = Max[ 转换金额×转入费率/(1+转入费率)
|
||||||
|
− 转换金额×转出费率/(1+转出费率), 0 ] # 价外法两端差(本期默认)
|
||||||
|
补差费(A) = 转换金额 × max(转入费率−转出费率,0) / (1+补差费率) # 费率差法(配置保留)
|
||||||
|
转入金额 = 转换金额 − 补差费
|
||||||
|
转入份额 = 转入金额 ÷ 转入净值 # 2 位,ROUND_HALF_UP
|
||||||
|
|
||||||
|
运行:<托管 Python> scripts/dev/calc_convert_demo.py
|
||||||
|
"""
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
|
||||||
|
TWO = Decimal("0.01")
|
||||||
|
FOUR = Decimal("0.0001")
|
||||||
|
|
||||||
|
|
||||||
|
def q2(x: Decimal) -> Decimal:
|
||||||
|
"""金额/份额统一保留 2 位、四舍五入(显式 ROUND_HALF_UP,禁用默认 HALF_EVEN)。"""
|
||||||
|
return x.quantize(TWO, rounding=ROUND_HALF_UP)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
# —— 主示例输入(与 §5.3 / §4.3 种子一致)——
|
||||||
|
lots = [
|
||||||
|
# (份额, 持有天数, 该批赎回费率) —— 费率来自 §2.1.3 分档表
|
||||||
|
(Decimal("30000"), 100, Decimal("0.0050")), # [30,180) 日 → 0.5%
|
||||||
|
(Decimal("20000"), 3, Decimal("0.0150")), # [0,7) 日 → 1.5%
|
||||||
|
]
|
||||||
|
nav_out = Decimal("1.03") # PROD-110022 债基 转出净值
|
||||||
|
nav_in = Decimal("0.95") # PROD-005827 主动偏股 转入净值
|
||||||
|
fee_out = Decimal("0.0030") # 转出端申购费率(债基,22 号文 §8 上限 0.3%)
|
||||||
|
fee_in = Decimal("0.0080") # 转入端申购费率(主动偏股,上限 0.8%)
|
||||||
|
|
||||||
|
out_amount = Decimal("0")
|
||||||
|
redeem_fee = Decimal("0")
|
||||||
|
detail = []
|
||||||
|
for qty, days, rate in lots:
|
||||||
|
amt = q2(qty * nav_out)
|
||||||
|
fee = q2(amt * rate)
|
||||||
|
out_amount += amt
|
||||||
|
redeem_fee += fee
|
||||||
|
detail.append((qty, days, rate, amt, fee))
|
||||||
|
|
||||||
|
convert_amount = q2(out_amount - redeem_fee)
|
||||||
|
|
||||||
|
# —— 口径 B:价外法两端差(本期默认)——
|
||||||
|
sub_in = convert_amount * fee_in / (Decimal("1") + fee_in)
|
||||||
|
sub_out = convert_amount * fee_out / (Decimal("1") + fee_out)
|
||||||
|
diff_b = q2(max(sub_in - sub_out, Decimal("0")))
|
||||||
|
in_amount = q2(convert_amount - diff_b)
|
||||||
|
theoretical = in_amount / nav_in
|
||||||
|
in_qty = q2(theoretical)
|
||||||
|
rounding_diff = theoretical - in_qty
|
||||||
|
|
||||||
|
# —— 口径 A:费率差法(配置 convert_diff_fee_mode='rate_diff' 保留)——
|
||||||
|
rate_diff = max(fee_in - fee_out, Decimal("0"))
|
||||||
|
diff_a = q2(convert_amount * rate_diff / (Decimal("1") + rate_diff))
|
||||||
|
in_qty_a = q2(q2(convert_amount - diff_a) / nav_in)
|
||||||
|
|
||||||
|
# —— 同费率对照(补差 = 0),用于 §9 第 19 条 ——
|
||||||
|
same_rate = Decimal("0.0030")
|
||||||
|
diff_same = q2(max(convert_amount * same_rate / (Decimal("1") + same_rate)
|
||||||
|
- convert_amount * same_rate / (Decimal("1") + same_rate),
|
||||||
|
Decimal("0")))
|
||||||
|
in_amount_same = q2(convert_amount - diff_same)
|
||||||
|
in_qty_same = q2(in_amount_same / nav_in)
|
||||||
|
|
||||||
|
print("=== 主示例(口径 B)===")
|
||||||
|
for qty, days, rate, amt, fee in detail:
|
||||||
|
print(f"批次 {qty} 份 / {days} 天 / 费率 {rate} -> amount={amt} fee={fee}")
|
||||||
|
print(f"out_amount = {out_amount}")
|
||||||
|
print(f"redeem_fee = {redeem_fee}")
|
||||||
|
print(f"convert_amount = {convert_amount}")
|
||||||
|
print(f" (转入端申购费) = {sub_in.quantize(FOUR)} -> q2 {q2(sub_in)}")
|
||||||
|
print(f" (转出端申购费) = {sub_out.quantize(FOUR)} -> q2 {q2(sub_out)}")
|
||||||
|
print(f"diff_fee(B) = {diff_b}")
|
||||||
|
print(f"in_amount = {in_amount}")
|
||||||
|
print(f"in_qty = {in_qty}")
|
||||||
|
print(f"rounding_diff = {rounding_diff.quantize(FOUR)} (理论 {theoretical.quantize(FOUR)} - 实际 {in_qty})")
|
||||||
|
print()
|
||||||
|
print("=== 对照:口径 A(费率差法)===")
|
||||||
|
print(f"diff_fee(A) = {diff_a}")
|
||||||
|
print(f"in_qty(A) = {in_qty_a}")
|
||||||
|
print()
|
||||||
|
print("=== 同费率对照(两端 0.0030,补差 = 0)===")
|
||||||
|
print(f"diff_fee={diff_same} in_amount={in_amount_same} in_qty={in_qty_same}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user