diff --git a/AGENTS.md b/AGENTS.md index ea5c25e..d96e7bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ - 技术选型 / JWT:`docs/项目框架设计/技术选型和版本/` - Core 模拟底座:`docs/项目框架设计/Core模拟底座/` - 业务记忆分层:`docs/业务记忆管理/业务记忆管理手册.md` -- **基金转换线(第 5 步 todo 开发 ✅ 已收官 2026-09-12 · T+1 受理/确认分离模型)**:**T-1 ~ T-17 全部完成**(当前基线 **842 passed / 8 skipped** 实测;真库 verify 套件 **9/9 退出码 0**:accept 43 / confirm 103 / engine 39 / api 89 / compensate 56 / apply 30 / lots 20 / seed 全 PASS / tools 14。T-15 = D28 部分成交专项(防御纵深三道防线 + 真库争批);T-16 = D29 资金流中转(core_cash_flow 全仓唯一写入点同事务 2 条 remark 带 gid);T-17 = D30 A/C 互转(share_class 非空且不同才查 allow_ac_convert,任一端=1 放行;口径裁定 NULL 不参与判定/同类走一般规则待追认);开口 3 项待用户拍板 = PRD §2.5.3 易方达句修订 + rounding_mode=truncate 接入 + R-12 口径裁定追认;下一步 = **第 6 步集成测试**);开工入口 **`交接文档.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 开发 ✅ 已收官 2026-09-12 · T+1 受理/确认分离模型)**:**T-1 ~ T-17 全部完成**(当前基线 **849 passed / 8 skipped** 实测;真库 verify 套件 **9/9 退出码 0**:accept 43 / confirm 103 / engine 39 / api 89 / compensate 56 / apply 30 / lots 20 / seed 全 PASS / tools 14。T-15 = D28 部分成交专项(防御纵深三道防线 + 真库争批);T-16 = D29 资金流中转(core_cash_flow 全仓唯一写入点同事务 2 条 remark 带 gid);T-17 = D30 A/C 互转(share_class 非空且不同才查 allow_ac_convert,任一端=1 放行;口径裁定 NULL 不参与判定/同类走一般规则);**开口 3 项 ✅ 已全部闭环(2026-09-12 用户拍板)** = PRD §2.5.3 已修订 + truncate 已接入(get_share_rounding 单点 + 09 ⑦ 段载体 PROD-005828)+ R-12 口径已追认定稿;下一步 = **第 6 步集成测试**);开工入口 **`交接文档.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 17edb77..b793216 100644 --- a/app/gateway/trade_gateway.py +++ b/app/gateway/trade_gateway.py @@ -361,10 +361,10 @@ def _maintain_lots( product_id, ) return - # ── 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) + # ── D27 产品级舍入(T-14 / 开口②闭环 T-14b):申购折算份额位数与 + # 舍入模式按产品规则取(回退链单点 `get_share_rounding`)。 + digits, round_mode = core.get_share_rounding(product_id, SUBSCRIBE) + sub_qty = product_round(amount / nav, digits, round_mode) if sub_qty <= 0: return writer.insert_share_lots( diff --git a/app/repository/core_ro.py b/app/repository/core_ro.py index ddf945b..fd42dac 100644 --- a/app/repository/core_ro.py +++ b/app/repository/core_ro.py @@ -726,24 +726,53 @@ 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 问)。 + def get_share_rounding( + self, product_id: str, business_type: str + ) -> tuple[int, str]: + """产品份额**位数 + 舍入模式**(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 位)。 + **优先** → 缺行回退 `core_product`(T-1 补列:share_digits DEFAULT 2 / + rounding_mode DEFAULT 'half_up')→ 再缺省回退 + `(PLACES, 'half_up')`(PRD §2.5 全局 2 位四舍五入)。 + 位数与模式**逐层独立回退**(如规则行只配位数、模式仍可落 product 列)。 - 消费方两处(T-14 接入):确认段转入份额(business_type='convert')、 - 网关申购折算份额(business_type='subscribe');redeem 为份额申报 - (D26),份额来自客户不折算,无消费方。 + 消费方两处(T-14 接入位数 / 开口②闭环接入模式):确认段转入份额 + (business_type='convert')、网关申购折算份额(business_type='subscribe'); + redeem 为份额申报(D26),份额来自客户不折算,无消费方。 + **mode 合法性在本单点校验**(DDL 为 VARCHAR 无 ENUM):仅 + `half_up` / `truncate`(PRD §2.5.3),错配在读取时即报错,不留到算份额。 """ + digits: int | None = None + mode: str | None = None rule = self.get_share_rule(product_id, business_type) - if rule and rule.get("share_digits") is not None: - return int(rule["share_digits"]) + if rule is not None: + if rule.get("share_digits") is not None: + digits = int(rule["share_digits"]) + if rule.get("rounding_mode"): + mode = str(rule["rounding_mode"]).strip() product = self.get_product(product_id) - if product and product.get("share_digits") is not None: - return int(product["share_digits"]) - return PLACES + if product is not None: + if digits is None and product.get("share_digits") is not None: + digits = int(product["share_digits"]) + if mode is None and product.get("rounding_mode"): + mode = str(product["rounding_mode"]).strip() + if digits is None: + digits = PLACES + if mode is None: + mode = "half_up" + if mode not in ("half_up", "truncate"): + raise ValueError( + f"产品 {product_id} 舍入模式非法:{mode!r}(合法:half_up / truncate)" + ) + return digits, mode + + def get_share_digits(self, product_id: str, business_type: str) -> int: + """产品份额位数(D27 / R-13)—— `get_share_rounding` 的位数侧薄包装。 + + 保留原签名:T-14 既有测试与读路径(如展示位数)零改动。 + """ + return self.get_share_rounding(product_id, business_type)[0] def list_active_customers(self) -> list[dict[str, Any]]: """全量在册客户(id + display_name;AML scan_all 全量扫描用,仅 SELECT)。""" diff --git a/app/service/convert/calc.py b/app/service/convert/calc.py index 765ae14..fab6773 100644 --- a/app/service/convert/calc.py +++ b/app/service/convert/calc.py @@ -19,7 +19,7 @@ from __future__ import annotations from datetime import date, datetime -from decimal import ROUND_HALF_UP, Decimal +from decimal import ROUND_DOWN, ROUND_HALF_UP, Decimal from typing import Iterable from app.service.convert.errors import InsufficientShares, TooManyLots @@ -48,22 +48,33 @@ def round2(value: Decimal) -> Decimal: return to_decimal(value).quantize(_CENT, rounding=ROUND_HALF_UP) -def product_round(value: Decimal, digits: int) -> Decimal: - """产品级份额舍入(D27 / R-13):量化到 `digits` 位,显式 HALF_UP。 +#: D27 产品级舍入模式 → Decimal rounding 映射(PRD §2.5.3)。 +#: half_up = 四舍五入(主流);truncate = 舍去法 ROUND_DOWN(南方利众/宝元、 +#: 邮储、华宝、申万菱信、东方基金等合同/公告明写「舍去」,2026-09-12 联网核实)。 +_ROUND_MODES = {"half_up": ROUND_HALF_UP, "truncate": ROUND_DOWN} - **T-14 已接入主链路**(确认段转入份额 / 网关申购折算份额)——位数由 - 调用方经 `core_ro.get_share_digits` 查回退链取得(规则行 → - `core_product.share_digits` → 默认 2),本函数只做纯量化、不查库。 - 默认 `digits=2` 与 `PLACES` 一致,此时与 `round2` 逐字节一致。 + +def product_round(value: Decimal, digits: int, mode: str = "half_up") -> Decimal: + """产品级份额舍入(D27 / R-13):量化到 `digits` 位,模式按产品配置。 + + **T-14 已接入主链路**(确认段转入份额 / 网关申购折算份额)——位数与舍入 + 模式由调用方经 `core_ro.get_share_rounding` 查回退链取得(规则行 → + `core_product` → 默认),本函数只做纯量化、不查库。 + 默认 `digits=2` + `mode="half_up"`,与 `round2` 逐字节一致。 ⚠️ 联网核实(2026-09-12):公募场外份额位数主流 2 位、按各基金合同配置 (南方 2012 转换调整公告为行业依据);`digits=0/4` 等**非 2 位值是本函数 的机制能力**,供测试自建种子与未来真实差异产品接入,真库种子(11-seed) 全部 2 位——v1.0 种子「指数基金 4 位」的注释依据已证伪并纠偏。 + **T-14 开口②闭环(2026-09-12 用户拍板补接)**:`mode` 支持 `"truncate"` + (舍去法,出处见 `_ROUND_MODES`);非法 mode 直接 ValueError。 """ if not isinstance(digits, int) or digits < 0: raise ValueError(f"share 位数必须为非负整数,实际 {digits!r}") + rounding = _ROUND_MODES.get(mode) + if rounding is None: + raise ValueError(f"未知舍入模式 {mode!r}(合法:half_up / truncate)") unit = Decimal(1).scaleb(-digits) - return to_decimal(value).quantize(unit, rounding=ROUND_HALF_UP) + return to_decimal(value).quantize(unit, rounding=rounding) def redeem_amount(qty: Decimal, nav: Decimal, fee: Decimal) -> Decimal: @@ -111,18 +122,21 @@ 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, digits: int = PLACES) -> Decimal: +def in_qty( + in_amount: Decimal, in_nav: Decimal, digits: int = PLACES, mode: str = "half_up" +) -> Decimal: """转入份额 = 净转入金额 ÷ 转入基金净值(HALF_UP · v1.0 勘误)。 ⚠️ 份额**不是** 4 位向下取整——那是 v0.2 的残留、与 §1 原则 10 直接矛盾, PRD v0.8 / 架构 v1.0 已统一为四舍五入,尾差"在基金资产列支"。 - 全局默认 2 位(`PLACES`);**T-14(D27)起确认段按产品传 `digits`** - (来源 `core_ro.get_share_digits`,缺省 2 = 原行为,零回归)。 + 全局默认 2 位(`PLACES`);**T-14(D27)起确认段按产品传 `digits`**, + **T-14 开口②闭环(2026-09-12)起按产品传 `mode`**(来源 + `core_ro.get_share_rounding`,缺省 2 位 half_up = 原行为,零回归)。 """ nav = to_decimal(in_nav) if nav <= 0: raise ValueError(f"转入净值必须大于 0,实际 {nav}") - return product_round(to_decimal(in_amount) / nav, digits) + return product_round(to_decimal(in_amount) / nav, digits, mode) def rounding_diff( diff --git a/app/service/convert/confirm_service.py b/app/service/convert/confirm_service.py index e249555..0a70b35 100644 --- a/app/service/convert/confirm_service.py +++ b/app/service/convert/confirm_service.py @@ -334,11 +334,12 @@ 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 - # ── 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) + # ── D27 产品级舍入(T-14 / 开口②闭环 T-14b):转入份额位数与舍入模式按 + # **转入产品**的转换规则取(回退链单点 `get_share_rounding`:规则行 → + # core_product → 默认 2 位 half_up)。2 位 half_up 产品与原 round2 逐字节 + # 一致;4 位产品(指数/货基)与 truncate 型(舍去法)在此分化。 + digits, round_mode = core.get_share_rounding(to_pid, _RULE_BT_CONVERT) + in_shares = in_qty(in_amount, in_nav, digits, round_mode) tail = rounding_diff(in_amount, in_nav, in_shares) # 受理-确认两段的决策汇总:`forced_full_transfer` **继承受理段**(确认段不 diff --git a/docs/PRD/PRD-基金转换交易.md b/docs/PRD/PRD-基金转换交易.md index e272fc6..392fccb 100644 --- a/docs/PRD/PRD-基金转换交易.md +++ b/docs/PRD/PRD-基金转换交易.md @@ -1,6 +1,7 @@ # PRD · 基金转换(convert)交易 > 版本:**v1.1(精简最终态 · T+1 受理/确认分离模型)** · 2026-09-11 +> 修订:§2.5.3 舍入口径按联网核实修订(2026-09-12 用户拍板)——删「易方达 ETF 场外取整数位」(无证据且有反证,真实整数位载体为场内),改记「2 位为主流 + 舍去型真实存在」 > 分支:`risk-control-agent` > 阶段:第 2 步产出(v1.1 精简重写已通过独立 AI 联网审查,**已定稿**(2026-09-11 用户批准)) > 关联:`docs/项目框架设计/架构设计-基金转换交易.md`(v2.1) @@ -136,9 +137,9 @@ #### 2.5.3 舍入差异按产品配置(D27) -真实业务**不统一**:主流转入份额 2 位四舍五入;**易方达 ETF 场外**取整数位;**南方基金**两位后截断(非四舍五入)。本项目按产品配置: +真实业务**不统一**(2026-09-12 联网核实修订,用户拍板):主流转入份额 **2 位四舍五入**(各基金合同普遍约定,易方达/汇添富/华夏 ETF 联接合同均 2 位);**舍去(TRUNCATE)型真实存在**(南方利众/宝元、邮储、华宝、申万菱信、东方基金等,合同/公告明写「舍去法」);**「取整数位」仅见于场内份额**(交易所 100 份/手约束,LOF/ETF 场内先 2 位再截位整数,招商瑞智 LOF 合同)——**场外无取整数位依据**(原「易方达 ETF 场外取整数位」无证据且有反证,已删)。本项目按产品配置: -- 新增 `core_share_rule(product_id + business_type)` 表(`11-seed-share-rule.sql`,**逐产品列 `(share_digits, rounding_mode)` 组合**,如南方=2 位+TRUNCATE、易方达 ETF 场外=整数位、东方赎回费=TRUNCATE,禁错配) +- 新增 `core_share_rule(product_id + business_type)` 表(`11-seed-share-rule.sql`,**逐产品列 `(share_digits, rounding_mode)` 组合**,如南方=2 位+TRUNCATE、东方=TRUNCATE,禁错配) - `rounding_mode ∈ {HALF_UP, TRUNCATE(=ROUND_DOWN)}`(东方/南方截断=ROUND_DOWN,须与种子一致) - calc 按产品取真实规则;**老产品缺规则时回退主流 2 位 HALF_UP**(显式标注) diff --git a/docs/memory/2026-09-12.md b/docs/memory/2026-09-12.md index 5e845e7..f0dc551 100644 --- a/docs/memory/2026-09-12.md +++ b/docs/memory/2026-09-12.md @@ -490,3 +490,49 @@ cancelled)是幂等 skip**(批处理单笔容错语义),`ConfirmConflict **T-1~T-17 全部完成,转换线收官**。剩余:全套 9 个真库脚本复跑 + 三任务 (T-15/T-16/T-17)提交 + 交接文档/TODO/MEMORY 终态更新;之后按开发计划第 6 步 做集成测试。 + +--- + +# T-14/T-17 开口闭环(2026-09-12 用户三项拍板)+ truncate 舍去法接入 + +## 一、三项拍板与执行 + +| # | 事项 | 拍板 | 执行 | +|---|---|---|---| +| 1 | PRD §2.5.3「易方达 ETF 场外取整数位」 | **改** | 已按核实结论修订(该句无证据且有反证,真实整数位载体为场内渠道),版本块加修订注记 | +| 2 | `rounding_mode='truncate'` 消费接入 | **补接** | 当轮完成(见下) | +| 3 | T-17 R-12 口径裁定(NULL 不参与判定 / 同类走一般规则) | **认可** | 开发计划 R-12 条目回写定稿 + T-17 执行记录同步 | + +**审核教训留痕**:PRD v1.1 经独立 AI 三轮审查(0 阻断级)但「易方达 ETF 场外 +取整数位」无出处一句未被任何一轮抓出——逻辑矛盾审查抓不住**外部事实断言**, +只能靠执行期联网核实兜底。「外部事实必须联网核实」铁律再添一例证。 + +## 二、truncate 接入(生产代码 3 处 + 单点扩展) + +- **`core_ro.get_share_rounding`**:位数+模式回退链**单点**(规则行 → + core_product → 默认 `(2, 'half_up')`),逐层独立回退、mode 合法性读取即 + 校验(`half_up`/`truncate` 白名单);`get_share_digits` 转薄包装(T-14 + 既有调用与测试零破坏)。 +- **`calc.product_round` / `in_qty`**:加 `mode` 参数(`_ROUND_MODES`: + half_up → ROUND_HALF_UP / truncate → ROUND_DOWN),非法 mode ValueError。 +- **确认段(confirm_service)与网关申购折算(trade_gateway)**:两处透传。 +- **载体**:09-seed-org.sql **⑦ 段** `PROD-005828.rounding_mode='truncate'` + (11-seed 规则行未覆盖 → 走 product 层回退;无任何转换场景引用 → 全套 + 零回归)。11-seed 头注「登记开口」同步改「已接入」。 + +## 三、验证(全量 849 passed / 8 skipped · 真库 9/9) + +- **测试 +7(842 → 849)**:calc 4(truncate/half_up 分化 34945.53 vs .54、 + 整数边界向零、非法 mode、in_qty 透传 16.66 vs 16.67)+ 确认段 2(链路 + truncate 舍去生效;回退链逐层独立 + 规则行整体优先遮蔽 product 层错配)+ + 申购段 1(对称,166.66 vs 166.67)。首跑踩坑:非法值断言放在规则行存在 + 之后 → DID NOT RAISE(规则行遮蔽 product 层)——即遮蔽语义本身,改用例 + 先删规则行。 +- **突变**:两调用点同拆 mode 透传 → 2 链路用例红;还原绿,MUTATION 零残留。 +- **真库 9/9 退出码 0**:09 ⑦ 段幂等灌库后 `verify_convert_seed` 新增 ⑫ + 断言(载体在位)通过;其余 8 脚本零回归。 + +## 四、下一步 + +**开口清零**。转换线(T-1~T-17 + 开口闭环)全部完成;下一步 = 开发计划 +**第 6 步集成测试**。 diff --git a/docs/memory/MEMORY.md b/docs/memory/MEMORY.md index 0434d9a..df87b9d 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 开发已收官(2026-09-12,T-1~T-17 全部完成,**842 passed / 8 skipped** 零失败;真库 verify 套件 **9/9 退出码 0**:accept 43 / confirm 103 / engine 39 / api 89 / compensate 56 / apply 30 / lots 20 / seed 全 PASS / tools 14。T-15 = D28 部分成交专项(终态单幂等 skipped 口径 + 防御纵深三道防线实证 + Barrier 窄窗争批);T-16 = D29 资金流中转(core_cash_flow 全仓唯一写入点,out/redeem + in/subscribe 各 1 条 remark 带 gid 同事务,5 处清理段 FK 坑修复);T-17 = D30 A/C 互转(share_class 非空且不同才查 allow_ac_convert 开关,任一端=1 两向放行,联网核实 = 摩根 2025-11「开通」公告;口径裁定:NULL 不参与判定、同类走一般规则,待用户追认),下一步 = 第 6 步集成测试)**。**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 开发已收官(2026-09-12,T-1~T-17 全部完成 + 三项开口闭环【PRD §2.5.3 修订 / truncate 已接入 / R-12 定稿】,**849 passed / 8 skipped** 零失败;真库 verify 套件 **9/9 退出码 0**:accept 43 / confirm 103 / engine 39 / api 89 / compensate 56 / apply 30 / lots 20 / seed 全 PASS / tools 14。T-15 = D28 部分成交专项(终态单幂等 skipped 口径 + 防御纵深三道防线实证 + Barrier 窄窗争批);T-16 = D29 资金流中转(core_cash_flow 全仓唯一写入点,out/redeem + in/subscribe 各 1 条 remark 带 gid 同事务,5 处清理段 FK 坑修复);T-17 = D30 A/C 互转(share_class 非空且不同才查 allow_ac_convert 开关,任一端=1 两向放行,联网核实 = 摩根 2025-11「开通」公告;口径裁定:NULL 不参与判定、同类走一般规则,已追认定稿),下一步 = 第 6 步集成测试)**。**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 交易日)。 **仓库地图:** | 路径 | 状态 | 说明 | diff --git a/docs/memory/TODO.md b/docs/memory/TODO.md index 5e255f2..dbab5a3 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-12):T-1 ~ T-17 全部完成(全量 pytest 842 passed / 8 skipped 零失败;真库 verify 套件 9/9 退出码 0:accept 43 / confirm 103 / engine 39 / api 89 / compensate 56 / apply 30 / lots 20 / seed 全 PASS / tools 14,v1.0 链路 verify_convert_service 已随 convert_fund 退役删除),下一步 = 第 6 步集成测试**。撤单范围: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-12):T-1 ~ T-17 全部完成(全量 pytest 849 passed / 8 skipped 零失败(含 T-14 开口② truncate 接入 +7);真库 verify 套件 9/9 退出码 0:accept 43 / confirm 103 / engine 39 / api 89 / compensate 56 / apply 30 / lots 20 / seed 全 PASS / tools 14,v1.0 链路 verify_convert_service 已随 convert_fund 退役删除),下一步 = 第 6 步集成测试**。撤单范围:PRD §5.5 原表述与实现(仅 `accepted`)不矛盾、无需回填(第三轮 C1 订正)。 ### 基金转换线待办(推荐顺序) @@ -40,10 +40,10 @@ - [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。 -- [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 范围外)。 +- [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 项 → ✅ 2026-09-12 用户拍板全闭环**:① PRD §2.5.3 已修订(删易方达句);② truncate 已接入(`get_share_rounding` 单点 + `product_round`/`in_qty` mode 参数 + 09 ⑦ 段 PROD-005828 载体;测试 +7 → **849 passed / 8 skipped**,真库 9/9,突变红/还原绿)。 - [x] **T-15 D28 部分成交专项测试 + 边界补强 ✅(2026-09-12)**:sqlite +1 `test_confirm_after_cancel_conflicts`(**时序口径裁定**:`confirm_one` 对终态单(含 cancelled)= 幂等 `skipped` + current_status 回显,`ConfirmConflict` 仅限「读单后状态才变」窗口);真库 verify_confirm +3 组 19 项(E2 占用释放双向证明 / G2 `actual_qty=0` → `rejected` 零残留 / H2 撤单后确认)→ **102/102**;R-10 实测钉死 = 部分确认吃掉全部物理可用、「释放」的是受理占用。**CONVERT_STRESS 争批**:`Barrier(2)` 钉 `apply_convert` 事务门口构造「两单都完成 ④⑤ 读」窄窗,断言恰 1 confirmed + 1 rejected。**突变 = 防御纵深实证**:单独拆事务内哨兵不红(④⑤ 拦)、单独拆 ④ partial_qty 不红(⑤ plan_lots 拦)→ 窄窗 + 同拆哨兵 ×3 红、还原 ×3 绿——④可用量复核 / ⑤plan_lots / 事务内哨兵三道防线拆任何一道仍收敛 rejected。全量 **838 passed / 8 skipped**。 - [x] **T-16 D29 资金流中转建模 ✅(2026-09-12)**:`_apply_convert_once` 在 `_insert_trades` 后加 `_insert_cash_flows` —— **全仓唯一写入点**,2 条 `out/redeem`=out_amount(费前)+ `in/subscribe`=in_amount(净转入),remark=`convert:{gid}`、channel=online,与流水/明细/持仓/置态同事务(幂等由 T-7 哨兵保证);`tests/_ddl.py` 补 sqlite 表 + 门禁;`test_confirm_writes_no_cash_flow` **反转为** `test_confirm_writes_exactly_two_cash_flows`(T-7 登记「表建立后改 0 行断言」闭环);真库 apply **A2 组 5 项 + B 组冲突 0 残留 → 24 → 30 项**、confirm「不写资金流」断言反转 → **103 项**。**⚠️ FK 新坑(T-9 同款)**:`core_cash_flow.fk_cf_customer` 挡客户删除 → **5 处清理段补删**(verify_apply / test_convert_integration / verify_engine / verify_api / verify_compensate,remark LIKE 精准定位)。**突变**:拆写入调用 → 反转用例红,还原全绿。 -- [x] **T-17 D30 share_class + A/C 互转 ✅(2026-09-12 · 转换线收官)**:`_validate_products` ta_code 后**纯新增拦截分支**——两端 share_class 非空且不同 = A/C 场景,任一端 `allow_ac_convert=1` 两向放行、全关 → `CrossEntityNotSupported`(错误码沿用,message 指明 A/C)。**口径裁定 2 条(R-12 字面偏差,待用户追认)**:① NULL 不参与 A/C 判定(字面会打穿全部既有受理用例);② 「share_class 相同 → 400」不采纳(同类别跨基金走一般转换规则)。**联网核实**:A/C 互转需管理人「开通」(摩根行业睿选 2025-11 公告)= 开关真实载体;转换后持有期重新起算(本仓转入新批次一致零改动);转换费 = 赎回费 + 申购补差(本仓范围外)。**种子 ⑥ 段**(09-seed-org.sql:双开对/双关对照/同类对照,其余 NULL 零回归)。**测试 +4(838→842)**:双开/单开/双关 400/同类+NULL 回归保护;真库 accept **H 组 6 项 → 43/43**(H3 首跑撞种子 min_redeem_qty=100 教训:种子产品做载体须通读既有段);**突变**:分支恒 False → closed 红,还原绿。模拟库近似留痕:无 fund_code,判定 = 同管理人同 TA + share_class 非空且不同。 +- [x] **T-17 D30 share_class + A/C 互转 ✅(2026-09-12 · 转换线收官)**:`_validate_products` ta_code 后**纯新增拦截分支**——两端 share_class 非空且不同 = A/C 场景,任一端 `allow_ac_convert=1` 两向放行、全关 → `CrossEntityNotSupported`(错误码沿用,message 指明 A/C)。**口径裁定 2 条(R-12 字面偏差,✅ 2026-09-12 用户追认定稿,R-12 条目已回写)**:① NULL 不参与 A/C 判定(字面会打穿全部既有受理用例);② 「share_class 相同 → 400」不采纳(同类别跨基金走一般转换规则)。**联网核实**:A/C 互转需管理人「开通」(摩根行业睿选 2025-11 公告)= 开关真实载体;转换后持有期重新起算(本仓转入新批次一致零改动);转换费 = 赎回费 + 申购补差(本仓范围外)。**种子 ⑥ 段**(09-seed-org.sql:双开对/双关对照/同类对照,其余 NULL 零回归)。**测试 +4(838→842)**:双开/单开/双关 400/同类+NULL 回归保护;真库 accept **H 组 6 项 → 43/43**(H3 首跑撞种子 min_redeem_qty=100 教训:种子产品做载体须通读既有段);**突变**:分支恒 False → closed 红,还原绿。模拟库近似留痕:无 fund_code,判定 = 同管理人同 TA + share_class 非空且不同。 > **第 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 26c60b7..a8fdb5a 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 基线 **842 passed / 8 skipped**(T-17 后;**T-0~T-17 全部完成**,v1.0 `convert_fund` 已退役)· push 由用户定 +> **代码基线**:分支 `risk-control-agent` · pytest 基线 **849 passed / 8 skipped**(T-14 开口②闭环后;T-0~T-17 全部完成,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) > @@ -220,7 +220,7 @@ | R-9 | 撤单窗口判定(FR-C22) | 仅 `status='accepted'` 且**业务日 = 受理日**且提交时刻 ≤ 截点(`settings.convert_cutoff_time`,B1)可撤;否则 409 `CANCEL_NOT_ALLOWED`;撤单事务:条件 UPDATE `accepted→cancelled` + 占用自然释放(R-3 推导) | PRD §5.5;nav_pending 在 T+1 才出现,撤单窗口 T 日 15:00 前已过,不可撤(§1.3 裁定 9) | 允许 nav_pending 撤(业务上窗口已过,驳回) | | R-10 | 部分成交(D28)怎么落库 | `core_convert_request` 加 `actual_qty DECIMAL(18,4) NULL`;确认时 `available = min(申请 qty, 当前可用)`;`actual_qty < qty` → 落 `actual_qty`、状态仍 `confirmed`、`remark='partial'`;未确认部分占用随终态自动释放 | 状态机不加 partial 态(6 态权威);记 actual_qty 差异,响应/查询可展示 | 加 `partial` 第七态(复杂化,驳回;PRD 六态) | | R-11 | D29 资金流中转怎么建模 | 确认事务内写 **2 条** `core_cash_flow`:转出产品 `flow_subtype='redeem'`(out)+ 转入产品 `flow_subtype='subscribe'`(in),`remark` 带 `convert_group_id`;channel/金额对齐两条 core_trade;幂等:与流水同事务天然保证 | D29 注册登记账户中转 = 转出方出账 + 转入方入账;同事务防半写 | 单条汇总(丢失中转语义,驳回) | -| R-12 | D30 A/C 互转校验规则 | 放行条件:两端 `fund_company` 相同 + `ta_code` 相同 + `allow_ac_convert=1`(任一为 1 即两向放行)+ 产品不同但 `share_class` 不同;不满足 → 400 `CROSS_ENTITY_NOT_SUPPORTED` 语义沿用(换提示) | A/C 互转 = 同管理人同 TA 不同份额类别;D30 定义 | 放宽同产品(违反转换定义,驳回) | +| R-12 | D30 A/C 互转校验规则 | **(2026-09-12 实现后追认定稿)**两端 `share_class` **非空且不同** = A/C 场景,才查 `allow_ac_convert`(任一端 =1 即两向放行,全关 → 400 `CROSS_ENTITY_NOT_SUPPORTED` 语义沿用换提示);`share_class` **NULL 不参与 A/C 判定**(走一般转换规则);**同类别(如 A→A)不 400**,同走一般规则。前置仍为 `fund_company` 同 + `ta_code` 同 + 产品不同 | 原字面「不满足→400」会让 share_class NULL 参与比较打穿全部既有受理用例(种子全 NULL);「share_class 相同→400」在真实业务无依据(同管理人同类别跨基金互转 = 普通转换,市面平台无禁转做法)。两处字面偏差经 T-17 实现裁定、**用户 2026-09-12 追认** | 放宽同产品(违反转换定义,驳回) | | R-13 | D27 产品舍入接入点 | `core_share_rule(product_id, business_type)` 给 `share_digits`(默认 2);`calc.round2` **不动**,新增 `product_round(value, product_id)`:查规则取位数 → quantize;接入点 = `in_qty`(转入份额)与普通申赎 qty 落库处;**T-14 才切换**(T-2 只建入口不接) | 避免 T-2 动全局行为导致回归爆炸;T-14 一次性切换 + 全量回归 | T-2 直接接管 round2(回归爆炸,驳回) | | R-14 | 补偿脚本改写后「重跑确认」语义 | `cleanup_pending_convert.py` 改为扫 `core_convert_request`:`accepted/nav_pending` 且受理日 < 当前业务日 − **2 个交易日** → `expired`(标记不硬删,S2 沿用);`rebuild_alerts.py` 继续仅凭 Core 侧数据(含 core_convert_lot_detail.nav/nav_date)补 agent 进度/审计 | Q14 拍板 SLA=2 交易日;验收 17 | 沿用 SLA 24h 自然小时(Q14 推翻,弃) | | R-15 | `convert_confirm_offset_days` 处置 | **废弃**(保留字段+COMMENT 标记 deprecated,不删防破坏既有 env);T-7 全程改用交易日历;**同法处置新增 `convert_cutoff_time`(默认 "15:00")与 `convert_confirm_sla_days=2`** | 自然日近似作废(Q11);cutoff 硬化(B1);不删字段保测试环境兼容 | 直接删除(破坏既有 env 引用,驳回) | @@ -786,6 +786,23 @@ engine 39 / api 89 / compensate 56 / apply 24 / lots 20 / tools 14 —— 2 位 修订;② `rounding_mode='truncate'` 消费接入(真实存在但计划范围外,接入 = product_round 加 mode 参数 + 回退链透传 + 种子配 truncate 产品)。 +**✅ 开口闭环(2026-09-12 用户三项拍板)**:① **PRD §2.5.3 已按核实结论修订** +(删「易方达 ETF 场外取整数位」——无证据且有反证,真实整数位载体为场内; +改记「2 位为主流 + 舍去型真实存在」,PRD 版本块加修订注记)。② **truncate +消费接入已补做**(独立 AI 三轮审核未抓出 PRD 该句无出处,靠执行期联网核实 +兜底——外部事实断言必须联网核实的铁律再添一例):`core_ro.get_share_rounding` += 位数+模式回退链单点(规则行 → core_product → 默认 `(2,'half_up')`,mode +合法性读取即校验,get_share_digits 转薄包装零破坏);`calc.product_round` / +`in_qty` 加 `mode` 参数(`_ROUND_MODES = {half_up, truncate→ROUND_DOWN}`); +确认段与网关申购折算两处透传。**载体 09-seed-org.sql ⑦ 段 PROD-005828** +(11-seed 未覆盖 → 走 product 层回退,零转换场景引用 → 全套零回归)。**测试 ++7(842 → 849)**:calc 4(truncate 分化 / 非法 mode / in_qty 透传 / 默认一致 +锚点保持)+ 确认段 2(链路 truncate 34945.53 vs half_up 34945.54 天然分化; +回退链逐层独立 + 规则行遮蔽语义 + 非法值)+ 申购段 1(对称)。**突变**: +两调用点同拆 mode 透传 → 2 链路用例红;还原绿。真库 **9/9 退出码 0** +(seed 新增 ⑫ 断言:⑦ 段载体在位)。③ **R-12 口径裁定用户追认**,条目已 +回写定稿(NULL 不参与 A/C 判定 / 同类走一般规则),T-17 执行记录同步。 + --- ### T-15 · D28 部分成交专项测试 + 边界补强 @@ -897,7 +914,7 @@ core+confirm 37 全绿,MUTATION 零残留。 **回滚点**:校验是前置分支,git 回退即还原 **✅ T-17 完成(D30 share_class + A/C 互转 · 2026-09-12 · 转换线收官)**: -**① 口径裁定(R-12 字面两处偏差,取业务正确口径,登记待用户追认)**: +**① 口径裁定(R-12 字面两处偏差,取业务正确口径,✅ 用户 2026-09-12 已追认定稿,R-12 条目已回写)**: (a) `share_class` **NULL 不参与 A/C 判定**——字面「不满足 → 400」会打穿全部既有 受理用例(种子产品 share_class 全 NULL);实现 = 两端非空且不同才触发 A/C 分支。(b) 字面「share_class 相同 → 400」**不采纳**:非空同类别跨基金互转走 diff --git a/scripts/core/09-seed-org.sql b/scripts/core/09-seed-org.sql index b9b53c1..d71dcc7 100644 --- a/scripts/core/09-seed-org.sql +++ b/scripts/core/09-seed-org.sql @@ -99,3 +99,9 @@ UPDATE core_product SET share_class = 'A', allow_ac_convert = 0 WHERE product_id UPDATE core_product SET share_class = 'C', allow_ac_convert = 0 WHERE product_id = 'PROD-003095'; -- 同类别对照:'C' ↔ 'C' 不触发 A/C 判定,走一般转换规则放行(allow_ac_convert 保持默认 0) UPDATE core_product SET share_class = 'C' WHERE product_id = 'PROD-510300'; + +-- ---- ⑦ T-14 开口②闭环(2026-09-12 用户拍板补接):舍去法(TRUNCATE)产品载体 ---- +-- 联网核实:「舍去」型真实存在(南方利众/宝元、邮储、华宝、申万菱信、东方基金等 +-- 合同/公告明写「份额 2 位后舍去」)。载体选 PROD-005828(稳健增利混合): +-- 无规则行(11-seed 未覆盖)→ 走 core_product 层回退链,且无任何转换场景引用(零回归)。 +UPDATE core_product SET rounding_mode = 'truncate' WHERE product_id = 'PROD-005828'; diff --git a/scripts/core/11-seed-share-rule.sql b/scripts/core/11-seed-share-rule.sql index 40f62d9..4282cf8 100644 --- a/scripts/core/11-seed-share-rule.sql +++ b/scripts/core/11-seed-share-rule.sql @@ -25,9 +25,11 @@ USE jinrong_core; -- - 南方利众混合 C 转换公告:份额保留 2 位、两位以后的部分**舍去**归基金资产 -- - 邮储银行《基金转换常见问题》:转换份额 2 位后舍去 -- - 华宝未来主导混合 / 申万菱信业务规则 / 东方基金登记业务规则:同类「舍去」条款 --- ⚠️ 本项目 T-14 只接入 share_digits;rounding_mode='truncate' 的消费接入为 --- 登记开口(真实差异载体,待用户裁定后排期),故本种子暂不配置 truncate 行, --- 避免出现「种子有值、实现不消费」的半成品。 +-- ✅ 消费接入已闭环(2026-09-12 用户拍板补接):`core_ro.get_share_rounding` +-- 返回 (digits, mode) 单点,calc.product_round 消费 mode(half_up/truncate)。 +-- 舍去型载体配在 core_product 层(09-seed-org.sql ⑦ 段 PROD-005828), +-- 本文件(规则行层)仍全 half_up —— 恰好验证「位数取规则行、模式落 product」 +-- 的逐层独立回退。 -- 4. 整数位的真实载体是**场内渠道**(LOF/ETF 场内申购先 2 位再截位到整数份, -- 如招商瑞智优选 LOF 基金合同),不是产品固有属性;本项目无场内/场外渠道 -- 维度,不做该建模。 diff --git a/scripts/dev/verify_convert_seed.py b/scripts/dev/verify_convert_seed.py index 2427c2a..fff190b 100644 --- a/scripts/dev/verify_convert_seed.py +++ b/scripts/dev/verify_convert_seed.py @@ -212,6 +212,17 @@ def run_assertions() -> bool: print(" ✗ core_share_rule 种子不足") ok &= p10 + # ---- T-14 开口②闭环:09-seed-org.sql ⑦ 段 truncate 载体在位 ---- + cur.execute( + "SELECT rounding_mode FROM core_product WHERE product_id = 'PROD-005828'" + ) + tr_row = cur.fetchone() + p12 = bool(tr_row) and tr_row[0] == "truncate" + print(f"⑫ 舍去法载体:PROD-005828.rounding_mode = {tr_row[0] if tr_row else None}(期望 truncate)") + if not p12: + print(" ✗ ⑦ 段未生效(重灌 scripts/core/09-seed-org.sql)") + ok &= p12 + # ---- DoD ⑪ core_convert_request 建表即含 6 态 ENUM(T-1 · 免二期 ALTER)---- cur.execute( "SELECT COLUMN_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() " diff --git a/tests/test_convert_calc.py b/tests/test_convert_calc.py index bd5c344..39317b1 100644 --- a/tests/test_convert_calc.py +++ b/tests/test_convert_calc.py @@ -123,6 +123,16 @@ class TestRounding: # 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_truncate_mode_passthrough(self): + # T-14 开口②闭环(2026-09-12 用户拍板):mode 透传 —— 舍去法 vs 四舍五入 + # 100 / 6 = 16.6666… → half_up 16.67 / truncate 16.66(分化即证透传生效) + assert calc.in_qty(Decimal("100.00"), Decimal("6"), 2, "truncate") == Decimal("16.66") + assert calc.in_qty(Decimal("100.00"), Decimal("6"), 2, "half_up") == Decimal("16.67") + + def test_in_qty_unknown_mode_raises(self): + with pytest.raises(ValueError, match="未知舍入模式"): + calc.in_qty(Decimal("100"), Decimal("6"), 2, "round_up") + 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")): @@ -161,6 +171,21 @@ class TestProductRound: with pytest.raises(ValueError): calc.product_round(Decimal("1.0"), -1) + def test_truncate_mode_rounds_down(self): + """T-14 开口②闭环:truncate(舍去法 ROUND_DOWN)与 half_up 分化。 + + 舍去型真实出处(2026-09-12 联网核实):南方利众/宝元、邮储、华宝、 + 申万菱信、东方基金等合同/公告明写「份额 2 位后舍去」(PRD §2.5.3)。 + """ + assert calc.product_round(Decimal("34945.5351"), 2, "truncate") == Decimal("34945.53") + assert calc.product_round(Decimal("34945.5351"), 2, "half_up") == Decimal("34945.54") + # 舍去永远向零:整数边界不进位 + assert calc.product_round(Decimal("0.999"), 2, "truncate") == Decimal("0.99") + + def test_unknown_mode_rejected(self): + with pytest.raises(ValueError, match="未知舍入模式"): + calc.product_round(Decimal("1.0"), 2, "bankers") + class TestRedeemAmount: """redeem_amount:份额申报 → 赎回金额(D26 · FR-C29)。""" diff --git a/tests/test_convert_confirm.py b/tests/test_convert_confirm.py index 5763da1..4573125 100644 --- a/tests/test_convert_confirm.py +++ b/tests/test_convert_confirm.py @@ -393,6 +393,75 @@ def test_confirm_in_qty_missing_rule_falls_back_default_two_places(sqlite_engine assert _in_trade_qty(sqlite_engine, gid) == Decimal("34945.54") +def test_confirm_in_qty_truncate_mode_from_product(sqlite_engine): + """T-14 开口②闭环(2026-09-12 用户拍板补接):产品层 `rounding_mode='truncate'` + (无规则行)→ 确认段转入份额走**舍去法**。 + + 基准值 34945.5351 天然分化:half_up = 34945.54(PRD §5.3.2 锚点), + truncate = 34945.53 —— 错取 half_up 即红。 + 舍去型真实出处:南方利众/宝元、邮储、华宝、申万菱信、东方基金等 + (联网核实 2026-09-12,PRD §2.5.3);真库载体 = 09-seed-org.sql ⑦ 段 + PROD-005828(规则行未覆盖 → 恰好走 core_product 层回退)。 + """ + _seed(sqlite_engine) + _exec( + sqlite_engine, + "UPDATE core_product SET rounding_mode = 'truncate' WHERE product_id = :p", + p=PROD_IN, + ) + gid = _accept(sqlite_engine, cid="CC-REQ-D27T")["convert_group_id"] + + res = _confirm(sqlite_engine, gid) + + assert res["status"] == "confirmed" + assert _in_trade_qty(sqlite_engine, gid) == Decimal("34945.53") + # 转入批次同值(库内 DECIMAL(18,4) 存量化结果) + assert _qty(sqlite_engine, f"LOT-{gid}-IN") == Decimal("34945.53") + + +def test_get_share_rounding_fallback_chain_digits_and_mode(sqlite_engine): + """回退链单点扩展(开口②):`get_share_rounding` 位数与模式**逐层独立回退**。 + + ① 全缺 → (2, 'half_up');② product 层配 mode(无规则行)→ (2, 'truncate'); + ③ 规则行存在 → digits+mode 同取规则行(规则行两列 NOT NULL,恒整体优先); + ④ product 层 mode 非法 → 读取即 ValueError(错配不留到算份额)。 + """ + _seed(sqlite_engine) + ro = CoreReadOnlyRepository(engine=sqlite_engine) + + assert ro.get_share_rounding(PROD_IN, "convert") == (2, "half_up") + + _exec( + sqlite_engine, + "UPDATE core_product SET rounding_mode = 'truncate' WHERE product_id = :p", + p=PROD_IN, + ) + assert ro.get_share_rounding(PROD_IN, "convert") == (2, "truncate") + + _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, + ) + assert ro.get_share_rounding(PROD_IN, "convert") == (4, "half_up") + + # ④ product 层 mode 非法 → 读取即 ValueError(先删规则行:规则行 mode + # 恒整体优先,其存在会遮蔽 product 层错配 —— 本断言同时钉住遮蔽语义) + _exec( + sqlite_engine, + "DELETE FROM core_share_rule WHERE product_id = :p AND business_type = 'convert'", + p=PROD_IN, + ) + _exec( + sqlite_engine, + "UPDATE core_product SET rounding_mode = 'bogus' WHERE product_id = :p", + p=PROD_IN, + ) + with pytest.raises(ValueError, match="舍入模式非法"): + ro.get_share_rounding(PROD_IN, "convert") + + # ── 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 6298ed0..5822547 100644 --- a/tests/test_share_lot.py +++ b/tests/test_share_lot.py @@ -511,6 +511,31 @@ def test_subscribe_qty_falls_back_to_product_share_digits(sqlite_engine): assert _dec(row["remain_qty"]) == Decimal("833.3333") +def test_subscribe_qty_truncate_mode_from_product(sqlite_engine): + """T-14 开口②闭环(2026-09-12 用户拍板):产品层 `rounding_mode='truncate'` + → 申购折算份额走**舍去法**(对称于确认段)。 + + 1000 / 6 = 166.6666… → half_up 166.67 / truncate 166.66(错取 half_up 即红)。 + 舍去型真实出处:南方利众/宝元、邮储、华宝、申万菱信、东方基金等 + (联网核实 2026-09-12,PRD §2.5.3)。 + """ + _seed_customer(sqlite_engine, CUSTOMER) + _seed_product(sqlite_engine, PRODUCT_A) + _seed_nav(sqlite_engine, PRODUCT_A, "6.0000") + with sqlite_engine.begin() as conn: + conn.execute( + text("UPDATE core_product SET rounding_mode = 'truncate' WHERE product_id = :pid"), + {"pid": PRODUCT_A}, + ) + + _maintain( + sqlite_engine, trade_id="TRD-T14-SUBTR", trade_type="subscribe", amount="1000" + ) + + row = _rows(sqlite_engine, "SELECT * FROM core_share_lot")[0] + assert _dec(row["remain_qty"]) == Decimal("166.66") + + # ── 7.2 真实路径:赎回 FIFO 扣减 ─────────────────────────────────────── def test_redeem_deducts_fifo_across_lots(sqlite_engine):