T-14/T-17 开口闭环:PRD §2.5.3 修订 + truncate 舍去法接入 + R-12 定稿(用户三项拍板 · 849 绿 / 真库 9/9)
① PRD §2.5.3 修订:删「易方达 ETF 场外取整数位」(无证据且有反证,真实整数位载体为场内),改记「2 位主流 + 舍去型真实存在」,版本块加注记 ② rounding_mode=truncate 消费接入(用户拍板补做): - core_ro.get_share_rounding = 位数+模式回退链单点(规则行 → core_product → 默认 2/half_up,mode 白名单读取即校验,get_share_digits 转薄包装) - calc.product_round/in_qty 加 mode 参数(truncate → ROUND_DOWN) - 确认段 + 申购折算两处透传;载体 09-seed-org.sql ⑦ 段 PROD-005828(product 层回退,零转换场景引用) - 测试 +7(842→849):calc 4 + 确认段 2(truncate 34945.53 vs half_up .54 分化 + 回退链遮蔽语义)+ 申购段 1 - 突变:两调用点同拆 mode 透传 → 2 红,还原绿;真库 9/9(seed 新增 ⑫ 载体在位断言) ③ R-12 口径裁定追认定稿:条目回写开发计划(NULL 不参与 A/C 判定 / 同类走一般转换规则) 审核教训留痕:PRD 独立 AI 三轮审查未抓出外部事实断言无出处 —— 联网核实铁律再添例证 文档:开发计划 T-14 开口闭环段 + 基线 849 · 日志新节 · TODO/MEMORY/AGENTS/交接文档开口清零
This commit is contained in:
@@ -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**
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
+41
-12
@@ -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)。"""
|
||||
|
||||
+26
-12
@@ -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(
|
||||
|
||||
@@ -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` **继承受理段**(确认段不
|
||||
|
||||
@@ -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**(显式标注)
|
||||
|
||||
|
||||
@@ -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 步集成测试**。
|
||||
|
||||
@@ -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 交易日)。
|
||||
**仓库地图:**
|
||||
|
||||
| 路径 | 状态 | 说明 |
|
||||
|
||||
+3
-3
@@ -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「提升为公开函数」是两回事。
|
||||
|
||||
@@ -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」**不采纳**:非空同类别跨基金互转走
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 基金合同),不是产品固有属性;本项目无场内/场外渠道
|
||||
-- 维度,不做该建模。
|
||||
|
||||
@@ -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() "
|
||||
|
||||
@@ -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)。"""
|
||||
|
||||
@@ -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)。"""
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user