From a6ac39edd6c3abf736d13f6b9b44f5f4be452ebd Mon Sep 17 00:00:00 2001 From: YUAN Date: Thu, 10 Sep 2026 18:40:27 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9F=BA=E9=87=91=E8=BD=AC=E6=8D=A2=20T-11?= =?UTF-8?q?=EF=BC=9A=E5=B7=A5=E5=85=B7=E4=B8=8E=20SQL=20=E6=B1=87=E6=80=BB?= =?UTF-8?q?=E5=8E=BB=E9=87=8D=EF=BC=88FR-C15=20/=20R-d=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/repository/core_ro.py | 23 ++ app/service/risk/rules.py | 25 ++- app/tool/core_tools.py | 20 +- docs/memory/2026-09-10.md | 47 ++++ docs/memory/MEMORY.md | 8 +- docs/memory/TODO.md | 9 +- docs/项目框架设计/开发计划-基金转换交易.md | 62 +++++- scripts/dev/verify_convert_engine.py | 4 +- scripts/dev/verify_convert_tools.py | 240 +++++++++++++++++++++ tests/test_convert_engine.py | 16 +- tests/test_core_ro_sum.py | 32 ++- tests/test_core_tools.py | 107 +++++++++ 12 files changed, 562 insertions(+), 31 deletions(-) create mode 100644 scripts/dev/verify_convert_tools.py create mode 100644 tests/test_core_tools.py diff --git a/app/repository/core_ro.py b/app/repository/core_ro.py index 1c2ba70..eb03a02 100644 --- a/app/repository/core_ro.py +++ b/app/repository/core_ro.py @@ -284,6 +284,13 @@ class CoreReadOnlyRepository: 注意:调用方若要精确合计,须自行判断 len(rows) 是否触及 limit (core_tools.query_holdings 以 truncated 字段对外暴露)。 main 扩列:min_subscribe_amount / term_days(C-11 起购与期限口径)。 + + ⚠️ **`qty > 0` 过滤(T-11)**:convert 转出全部份额时批次归零、 + `core_holding.qty` 记 0 但**行保留**(份额台账留痕,D 决策不物理删除)。 + 持仓语义下归零行不是持仓,必须过滤;过滤放 SQL 而非调用方, + 是为让 `total_count` / `sum_market_value` / `truncated` 三者口径一致。 + 唯一生产调用方为 `core_tools.query_holdings`(`concentration_profile` + 用独立 SQL,见下方「未顺手改」说明)。 """ sql = text( """ @@ -292,6 +299,7 @@ class CoreReadOnlyRepository: FROM core_holding h JOIN core_product p ON p.product_id = h.product_id WHERE h.customer_id = :cid + AND h.qty > 0 ORDER BY h.market_value DESC LIMIT :lim """ @@ -426,6 +434,20 @@ class CoreReadOnlyRepository: """当日申赎合计金额(RISK-002 累计口径:仅 confirmed 的 subscribe/redeem)。 当日 = 服务器本地时区自然日,以 traded_at 落在 [day 00:00, day+1 00:00) 为准。 + + ⚠️ **convert 去重(T-11 · R-d,架构 §6 未覆盖的读取方)**: + 一次基金转换在 `core_trade` 落**两条**流水(转出 `redeem` + 转入 `subscribe`, + 共享 `convert_group_id`),逐条求和会**翻倍**。故组内**只计转出端**: + `(convert_group_id IS NULL OR convert_group_id = '' OR trade_type = 'redeem')`。 + + 与 `rules.amount_view` 的 `if not gid` **严格对齐**(NULL 与空串均视为「无组」)。 + 唯一**不可达**的边界差异:组内无 `redeem` 时本 SQL 丢整组、`amount_view` 保留首条, + 由 R-b(convert 必写转出端)保证组内恒有 redeem,故实践中等价。 + 该等价性由 `tests/test_core_ro_sum.py` 的 convert 用例与 `tests/test_core_tools.py` + 的**跨口径一致性断言**共同守护(自检第 13 问:同口径不得有两份实现漂移)。 + + 注:本方法**生产代码暂无调用方**(F-10 / `grep` 实测),仅测试在用; + 仍顺带修正口径,避免它是公开仓储方法却埋着双计的坑。 """ day_start = datetime.combine(day, time.min) day_end = day_start + timedelta(days=1) @@ -438,6 +460,7 @@ class CoreReadOnlyRepository: AND trade_status = 'confirmed' AND traded_at >= :day_start AND traded_at < :day_end + AND (convert_group_id IS NULL OR convert_group_id = '' OR trade_type = 'redeem') """ ) with self._engine.connect() as conn: diff --git a/app/service/risk/rules.py b/app/service/risk/rules.py index ba1dfb6..2b30827 100644 --- a/app/service/risk/rules.py +++ b/app/service/risk/rules.py @@ -5,7 +5,7 @@ amount 为 Decimal,traded_at 为 datetime;输出命中规则列表(RuleHit 阈值由 RiskThresholds 打包(默认来自 settings,可 .env 覆盖)。 T-8 起:一次基金转换落**两条**同 `convert_group_id` 的流水,金额聚合类规则 -(RISK-002/005)走 `_amount_view` 只计一次,逐笔类规则(RISK-001/003/004)仍看全量。 +(RISK-002/005)走 `amount_view` 只计一次,逐笔类规则(RISK-001/003/004)仍看全量。 """ from __future__ import annotations @@ -91,9 +91,17 @@ def _amount(trade: dict[str, Any]) -> Decimal: return v if isinstance(v, Decimal) else Decimal(str(v)) -def _amount_view(trades: list[dict[str, Any]]) -> list[dict[str, Any]]: +def amount_view(trades: list[dict[str, Any]]) -> list[dict[str, Any]]: """金额口径视图(架构 §6.1 / PRD §6.3 · T-8):一次转换只计**一次**金额。 + ⚠️ **本函数是全仓唯一的「金额聚合去重」口径**(T-11 起由 `_amount_view` 提升为公开, + 原因是它要被 `app/tool/core_tools.py` 跨层复用 —— 复制第二份必然漂移 / 自检第 13 问)。 + 三处消费方共用同一语义,**改口径必须三处同步**: + ① `run_rules` 的 RISK-002 / RISK-005(**同调本函数**); + ② `core_tools.query_recent_trades` 的 `sum_amount`(**同调本函数**); + ③ `core_ro.sum_trades_on_date`(SQL 侧落地同一条件,见下方「SQL 等价条件」; + 由 `tests/test_core_ro_sum.py` / `tests/test_core_tools.py` 的跨口径一致性断言守护)。 + 一次基金转换在 `core_trade` 落**两条**流水(转出 `redeem` + 转入 `subscribe`, 同 `convert_group_id`),存储层如实记两份金额是对的,但**金额聚合类规则必须只算一次** —— 否则 RISK-002 当日累计翻倍、RISK-005 把转入端重复当成「铺垫」。 @@ -104,6 +112,13 @@ def _amount_view(trades: list[dict[str, Any]]) -> list[dict[str, Any]]: - **绝不删行**(只在金额视图内替换,`run_rules` 的其余规则仍看全量)。 返回新列表,顺序与入参一致(命中替换不改变位置),故对无 gid 输入满足恒等。 + + **SQL 等价条件**(`core_ro.sum_trades_on_date`,T-11 · R-d): + `(convert_group_id IS NULL OR convert_group_id = '' OR trade_type = 'redeem')`。 + 与上方 `if not gid` **严格对齐**——NULL 与空串都视为「无组」(真库 DDL 注释约定 + 非 convert 场景为 NULL,`=''` 为防御分支)。唯一**不可达**的边界差异是 + 「组内无 redeem 时本函数保留首条、SQL 会丢整组」,由 R-b(convert 必写转出端) + 保证组内恒有 redeem,故实践中等价;该等价性由跨口径一致性断言守护。 """ view: list[dict[str, Any]] = [] index_of_group: dict[str, int] = {} @@ -255,12 +270,12 @@ def run_rules( eligible = [t for t in trades if _eligible(t)] if not eligible: return [] - amount_view = _amount_view(eligible) + amt_view = amount_view(eligible) hits: list[RuleHit | None] = [ rule_large_amount(eligible, th), - rule_daily_total(amount_view, th), + rule_daily_total(amt_view, th), rule_freq_trade(eligible, th), rule_probe_pattern(eligible, th, now), - rule_small_then_large(amount_view, th), + rule_small_then_large(amt_view, th), ] return [h for h in hits if h is not None] diff --git a/app/tool/core_tools.py b/app/tool/core_tools.py index 0e2bff2..3721d0a 100644 --- a/app/tool/core_tools.py +++ b/app/tool/core_tools.py @@ -19,6 +19,7 @@ from decimal import Decimal from typing import Any, Callable from app.repository.core_ro import CoreReadOnlyRepository +from app.service.risk.rules import amount_view DETAIL_LIMIT = 20 # 明细条数上限(上下文与落库同限,防超长) DEFAULT_TRADE_DAYS = 30 @@ -76,6 +77,12 @@ def query_holdings( SQL 层 LIMIT HOLDING_FETCH_LIMIT(T-04 评审 P2):命中上限时 truncated= True——此时 total_count/sum_market_value 为"已拉取部分"的统计,摘要会 显式提示截断,避免静默给出偏小口径。 + + ⚠️ **归零行已在 SQL 层过滤**(T-11 · `core_ro.list_holdings` 的 `h.qty > 0`): + convert 转出全部份额时 `core_share_lot` 批次归零、`core_holding.qty` 记 0, + 但**行仍保留**(份额台账留痕,D 决策:转出归零保留 `qty=0` 行,不物理删除); + 持仓查询须过滤,否则会把已清仓产品当持仓返回。过滤**下沉到 SQL** 而非本层, + 是为让 `total_count` / `sum_market_value` / `truncated` 三者口径一致。 """ repo = core_ro or CoreReadOnlyRepository() # 多取 1 条用于判定是否真被截断(恰好 500 笔不误报) @@ -98,12 +105,21 @@ def query_recent_trades( core_ro: CoreReadOnlyRepository | None = None, risk_repo=None, ) -> dict[str, Any]: - """近 N 天 confirmed 申赎流水(时间升序截断至 DETAIL_LIMIT)。""" + """近 N 天 confirmed 申赎流水(时间升序截断至 DETAIL_LIMIT)。 + + ⚠️ **汇总与明细口径刻意不同**(FR-C15 · PRD §6.4 · T-11): + `items` / `total_count` 保持**全量** —— 一次基金转换落两条流水是真实的两笔权益变动, + 读到两条是对的;但 `sum_amount` **必须走 `amount_view`**, + 否则同一笔转换会被计两次、金额翻倍。 + + `amount_view` 是全仓唯一的金额聚合口径(D7 / 自检第 13 问),**不得在本模块另写一份**; + 与 `core_ro.sum_trades_on_date` 的 SQL 条件同语义(由跨口径一致性断言守护)。 + """ repo = core_ro or CoreReadOnlyRepository() end = _now_naive() start = end - _dt.timedelta(days=days) rows = repo.list_trades_range(customer_id, start, end) - total = sum((r.get("amount") or 0) for r in rows) + total = sum((r.get("amount") or 0) for r in amount_view(rows)) return { "days": days, "total_count": len(rows), diff --git a/docs/memory/2026-09-10.md b/docs/memory/2026-09-10.md index 4f2e068..35073b2 100644 --- a/docs/memory/2026-09-10.md +++ b/docs/memory/2026-09-10.md @@ -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 数字回填)**。 diff --git a/docs/memory/MEMORY.md b/docs/memory/MEMORY.md index 5a333e4..59c80aa 100644 --- a/docs/memory/MEMORY.md +++ b/docs/memory/MEMORY.md @@ -11,7 +11,7 @@ **当前进度:** 需求与表设计已定 · **风控模块 B1~B9b 全部完成(M2 tag risk-m2),M4 复核已闭环(2026-09-07),风控阶段 B 正式完结** · **Wave 0 已完成(2026-09-07,经独立 AI 评审闭环)**:T-01 JWT 鉴权(auth_service Auth SDK + deps 工厂替换 + X-Agent-Type 准入矩阵)/ T-02 审计中间件(http_access + 独立 request_id + 4xx/500 统一错误体 + input_guard_log 双写)/ T-06 chat 最小闭环(POST /api/chat + 会话落库 + Redis 窗口)/ T-07 LangGraph StateGraph 骨架 + DeepSeek(无 key 降级)· **T-04 Core RO Tool 节点已完成(2026-09-07)**:app/tool/core_tools.py 三只读 Tool + tool_service(意图/归属校验/run_tool)+ 图 tool 节点 + agent_tool_call 落库 + `utils/authz.py` 公共鉴权留痕,**321 测试绿(首评+复审双闭环)**。风控阶段 C 已完成(2026-09-07,345 绿,tag `risk-m3`,A-6 对话线验收通过,独立 AI 评审 PASS P0=0)。T-03 输入防护已完成(2026-09-07,378 绿,独立 AI 评审 PASS with findings P0=0):app/service/input_guard.py 注入词表 45 条纯函数检测 + oversize 4000 + actor 级 Redis 固定窗口限流 30 次/分(fail-open);chat 链路顺序 = 鉴权→准入→空白→限流 429→注入/超长 400→归属→会话,被拒 fail-fast 不建会话,blocked 落 input_guard_log(ENUM 四值已用满)。**阶段一「对齐 main 基准」AL-01~AL-08 已完成(2026-09-07,逐项独立 commit bb244f4~b5fd52e):适当性判定换核为 main 的 core_ro.check_suitability(C×R 矩阵表数据驱动,match_result 五值/JR-AST-012/FM-01/FM-03/JR-AST-PRO 契约,SUIT-001~008 退役);risk_suitability_log 重建 21 列;Core 表加 is_hnw/风评七新列(expires_at);种子 33 客户/14 产品;全量 406 passed 0 failed 0 skipped + uvicorn 冒烟三端点通过;risk-m1 已补打(指向 3c07de6)**。**下一步:阶段一验收门(**合并 main 前的最终交付检查点,非分支开发阻塞**;用户浏览器目视确认 UI)→ AL-09 合并 main → AL-10 PRD v1.2 → AL-11 docx 登记 → 阶段二 C4~C6(**已完成并打 `risk-m4` tag**)/ 前端 React 多 Agent 入口(HashRouter `web/` init)。****开发在分支 `risk-control-agent`(与 origin/main 已分叉:领先 84 提交 / 落后 0,origin/main 为分支祖先;**分支已推送远程,origin/risk-control-agent 同步于 `1d00e53`,本地跟踪已建立**;**架构改进与稳定性加固 T-101~T-202 已于 2026-09-09 完成并推送 origin/risk-control-agent(2d0e2fa..f733fc2 快进,pytest 510 绿,较 503 基线 +7 例:T-201.3 双层锁 6 例 + T-202 trace 顺序守卫 1 例),含 Redis 双层分布式锁 T-201 与审计中间件 trace 顺序守卫 T-202,均为文档/告警/锁原语修正、接口契约与表结构零变更;远程已提示走 PR 合并 main;**合并(risk-control-agent → main)由合并执行人负责,不归用户管**——AI 只负责把分支推到远程 + 更新文档,不得主动发起 PR / 执行合并,移交执行人按《合并注意事项-风控模块并入main.md》操作。** 旧文档中的 `feature/risk` 为过时口径)。** -**⚡ 并行新线 · 基金转换(convert)交易(2026-09-10 设计+开发计划闭环,**第 5 步进行中:T-0 / T-0b / T-1 已完成**):** `PRD-风控监测Agent` FR-1 一期显式拒收 convert,本线将其放开。**AIcoding 第 1~4 步已完成**:PRD **v0.9.2 定稿**(33 条外审闭环 + 2 处架构回填 + **费率分类修正** + **展示位数补全**)→ 架构 **v1.0.1 定稿**(独立评审通过,13 条建议 **0 悬空**,接受 10 / 修正性接受 3 / 驳回 0)→ 门控 **M-7 已满足** → **开发计划 v1.0 已产出(`docs/项目框架设计/开发计划-基金转换交易.md`,**1,047 行**)并经**四轮**独立子代理审核收敛(**12 条意见 → 接受 11 / 驳回 1(附实测证据)/ 0 悬空**)。**第 5 步进行中:T-0 + T-0b + T-1 + T-2 + T-2b + T-3 + T-4 + T-5 + T-6 + T-7 + T-8 + T-9 已于 2026-09-10 完成(697 passed / 3 skipped;T-1 断言 8/8 PASS;T-2 纯函数包 7 文件 / 93 用例;T-2b 实算脚本 15/15 一致;T-6 真库 24/24;T-7 真库 35/35;T-8 真库 31/31;T-9 真 MySQL 集成 8 条 + 11 条错误码映射 + 4 组突变验证 + **展示位数口径修复**)**,**下一步 = T-10(普通申赎批次维护 · 回归风险最大)/ T-11(汇总去重 · 依赖 T-8 已解锁)**。**开工前置两个阻断项(✅ 2026-09-10 均已完成)**:**T-0**(sqlite/MySQL 结构对齐:`core_holding` 列名+PK **+ 补 `core_product_nav`**,建库自校验,**同步改 2 处测试 INSERT 并补 3 个 NOT NULL 列**)与 **T-0b**(**DB 账号分离 D20**:`xh_core_ro` SELECT 全库 / `xh_core_rw` **4 表写**无 DELETE·DDL / `xh_agent_rw` **`audit_log` 只授 SELECT+INSERT**(不可改删);**`conftest.py` 四处 engine 已显式 `role="admin"`**)。**遗留环境操作**:`scripts/core/00-grant.sql` 需管理员执行一次 + 写 `.env`,未执行时 3 条权限断言自动 skip,不阻塞 T-1。设计资产五件套:`docs/PRD/PRD-基金转换交易.md` · `docs/项目框架设计/架构设计-基金转换交易.md` · **`docs/项目框架设计/开发计划-基金转换交易.md`(新)** · `评审待办-风控主架构与基金转换.md` · `基金转换-审查意见处置表.md`。**开工前必读开发计划 §1.4(15 条代码事实)/ §1.5(7 条实现级裁定 R-a~R-g)/ §12(16 条回归面)** —— 尤其是 **R-a(弃用方言 UPSERT)· R-b(流水写 redeem/subscribe 不写 convert)· R-e(conftest 用 admin)** 三条,不读必踩。**交接入口:项目根 `交接文档.md` §B(三线合并版唯一入口)**。合规基准 = 证监会公告〔2025〕22 号。 +**⚡ 并行新线 · 基金转换(convert)交易(2026-09-10 设计+开发计划闭环,**第 5 步进行中:T-0 / T-0b / T-1 已完成**):** `PRD-风控监测Agent` FR-1 一期显式拒收 convert,本线将其放开。**AIcoding 第 1~4 步已完成**:PRD **v0.9.2 定稿**(33 条外审闭环 + 2 处架构回填 + **费率分类修正** + **展示位数补全**)→ 架构 **v1.0.1 定稿**(独立评审通过,13 条建议 **0 悬空**,接受 10 / 修正性接受 3 / 驳回 0)→ 门控 **M-7 已满足** → **开发计划 v1.0 已产出(`docs/项目框架设计/开发计划-基金转换交易.md`,**1,047 行**)并经**四轮**独立子代理审核收敛(**12 条意见 → 接受 11 / 驳回 1(附实测证据)/ 0 悬空**)。**第 5 步进行中:T-0 + T-0b + T-1 + T-2 + T-2b + T-3 + T-4 + T-5 + T-6 + T-7 + T-8 + T-9 已于 2026-09-10 完成(697 passed / 3 skipped;T-1 断言 8/8 PASS;T-2 纯函数包 7 文件 / 93 用例;T-2b 实算脚本 15/15 一致;T-6 真库 24/24;T-7 真库 35/35;T-8 真库 31/31;T-9 真 MySQL 集成 8 条 + 11 条错误码映射 + 4 组突变验证 + **展示位数口径修复**)**,**T-10 ✅(普通申赎批次维护 · 回归风险最大 · +17 用例 / 真库 20/20)与 T-11 ✅(工具与 SQL 汇总去重 · +4 用例 / 真库 14/14)均已完成**,**下一步 = T-12(补偿脚本)**。**开工前置两个阻断项(✅ 2026-09-10 均已完成)**:**T-0**(sqlite/MySQL 结构对齐:`core_holding` 列名+PK **+ 补 `core_product_nav`**,建库自校验,**同步改 2 处测试 INSERT 并补 3 个 NOT NULL 列**)与 **T-0b**(**DB 账号分离 D20**:`xh_core_ro` SELECT 全库 / `xh_core_rw` **4 表写**无 DELETE·DDL / `xh_agent_rw` **`audit_log` 只授 SELECT+INSERT**(不可改删);**`conftest.py` 四处 engine 已显式 `role="admin"`**)。**遗留环境操作**:`scripts/core/00-grant.sql` 需管理员执行一次 + 写 `.env`,未执行时 3 条权限断言自动 skip,不阻塞 T-1。设计资产五件套:`docs/PRD/PRD-基金转换交易.md` · `docs/项目框架设计/架构设计-基金转换交易.md` · **`docs/项目框架设计/开发计划-基金转换交易.md`(新)** · `评审待办-风控主架构与基金转换.md` · `基金转换-审查意见处置表.md`。**开工前必读开发计划 §1.4(15 条代码事实)/ §1.5(7 条实现级裁定 R-a~R-g)/ §12(16 条回归面)** —— 尤其是 **R-a(弃用方言 UPSERT)· R-b(流水写 redeem/subscribe 不写 convert)· R-e(conftest 用 admin)** 三条,不读必踩。**交接入口:项目根 `交接文档.md` §B(三线合并版唯一入口)**。合规基准 = 证监会公告〔2025〕22 号。 **仓库地图:** @@ -70,7 +70,7 @@ **下一步开发(见 TODO):** **模块侧交付完毕(2026-09-07:全量 pytest 482 绿 + 接口实调验收通过——suitability/check 阻断+放行、simulate/trade 阻断、三条鉴权边界 401/403/403 契约零偏差;`risk-m1~m4` tag 齐)。合并 main 已移交合并执行人,操作手册《docs/项目框架设计/合并注意事项-风控模块并入main.md》(含基底锁定/20 冲突裁决/14 静默文件/三硬伤/合并后必测,实测数据编制)。模块侧开放项:chat 链路 risk_suitability_log.actor_id 落 SYSTEM 待评估 / 前端 React 多 Agent 入口(`web/` 未 init,归属待拍板)。**演示走查按 `docs/项目框架设计/演示SOP-风控模块.md`(debug 头通道仍有效;JWT 通道签发用 `scripts/dev/issue_dev_token.py`;演示库已按 AL-08 expires_at 新口径重灌)。知识库入库:`python scripts/kb/build_kb.py`(先启 Ollama;**Milvus 数据路径必须纯英文**——faiss 不支持中文路径,本机 .env 已配 C:/Users/YUAN/.jinrong/milvus/)。 -**另(2026-09-10 待办)**:① **基金转换线**第 5 步进行中(**T-0~T-9 已完成、697 绿,下一步 = T-10(批次维护 · 回归风险最大)/ T-11(汇总去重)**;设计 + 开发计划均已闭环,入口 项目根 `交接文档.md` §B);② ~~架构改进线收尾~~ —— **2026-09-10 已闭环结项**:§7.2 七项手工冒烟补跑 **7/7 PASS**、冒烟残留按 SOP §2 重灌双库清除、全量 **510 passed** 复绿;**「`037ce7e` 未 push」的旧表述已作废**(实测 `git ls-remote`:远程 `risk-control-agent` = `fffb78a` = 本地 HEAD,`037ce7e` 在其祖先链上,早已推送;本地 `git branch -vv` 显示 `origin/risk-control-agent: gone` 是本仓 `refs/remotes/**` 写入不落盘所致(见交接清单第 7 问,**不是**「`git fetch` 即恢复」的普通 stale ref),非远程分支被删)。入口 **项目根 `交接文档.md` §C**。 +**另(2026-09-10 待办)**:① **基金转换线**第 5 步进行中(**T-0~T-11 已完成、718 绿,下一步 = T-12(补偿脚本)**;设计 + 开发计划均已闭环,入口 项目根 `交接文档.md` §B);② ~~架构改进线收尾~~ —— **2026-09-10 已闭环结项**:§7.2 七项手工冒烟补跑 **7/7 PASS**、冒烟残留按 SOP §2 重灌双库清除、全量 **510 passed** 复绿;**「`037ce7e` 未 push」的旧表述已作废**(实测 `git ls-remote`:远程 `risk-control-agent` = `fffb78a` = 本地 HEAD,`037ce7e` 在其祖先链上,早已推送;本地 `git branch -vv` 显示 `origin/risk-control-agent: gone` 是本仓 `refs/remotes/**` 写入不落盘所致(见交接清单第 7 问,**不是**「`git fetch` 即恢复」的普通 stale ref),非远程分支被删)。入口 **项目根 `交接文档.md` §C**。 **禁止(改代码前必记):** Core 正式 C1~C5 不可被画像覆盖 · 审计表只 INSERT · 代理人草稿不外发 · 仅 R-02 可阻断交易 · 四 Agent 不互调 LLM。 @@ -199,8 +199,8 @@ RBAC 联调账号:scripts/dev/rbac-seed-reference.md 2. 改动属于 api / service / tool / repository 哪一层? 3. 是否需 customer_id 归属与 JWT RBAC? 4. Core 是模拟库只读还是 agent 库读写? -5. 如何验证?(`python -m pytest` 全量(当前 **714 passed / 3 skipped**,基线 510)· uvicorn 启动 + /health · SQL / sync 脚本 · 对照 REQUIREMENTS 验收列) -6. **当前有哪两条并行线?**(① 风控/架构改进线:**已结项**(510 基线绿、§7.2 七项冒烟 7/7 PASS、`037ce7e` 已核实早已推送);② **基金转换线**:设计闭环,**第 5 步进行中 —— T-0~T-10 已完成(714 passed),下一步 = T-11**)——动代码前先确认自己属于哪条线,别混淆前置条件。 +5. 如何验证?(`python -m pytest` 全量(当前 **718 passed / 3 skipped**,基线 510)· uvicorn 启动 + /health · SQL / sync 脚本 · 对照 REQUIREMENTS 验收列) +6. **当前有哪两条并行线?**(① 风控/架构改进线:**已结项**(510 基线绿、§7.2 七项冒烟 7/7 PASS、`037ce7e` 已核实早已推送);② **基金转换线**:设计闭环,**第 5 步进行中 —— T-0~T-11 已完成(718 passed),下一步 = T-12**)——动代码前先确认自己属于哪条线,别混淆前置条件。 7. **远程分支到底还在不在?**(**在**。`git ls-remote --heads origin` 实测 `refs/heads/risk-control-agent` = `fffb78a`。⚠️ **判断远程存亡只能用 `git ls-remote`** —— 2026-09-10 **同日误报两次**,勿再踩。) **本仓特有异常(2026-09-10 深挖确认,别误判为 stale ref)**:`.git/refs/remotes/**` **写入不落盘** —— `git update-ref refs/remotes/origin/X ` 返回 0,但松散引用消失,**且整个 `refs/remotes/origin/` 目录被删** diff --git a/docs/memory/TODO.md b/docs/memory/TODO.md index f9d9a2e..6e2dda9 100644 --- a/docs/memory/TODO.md +++ b/docs/memory/TODO.md @@ -7,7 +7,7 @@ **阶段一 AL-01~AL-08 与阶段二 C4~C6 均已完成(2026-09-07)**:全量 pytest **482 passed 0 failed 0 skipped**(真库集成)✓ · uvicorn 冒烟三端点 ✓ · 接口实调验收 ✓(2026-09-07:suitability/check 阻断+放行、simulate/trade 阻断、三条鉴权边界 401/403/403,契约零偏差)· risk-m1~m4 tag 齐。**合并 main 已移交合并执行人**(操作手册:《docs/项目框架设计/合并注意事项-风控模块并入main.md》,随分支上传),后续模块侧待办见下方。 -**⚡ 并行新线 · 基金转换(convert)**(2026-09-10):**设计 + 开发计划均已闭环** —— PRD **v0.9.2** + 架构 **v1.0.1** + 独立评审 13 条 **0 悬空**(接受 10 / 修正性接受 3 / 驳回 0),门控 **M-7 已满足**;**第 4 步开发计划 v1.0 已产出并经独立审核**(4 条意见全接受、**驳回 0**,含新增 2 条回归面 R15/R16 + R-c 双条修订);**第 5 步进行中**:**T-0 ~ T-10 均已于 2026-09-10 完成**(**714 passed / 3 skipped**;T-1 断言 **8/8 PASS**、T-2 纯函数 **93 用例**、T-2b 实算 **15/15 一致**、T-6 真库 **24/24**、T-7 真库 **35/35**、**T-8 真库 31/31**、**T-9 真 MySQL 集成 8 条 + 4 组突变验证 + 展示位数修复**、**T-10 新增 17 用例 + 真库 20/20 + 2 组突变验证**),**下一步 = T-11(`core_tools` 与 `sum_trades_on_date` 汇总去重 · 依赖 T-8 已解锁)**。两个阻断前置(**T-0** sqlite/MySQL 列名统一 + 建库自校验 · **T-0b** DB 账号分离 D20:`xh_core_ro`/`xh_core_rw`/`xh_agent_rw`)**均已落地**。**入口:项目根 `交接文档.md` §B(三线合并版唯一入口,读这一节即可开工)**;**开工前必读开发计划 §1.4(15 条代码事实)+ §1.5(8 条实现级裁定 R-a~R-h)+ §12(16 条回归面)**。 +**⚡ 并行新线 · 基金转换(convert)**(2026-09-10):**设计 + 开发计划均已闭环** —— PRD **v0.9.2** + 架构 **v1.0.1** + 独立评审 13 条 **0 悬空**(接受 10 / 修正性接受 3 / 驳回 0),门控 **M-7 已满足**;**第 4 步开发计划 v1.0 已产出并经独立审核**(4 条意见全接受、**驳回 0**,含新增 2 条回归面 R15/R16 + R-c 双条修订);**第 5 步进行中**:**T-0 ~ T-11 均已于 2026-09-10 完成**(**718 passed / 3 skipped**;T-1 断言 **8/8 PASS**、T-2 纯函数 **93 用例**、T-2b 实算 **15/15 一致**、T-6 真库 **24/24**、T-7 真库 **35/35**、**T-8 真库 31/31**、**T-9 真 MySQL 集成 8 条 + 4 组突变验证 + 展示位数修复**、**T-10 新增 17 用例 + 真库 20/20 + 2 组突变验证**、**T-11 新增 4 用例 + 真库 14/14 + 3 组突变验证 · `amount_view` 提升为公开**),**下一步 = T-12(补偿脚本)**。两个阻断前置(**T-0** sqlite/MySQL 列名统一 + 建库自校验 · **T-0b** DB 账号分离 D20:`xh_core_ro`/`xh_core_rw`/`xh_agent_rw`)**均已落地**。**入口:项目根 `交接文档.md` §B(三线合并版唯一入口,读这一节即可开工)**;**开工前必读开发计划 §1.4(15 条代码事实)+ §1.5(8 条实现级裁定 R-a~R-h)+ §12(16 条回归面)**。 ### 基金转换线待办(推荐顺序) @@ -17,11 +17,14 @@ - [x] **【T-1 · 第 1 批】DDL + 种子 + sqlite 同步** —— **完成(2026-09-10)**:`scripts/core/01-ddl.sql` 新建 `core_fee_rule`/`core_share_lot`/`core_convert_lot_detail` + `core_trade` 加 `convert_group_id`+索引 + `core_product` 加 8 列(`subscribe_fee_rate` 等)+ `fee_rate` 补 COMMENT;**新增 `07-seed-fee-rule.sql`**(14 产品 × 5 档,按 22 号文 §10)/ **`08-seed-share-lot.sql`**(58 行持仓 → 61 行批次,Σ remain_qty 恒等于 qty,CUST-9527 跨批次)/ **`09-seed-org.sql`**(管理人 + TA + 申购费率 + 最低持有余额);`reset.ps1` 追加 07/08/09;`02-mysql-agent专用.sql` 追加 `risk_convert_detail`(status ENUM 建表即 5 值);`tests/_ddl.py` 同步 4 表 + `REQUIRED_CONVERT_TABLES` 门禁。**验证**:新增 `scripts/dev/verify_convert_seed.py`(pymysql 等价 reset 流程 + 8 条断言)→ **8/8 PASS**;`pytest -q` → **516 passed / 3 skipped(零回归)**。**3 点需注意**:① mysql 不在 PATH → 用该脚本替代 reset.ps1;② `core_fee_rule` 读取走只读账号(T-6 遵守);③ ~~`PROD-005827` 费率分类口径差异(`mixed` vs 主动偏股)待裁定~~ → **已裁定并修正(PRD v0.9.1)**:`mixed` 归位 `0.0050`(其他混合型),主示例转入方改真主动偏股 `PROD-003095`,`09-seed-org.sql` 升 **v1.1** 按「管理人全产品线」重排,并新增断言 ⑧ 机器化卡口 - [x] **【T-2 · 第 2 批】`service/convert/` 纯函数包** —— **完成(2026-09-10)**:新建 `app/service/convert/` **7 文件**(`__init__` / `types`(`Lot`/`FeeRule`/`LotAllocation`/`PlanResult` frozen dataclass + 3 个归一工具)/ `calc`(`plan_lots`/`lot_amount`/`lot_fee`/`convert_amount`/`in_qty`/`rounding_diff`/`diff_fee`/`hold_days`/`ensure_batch_limit`)/ `fee`(`pick_fee_rate` 左闭右开)/ `nav`(`ensure_nav_ready`→503 / `is_stale`)/ `lot_bootstrap`(D18 单点,`crc32` 确定性偏移)/ `errors`(`ConvertError` + 11 子类))。**新增 `tests/test_convert_calc.py` 93 用例**(12 类:精度 HALF_UP 反向自证 / 分档边界 6-7-29-30-179-180-364-365 / FIFO 含同 `confirmed_at` tiebreak / 跨批计费 / 双口径 252.40 vs 253.91 / 强制全转与强制赎回 / 恰好等于阈值不触发 / **零剩余不触发(新裁定 R-h)** / PRD §5.3 全链自证 / T+1 起算 / 净值 503 与 stale 分家 / D18 确定性 / §8.3 错误码 / **纯函数零 IO 依赖断言**)。**验证**:`pytest -q` → **609 passed / 3 skipped(+93,零回归)**;`calc_convert_demo.py` → **15/15 与 PRD §5.3 一致**(退出码 0) - [x] **【T-3 ~ T-9 已完成】** 仓储与锁(并行组 A:T-3 / T-4 / T-5 ✅)→ T-6 ✅(阶段一事务 · 真库 24/24)→ T-7 ✅(八步编排 · 真库 35/35)→ **T-8 ✅(规则引擎改造 · 真库 31/31;⭐ 阶段 1.5 从「跳过」变「真跑」)** → **T-9 ✅(API 模型 + 网关分派 · **HTTP 层 convert 已走通**;新增真 MySQL 集成 7 条 + 11 条错误码映射 + 3 组突变验证)** -- [ ] **【T-10 起】剩余任务**:**T-10 高风险单列**(普通申赎批次维护 · 改 `trade_gateway` 主流程 · 先跑基线再动)→ T-11(`core_tools` / `sum_trades_on_date` 汇总去重)→ T-12(补偿脚本)→ **T-13「50 并发压测 + 性能补录」**(最后跑,产出 PRD §9 第 18 条实测值)。**T-13 内部顺序**:先 50 并发压测 → 再性能实测补录 → 最后 PRD §5.3 数字回填(详见架构 §15 + 开发计划 §2~§10) +- [x] **【T-10 ✅ · T-11 ✅】均已结项(2026-09-10)**:**T-10** 普通申赎批次维护(改 `trade_gateway` 主流程 + D8 兜底补建 + `rebuild_lots.py`;**+17 用例** · 真库 20/20 · 突变 2 组 · R16 零改动通过)→ **T-11** 工具与 SQL 汇总去重(`_amount_view` **提升为公开 `amount_view`** + `query_recent_trades` 汇总去重 FR-C15 + `list_holdings` 过滤 `qty > 0` + `sum_trades_on_date` 加 convert 去重 **R-d**;**+4 用例** · 真库 14/14 · 突变 3 组) +- [ ] **【T-12 起】剩余任务**:**T-12(补偿脚本)→ T-13「50 并发压测 + 性能补录」**(最后跑,产出 PRD §9 第 18 条实测值)。**T-13 内部顺序**:先 50 并发压测 → 再性能实测补录 → 最后 PRD §5.3 数字回填(详见架构 §15 + 开发计划 §2~§10) - [ ] **【待用户裁定】幂等重放响应的数值位数偏差**(T-9 执行期发现,**未顺手改**):首次响应 2 位(`calc` 量化)vs 重放响应 4 位(`core_trade` `DECIMAL(18,4)` 直读)→ `"53456.95"` vs `"53456.9500"`,**数值相等**,违反 PRD「对外一律 2 位」展示契约,属 T-7 `_rebuild_quote` 范畴。集成测试已「比数值不比字符串」并钉住偏差 > **第 0~2 批结果(2026-09-10)**:基线 **510 passed** → 批 0 后 **516 passed / 3 skipped**(+3 T-0 用例 +3 T-0b 引擎用例)→ 批 1(T-1)后**仍 516 passed / 3 skipped**(只加表与种子,未加用例 → **零回归**);**T-1 数据层断言 8/8 PASS**(含新增断言 ⑧:费率档 ↔ `product_type` 匹配,越档即 FAIL)→ 批 2(T-2 + T-2b)后 **609 passed / 3 skipped**(**+93 纯函数用例**,零回归);T-2b 实算脚本 15/15 与 PRD §5.3 一致(退出码 0)。 -> **下一步 = T-11**(`core_tools.query_recent_trades` 汇总走 `_amount_view` 去重 FR-C15 + 持仓查询过滤 `qty <= 0` + `core_ro.sum_trades_on_date` 加 convert 去重条件 **R-d**;DoD 见开发计划 §7.3)。基线 **714 passed / 3 skipped**。 +> **下一步 = T-12**(补偿脚本,开发计划 §9)。基线 **718 passed / 3 skipped**(T-10 后 714 → **T-11 后 718**,+4)。 +> **T-11 执行期两条裁定**:① `_amount_view` **提升为公开 `amount_view`**(跨层复用,全仓唯一金额聚合口径); +> ② SQL 去重条件从计划的 `IS NULL` 扩为 **`IS NULL OR convert_group_id = ''`** —— 真库实证「只写 `IS NULL` 会漏掉空串那笔」(400000 vs 450000,差 50000)。 > ⚠️ **基金转换的 T-0b 与下方「架构改进第 3/4 批」的 `core_ro` 只读账号是同一件事** —— 已由本线定案为 D20,**不再挂在架构改进线**(该线原「不要做」清单已更新)。 diff --git a/docs/项目框架设计/开发计划-基金转换交易.md b/docs/项目框架设计/开发计划-基金转换交易.md index 7885854..c5e2558 100644 --- a/docs/项目框架设计/开发计划-基金转换交易.md +++ b/docs/项目框架设计/开发计划-基金转换交易.md @@ -1180,13 +1180,67 @@ sqlite 无 gap lock,故该分支由 `tests/test_convert_core.py` 用注入点 | `tests/test_core_ro_sum.py` | 【改】追加 1 条 convert 去重用例 | **DoD** -- [ ] `core_tools` 汇总不翻倍(验收 11) -- [ ] 持仓查询不返回 `qty = 0` 的行 -- [ ] `sum_trades_on_date` 既有 5 条断言零改动 + 新 1 条去重用例绿 -- [ ] `pytest -q` 全绿 +- [x] `core_tools` 汇总不翻倍(验收 11) +- [x] 持仓查询不返回 `qty = 0` 的行 +- [x] `sum_trades_on_date` 既有 5 条断言零改动 + 新 1 条去重用例绿 +- [x] `pytest -q` 全绿(**718 passed / 3 skipped** = 714 + 4) **依赖**:T-8 +**执行记录(2026-09-10 · 已完成)** + +*改码 3 处 + 新增 1 脚本 + 测试 3 文件* + +| 文件 | 动作 | +| --- | --- | +| `app/service/risk/rules.py` | 【改】`_amount_view` → **公开 `amount_view`**(T-8 的私有函数提升为公开,供 `core_tools` 跨层复用)。docstring 补「**全仓唯一金额聚合口径**」+ SQL 等价条件说明;`run_rules` 内局部变量改名 `amt_view` 避免同名遮蔽 | +| `app/tool/core_tools.py` | 【改】`query_recent_trades` 的 `sum_amount` 改走 `amount_view`(FR-C15;**明细与 `total_count` 保持全量** —— 一次转换两条流水是真实的两笔权益变动);`query_holdings` docstring 说明归零行已在 SQL 层过滤 | +| `app/repository/core_ro.py` | 【改】`sum_trades_on_date` 加 convert 去重条件(**R-d**);`list_holdings` 加 `AND h.qty > 0`(归零行过滤**下沉到 SQL**,使 `total_count` / `sum_market_value` / `truncated` 三者口径一致) | +| `scripts/dev/verify_convert_tools.py` | 【新增】真 MySQL 验证 **14/14**:真库值域确认(NULL / 空串 / 组 ID)+ 去重 + **对照组**(只写 `IS NULL` 会漏空串那笔)+ 跨口径一致 + 归零行过滤 + 底表复核。隔离前缀 `CUST-TOOLT` / `PROD-TOOLT*`,跑完**零残留** | +| `tests/test_core_ro_sum.py` | 【改】fixture 的 `insert` 增可选 `gid` 参数(默认 `None`,**既有 4 条用例零改动**)+ **追加 1 条 convert 去重用例** | +| `tests/test_core_tools.py` | 【新增】**3 条**:汇总不翻倍 / **跨口径一致性** / 归零行过滤 | +| `tests/test_convert_engine.py` | 【改】随 `amount_view` 改名的 import 与 5 处调用(**断言零改动**) | + +*执行期裁定 2 条(计划未点明,执行期定并留痕)* + +1. **`_amount_view` 提升为公开 `amount_view`,而非在 `core_tools` 复制一份**。 + 计划只说「汇总走 `_amount_view`」,但它是 `rules.py` 的**私有函数**,跨模块调私有名不当。 + 按自检第 13 问(同一规则只留一个副本),**提升为公开**并保留**单一实现**; + 代价是 `test_convert_engine.py` 的 import 与调用同步改名(**断言未动**)。 + 备选的「公开别名 `amount_view = _amount_view`」被否 —— **一个函数两个名字本身就是新的混淆源**。 + (依赖方向已核实:`app/tool/kb_tools.py:22` 早有 tool→service 先例,无循环导入。) + +2. **SQL 去重条件从计划的 `IS NULL` 扩为 `IS NULL OR = ''`**(计划 §1.5 R-d 只写了 `IS NULL`)。 + 依据:Python 侧 `amount_view` 判的是 `if not gid` —— **NULL 与空串都算「无组」**; + 而 SQL 三值逻辑下 `'' IS NULL` 恒为假,**只写 `IS NULL` 会把空串那笔漏掉**。 + **真库实证**(`verify_convert_tools.py` [C] 对照组):只写 `IS NULL` → `400000`, + 正确口径 → `450000`,**差额恰为空串那笔 50000**;且真库确认 `VARCHAR` 存 `''` 而非 NULL。 + → 若不加该分支,RISK-002 会**漏算**,且只在「普通交易被写成空串」时才暴露。 + (唯一**不可达**的边界差异:组内无 `redeem` 时 SQL 丢整组、`amount_view` 保留首条 —— + 由 R-b「convert 必写转出端」保证,已在两处 docstring 注明。) + +*测试与验证* + +- `pytest -q` → **718 passed / 3 skipped**(基线 714 **+4**:`test_core_ro_sum` +1 / `test_core_tools` +3,**零回归**) +- `sum_trades_on_date` 既有 4 条用例(5 条断言)**零改动通过**(既有数据无 gid → 新条件恒等); + fixture 的 `insert` 仅**增可选参数**,默认 `None` 行为不变 +- `tests/test_convert_engine.py` 15 条**断言零改动通过**(仅 import 与调用名同步) +- **突变验证 3 组(防假绿)**: + ① 去掉 `sum_trades_on_date` 的 gid 条件 → **精准 2 条**红(convert 去重用例 + 跨口径断言); + ② 去掉 `list_holdings` 的 `qty > 0` → **精准 1 条**红(归零行过滤用例); + ③ 让 `query_recent_trades` 不走 `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` 未过滤 `qty = 0`**:它走**独立 SQL**(非 `list_holdings`),当前不过滤归零行。 + 归零行 `market_value = 0`,对 R4+R5 占比**无实际影响**,但 `core_ro.py` 中 + 「与 list_holdings 同源同口径」的注释与实际存在**措辞落差**。 + 是否一并收口属新范围,留待用户决定。 + --- ## 8. 第 5 批 · 高风险专项 diff --git a/scripts/dev/verify_convert_engine.py b/scripts/dev/verify_convert_engine.py index 2fbb27f..d94f101 100644 --- a/scripts/dev/verify_convert_engine.py +++ b/scripts/dev/verify_convert_engine.py @@ -6,7 +6,7 @@ `convert_service._run_engine` 走 `ImportError` 分支**静默跳过**;T-8 落地后同一笔转换会 **真实出单 + 写 L3 + 落审计**。真库跑一遍才能确认没有连带破坏(审计条数、L3 写入、单落库)。 2. **`convert_group_id` 的 NULL 值域**:MySQL 里普通交易该列为 NULL、convert 两条为同一串值; - `_amount_view` 的 `if not gid` 必须在真库值域下成立(NULL / 空串都不得误聚合)。 + `amount_view` 的 `if not gid` 必须在真库值域下成立(NULL / 空串都不得误聚合)。 3. **DECIMAL 精度**:`core_trade.amount` 在 MySQL 是 DECIMAL,进 Python 后与生产纯函数 逐项比对(sqlite 用 REAL 无此保证,需 `float()` 绑定)。 4. **JSON 列反解**:`risk_alert.payload` 真库落库后反解出的 `events` 长度必须为 2。 @@ -249,7 +249,7 @@ def main() -> int: core_engine = get_engine("jinrong_core", role="admin") agent_engine = get_engine("jinrong_agent", role="admin") print("=" * 60) - print("T-8 真库验证:规则引擎改造(_amount_view 去重 + process_convert_event 出单)") + print("T-8 真库验证:规则引擎改造(amount_view 去重 + process_convert_event 出单)") print("=" * 60) cleanup(core_engine, agent_engine) diff --git a/scripts/dev/verify_convert_tools.py b/scripts/dev/verify_convert_tools.py new file mode 100644 index 0000000..0f7061e --- /dev/null +++ b/scripts/dev/verify_convert_tools.py @@ -0,0 +1,240 @@ +"""T-11 真 MySQL 验证脚本:金额汇总去重 + 归零行过滤(DoD 逐条断言)。 + +**为什么 sqlite 单测全绿还不够** + +T-11 的核心是把「一次转换只计一次金额」这个口径,从 Python(`rules.amount_view`) +复制到 SQL(`core_ro.sum_trades_on_date`)。跨语言的两份实现最容易漂移, +**在 NULL 值域上尤其危险**: + +- `amount_view` 判的是 Python 的 `if not gid` —— **NULL 与空串都算「无组」**; +- 若 SQL 只写 `convert_group_id IS NULL`,**空串会被漏掉**(三值逻辑里 `'' IS NULL` 恒为假), + 该笔普通交易从当日累计里凭空消失 → RISK-002 少算、且**测试很难发现** + (sqlite 单测若只造 NULL 数据,这条分支永远不被执行)。 + +于是本脚本专门造一笔 `convert_group_id = ''` 的真库数据,并**同时跑两条 SQL 做对照** +(只 `IS NULL` vs `IS NULL OR = ''`),把「差异是真实存在的」变成可打印的证据。 + +同时验证 `list_holdings` 的 `qty > 0` 在 MySQL `DECIMAL(18,4)` 下的比较语义 +(convert 转出全部份额后 `qty = 0.0000` 的台账留痕行必须被排除)。 + +用法: + python scripts/dev/verify_convert_tools.py # 建隔离数据 → 跑断言 → 清理 + +注意: +- 全部数据用 **TOOLT 前缀**(客户 `CUST-TOOLT` / 产品 `PROD-TOOLT*`),跑完清理干净,不碰既有种子; +- 建/清数据走 `role="admin"`(需 DELETE,R-e);被测路径走生产同款仓储 + (`CoreReadOnlyRepository` ro 读 + `core_tools` 同款函数); +- 退出码 1 = 有断言不一致(供 CI / 人工判定)。 +""" + +from __future__ import annotations + +import sys +from datetime import datetime, time, timedelta +from decimal import Decimal +from pathlib import Path + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from app.config.settings import settings # noqa: E402 +from app.repository.core_ro import CoreReadOnlyRepository # noqa: E402 +from app.tool.core_tools import query_holdings, query_recent_trades # noqa: E402 +from app.utils.db import dispose_engines, get_engine # noqa: E402 + +CUSTOMER = "CUST-TOOLT" +PROD_A = "PROD-TOOLTA" # 正常持仓(qty > 0) +PROD_B = "PROD-TOOLTB" # 归零行(qty = 0,convert 转出全部后的台账留痕) +GROUP = "G-TOOLT-1" + +# 金额设计(每一项都能区分「对 / 未去重 / 只判 IS NULL」三种实现): +AMT_OUT = "300000" # convert 转出端 → 应计入 +AMT_IN = "300000" # convert 转入端 → 同组,不应计入 +AMT_PLAIN = "100000" # 普通赎回(gid = NULL)→ 应计入 +AMT_EMPTY = "50000" # gid = 空串 → 视为无组,**应计入**(区分 ='' 分支的关键) +EXPECTED_SUM = Decimal("450000") # 300000 + 100000 + 50000 +SUM_IF_NO_DEDUP = Decimal("750000") # 未去重:再 + 300000 +SUM_IF_NULL_ONLY = Decimal("400000") # 只写 IS NULL:丢掉空串那笔 50000 + +_passed = 0 +_failed = 0 + + +def check(name: str, actual, expected) -> None: + """逐条断言并打印(与既有 verify_convert_*.py 同款输出)。""" + global _passed, _failed + ok = actual == expected + if ok: + _passed += 1 + else: + _failed += 1 + flag = "✅" if ok else "❌" + print(f" {flag} {name}: 实际 {actual!r}" + ("" if ok else f" / 期望 {expected!r}")) + + +def dec(value, places: str = "0.01") -> Decimal: + """真库读回是 `Decimal`(MySQL DECIMAL)→ 统一量化后比较。""" + return Decimal(str(value)).quantize(Decimal(places)) + + +def q1(engine, sql: str, **params): + with engine.connect() as conn: + return conn.execute(text(sql), params).scalar_one_or_none() + + +# ── 数据准备 / 清理 ───────────────────────────────────────────────── +def cleanup(engine) -> None: + """按 FK 依赖倒序删:trade → holding → product → customer(全部按前缀)。""" + with engine.begin() as conn: + conn.execute( + text("DELETE FROM core_trade WHERE customer_id LIKE :p"), {"p": f"{CUSTOMER}%"} + ) + conn.execute( + text("DELETE FROM core_holding WHERE customer_id LIKE :p"), {"p": f"{CUSTOMER}%"} + ) + conn.execute( + text("DELETE FROM core_product WHERE product_id LIKE :p"), {"p": "PROD-TOOLT%"} + ) + conn.execute( + text("DELETE FROM core_customer WHERE customer_id LIKE :p"), {"p": f"{CUSTOMER}%"} + ) + + +def seed(engine, now: datetime) -> None: + today = now.date() + with engine.begin() as conn: + conn.execute( + text( + "INSERT INTO core_customer (customer_id, display_name, open_date) " + "VALUES (:c, 'T11真库验证', :d)" + ), + {"c": CUSTOMER, "d": today}, + ) + for pid, name in [(PROD_A, "T11正常持仓基金"), (PROD_B, "T11归零基金")]: + conn.execute( + text( + # product_type 是真库 ENUM('money','bond','mixed',...) 的中文值非法 + "INSERT INTO core_product (product_id, product_name, min_risk_code, " + "product_type, can_subscribe, can_redeem) " + "VALUES (:p, :n, 'R3', 'mixed', 1, 1)" + ), + {"p": pid, "n": name}, + ) + # 持仓:一条正常 + 一条归零(qty = 0,行必须保留 —— 台账留痕,不物理删除) + conn.execute( + text( + "INSERT INTO core_holding (customer_id, product_id, qty, cost_amount, " + "market_value, pnl_pct, as_of) VALUES " + "(:c, :pa, 1000.0000, 1000.00, 1200.00, 0.2000, :d), " + "(:c, :pb, 0.0000, 0.00, 0.00, 0.0000, :d)" + ), + {"c": CUSTOMER, "pa": PROD_A, "pb": PROD_B, "d": today}, + ) + # 流水:一组 convert(两条共享 gid)+ 普通赎回(NULL)+ 空串 gid 申购 + rows = [ + ("TX-TOOLT-OUT", PROD_A, "redeem", AMT_OUT, GROUP), + ("TX-TOOLT-IN", PROD_B, "subscribe", AMT_IN, GROUP), + ("TX-TOOLT-PLAIN", PROD_A, "redeem", AMT_PLAIN, None), + ("TX-TOOLT-EMPTY", PROD_A, "subscribe", AMT_EMPTY, ""), + ] + for tid, pid, ttype, amt, gid in rows: + conn.execute( + text( + "INSERT INTO core_trade (trade_id, customer_id, product_id, trade_type, " + "amount, qty, convert_group_id, trade_status, traded_at) " + "VALUES (:tid, :c, :p, :tt, :amt, 100.0000, :gid, 'confirmed', :t)" + ), + {"tid": tid, "c": CUSTOMER, "p": pid, "tt": ttype, "amt": amt, "gid": gid, "t": now}, + ) + + +# ── 被测路径断言 ──────────────────────────────────────────────────── +def run_checks(engine, now: datetime) -> None: + core_ro = CoreReadOnlyRepository(engine=get_engine(settings.mysql_core_database, "ro")) + today = now.date() + day_start = datetime.combine(today, time.min) + + print("\n[A] 真库值域确认(前提)") + gid_out = q1( + engine, "SELECT convert_group_id FROM core_trade WHERE trade_id = 'TX-TOOLT-OUT'" + ) + gid_plain = q1( + engine, "SELECT convert_group_id FROM core_trade WHERE trade_id = 'TX-TOOLT-PLAIN'" + ) + gid_empty = q1( + engine, "SELECT convert_group_id FROM core_trade WHERE trade_id = 'TX-TOOLT-EMPTY'" + ) + check("convert 转出端 gid 非空", gid_out, GROUP) + check("普通交易 gid 为 NULL", gid_plain, None) + check("空串 gid 真库存成空串(非 NULL)", gid_empty, "") + + print("\n[B] sum_trades_on_date 去重(R-d)") + actual_sum = core_ro.sum_trades_on_date(CUSTOMER, today) + check("去重后当日合计", dec(actual_sum), EXPECTED_SUM) + check("不等于未去重的值(去重真的生效)", dec(actual_sum) != SUM_IF_NO_DEDUP, True) + + print("\n[C] 对照组:只写 IS NULL 会漏掉空串那笔(证明 ='' 分支必要)") + null_only = q1( + engine, + "SELECT COALESCE(SUM(amount), 0) FROM core_trade " + "WHERE customer_id = :c AND trade_type IN ('subscribe','redeem') " + "AND trade_status = 'confirmed' " + "AND traded_at >= :s AND traded_at < :e " + "AND (convert_group_id IS NULL OR trade_type = 'redeem')", + c=CUSTOMER, + s=day_start, + e=day_start + timedelta(days=1), + ) + check("只判 IS NULL 的结果(少算空串那笔)", dec(null_only), SUM_IF_NULL_ONLY) + check("与正确口径差额 = 空串那笔金额", dec(actual_sum) - dec(null_only), Decimal(AMT_EMPTY)) + + print("\n[D] core_tools.query_recent_trades 汇总去重(FR-C15)") + res = query_recent_trades(CUSTOMER, days=1, core_ro=core_ro) + check("明细保持全量(4 条)", res["total_count"], 4) + check("sum_amount 不翻倍", dec(res["sum_amount"]), EXPECTED_SUM) + check("跨口径一致:Tool 汇总 == 仓储 SQL 汇总", dec(res["sum_amount"]), dec(actual_sum)) + + print("\n[E] core_tools.query_holdings 归零行过滤") + holdings = query_holdings(CUSTOMER, core_ro=core_ro) + check("持仓条数(归零行被排除)", holdings["total_count"], 1) + check("返回的是正常持仓产品", [r["product_id"] for r in holdings["items"]], [PROD_A]) + check("合计市值不含归零行", dec(holdings["sum_market_value"]), Decimal("1200")) + + print("\n[F] 底表复核:归零行确实还在库里(过滤发生在查询侧,不是删除)") + zero_rows = q1( + engine, + "SELECT COUNT(*) FROM core_holding WHERE customer_id = :c AND qty <= 0", + c=CUSTOMER, + ) + check("core_holding 归零行仍保留", zero_rows, 1) + + +def main() -> int: + now = datetime.now() + engine = get_engine(settings.mysql_core_database, "admin") + print("T-11 真库验证:金额汇总去重(FR-C15 / R-d)+ 归零行过滤") + print(f"客户={CUSTOMER} 产品={PROD_A}/{PROD_B} 交易日={now.date()}") + cleanup(engine) + try: + seed(engine, now) + run_checks(engine, now) + finally: + cleanup(engine) + left = q1( + engine, + "SELECT (SELECT COUNT(*) FROM core_trade WHERE customer_id LIKE 'CUST-TOOLT%') " + "+ (SELECT COUNT(*) FROM core_holding WHERE customer_id LIKE 'CUST-TOOLT%') " + "+ (SELECT COUNT(*) FROM core_product WHERE product_id LIKE 'PROD-TOOLT%') " + "+ (SELECT COUNT(*) FROM core_customer WHERE customer_id LIKE 'CUST-TOOLT%')", + ) + print(f"\n清理后残留行数:{left}") + dispose_engines() + + print(f"\n结果:{_passed} 通过 / {_failed} 失败") + return 1 if _failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_convert_engine.py b/tests/test_convert_engine.py index 5b28664..7f3524d 100644 --- a/tests/test_convert_engine.py +++ b/tests/test_convert_engine.py @@ -1,7 +1,7 @@ """T-8 规则引擎改造单测(开发计划 §7.1 DoD / 验收 5·6·7)。 覆盖三层: -1. `rules._amount_view` 纯函数 —— 同组只留转出端 / 无 gid 恒等 / 组内无 redeem 防御 / 顺序保持 +1. `rules.amount_view` 纯函数 —— 同组只留转出端 / 无 gid 恒等 / 组内无 redeem 防御 / 顺序保持 2. `run_rules` 视图分流 —— RISK-002 与 RISK-005 走金额视图(**不翻倍**), RISK-001 / RISK-003 仍看**全量**(证明去重未删行,验收 6) 3. `engine.process_convert_event` —— **一张单 + `payload.events` 两条**(验收 7)、 @@ -22,7 +22,7 @@ from sqlalchemy import text from app.repository.core_ro import CoreReadOnlyRepository from app.repository.risk_repository import RiskRepository from app.service.risk.engine import process_convert_event, process_trade_event -from app.service.risk.rules import RiskThresholds, _amount_view, run_rules +from app.service.risk.rules import RiskThresholds, amount_view, run_rules CUST = "CUST-T8" PROD_A = "PROD-T8A" # 转出方 @@ -84,27 +84,27 @@ def _ids(hits) -> set[str]: return {h.rule_id for h in hits} -# ── 1. `_amount_view` 纯函数 ───────────────────────────────────────── +# ── 1. `amount_view` 纯函数 ───────────────────────────────────────── def test_amount_view_identity_without_group(): """无 convert_group_id 的交易**原样通过**(非 convert 场景恒等 → 既有断言零影响)。""" raw = [ _trade("T-1", "redeem", "1000"), _trade("T-2", "subscribe", "900", NOW + timedelta(seconds=1), product=PROD_B), ] - view = _amount_view(raw) + view = amount_view(raw) assert view == raw, "无 gid 输入必须恒等(内容与顺序都不变)" assert view is not raw, "返回独立列表,不得共享可变状态" def test_amount_view_keeps_redeem_when_subscribe_comes_first(): out, inn = _convert_legs() - view = _amount_view([inn, out]) + view = amount_view([inn, out]) assert [t["trade_id"] for t in view] == ["TRD-T8-OUT"], "同组只留转出端,且位置不变" def test_amount_view_keeps_redeem_when_redeem_comes_first(): out, inn = _convert_legs() - view = _amount_view([out, inn]) + view = amount_view([out, inn]) assert [t["trade_id"] for t in view] == ["TRD-T8-OUT"], "转入端不得覆盖已占位的转出端" @@ -113,7 +113,7 @@ def test_amount_view_group_without_redeem_keeps_first_row(): a = _trade("T-IN-1", "subscribe", "100", gid="CNV-T8-X", product=PROD_B) b = _trade("T-IN-2", "subscribe", "200", NOW + timedelta(seconds=1), gid="CNV-T8-X", product=PROD_B) - assert _amount_view([a, b]) == [a] + assert amount_view([a, b]) == [a] def test_amount_view_mixes_groups_and_plain_trades_in_order(): @@ -124,7 +124,7 @@ def test_amount_view_mixes_groups_and_plain_trades_in_order(): out2 = _trade("TRD-T8-OUT2", "redeem", "500", NOW + timedelta(seconds=4), gid="CNV-T8-0002") in2 = _trade("TRD-T8-IN2", "subscribe", "480", NOW + timedelta(seconds=5), gid="CNV-T8-0002", product=PROD_B) - view = _amount_view([n1, in1, out1, n2, in2, out2]) + view = amount_view([n1, in1, out1, n2, in2, out2]) assert [t["trade_id"] for t in view] == ["T-N1", "TRD-T8-OUT", "T-N2", "TRD-T8-OUT2"] diff --git a/tests/test_core_ro_sum.py b/tests/test_core_ro_sum.py index 5d299b0..7b7a6de 100644 --- a/tests/test_core_ro_sum.py +++ b/tests/test_core_ro_sum.py @@ -15,13 +15,22 @@ from app.repository.core_ro import CoreReadOnlyRepository def repo(): engine = create_sqlite_engine() # DDL 单一事实源(B4 评审 P3-12) - def insert(trade_id, amount, traded_at, trade_type="subscribe", status="confirmed", cid="C1"): + def insert( + trade_id, + amount, + traded_at, + trade_type="subscribe", + status="confirmed", + cid="C1", + gid=None, + ): + """插一笔流水。`gid` = convert 组 ID:None → NULL、'' → 空串,二者均视为「非 convert」。""" with engine.begin() as conn: conn.execute( text( "INSERT INTO core_trade (trade_id, customer_id, product_id, trade_type," - " amount, trade_status, traded_at)" - " VALUES (:tid, :cid, 'P1', :tt, :amt, :st, :at)" + " amount, trade_status, traded_at, convert_group_id)" + " VALUES (:tid, :cid, 'P1', :tt, :amt, :st, :at, :gid)" ), { "tid": trade_id, @@ -30,6 +39,7 @@ def repo(): "amt": amount, "st": status, "at": traded_at, + "gid": gid, }, ) @@ -72,3 +82,19 @@ def test_sum_per_customer(repo): insert("T2", "500000", _at(9), cid="C2") assert r.sum_trades_on_date("C1", DAY) == Decimal("500000") assert r.sum_trades_on_date("C2", DAY) == Decimal("500000") + + +def test_sum_dedupes_convert_group(repo): + """T-11 · R-d:一次转换只计**转出端**;空串 gid 与 NULL 同视为「非 convert」。 + + 数据:G1 组两条(转出 300000 + 转入 300000,共享 gid)+ 普通赎回 100000 + + 空串 gid 的申购 50000。 + 期望:300000 + 100000 + 50000 = **450000**。 + **若未去重则为 750000(T1+T2 双计)** —— 断言用具体值,能区分对错。 + """ + r, insert = repo + insert("T1", "300000", _at(9), "redeem", gid="G1") # convert 转出端 → 计入 + insert("T2", "300000", _at(9), "subscribe", gid="G1") # convert 转入端 → 同组,不计 + insert("T3", "100000", _at(10), "redeem") # 普通赎回(gid=NULL)→ 计入 + insert("T4", "50000", _at(11), "subscribe", gid="") # 空串 gid → 视为无组 → 计入 + assert r.sum_trades_on_date("C1", DAY) == Decimal("450000") diff --git a/tests/test_core_tools.py b/tests/test_core_tools.py new file mode 100644 index 0000000..6115588 --- /dev/null +++ b/tests/test_core_tools.py @@ -0,0 +1,107 @@ +"""core_tools 查询口径单测(T-11 · FR-C15 + 归零行过滤)。 + +自建 sqlite 完整种子(`core_product` / `core_holding` / `core_trade`),直调 Tool 函数。 +覆盖 T-11 两条 DoD: + +1. `query_recent_trades` 的 `sum_amount` 不因 convert 两条流水而**翻倍** + (走 `amount_view`;明细与 `total_count` 仍为全量), + 并与 `core_ro.sum_trades_on_date` 做**跨口径一致性**断言(自检第 13 问: + 同一口径不得有两份实现漂移); +2. `query_holdings` 不返回 `qty = 0` 的**归零行**(convert 转出全部份额后的台账留痕行)。 + +种子时间刻意取**同一个 `now`**:既落在 `query_recent_trades` 的 [now−30d, now) 窗内, +又保证属于 `now.date()` 这一天,使跨口径断言不受运行时刻影响。 +""" + +from __future__ import annotations + +import datetime as _dt +from decimal import Decimal + +import pytest +from sqlalchemy import text + +from _ddl import create_sqlite_engine + +from app.repository.core_ro import CoreReadOnlyRepository +from app.tool.core_tools import query_holdings, query_recent_trades + +CUST = "CUST-T11" + + +@pytest.fixture() +def seed(): + engine = create_sqlite_engine() + now = _dt.datetime.now() + with engine.begin() as conn: + conn.execute( + text( + "INSERT INTO core_product (product_id, product_name, min_risk_code, product_type," + " min_subscribe_amount, term_days) VALUES" + " ('PA', '甲基金', 'R3', 'mixed', 100, 0)," + " ('PB', '乙基金', 'R3', 'mixed', 100, 0)" + ) + ) + # PA 正常持有;PB 为 convert 转出全部后的**归零行**(qty = 0,行保留) + conn.execute( + text( + "INSERT INTO core_holding (customer_id, product_id, qty, cost_amount," + " market_value, pnl_pct, as_of) VALUES" + " (:cid, 'PA', 1000.00, 1000.00, 1200.00, 0.2000, :d)," + " (:cid, 'PB', 0.00, 0.00, 0.00, 0.0000, :d)" + ), + {"cid": CUST, "d": now.date()}, + ) + # 一次 convert 落两条(转出 redeem 300000 + 转入 subscribe 300000,共享 G1) + # + 1 笔普通赎回 100000(convert_group_id = NULL) + conn.execute( + text( + "INSERT INTO core_trade (trade_id, customer_id, product_id, trade_type, amount," + " qty, convert_group_id, trade_status, traded_at) VALUES" + " ('TX-OUT', :cid, 'PA', 'redeem', 300000, 250.00, 'G1', 'confirmed', :t)," + " ('TX-IN', :cid, 'PB', 'subscribe', 300000, 240.00, 'G1', 'confirmed', :t)," + " ('TX-PLAIN', :cid, 'PA', 'redeem', 100000, 80.00, NULL, 'confirmed', :t)" + ), + {"cid": CUST, "t": now}, + ) + yield engine + engine.dispose() + + +def test_query_recent_trades_sum_not_doubled(seed): + """FR-C15:汇总只计转出端;**明细与条数保持全量**(一次转换两条是真实的)。""" + repo = CoreReadOnlyRepository(engine=seed) + res = query_recent_trades(CUST, days=30, core_ro=repo) + + # 明细全量:convert 两条 + 普通赎回 1 条 + assert res["total_count"] == 3 + assert sorted(r["trade_type"] for r in res["items"]) == ["redeem", "redeem", "subscribe"] + + # 汇总去重:300000(转出端)+ 100000(普通赎回)= 400000 + # 若未去重则为 700000(TX-OUT + TX-IN 双计)—— 用具体值才区分得开 + assert res["sum_amount"] == 400000.0 + + +def test_query_recent_trades_sum_matches_sum_trades_on_date(seed): + """跨口径一致性:Tool 汇总(Python 侧 amount_view)== 仓储 SQL 汇总(IS NULL OR ='')。 + + 两处是同一口径的两种落地(一个 Python 一个 SQL),此处用断言锁死等价性, + 任一侧口径漂移都会在此变红。 + """ + repo = CoreReadOnlyRepository(engine=seed) + tool_sum = query_recent_trades(CUST, days=30, core_ro=repo)["sum_amount"] + sql_sum = repo.sum_trades_on_date(CUST, _dt.date.today()) + assert Decimal(str(tool_sum)) == sql_sum + assert sql_sum == Decimal("400000") + + +def test_query_holdings_excludes_zero_qty(seed): + """归零行(convert 转出全部份额,`qty = 0` 台账留痕)不得作为持仓返回。""" + repo = CoreReadOnlyRepository(engine=seed) + res = query_holdings(CUST, core_ro=repo) + + assert res["total_count"] == 1 + assert [r["product_id"] for r in res["items"]] == ["PA"] + # 合计不含归零行(PB 的 market_value 为 0,即使计入也不变; + # 故断言**条数**才是真正能区分对错的判据) + assert res["sum_market_value"] == 1200.0