diff --git a/AGENTS.md b/AGENTS.md index e77eaec..9feb930 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ - 技术选型 / JWT:`docs/项目框架设计/技术选型和版本/` - Core 模拟底座:`docs/项目框架设计/Core模拟底座/` - 业务记忆分层:`docs/业务记忆管理/业务记忆管理手册.md` -- **基金转换线(第 5 步 todo 开发进行中 · T+1 受理/确认分离模型)**:**T-1 ~ T-13 ✅ / T-14 ~ T-17 ⬜**(当前基线 **830 passed / 7 skipped** 实测;真库 verify 套件 **9/9 退出码 0**:accept 37 / confirm 83 / engine 39 / api 89 / compensate 56 / apply 24 / lots 20 / seed 全 PASS / tools 14;T-13 = 全量门禁 + **convert_fund 退役**(v1.0 入口已删,`verify_convert_service.py` 已删);性能实测端到端 P95 **110.3ms**(<2s 硬门禁);**紧池 100%**(v1.0 同场景 80%);下一步 **T-14** = D27 产品舍入真正接入);开工入口 **`交接文档.md` §B**(§B.11 下一条立项指引 = 最新状态)· `docs/PRD/PRD-基金转换交易.md`(**v1.1**)· `docs/项目框架设计/架构设计-基金转换交易.md`(**v2.1**)· `docs/项目框架设计/开发计划-基金转换交易.md`(**v2.0** · T-1~T-17 逐任务改法 + DoD) +- **基金转换线(第 5 步 todo 开发进行中 · T+1 受理/确认分离模型)**:**T-1 ~ T-14 ✅ / T-15 ~ T-17 ⬜**(当前基线 **837 passed / 7 skipped** 实测;真库 verify 套件 **9/9 退出码 0**:accept 37 / confirm 83 / engine 39 / api 89 / compensate 56 / apply 24 / lots 20 / seed 全 PASS / tools 14;T-14 = D27 产品舍入真正接入——`core_ro.get_share_digits` 回退链单点 + 确认段/申购折算接入,**联网核实纠偏:种子「指数基金 4 位」证伪 → 11-seed v1.1 全 2 位**;开口 2 项待用户拍板 = PRD §2.5.3 易方达句修订 + rounding_mode=truncate 接入;下一步 **T-15** = D28 部分成交专项 + 边界补强);开工入口 **`交接文档.md` §B**(§B.11 下一条立项指引 = 最新状态)· `docs/PRD/PRD-基金转换交易.md`(**v1.1**)· `docs/项目框架设计/架构设计-基金转换交易.md`(**v2.1**)· `docs/项目框架设计/开发计划-基金转换交易.md`(**v2.0** · T-1~T-17 逐任务改法 + DoD) - **架构改进线(✅ 2026-09-10 已结项,无待办)**:开工入口 **`交接文档.md` §C** - **风控模块主线**:**`交接文档.md` §A** diff --git a/app/gateway/trade_gateway.py b/app/gateway/trade_gateway.py index b4a7503..17edb77 100644 --- a/app/gateway/trade_gateway.py +++ b/app/gateway/trade_gateway.py @@ -45,6 +45,7 @@ from app.service.convert.calc import ( lot_amount, lot_fee, plan_lots, + product_round, round2, ) from app.service.convert.convert_service import accept_convert @@ -360,7 +361,10 @@ def _maintain_lots( product_id, ) return - sub_qty = round2(amount / nav) + # ── D27 产品级舍入(T-14):申购折算份额位数按产品规则取 + # (回退链单点 `get_share_digits`:规则行 → core_product.share_digits → 默认 2)。 + digits = core.get_share_digits(product_id, SUBSCRIBE) + sub_qty = product_round(amount / nav, digits) if sub_qty <= 0: return writer.insert_share_lots( diff --git a/app/repository/core_ro.py b/app/repository/core_ro.py index 96345d3..ddf945b 100644 --- a/app/repository/core_ro.py +++ b/app/repository/core_ro.py @@ -22,6 +22,7 @@ from sqlalchemy import text from sqlalchemy.engine import Engine from app.config.settings import settings +from app.service.convert.calc import PLACES from app.utils.db import get_engine MatchResult = Literal[ @@ -725,6 +726,25 @@ class CoreReadOnlyRepository: row = conn.execute(sql, {"pid": product_id, "bt": business_type}).mappings().first() return dict(row) if row else None + def get_share_digits(self, product_id: str, business_type: str) -> int: + """产品份额位数(D27 / R-13 · **全仓唯一回退链单点**,自检第 13 问)。 + + 回退链(11-seed-share-rule.sql 头注同口径):`core_share_rule` 规则行 + **优先** → 缺行回退 `core_product.share_digits`(T-1 补列,DEFAULT 2)→ + 再缺省回退 `PLACES`(PRD §2.5 全局 2 位)。 + + 消费方两处(T-14 接入):确认段转入份额(business_type='convert')、 + 网关申购折算份额(business_type='subscribe');redeem 为份额申报 + (D26),份额来自客户不折算,无消费方。 + """ + rule = self.get_share_rule(product_id, business_type) + if rule and rule.get("share_digits") is not None: + return int(rule["share_digits"]) + product = self.get_product(product_id) + if product and product.get("share_digits") is not None: + return int(product["share_digits"]) + return PLACES + def list_active_customers(self) -> list[dict[str, Any]]: """全量在册客户(id + display_name;AML scan_all 全量扫描用,仅 SELECT)。""" sql = text( diff --git a/app/service/convert/calc.py b/app/service/convert/calc.py index 270cd3b..765ae14 100644 --- a/app/service/convert/calc.py +++ b/app/service/convert/calc.py @@ -51,10 +51,14 @@ def round2(value: Decimal) -> Decimal: def product_round(value: Decimal, digits: int) -> Decimal: """产品级份额舍入(D27 / R-13):量化到 `digits` 位,显式 HALF_UP。 - T-14 之前仅作为**独立入口**提供(不接主链路),供 `calc_convert_demo.py` - 等外部演示与实际接入前的测试使用;行为与既有 `round2` 完全一致(位数=2)。 - 默认 `digits=2`,与 `PLACES` 一致;`digits=0` 取整(个别整份产品)、 - `digits=4` 供指数/货基(11-seed-share-rule.sql 注释依据)。 + **T-14 已接入主链路**(确认段转入份额 / 网关申购折算份额)——位数由 + 调用方经 `core_ro.get_share_digits` 查回退链取得(规则行 → + `core_product.share_digits` → 默认 2),本函数只做纯量化、不查库。 + 默认 `digits=2` 与 `PLACES` 一致,此时与 `round2` 逐字节一致。 + ⚠️ 联网核实(2026-09-12):公募场外份额位数主流 2 位、按各基金合同配置 + (南方 2012 转换调整公告为行业依据);`digits=0/4` 等**非 2 位值是本函数 + 的机制能力**,供测试自建种子与未来真实差异产品接入,真库种子(11-seed) + 全部 2 位——v1.0 种子「指数基金 4 位」的注释依据已证伪并纠偏。 """ if not isinstance(digits, int) or digits < 0: raise ValueError(f"share 位数必须为非负整数,实际 {digits!r}") @@ -107,16 +111,18 @@ def convert_amount(out_amount: Decimal, redeem_fee: Decimal) -> Decimal: return round2(to_decimal(out_amount) - to_decimal(redeem_fee)) -def in_qty(in_amount: Decimal, in_nav: Decimal) -> Decimal: - """转入份额 = 净转入金额 ÷ 转入基金净值(2 位 HALF_UP · v1.0 勘误)。 +def in_qty(in_amount: Decimal, in_nav: Decimal, digits: int = PLACES) -> Decimal: + """转入份额 = 净转入金额 ÷ 转入基金净值(HALF_UP · v1.0 勘误)。 ⚠️ 份额**不是** 4 位向下取整——那是 v0.2 的残留、与 §1 原则 10 直接矛盾, - PRD v0.8 / 架构 v1.0 已统一为 **2 位四舍五入**,尾差"在基金资产列支"。 + PRD v0.8 / 架构 v1.0 已统一为四舍五入,尾差"在基金资产列支"。 + 全局默认 2 位(`PLACES`);**T-14(D27)起确认段按产品传 `digits`** + (来源 `core_ro.get_share_digits`,缺省 2 = 原行为,零回归)。 """ nav = to_decimal(in_nav) if nav <= 0: raise ValueError(f"转入净值必须大于 0,实际 {nav}") - return round2(to_decimal(in_amount) / nav) + return product_round(to_decimal(in_amount) / nav, digits) def rounding_diff( diff --git a/app/service/convert/confirm_service.py b/app/service/convert/confirm_service.py index dba6007..e249555 100644 --- a/app/service/convert/confirm_service.py +++ b/app/service/convert/confirm_service.py @@ -95,6 +95,10 @@ MIRROR_FAILED = "failed" _REMARK_FULL_TRANSFER = REMARK_FULL_TRANSFER _REMARK_PARTIAL = REMARK_PARTIAL +#: `core_share_rule.business_type` 的转换确认取值(D27;11-seed-share-rule.sql 种子三型之一)。 +#: 转入份额属「转换」业务,不取 `subscribe` —— 两型规则行可各配位数。 +_RULE_BT_CONVERT = "convert" + def _split_remark(raw: Any) -> list[str]: """把受理单 `remark` 拆成标记列表(`;` 分隔,容忍空值/空白)。""" @@ -330,7 +334,11 @@ def confirm_one( in_rate = to_decimal(in_product.get("subscribe_fee_rate") or 0) gap = diff_fee(conv_amount, out_rate, in_rate, settings.convert_diff_fee_mode) in_amount = conv_amount - gap - in_shares = in_qty(in_amount, in_nav) + # ── D27 产品级舍入(T-14):转入份额位数按**转入产品**的转换规则取 + # (回退链单点 `get_share_digits`:规则行 → core_product.share_digits → 默认 2)。 + # 2 位产品与原 round2 行为逐字节一致(DoD 3),4 位产品(指数/货基)在此分化。 + digits = core.get_share_digits(to_pid, _RULE_BT_CONVERT) + in_shares = in_qty(in_amount, in_nav, digits) tail = rounding_diff(in_amount, in_nav, in_shares) # 受理-确认两段的决策汇总:`forced_full_transfer` **继承受理段**(确认段不 diff --git a/docs/memory/2026-09-12.md b/docs/memory/2026-09-12.md index 6edbddf..d2abd20 100644 --- a/docs/memory/2026-09-12.md +++ b/docs/memory/2026-09-12.md @@ -248,6 +248,30 @@ n=59(n=60 剔除首个连接池冷启动样本)· 本机 MySQL 8.0.46 / 模 确认事务(`apply_convert`)P50 10.0 / P95 25.3 / max 25.5; 确认全程 P50 41.4 / P95 61.3 / max 74.4;端到端 = 受理 + 确认两段之和。 +## 五b、用户指出硬编码问题 → 全仓排查 + 补修(当日傍晚) + +**用户批评**:「硬编码是个很低级的错误了,怎么你还会犯呢?再查一下还有没有。」—— +指 `verify_convert_lots.py` 的字面造数日期(`NAV_DATE=date(2026,9,4)` 等)。T-12 已立 +「verify 造数的受理日/SLA 边界全部改 `previous_biz_day` 链式走真库交易日历、不猜自然日」 +的模式,本轮修 lots 脚本时只修了 `amount=`→`qty=`,**没有把同类脚本的日期硬编码一并清掉** —— +同一类错误在脚本层复发,教训:**修 A 类问题时必须同 grep 一遍全仓的 A 类模式**。 + +**补修**:`verify_convert_lots.py` + `verify_convert_apply.py` 共 9 处字面日期全部改 +`previous_biz_day` 链式回推动态锚定(`_init_dates(admin)` 在 main 开头按真库日历算: +TRADE_DATE=回推 3 个交易日、C1/C2=再回推 3/2 交易日保 T+2 可扣、A1/A2/持仓 as_of 按 +自然日相对量保持 hold_days 档位断言不变);复跑 **20/20 + 24/24 退出码 0**, +动态生效自证 = confirmed_at 随回推日漂移(09-04 → 09-09)而断言同源跟随。 + +**全仓硬编码排查结论(分类,非一刀切)**: +| 类别 | 结论 | +|---|---| +| 生产代码 `app/` | **零字面日期**;cutoff/SLA/死锁重试/批上限全走 settings(B1/R-14 整改完整) | +| T-6~T-12 五个真库脚本(accept/confirm/engine/api/compensate) | 零字面日期(各自任务期已按 T-12 模式合规) | +| `verify_convert_lots.py` / `verify_convert_apply.py` | **违例 → 本轮已修**(9 处) | +| `verify_convert_seed.py` 的 `AS_OF` | **有意固定**:与 08-seed-share-lot.sql 种子里的日期强绑定(验证对象就是那批固定数据),改动态反而错 | +| `calc_convert_demo.py` 的 `TRADE_DATE=2026-09-09` | **有意固定**:PRD 验收 20「示例与种子强绑定」(demo/集成测试/种子三方逐字节一致),改动态违反验收 | +| sqlite 单测固定日期(test_core_tools / test_trade_gateway 等) | **确定性测试惯例**:自插日历/净值配套数据、不连真库、自洽无漂移,保留 | + ## 六、文档回写 开发计划 v2.0(文件头基线 830/7 + T-13 节 DoD 6/6 全勾 + 执行记录 ①~⑧)· @@ -260,3 +284,84 @@ T-14~T-17 待办拆出)· `docs/memory/MEMORY.md`(3 处进度)· `AGENTS.m **T-14**(D27 产品舍入真正接入):`calc.py` `product_round` 接入 + `core_share_rule` 种子 + 4 位产品用例(覆盖率不得让降级路径当覆盖)。 其后 T-15~T-17。提交:代码 `3bc84a0` + 文档(本次),均**不 push**(由用户定)。 + +--- + +# T-14:D27 产品舍入真正接入 + 联网核实纠偏(2026-09-12) + +## 一、接入面(生产代码 4 处,全部单点收敛) + +| 文件 | 改动 | +|---|---| +| `app/repository/core_ro.py` | 新增 **`get_share_digits(product_id, business_type)`** —— 全仓唯一回退链单点(自检第 13 问):`core_share_rule` 规则行优先 → `core_product.share_digits`(T-1 列,DEFAULT 2)→ `calc.PLACES`(2)。import `PLACES` 有 `convert_core_repository` 先例(repository → convert.calc),无环 | +| `app/service/convert/calc.py` | `in_qty` 加可选参数 `digits: int = PLACES`(默认行为逐字节不变),内部改走 `product_round`(nav<=0 校验保留单点);`product_round` docstring 更新为「已接入主链路」 | +| `app/service/convert/confirm_service.py` | 确认段第⑤步:`digits = core.get_share_digits(to_pid, _RULE_BT_CONVERT)` → `in_qty(in_amount, in_nav, digits)`;新增常量 `_RULE_BT_CONVERT = "convert"`(转入份额属「转换」业务,不取 `subscribe` —— 两型规则行可各配位数) | +| `app/gateway/trade_gateway.py` | 申购折算:`digits = core.get_share_digits(product_id, SUBSCRIBE)` → `sub_qty = product_round(amount / nav, digits)`(redeem 为份额申报 D26,无折算无消费方) | + +**边界裁定(开发计划 DoD 即边界)**:T-14 只接**落库侧**位数;响应展示维持既有 +`q(x, D2)` 收敛规格(库内 `DECIMAL(18,4)` 本就高于展示位,展示规格是独立维度, +2 位产品路径零漂移)。PRD §2.5.2「份额展示 2 位(可被产品配置覆盖)」的后半句 +(展示位数按产品覆盖)**未落实**,登记开口待用户裁定。 + +## 二、联网核实(用户铁律 2026-09-12 · 本轮最重要产出) + +> 用户指示:「不确定的不要盲目相信 prd 和交接文档,而是联网查询确认,要和真实项目一致」—— +> T-14 的落地依据全部是外部事实断言,逐条联网核实: + +| # | 断言(PRD/种子 v1.0 原口径) | 核实结论 | 依据 | +|---|---|---|---| +| 1 | 主流份额 2 位四舍五入 | ✅ 成立 | 民生加银转换规则第八条 / 富时基金业务规则 / 新华基金 / 泰康安泰(申购份额 2 位第 3 位四舍五入) | +| 2 | 位数按产品配置(D27 机制) | ✅ 成立且比 PRD 更硬 | **南方基金《关于调整旗下基金转换业务规则的公告》(2012)**:把统一的「四舍五入保留两位」改为「**根据各基金基金合同和招募说明书的相关规定**保留小数点后 2 位」——产品级配置的直接行业出处 | +| 3 | 舍入方式存在「舍去(TRUNCATE)」型 | ✅ 成立(rounding_mode 字段有现实载体) | 南方利众 C / 南方宝元转换公告(两位后**舍去**归基金资产)、邮储银行转换 FAQ、华宝未来混合、申万菱信业务规则、东方基金登记业务规则 | +| 4 | 南方截断 | ✅ 成立 | 同上(南方自家公告即「舍去」型) | +| 5 | 货基 1 元面值、份额 2 位 | ✅ 成立 | 中银货币 / 融通易支付 / 鑫元货币基金合同 | +| 6 | **「指数基金申购/转入确认份额允许 4 位小数」**(v1.0 种子给 3 产品配 4 位的唯一注释依据) | ❌ **证伪**:反证充分——易方达沪深300非银 ETF 基金合同、汇添富深证300 ETF 联接、华夏恒生 ETF 联接、科创板 ETF 联接全部 2 位;易方达创业板新能源 ETF 联接转换公告直接写「**转入本基金的份额计算结果保留到小数点后两位**」 | v1.0 注释声称「见 PRD 已知差异」,PRD §2.5.3 实际无此依据——注释与 PRD 互相「引用」但源头不存在 | +| 7 | 「易方达 ETF 场外取整数位」 | ⚠️ 未找到证据,且有反证(易方达自家 ETF/联接 2 位);真实「整数位」载体是**场内渠道**(招商瑞智优选 LOF 合同:场内申购先 2 位再截位到整数份),属渠道维度非产品维度 | PRD §2.5.3 该句**待用户裁定修订** | + +**种子纠偏(`11-seed-share-rule.sql` → v1.1)**:PROD-510300 / PROD-510500 / +PROD-000002 共 9 行 4 → **2**(42 行数不变,`verify_convert_seed.py` DoD ⑩ 只断言行数, +不炸);头注重写为上表 1~5 的核实结论 + 出处;**刻意不配 `truncate` 行**—— +T-14 只接 digits,配了不消费即是半成品(TRUNCATE 接入为登记开口)。 + +## 三、测试(+7 → 全量 837 passed / 7 skipped,零回归) + +| 文件 | 用例 | 覆盖 | +|---|---|---| +| `test_convert_calc.py` | `test_in_qty_digits_four_places` / `test_in_qty_default_digits_matches_round2` | 纯函数层:4 位量化 / 默认 digits 与 `round2` 逐字节一致(DoD 3) | +| `test_convert_confirm.py` | `test_confirm_in_qty_four_places_via_share_rule`(+`subscribe` 型 2 位干扰行证 business_type 区分度) | 回退链第一层(DoD 1,非降级):in 流水 qty + 转入批次均 4 位 | +| | `test_confirm_in_qty_falls_back_to_product_share_digits` | 回退链第二层:无规则行 + `core_product.share_digits=4` → 4 位(用 4 位而非 2 位才能证明第二层真被消费,排除「恒 2」假实现) | +| | `test_confirm_in_qty_missing_rule_falls_back_default_two_places` | 回退链第三层:全缺(share_digits 置 NULL)→ 默认 2 位 == PRD 示例逐字节(DoD 2 显式降级 + DoD 3 链路锚点) | +| `test_share_lot.py` | `test_subscribe_qty_four_places_via_share_rule` / `test_subscribe_qty_falls_back_to_product_share_digits` | 申购侧对称两条(+`convert` 型干扰行) | + +⚠️ 4 位种子行的定位 = **D27 机制验证载体**(非真实业务常态),已在三处用例 docstring +与 `product_round` docstring 钉明,防止后人误当真实口径。回退链三层各有用例, +降级路径非唯一覆盖(DoD 2 的 PRD 示例用例同样走缺规则路径)。 + +## 四、突变验证 2 组(精准命中后还原,MUTATION 零残留) + +| # | 突变 | 结果 | +|---|---|---| +| 1 | `confirm_service` digits 接入改固定 `digits = 2` | **2 红**(4 位规则行 + product 回退两条;missing_rule 默认 2 位用例仍绿属预期) | +| 2 | `trade_gateway` digits 接入改固定 `digits = 2` | **2 红**(申购 4 位两条) | + +## 五、真库门禁(9/9 退出码 0) + +`verify_convert_seed.py` 重灌后全 PASS(⑩ 42 行落库生效,全 2 位)+ +accept 37 / confirm 83 / engine 39 / api 89 / compensate 56 / apply 24 / lots 20 / +tools 14 —— 全部零回归(2 位产品路径与接入前逐字节一致)。 + +## 六、登记开口(2 项,待用户裁定,未顺手做) + +1. **PRD §2.5.3 修订**:「易方达 ETF 场外取整数位」未找到证据且有反证(易方达自家 + ETF/联接 2 位);真实整数位载体是场内渠道(LOF/ETF 场内先 2 位再截位整数)。 + PRD 已定稿,修订须用户拍板。 +2. **`rounding_mode='truncate'` 消费接入**:真实业务存在「舍去」型(南方/华宝/ + 申万菱信等,核实 #3),D27 字段已建但 calc 未消费(`product_round` 固定 HALF_UP)。 + 属 T-14 计划范围外(开发计划改法只接 digits),接入方案 = `product_round` 加 mode + 参数 + 回退链透传 + 种子配一对 truncate 产品,待用户拍板是否补做及归哪个任务。 + +## 七、下一步 + +**T-15**(D28 部分成交专项测试 + 边界补强):用例矩阵(占用被抢 / 部分确认后 +释放 / 撤单与确认竞态 / `actual_qty=0 → rejected` R-10 补强)+ `CONVERT_STRESS` +两单争同一批。入口:开发计划 T-15 节。 diff --git a/docs/memory/MEMORY.md b/docs/memory/MEMORY.md index cee7dff..d68654c 100644 --- a/docs/memory/MEMORY.md +++ b/docs/memory/MEMORY.md @@ -13,7 +13,7 @@ **⚡ 基金转换(convert)线 — 两条子线**: - **(一)v0.x 两阶段实时模型(已结项 2026-09-10)**:PRD **v0.9.3** + 架构 **v1.0.1** + 开发计划 **v1.0**,T-0 ~ T-13 全部完成(第 5+6 步闭环,最终 **739 passed / 10 skipped**,`CONVERT_STRESS=1` 并发 9/9,含死锁重试补丁 `43cf2a1`);**该模型已被下一线取代,其 PRD/架构/开发计划作废,代码保留作过渡基线**。 -- **(二)下一线 · T+1 受理/确认分离模型(进行中 · 2026-09-11)**:入口 **交接文档.md §B.11**(10 项真实业务拍板)· PRD **v1.1** + 架构 **v2.1** 已整体重写并经独立 AI 联网审查通过(0 阻断级),**已定稿**(2026-09-11 用户批准);**第 4 步开发计划 v2.0 已整体重做**(`docs/项目框架设计/开发计划-基金转换交易.md`,797 行:T-0~T-17 按架构 §15 重排 + §1.4 代码事实核对表 F-1~F-25 + §1.5 实现级裁定 R-1~R-15 + §5 十六条回归面 + §6 验收 31 条 + FR-C29/C30 全映射),**经三轮独立审核闭环(第一轮挑毛病 2 阻断+5 重要+3 可选 / 第二轮验证型 12 项零残留 / 第三轮白纸重审 C1~C5+O1~O4 + 验证型复核 22/22 落位),已定稿(2026-09-11 用户批准)**。**核心认知:不新增冻结列,在途占用由受理单推导;紧池 80% 病根在 T+1 串行确认下自动消失。** 下一步:**第 5 步 todo 开发进行中(T-1~T-13 ✅,**830 passed / 7 skipped** 零失败;真库 verify 套件 **9/9 退出码 0**:accept 37 / confirm 83 / engine 39 / api 89 / compensate 56 / apply 24 / lots 20 / seed 全 PASS / tools 14;T-13 = 全量门禁 + convert_fund 退役——v1.0 入口净删 468 行、端到端性能实测 P95 110.3ms(<2s)、紧池 100%(v1.0 为 80%);下一步 T-14 D27 产品舍入接入)**。**T-7 关键裁定:强制全转属受理段决策** —— 受理由 `plan_lots` 判定并把 `qty` 收敛为实际全转量 + 受理单 `remark='full_transfer'`,确认段**只继承不重判**(`plan_lots` 不传 `min_hold_qty`);重判会因 T→T+1 可用份额变化(部分成交/他人申赎/产品参数改)得出与受理承诺不一致的结论(擅自扩大客户指令)。**T-8 关键裁定**:引擎时机(R-7)的实现部分在 T-7 已完成,T-8 补的是**守护与验证**(AST 守护唯一调用点 / 受理不触引擎 / 确认链路真引擎正证+反证);⚠️ **T-8 不动去重口径** —— `amount_view` 三处消费方原样不动(属 T-11);`convert_fund` 的阶段 1.5 调用点随 T-9 删函数一并清除(只摘调用会让 v1.0 过渡函数「扣份额但不出预警单」,风控缺失比时机错更危险)。撤单范围:PRD §5.5 原表述与实现(仅 `accepted`)不矛盾、无需回填(第三轮 C1 订正)。已就绪:`fetch_nav.py` / `06-seed-nav.sql`(60 个真实净值日)/ `gen_trade_calendar.py` / `10-seed-trade-calendar.sql`(503 交易日)。 +- **(二)下一线 · T+1 受理/确认分离模型(进行中 · 2026-09-11)**:入口 **交接文档.md §B.11**(10 项真实业务拍板)· PRD **v1.1** + 架构 **v2.1** 已整体重写并经独立 AI 联网审查通过(0 阻断级),**已定稿**(2026-09-11 用户批准);**第 4 步开发计划 v2.0 已整体重做**(`docs/项目框架设计/开发计划-基金转换交易.md`,797 行:T-0~T-17 按架构 §15 重排 + §1.4 代码事实核对表 F-1~F-25 + §1.5 实现级裁定 R-1~R-15 + §5 十六条回归面 + §6 验收 31 条 + FR-C29/C30 全映射),**经三轮独立审核闭环(第一轮挑毛病 2 阻断+5 重要+3 可选 / 第二轮验证型 12 项零残留 / 第三轮白纸重审 C1~C5+O1~O4 + 验证型复核 22/22 落位),已定稿(2026-09-11 用户批准)**。**核心认知:不新增冻结列,在途占用由受理单推导;紧池 80% 病根在 T+1 串行确认下自动消失。** 下一步:**第 5 步 todo 开发进行中(T-1~T-14 ✅,**837 passed / 7 skipped** 零失败;真库 verify 套件 **9/9 退出码 0**:accept 37 / confirm 83 / engine 39 / api 89 / compensate 56 / apply 24 / lots 20 / seed 全 PASS / tools 14;T-14 = D27 产品舍入真正接入——`core_ro.get_share_digits` 回退链单点(规则行 → core_product.share_digits → 默认 2)+ 确认段(convert)/申购折算(subscribe)两处接入;**联网核实纠偏:种子「指数基金 4 位」证伪(ETF 联接/指数合同全 2 位)→ 11-seed v1.1 全 2 位**,开口 = PRD §2.5.3 易方达句修订 + rounding_mode=truncate 接入待用户拍板;下一步 T-15 D28 部分成交专项)**。**T-7 关键裁定:强制全转属受理段决策** —— 受理由 `plan_lots` 判定并把 `qty` 收敛为实际全转量 + 受理单 `remark='full_transfer'`,确认段**只继承不重判**(`plan_lots` 不传 `min_hold_qty`);重判会因 T→T+1 可用份额变化(部分成交/他人申赎/产品参数改)得出与受理承诺不一致的结论(擅自扩大客户指令)。**T-8 关键裁定**:引擎时机(R-7)的实现部分在 T-7 已完成,T-8 补的是**守护与验证**(AST 守护唯一调用点 / 受理不触引擎 / 确认链路真引擎正证+反证);⚠️ **T-8 不动去重口径** —— `amount_view` 三处消费方原样不动(属 T-11);`convert_fund` 的阶段 1.5 调用点随 T-9 删函数一并清除(只摘调用会让 v1.0 过渡函数「扣份额但不出预警单」,风控缺失比时机错更危险)。撤单范围:PRD §5.5 原表述与实现(仅 `accepted`)不矛盾、无需回填(第三轮 C1 订正)。已就绪:`fetch_nav.py` / `06-seed-nav.sql`(60 个真实净值日)/ `gen_trade_calendar.py` / `10-seed-trade-calendar.sql`(503 交易日)。 **仓库地图:** | 路径 | 状态 | 说明 | @@ -200,8 +200,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` 全量(当前 **830 passed / 7 skipped**,风控基线 510 / v0.x convert 完结 739)· uvicorn 启动 + /health · SQL / sync 脚本 · 对照 REQUIREMENTS 验收列) -6. **当前有哪两条并行线?**(① 风控/架构改进线:**已结项**(510 基线绿、§7.2 七项冒烟 7/7 PASS、`037ce7e` 已核实早已推送);② **基金转换线**:v0.x 实时模型已结项(739 绿,**已被 T+1 模型取代**);**下一线 T+1 受理/确认分离模型进行中(2026-09-11)**:PRD v1.1 / 架构 v2.1 / 开发计划 v2.0 重写完毕并经独立审查收敛,**已定稿**(2026-09-11 用户批准),第 5 步 todo 开发进行中(T-1~T-17;**当前进度 T-1~T-13 ✅,830 绿**))——动代码前先确认自己属于哪条线,别混淆前置条件。 +5. 如何验证?(`python -m pytest` 全量(当前 **837 passed / 7 skipped**,风控基线 510 / v0.x convert 完结 739)· uvicorn 启动 + /health · SQL / sync 脚本 · 对照 REQUIREMENTS 验收列) +6. **当前有哪两条并行线?**(① 风控/架构改进线:**已结项**(510 基线绿、§7.2 七项冒烟 7/7 PASS、`037ce7e` 已核实早已推送);② **基金转换线**:v0.x 实时模型已结项(739 绿,**已被 T+1 模型取代**);**下一线 T+1 受理/确认分离模型进行中(2026-09-11)**:PRD v1.1 / 架构 v2.1 / 开发计划 v2.0 重写完毕并经独立审查收敛,**已定稿**(2026-09-11 用户批准),第 5 步 todo 开发进行中(T-1~T-17;**当前进度 T-1~T-14 ✅,837 绿**))——动代码前先确认自己属于哪条线,别混淆前置条件。 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 df82c32..b129b4c 100644 --- a/docs/memory/TODO.md +++ b/docs/memory/TODO.md @@ -9,7 +9,7 @@ **⚡ 并行新线 · 基金转换(convert)**—— **两条子线**: - **(一)v0.x 两阶段实时模型(已结项 2026-09-10)**:PRD **v0.9.3** + 架构 **v1.0.1** + 开发计划 **v1.0**,**T-0 ~ T-13 全部完成**(第 5 步 + 第 6 步闭环,**736 passed / 10 skipped**;7 个 convert 真库脚本复跑零回归;压测修复 2 处并发缺陷 + 死锁重试后补 `43cf2a1` 最终 **739 passed / 10 skipped**)。**注:该模型已被下一线取代(按下述),其设计文档作废,代码仍保留作过渡基线**。 -- **(二)下一线 · T+1 受理/确认分离模型(进行中 · 2026-09-11)**:入口 **交接文档.md §B.11** · PRD **v1.1** + 架构 **v2.1** 均已整体重写并经独立 AI 联网审查通过(0 阻断级),**已定稿**(2026-09-11 用户批准);**第 4 步开发计划 v2.0 已整体重做(`docs/项目框架设计/开发计划-基金转换交易.md`,T-1~T-17 按架构 §15 重排 + 代码事实 F-1~F-25 + 裁定 R-1~R-15 + 16 条回归面 + 验收 31 条 + FR-C29/C30 全映射),经三轮独立审核闭环(挑毛病 / 验证型 / 白纸重审 + 验证型复核 22/22 落位),已定稿(2026-09-11 用户批准)**。**第 5 步 todo 开发已开工(2026-09-11):T-1 ~ T-13 完成(全量 pytest 830 passed / 7 skipped 零失败;真库 verify 套件 9/9 退出码 0:accept 37 / confirm 83 / engine 39 / api 89 / compensate 56 / apply 24 / lots 20 / seed 全 PASS / tools 14,v1.0 链路 verify_convert_service 已随 convert_fund 退役删除),下一步 T-14(D27 产品舍入真正接入)**。撤单范围:PRD §5.5 原表述与实现(仅 `accepted`)不矛盾、无需回填(第三轮 C1 订正)。 +- **(二)下一线 · T+1 受理/确认分离模型(进行中 · 2026-09-11)**:入口 **交接文档.md §B.11** · PRD **v1.1** + 架构 **v2.1** 均已整体重写并经独立 AI 联网审查通过(0 阻断级),**已定稿**(2026-09-11 用户批准);**第 4 步开发计划 v2.0 已整体重做(`docs/项目框架设计/开发计划-基金转换交易.md`,T-1~T-17 按架构 §15 重排 + 代码事实 F-1~F-25 + 裁定 R-1~R-15 + 16 条回归面 + 验收 31 条 + FR-C29/C30 全映射),经三轮独立审核闭环(挑毛病 / 验证型 / 白纸重审 + 验证型复核 22/22 落位),已定稿(2026-09-11 用户批准)**。**第 5 步 todo 开发已开工(2026-09-11):T-1 ~ T-14 完成(全量 pytest 837 passed / 7 skipped 零失败;真库 verify 套件 9/9 退出码 0:accept 37 / confirm 83 / engine 39 / api 89 / compensate 56 / apply 24 / lots 20 / seed 全 PASS / tools 14,v1.0 链路 verify_convert_service 已随 convert_fund 退役删除),下一步 T-15(D28 部分成交专项 + 边界补强)**。撤单范围:PRD §5.5 原表述与实现(仅 `accepted`)不矛盾、无需回填(第三轮 C1 订正)。 ### 基金转换线待办(推荐顺序) @@ -40,7 +40,8 @@ - [x] **T-11 core_tools 汇总去重 + 持仓过滤 ✅(2026-09-12,生产代码零改动)**:口径三处同调(F-10)与 `list_holdings` 的 `qty > 0` SQL 过滤均已就位(v0.x T-11 产物),本轮增量 = 把口径用例升级为 **T+1 真实链路场景**(R-回归 8「保留 + 补用例」/ R-回归 13「补确认段场景」—— T+1 下流水与持仓只在确认段产生):`tests/test_core_tools.py` +2 —— ① `test_confirm_then_sum_amount_counts_once`(受理→`confirm_one` 落两条同 gid 流水后 `sum_amount` = 转出端 40812.00 + 无组普通 100000 = 140812.00,明细仍 3 条全量,`sum_trades_on_date(T+1)` 跨口径一致)② `test_confirm_full_transfer_holding_zero_row_excluded`(先证归零行仍在库(台账留痕),再断言 `list_holdings`/`query_holdings` 只剩转入端)。**验证**:全量 **846 passed / 10 skipped**(844+2 零回归);突变 2 组均精准 3 红(去 `qty>0` / 绕开 `amount_view`)已还原;真库 `verify_convert_tools.py` 复跑 **14/14** 零残留。**执行期裁定 2 条**:① 转出端流水 `amount` = **费前**转出金额(与 PRD §5.3.2 的 68020.00 同语义,赎回费单列 `redeem_fee`)—— 初版锚点误按费后被真跑抓出;② `t1_env` 必须调 `seed_suitability_matrix`(缺 C×R 矩阵 → 受理全量 forbidden,与 conftest 同源) - [x] **T-12 补偿 + SLA 清理切 T+1 链路 ✅(2026-09-12)**:① `cleanup_pending_convert.py` **整体重写**(扫描对象迁 Core:`list_inflight_before` 扫 `accepted/nav_pending` 且 `requested_at` 严格早于 cutoff → 条件 UPDATE 置 `expired`(S2 不硬删、天然幂等不加锁);SLA 边界 = 当前业务日上推 `convert_confirm_sla_days` 个交易日 00:00(`previous_biz_day` 链式,与 `_window_start` 同口径,日历缺失降级自然日并告警);`--days`/`--dry-run`;**不动镜像**(T-4 契约));② `compensate_convert` **从 v1.0 Deprecated 组救回在役**(详情侧 = Core 三件套前置校验(流水 ≥2 → `confirmed` → 明细非空,任一不满足 `missing` 零写入),补写与确认段第⑧步同口径 `sync_mirror` + `confirmed` 审计 `phase='confirm-compensate'`);③ `convert_request_repository` +`list_inflight_before`。**联网核实**(用户铁律):基金转换「无效申请」真实口径(多家基金公司业务规则:TA T+1 确认、无效资金退回 → `rejected` 映射成立)+ A 股连续休市最长 = 2020 春节 10 自然日(上交所通知,`_MAX_SCAN_DAYS=256` 依据成立)。**⚠️ 真库脚本暴露并修复**:`confirm_one` 第⑦步引擎异常只 log 不留痕(全仓唯一不落痕路径)→ except 补写 `decision='engine_error'` 审计 + `engine_error` 标志贯穿(D17 不阻断保留;test_convert_confirm 断言翻转 is True + 补审计断言)。**验证**:全量 **849 passed / 10 skipped**(846+3 零回归);真库 `verify_convert_compensate.py` **整体重写 T+1 链路 56/56 残留 0**(含幂等重跑;真库修正 1 条口径:`confirmed` 审计真链路恰 2 条 = 确认段 + 补偿各 1);**突变 4 组全被抓还原**(cleanup 窗口反转 5 红 / 前置守卫删除 1 红 / 真库 expired→cancelled 2 红 / 真库 nav_stale=True 1 红)。**勘误**:开发计划原写 `scripts/agent/rebuild_alerts.py` 实际在 `scripts/demo/`(零改动) - [x] **T-13 全量门禁 + convert_fund 退役 ✅(2026-09-12,提交 `3bc84a0`)**:① `convert_fund` 及 v1.0 依赖链净删 468 行(`rebuild_convert_response` 按实际改判**保留在役**:T-9 查询接口读路径);`verify_convert_service.py` git rm;`test_convert_service.py`(805→约 440 行)与 `test_convert_concurrency.py`(v1.0 九用例 → T+1 六用例)全量重写;grep 仅余 4 处注释性历史说明、零调用。② **门禁**:全量 **830 passed / 7 skipped**(849 → 830 为 v1.0 用例组退役净减);CONVERT_STRESS=1 **6/6**;真库 **9/9 退出码 0**。③ **性能实测(PRD §9 第 18 条已回填)**:端到端 P50 79.2 / P95 110.3 / max 114.7 ms —— < 2s 硬门禁余量 17 倍。④ **紧池 100%(验收 31 已回填 PRD)**:50/50 受理 + 50/50 串行确认(v1.0 同场景 80% → 病根结构性消除)。⑤ **计划外产出**:`verify_convert_lots.py` B/C/D 组仍按 v1.0 金额赎回传 `amount=`(T-9 已改份额申报,redeem 分支 `qty is None` 静默跳过)→ 修正为 `qty=` 后 20/20。 -- [ ] T-14 ~ T-17(D27 产品级舍入 / D28 巨额赎回 / D29 资金流中转 / D30 A/C 互转) +- [x] **T-14 D27 产品舍入真正接入 + 联网核实纠偏 ✅(2026-09-12)**:**接入面 4 处单点收敛**——`core_ro.get_share_digits`(全仓唯一回退链:规则行 → `core_product.share_digits` → `calc.PLACES`=2)· `calc.in_qty` 加可选 `digits=PLACES`(默认行为逐字节不变,内部走 `product_round`)· 确认段(business_type=`convert`)· 网关申购折算(`subscribe`);redeem 份额申报(D26)无消费方。**边界裁定**:只接落库侧(DoD 即边界),展示维持 `q(D2)`(库内 DECIMAL(18,4) 本高于展示位,2 位产品零漂移)。**联网核实(用户铁律 · 本轮最重要产出)**:主流 2 位 ✅(民生加银/富时/新华/泰康)· **位数按各基金合同配置 = 行业真实做法** ✅(南方 2012 转换调整公告)· 「舍去 TRUNCATE」型真实存在 ✅(南方利众/宝元、邮储、华宝、申万菱信、东方)· 货基 2 位 ✅(中银/融通/鑫元)· **「指数基金份额 4 位」证伪** ❌(易方达沪深300非银 ETF 合同、汇添富深证300/华夏恒生/科创板 ETF 联接全 2 位;易方达创业板新能源 ETF 联接转换公告明写「转入份额保留两位」)→ **`11-seed-share-rule.sql` v1.1:3 产品 9 行 4→2 + 头注重写为核实出处**;「易方达 ETF 场外整数位」⚠️ 无证据且有反证(真实整数位载体 = 场内渠道,LOF 场内先 2 位再截位整数)。**测试 +7(830→837 零回归)**:calc 层 2 + confirm 段 3(回退链三层各 1,第一层带 business_type 干扰行)+ 申购段 2;4 位种子行 = D27 机制验证载体(docstring 钉明)。**突变 2 组**(confirm/gateway digits 改固定 2 → 各 2 红,还原零残留);真库 **9/9 退出码 0**(seed 重灌 42 行全 2 位生效)。**开口 2 项待用户拍板**:① PRD §2.5.3「易方达 ETF 场外取整数位」修订;② `rounding_mode=truncate` 消费接入(真实存在、T-14 范围外)。 +- [ ] T-15 ~ T-17(D28 部分成交专项 / D29 资金流中转 / D30 A/C 互转) > **第 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)。 > ⚠️ **以下四条引用块均为 v0.x 两阶段实时线记录,其 T 编号与新线(T-1~T-17)无关、勿混**:新线 T-11 亦涉 `amount_view`,与 v0.x T-11「提升为公开函数」是两回事。 diff --git a/docs/项目框架设计/开发计划-基金转换交易.md b/docs/项目框架设计/开发计划-基金转换交易.md index 5c91e5b..48a6ebf 100644 --- a/docs/项目框架设计/开发计划-基金转换交易.md +++ b/docs/项目框架设计/开发计划-基金转换交易.md @@ -2,7 +2,7 @@ > **文档状态**:v2.0 整体重写 · ✅ **三轮独立审核闭环**(第一轮 2 阻断 B1/B2 + 5 重要 + 3 可选;第二轮验证型 12 项零残留;**第三轮白纸重审 C1~C5+O1~O4 全修订 + 第三轮验证型复核 22/22 落位、D-1 残留已修复**)· **已定稿**(2026-09-11 用户批准)(批准后可进入第 5 步 todo 开发) > -> **代码基线**:分支 `risk-control-agent` · pytest 基线 **830 passed / 7 skipped**(T-13 后;T-0~T-13 完成,v1.0 `convert_fund` 已退役)· push 由用户定 +> **代码基线**:分支 `risk-control-agent` · pytest 基线 **837 passed / 7 skipped**(T-14 后;T-0~T-14 完成,v1.0 `convert_fund` 已退役)· push 由用户定 > > **上游依据**:PRD-基金转换交易.md **v1.1**(§2 模型 / §5 接口 / §6 引擎 / §9 验收 31 条 / Q1~Q15 拍板)· 架构设计-基金转换交易.md **v2.1**(D1~D30 · §15 任务映射 T-0~T-17) > @@ -745,14 +745,47 @@ **依赖**:T-1 / T-2 **DoD**: -- [ ] 4 位产品用例:转入份额 quantize 到 4 位(非降级) -- [ ] 缺规则产品:回退 2 位(显式降级用例存在但**非唯一覆盖**) -- [ ] 与 `round2` 默认行为一致性断言(2 位路径逐字节一致) -- [ ] pytest 全绿 +- [x] 4 位产品用例:转入份额 quantize 到 4 位(非降级)—— confirm 段 + 申购段各 1 条真实路径用例(回退链第一层),business_type 区分度由干扰行钉住 +- [x] 缺规则产品:回退 2 位(显式降级用例存在但**非唯一覆盖**)—— `test_confirm_in_qty_missing_rule_falls_back_default_two_places`(第三层全缺 → 默认 2 位 == PRD 示例逐字节);PRD 示例用例同样走缺规则路径,非唯一 +- [x] 与 `round2` 默认行为一致性断言(2 位路径逐字节一致)—— calc 层 `test_in_qty_default_digits_matches_round2` + 链路层全量零回归 + 真库 9/9(2 位产品逐字节一致) +- [x] pytest 全绿 —— **837 passed / 7 skipped**(830 基线 + 7,零回归);真库 **9/9 退出码 0** **风险**:全局切舍入炸既有断言 → 与 round2 一致性断言守护 **回滚点**:单点切换(product_round 封装) +**✅ T-14 完成(D27 产品舍入真正接入 + 联网核实纠偏 · 2026-09-12)**: +**① 接入面(生产代码 4 处,单点收敛)**:`core_ro.get_share_digits` = 全仓唯一回退链 +(规则行 → `core_product.share_digits` → `calc.PLACES`);`calc.in_qty` 加可选 +`digits=PLACES` 参数(默认行为逐字节不变)内部改走 `product_round`;确认段 +(business_type=`convert`,新常量 `_RULE_BT_CONVERT`)与网关申购折算(`subscribe`) +两处接入;redeem 份额申报(D26)无折算无消费方。**边界裁定**:T-14 只接落库侧 +(DoD 即边界);响应展示维持 `q(x, D2)` 收敛(库内 DECIMAL(18,4) 本高于展示位), +PRD §2.5.2「展示位数可被产品覆盖」后半句未落实 → 登记开口。 +**② 联网核实(用户铁律 · 本轮最重要产出)**:主流 2 位四舍五入 ✅(民生加银/富时/ +新华/泰康);**位数按各基金合同配置 = 真实行业做法** ✅(南方基金 2012 转换调整公告, +比 PRD 依据更硬);「舍去 TRUNCATE」型真实存在 ✅(南方利众/宝元、邮储、华宝、 +申万菱信、东方基金);货基 1 元面值 2 位 ✅(中银/融通/鑫元合同);**「指数基金 +份额 4 位」证伪** ❌(易方达沪深300非银 ETF 合同、汇添富深证300/华夏恒生/科创板 +ETF 联接全部 2 位,易方达创业板新能源 ETF 联接转换公告明写转入份额保留两位)—— +v1.0 种子注释声称「见 PRD 已知差异」而 PRD 实无此依据,属注释与 PRD 互为虚引; +「易方达 ETF 场外取整数位」⚠️ 未找到证据且有反证,真实整数位载体是**场内渠道** +(招商瑞智 LOF:场内先 2 位再截位整数)→ PRD §2.5.3 修订待用户拍板。 +**③ 种子纠偏(11-seed-share-rule.sql → v1.1)**:3 产品 9 行 4→2(42 行数不变, +DoD ⑩ 只断言行数不炸);头注重写为核实结论 + 出处;刻意不配 truncate 行 +(T-14 不消费 rounding_mode,配了即半成品)。 +**④ 测试 +7(830 → 837,零回归)**:calc 层 2(4 位量化 / 默认与 round2 逐字节) ++ confirm 段 3(回退链第一层 4 位 + 第二层 product 列 4 位 + 第三层全缺默认 2 位; +第一层带 `subscribe` 型 2 位干扰行证 business_type 区分度)+ 申购段 2(对称)。 +4 位种子行定位 = **D27 机制验证载体**(非真实业务常态),三处 docstring 钉明。 +降级路径非唯一覆盖(DoD 2)。 +**⑤ 突变验证 2 组**(精准命中后还原、MUTATION 零残留):confirm 段 digits 改固定 2 → +2 红;gateway 段同 → 2 红(默认 2 位用例仍绿属预期)。 +**⑥ 真库 9/9 退出码 0**:seed 重灌全 PASS(42 行全 2 位生效)+ accept 37 / confirm 83 / +engine 39 / api 89 / compensate 56 / apply 24 / lots 20 / tools 14 —— 2 位路径零回归。 +**⑦ 登记开口(待用户裁定,未顺手做)**:① PRD §2.5.3「易方达 ETF 场外取整数位」 +修订;② `rounding_mode='truncate'` 消费接入(真实存在但计划范围外,接入 = +product_round 加 mode 参数 + 回退链透传 + 种子配 truncate 产品)。 + --- ### T-15 · D28 部分成交专项测试 + 边界补强 diff --git a/scripts/core/11-seed-share-rule.sql b/scripts/core/11-seed-share-rule.sql index 095b2f7..40f62d9 100644 --- a/scripts/core/11-seed-share-rule.sql +++ b/scripts/core/11-seed-share-rule.sql @@ -2,20 +2,44 @@ USE jinrong_core; -- ============================================================================ -- 11-seed-share-rule.sql · 产品份额舍入规则种子(D27 / R-13) --- 版本:v1.0(2026-09-11 · T-1 新增) --- 依据:开发计划 v2.0 T-14(D27 产品舍入真正接入) --- 说明 1:core_share_rule(product_id, business_type) 给 share_digits(默认 2); --- 与 core_product.share_digits 二选一优先 —— 规则行优先(T-14 DoD)。 --- 说明 2:本文件给主要产品按 business_type 落 share_digits = 2(默认口径); --- 个别指数/货基给 4 位(真实业务中指数基金份额可计到 0.0001 份, --- 货币基金份额保持 0.01 元面值 2 位 —— 按 22 号文口径,指数基金申购/转 --- 入确认份额允许保留 4 位小数,见 PRD 已知差异)。 --- 说明 3:未被本文件覆盖的产品(business_type 无行)由 product_round 回退 --- core_product.share_digits(默认 2),降级路径有显式用例(T-14 DoD 2)。 +-- 版本:v1.1(2026-09-12 · T-14 联网核实纠偏;v1.0 曾给指数/存单产品配 4 位, +-- 注释依据「指数基金申购/转入确认份额允许保留 4 位小数」经联网核实 +-- 查无实据且反证充分,见下——全部产品改回主流 2 位) +-- 依据:开发计划 v2.0 T-14(D27 产品舍入真正接入)+ 2026-09-12 联网核实 +-- +-- 【联网核实的真实业务口径(2026-09-12,逐条附出处)】 +-- 1. 位数按产品(基金合同)配置 —— 行业真实做法: +-- 南方基金《关于调整旗下基金转换业务规则的公告》(2012) 明确把统一的 +-- 「转入份额四舍五入保留两位」改为「根据各基金基金合同和招募说明书的 +-- 相关规定保留小数点后 2 位」(pdf.dfcfw.com/pdf/H22_AN201202280004321524_1.pdf) +-- —— 这正是 D27「按产品配置」的最直接行业依据。 +-- 2. 份额位数主流 2 位(申购/转换一致): +-- - 民生加银《基金转换规则》第八条:申请转换份额按四舍五入保留到小数点后 2 位 +-- (msjyfund.com.cn/osoa/views/serviceCenter/conversionRule.html) +-- - 富时基金《开放式基金业务管理规则》:申购份额保留 2 位、第 3 位四舍五入 +-- - 汇添富深证300 ETF 联接 / 华夏恒生 ETF 联接 / 易方达沪深300非银 ETF 基金合同: +-- 指数(联接)基金申购份额同样保留 2 位 —— **「指数基金 4 位」查无实据** +-- - 货币基金:1.00 元面值、份额 = 金额 ÷ 1.00 保留 2 位四舍五入 +-- (中银货币 / 融通易支付 / 鑫元货币基金合同) +-- 3. 舍入方式真实存在「舍去(TRUNCATE)」型 —— rounding_mode 字段的现实载体: +-- - 南方利众混合 C 转换公告:份额保留 2 位、两位以后的部分**舍去**归基金资产 +-- - 邮储银行《基金转换常见问题》:转换份额 2 位后舍去 +-- - 华宝未来主导混合 / 申万菱信业务规则 / 东方基金登记业务规则:同类「舍去」条款 +-- ⚠️ 本项目 T-14 只接入 share_digits;rounding_mode='truncate' 的消费接入为 +-- 登记开口(真实差异载体,待用户裁定后排期),故本种子暂不配置 truncate 行, +-- 避免出现「种子有值、实现不消费」的半成品。 +-- 4. 整数位的真实载体是**场内渠道**(LOF/ETF 场内申购先 2 位再截位到整数份, +-- 如招商瑞智优选 LOF 基金合同),不是产品固有属性;本项目无场内/场外渠道 +-- 维度,不做该建模。 +-- +-- 说明(回退链,11-seed 头注 v1.0 原文沿用):未被本文件覆盖的产品 +-- (business_type 无行)由 core_ro.get_share_digits 回退 core_product.share_digits +-- (DEFAULT 2)→ 再缺省回退 calc.PLACES(2)。降级路径有显式用例(T-14 DoD 2)。 -- ============================================================================ INSERT INTO core_share_rule (product_id, business_type, share_digits, rounding_mode) VALUES --- 主演示产品:统一 2 位(大众口径) +-- 全部产品:主流 2 位 HALF_UP(核实结论:公募场外申购/转换份额位数主流即 2 位, +-- 产品间真实差异在舍入方式而非位数 —— 见头注 1/2/3) ('PROD-110022', 'subscribe', 2, 'half_up'), ('PROD-110022', 'redeem', 2, 'half_up'), ('PROD-110022', 'convert', 2, 'half_up'), @@ -34,16 +58,16 @@ INSERT INTO core_share_rule (product_id, business_type, share_digits, rounding_m ('PROD-000001', 'subscribe', 2, 'half_up'), ('PROD-000001', 'redeem', 2, 'half_up'), ('PROD-000001', 'convert', 2, 'half_up'), --- 指数/货基:份额 4 位(R13 依据:指数基金份额确认允许 4 位小数) -('PROD-510300', 'subscribe', 4, 'half_up'), -('PROD-510300', 'redeem', 4, 'half_up'), -('PROD-510300', 'convert', 4, 'half_up'), -('PROD-510500', 'subscribe', 4, 'half_up'), -('PROD-510500', 'redeem', 4, 'half_up'), -('PROD-510500', 'convert', 4, 'half_up'), -('PROD-000002', 'subscribe', 4, 'half_up'), -('PROD-000002', 'redeem', 4, 'half_up'), -('PROD-000002', 'convert', 4, 'half_up'), +-- 指数/存单产品:同为 2 位(v1.0 曾配 4 位,注释依据经联网核实不成立,2026-09-12 纠偏) +('PROD-510300', 'subscribe', 2, 'half_up'), +('PROD-510300', 'redeem', 2, 'half_up'), +('PROD-510300', 'convert', 2, 'half_up'), +('PROD-510500', 'subscribe', 2, 'half_up'), +('PROD-510500', 'redeem', 2, 'half_up'), +('PROD-510500', 'convert', 2, 'half_up'), +('PROD-000002', 'subscribe', 2, 'half_up'), +('PROD-000002', 'redeem', 2, 'half_up'), +('PROD-000002', 'convert', 2, 'half_up'), ('PROD-161725', 'subscribe', 2, 'half_up'), ('PROD-161725', 'redeem', 2, 'half_up'), ('PROD-161725', 'convert', 2, 'half_up'), @@ -58,4 +82,4 @@ INSERT INTO core_share_rule (product_id, business_type, share_digits, rounding_m ('PROD-WMG001', 'convert', 2, 'half_up'), ('PROD-PRIV01', 'subscribe', 2, 'half_up'), ('PROD-PRIV01', 'redeem', 2, 'half_up'), -('PROD-PRIV01', 'convert', 2, 'half_up'); \ No newline at end of file +('PROD-PRIV01', 'convert', 2, 'half_up'); diff --git a/tests/test_convert_calc.py b/tests/test_convert_calc.py index 771202f..bd5c344 100644 --- a/tests/test_convert_calc.py +++ b/tests/test_convert_calc.py @@ -117,6 +117,19 @@ class TestRounding: with pytest.raises(ValueError): calc.in_qty(Decimal("100"), Decimal("0")) + def test_in_qty_digits_four_places(self): + # T-14(D27):digits=4 按产品位数量化 —— 4 位是**机制验证载体** + # (真实业务主流 2 位,2026-09-12 联网核实;产品间真实差异在 rounding_mode) + # 50784.10 / 0.95 = 53456.947368… → 4 位 53456.9474(2 位会是 53456.95) + assert calc.in_qty(Decimal("50784.10"), Decimal("0.9500"), 4) == Decimal("53456.9474") + + def test_in_qty_default_digits_matches_round2(self): + # T-14 DoD3:默认 digits(=PLACES=2)路径与 round2 逐字节一致(零回归锚点) + for amount, nav in (("50784.10", "0.9500"), ("67074.46", "1.9194"), ("1", "3")): + assert calc.in_qty(Decimal(amount), Decimal(nav)) == calc.round2( + Decimal(amount) / Decimal(nav) + ) + def test_rounding_diff_negative_means_client_gains(self): # 理论 53456.9474 − 实得 53456.95 = -0.0026(负 = 客户多得) diff = calc.rounding_diff(Decimal("50784.10"), Decimal("0.9500"), Decimal("53456.95")) diff --git a/tests/test_convert_confirm.py b/tests/test_convert_confirm.py index 6f4f9eb..672efef 100644 --- a/tests/test_convert_confirm.py +++ b/tests/test_convert_confirm.py @@ -312,6 +312,87 @@ def test_confirm_full_flow_matches_prd_example(sqlite_engine): assert "confirmed" in audits +# ── 1.5 D27 产品级舍入(T-14):转入份额位数按 core_share_rule 回退链 ── +# 4 位期望锚点:67074.46 ÷ 1.9194 = 34945.53506… → 4 位 34945.5351(2 位 34945.54) +_IN_QTY_4 = Decimal("34945.5351") + + +def _in_trade_qty(engine, gid: str) -> Decimal: + return Decimal(str( + [t for t in _trades(engine, gid) if t["trade_type"] == "subscribe"][0]["qty"] + )) + + +def test_confirm_in_qty_four_places_via_share_rule(sqlite_engine): + """T-14 DoD1(非降级):转入产品 `convert` 规则行 4 位 → in_qty 量化到 4 位。 + + 同产品插一条 `subscribe` 型 2 位干扰行 —— 确认段必须取 `convert` 型 + (business_type 区分度;错取干扰行会得 34945.54 而红)。 + ⚠️ 4 位是 **D27 机制验证载体**(位数按产品合同配置,南方 2012 调整公告 + 为行业依据),非真实业务常态 —— 联网核实(2026-09-12):公募场外申购/ + 转换份额主流 2 位,产品间真实差异在舍入方式(rounding_mode)而非位数; + 真库种子 11-seed 全 2 位(v1.0 的「指数基金 4 位」依据已证伪并纠偏)。 + """ + _seed(sqlite_engine) + _exec( + sqlite_engine, + "INSERT INTO core_share_rule (product_id, business_type, share_digits, rounding_mode) " + "VALUES (:p, 'convert', 4, 'half_up')", + p=PROD_IN, + ) + _exec( + sqlite_engine, + "INSERT INTO core_share_rule (product_id, business_type, share_digits, rounding_mode) " + "VALUES (:p, 'subscribe', 2, 'half_up')", + p=PROD_IN, + ) + gid = _accept(sqlite_engine, cid="CC-REQ-D27A")["convert_group_id"] + + res = _confirm(sqlite_engine, gid) + + assert res["status"] == "confirmed" + assert _in_trade_qty(sqlite_engine, gid) == _IN_QTY_4 + # 转入批次同值(库内 DECIMAL(18,4) 存 4 位量化结果) + assert _qty(sqlite_engine, f"LOT-{gid}-IN") == _IN_QTY_4 + + +def test_confirm_in_qty_falls_back_to_product_share_digits(sqlite_engine): + """T-14 回退链第二层:无规则行 → `core_product.share_digits`(显式置 4)。""" + _seed(sqlite_engine) + _exec( + sqlite_engine, + "UPDATE core_product SET share_digits = 4 WHERE product_id = :p", + p=PROD_IN, + ) + gid = _accept(sqlite_engine, cid="CC-REQ-D27B")["convert_group_id"] + + res = _confirm(sqlite_engine, gid) + + assert res["status"] == "confirmed" + assert _in_trade_qty(sqlite_engine, gid) == _IN_QTY_4 + + +def test_confirm_in_qty_missing_rule_falls_back_default_two_places(sqlite_engine): + """T-14 DoD2:缺规则产品回退 2 位(显式降级用例,非唯一覆盖 —— + PRD §5.3.2 示例用例同样在无规则行下断言 2 位)。 + + 连 `core_product.share_digits` 也置 NULL → 回退链第三层默认 `PLACES=2`。 + """ + _seed(sqlite_engine) + _exec( + sqlite_engine, + "UPDATE core_product SET share_digits = NULL WHERE product_id = :p", + p=PROD_IN, + ) + gid = _accept(sqlite_engine, cid="CC-REQ-D27C")["convert_group_id"] + + res = _confirm(sqlite_engine, gid) + + assert res["status"] == "confirmed" + # 与不接 D27 时代的 PRD §5.3.2 示例逐字节一致(DoD3 链路级锚点) + assert _in_trade_qty(sqlite_engine, gid) == Decimal("34945.54") + + # ── 2. 幂等 ───────────────────────────────────────────────────────── def test_confirm_twice_is_idempotent(sqlite_engine): """重复确认:第二次 `skipped`,流水不重复(验收 24 后半 / 15)。""" diff --git a/tests/test_share_lot.py b/tests/test_share_lot.py index 15618af..6298ed0 100644 --- a/tests/test_share_lot.py +++ b/tests/test_share_lot.py @@ -456,6 +456,61 @@ def test_subscribe_rounds_qty_half_up(sqlite_engine): assert _dec(row["remain_qty"]) == Decimal("833.33") +# ── 7.1.1 D27 产品级舍入(T-14):申购折算份额按 core_share_rule 回退链 ── +def _seed_share_rule(engine, pid: str, business_type: str, digits: int) -> None: + with engine.begin() as conn: + conn.execute( + text( + "INSERT INTO core_share_rule (product_id, business_type, share_digits, " + "rounding_mode) VALUES (:pid, :bt, :digits, 'half_up')" + ), + {"pid": pid, "bt": business_type, "digits": digits}, + ) + + +def test_subscribe_qty_four_places_via_share_rule(sqlite_engine): + """T-14 DoD1(非降级):`subscribe` 规则行 4 位 → 折算份额 4 位。 + + 同产品插一条 `convert` 型 2 位干扰行 —— 申购必须取 `subscribe` 型 + (business_type 区分度;错取干扰行会得 833.33 而红)。 + ⚠️ 4 位是 **D27 机制验证载体**,非真实业务常态 —— 联网核实(2026-09-12): + 公募场外申购份额主流 2 位(指数/联接/货基同样 2 位),真库种子 11-seed + 全 2 位(v1.0 的「指数基金 4 位」依据已证伪并纠偏)。 + """ + _seed_customer(sqlite_engine, CUSTOMER) + _seed_product(sqlite_engine, PRODUCT_A) + _seed_nav(sqlite_engine, PRODUCT_A, "1.2000") + _seed_share_rule(sqlite_engine, PRODUCT_A, "subscribe", 4) + _seed_share_rule(sqlite_engine, PRODUCT_A, "convert", 2) + + _maintain( + sqlite_engine, trade_id="TRD-T14-SUB4", trade_type="subscribe", amount="1000" + ) + + row = _rows(sqlite_engine, "SELECT * FROM core_share_lot")[0] + # 1000 / 1.2 = 833.3333… → 4 位 833.3333(2 位会是 833.33) + assert _dec(row["remain_qty"]) == Decimal("833.3333") + + +def test_subscribe_qty_falls_back_to_product_share_digits(sqlite_engine): + """T-14 回退链第二层:无规则行 → `core_product.share_digits`(显式置 4)。""" + _seed_customer(sqlite_engine, CUSTOMER) + _seed_product(sqlite_engine, PRODUCT_A) + _seed_nav(sqlite_engine, PRODUCT_A, "1.2000") + with sqlite_engine.begin() as conn: + conn.execute( + text("UPDATE core_product SET share_digits = 4 WHERE product_id = :pid"), + {"pid": PRODUCT_A}, + ) + + _maintain( + sqlite_engine, trade_id="TRD-T14-SUBP4", trade_type="subscribe", amount="1000" + ) + + row = _rows(sqlite_engine, "SELECT * FROM core_share_lot")[0] + assert _dec(row["remain_qty"]) == Decimal("833.3333") + + # ── 7.2 真实路径:赎回 FIFO 扣减 ─────────────────────────────────────── def test_redeem_deducts_fifo_across_lots(sqlite_engine):