基金转换 T-11:工具与 SQL 汇总去重(FR-C15 / R-d)

convert 在 core_trade 落两条流水(转出 redeem + 转入 subscribe,共享 convert_group_id),
所有金额聚合读取方必须只计一次,否则同笔转换金额翻倍。

落地(改码 3 处 + 新增 1 脚本 + 测试 3 文件)
- rules.py:_amount_view → 公开 amount_view(提升而非复制别名,全仓唯一金额聚合口径);
  docstring 补「跨模块共用」说明
- core_tools.py:query_recent_trades 的 sum_amount 改走 amount_view(FR-C15);
  items / total_count 保持全量不变——两条流水是真实的两笔权益变动,读到两条是对的
- core_ro.py:list_holdings 加 h.qty > 0(convert 转出全部份额留下的 qty=0 归零行不是持仓);
  sum_trades_on_date 加 convert 去重条件(R-d),组内只计转出端
- 新增 tests/test_core_tools.py(3 条:汇总不翻倍 / 跨口径一致性 / 持仓不返回 qty=0 行)
- tests/test_core_ro_sum.py 追加 1 条 convert 去重用例(既有断言零改动)
- 新增 scripts/dev/verify_convert_tools.py:真库验证脚本(MySQL 8.0.46)

执行期裁定 2 条(已留痕)
- _amount_view 提升为公开而非复制:一个函数两个名字会漂移(自检第 13 问)
- SQL 条件从 IS NULL 扩为 IS NULL OR = '':Python 的 if not gid 把空串当无组,
  而 SQL 里 '' IS NULL 恒假。真库实证:只写 IS NULL → 合计数 400000(正确 450000,漏算 50000)。
  不加则 RISK-002 漏算,且 sqlite 单测若只造 NULL 数据永远发现不了
- 等价性边界:组内无 redeem 时 SQL 丢整组、amount_view 保留首条;由 R-b 保证不可达

验证
- pytest -q → 718 passed / 3 skipped(基线 714 加 4,零回归)
- 突变验证 3 组均精准命中:去掉去重条件(2 红)/ 去掉 qty>0(1 红)/ 汇总不走 amount_view(2 红)
- 真库 verify_convert_tools.py 14/14,隔离数据零残留
- 复跑受影响真库脚本零回归:T-8 31/31、T-7 35/35、T-10 20/20
This commit is contained in:
2026-09-10 18:40:27 +08:00
parent 5c2dc94b40
commit a6ac39edd6
12 changed files with 562 additions and 31 deletions
+47
View File
@@ -499,3 +499,50 @@ B.1 状态 / B.6 拓扑与基线 / **新增 B.6.3**)· `docs/memory/{TODO,MEMO
已改为 T-10 ✅ / 下一步 T-11。
**下一步 = T-11**(`core_tools` 汇总去重 + 持仓过滤 `qty <= 0` + `sum_trades_on_date` 加 convert 去重 R-d)。
---
## ✅ T-11 已完成(2026-09-10 · 尚未提交)
**任务**:工具与 SQL 汇总去重(FR-C15 / R-d)—— 让 convert 落两条流水后,**所有金额聚合读取方都只计一次**。
**改码 3 处 + 新增 1 脚本 + 测试 3 文件**
1. `app/service/risk/rules.py` —— `_amount_view` **提升为公开 `amount_view`**(跨层复用;全仓唯一金额聚合口径);
`run_rules` 内局部变量改名 `amt_view`;docstring 补 SQL 等价条件。
2. `app/tool/core_tools.py` —— `query_recent_trades` 的 `sum_amount` 走 `amount_view`
(**明细与 `total_count` 保持全量**:一次转换两条流水是真实的**两笔**权益变动);
`query_holdings` docstring 说明归零行已在 SQL 层过滤。
3. `app/repository/core_ro.py` —— `list_holdings` 加 `AND h.qty > 0`;
`sum_trades_on_date` 加 convert 去重条件(**R-d**)。
4. `scripts/dev/verify_convert_tools.py`【新增】—— 真库 **14/14**。
5. `tests/test_core_tools.py`【新增】3 条;`tests/test_core_ro_sum.py` fixture 增 `gid` + 追加 1 条;
`tests/test_convert_engine.py` 随改名同步(**断言零改动**)。
**执行期裁定 2 条(计划未点明,已留痕)**
1. **`_amount_view` 提升为公开,而非在 `core_tools` 复制一份** —— 计划只说「汇总走 `_amount_view`」,
但它是私有名。按自检第 13 问**提升为公开保单一实现**;否决「公开别名」方案(一函数两名 = 新混淆源)。
依赖方向已核实:`app/tool/kb_tools.py:22` 早有 tool→service 先例,无循环导入。
2. ⭐ **SQL 去重条件从计划的 `IS NULL` 扩为 `IS NULL OR convert_group_id = ''`**。
依据:Python 侧 `if not gid` **把空串也当无组**,而 SQL 三值逻辑下 `'' IS NULL` **恒为假**。
**真库实证**(脚本 [C] 对照组):只写 `IS NULL` → `400000`,正确口径 → `450000`,
**差额恰为空串那笔 50000**。不加则该笔从当日累计里消失 → **RISK-002 漏算**,
且只在「普通交易被写成空串」时才暴露(sqlite 单测若只造 NULL 数据**永远发现不了**)。
**验证**
- `pytest -q` → **718 passed / 3 skipped**(基线 714 **+4**,零回归)
- `sum_trades_on_date` 既有 4 条用例(5 条断言)**零改动通过**;`test_convert_engine.py` 15 条断言零改动
- **突变验证 3 组**:① 去掉 gid 条件 → **精准 2 红**;② 去掉 `qty > 0` → **精准 1 红**;
③ 不走 `amount_view` → **精准 2 红**。均已恢复,`grep "1 = 1\|MUTATION"` **无残留**
- **真库**:`verify_convert_tools.py` **14/14**、跑完**零残留**
- **回归复跑**(铁律 2:接线变化必跑):T-8 `31/31`(`amount_view` 改名影响面)· T-7 `35/35` · T-10 `20/20` 均零回归
**未顺手改(已上报)**:`concentration_profile` 走**独立 SQL**、未过滤 `qty = 0`
(归零行市值为 0,对 R4+R5 占比**无实际影响**,但与「与 `list_holdings` 同源同口径」的注释有**措辞落差**)。
**文档回写**:开发计划 §7.3(DoD 全勾 + 执行记录 + 2 条裁定 + 未顺手改)· `交接文档.md` **v2.2**
(§0 导航 / §B 状态 / B.1 / B.6 拓扑与基线 / **新增 B.6.4**)· `docs/memory/{TODO,MEMORY}` · 本条。
**下一步 = T-12(补偿脚本)→ T-13(全量回归 + 50 并发压测 + PRD §5.3 数字回填)**。