feat(convert): 基金转换 T-0/T-0b 门禁 + T-1 数据层 + T-2/T-2b 纯函数与实算回填

T-0 / T-0b(门禁 · 2026-09-10)
- T-0:sqlite 与 MySQL 结构对齐 —— core_holding 统一为 qty/cost_amount/as_of/pnl_pct
  + PK + UNIQUE(customer_id, product_id);补 core_product_nav;新增建库自校验
  _assert_ddl_aligned()(R-g);test_db.py 增 3 条门禁用例(含反向验证门禁失效)
- T-0b:DB 账号分离(D20)—— 新增 scripts/core/00-grant.sql(三账号逐表授权);
  settings.py 增 3 组账号;db.py 改 get_engine(db, role),缓存键改为 (库名, 角色),
  账号未配置回退单账号;core_ro→ro / gateway_repository→rw / risk·session_repository→rw;
  tests/conftest.py 四处显式 role="admin"(R-e)

T-1(数据层)
- scripts/core/01-ddl.sql:新建 core_fee_rule / core_share_lot / core_convert_lot_detail;
  core_trade 加 convert_group_id + idx_convert_group;core_product 加 8 列 + fee_rate 补 COMMENT
- 新增 07-seed-fee-rule.sql(赎回费 5 档 × 14 产品,按 22 号文 §10)/ 08-seed-share-lot.sql
  (58 行持仓 → 61 行批次,Σ remain_qty 恒等于 qty)/ 09-seed-org.sql(管理人 + TA +
  申购费率 + 最低持有余额,v1.1 按「管理人全产品线」重排)
- reset.ps1 追加 07/08/09;02-mysql-agent专用.sql 追加 risk_convert_detail
- tests/_ddl.py 同步 4 表 + 新增 REQUIRED_CONVERT_TABLES 建库门禁
- 新增 scripts/dev/verify_convert_seed.py(pymysql 等价 reset 流程 + 8 条 DoD 断言,
  含断言 ⑧「费率档 ↔ product_type 匹配」,越档即 FAIL)

T-2 / T-2b(纯函数包 + 示例实算回填)
- 新增 app/service/convert/ 7 文件:__init__ / types / calc / fee / nav / lot_bootstrap / errors
  (纯函数,不查库、不碰 SQL;所有量化显式 ROUND_HALF_UP;lot_bootstrap 用 zlib.crc32
   保证 D18 跨进程同源)
- 新增 tests/test_convert_calc.py 93 用例(12 类:HALF_UP 反向自证 / 分档边界 /
  FIFO 含同 confirmed_at 兜底 / 双口径 / 强制全转与强制赎回 / PRD §5.3 全链自证 /
  纯函数零 IO 依赖断言)
- 重写 scripts/dev/calc_convert_demo.py:去掉脚本内公式副本,改为调用生产 calc.py,
  末尾与 PRD §5.3 逐项比对(不一致即退出码 1),兼作一致性门禁

验证
- pytest 609 passed / 3 skipped(516 → +93,零回归)
- verify_convert_seed.py 8/8 PASS;calc_convert_demo.py 15/15 与 PRD §5.3 一致

文档:PRD v0.9.1(费率分类修正)· 架构 §7 签名回填 / §8.3 错误码注 / §15 T-2 完成 ·
开发计划 §1.5 新增 R-h + §4.2·§4.3 执行记录 · AGENTS.md · docs/memory
This commit is contained in:
2026-09-10 14:45:55 +08:00
parent ea9a132a40
commit c5182f1910
42 changed files with 4427 additions and 133 deletions
@@ -402,8 +402,17 @@ def diff_fee(conv_amount: Decimal, out_rate: Decimal, in_rate: Decimal,
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 修正)
def hold_days(trade_date: date, confirmed_at: datetime) -> int: # (交易日 − 确认日).days,不含申请日
def rounding_diff(in_amount: Decimal, in_nav: Decimal, actual_in_qty: Decimal,
places: int = 4) -> Decimal: # 理论份额 − 实得份额(响应字段)
def ensure_batch_limit(plan: PlanResult, max_lots: int) -> None: # 批次数超限 → TooManyLots(§8.3)
```
> **T-2 落地补充(2026-09-10)**:末三行签名由 T-2 执行期补入,`hold_days` 是
> `pick_fee_rate` 的入参来源(T+1 起算、满 7 日归 7–30 档都靠它),`rounding_diff`
> 对应 PRD §5.3 响应字段,`ensure_batch_limit` 把「先规划再判上限」固定成一步。
> 三者均在 `calc.py`,纯函数、零 IO,已在 `tests/test_convert_calc.py` 覆盖。
> ⚠️ **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)。
@@ -508,6 +517,14 @@ class TradeRequest(BaseModel):
异常类定义在 `app/utils/exceptions.py`(复用现有 `ApiError`),convert 专属异常放 `service/convert/errors.py`。
> **T-2 落地补充(2026-09-10)**:`errors.py` 实际实现 **11 个**异常 —— 上表 10 条
> 全部落地(含 `LotConflict` / `IdempotencyUnavailable` 两个机制类),**另加 1 个上表之外的
> 内部兜底**:`FeeRuleMissing`(500 / `FEE_RULE_MISSING`),仅在 `core_fee_rule` 缺少
> `[0, 7)` 档(种子漏灌)时触发。**它不是业务错误体契约的一部分**,列在这里只为
> 「触发条件可查」:`pick_fee_rate` 无命中时**绝不返回 0 费率**——静默按 0 计费会少收
> 赎回费且不留痕,比直接失败危险得多。(开发计划 §4.2 原文写「8 个」,为该文档的计数笔误,
> 以本表为准;差异已在开发计划执行记录中留痕。)
---
## 9. 数据模型落点清单(DDL 与种子)
@@ -589,8 +606,8 @@ teardown 按前缀清理 `core_trade`/`core_share_lot`/`core_convert_lot_detail`
| --- | --- | --- | --- |
| `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`,**应用运行时不持有** |
| `xh_agent_rw` | `jinrong_agent` | `SELECT/INSERT/UPDATE` 业务表;**`audit_log` 只授 `SELECT, INSERT`**(无 `UPDATE`/`DELETE` —— 只能追加、不可改删) | Agent 侧全部(`risk_repository` / `session_repository` / `convert_repository`) |
| `root`(管理员) | 两库 | 全权 | **仅** `00-grant.sql` / `01-ddl.sql` / `reset.ps1`,以及 **`tests/conftest.py` 真 MySQL 集成测试的 setup/teardown**(teardown 需 DELETE,见下方修正);**应用运行时不持有** |
> ⚠️ **关键边界**:**gateway 的读走 `ro`、写走 `rw`**,两个 engine 并存 ——
> `trade_gateway.py:105` 现有的 `core_ro or CoreReadOnlyRepository()`(校验读)**保持只读账号不变**,
@@ -613,8 +630,13 @@ def get_engine(database: str, role: str = "rw") -> Engine:
对应角色账号未配置 → 回退 settings.mysql_user(行为与现状一致)。"""
```
> `dispose_engines()` 语义不变(清空全部缓存);`tests/conftest.py:183-233` 的两处调用
> 保持单参数默认值即可,**现有 510 用例零改动**。
> `dispose_engines()` 语义不变(清空全部缓存)。
>
> ⚠️ **修正(T-0b 实施时发现)**:`tests/conftest.py` 的四处调用**不能**沿用默认角色 ——
> teardown 要 `DELETE FROM core_trade / risk_alert / audit_log`,而 `xh_core_rw` 与
> `xh_agent_rw` **均无 DELETE 权限**,沿用默认角色会让真 MySQL 集成测试在 setup/teardown 失权。
> 故四处**显式 `role="admin"`**(对应开发计划 R-e);`app/` 内业务调用一律不用 admin。
> sqlite 单测路径无账号概念,默认配置下现有用例仍零改动。
**验收(并入 T-0b,真 MySQL 才跑)**
@@ -627,6 +649,20 @@ def get_engine(database: str, role: str = "rw") -> Engine:
> **sqlite 测试路径零影响**:`tests/conftest.py` 走 sqlite + `tests/_ddl.py`,**无账号概念**;
> 账号分离只在真 MySQL(集成测试 / 生产)生效,不改变任何现有用例的连接方式。
**实施记录(T-0b · 2026-09-10 完成)**
- `scripts/core/00-grant.sql` 已落地:3 账号 + 逐表授权(agent 库 17 张业务表 + `risk_convert_detail`),
**不进 `reset.ps1`**(`DROP DATABASE` 不清 `mysql.db` 授权行,授权一次即可)。
- **两处口径修正(实施时发现,已同步 `00-grant.sql` 注释)**:
1. `audit_log` 实际授权为 **`SELECT, INSERT`** —— 原文「只授 INSERT」若**字面执行**会一并剥夺
`SELECT`,使 `risk_repository.has_engine_error_audit`(`:231`)与 `list_audit_events`(`:420`)
双双失败;红线本意是「只能追加、不可改删」,故正确授权含 SELECT。
2. `tests/conftest.py` 四处改 `role="admin"`(见上方修正框,对应开发计划 R-e)。
- **不授 DELETE 的依据**:`app/` 与 `scripts/` 全仓**无任何 `DELETE` / `TRUNCATE` / `DROP`**(已 grep 核实),
故 `xh_core_rw` / `xh_agent_rw` 去掉 DELETE 不影响任何现有代码路径。
- 3 条权限断言用例已写入 `tests/test_db.py`,**账号未配置或真库不可达时自动 skip**;
默认配置下全量测试保持全绿(本任务完成时 **516 passed / 3 skipped**,基线 510)。
---
## 12. 风险与应对
@@ -707,7 +743,7 @@ def get_engine(database: str, role: str = "rw") -> Engine:
| 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% |
| 5 | **种子数据**? | 07/08/09 三新种子(**文件头带版本注释块**);`subscribe_fee_rate` **严格按 `product_type` 取档**(`stock` 0.0080 / `mixed` 0.0050 / `bond`·`index` 0.0030 / `money` 0)造出**合规上限内且同主体可互转**的不同费率对(`PROD-110022` 债基 0.0030 → `PROD-003095` 主动偏股 0.0080,同属华夏模拟基金/TA-CN-001),否则补差费恒 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,现有调用点零改动) |
@@ -720,10 +756,10 @@ def get_engine(database: str, role: str = "rw") -> Engine:
| 任务 | 内容 | 依赖 |
| --- | --- | --- |
| **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-0** ✅ | **列名统一 + 启动断言(评审 R1 · 阻断前置)**:以 MySQL 为准重写 `tests/_ddl.py` 的 `core_holding`(`qty`/`cost_amount`/`as_of`/`pnl_pct` + PK)+ 新增 `core_product_nav` + 建库自校验 `_assert_ddl_aligned()`,新增 **`tests/test_db.py::test_core_holding_columns`**。**2026-09-10 完成**(另发现:2 处测试 INSERT 还需补 3 个 NOT NULL 列) | 无 |
| **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)。**2026-09-10 完成**(`audit_log` 授权修正为 `SELECT, INSERT`;conftest 四处改 `role="admin"`) | 无(可与 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-2 | `service/convert/` 纯函数:`types.py` + `calc.py` + `fee.py` + `nav.py` + `lot_bootstrap.py` + `errors.py`(D18) | 无 | ✅ **2026-09-10 完成**(7 文件 / 93 用例 / 全量 609 绿;T-2b 实算脚本 15/15 一致) |
| 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 |