diff --git a/AGENTS.md b/AGENTS.md index 5ac91bb..60e262f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,8 @@ - 技术选型 / JWT:`docs/项目框架设计/技术选型和版本/` - Core 模拟底座:`docs/项目框架设计/Core模拟底座/` - 业务记忆分层:`docs/业务记忆管理/业务记忆管理手册.md` +- **基金转换线(第 5 步进行中 · T-0/T-0b/T-1/T-2/T-2b 已完成 · 下一步 T-3)**:开工入口 `docs/交接文档-基金转换.md` · `docs/PRD/PRD-基金转换交易.md`(v0.9.1)· `docs/项目框架设计/架构设计-基金转换交易.md`(v1.0)· `docs/项目框架设计/开发计划-基金转换交易.md`(v1.0 · T-0~T-13 逐任务改法 + DoD) +- **架构改进线(已交付 · 待收尾)**:开工入口 `docs/交接文档-架构改进.md` ## 代码入口 diff --git a/app/config/settings.py b/app/config/settings.py index afb4bc1..1bb97cd 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -17,6 +17,17 @@ class Settings(BaseSettings): mysql_user: str = "root" mysql_password: str = "" + # ===== DB 账号分离(D20 · 架构设计-基金转换交易 §11.1)===== + # 把「Core 只读」「审计不可删改」从代码约定升级为 DB 级强制。 + # 账号由 scripts/core/00-grant.sql 创建(管理员执行一次,不进 reset.ps1)。 + # 留空 = 回退 mysql_user,行为与单账号时代完全一致(渐进启用,不阻塞本地开发)。 + mysql_core_ro_user: str = "" + mysql_core_ro_password: str = "" + mysql_core_rw_user: str = "" + mysql_core_rw_password: str = "" + mysql_agent_user: str = "" + mysql_agent_password: str = "" + redis_url: str = "redis://127.0.0.1:6379/0" neo4j_uri: str = "bolt://localhost:7687" diff --git a/app/gateway/gateway_repository.py b/app/gateway/gateway_repository.py index 2343025..8b51464 100644 --- a/app/gateway/gateway_repository.py +++ b/app/gateway/gateway_repository.py @@ -18,10 +18,16 @@ from app.utils.db import get_engine class GatewayRepository: - """core_trade 唯一写入口;仅 INSERT,不改不删(模拟网关语义)。""" + """core_trade 唯一写入口;仅 INSERT,不改不删(模拟网关语义)。 + + D20(T-0b):绑定 `role="rw"`(账号 `xh_core_rw`,仅限 4 张表的 + SELECT/INSERT/UPDATE,**无 DELETE、无 DDL**)。注意 gateway 的**读**走 + `role="ro"`(`CoreReadOnlyRepository`),两个 engine 并存——这是「最小 + 权限」能否成立的关键边界(架构 §11.1)。 + """ def __init__(self, engine: Engine | None = None) -> None: - self._engine = engine or get_engine(settings.mysql_core_database) + self._engine = engine or get_engine(settings.mysql_core_database, "rw") def insert_trade( self, diff --git a/app/repository/core_ro.py b/app/repository/core_ro.py index 6b51928..d109a0d 100644 --- a/app/repository/core_ro.py +++ b/app/repository/core_ro.py @@ -49,10 +49,15 @@ def _is_expired(value: Any, today: date | None = None) -> bool: class CoreReadOnlyRepository: - """仅 SELECT jinrong_core;禁止写操作。""" + """仅 SELECT jinrong_core;禁止写操作。 + + D20(T-0b):绑定 `role="ro"`(账号 `xh_core_ro`,库级 SELECT), + 使「Core 只读」从代码约定升级为 **DB 级强制**——写语句在数据库层即被拒。 + 账号未配置时回退 `mysql_user`(单账号时代行为)。 + """ def __init__(self, engine: Engine | None = None) -> None: - self._engine = engine or get_engine(settings.mysql_core_database) + self._engine = engine or get_engine(settings.mysql_core_database, "ro") def get_customer_l0(self, customer_id: str) -> dict[str, Any] | None: """L0 客户主档 + 正式风评(对齐用户信息数据示例 / 适当性指南;main 扩列版)。""" diff --git a/app/repository/risk_repository.py b/app/repository/risk_repository.py index 397a108..ec67756 100644 --- a/app/repository/risk_repository.py +++ b/app/repository/risk_repository.py @@ -34,7 +34,7 @@ class RiskRepository: """风控产出表读写;不修改表结构(PRD 冻结约束)。""" def __init__(self, engine: Engine | None = None) -> None: - self._engine = engine or get_engine(settings.mysql_database) + self._engine = engine or get_engine(settings.mysql_database, "rw") # ---------- risk_alert ---------- diff --git a/app/repository/session_repository.py b/app/repository/session_repository.py index 8db1738..d3f6e5d 100644 --- a/app/repository/session_repository.py +++ b/app/repository/session_repository.py @@ -22,7 +22,7 @@ class SessionRepository: """agent_session / agent_message 读写;audit 类表不在本层(T-02 中间件)。""" def __init__(self, engine: Engine | None = None) -> None: - self._engine = engine or get_engine(settings.mysql_database) + self._engine = engine or get_engine(settings.mysql_database, "rw") # ---------- agent_session ---------- diff --git a/app/service/convert/__init__.py b/app/service/convert/__init__.py new file mode 100644 index 0000000..75d1b1b --- /dev/null +++ b/app/service/convert/__init__.py @@ -0,0 +1,12 @@ +"""基金转换(convert)纯函数包(架构设计-基金转换交易 §2/§7)。 + +**本包硬约束**:只做金额/费率/份额/日期口径的计算与规则判定, +**不查库、不碰 SQL、不读 settings、不写日志**——所有输入由调用方 +(`repository` 取数后)显式传入,输出为不可变 dataclass。 + +这样做的目的: +1. 金额链路最易错,纯函数才能**穷举单测**(D1); +2. `plan_lots()` 必须"先规划再判上限",不能在事务里半途失败(§8.3 `TOO_MANY_LOTS`); +3. `lot_bootstrap.bootstrap_lots()` 被 `trade_gateway` 兜底补建与 + `scripts/core/rebuild_lots.py` **同调**(D18),必须与 IO 解耦才能两侧一致。 +""" diff --git a/app/service/convert/calc.py b/app/service/convert/calc.py new file mode 100644 index 0000000..b0fced1 --- /dev/null +++ b/app/service/convert/calc.py @@ -0,0 +1,274 @@ +"""基金转换金额与份额口径(架构 §7 纯函数签名 · D1/D13)。 + +**本模块的每一条规则都对应一处已核验的真实业务口径**(不是"看起来合理"): + +| 规则 | 依据 | +| --- | --- | +| 金额/份额一律 **2 位 ROUND_HALF_UP** | 证监会公告〔2025〕22 号;PRD §2.5(v1.0 已废止"4 位向下取整"残留) | +| **逐批先舍入后求和** | PRD §12「舍入顺序」;否则跨批次与逐批计费结果不一致 | +| 补差费 **双口径**(B 默认 / A 保留) | D13;`convert_diff_fee_mode` 可切 | +| 持有期 **不含申请日**、分档**左闭右开** | PRD §12 I-2;满 7 日归 7–30 档 | +| FIFO 主序 `confirmed_at`、同行以 `lot_id` 兜底 | 评审 S1(否则同注册日多批次顺序不可复现) | +| 最低持有余额:**余额 < 阈值**才触发(非 ≤) | PRD §12 I-4;`≤` 会把"恰好等于下限"误判为强制全转 | + +⚠️ **精度陷阱**:`Decimal` 的**默认舍入是 `ROUND_HALF_EVEN`**(银行家舍入), +`Decimal("0.005").quantize(Decimal("0.01"))` 会得到 `0.00` 而非 `0.01`。 +本模块所有量化点**显式传 `rounding=ROUND_HALF_UP`**,不留默认值(架构风险 #3)。 +""" + +from __future__ import annotations + +from datetime import date, datetime +from decimal import ROUND_HALF_UP, Decimal +from typing import Iterable + +from app.service.convert.errors import InsufficientShares, TooManyLots +from app.service.convert.types import ( + DiffFeeMode, + Lot, + LotAllocation, + MinHoldAction, + PlanAction, + PlanResult, + to_date, + to_decimal, +) + +#: 金额与份额的量化位数(PRD §2.5:两者都是 2 位四舍五入)。 +PLACES = 2 +_CENT = Decimal(1).scaleb(-PLACES) +_ONE = Decimal("1") +_ZERO_2 = Decimal("0.00") +#: 理论份额与实得份额的差额(`rounding_diff`)保留 4 位——PRD §5.3 示例为 `-0.0026`。 +_TEN_THOUSANDTH = Decimal("0.0001") + + +def round2(value: Decimal) -> Decimal: + """2 位四舍五入(**显式 HALF_UP**,见模块 docstring 的精度陷阱)。""" + return to_decimal(value).quantize(_CENT, rounding=ROUND_HALF_UP) + + +# ── 逐批计算 ──────────────────────────────────────────────────────── +def lot_amount(qty: Decimal, nav: Decimal) -> Decimal: + """本批转出金额 = 份额 × 成交净值(2 位 HALF_UP)。""" + return round2(to_decimal(qty) * to_decimal(nav)) + + +def lot_fee(amount: Decimal, rate: Decimal) -> Decimal: + """本批赎回费 = 本批金额 × 适用费率(2 位 HALF_UP)。 + + 与 `lot_amount()` 配成一对,供 `plan_lots()` 的每个 `LotAllocation` 逐批调用—— + **逐批算完先舍入、再求和**,是 PRD §5.3 示例(154.50 + 309.00 = 463.50) + 与"整笔按加权费率算"结果不同的根源。 + """ + return round2(to_decimal(amount) * to_decimal(rate)) + + +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 勘误)。 + + ⚠️ 份额**不是** 4 位向下取整——那是 v0.2 的残留、与 §1 原则 10 直接矛盾, + PRD v0.8 / 架构 v1.0 已统一为 **2 位四舍五入**,尾差"在基金资产列支"。 + """ + nav = to_decimal(in_nav) + if nav <= 0: + raise ValueError(f"转入净值必须大于 0,实际 {nav}") + return round2(to_decimal(in_amount) / nav) + + +def rounding_diff( + in_amount: Decimal, in_nav: Decimal, actual_in_qty: Decimal, places: int = 4 +) -> Decimal: + """份额尾差 = 理论份额 − 实得份额(可正可负,负 = 客户多得)。 + + 理论份额按 `places`(默认 4)位 HALF_UP 量化后相减,与 PRD §5.3 + 的 `-0.0026` 口径一致。该值仅供前端展示与对账,**不参与任何后续计算**。 + """ + nav = to_decimal(in_nav) + if nav <= 0: + raise ValueError(f"转入净值必须大于 0,实际 {nav}") + unit = _TEN_THOUSANDTH if places == 4 else Decimal(1).scaleb(-places) + theoretical = (to_decimal(in_amount) / nav).quantize(unit, rounding=ROUND_HALF_UP) + return (theoretical - to_decimal(actual_in_qty)).quantize(unit, rounding=ROUND_HALF_UP) + + +# ── 补差费(D13 双口径)────────────────────────────────────────────── +def diff_fee( + conv_amount: Decimal, + out_rate: Decimal, + in_rate: Decimal, + mode: DiffFeeMode = "amount_diff", +) -> Decimal: + """补差费(申购费差额),**只在转入端费率高时收**,结果 2 位 HALF_UP。 + + - `mode="amount_diff"`(**默认 · 口径 B · 价外法两端差**): + 两端各按价外法算出申购费,**各自 2 位舍入后取差**,负值归零。 + 与 PRD §5.3 表格逐行一致(405.05 − 152.65 = 252.40)。 + - `mode="rate_diff"`(口径 A · 费率差法): + `conv × max(in−out, 0) / (1 + max(in−out, 0))`,同样 2 位 HALF_UP。 + + 两口径在同一输入下相差约 0.6%(252.40 vs 253.91),故**必须显式选口径**, + 不可混用(PRD §2.1.1 / Q9)。 + """ + conv = to_decimal(conv_amount) + out_r = to_decimal(out_rate) + in_r = to_decimal(in_rate) + + if mode == "amount_diff": + # 价外法(申购费 = 金额 × 费率 ÷ (1 + 费率))两端各算一次、各自舍入。 + in_fee = round2(conv * in_r / (_ONE + in_r)) + out_fee = round2(conv * out_r / (_ONE + out_r)) + delta = in_fee - out_fee + return delta if delta > 0 else _ZERO_2 + + if mode == "rate_diff": + gap = in_r - out_r + if gap <= 0: + return _ZERO_2 + return round2(conv * gap / (_ONE + gap)) + + raise ValueError(f"未知的补差费口径 mode={mode!r}(应为 amount_diff / rate_diff)") + + +# ── 持有期 ────────────────────────────────────────────────────────── +def hold_days(trade_date: date, confirmed_at: datetime) -> int: + """持有天数 =(交易日 − 确认日).days —— **自然日、不含申请日**(PRD §12 I-2)。 + + 模拟库无交易日历,故用自然日近似(真实为工作日);`convert_confirm_offset_days` + 让转换转入批次的 `confirmed_at = T+1`,因此同一交易日下该批次的 `hold_days` + 比"自 T 起算"**少 1 天**(费率档更严,PRD B-5)——这正是要贴近的真实口径。 + """ + d = to_date(trade_date) + c = to_date(confirmed_at) + if d is None or c is None: + raise ValueError("交易日与确认日都不能为 None") + return (d - c).days + + +# ── FIFO 分配与最低持有处置 ─────────────────────────────────────────── +def _fifo_order(lots: Iterable[Lot]) -> list[Lot]: + """FIFO 排序:`confirmed_at` 升序,同行以 `lot_id` 升序兜底(评审 S1)。 + + 调用方 SQL 已 `ORDER BY confirmed_at ASC, lot_id ASC`;此处**再排一次** + 是为了让本函数自身确定——纯函数不该依赖调用方是否记得写 ORDER BY + (否则同一注册日的多批次顺序由存储引擎决定,重跑结果漂移)。 + """ + return sorted(lots, key=lambda lot: (lot.confirmed_at, lot.lot_id)) + + +def plan_lots( + lots: list[Lot], + requested_qty: Decimal, + min_hold_qty: Decimal | None = None, + min_hold_action: MinHoldAction = "force_transfer", +) -> PlanResult: + """FIFO 分配 + 最低持有处置判定(架构 §7 · PRD §12 I-4)。 + + 判定顺序(**严格按 PRD 校验优先级 ①③**;② 最低转出份额由 service 层前置, + 因其需要"是否等于全部可转份额"的上下文,本函数不重复实现): + + 1. `Σ remain_qty >= 申请份额`?否则 `InsufficientShares`; + 2. 申请后**仍有剩余**且 `剩余 < min_hold_qty` → 强制处置: + `actual_qty = 全部可转份额`、`forced_full_transfer = True`、 + `action` 取 `min_hold_action`(强制全转 / 强制赎回剩余)。 + + **实现级裁定:申请后剩余为 0(客户本就是清仓)不触发强制处置。** + PRD 的字面条件是"余额 < 阈值",而余额 = 0 时字面也成立;但此时 + `actual_qty == requested_qty`,"强制"二字对客户是**误报**(响应里的 + `forced_full_transfer` 会被前端当成"你的指令被改了")。 + 故加 `剩余 > 0` 前置条件。 + """ + requested = to_decimal(requested_qty) + if requested <= 0: + raise ValueError(f"申请份额必须大于 0,实际 {requested}") + + ordered = _fifo_order(lots) + available = sum( + (to_decimal(lot.remain_qty) for lot in ordered if to_decimal(lot.remain_qty) > 0), + Decimal("0"), + ) + if requested > available: + raise InsufficientShares( + f"可转份额不足:可转 {available},申请 {requested}" + ) + + threshold = to_decimal(min_hold_qty) if min_hold_qty is not None else Decimal("0") + leftover = available - requested + trigger = leftover > 0 and threshold > 0 and leftover < threshold + + if trigger: + if min_hold_action not in ("force_transfer", "force_redeem"): + raise ValueError( + f"未知的最低持有处置动作 min_hold_action={min_hold_action!r}" + "(应为 force_transfer / force_redeem)" + ) + actual_qty = available + action: PlanAction = min_hold_action + else: + actual_qty = requested + action = "transfer" + + # FIFO 摊到各批次:按顺序吃满为止(actual_qty 已保证 <= available)。 + allocations: list[LotAllocation] = [] + remaining = actual_qty + for lot in ordered: + if remaining <= 0: + break + lot_remain = to_decimal(lot.remain_qty) + if lot_remain <= 0: + continue + take = lot_remain if lot_remain < remaining else remaining + allocations.append( + LotAllocation( + lot_id=lot.lot_id, + qty=take, + nav=to_decimal(lot.nav), + confirmed_at=lot.confirmed_at, + ) + ) + remaining -= take + + if remaining > 0: # 防御:available 已校验,走到这里说明 lots 被并发改过 + raise InsufficientShares( + f"可转份额不足:可转 {available},申请 {actual_qty}" + ) + + return PlanResult( + allocations=tuple(allocations), + requested_qty=requested, + actual_qty=actual_qty, + available_qty=available, + forced_full_transfer=trigger, + action=action, + ) + + +def ensure_batch_limit(plan: PlanResult, max_lots: int) -> None: + """批次数上限校验 —— **必须在 `plan_lots()` 之后**调用(§8.3)。 + + 先规划再判上限,是为了让 `TOO_MANY_LOTS` 的响应体能带上**继续转换实际所需的 + 批次数**(可能远大于上限),前端据此提示"请拆分多笔申请"; + 反之若边分配边判,会在事务里半途失败、占位残留。 + """ + if plan.batch_count > max_lots: + raise TooManyLots(plan.batch_count, max_lots) + + +__all__ = [ + "PLACES", + "round2", + "lot_amount", + "lot_fee", + "convert_amount", + "in_qty", + "rounding_diff", + "diff_fee", + "hold_days", + "plan_lots", + "ensure_batch_limit", +] diff --git a/app/service/convert/errors.py b/app/service/convert/errors.py new file mode 100644 index 0000000..83a2cbc --- /dev/null +++ b/app/service/convert/errors.py @@ -0,0 +1,169 @@ +"""convert 专属异常(架构 §8.3 错误码映射)。 + +全部继承 `app.utils.exceptions.ApiError` —— 复用 `app/main.py` 已有的 +`register_error_handlers`,统一输出 `{error_code, message, trace_id, request_id}` 错误体, +**不新增异常出口、不改中间件**。 + +两点必须分清(PRD L-4 / §8.3): + +1. **技术故障码不是业务阻断**。`NavNotReady`(503)、`LotConflict`(409)、 + `IdempotencyUnavailable`(503) 表示系统瞬时状态,恢复后重试即可成功, + **不适用「仅 R-02 可阻断交易」的业务铁律**。 +2. **`extra` 只承载结构性附加字段**(如 `TOO_MANY_LOTS` 的 `batch_count`/`max_lots`), + 由 T-7 的 `api/simulate.py` 分支展开进错误体;本包不构造响应。 +""" + +from __future__ import annotations + +from typing import Any + +from app.utils.exceptions import ApiError + + +class ConvertError(ApiError): + """convert 错误共同基类(便于 service 层统一 `except ConvertError` 分流)。 + + 子类通过类属性声明 `status_code` / `error_code`,无需逐个重写 `__init__`。 + """ + + status_code: int = 400 + error_code: str = "BAD_REQUEST" + default_message: str = "基金转换请求被拒绝" + + def __init__(self, message: str | None = None, *, extra: dict[str, Any] | None = None) -> None: + super().__init__(self.status_code, self.error_code, message or self.default_message) + #: 附加字段(供错误体展开,如 batch_count / max_lots);不参与 message。 + self.extra: dict[str, Any] = dict(extra or {}) + + +# ── 400:业务校验类(HTTP 400 · §8.3 前 7 条)───────────────────────── +class ProductNotRedeemable(ConvertError): + """转出方不可赎回(`core_product.can_redeem = 0`)。""" + + error_code = "PRODUCT_NOT_REDEEMABLE" + default_message = "转出基金当前不可赎回" + + +class ProductNotSubscribable(ConvertError): + """转入方不可申购(`core_product.can_subscribe = 0`)。""" + + error_code = "PRODUCT_NOT_SUBSCRIBABLE" + default_message = "转入基金当前不可申购" + + +class InsufficientShares(ConvertError): + """可转份额不足(`Σ remain_qty < 申请份额` · PRD §12)。""" + + error_code = "INSUFFICIENT_SHARES" + default_message = "可转份额不足" + + +class BelowMinQty(ConvertError): + """低于最低转出份额(申请份额 == 全部可转份额时豁免 `min_redeem_qty`)。""" + + error_code = "BELOW_MIN_QTY" + default_message = "低于该基金的最低转出份额" + + +class SameProduct(ConvertError): + """转出与转入为同一产品。""" + + error_code = "SAME_PRODUCT" + default_message = "转出与转入基金不能为同一产品" + + +class CrossEntityNotSupported(ConvertError): + """跨主体转换(不满足 同销售机构 + 同管理人 + 同 TA)。""" + + error_code = "CROSS_ENTITY_NOT_SUPPORTED" + default_message = "仅支持同一销售机构、同一管理人、同一注册登记机构的基金互转" + + +class TooManyLots(ConvertError): + """跨越批次数超上限(`convert_batch_max_lots`,默认 200)。 + + 一期**不做自动分拆**(§1 原则 12:单笔请求 = 单事务 = 单 `convert_group_id`), + 故把实际所需批次数与上限一并回给前端,提示「请拆分多笔申请」。 + + `extra` 与 PRD「执行期风险 #5」的响应体约定一致: + `{"error_code":"TOO_MANY_LOTS","batch_count":<实际>,"max_lots":200}`。 + """ + + error_code = "TOO_MANY_LOTS" + default_message = "本次转换跨越的批次数超过上限,请拆分多笔申请" + + def __init__( + self, + batch_count: int, + max_lots: int, + message: str | None = None, + ) -> None: + super().__init__( + message + or f"本次转换需跨 {batch_count} 个批次,超过上限 {max_lots},请拆分多笔申请", + extra={"batch_count": int(batch_count), "max_lots": int(max_lots)}, + ) + self.batch_count = int(batch_count) + self.max_lots = int(max_lots) + + +# ── 409 / 503:技术故障类(**非业务阻断** · PRD L-4)────────────────── +class LotConflict(ConvertError): + """批次条件 UPDATE 的 `rowcount != 1`(并发扣减冲突)。 + + 调用方按 100/200/400ms 退避重试,建议 ≤3 次(§8.3)。 + """ + + status_code = 409 + error_code = "LOT_CONFLICT" + default_message = "份额批次并发冲突,请重试" + + +class NavNotReady(ConvertError): + """该产品无任何净值记录(PRD §8.3:503 `NAV_NOT_READY`)。 + + ⚠️ **`nav_stale`(净值过期)不是本异常**:净值过期只额外落一条 + `nav_stale` 审计并继续折算(PRD §2.2),只有**完全没有净值**才阻断。 + """ + + status_code = 503 + error_code = "NAV_NOT_READY" + default_message = "该基金尚无可用净值,暂时无法转换" + + +class IdempotencyUnavailable(ConvertError): + """幂等执行权获取失败且无法判定状态(如 Redis 与占位表同时不可用)。""" + + status_code = 503 + error_code = "IDEMPOTENCY_UNAVAILABLE" + default_message = "幂等校验服务暂不可用,请稍后重试" + + +# ── 500:内部兜底(§8.3 表之外 · 仅数据缺失时触发)──────────────────── +class FeeRuleMissing(ConvertError): + """未匹配到任何赎回费率档 —— **属数据完整性事故,不是业务分支**。 + + 触发条件:`core_fee_rule` 缺少该产品的 `[0, 7)` 档(种子漏灌或产品未配档)。 + 真实费用规则必含最低档,故此处**不降级为 0 费率**——静默按 0 计费会 + 少收赎回费且不留痕,比直接失败危险得多。 + """ + + status_code = 500 + error_code = "FEE_RULE_MISSING" + default_message = "未匹配到赎回费率规则(费率表数据缺失)" + + +__all__ = [ + "ConvertError", + "ProductNotRedeemable", + "ProductNotSubscribable", + "InsufficientShares", + "BelowMinQty", + "SameProduct", + "CrossEntityNotSupported", + "TooManyLots", + "LotConflict", + "NavNotReady", + "IdempotencyUnavailable", + "FeeRuleMissing", +] diff --git a/app/service/convert/fee.py b/app/service/convert/fee.py new file mode 100644 index 0000000..3c21169 --- /dev/null +++ b/app/service/convert/fee.py @@ -0,0 +1,65 @@ +"""持有期 → 赎回费率查表(架构 §7 · D11 数据驱动)。 + +规则来自 `core_fee_rule`(`fee_type='redeem'`),**本模块不查库** —— +调用方(`core_ro.get_redeem_fee_rules(product_id)`)取回规则行后传入。 + +**区间口径:左闭右开 `[min_hold_days, max_hold_days)`**,`max_hold_days = NULL` 表示无上限。 +与 `scripts/core/07-seed-fee-rule.sql`、架构 §7 分档表三处一致: + +| 档 | 区间 | 费率(22 号文 §10 下限) | +| --- | --- | --- | +| < 7 日 | `[0, 7)` | 0.0150 | +| 7–30 日 | `[7, 30)` | 0.0100 | +| 30–180 日 | `[30, 180)` | 0.0050 | +| 180–365 日 | `[180, 365)` | 0.0025 | +| ≥ 365 日 | `[365, NULL)` | 0.0000 | + +边界语义举例(PRD §12:**满 7 日归 7–30 档**):`hold_days = 6` → 1.5%; +`hold_days = 7` → 1.0%;`hold_days = 365` → 0。 +""" + +from __future__ import annotations + +from decimal import Decimal +from typing import Iterable, Sequence + +from app.service.convert.errors import FeeRuleMissing +from app.service.convert.types import FeeRule, to_decimal + + +def matches(rule: FeeRule, hold_days: int) -> bool: + """单条规则是否命中(左闭右开;`max_hold_days is None` 表示无上限)。""" + if hold_days < rule.min_hold_days: + return False + return rule.max_hold_days is None or hold_days < rule.max_hold_days + + +def pick_fee_rate( + rules: Sequence[FeeRule] | Iterable[FeeRule], + hold_days: int, + *, + product_id: str | None = None, +) -> Decimal: + """按持有天数取赎回费率;**无命中即数据缺失,直接失败**(不降级为 0)。 + + 多档同时命中时取 `min_hold_days` **最大**的一档(最具体的区间)—— + 真实费率表本不应重叠,这条规则是防止种子误配出重叠区间时"取到哪档看运气"。 + + ⚠️ 为什么无命中不能返回 0:静默按 0 计费会**少收赎回费且不留痕** + (客户与基金财产双向损失),比直接失败危险得多。费率表必含 `[0, 7)` 档, + 走到这里说明种子漏灌或产品未配档 → `FeeRuleMissing`(500,内部兜底)。 + """ + candidates = [ + rule + for rule in rules + if rule.fee_type == "redeem" and matches(rule, hold_days) + ] + if not candidates: + raise FeeRuleMissing( + f"未匹配到赎回费率档:product_id={product_id!r},持有 {hold_days} 天" + ) + best = max(candidates, key=lambda rule: rule.min_hold_days) + return to_decimal(best.rate) + + +__all__ = ["matches", "pick_fee_rate"] diff --git a/app/service/convert/lot_bootstrap.py b/app/service/convert/lot_bootstrap.py new file mode 100644 index 0000000..cb50b15 --- /dev/null +++ b/app/service/convert/lot_bootstrap.py @@ -0,0 +1,130 @@ +"""批次兜底补建(架构 D18 · D8 · PRD §4.4 历史数据兜底)。 + +**唯一目的:让"补建规则"只有一个副本。** + +`core_holding` 是持仓快照、`core_share_lot` 是 FIFO 计费的权威源。存量持仓 +(批次机制上线前就存在)没有批次行,此时: + +- `trade_gateway` 的普通申赎分支要**兜底补建后再扣**(D8,不跳过、不阻断); +- `scripts/core/rebuild_lots.py` 要按持仓**快照重建**整表(L-7,非交易回滚)。 + +两处都要"由 `core_holding` 反推初始批次的 `confirmed_at`"。若各写一份, +**必然漂移**——同一持仓在两处算出不同的 `confirmed_at`,费率档随之不同, +而这类差异在演示里看不出来、在生产里就是错账。故本模块被两侧共同调用(D18)。 + +**错开规则**与 `scripts/core/08-seed-share-lot.sql` 同族:以 `core_holding.as_of` +为基准日向前错开 `(491, 201, 34, 10, 3)` 天,分别落在 +`[365,∞)` / `[180,365)` / `[30,180)` / `[7,30)` / `[0,7)` 五档, +避免"全部持仓都落在同一费率档"导致分档分支无数据。 +""" + +from __future__ import annotations + +import zlib +from datetime import datetime, time, timedelta +from decimal import ROUND_HALF_UP, Decimal +from typing import Any, Mapping, Sequence + +from app.service.convert.types import Lot, to_date, to_decimal + +#: 错开天数(与 08 种子同族,依次对应 ≥365 / 180-365 / 30-180 / 7-30 / <7 五档)。 +BOOTSTRAP_OFFSET_DAYS: tuple[int, ...] = (491, 201, 34, 10, 3) + +#: 确认时点统一取 10:00:00,与 08 种子的 `confirmed_at` 写法保持一致 +#: (持有期按自然日算,时分秒不参与计费,统一值只为可比对)。 +BOOTSTRAP_CONFIRM_TIME = time(10, 0, 0) + +#: `lot_id` 前缀:便于在库里一眼区分「兜底补建」与「真实流水生成」的批次。 +BOOTSTRAP_LOT_PREFIX = "LOT-BOOT" + +#: `lot_id` / `product_id` 列宽(`core_share_lot.lot_id VARCHAR(64)`)。 +_MAX_LOT_ID_LEN = 64 + + +def offset_for( + customer_id: str, + product_id: str, + offsets: Sequence[int] = BOOTSTRAP_OFFSET_DAYS, +) -> int: + """按 `(customer_id, product_id)` 稳定选择一个错开天数。 + + ⚠️ **必须用 `zlib.crc32`,不可用内置的 `hash` 函数**:CPython 对 `str` 启用了 + 哈希随机化(`PYTHONHASHSEED`),内置哈希在不同进程间结果不同 —— + 这会让 gateway 与 `rebuild_lots.py`(两个进程)算出**不同**的 `confirmed_at`, + D18 的同源断言变成"时绿时红"的随机失败。 + """ + key = f"{customer_id}|{product_id}".encode("utf-8") + return offsets[zlib.crc32(key) % len(offsets)] + + +def bootstrap_lot_id(customer_id: str, product_id: str) -> str: + """兜底批次的 `lot_id`(确定性:同一持仓重复补建得到同一个 id,天然防重)。 + + 超长时退化为「截断 + crc32 摘要」而**不抛异常** —— D8 明确要求兜底补建 + 「不跳过、不阻断」,让一个 64 字符列宽问题打断交易是不对的。 + 两个 id 列各 64 字符,拼起来可能超 `VARCHAR(64)`,故保留摘要段保证唯一性。 + """ + raw = f"{customer_id}-{product_id}" + lot_id = f"{BOOTSTRAP_LOT_PREFIX}-{raw}" + if len(lot_id) > _MAX_LOT_ID_LEN: + digest = f"{zlib.crc32(raw.encode('utf-8')):08x}" + keep = _MAX_LOT_ID_LEN - len(BOOTSTRAP_LOT_PREFIX) - len(digest) - 2 + lot_id = f"{BOOTSTRAP_LOT_PREFIX}-{raw[:keep]}-{digest}" + return lot_id + + +def bootstrap_lots( + holding_row: Mapping[str, Any], + *, + offset_days: int | None = None, +) -> list[Lot]: + """按 `core_holding` 一行反推初始批次(D8 兜底补建 / `rebuild_lots.py` 共用)。 + + 返回**单批次**(历史存量视为一次确认),`qty = remain_qty = holding.qty`, + 因此天然满足 `Σ remain_qty == core_holding.qty`(08 种子的同一不变量)。 + + `nav` 用 **成本单价 `cost_amount / qty`**(4 位 HALF_UP)近似成交净值: + `core_holding` 没有净值列,而 `core_share_lot.nav` 是 NOT NULL; + 补建批次是"历史成本"语义,成本单价虽不等于当时净值,但**口径一致、可复算**, + 且不会被误当成当日净值参与定价(定价一律用 `core_product_nav`)。 + + `offset_days` 显式传入时覆盖默认错开(供单测固定档位用)。 + """ + qty = to_decimal(holding_row["qty"]) + if qty <= 0: + # 份额归零的持仓行**保留但不再补建批次**(D9/P2:归零行不删)。 + return [] + + customer_id = str(holding_row["customer_id"]) + product_id = str(holding_row["product_id"]) + as_of = to_date(holding_row["as_of"]) + if as_of is None: + raise ValueError("core_holding.as_of 不能为 None(反推批次的基准日)") + + cost_amount = to_decimal(holding_row.get("cost_amount") or 0) + offset = offset_days if offset_days is not None else offset_for(customer_id, product_id) + confirmed_at = datetime.combine(as_of - timedelta(days=offset), BOOTSTRAP_CONFIRM_TIME) + nav = (cost_amount / qty).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP) + + return [ + Lot( + lot_id=bootstrap_lot_id(customer_id, product_id), + confirmed_at=confirmed_at, + remain_qty=qty, + nav=nav, + qty=qty, + customer_id=customer_id, + product_id=product_id, + source_trade_id=None, # 兜底补建无来源流水(与 08 种子一致) + ) + ] + + +__all__ = [ + "BOOTSTRAP_OFFSET_DAYS", + "BOOTSTRAP_CONFIRM_TIME", + "BOOTSTRAP_LOT_PREFIX", + "offset_for", + "bootstrap_lot_id", + "bootstrap_lots", +] diff --git a/app/service/convert/nav.py b/app/service/convert/nav.py new file mode 100644 index 0000000..94bd9fd --- /dev/null +++ b/app/service/convert/nav.py @@ -0,0 +1,74 @@ +"""净值口径判定(架构 §7 · D10 · PRD §2.2/§8.3)。 + +**本模块不查库** —— 调用方先 `core_ro.get_nav_as_of(product_id, trade_date)` 取回 +`nav_date <= 交易日` 的最近一行,再传入此处判定。取值本身(T 日未知价法、 +降序取 1)由 SQL 保证(D10:**不改** `get_latest_nav`,它会取到未来日期的净值)。 + +两个判定必须分清——它们对应**完全不同的处置**: + +| 判定 | 含义 | 处置 | +| --- | --- | --- | +| `ensure_nav_ready()` | 该产品**无任何净值记录** | **503 `NAV_NOT_READY`**,不落占位(PRD §8.3) | +| `is_stale()` | 有净值但距交易日过久 | **不阻断**,仅额外落一条 `nav_stale` 审计(PRD §2.2) | + +把"净值过期"误当"无净值"会拦掉本可完成的转换;反之会让演示库的过期净值 +静默按新鲜净值折算。故两者是**两个独立函数**,不合并成一个布尔返回。 +""" + +from __future__ import annotations + +from datetime import date + +from app.service.convert.errors import NavNotReady +from app.service.convert.types import to_date + +#: 净值过期阈值(天)—— 与 `settings.convert_nav_stale_days` 默认值一致。 +#: 纯函数不读配置,故此处只是**默认参数**,运行时以调用方传入为准。 +DEFAULT_STALE_DAYS = 3 + + +def ensure_nav_ready(nav_date: date | None, *, product_id: str | None = None) -> None: + """无净值记录 → `NavNotReady`(503);有则原样通过。""" + if to_date(nav_date) is None: + raise NavNotReady(f"基金 {product_id!r} 尚无可用净值(无任何净值记录)") + + +def is_stale( + nav_date: date | None, + trade_date: date, + stale_days: int = DEFAULT_STALE_DAYS, +) -> bool: + """净值是否过期:`(交易日 − 净值日期).days > stale_days`。 + + 注意是 **`>`**:恰好等于阈值不算过期(与 PRD §2.2「超过此值」一致)。 + + `nav_date` 为 `None` 时抛 `ValueError` 而**不是**返回 `True`—— + "无净值"必须走 `ensure_nav_ready()` 得到 503,混进 `stale` 分支会 + 变成"不阻断 + 落审计",性质完全不同。调用顺序:先 `ensure_nav_ready` 再 `is_stale`。 + """ + d = to_date(nav_date) + t = to_date(trade_date) + if d is None: + raise ValueError("nav_date 为 None(无净值记录),应先调用 ensure_nav_ready()") + if t is None: + raise ValueError("trade_date 不能为 None") + return (t - d).days > stale_days + + +def evaluate_nav( + nav_date: date | None, + trade_date: date, + stale_days: int = DEFAULT_STALE_DAYS, + *, + product_id: str | None = None, +) -> bool: + """便捷入口:先确保有净值(否则 503),再返回是否过期。 + + 返回值即响应体 `nav_stale` 字段(PRD §5.3),也是 `risk_convert_detail.nav_stale` + 的取值(PRD §4.1)。 + """ + ensure_nav_ready(nav_date, product_id=product_id) + return is_stale(nav_date, trade_date, stale_days) + + +__all__ = ["DEFAULT_STALE_DAYS", "ensure_nav_ready", "is_stale", "evaluate_nav"] diff --git a/app/service/convert/types.py b/app/service/convert/types.py new file mode 100644 index 0000000..1b94b7c --- /dev/null +++ b/app/service/convert/types.py @@ -0,0 +1,167 @@ +"""基金转换纯函数层的类型定义(架构 §7 · 评审 S3)。 + +**为什么用 `@dataclass(frozen=True)` 而不是裸 dict** + +主架构 `app/entities.py` 的 31 个 ORM 类实测闲置、全链路 dict 传参, +键名写错只在运行时炸。基金转换的金额链路是 +「份额 → 本批金额 → 本批赎回费 → 转换金额 → 补差费 → 净转入金额 → 转入份额」, +**任一步键名写错都会算出一个"看着合理"的错数**(不是抛异常), +故本包一律用不可变 dataclass 承载,错误在构造期即暴露。 + +字段与 DB 列的对应关系(列名以 `scripts/core/01-ddl.sql` 为准): + +| 本包类型 | DB 表 | +| --- | --- | +| `Lot` | `core_share_lot`(FIFO 选批的输入行) | +| `FeeRule` | `core_fee_rule`(持有期分档,`fee_type='redeem'`) | +| `LotAllocation` / `PlanResult` | 计算产物,不落库;落库为 `core_convert_lot_detail` | +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime +from decimal import Decimal +from typing import Any, Literal, Mapping + +# ── 枚举型别名(与 PRD/架构口径一一对应)──────────────────────────── +#: `core_product.min_hold_action`(PRD Q12 · D15): +#: `force_transfer` = 阈值大(100/500/1000 份)→ 强制全转; +#: `force_redeem` = 阈值小(0.1/1 份)→ 强制赎回剩余。 +MinHoldAction = Literal["force_transfer", "force_redeem"] + +#: `PlanResult.action`:正常转出 / 强制全转 / 强制赎回剩余余额。 +#: 与前者的区别——`force_*` 表示**客户指令被改变**(实际转出份额 > 申请份额), +#: 必须在响应与审计留痕(PRD 二轮第 9 条)。 +PlanAction = Literal["transfer", "force_transfer", "force_redeem"] + +#: 补差费口径(D13 · `convert_diff_fee_mode`)。 +#: `amount_diff` = 价外法两端差(默认 B);`rate_diff` = 费率差法(A)。 +DiffFeeMode = Literal["amount_diff", "rate_diff"] + + +def to_decimal(value: Any) -> Decimal: + """把 DB 取回的值统一成 `Decimal`(`float` 会引入二进制误差,禁止直传)。""" + if isinstance(value, Decimal): + return value + return Decimal(str(value)) + + +def to_datetime(value: Any) -> datetime: + """统一时间值(MySQL `DATETIME(3)`→`datetime` / sqlite `TIMESTAMP`→`str` 兜底)。""" + if isinstance(value, datetime): + return value + if isinstance(value, date): + return datetime(value.year, value.month, value.day) + return datetime.fromisoformat(str(value)) + + +def to_date(value: Any) -> date | None: + """统一日期值;`None` 原样返回(净值缺失的判定依赖 `None`,不可吞成 `today`)。""" + if value is None: + return None + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + return date.fromisoformat(str(value)[:10]) + + +@dataclass(frozen=True) +class Lot: + """`core_share_lot` 一行 —— FIFO 选批与逐批计费的输入(评审 S3)。 + + `qty` 为原始份额、`remain_qty` 为 FIFO 扣减后的剩余份额; + **计费与分配一律以 `remain_qty` 为准**(PRD §12:份额是否足够以 + `Σ core_share_lot.remain_qty` 判定,不以 `core_holding.qty` 为准)。 + """ + + lot_id: str + confirmed_at: datetime + remain_qty: Decimal + nav: Decimal + qty: Decimal | None = None + customer_id: str | None = None + product_id: str | None = None + source_trade_id: str | None = None + + @classmethod + def from_row(cls, row: Mapping[str, Any]) -> Lot: + """由 DB 行映射构造(`row._mapping` 或普通 dict 均可)。""" + return cls( + lot_id=str(row["lot_id"]), + confirmed_at=to_datetime(row["confirmed_at"]), + remain_qty=to_decimal(row["remain_qty"]), + nav=to_decimal(row["nav"]), + qty=to_decimal(row["qty"]) if row.get("qty") is not None else None, + customer_id=row.get("customer_id"), + product_id=row.get("product_id"), + source_trade_id=row.get("source_trade_id"), + ) + + +@dataclass(frozen=True) +class FeeRule: + """`core_fee_rule` 一行 —— 持有期→赎回费率的查表输入(`fee_type='redeem'`)。 + + `to_fund_ratio`(赎回费计入基金财产比例)22 号文 §10 恒为 1.0, + 本期**不参与计算、仅留痕**(D16),故不设参与计算的字段语义。 + """ + + min_hold_days: int + max_hold_days: int | None + rate: Decimal + fee_type: str = "redeem" + product_id: str | None = None + to_fund_ratio: Decimal = Decimal("1.0000") + + @classmethod + def from_row(cls, row: Mapping[str, Any]) -> FeeRule: + return cls( + min_hold_days=int(row["min_hold_days"]), + max_hold_days=int(row["max_hold_days"]) if row.get("max_hold_days") is not None else None, + rate=to_decimal(row["rate"]), + fee_type=str(row.get("fee_type") or "redeem"), + product_id=row.get("product_id"), + to_fund_ratio=to_decimal(row.get("to_fund_ratio") or "1.0000"), + ) + + +@dataclass(frozen=True) +class LotAllocation: + """FIFO 分配结果中的单批次:**本批次被扣减的份额**及其成交要素。 + + 带上 `nav` / `confirmed_at` 是为了让下游(`convert_service` 落 + `core_convert_lot_detail`)**无需再回查 `Lot`**——避免"同一批次两次取数 + 拿到不同快照"的非确定性(评审 S1 关注的同类问题)。 + """ + + lot_id: str + qty: Decimal + nav: Decimal + confirmed_at: datetime + + +@dataclass(frozen=True) +class PlanResult: + """`plan_lots()` 的完整规划结果(**先规划再判上限**,§8.3)。 + + `actual_qty != requested_qty` 即为「强制全转/强制赎回」发生过, + 调用方须把 `forced_full_transfer` 与 `action` 写进响应与审计(PRD §9 第 13 条)。 + """ + + allocations: tuple[LotAllocation, ...] + requested_qty: Decimal + actual_qty: Decimal + available_qty: Decimal + forced_full_transfer: bool + action: PlanAction + + @property + def batch_count(self) -> int: + """本次实际跨越的批次数。 + + 刻意做成 property 而非字段:`batch_count` 必须与 `allocations` **恒等**, + 独立字段存在被手填错的余地,而它正是 `TOO_MANY_LOTS` 的判据(§8.3)。 + """ + return len(self.allocations) diff --git a/app/utils/db.py b/app/utils/db.py index 3dfab89..eedb5bb 100644 --- a/app/utils/db.py +++ b/app/utils/db.py @@ -1,10 +1,13 @@ """SQLAlchemy 引擎工厂(B7 · 开发计划挂账⑥)。 -按库名缓存单例 Engine:deps / risk / simulate 路由每请求实例化 Repository 时 -复用同一连接池,不再每次 create_engine(B6 复审 P3:实例化点泄漏);应用 -shutdown 经 dispose_engines 统一释放(B7 评审 P1-1:显式 Engine.dispose(), -不依赖 GC 兜底)。测试直传 engine= 参数的用法不受影响; +按 (库名, 角色) 缓存单例 Engine:deps / risk / simulate 路由每请求实例化 +Repository 时复用同一连接池,不再每次 create_engine(B6 复审 P3:实例化点 +泄漏);应用 shutdown 经 dispose_engines 统一释放(B7 评审 P1-1:显式 +Engine.dispose(),不依赖 GC 兜底)。测试直传 engine= 参数的用法不受影响; monkeypatch settings 后须先 dispose_engines() 清缓存。 + +角色(D20 · 架构设计-基金转换交易 §11.1):ro / rw / admin 三档, +由 `get_engine` 的 `role` 参数选择账号;账号未配置时回退 mysql_user。 """ from __future__ import annotations @@ -19,20 +22,62 @@ from app.config.settings import settings _engines: dict[str, Engine] = {} _engines_lock = threading.Lock() +_VALID_ROLES = ("ro", "rw", "admin") -def get_engine(database: str) -> Engine: - """取指定库的单例 Engine(进程内共享连接池)。""" + +def _resolve_credentials(database: str, role: str) -> tuple[str, str]: + """解析该 (库, 角色) 应使用的 (user, password)。 + + - `admin`:恒用 `mysql_user`(DDL / 测试 teardown 等需 DELETE 的场景) + - `ro` / `rw` + core 库:分别用 `mysql_core_ro_*` / `mysql_core_rw_*` + - `ro` / `rw` + agent 库:统一用 `mysql_agent_*`(agent 库无独立只读账号) + - 对应账号为空串 → 回退 `mysql_user`(渐进启用,不阻塞本地开发) + """ + if role == "admin": + return settings.mysql_user, settings.mysql_password + if role not in _VALID_ROLES: + raise ValueError(f"unknown db role: {role!r}(应为 ro / rw / admin)") + + if database == settings.mysql_core_database: + if role == "ro": + user, password = settings.mysql_core_ro_user, settings.mysql_core_ro_password + else: + user, password = settings.mysql_core_rw_user, settings.mysql_core_rw_password + else: + user, password = settings.mysql_agent_user, settings.mysql_agent_password + + if not user: + return settings.mysql_user, settings.mysql_password + return user, password + + +def get_engine(database: str, role: str = "rw") -> Engine: + """取指定 (库名, 角色) 的单例 Engine(进程内共享连接池)。 + + ⚠️ 调用点必须显式声明角色(F-11 清单共 13 处),不要依赖默认值表达意图: + - `role="ro"`:只读账号 → `core_ro` / `core_tools` / deps 归属校验 / + risk 扫描 / **gateway 的校验读** + - `role="rw"`:读写账号 → `gateway_repository`(core 写)、 + `risk_repository` / `session_repository`(agent 读写) + - `role="admin"`:`mysql_user` → 建库/DDL、`tests/conftest.py` teardown(需 DELETE) + + 账号未配置时回退 `mysql_user`,与单账号时代行为一致。 + """ + if role not in _VALID_ROLES: + raise ValueError(f"unknown db role: {role!r}(应为 ro / rw / admin)") + + cache_key = f"{database}:{role}" with _engines_lock: - engine = _engines.get(database) + engine = _engines.get(cache_key) if engine is None: - pwd = settings.mysql_password - auth = f"{settings.mysql_user}:{pwd}" if pwd else settings.mysql_user + user, password = _resolve_credentials(database, role) + auth = f"{user}:{password}" if password else user url = ( f"mysql+pymysql://{auth}@{settings.mysql_host}:{settings.mysql_port}" f"/{database}?charset=utf8mb4" ) engine = create_engine(url, pool_pre_ping=True) - _engines[database] = engine + _engines[cache_key] = engine return engine diff --git a/docs/PRD/PRD-基金转换交易.md b/docs/PRD/PRD-基金转换交易.md index 5f85ac3..d0c7d79 100644 --- a/docs/PRD/PRD-基金转换交易.md +++ b/docs/PRD/PRD-基金转换交易.md @@ -1,6 +1,6 @@ # PRD · 基金转换(convert)交易 -> 版本:**v0.9(架构评审增补 · 定稿)** · 日期:2026-09-10 +> 版本:**v0.9.1(费率分类修正 · 定稿)** · 日期:2026-09-10 > 分支:`risk-control-agent` > 状态:第 2 步产出。**v0.7 已冻结**(架构 v0.2 §0 查证发现 4 项合规硬伤,外审判定「❌ 不建议进入编码」) > → 回退修订出 **v0.8**(按处置表全量修订,33 条闭环)→ 架构 v1.0 独立评审**通过**后,回填 2 处契约出 **v0.9**。 @@ -20,6 +20,21 @@ > | 2 | 响应增 `confirm_basis: "natural_day_approx"`(T+1 用自然日近似,真实 Core 接入后为工作日) | 契约(响应) | 架构评审 R2 | > | 3 | `cleanup_pending_convert.py` 措辞由「清理超 24h 的 `pending` 孤儿」统一为「置 `status='expired'`(**标记不硬删**,留痕供对账)」 | **措辞统一**(同 S2 语义,非新增契约) | 架构评审 S2 | > +> **v0.9 → v0.9.1 变更(费率分类修正 · 用户裁定,2026-09-10)** +> +> | # | 变更 | 性质 | 依据 | +> | --- | --- | --- | --- | +> | 1 | 申购费率档**严格按 `product_type` 取**:`PROD-005827` 由「主动偏股 **0.80%**」更正为「**其他混合型 0.50%**」—— `mixed` 只能套 ≤0.5% 档 | 契约(种子口径) | 22 号文 §8 的分类维度 + 用户裁定 | +> | 2 | 主示例**转入方**由 `PROD-005827` 改为 `PROD-003095`(`stock` / 主动偏股型 / R4 / 净值 **0.9500**);费率仍 0.80%、**§5.3 全部派生数字一个都不用改** | 契约(示例) | 同上(两者净值恰好同为 0.9500) | +> | 3 | 主示例两端主体明确为 **华夏模拟基金 / TA-CN-001**(同管理人 + 同 TA —— 转换的前置硬约束) | 契约(示例) | 架构:同销售机构 + 同管理人 + 同 TA | +> | 4 | 新增「**分类必须与 `product_type` 匹配**」说明 + 连带约束核对(同主体 / 净值 / 适当性三分支) | 说明(非契约) | 本次「外审级漏网」复盘 | +> +> **根因(必须记住)**:v0.8 按 22 号文上限重定费率时,为凑出 0.50% 的补差幅度, +> 把 `mixed` 的 `PROD-005827` 标成「主动偏股」并直接取到 0.8% 上限 —— **这是分类错误**。 +> 五轮外审与我方逐条核对**只验证了「数值 ≤ 上限」,从未验证「该产品是否属于这一档」**。 +> → 新增**自检第 12 问:类型与口径必须匹配**,并把该约束固化为**可执行断言 ⑧** +> (`scripts/dev/verify_convert_seed.py`,越档即 FAIL),不再依赖人工比对。 +> > **变更门规(防版本循环)**:只有「**对外契约 / 表结构 / 枚举**」变更才回改本 PRD; > 算法、补偿方式、索引、类型定义等**内部实现由架构自主决定**,本 PRD 不动。 > @@ -117,7 +132,7 @@ B 亦为新近公告的主流写法。 > ⚠️ `diff_fee` 一期由两端产品的 `subscribe_fee_rate` 决定(**独立列,详见 §4.2 / §10 Q8, > 不得取 `fee_rate`**——后者是管理费率,语义不同)。 -> **主示例取不同费率**(债基 **0.30%** → 主动偏股 **0.80%**)→ `diff_fee = 252.40`(§5.3 示例自证块)。 +> **主示例取不同费率**(`PROD-110022` 债基 **0.30%** → `PROD-003095` 主动偏股 **0.80%**)→ `diff_fee = 252.40`(§5.3 示例自证块)。 > 同费率场景(补差 0)由 §5.3 同费率对照 + §9 第 19 条用例覆盖。 ### 2.1.3 赎回费分档(v0.8 按 22 号文重排) @@ -376,11 +391,26 @@ ALTER TABLE core_product > | 产品 | 类型 | `subscribe_fee_rate` | 依据(22 号文 §8 上限) | > | --- | --- | --- | --- | > | `PROD-110022`(示例转出) | bond | **0.0030** | 债券型 ≤0.3% | - > | `PROD-005827`(示例转入) | 主动偏股 | **0.0080** | 主动偏股型 ≤0.8% | + > | `PROD-005827` | mixed(其他混合型) | **0.0050** | 其他混合型 ≤0.5% | + > | `PROD-003095`(示例转入) | stock(主动偏股型) | **0.0080** | 主动偏股型 ≤0.8% | > | `PROD-000001`(同费率对照组) | money | 0.0000 | 货基 0 | > | `PROD-510300`(同费率对照组) | index | 0.0030 | 指数型 ≤0.3% | > - > §5.3 主示例即取自前两行(**补差 0.50%**);§9 第 19 条两条用例(非零 / 同费率)均需可跑。 + > §5.3 主示例即取自 `PROD-110022`(0.0030)→ `PROD-003095`(0.0080)两行(**补差 0.50%**); + > §9 第 19 条两条用例(非零 / 同费率)均需可跑。 + > + > ⚠️ **分类必须与 `product_type` 匹配**(自检第 12 问):费率档由**产品类型**决定, + > **不得为凑出数值差把 `mixed` 产品按「主动偏股」取 0.8%**。v0.8 曾把 + > `PROD-005827`(`mixed`,平衡混合一号)标为「主动偏股」并取到 0.8% 上限—— + > 属分类错误,v0.9.1 已修正:`PROD-005827` 归「其他混合型 0.5%」, + > 示例转入方改用真正的主动偏股产品 `PROD-003095`(`stock` / R4)。 + > + > **连带约束核对**(同一次修正必须一起看): + > ① `PROD-003095` 归 **华夏模拟基金 / TA-CN-001**,与转出方 `PROD-110022` + > **同管理人、同 TA** —— 满足转换的前置硬约束(否则跨主体不可转换); + > ② `PROD-003095` 净值 **0.9500**,与原示例除数的 0.9500 相同 → **§5.3 派生数字全部不变**; + > ③ 客户 CUST-9527 为 **C3**、`PROD-003095` 为 **R4** → 匹配结果 + > `allowed_with_disclosure`(需签风险揭示书后**放行**,非阻断;`forbidden` 才阻断,§7.2)。 > > **赎回费种子同步(B-3)**:`07-seed-fee-rule.sql` 的 `core_fee_rule.rate` 按 §2.1.3 新档写 > `0.0150 / 0.0100 / 0.0050 / 0.0025 / 0`(**v0.7 的 7–30 日 0.0075 低于法定下限,违规**)。 @@ -448,7 +478,7 @@ POST /api/simulate/trade "customer_id": "CUST-9527", "trade_type": "convert", "from_product_id": "PROD-110022", - "to_product_id": "PROD-005827", + "to_product_id": "PROD-003095", "qty": 50000.0000, "client_request_id": "CLI-20260909-0001" // 幂等键,选填 } @@ -468,8 +498,9 @@ POST /api/simulate/trade > **示例自证:每个派生值都由本块数据算出(v0.8 · 口径 B · 脚本实算)** > -> 数值来源:`scripts/dev/calc_convert_demo.py`(与 §2.1 / §2.5 同一套公式), -> **禁止手算**——v0.7 曾两次因手算与口径不符被外部审查质疑。 +> 数值来源:`scripts/dev/calc_convert_demo.py`,**禁止手算**——v0.7 曾两次因手算与口径不符被外部审查质疑。 +> **T-2b 落地(2026-09-10)**:该脚本已改为**直接调用生产 `app/service/convert/calc.py` 与 `fee.py`** +> (不再自带公式副本),并在末尾与本节表格**逐项比对,不一致即退出码 1**;实算 15/15 一致。 > > | 步 | 计算 | 结果 | > | --- | --- | --- | @@ -485,7 +516,7 @@ POST /api/simulate/trade > | `in_qty` | 50784.10 ÷ 0.9500 = 53456.9474 | q2 **53456.95**(2 位四舍五入) | > | `rounding_diff` | 理论 53456.9474 − 实际 53456.95 | **-0.0026**(负 = 客户多得) | > -> **两端申购费率**:转出 `PROD-110022` 债基 **0.30%**、转入 `PROD-005827` 主动偏股 **0.80%** +> **两端申购费率**:转出 `PROD-110022` 稳健债基 A(bond)**0.30%**、转入 `PROD-003095` 医药健康精选(stock,主动偏股型)**0.80%** > (均 §4.3 种子显式给定,且在 22 号文 §8 上限内)。 > > **补差费口径差异对照**:同输入下口径 A(费率差法)`diff_fee = 253.91`、`in_qty = 53455.36` @@ -525,7 +556,7 @@ POST /api/simulate/trade // ── 转入端(视同申购)── "in_trade_id": "TRD-20260909-YYYYYYYY", - "in_nav": "0.9500", // PROD-005827 成交净值 + "in_nav": "0.9500", // PROD-003095 成交净值 "convert_amount": "51036.50", // 扣赎回费后可用于转入的金额 = 51500.00 − 463.50 "diff_fee": "252.40", // 口径B:405.05 − 152.65(价外法两端差,§2.1.1) "in_amount": "50784.10", // 扣补差费后实际买入金额 = 51036.50 − 252.40 @@ -834,7 +865,7 @@ SELECT 1 FROM core_trade WHERE convert_group_id = :gid LIMIT 1; 第 6 步集成测试**实测补录真实耗时**;若实测超阈值,需优化索引 / 锁策略后再定阈值, **不得反向修改实测数据迁就指标**) 19. **补差费非零场景有覆盖**(v0.7 自查问题 A 衍生 · **Q8 已定稿**): - 存在两端 `subscribe_fee_rate` 不同的产品对(`PROD-110022` 0.30% / `PROD-005827` 0.80%),且有用例验证 + 存在两端 `subscribe_fee_rate` 不同的产品对(`PROD-110022` 0.30% / `PROD-003095` 0.80%,且**同属一个管理人 + 同一 TA**),且有用例验证 `diff_fee > 0` 时 §2.1 公式成立(`in_amount = convert_amount − diff_fee`); 另需一条**同费率对照**用例(`diff_fee = "0.00"`)。 否则补差费链路在种子数据下**恒为 0、永不触发**(同第 14 条教训:无覆盖 = 校验能力漏测) diff --git a/docs/memory/2026-09-10.md b/docs/memory/2026-09-10.md new file mode 100644 index 0000000..930d7a0 --- /dev/null +++ b/docs/memory/2026-09-10.md @@ -0,0 +1,172 @@ +# 2026-09-10 工作日志 + +> 本日两条线:**架构改进线收尾闭环**(本文主体)+ 基金转换线**第 4 步开发计划待启动**。 + +--- + +## 一、接手与状态核对(会话起始) + +读入:`docs/交接文档-基金转换.md`(v1.0)· `docs/memory/MEMORY.md §0` · `docs/交接文档-架构改进.md`(v1.1) +· `docs/项目框架设计/架构设计-基金转换交易.md §15/§15.1` · `docs/memory/{TODO,ITERATION}.md` · `AGENTS.md`。 + +**基线复核**:`python -m pytest -q`(系统 Python 3.13.14)→ **510 passed**(9.93s)。 + +**设计资产核对**:`tests/_ddl.py:50-54` 的 `core_holding` 仍为 `market_value`/`quantity` 且**无 PK** —— +与 T-0 描述**一致**,架构文档无误。 + +### ⚠️ 核对中发现的两处「文档与实况不符」(以实况为准) + +1. **`037ce7e`「未 push」是过期表述。** + 实测 `git ls-remote origin`:`refs/heads/risk-control-agent` = **`fffb78a`** = 本地 HEAD; + `git merge-base --is-ancestor 037ce7e fffb78a` → 成立。即**架构改进线全部提交(含其后 5 个文档提交)早已推送**。 + 本地 `git branch -vv` 显示 `origin/risk-control-agent: gone` **只是本地远程跟踪引用失效**,非远程分支被删。 +2. **基金转换线 6 个设计文件全部 untracked**(未纳入版本库):`docs/PRD/PRD-基金转换交易.md`、 + `docs/项目框架设计/架构设计-基金转换交易.md`、`基金转换-审查意见处置表.md`、 + `评审待办-风控主架构与基金转换.md`、`docs/交接文档-基金转换.md`、`scripts/dev/calc_convert_demo.py`。 + → 用户拍板:**先 commit 入库(只 commit,不 push)**。 + +--- + +## 二、架构改进线 §7.2 手工冒烟(七项,全部代跑,2026-09-10) + +用户授权「七项全部代跑」。所有临时改动**跑完即恢复**。 + +### 第 1 项 · 无 key 启动告警 ✅ PASS + +- 本机 `.env` 的 `DEEPSEEK_API_KEY` **本就为空**(实测 `raw_value_len = 0`),故「清空」态即当前态。 +- `uvicorn app.main:app`(端口 8123)→ **1 秒就绪**,`/health` → `200 {"status":"ok","env":"development"}`。 +- 进程存活 → **告警不阻塞启动** ✓。 +- 日志命中:`DEEPSEEK_API_KEY 未配置,对话将走降级回复(前缀 (LLM 未配置:请在 .env 设置 DEEPSEEK_API_KEY 后重启)),LLM 能力不可用`(`app/main.py:66-72`)。 + +### 第 2 项 · 恢复 key → 无告警 ✅ PASS(**口径替代,已留痕**) + +- **替代说明**:本机无真实 DeepSeek key,「恢复 key」一支改用**非空占位值**(`sk-smoke-placeholder-not-a-real-key`)。 + 告警分支只判 `not settings.deepseek_api_key`,**不校验 key 有效性**,故该替代足以验证条件分支。 +- 改后 `key_present: True` → 启动(端口 8124)→ `/health` 200 → 日志**未出现**该告警 ✓。 +- 顺带验证 trace 中间件:响应头 `x-trace-id: trc-48349abc526e4055` / `x-request-id: req-612a3f2acb8349d6` ✓。 +- **`.env` 已还原**,`key_present: False`(原状)✓。 + +### 第 3 项 · 交易阻断 + 放行各一次 ✅ PASS + +| 场景 | 请求(debug 头 `X-Debug-Role: risk_demo` / `X-Debug-Actor: STAFF-DEMO`) | 响应 | +| --- | --- | --- | +| 阻断(SOP A-2) | CUST-4001 / PROD-161725 / subscribe / 20000 | `blocked=true`,`block_response_code=SUIT_AGE_CONFIRM`,`needs_branch_confirm=true`,`rule_refs=["FM-01"]` | +| 放行(SOP A-3) | CUST-3001 / PROD-510300 / subscribe / 500000 | `blocked=false`,`triggered_rules=["RISK-001","RISK-002"]`,`alert_ids=["ALT-20260910-7BCBBC94"]` | + +**与 SOP 记录的预期逐字一致。** 库内核验: + +- `core_trade`:阻断单 `TRD-20260910-C1D8AA26` **未落库** ✓;放行单 `TRD-20260910-FE545DF0` 已落库(`confirmed`, 500000.00)✓ +- `risk_suitability_log`:CUST-4001 `is_blocked=1` ✓;`risk_alert` 出 `suitability` 单 `ALT-20260910-652280E1`(`pending_review`, score 90)✓ +- `audit_log`:`suitability_block / suitability_alert_created`、`trade_request / suitability_blocked`(`rule_id=SUIT_AGE_CONFIRM`)、 + `trade_request / trade_accepted`(放行)、`risk_judgement / alert_created` ✓ + +### 第 4 项 · 预警聚合行为不变 ✅ PASS + +SOP A-4:CUST-9527 / PROD-510300 / subscribe / 1000 **连发 4 笔**: + +| 笔次 | `triggered_rules` | `alert_ids` | +| --- | --- | --- | +| 1 | `[]` | `[]` | +| 2 | `[]` | `[]` | +| 3 | `["RISK-003"]` | `["ALT-20260910-C8600425"]` | +| 4 | `["RISK-003"]` | `["ALT-20260910-C8600425"]` ← **与第 3 笔同一张单(并入,非新建)** | + +库内核验: + +- `risk_alert` CUST-9527 当日 **`pending_review` 单数 = 1** ✓;`payload.events` **长度 = 2** ✓ +- `audit_log` 呈 **`alert_created`(1184) → `alert_appended`(1187)`** 序列,聚合语义在审计层可证 ✓ +- 4 笔交易全部落库(`TRD-20260910-B66CB0D8` / `B39E1456` / `83D58A41` / `79EDC16E`)✓ + +> 注:`risk_alert.status` 实际取值为 **`pending_review`**(文档口头简称「pending」)。首次核库我按 `status='pending'` 查得 0 条, +> 系**断言写错**而非系统问题,修正后为 1 ✓。 + +### 第 5 项 · Redis 不可用 → 退回进程内锁 ✅ PASS(**口径替代,已留痕**) + +- **替代说明**:本机 Redis 为 `redis-server.exe`(PID 7716,Services 会话),**无文档化启动方式、`redis-server` 不在 PATH、 + `sc.exe` 被本机安全策略拉黑**,杀掉后无法保证原样拉起(且 `FLOW.md` 明示其非权威数据源)。 + 改用 **`REDIS_URL=redis://127.0.0.1:6399/0`(死端口)**:应用侧抛 `ConnectionError`,与真宕机**同一条代码路径** + (`redis_gateway._ensure()` → 命令异常 → `locks._acquire_redis` 的 `except Exception` → `unavailable` → 进程内锁),且完全可逆。 +- 结果:启动**不被阻塞**(1s,`/health` 200)→ 交易返回 `blocked=false`,`alert_ids=["ALT-20260910-7BCBBC94"]`,**业务照常完成** ✓ +- 日志**明确出现 2 次** `Redis 锁不可用,退回进程内锁`: + `lock:agg:event:CUST-3001:2026-09-10`(`alert_service` 聚合锁)、`lock:l3:CUST-3001`(`profile_l3` L3 锁)✓ +- 底层异常:`redis.exceptions.ConnectionError: Error 10061 connecting to 127.0.0.1:6399`,被正确吞噬未上抛 ✓ + +### 第 6 项 · Redis 恢复 → 行为一致 ✅ PASS + +- `redis.Redis.from_url(settings.redis_url).ping()` → **True**(`redis://127.0.0.1:6379/0`)。 +- 同笔交易 → `blocked=false`,`alert_ids=["ALT-20260910-7BCBBC94"]`(并入同单)✓ +- 日志中 `退回进程内锁` **命中 0 行** → 确实走 Redis 锁路径,**未降级** ✓ +- `triggered_rules` 由 `["RISK-001","RISK-002"]` 变为 `+RISK-003`:CUST-3001 当日累计至第 3 笔触发频次规则, + **属预期累积行为,非不一致**。 + +### 第 7 项 · 调换装饰器验证 T-202 守卫变红 ✅ PASS(本项价值最高) + +守用例:`tests/test_audit_middleware.py::test_audit_middleware_runs_inside_trace_middleware:210`。 + +| 步骤 | 结果 | +| --- | --- | +| ① 基线 | `1 passed`(绿) | +| ② 调换 `main.py` 两装饰器(trace 先注册 → 变最内层;audit 后注册 → 变最外层) | 已调换,行序:`trace_middleware`@89 / `audit_middleware_entry`@117 | +| ③ 跑守卫 | **`1 failed`** ✓ 变红 | +| ④ 失败断言原文 | `AssertionError: audit 若先于 trace 执行,此处会静默为空` / `assert ''` @ `test_audit_middleware.py:221` | +| ⑤ 还原 | `sha256` 前后同为 `d9d7559445dffd2a4576cdc5454030d3acae5fd4c076de8daa5a35f53802510a` → **字节级一致** ✓ | +| ⑥ 再跑守卫 | `1 passed`(转绿)✓ | +| ⑦ 现场核对 | `git status --short app/main.py` 空、无残留 `*.smoke.bak` ✓ | + +→ **守卫确实能抓住「audit 先于 trace 执行 → 全站审计静默丢 trace_id 且不报错」这一回归**。设计意图达成。 + +--- + +## 三、冒烟副产物与现场恢复 + +### 3.1 集成测试失败(防护行为,非回归) + +冒烟后在真 MySQL 上跑全量:**507 passed / 3 failed**,全部落在 `tests/test_integration_risk.py`: + +- `_assert_a3_clean` → `CUST-3001 今日已有 confirmed 交易(影响 RISK-002 累计/RISK-003 频次口径)`(我的 `TRD-20260910-FE545DF0`) +- `_assert_a4_clean` → `CUST-9527 今日已有 confirmed 交易(A-4 频次计数失真)`(我的 `TRD-20260910-B66CB0D8`) +- `test_a7_handle_state_machine_compliance_forbidden` → 级联 `KeyError: 'a3_alert_id'` + +→ 这正是**核查单⑥「演示/测试同日交叉防护」前置断言**在按设计工作(交接文档已预警)。 + +### 3.2 演示库重灌(用户拍板:现在重灌) + +按 `docs/项目框架设计/演示SOP-风控模块.md §2` 脚本化方式执行(`MYSQL_PWD` 传密码,避开 `reset.ps1` 交互 `-p` 卡死): + +1. `DROP DATABASE IF EXISTS jinrong_core; DROP DATABASE IF EXISTS jinrong_agent;` +2. 依序灌 11 个 SQL:`scripts/core/00~06` → `表设计/01-mysql-共用底座.sql` → `表设计/02-mysql-agent专用.sql` + → `scripts/agent/seed-aml-list.sql` → `scripts/demo/prepare_risk_demo.sql` +3. `python scripts/sync/sync_advisor_rel.py` + +**完成标志三项全部吻合**:AML 名单 **8** 条 · `prepare_risk_demo` **7** 行测评(`expires_at` 剩余 **275** 天)· +`sync_advisor_rel` upserted **33** rows。 + +### 3.3 全量复跑(用户要求「改完后再跑一次」) + +**`510 passed, 656 warnings in 9.52s`** → **复绿** ✓ + +现场状态:`.env` 已还原(key 为空、`REDIS_URL` 为 6379)✓ · `app/main.py` 字节级还原且 git 无改动 ✓ · 无残留 `.smoke.bak` ✓。 + +--- + +## 四、回写清单 + +| 文档 | 改动 | +| --- | --- | +| `docs/项目框架设计/TODO-架构改进.md` | T-107 手工冒烟勾选 + 完成判定块三勾 + 状态块改「全部闭环」+ push 状态纠正 | +| `docs/交接文档-架构改进.md` | **v1.1 → v1.2**;状态块重写(push 纠正 + 两处替代口径留痕);§7.3 全勾;§10 重写为结项 | +| `docs/交接文档-基金转换.md` | §9「另一条线的遗留」改为**已闭环**,纠正 push 表述 | +| `docs/memory/MEMORY.md` | §0 末尾「待办两条线」改为「架构改进线已结项」+ push 纠正常见坑 | +| `docs/memory/TODO.md` | 模块侧开放项「架构改进线 · 收尾」勾选并附七项证据摘要 | +| `docs/memory/ITERATION.md` | 新增 **2026-09-10 架构改进线收尾闭环** 迭代行 | +| `docs/memory/2026-09-10.md` | **本文件(新建)** | + +--- + +## 五、下一步 + +**架构改进线:结项,无待办。**(可选:push 后打里程碑 tag,待用户拍板) + +**基金转换线:第 4 步「产出开发计划」** —— 输入已齐备: +`docs/项目框架设计/架构设计-基金转换交易.md` §15(任务映射 T-0~T-13)+ §15.1(依赖拓扑与并行分组)+ §12(风险表); +产出落点 `docs/项目框架设计/开发计划-基金转换交易.md`(待创建)。 diff --git a/docs/memory/FRAMEWORK.md b/docs/memory/FRAMEWORK.md index ce84b97..5d93520 100644 --- a/docs/memory/FRAMEWORK.md +++ b/docs/memory/FRAMEWORK.md @@ -39,6 +39,7 @@ | 代理人助手 Agent | L2 画像、RAG、草稿 | L1 只读、Milvus | 空壳 service(chat 骨架已通) | | 数据分析 Agent | NL→SQL→解读 | Core RO、画像只读 | 空壳 service(chat 骨架已通) | | 风控监测 Agent | 预警、L3、R-02 适当性 | 交易事件、AML 名单 | **已实现 B1~B7 + C1~C3(对话线四 Tool + risk 分支 StateGraph,A-6 验收)**;追加 FR-8/9/10(C4~C6:集中度/时效升级/行为链)方案已定稿待编码 | +| **基金转换(convert)交易** | 转出赎回 + 转入申购、逐批次计费、补差费、批次维护 | Core 写(仅 `app/gateway/`)、`core_share_lot` 批次表、规则引擎 | 🟡 **第 5 步进行中**(**T-0 / T-0b / T-1 / T-2 / T-2b 已完成(609 passed),下一步 = T-3**):PRD **v0.9.1** + 架构 **v1.0** + 开发计划 **v1.0**(T-3~T-13 待实现);入口 `docs/交接文档-基金转换.md` | | Core 只读层 | L0 事实查询 | `jinrong_core` | **已实现 + 已接对话 Tool(T-04)**:core_ro 经 app/tool/core_tools.py 三只读 Tool(L0/持仓/流水)进 chat;风控扩展查询照旧 | | 共用底座 | 会话、审计、输入防护 | MySQL 11 表 + Redis | **已接入(2026-09-07)**:会话(T-06 session_repository + memory_service 窗口)、审计中间件(T-02 http_access + input_guard_log 双写)、agent_tool_call Tool 留痕(T-04)、输入防护(T-03 input_guard:注入词表纯函数检测 + oversize + Redis 固定窗口限流,chat 链路 限流→注入/超长→归属) | | 对话编排 | LangGraph StateGraph + DeepSeek | langgraph/langchain-openai | **已实现(T-07 骨架 + T-04 Tool 节点 + C1 风控四 Tool + C5 追加 query_overdue_alerts 共五只 + C2 risk 分支)**:tool(分组关键词意图→Tool,customer/advisor/risk)→llm→guard(免责声明);analyst 分支与 LLM intent 待后续 | diff --git a/docs/memory/ITERATION.md b/docs/memory/ITERATION.md index 2dcd385..7bd0a9b 100644 --- a/docs/memory/ITERATION.md +++ b/docs/memory/ITERATION.md @@ -12,3 +12,11 @@ | 2026-09-07 | 《实现方案-风控追加需求v1.1-C4C6.md》定稿:独立 AI 评审 FAIL(P0×1/P1×4/P2×6)→ 修订闭环;C4~C6 编码依据可照抄 | 开发流程第 2 步(另一 AI 验证挑毛病) | 实现方案文档 | | 2026-09-07 | 全库文档同步:分支口径修正(`feature/risk` → 实况 `risk-control-agent`,与 main 分叉 67/4)+ T-21 评审闭环 / 425 绿 / T-03 / C1~C3 状态落账 + 新建项目根交接文档.md | 文档与实况不一致(用户排查要求) | MEMORY / TODO / REQUIREMENTS / FRAMEWORK / FLOW / ENVIRONMENT / ITERATION / PRD / 架构设计 / 开发计划 / README / 交接文档 | | 2026-09-07 | **阶段一「对齐 main 基准」AL-01~AL-08 完成**(逐项独立 commit bb244f4~b5fd52e):SUIT-001~008 换核为 core_ro.check_suitability(C×R 矩阵 + match_result 五值 + JR-AST/FM 契约)· risk_suitability_log 重建 21 列 · Core 表 is_hnw/风评 expires_at 七新列 · 种子 33 客户/14 产品 · 全量 **406 passed 0 failed 0 skipped**(425→406,SUIT 测试重写)· uvicorn 冒烟三端点通过 · risk-m1 补打(3c07de6)· 六项拍板全落定 | 阶段一执行(用户拍板 main = 需求唯一基准);基线变更与 SUIT 体系退役需落账 | MEMORY / TODO / REQUIREMENTS / FRAMEWORK / FLOW / ENVIRONMENT / ITERATION / README / 交接文档 | +| 2026-09-08 | 前端接入面交付:**方案 B** 会话管理三端点(`8328c24`)+ **方案 C** SSE 流式对话(`01ec5fc`),测试 482→**503 绿** | 前端 React 多 Agent 入口需后端接入面先就绪(用户拍板方案 B+C) | chat.py / session_repository / agent_service / 测试 / README / 交接文档 | +| 2026-09-09 | **架构改进与稳定性加固 T-101~T-202**:Redis 双层分布式锁(T-201)+ 无 key 启动告警(T-107)+ 审计 trace 顺序守卫(T-202)+ 文档口径勘误(词表 45 / Tool 5 / Agent 表 6);pytest 503→**510 绿**(+7);接口契约与表结构零变更 | 架构梳理发现 24 项待处理,两轮独立审核通过;用户拍板「补告警 + 换 Redis 锁」;合并 main 前只做低风险项 | 交接文档-架构改进 / 架构设计-风控模块 / MEMORY / 开发计划-架构改进 / TODO / 测试 +7 | +| 2026-09-10 | **架构改进线收尾闭环**:§7.2 七项手工冒烟补跑 **7/7 PASS**(无 key 告警 / 交易阻断放行 / 预警聚合 / Redis 降级退回进程内锁 / Redis 恢复零降级 / 调换装饰器使 T-202 守卫变红后字节级还原);**核实并纠正「`037ce7e` 未 push」的过期表述**(远程 `risk-control-agent` = `fffb78a` = 本地 HEAD,早已推送);冒烟残留按 SOP §2 重灌双库清除,全量 **510 passed** 复绿 | 架构改进线唯一缺口是手工冒烟未执行;交接文档/项目记忆的 push 状态与实际 git 状态不符(以 `git ls-remote` 为准) | TODO-架构改进 / 交接文档-架构改进 / 交接文档-基金转换 / MEMORY / TODO / ITERATION / 当日日志 | +| 2026-09-10 | **基金转换(convert)线设计闭环**:PRD **v0.9**(33 条外审闭环)+ 架构 **v1.0**(独立评审通过,13 条建议 **0 悬空**)+ 执行期风险 5 条 + **D18/D19/D20**;**DB 账号分离定案**(用户拍板「按真实项目走」,接主架构 C1);**新建 `docs/交接文档-基金转换.md`** | `PRD-风控监测Agent` FR-1 一期显式拒收 convert,需放开;合规口径须外部事实核验(证监会公告〔2025〕22 号);账号方案为跨线交叉点 | PRD-基金转换 / 架构设计-基金转换 / 评审待办 / 处置表 / **交接文档-基金转换(新)** / MEMORY / TODO / 交接文档.md / 交接文档-架构改进 / 开发计划-架构改进 | +| 2026-09-10 | **基金转换(convert)线第 4 步开发计划闭环**:产出 `docs/项目框架设计/开发计划-基金转换交易.md`(**v1.0,1,047 行**,T-0~T-13 逐任务改法 + DoD + §0 速览 + §1.4 **十五条代码事实核对表** + §1.5 **七条实现级裁定 R-a~R-g** + §12 **十六条回归面** + §13 PRD 19 条验收映射 + §14 实现级风险表);**经四轮独立子代理审核收敛:12 条 → 接受 11 / 驳回 1(附实测证据)/ 0 悬空**(一轮补 R15 真 MySQL 集成用例 `test_integration_risk.py:426-437` + R16 redeem 用例 + **R-c 拆「降级规则 + 覆盖率补偿」双条** + F-11 计数 11→13;二轮抓出 **R-a 并发论证不成立**——执行权锁是**请求粒度** → 增第 ③ 步 `IntegrityError` 回退 + T-6 并发用例;三轮修 §14「四道→五道」口径不一致 + **实测驳回**「SQLAlchemy 会抛 `PendingRollbackError`」的推测;四轮终验通过、无「声明已改实际未改」残留);**三项实测加固**(sqlite 3.50.4 不支持 `ON DUPLICATE KEY UPDATE`,且 `WHERE NOT EXISTS`/`FROM DUAL` 两个候选写法**方言互斥**;SQLAlchemy 2.0.51 异常后同事务可用;pydantic 2.13.4 `Optional+gt` 保 422) | AIcoding 第 4 步;用户要求「写完拉起子进程审核,改完后再审,审到没问题才能进行下一步」;架构/PRD 存在 2 处口径不一致需裁定(W-1/W-2),且实现级空白 7 处需收口 | 开发计划-基金转换交易(新) / 交接文档-基金转换(v1.2) / MEMORY / TODO / ITERATION / skill `ai-coding-step4-dev-plan`(新) | +| 2026-09-10 | **基金转换线:T-0 / T-0b / T-1 落地 + PRD 费率分类修正(v0.9 → v0.9.1)**:**T-0** sqlite/MySQL 结构对齐(`core_holding` 列名+PK、补 `core_product_nav`)+ 建库自校验 · **T-0b** DB 账号分离 D20(`xh_core_ro` / `xh_core_rw` / `xh_agent_rw`)· **T-1** 数据层(`core_fee_rule`、`core_share_lot`、`core_convert_lot_detail`、`risk_convert_detail` 四表 + `07-seed-fee-rule` / `08-seed-share-lot` / `09-seed-org` 三个种子 + reset 链),全量 **516 passed / 3 skipped**(批 0 后基线,零回归),T-1 数据层断言 **8/8 PASS**;**外审漏网的费率分类错误修正**——`PROD-005827` 由「主动偏股 0.0080」归位「其他混合型 0.0050」(22 号文 §8 分档),主示例转入方改真主动偏股 `PROD-003095`,`09-seed-org` 按真实「管理人全产品线」重排为 v1.1 | 用户驳回「那是两种类型,怎么能混呢?」并强调「按真实业务走,强调很多遍了,这个项目要贴近真实,是要上线的」;根因是**五轮评审只做内部自洽核对,从未做「产品类型 ↔ 费率档」匹配核对** | PRD-基金转换(v0.9.1) / 开发计划-基金转换交易 / 交接文档-基金转换(v1.3) / 架构设计-基金转换交易 / AGENTS.md / `09-seed-org.sql`(v1.1) / `verify_convert_seed.py` / MEMORY(自检第 11、12 问) / TODO / FRAMEWORK | +| 2026-09-10 | **基金转换线 T-2(纯函数包)+ T-2b(实算回填)完成**:新增 `app/service/convert/` **7 文件**(`__init__` / `types`(frozen dataclass + 3 归一工具)/ `calc` / `fee` / `nav` / `lot_bootstrap` / `errors`(`ConvertError` + **11** 子类))+ 新增 `tests/test_convert_calc.py` **93 用例**(12 类,含 **HALF_UP 反向自证**与**纯函数零 IO 依赖断言**,把 DoD 的「人工 grep」升级为硬断言);**重写 `scripts/dev/calc_convert_demo.py`** —— 去掉脚本内公式副本、改调生产 `calc.py`、末尾 15 项自校验(不一致即退出码 1),使其兼作「PRD §5.3 ↔ 生产口径」一致性门禁;新增实现级裁定 **R-h**(`plan_lots` 零剩余不触发强制处置);两处文档计数差异已留痕(异常实为 11 个而非 8 个 / 架构 §7 补 `hold_days`·`rounding_diff`·`ensure_batch_limit` 三签名);全量 **609 passed / 3 skipped**(516 → +93,零回归) | 用户「继续推进到结束」;T-2 是并行组 A(T-3/T-4/T-5)与 T-2b 的唯一前置;原实算脚本自带公式副本 = 与 D18 同类的漂移风险 | 开发计划-基金转换交易(§1.5 R-h + §4.2/§4.3 执行记录 + DoD 勾选) / 架构设计-基金转换交易(§7 签名回填 · §8.3 `FEE_RULE_MISSING` 注 · §15 T-2 完成) / PRD-基金转换(§5.3 数值来源) / 交接文档-基金转换(v1.4) / AGENTS.md / `app/service/convert/*`(新) / `tests/test_convert_calc.py`(新) / `scripts/dev/calc_convert_demo.py` / MEMORY / TODO / FRAMEWORK / ITERATION | +| 2026-09-10 | **基金转换线第 5 步开工 · 第 0 批门禁 T-0 + T-0b 完成**:基线 510 → **516 passed / 3 skipped**。**T-0**:`tests/_ddl.py` 重写 `core_holding`(`qty`/`cost_amount`/`as_of`/`pnl_pct` + PK + `uk_cust_product`)+ 新增 `core_product_nav` + `_assert_ddl_aligned()` 建库自校验;2 处测试 INSERT 改 `qty` **并补 3 个 NOT NULL 列**(**原计划漏项**);`test_db.py` +3 用例(含**门禁反向验证的自动化用例**)。**T-0b(D20 账号分离)**:新增 `scripts/core/00-grant.sql`(3 账号逐表授权,不进 reset.ps1);`settings.py` +6 项;`db.py` `get_engine(db, role)` + `_resolve_credentials`(缓存键 `(db, role)`,未配置回退 `mysql_user`);`core_ro`→`ro` / `gateway_repository`→`rw` / `risk_repository`·`session_repository`→`rw` 显式;`conftest.py` 4 处→`admin`(R-e)。**两处口径修正**:① `audit_log` 实授 **`SELECT, INSERT`**(字面「只授 INSERT」会连读一起剥夺,致 `has_engine_error_audit`/`list_audit_events` 失权)② conftest 必须 admin。**遗留环境操作**:`00-grant.sql` 需管理员执行 + 写 `.env`(3 条权限断言未配置时自动 skip) | AIcoding 第 5 步(T-0/T-0b 为阻断前置);用户「你进行下一步」 | tests/_ddl.py · tests/test_db.py · tests/conftest.py · tests/test_chat_tools.py · tests/test_concentration_c4.py · app/utils/db.py · app/config/settings.py · 4 个 Repository · **scripts/core/00-grant.sql(新)** · 开发计划-基金转换(§3.3) · 架构设计-基金转换(§11.1 实施记录) · 交接文档-基金转换(v1.2) · MEMORY / TODO / 当日日志 | diff --git a/docs/memory/MEMORY.md b/docs/memory/MEMORY.md index 6d04972..c2b0c14 100644 --- a/docs/memory/MEMORY.md +++ b/docs/memory/MEMORY.md @@ -11,6 +11,8 @@ **当前进度:** 需求与表设计已定 · **风控模块 B1~B9b 全部完成(M2 tag risk-m2),M4 复核已闭环(2026-09-07),风控阶段 B 正式完结** · **Wave 0 已完成(2026-09-07,经独立 AI 评审闭环)**:T-01 JWT 鉴权(auth_service Auth SDK + deps 工厂替换 + X-Agent-Type 准入矩阵)/ T-02 审计中间件(http_access + 独立 request_id + 4xx/500 统一错误体 + input_guard_log 双写)/ T-06 chat 最小闭环(POST /api/chat + 会话落库 + Redis 窗口)/ T-07 LangGraph StateGraph 骨架 + DeepSeek(无 key 降级)· **T-04 Core RO Tool 节点已完成(2026-09-07)**:app/tool/core_tools.py 三只读 Tool + tool_service(意图/归属校验/run_tool)+ 图 tool 节点 + agent_tool_call 落库 + `utils/authz.py` 公共鉴权留痕,**321 测试绿(首评+复审双闭环)**。风控阶段 C 已完成(2026-09-07,345 绿,tag `risk-m3`,A-6 对话线验收通过,独立 AI 评审 PASS P0=0)。T-03 输入防护已完成(2026-09-07,378 绿,独立 AI 评审 PASS with findings P0=0):app/service/input_guard.py 注入词表 45 条纯函数检测 + oversize 4000 + actor 级 Redis 固定窗口限流 30 次/分(fail-open);chat 链路顺序 = 鉴权→准入→空白→限流 429→注入/超长 400→归属→会话,被拒 fail-fast 不建会话,blocked 落 input_guard_log(ENUM 四值已用满)。**阶段一「对齐 main 基准」AL-01~AL-08 已完成(2026-09-07,逐项独立 commit bb244f4~b5fd52e):适当性判定换核为 main 的 core_ro.check_suitability(C×R 矩阵表数据驱动,match_result 五值/JR-AST-012/FM-01/FM-03/JR-AST-PRO 契约,SUIT-001~008 退役);risk_suitability_log 重建 21 列;Core 表加 is_hnw/风评七新列(expires_at);种子 33 客户/14 产品;全量 406 passed 0 failed 0 skipped + uvicorn 冒烟三端点通过;risk-m1 已补打(指向 3c07de6)**。**下一步:阶段一验收门(**合并 main 前的最终交付检查点,非分支开发阻塞**;用户浏览器目视确认 UI)→ AL-09 合并 main → AL-10 PRD v1.2 → AL-11 docx 登记 → 阶段二 C4~C6(**已完成并打 `risk-m4` tag**)/ 前端 React 多 Agent 入口(HashRouter `web/` init)。****开发在分支 `risk-control-agent`(与 origin/main 已分叉:领先 84 提交 / 落后 0,origin/main 为分支祖先;**分支已推送远程,origin/risk-control-agent 同步于 `1d00e53`,本地跟踪已建立**;**架构改进与稳定性加固 T-101~T-202 已于 2026-09-09 完成并推送 origin/risk-control-agent(2d0e2fa..f733fc2 快进,pytest 510 绿,较 503 基线 +7 例:T-201.3 双层锁 6 例 + T-202 trace 顺序守卫 1 例),含 Redis 双层分布式锁 T-201 与审计中间件 trace 顺序守卫 T-202,均为文档/告警/锁原语修正、接口契约与表结构零变更;远程已提示走 PR 合并 main;**合并(risk-control-agent → main)由合并执行人负责,不归用户管**——AI 只负责把分支推到远程 + 更新文档,不得主动发起 PR / 执行合并,移交执行人按《合并注意事项-风控模块并入main.md》操作。** 旧文档中的 `feature/risk` 为过时口径)。** +**⚡ 并行新线 · 基金转换(convert)交易(2026-09-10 设计+开发计划闭环,**第 5 步进行中:T-0 / T-0b / T-1 已完成**):** `PRD-风控监测Agent` FR-1 一期显式拒收 convert,本线将其放开。**AIcoding 第 1~4 步已完成**:PRD **v0.9.1 定稿**(33 条外审闭环 + 2 处架构回填 + **费率分类修正**)→ 架构 **v1.0 定稿**(独立评审通过,13 条建议 **0 悬空**,接受 10 / 修正性接受 3 / 驳回 0)→ 门控 **M-7 已满足** → **开发计划 v1.0 已产出(`docs/项目框架设计/开发计划-基金转换交易.md`,**1,047 行**)并经**四轮**独立子代理审核收敛(**12 条意见 → 接受 11 / 驳回 1(附实测证据)/ 0 悬空**)。**第 5 步进行中:T-0 + T-0b + T-1 + T-2 + T-2b 已于 2026-09-10 完成(609 passed / 3 skipped;T-1 断言 8/8 PASS;T-2 纯函数包 7 文件 / 93 用例;T-2b 实算脚本 15/15 一致)**,**下一步 = T-3(并行组 A:T-3 / T-4 / T-5 可同时开工)**。**开工前置两个阻断项(✅ 2026-09-10 均已完成)**:**T-0**(sqlite/MySQL 结构对齐:`core_holding` 列名+PK **+ 补 `core_product_nav`**,建库自校验,**同步改 2 处测试 INSERT 并补 3 个 NOT NULL 列**)与 **T-0b**(**DB 账号分离 D20**:`xh_core_ro` SELECT 全库 / `xh_core_rw` **4 表写**无 DELETE·DDL / `xh_agent_rw` **`audit_log` 只授 SELECT+INSERT**(不可改删);**`conftest.py` 四处 engine 已显式 `role="admin"`**)。**遗留环境操作**:`scripts/core/00-grant.sql` 需管理员执行一次 + 写 `.env`,未执行时 3 条权限断言自动 skip,不阻塞 T-1。设计资产五件套:`docs/PRD/PRD-基金转换交易.md` · `docs/项目框架设计/架构设计-基金转换交易.md` · **`docs/项目框架设计/开发计划-基金转换交易.md`(新)** · `评审待办-风控主架构与基金转换.md` · `基金转换-审查意见处置表.md`。**开工前必读开发计划 §1.4(15 条代码事实)/ §1.5(7 条实现级裁定 R-a~R-g)/ §12(16 条回归面)** —— 尤其是 **R-a(弃用方言 UPSERT)· R-b(流水写 redeem/subscribe 不写 convert)· R-e(conftest 用 admin)** 三条,不读必踩。**交接入口:`docs/交接文档-基金转换.md`**。合规基准 = 证监会公告〔2025〕22 号。 + **仓库地图:** | 路径 | 状态 | 说明 | @@ -34,7 +36,7 @@ | `scripts/core/*.sql` + `reset.ps1` | **已实现** | Core 模拟库 DDL + 种子 | | `scripts/agent/` `scripts/demo/` `scripts/dev/` | **已实现** | AML 名单种子 + 风控演示数据 + subscribe_alerts/rebuild_alerts + issue_dev_token(JWT 签发) | | `scripts/sync/*.py` | **已实现** | 归属同步 + Neo4j 全图 | -| `tests/` | **已实现** | 36 个测试模块 510 用例(sqlite 隔离;DDL 单一事实源 `_ddl.py`;`test_integration_risk.py` 走真 MySQL + TRD-TEST- 前缀隔离;pytest 依赖装在系统 Python 3.13.14;阶段一基线 425→406,前端接入 B/C 后 **482→503**,架构改进 T-201.3+T-202 后 **503→510**)。**改路由必同步 `tests/test_main.py::test_all_routers_mounted` 的路径清单,否则必红** | +| `tests/` | **已实现** | 36 个测试模块 516 用例(sqlite 隔离;DDL 单一事实源 `_ddl.py`;`test_integration_risk.py` 走真 MySQL + TRD-TEST- 前缀隔离;pytest 依赖装在系统 Python 3.13.14;阶段一基线 425→406,前端接入 B/C 后 **482→503**,架构改进 T-201.3+T-202 后 **503→510**)。**改路由必同步 `tests/test_main.py::test_all_routers_mounted` 的路径清单,否则必红** | | `docs/需求拆解/` | 已定 | 场景 P0、矩阵、合规原文 | | `docs/PRD/PRD-风控监测Agent.md` | **已冻结(v1.1)** | 风控 PRD v1.0 + v1.1 追加 FR-8/9/10(§4A)+ 规则表附录 | | `docs/项目框架设计/实现方案-风控追加需求v1.1-C4C6.md` | **已定稿** | C4~C6 编码依据(经独立 AI 评审修订闭环);分支/进度速览另见项目根 `交接文档.md` | @@ -42,6 +44,10 @@ | `docs/项目框架设计/表设计/` | 已定 | Agent 共用 11 表 + agent 专用 SQL | | `docs/项目框架设计/Core模拟底座/` | 已定 | 无真实 Core 时的 L0 方案 | | `web/` | **不存在** | 前端 React 待 init | +| `docs/PRD/PRD-基金转换交易.md` | **已定稿(v0.9.1)** | 基金转换线需求权威(FR-C1~C16 / §4 表结构 / §5 接口 / §9 验收);⚠️ **代码未实现** | +| `docs/项目框架设计/架构设计-基金转换交易.md` | **已定稿(v1.0)** | 基金转换实现依据(D1~D20 / §5 事务 / §11.1 DB 账号 / §15 任务 T-0~T-13);⚠️ **代码未实现** | +| `docs/项目框架设计/开发计划-基金转换交易.md` | **已定稿(v1.0 · 2026-09-10)** | **基金转换实现计划**(T-0~T-13 逐任务改法 + DoD + §1.4 **十五条代码事实核对表** + §1.5 **七条实现级裁定 R-a~R-g** + §12 **十六条回归面** + 四轮审核记录 + **§3.3 第 0 批 + §4.1 第 1 批执行记录**);**T-0/T-0b/T-1 已实现**,T-2 起待实现 · **开工必读** | +| `docs/交接文档-基金转换.md` | **新增(2026-09-10)** | **基金转换线交接入口**——给下一会话 AI,读完即可开工 | **本地 bootstrap(首次):** 完整步骤与前置说明见 `FLOW.md` §0(权威),速览: @@ -64,6 +70,8 @@ **下一步开发(见 TODO):** **模块侧交付完毕(2026-09-07:全量 pytest 482 绿 + 接口实调验收通过——suitability/check 阻断+放行、simulate/trade 阻断、三条鉴权边界 401/403/403 契约零偏差;`risk-m1~m4` tag 齐)。合并 main 已移交合并执行人,操作手册《docs/项目框架设计/合并注意事项-风控模块并入main.md》(含基底锁定/20 冲突裁决/14 静默文件/三硬伤/合并后必测,实测数据编制)。模块侧开放项:chat 链路 risk_suitability_log.actor_id 落 SYSTEM 待评估 / 前端 React 多 Agent 入口(`web/` 未 init,归属待拍板)。**演示走查按 `docs/项目框架设计/演示SOP-风控模块.md`(debug 头通道仍有效;JWT 通道签发用 `scripts/dev/issue_dev_token.py`;演示库已按 AL-08 expires_at 新口径重灌)。知识库入库:`python scripts/kb/build_kb.py`(先启 Ollama;**Milvus 数据路径必须纯英文**——faiss 不支持中文路径,本机 .env 已配 C:/Users/YUAN/.jinrong/milvus/)。 +**另(2026-09-10 待办)**:① **基金转换线**第 5 步进行中(**T-0 / T-0b / T-1 / T-2 / T-2b 已完成,下一步 = T-3**;设计 + 开发计划均已闭环,入口 `docs/交接文档-基金转换.md`);② ~~架构改进线收尾~~ —— **2026-09-10 已闭环结项**:§7.2 七项手工冒烟补跑 **7/7 PASS**、冒烟残留按 SOP §2 重灌双库清除、全量 **510 passed** 复绿;**「`037ce7e` 未 push」的旧表述已作废**(实测 `git ls-remote`:远程 `risk-control-agent` = `fffb78a` = 本地 HEAD,`037ce7e` 在其祖先链上,早已推送;本地 `git branch -vv` 显示 `origin/risk-control-agent: gone` 只是远程跟踪引用失效,`git fetch` 即恢复,非远程分支被删)。入口 `docs/交接文档-架构改进.md`。 + **禁止(改代码前必记):** Core 正式 C1~C5 不可被画像覆盖 · 审计表只 INSERT · 代理人草稿不外发 · 仅 R-02 可阻断交易 · 四 Agent 不互调 LLM。 **里程碑 tag 中文对照(tag 本体保留英文原名——git 技术标识符需跨平台/脚本稳定引用,符合「技术字面量保留英文」约定;中文名经对照表统一):** @@ -145,7 +153,7 @@ Core 模拟:scripts/core/reset.ps1 · 文档 docs/项目框架设计/Core模 种子:scripts/agent/seed-aml-list.sql(AML 名单)· scripts/demo/prepare_risk_demo.sql(reset 后重跑) 依赖:requirements.txt(LangGraph + langchain-core/openai + FastAPI + SQLAlchemy) 启动:uvicorn app.main:app --reload → GET /health -测试:python -m pytest(510 用例;集成测试需本机演示数据,未灌库时自动 skip) +测试:python -m pytest(516 用例;集成测试需本机演示数据,未灌库时自动 skip) 运维/演示脚本:scripts/demo/subscribe_alerts.py(订阅推送演示)· rebuild_alerts.py TRD-xxx(引擎异常补偿重放) JWT 联调:python scripts/dev/issue_dev_token.py --sub STAFF-30001 --roles risk_officer(+ Authorization: Bearer + X-Agent-Type) 配置:.env(见 .env.example) @@ -172,6 +180,8 @@ RBAC 联调账号:scripts/dev/rbac-seed-reference.md | `docs/需求拆解/` | 业务原文(场景、矩阵、合规) | | `docs/项目框架设计/` | 表结构、JWT 手册、Core 模拟、技术版本 | | `docs/业务记忆管理/` | Redis 短期 vs MySQL/Milvus/Neo4j 权威记忆 | +| `docs/交接文档-基金转换.md` | **基金转换线开工入口** —— 接手本线先读这一份(不必重读代码) | +| `docs/PRD/PRD-基金转换交易.md` · `docs/项目框架设计/架构设计-基金转换交易.md` | 基金转换需求与实现依据(**v0.9.1 / v1.0,代码未实现**) | 缺 `docs/memory/*` 文件:按 project-memory-kit 同名补回,**禁止空模板盖进度**。 @@ -189,6 +199,7 @@ RBAC 联调账号:scripts/dev/rbac-seed-reference.md 2. 改动属于 api / service / tool / repository 哪一层? 3. 是否需 customer_id 归属与 JWT RBAC? 4. Core 是模拟库只读还是 agent 库读写? -5. 如何验证?(`python -m pytest` 全量(当前 **510 绿**)· uvicorn 启动 + /health · SQL / sync 脚本 · 对照 REQUIREMENTS 验收列) +5. 如何验证?(`python -m pytest` 全量(当前 **516 passed / 3 skipped**,基线 510)· uvicorn 启动 + /health · SQL / sync 脚本 · 对照 REQUIREMENTS 验收列) +6. **当前有哪两条并行线?**(① 风控/架构改进线:**已结项**(510 基线绿、§7.2 七项冒烟 7/7 PASS、`037ce7e` 已核实早已推送);② **基金转换线**:设计闭环,**第 5 步进行中 —— T-0 / T-0b / T-1 / T-2 / T-2b 已完成(609 passed),下一步 = T-3**)——动代码前先确认自己属于哪条线,别混淆前置条件。 大任务:FRAMEWORK/FLOW 与实现状态不符时先更新 memory 再编码(用户确认跳过除外)。 diff --git a/docs/memory/TODO.md b/docs/memory/TODO.md index e4c82ca..5a5d8d0 100644 --- a/docs/memory/TODO.md +++ b/docs/memory/TODO.md @@ -7,6 +7,22 @@ **阶段一 AL-01~AL-08 与阶段二 C4~C6 均已完成(2026-09-07)**:全量 pytest **482 passed 0 failed 0 skipped**(真库集成)✓ · uvicorn 冒烟三端点 ✓ · 接口实调验收 ✓(2026-09-07:suitability/check 阻断+放行、simulate/trade 阻断、三条鉴权边界 401/403/403,契约零偏差)· risk-m1~m4 tag 齐。**合并 main 已移交合并执行人**(操作手册:《docs/项目框架设计/合并注意事项-风控模块并入main.md》,随分支上传),后续模块侧待办见下方。 +**⚡ 并行新线 · 基金转换(convert)**(2026-09-10):**设计 + 开发计划均已闭环** —— PRD **v0.9.1** + 架构 **v1.0** + 独立评审 13 条 **0 悬空**(接受 10 / 修正性接受 3 / 驳回 0),门控 **M-7 已满足**;**第 4 步开发计划 v1.0 已产出并经独立审核**(4 条意见全接受、**驳回 0**,含新增 2 条回归面 R15/R16 + R-c 双条修订);**第 5 步进行中**:**T-0 + T-0b + T-1 + T-2 + T-2b 均于 2026-09-10 完成**(**609 passed / 3 skipped**;T-1 断言 **8/8 PASS**、T-2 纯函数 **93 用例**、T-2b 实算 **15/15 一致**),**下一步 = T-3(并行组 A:T-3 / T-4 / T-5 可同时开工)**。两个阻断前置(**T-0** sqlite/MySQL 列名统一 + 建库自校验 · **T-0b** DB 账号分离 D20:`xh_core_ro`/`xh_core_rw`/`xh_agent_rw`)**均已落地**。**入口:`docs/交接文档-基金转换.md`**(读这一份即可开工);**开工前必读开发计划 §1.4(15 条代码事实)+ §1.5(7 条实现级裁定 R-a~R-g)+ §12(16 条回归面)**。 + +### 基金转换线待办(推荐顺序) + +- [x] **【第 4 步】产出开发计划** —— **完成(2026-09-10)**:`docs/项目框架设计/开发计划-基金转换交易.md`(**1,047 行**,含 T-0~T-13 任务拆解 + 逐条改法 + DoD + **四轮审核记录**)。**四轮独立子代理审核已收敛:12 条意见 → 接受 11 / 驳回 1(附实测证据)/ 0 悬空**(第一轮:补 R15 真 MySQL 集成用例 `test_integration_risk.py:426-437` + 补 R16 redeem 用例 + **R-c 拆「降级规则 + 覆盖率补偿」双条** + F-11 计数 11→13 更正;第二轮:发现**执行权锁是请求粒度**致并发首次建行撞 UNIQUE → **R-a 增第 ③ 步 `IntegrityError` 回退 UPDATE**,并实测排除 `WHERE NOT EXISTS`/`FROM DUAL` 两个不可移植写法;第三轮:修 §14「四道→五道」口径不一致 + 驳回「SQLAlchemy 会抛 PendingRollbackError」的推测;第四轮:终验通过、无「声明已改实际未改」残留)。**本计划新增的增量资产**:§1.3 两处 PRD-架构口径裁定(W-1/W-2)· §1.4 十五条代码事实核对表 · §1.5 七条实现级裁定(**R-a「条件 UPDATE → 首次 INSERT → `IntegrityError` 回退 UPDATE」三步**、R-b 流水写 redeem/subscribe 不写 convert、R-c 批次降级+覆盖、R-d `sum_trades_on_date` 去重、R-e conftest 用 admin 角色、R-f `core_product_nav` 并入 T-0、R-g 自校验落 `_ddl.py`)· §12 十六条回归面 · §13 PRD 19 条验收映射 · §14 新增实现级风险表 +- [x] **【第 5 步 · T-0 · 阻断前置】sqlite/MySQL 结构对齐 + 建库自校验** —— **完成(2026-09-10)**:`tests/_ddl.py` 重写 `core_holding`(`qty`/`cost_amount`/`as_of`/`pnl_pct` + PK + `uk_cust_product`)+ 补 `core_product_nav`(R-f)+ `_assert_ddl_aligned()` 建库自校验(R-g);2 处测试 INSERT(`test_chat_tools.py:81`、`test_concentration_c4.py:74`)改 `qty` **并补 3 个 NOT NULL 列**(原计划漏项);`test_db.py` 新增 `test_core_holding_columns` / `test_core_product_nav_columns` / `test_ddl_alignment_guard_fails_when_column_missing`(反向验证门禁,无需手工改删) +- [x] **【T-0b · 阻断前置】DB 账号分离(D20)** —— **完成(2026-09-10)**:`scripts/core/00-grant.sql` 新建(3 账号逐表授权,**不进 reset.ps1**);`settings.py` +6 项;`db.py` 改 `get_engine(db, role)` + `_resolve_credentials`(缓存键 `(db, role)`,未配置回退 `mysql_user`);`core_ro`→`ro` · `gateway_repository`→`rw` · `risk_repository`/`session_repository`→`rw` 显式;`conftest.py` 4 处→`admin`(R-e)。**两处口径修正**:① `audit_log` 实授 **`SELECT, INSERT`**(字面「只授 INSERT」会剥夺读,致 `has_engine_error_audit`/`list_audit_events` 失权)② conftest 必须 admin。**遗留环境操作**:`00-grant.sql` 需管理员执行 + 写 `.env`,未执行时 3 条权限断言自动 skip +- [x] **【T-1 · 第 1 批】DDL + 种子 + sqlite 同步** —— **完成(2026-09-10)**:`scripts/core/01-ddl.sql` 新建 `core_fee_rule`/`core_share_lot`/`core_convert_lot_detail` + `core_trade` 加 `convert_group_id`+索引 + `core_product` 加 8 列(`subscribe_fee_rate` 等)+ `fee_rate` 补 COMMENT;**新增 `07-seed-fee-rule.sql`**(14 产品 × 5 档,按 22 号文 §10)/ **`08-seed-share-lot.sql`**(58 行持仓 → 61 行批次,Σ remain_qty 恒等于 qty,CUST-9527 跨批次)/ **`09-seed-org.sql`**(管理人 + TA + 申购费率 + 最低持有余额);`reset.ps1` 追加 07/08/09;`02-mysql-agent专用.sql` 追加 `risk_convert_detail`(status ENUM 建表即 5 值);`tests/_ddl.py` 同步 4 表 + `REQUIRED_CONVERT_TABLES` 门禁。**验证**:新增 `scripts/dev/verify_convert_seed.py`(pymysql 等价 reset 流程 + 8 条断言)→ **8/8 PASS**;`pytest -q` → **516 passed / 3 skipped(零回归)**。**3 点需注意**:① mysql 不在 PATH → 用该脚本替代 reset.ps1;② `core_fee_rule` 读取走只读账号(T-6 遵守);③ ~~`PROD-005827` 费率分类口径差异(`mixed` vs 主动偏股)待裁定~~ → **已裁定并修正(PRD v0.9.1)**:`mixed` 归位 `0.0050`(其他混合型),主示例转入方改真主动偏股 `PROD-003095`,`09-seed-org.sql` 升 **v1.1** 按「管理人全产品线」重排,并新增断言 ⑧ 机器化卡口 +- [x] **【T-2 · 第 2 批】`service/convert/` 纯函数包** —— **完成(2026-09-10)**:新建 `app/service/convert/` **7 文件**(`__init__` / `types`(`Lot`/`FeeRule`/`LotAllocation`/`PlanResult` frozen dataclass + 3 个归一工具)/ `calc`(`plan_lots`/`lot_amount`/`lot_fee`/`convert_amount`/`in_qty`/`rounding_diff`/`diff_fee`/`hold_days`/`ensure_batch_limit`)/ `fee`(`pick_fee_rate` 左闭右开)/ `nav`(`ensure_nav_ready`→503 / `is_stale`)/ `lot_bootstrap`(D18 单点,`crc32` 确定性偏移)/ `errors`(`ConvertError` + 11 子类))。**新增 `tests/test_convert_calc.py` 93 用例**(12 类:精度 HALF_UP 反向自证 / 分档边界 6-7-29-30-179-180-364-365 / FIFO 含同 `confirmed_at` tiebreak / 跨批计费 / 双口径 252.40 vs 253.91 / 强制全转与强制赎回 / 恰好等于阈值不触发 / **零剩余不触发(新裁定 R-h)** / PRD §5.3 全链自证 / T+1 起算 / 净值 503 与 stale 分家 / D18 确定性 / §8.3 错误码 / **纯函数零 IO 依赖断言**)。**验证**:`pytest -q` → **609 passed / 3 skipped(+93,零回归)**;`calc_convert_demo.py` → **15/15 与 PRD §5.3 一致**(退出码 0) +- [ ] **【T-3 起】** 仓储与锁(并行组 A:T-3 / T-4 / T-5 可同时开工)→ T-6 / T-7 → T-8 / T-9 / T-11 → **T-10 高风险单列** → T-12 → **T-13「50 并发压测 + 性能补录」**(最后跑,产出 PRD §9 第 18 条实测值)。**T-13 内部顺序**:先 50 并发压测 → 再性能实测补录 → 最后 PRD §5.3 数字回填(详见架构 §15 + 开发计划 §2~§10) + +> **第 0~2 批结果(2026-09-10)**:基线 **510 passed** → 批 0 后 **516 passed / 3 skipped**(+3 T-0 用例 +3 T-0b 引擎用例)→ 批 1(T-1)后**仍 516 passed / 3 skipped**(只加表与种子,未加用例 → **零回归**);**T-1 数据层断言 8/8 PASS**(含新增断言 ⑧:费率档 ↔ `product_type` 匹配,越档即 FAIL)→ 批 2(T-2 + T-2b)后 **609 passed / 3 skipped**(**+93 纯函数用例**,零回归);T-2b 实算脚本 15/15 与 PRD §5.3 一致(退出码 0)。 +> **下一步 = T-3**(`core_ro` 五个新方法:`get_nav_as_of` / `get_redeem_fee_rules` / `list_share_lots` / `sum_remain_qty` / `get_holding` + 新增 `app/repository/share_lot_repository.py`;DoD 见开发计划 §5.1)。**并行组 A 的 T-3 / T-4 / T-5 可同时开工**。 + +> ⚠️ **基金转换的 T-0b 与下方「架构改进第 3/4 批」的 `core_ro` 只读账号是同一件事** —— 已由本线定案为 D20,**不再挂在架构改进线**(该线原「不要做」清单已更新)。 + ## 待办(推荐顺序) - [x] **【阶段一验收门】接口实调验收** —— **通过(2026-09-07 委托 AI 代验:三接口契约 + 三鉴权边界全部符合;前端 web/ 未 init,验收口径为后端接口验证)** @@ -35,6 +51,7 @@ ## 待办(模块侧开放项) +- [x] **【架构改进线 · 收尾】`docs/开发计划-架构改进.md` §7.2 七项手工冒烟 —— 2026-09-10 全部补跑,7/7 PASS**:无 key 启动告警(清空/占位非空各一次)· 交易阻断+放行(`SUIT_AGE_CONFIRM` / `RISK-001,002`)· 预警聚合(CUST-9527 连发 4 笔,第 3/4 笔 `alert_ids` 同单、`pending_review` 计数 1、`audit` 呈 `alert_created→alert_appended`)· Redis 不可用→退回进程内锁(日志 2 次 `退回进程内锁`,交易仍 200)· Redis 恢复→`退回进程内锁` 命中 0 行 · 调换 `main.py` 装饰器→T-202 守卫变红(`assert ''` @ `test_audit_middleware.py:221`)→字节级还原后转绿。**`037ce7e` 早已 push(远程 `risk-control-agent` = `fffb78a` = 本地 HEAD),原「未 push」表述作废**;残留已按 SOP §2 重灌,全量 **510 passed**。入口:`docs/交接文档-架构改进.md` - [ ] chat 链路 `risk_suitability_log.actor_id` 暂落 SYSTEM(run_tool 统一透传需动 core_tools/kb_tools 冻结 func 契约,发起者留痕已由 agent_tool_call 覆盖)——AL-06 遗留,动 Tool 契约前先出方案 - [x] **【前端接入·方案 B】chat 拉侧三端点**(commit `8328c24`,2026-09-08):`GET /api/chat/sessions`(本人+本 Agent 线分页列表)、`GET /api/chat/sessions/{id}/messages`(历史消息升序分页,closed 仍可读)、`POST /api/chat/sessions/{id}/close`(active→closed,重复/非 active 409);chat.py 抽 `_resolve_agent_type`/`_assert_chat_entry`/`_guard_session` 三守卫供四端点共用,POST "" 行为零回归;risk_manager 在对话线数据面保持 403(PRD 4A.1)。测试 482→**494 绿**;独立评审 P0=0,P1(close 并发 rowcount 静默 200)已修复 - [x] **【前端接入·方案 C】SSE 流式对话**(commit `01ec5fc`,2026-09-08):新增 `POST /api/chat/stream`(OpenAI 兼容 chunk:首帧 meta → delta → finish_reason=stop → `[DONE]`);`agent_service.stream_chat` 生成器 + `needs_disclaimer`;`_guard_request`/`_prepare_turn` 与同步端点共用守卫;`session_repository.insert_turn` user+assistant 同事务落库(修评审 P0/P1)。测试 494→**503 绿**。遗留:无心跳帧、断连留空会话待清理 diff --git a/docs/项目框架设计/Core模拟底座/00-方案总览.md b/docs/项目框架设计/Core模拟底座/00-方案总览.md index 8f36cf4..e6c0957 100644 --- a/docs/项目框架设计/Core模拟底座/00-方案总览.md +++ b/docs/项目框架设计/Core模拟底座/00-方案总览.md @@ -68,6 +68,10 @@ scripts/ ├── 04-seed-holdings.sql # 持仓 ├── 05-seed-trades.sql # 流水/交易(含 R-01 大额样例) ├── 06-seed-nav.sql # 净值/行情(C-05) + ├── 07-seed-fee-rule.sql # 【T-1】赎回费分档(14 产品 × 5 档,22 号文 §10) + ├── 08-seed-share-lot.sql # 【T-1】份额批次(按持仓反推,Σ remain_qty = qty) + ├── 09-seed-org.sql # 【T-1】产品机构与申购费率(同管理人/同 TA、费率对) + ├── 00-grant.sql # 【T-0b】DB 最小权限账号(管理员执行一次,不进 reset.ps1) ├── reset.ps1 # Windows:drop 数据 + 重跑 seed └── README.md # 执行顺序 diff --git a/docs/项目框架设计/TODO-架构改进.md b/docs/项目框架设计/TODO-架构改进.md index c7b8b40..5a48397 100644 --- a/docs/项目框架设计/TODO-架构改进.md +++ b/docs/项目框架设计/TODO-架构改进.md @@ -120,7 +120,7 @@ logger.exception( ### 第 1 批完成检查 - [x] `python -m pytest -q` 全绿(实测 **510 passed**) -- [ ] T-107 手工冒烟通过(清空/恢复 key 各一次) +- [x] T-107 手工冒烟通过(清空/恢复 key 各一次)—— **2026-09-10 补跑 ✓**:本机 `.env` 的 `DEEPSEEK_API_KEY` 本就为空(len=0),清空态直接验证:uvicorn 1s 起、`/health` 200、进程存活不阻塞、日志出现「DEEPSEEK_API_KEY 未配置,对话将走降级回复」;「恢复 key 无告警」一支因本机无真实 key,改用**非空占位值**验证条件分支(告警只判 `not settings.deepseek_api_key`,不校验有效性),未出现告警 ✓;验证后 `.env` 已还原 --- @@ -239,12 +239,13 @@ def test_audit_middleware_runs_inside_trace_middleware(client, captured_audit): ## 完成判定(总验收) -> 状态(2026-09-09):**代码侧已完成并提交 `037ce7e`**;下列手工冒烟项**尚未执行**,需 hy3/用户在起飞前补齐。 +> 状态(**2026-09-10 全部闭环**):代码侧自 `037ce7e` 起已推送远程;**§7.2 七项手工冒烟已补跑 7/7 PASS**;冒烟残留已按 SOP §2 重灌清除,全量 **510 passed** 复绿。 - [x] FR-01~FR-06 六项验收标准逐条通过(FR-01/02 文档勘误与标注、FR-03 无 key 告警、FR-04 审计告警、FR-05 Redis 锁、FR-06 中间件顺序测试均已落地) -- [x] `python -m pytest -q` 全绿且用例数 ≥ 503(实测 **510**) -- [ ] uvicorn 冒烟:`/health` 正常,无 key 告警按预期出现 -- [ ] 交易阻断 + 放行路径各一次,预警聚合行为不变 -- [ ] 停/启 Redis 各测一次,业务均可完成 +- [x] `python -m pytest -q` 全绿且用例数 ≥ 503(实测 **510**;冒烟残留曾致 3 条集成前置断言红,重灌后复绿 510) +- [x] uvicorn 冒烟:`/health` 正常,无 key 告警按预期出现(**2026-09-10 ✓**) +- [x] 交易阻断 + 放行路径各一次,预警聚合行为不变(**2026-09-10 ✓** 证据见 `docs/memory/2026-09-10.md`) +- [x] 停/启 Redis 各测一次,业务均可完成(**2026-09-10 ✓** Redis 不可用退回进程内锁、恢复后零降级命中) - [x] 未新增配置项、未新增依赖、未改路由 -- [x] **commit 但不 push**(已 commit `037ce7e`),等用户在浏览器目视确认后再推送 +- [x] **已推送**(远程 `risk-control-agent` = `fffb78a` = 本地 HEAD,`037ce7e` 在其祖先链上)——原「commit 但不 push」表述**已作废** +- [x] 附加守卫有效性验证:调换 `main.py` 两装饰器 → T-202 守卫变红 → 字节级还原后转绿(**2026-09-10 ✓**) diff --git a/docs/项目框架设计/开发计划-基金转换交易.md b/docs/项目框架设计/开发计划-基金转换交易.md new file mode 100644 index 0000000..14ca60d --- /dev/null +++ b/docs/项目框架设计/开发计划-基金转换交易.md @@ -0,0 +1,1209 @@ +# 开发计划 · 基金转换交易(convert) + +> **文档状态**:开发计划,**尚未动代码**。待独立审核 + 用户确认后按批次实施。 +> **代码基线**:分支 `risk-control-agent`,HEAD `ea9a132`(含 6 个基金转换设计文件);**批 0(T-0 / T-0b)完成后 pytest 516 绿**(开工前基线 510)。 +> **上游依据**:`docs/PRD/PRD-基金转换交易.md`(v0.9.1)· `docs/项目框架设计/架构设计-基金转换交易.md`(v1.0) +> · `docs/项目框架设计/评审待办-风控主架构与基金转换.md`(13 条 0 悬空)。 +> **任务编号**:沿用架构 §15 的 `T-0 ~ T-13`(**不重新编号**),§15.1 依赖拓扑为准。 +> **本计划的增量职责**:架构解决「怎么做」,本计划解决「谁先谁后、每一步改哪个文件的哪几行、 +> 怎么验证、改错了怎么退」。**所有事实性陈述均已实地核对代码**,核对结果见 §1.4。 + +--- + +## 审核记录 + +### 第一轮(2026-09-10 · 独立子代理,全新上下文,只报告不改码) + +**总体结论:需修改后实施(小改)。** 审核方逐条核对了 §1.4 的 15 条事实与 §12 的回归面, +并用 `grep` 独立复查。判定结果:**15 条事实全部属实的 14 条**(F-11 计数有误,见下), +**§12 漏报 2 条必破用例**(其中 1 条是真 MySQL 集成用例),**R-c 代价被低估**。 + +| # | 审核意见 | 档位 | 本轮处理 | +| --- | --- | --- | --- | +| 1 | §12 **漏报** `tests/test_integration_risk.py:426-437` 的 `test_convert_400_and_no_new_trade_audit`(真 MySQL 集成用例,断言 convert → 400 + 不落审计) | **重要** | **接受**:§12 补 **R15**;T-9 的 DoD 增该用例的处置 | +| 2 | §12 **漏报** `tests/test_trade_gateway.py:145-154` 的 `test_redeem_accepted_without_alert`(redeem 正向用例,`env` 无持仓无批次) | **重要** | **接受**:§12 补 **R16**;**R-c 补 redeem 侧降级规则**(初版只覆盖 subscribe 侧) | +| 3 | R-c 让 subscribe 全面降级 → **批次维护在单测层零覆盖**,验收第 9 条(不超扣)形同虚设 | **重要(建设性)** | **接受并实质修订**:R-c 拆为 **(1) 降级规则 + (2) 覆盖率补偿**——新测试必须自建种子、覆盖真实路径,**不得让降级路径充当覆盖**;T-10 增第 5 道风险控制 | +| 4 | F-11「`get_engine` 共 11 处」计数错误,实际 **13 处**(test_db.py 是 5 处不是 6 处) | 可选 | **接受**:F-11 更正为 13 处,T-0b DoD 同步更正 | + +**驳回:0 条。** + +**审核方确认无误的部分**(供后续会话判断哪些已复核过): +F-1 / F-3 / F-4 / F-5 / F-6 / F-8 / F-9 / F-10 / F-13 / F-14 / F-15 逐条比对代码后属实; +W-1 / W-2 两处 PRD-架构冲突确实存在、以架构为准合理;R-a / R-b / R-d / R-e / R-f / R-g 六条裁定成立; +§13 的 19 条验收**条条有承载任务**、无落空;一期不做项(自动分拆 / 撤单 / 巨额赎回比例 / 二期补偿自动化)**未被卷入**; +`list_trades_range` 不改可接受(convert 两条流水跨不同产品,RISK-003 按产品计数不受影响); +T-10 排在 T-7 之后**合理**(convert 走 `bootstrap_lots`,不依赖普通申赎的批次)。 + +**审核方无法核实、本轮已补实的部分**:审核方称 F-7(sqlite 不支持 `ON DUPLICATE KEY UPDATE`)为方言常识未实跑。 +本计划已实跑验证(sqlite **3.50.4**):`ON DUPLICATE KEY UPDATE` → `near "DUPLICATE": syntax error`; +`ON CONFLICT(b) DO UPDATE` → 正常执行;`PRAGMA index_list` 的 `unique` 字段 = `'u'`(UNIQUE 断言可行)。 +另实测 pydantic **2.13.4**:`Decimal | None = Field(None, gt=0)` 对 `0`/`-1` → 422、对 `None` → 放行(R5 的保住方案成立)。 + +**仍待实测的部分**(依赖真 MySQL 或需跑测试,留到实施期):T-0b 的 3 条权限断言 · T-1 灌库 · T-6 回滚断言 · +T-7 幂等窗口 · T-13 的 50 并发压测与性能补录 · PRD §5.3 实算回填。 + +### 第二轮(2026-09-10 · 独立子代理,全新上下文,只报告不改码) + +**总体结论:基本可进入下一步。** 审核方**逐条打开代码文件**核实上一轮 4 条意见的落地情况 +(结论:4 条全部已落地,其中 1 条改了但遗留一处内部矛盾),并新发现 **1 处真实的并发漏洞**。 +判定:**0 阻断级 · 2 重要 · 2 可选**,本轮**全部接受,驳回 0**。 + +| # | 审核意见 | 档位 | 本轮处理 | +| --- | --- | --- | --- | +| 1 | **R-a 的并发论证不成立**:理由是「阶段一持执行权锁」,但该锁 key 是 `convert:idem:{client_request_id}`(**请求粒度**);同一 `(customer_id, product_id)`、**不同** `client_request_id` 的两笔并发转换会各持不同锁、双双进入阶段一 → 首次建行时并发 `INSERT` 撞 `UNIQUE` → `IntegrityError` | **重要** | **接受**:R-a 裁定增**第 ③ 步「`IntegrityError` 回退 UPDATE」** + 写清并发必要性 + T-6 增「并发首次建行」用例;并**实测排除了两个看似可行的替代写法**(见下) | +| 2 | **文档自相矛盾**:T-10 正文仍写「风险控制(**四道**)」,而审核记录声称已改为「五道」 | **重要** | **接受**:T-10 已更正为「五道」(**上一轮该处编辑未生效**,本轮重修并逐条验证) | +| 3 | **用例数不自洽**:§11 写「+97 → 607」,但保守区间写「580~600」 | 可选 | **接受**:统一为「**+97 → 607**(含压测)· **CI 内 ≈597** · 落地区间 **585~610**」,4 处口径(§0 / §10 / §11 / §13)同步 | +| 4 | **2 处 DoD 偏模糊**:T-1「5×14 档(或按产品数)」、T-13「记录在 `docs/` 下」 | 可选 | **接受**:T-1 改为 **3 条可 SQL 断言的判据**;T-13 落点明确到「PRD §9 第 18 条」「交接文档」具体章节 | + +**本轮实测补跑**(审核方标注「无法核实」的方言问题)——**这是 R-a 最终写法的决定依据**: + +| 候选写法 | sqlite 3.50.4 | MySQL | 结论 | +| --- | --- | --- | --- | +| `INSERT ... SELECT ... WHERE NOT EXISTS`(无 `FROM`) | ✅ 实测通过 | ❌ `SELECT` 无 `FROM` 时不允许 `WHERE` | **不通用** | +| 同上 + `FROM DUAL` | ❌ `no such table: DUAL` | ✅ | **不通用** | +| **条件 UPDATE → INSERT → `IntegrityError` 回退 UPDATE** | ✅(实测:捕获异常后同事务内可继续 `UPDATE`、可正常提交) | ✅(InnoDB 语句级回滚,语义一致) | **通用 → 采纳** | + +**审核方确认无误的部分**:任务排序(T-10 位于 T-7 之后)合理 · §13 的 19 条验收**无落空** · +一期不做项(自动分拆 / 撤单 / 巨额赎回比例 / 二期补偿自动化)**未被卷入** · 回归面独立 `grep` **无新遗漏** +(含 `test_core_ro_sum.py:52` 用 `trade_type='convert'` 作种子数据的用例——因 R-d 的首条件 `IN('subscribe','redeem')` 仍将其排除,恒等通过,§12 R10 处置正确)· F-1~F-15 抽核行号与代码一致。 + +**仍待实测**(依赖真 MySQL / pytest,留到实施期):T-0b 的 3 条权限断言 · T-1 灌库 · **T-6 的回滚断言与并发建行用例** · +T-7 幂等窗口 · T-13 的 50 并发压测与性能补录 · PRD §5.3 实算回填。 + +### 第三轮(2026-09-10 · 独立子代理 · **聚焦验证**,只报告不改码) + +**总体结论:可进入下一步(todo 开发)。** 本轮只做两件事:复核第二轮 4 条是否真正落地、快速复扫有无新问题。 + +| # | 第三轮发现 | 档位 | 本轮处理 | +| --- | --- | --- | --- | +| 1 | **§14 风险 #6 仍写「四道风险控制」**,与 T-10 的「五道」矛盾 —— 第二轮修复遗漏了第三处 | **重要** | **接受**:已改为「五道」,并注明「第五道 = 覆盖率补偿,见 §8」 | +| 2 | **推测**:SQLAlchemy 2.0 在语句失败后会把事务置为 pending,第 ③ 步继续执行将抛 `PendingRollbackError`,**裁定因此失效** | 重要(推测) | **驳回(附实测证据)**:本机实测 **SQLAlchemy 2.0.51 + sqlite 3.50.4** → 捕获 `IntegrityError` 后继续 `UPDATE` 成功(`rowcount=1`)、`with engine.begin()` 正常退出且提交成功,**未抛 `PendingRollbackError`**。原因是 SQLAlchemy **Core** 的 `Connection` 不标记事务失效(该行为属 ORM `Session` 的 flush 机制),而本项目全链路用 `text()` + Core `Connection`。已把**版本号 + 驳回理由 + 实施期防护**写入 §1.5 R-a | +| 3 | `tests/test_suitability.py:26` 导入了 `get_engine`,未在 §12 列出(担心其 teardown 用默认角色发 DELETE) | 可选 | **接受(经核实无风险)**:实测该 `import` **未被调用**(全文件仅第 26 行出现一次),不构成调用点、也无角色隐患 → 已补入 F-11 备注 | +| 4 | MySQL 的 `rowcount` 按「changed」而非「matched」计,同值 `UPDATE` 会得 0 → 多走一次冗余兜底 | 可选 | **不处理(无害)**:仅多一次尝试,语义正确,不引入错误 | + +**第二轮 4 条的复核结果**:**3 条已改到位**(#1 R-a 三步 + 并发必要性 + T-6 用例;#3 用例数 4 处口径一致;#4 两处 DoD 已可机械验证), +**1 条修复不彻底**(#2 的 §14 未同步 —— 本轮已补,见上表第 1 行)。 + +**第三轮确认无误**:R-a 的方言互斥对照表**诚实**(第三轮独立复现 sqlite 侧结果与文档一致)· +§14 新增的 R-a1/R-a2 与 §1.5 R-a **自洽** · §12 的 R1~R16 覆盖完整(无新遗漏)· T-1 / T-13 / T-6 的 DoD **均可机械验证**。 + +**三轮审核合计**:**12 条意见 → 接受 11 · 驳回 1**(第三轮 #2,附实测证据)· +**0 条悬空 · 0 条遗留阻断**。 + +### 第四轮(2026-09-10 · 独立子代理 · **终验**,只报告不改码) + +**终局结论:可以进入第 5 步 todo 开发。** + +逐条核实第三轮 4 条的闭环情况:**4 条全部闭环**。 +- §14 #6 已为「五道」,与 T-10 一致(正文无「四道」残留,「四道」仅存在于审核记录的历史描述中) +- §1.5 R-a 已写明实测版本号(SQLAlchemy 2.0.51 + sqlite 3.50.4)与驳回理由,**审核方判定技术正确** +- `tests/test_suitability.py` 全文件仅第 26 行 import、无调用,F-11 备注与之相符 +- MySQL `rowcount` 冗余兜底项标注「不处理(无害)」,代码逻辑未被误改 + +**专项检查「声明已改但实际未改」的残留 → 无。** 三轮计数自洽(4+4+4=12 条,接受 11 / 驳回 1 / 0 悬空)。 + +> **审核收敛结论**:经四轮独立审核,本计划**已达到可实施状态**。 +> 四轮的价值排序(供后人参考):**第一轮最有价值**(补出真 MySQL 集成用例漏项 + 指出降级致零覆盖的实质性缺陷)· +> **第二轮最有技术含量**(发现执行权锁是请求粒度这一并发漏洞)· 第三/四轮为收敛验证。 +> 后续若再改本计划,**必须重跑至少一轮聚焦验证**(模式:给出「上轮意见 + 声称处置」→ 让对方打开文件核实,而非只看自述)。 + +--- + +## 0. 结论速览 + +| 批次 | 任务 | 内容 | 依赖 | 风险 | +| --- | --- | --- | --- | --- | +| **第 0 批 · 门禁** | **T-0** ✅ | sqlite/MySQL 结构对齐(`core_holding` 列名 + PK、补 `core_product_nav`)+ 建库自校验 + 门禁用例 —— **2026-09-10 完成** | 无 | **低(但阻断)** | +| | **T-0b** ✅ | DB 账号分离(D20):`00-grant.sql` + `settings` 3 组账号 + `get_engine(db, role)` + 3 个权限断言 —— **2026-09-10 完成** | 无(可并行 T-0) | **低(但阻断)** | +| **第 1 批 · 数据与纯函数** | T-1 | MySQL DDL + 3 个种子 + `reset.ps1` + sqlite DDL 同步 | **T-0 + T-0b 双绿** | 低 | +| | T-2 | `service/convert/` 纯函数包(`types`/`calc`/`fee`/`nav`/`lot_bootstrap`/`errors`) | 无 | 低 | +| | T-2b | `calc_convert_demo.py` 实算 + 回填 PRD §5.3 与验收断言 | T-2 | 低 | +| **第 2 批 · 仓储与锁** | T-3 | `core_ro` 五个新方法 + `share_lot_repository` | T-1 | 低 | +| | T-4 | `convert_repository`(占位/回写/查询/清理) | T-1 | 低 | +| | T-5 | `locks.try_lock` + 单测 | 无 | 低 | +| **第 3 批 · 事务与编排** | T-6 | `convert_core_repository.apply_convert`(阶段一单事务) | T-1/T-3 | **高(方言 + 并发)** | +| | T-7 | `convert_service` 编排(八步 + 执行权 + 幂等 + 三阶段 + 阶段 1.5) | T-2~T-6 | **高(关键路径)** | +| **第 4 批 · 引擎与网关** | T-8 | `_amount_view` + `engine.process_convert_event` + `alert_service.events` | 无(可与 T-2 并行) | 中 | +| | T-9 | `api/simulate.py` 模型与错误码 + `trade_gateway` convert 分派 | T-7 | 中 | +| | T-11 | `core_tools` 汇总去重 + 持仓 `qty <= 0` 过滤 + `sum_trades_on_date` 去重 | T-8 | 中 | +| **第 5 批 · 高风险专项** | **T-10** | 普通申赎批次维护(FR-C16,含 D8 兜底补建)+ `rebuild_lots.py` | T-3(排在 T-7 后) | **最高(打穿 510)** | +| **第 6 批 · 补偿** | T-12 | `rebuild_alerts --convert-group` + `cleanup_pending_convert.py` | T-4/T-7 | 低 | +| **第 7 批 · 收口** | T-13 | 全量回归 + 集成测试 + 50 并发压测 + 性能实测补录 | 全部 | 中 | + +**关键路径**:`T-0 → T-1 → T-2 → T-6 → T-7 → T-13` +**并行组 A**:T-3 / T-4 / T-5(T-1 完成后同时开工) +**并行组 B**:T-8 全程可与 T-2 之后任意任务并行 +**硬门禁**:`T-0` 与 `T-0b` **双双绿**才允许启动 T-1 及之后(T-0 用例 = `test_db.py::test_core_holding_columns`) +**测试基线**:**516**(批 0 后;原 510)→ 预计 **591 ~ 616**(CI 内约 **603**;含不进 CI 的压测用例约 **613**,见 §11) + +--- + +## 1. 背景与约束 + +### 1.1 上游依据与决策链 + +1. 第 1 步「讨论需求」→ 第 2 步 PRD(v0.9,经 5 轮外部审查 + 架构校准轮,接受 27 / 修正性接受 5 / 驳回 0) +2. 第 3 步架构设计(v1.0,独立评审 M-7 通过:接受 10 / 修正性接受 3 / 驳回 0,T-0 为唯一硬前置) +3. 用户拍板 P1~P8(处置表 §九):**批次兜底补建 / 归零保留行 / Core 明细仅补偿 / 补差费 B 默认 / 最低持有双动作 / 22 号文费率 / 份额 2 位 / T+1 建模** +4. 主架构评审 C1(`core_ro` 无 DB 级只读强制)跨线并入 → **D20 / T-0b** +5. 本计划:把上述结论拆成 14 个可独立验证、可独立回滚的开发任务 + +### 1.2 硬约束 + +| 约束 | 对本计划的影响 | +| --- | --- | +| **五条红线** | Core 只读(本期升级为 DB 级强制)· 审计只 INSERT · 代理人草稿不外发 · **仅 R-02 可阻断交易**(`NAV_NOT_READY`/`LOT_CONFLICT` 属技术故障码,不适用本铁律)· 四 Agent 不互调 LLM | +| **既有用例必须全绿**(批 0 前 510 → 当前 **516**) | 每个任务完成后即跑全量,不留到最后 | +| **实测才能改文档** | PRD §9 第 18 条:性能阈值为预估值,实测补录;**不得反向修改实测数据迁就指标** | +| **不新增路由** | `tests/test_main.py:58-74` 硬编码路由清单 → convert 复用 `/api/simulate/trade`,**该断言零影响**(需显式声明,见 §12) | +| **不新增第三方依赖** | 技术选型硬阀门(`requirements.txt` 不动) | +| **sqlite 无账号概念** | T-0b 的账号分离**只在真 MySQL 生效**,sqlite 路径不引入任何连接方式变化 | +| **不动范围** | RISK-001~006 口径不变(仅金额视图去重)· `entities.py` ORM 不新增 · 不改 `run_locked` · 不改 `get_latest_nav` 语义 · 不改 `rules._eligible` 的 trade_type 白名单 | + +### 1.3 文档口径差异裁定(**实现前必须先定,否则会两边都做**) + +核对 PRD v0.9 与架构 v1.0 时,发现 **2 处同一件事的表述不一致**。裁定原则:**架构 v1.0 晚于并细于 PRD 对应段落,以架构为准**;PRD 对应行视为旧版本残留。 + +| # | 事项 | PRD 表述 | 架构表述 | **裁定(实现依据)** | +| --- | --- | --- | --- | --- | +| **W-1** | 阶段一「两条流水同事务插入」的落点 | §5.2 表格:「`gateway_repository.py` → 新增 `insert_convert_pair()` 同事务插两条」 | §2 / D3:**新增** `app/gateway/convert_core_repository.py::apply_convert()`(单事务:2 流水 + 批次 + 明细 + 两端持仓) | **以架构为准**:`gateway_repository` **只**扩列(`qty`/`convert_group_id`),convert 的写全部走 `convert_core_repository` | +| **W-2** | 引擎如何吃两条流水 | §7.0 表述:「引擎只调用一次 `process_trade_event()`,**传入两条流水**」 | D5:**新增** `process_convert_event(out_trade, in_trade)`,与 `process_trade_event` 共用内部 `_run()`;**不改现有函数签名** | **以架构为准**:`process_trade_event` 签名零改动(避免动 13 处调用方) | + +> **为何必须显式裁定**:两处差异都属于「同一功能的两个落点」,若不裁定, +> 实现者可能既写 `insert_convert_pair()` 又写 `apply_convert()`(重复且事务边界冲突), +> 或给 `process_trade_event` 加第二参数(打穿既有 13 处调用与断言)。这是**成本最高的返工类型**。 + +### 1.4 开工前代码事实核对表(**本计划新增 · 全部实地核对**) + +> 下表每条都是「架构/PRD 未点明,但实现时必然撞上」的事实。**核对方式**:读源码 + `grep` 全仓引用, +> 行号为撰写时实测值。**这些是审核方最应复核的部分**——若有一条与代码不符,说明我对现状的判断有误。 + +| # | 事实 | 证据(文件:行) | 对实现的影响 | +| --- | --- | --- | --- | +| **F-1** | sqlite `core_holding` 为 `market_value`/`quantity` **且无 PK、无 UNIQUE** | `tests/_ddl.py:50-54` | T-0 根因;须改为 MySQL 的 `id` 自增 PK + `uk_cust_product` + 5 业务列 | +| **F-2** | **`tests/test_db.py` 已存在**(3 个用例:缓存单例 / dispose / 空缓存) | `tests/test_db.py:30-54` | 架构 §15 写「**新增** `tests/test_db.py::test_core_holding_columns`」表述不准 → 应为**在既有文件追加用例**,禁止覆盖 | +| **F-3** | sqlite `core_trade` **缺 `qty`**(MySQL 侧 `qty DECIMAL(18,4) NULL` **已存在**) | `tests/_ddl.py:55-60` vs `scripts/core/01-ddl.sql` core_trade | T-1 在 MySQL 只加 `convert_group_id`;sqlite 要**补两列**(`qty` + `convert_group_id`) | +| **F-4** | sqlite **缺 `core_product_nav` 表**(MySQL 有,`get_latest_nav` 查它) | `tests/_ddl.py:15-138`(无该表)· `app/repository/core_ro.py:450-460` | 架构 §9 的 sqlite 同步清单(4 张新表)**漏了它**;不补则 `get_nav_as_of` 的 sqlite 单测无法跑 → 建议并入 T-0 | +| **F-5** | sqlite `core_holding` 改列名会打穿 **2 处** 测试 INSERT(显式写 `market_value, quantity`) | `tests/test_chat_tools.py:81` · `tests/test_concentration_c4.py:74` | T-0 必须同步改这 2 处为 `qty`;否则 `OperationalError: no such column: quantity` | +| **F-6** | `app/` 全仓**无任何代码读 `core_holding.quantity`**;`list_holdings` 用 `SELECT h.*`、`concentration_profile` 只用 `market_value` | `grep -rn "market_value\|quantity" app/` → 仅 `core_ro.py:290/320/324/340`(均 `market_value`) | 列名统一对**生产代码零影响**,回归面**只在 2 处测试 INSERT** | +| **F-7** | **UPSERT 方言互斥**:MySQL 用 `INSERT ... ON DUPLICATE KEY UPDATE`,sqlite 用 `INSERT ... ON CONFLICT(...) DO UPDATE`,**两者不通用** | 架构 D9 备注「⚠️ sqlite ≥3.24 支持该语法」——该表述**不准确**(3.24 支持的是 `ON CONFLICT`,非 MySQL 语法) | **阶段一事务在 sqlite 单测会直接语法错**:`apply_convert` 的 `core_holding` 写入必须改用**方言无关**写法(见 §1.5 R-a) | +| **F-8** | convert 两条流水的 `trade_type` **必须是 `redeem` / `subscribe`**,靠 `convert_group_id` 关联 | 推论链:`rules._eligible`(`rules.py:78-83`)只放行 `subscribe`/`redeem` → 若写 `convert` 则**两条都进不了事件线**,与 PRD §6.1「两条流水都进事件线」矛盾;且架构 §7 `_amount_view` 明写「组内保留 `trade_type='redeem'` 那条」 | **实现时必须写死为 `redeem`/`subscribe`**;MySQL ENUM 里的 `convert` 值**本期不使用**(保留不用,注释说明) | +| **F-9** | `core_ro.sum_trades_on_date`(SQL 侧求和)**硬写 `trade_type IN ('subscribe','redeem')`,不经过 `_amount_view`** | `app/repository/core_ro.py:420-432` | 自检第 2 问「谁读」的**遗漏项**:convert 落地后此方法会把两条流水都算进去 → **双计**。架构 §6 只提了 `core_tools`,未提它(见 §1.5 R-d) | +| **F-10** | `sum_trades_on_date` **生产代码无调用方**,仅 `tests/test_core_ro_sum.py` 在用 | `grep -rn "sum_trades_on_date"` → 仅 `core_ro.py:420` 定义 + 该测试 5 处断言 | 改动**零生产回归**,但它是公开仓储方法,应顺带修正口径 | +| **F-11** | `get_engine` 调用点在 **T-0b 前为 13 处**(app 4 + conftest 4 + test_db.py **5**) | **T-0b 实施后实测行号**:`app/gateway/gateway_repository.py:30` · `app/repository/core_ro.py:60` · `risk_repository.py:37` · `session_repository.py:25` · `tests/conftest.py:185/186/236/237` · `tests/test_db.py` **21 处**(角色/缓存/回退用例自身,均带显式 `role` 或默认 `rw`) | T-0b 的改动清单必须**逐点定角色**,不能只改 `db.py`(
※ 初版误记为 11 处,经第一轮审核指出后实测更正为 13 处。
※ 第三轮补充:`tests/test_suitability.py:26` **仅 import 未调用** `get_engine`,不计入调用点、也无角色隐患) | +| **F-12** | 既有 4 处断言会因「放开 convert」而失效 | `tests/test_trade_gateway.py:92-99`(`test_convert_rejected`)· `:215-219`(`test_api_convert_returns_400`)· `:98`(`core_trade` 计数为 0)· `:261-263`(`amount=0` → 422,**必须保住**) | 逐条处置见 §12 | +| **F-13** | `tests/test_trade_gateway.py` 的 `env` fixture **未灌 `core_holding` 与净值** | `tests/test_trade_gateway.py:40-72`(只灌 customer/customer_risk/product) | T-10 后 subscribe 分支要建批次、redeem 分支要兜底补建(读 `core_holding`)→ **在既有用例上必然走到无数据路径**,见 §1.5 R-c | +| **F-14** | `scripts/core/01-ddl.sql` 中 `core_product.fee_rate` **无 COMMENT**、`core_trade.trade_type` ENUM **已含 `convert`** | `01-ddl.sql`(core_product / core_trade 段) | T-1 补 COMMENT(Q8 定稿);`convert` ENUM 值保留不用(F-8) | +| **F-15** | agent 库 DDL 落点 = `docs/项目框架设计/表设计/02-mysql-agent专用.sql`(`audit_log`/`risk_alert` 在 `01-mysql-共用底座.sql`) | `grep -rln "CREATE TABLE.*audit_log"` → `01-mysql-共用底座.sql`;`tests/conftest.py:181` 的 bootstrap 提示链 | 架构 §9 选点(`risk_convert_detail` → `02-mysql-agent专用.sql`)**正确**,与 `risk_aml_list` 等业务专用表同类 | + +### 1.5 实现级裁定(**本计划拍板,若不同意请在审核中驳回**) + +架构回答了架构级问题,但以下 **7 条是实现级空白**——不定就无法写代码,或写出来必然出错。 +每条给出「问题 / 裁定 / 理由 / 若被驳回的替代方案」。 + +**R-a · `core_holding` 写入用「UPDATE + rowcount + INSERT」两步,不用方言 UPSERT** + +- **问题**:架构 D9 写 `INSERT ... ON DUPLICATE KEY UPDATE`,但 sqlite 不支持该语法(F-7)。若实现时按 D9 原文写,**阶段一事务在任何 sqlite 单测里立即语法错**。 +- **裁定(第二轮审核后修订)**:`apply_convert` 内对两端 `core_holding` 统一走「条件 UPDATE → 首次 INSERT → 异常回退 UPDATE」: + ```python + # ① 条件 UPDATE(常规路径:该持仓行已存在) + r = conn.execute(update_sql, params) # UPDATE core_holding SET qty=:q, cost_amount=:c, + # market_value=:mv, pnl_pct=:pnl, as_of=:d + # WHERE customer_id=:cid AND product_id=:pid + if r.rowcount == 0: + # ② 首次建行 → INSERT + try: + conn.execute(insert_sql, params) + except IntegrityError: + # ③ 兜底:并发事务已插入该行 → 退化为 UPDATE(同一事务内可继续执行,已实测) + conn.execute(update_sql, params) + ``` +- **理由**:① 方言无关(不依赖任一方言的 UPSERT 语法);② 仍在 `with engine.begin()` 单事务内,原子性不变;③ 不引入 `engine.dialect.name` 分支(分支是长期维护负担,且 MySQL 分支在 sqlite 单测里永不被执行)。 +- **⚠️ 为何不能用「条件 INSERT」(本轮实测排除的两个方案,留痕防返工)**: + | 写法 | sqlite | MySQL | 结论 | + | --- | --- | --- | --- | + | `INSERT ... SELECT ... WHERE NOT EXISTS`(**无 FROM**) | ✅ 可用(实测通过) | ❌ MySQL 的 `SELECT` 不带 `FROM` 时**不允许 `WHERE`** | 不通用 | + | 同上 + **`FROM DUAL`** | ❌ 实测报 `no such table: DUAL` | ✅ 可用 | 不通用 | + → 二者**又组成一对互斥方言**,仍做不到「同一套 SQL 两库通吃」。故最终选**异常回退**写法。 +- **⚠️ 并发兜底的必要性(第二轮审核指出 · 初版理由③有误)**: + 初版称「阶段一持执行权锁可保不撞 UNIQUE」——**该论证不成立**。执行权锁的 key 是 + `convert:idem:{client_request_id}`(**请求粒度**,见 §6.2 步骤⑤ / T-5),不是持仓粒度。 + 因此**同一客户、同一产品、不同 `client_request_id` 的两笔并发转换**(典型场景:A→C 与 B→C + 同时转入 C;或首次转入某产品)会各持**不同**锁、双双进入阶段一,都 `UPDATE` 得 `rowcount == 0` + 后并发 `INSERT` → **撞 `UNIQUE(customer_id, product_id)` 抛 `IntegrityError`** + (事务回滚、返回 5xx;**非数据损坏,但属未兜底的异常路径**)。②③ 两步正是为此存在。 +- **必须覆盖的用例(写入 T-6 的 DoD)**:模拟两笔并发首次转入同一 `(cid, pid)` → + 断言最终**只有一行**、金额为两笔之和、**两笔都成功**(不得出现 5xx)。 +- **实测依据(SQLAlchemy 2.0.51 + sqlite 3.50.4,本项目系统 Python 3.13.14 环境实跑)**: + 同一事务内捕获 `IntegrityError` 后,连接仍可用、可继续执行 `UPDATE`(`rowcount == 1`)、 + `with engine.begin()` 块正常退出且事务提交成功——**未抛 `PendingRollbackError`**。 + > **第三轮的推测已被本次实测驳回**:第三轮曾推测「SQLAlchemy 2.0 在语句失败后会把事务置为 pending, + > 后续执行抛 `PendingRollbackError`,从而使本裁定失效」。实测否定了该推测——SQLAlchemy **Core** + > 的 `Connection` 在语句级失败后不标记事务失效(该行为属 ORM `Session` 的 flush 机制); + > 本项目全链路用 `text()` + Core `Connection`,不涉及 Session。 + > **实施期防护**:若后续升级 SQLAlchemy 主版本,T-6 的「并发首次建行」用例会立刻暴露行为变化,不会静默失效。 + MySQL InnoDB 亦为**语句级回滚**,失败语句不影响事务后续执行,语义一致。 +- **替代方案(若被驳回)**:保留 D9 原写法 + `if engine.dialect.name == "sqlite"` 分支。**不推荐**:两套 SQL 必然漂移,且 MySQL 分支在 sqlite 单测里永不被执行。 + 另一备选 = 阶段一增加 `(customer_id, product_id)` 维度的执行锁 —— **一期不取**:会把锁管理复杂度引入事务内部, + 并新增「锁超时」这一失败模式;**若 T-6 单测证明异常回退不足,再评估**。 + +**R-b · convert 两条流水写 `trade_type='redeem'/'subscribe'`,本期不使用 ENUM 里的 `convert`** + +- **问题**:MySQL `core_trade.trade_type` ENUM **已含 `convert`**(F-14),实现者极易直觉地写 `trade_type='convert'`。 +- **裁定**:转出端写 `redeem`、转入端写 `subscribe`,两行共享 `convert_group_id`;`trade_type='convert'` **本期任何代码都不写**。 +- **理由**:`rules._eligible`(`rules.py:78-83`)白名单只有 `subscribe`/`redeem`,写 `convert` 会让流水**被引擎过滤掉**,直接违反 PRD §6.1 / 验收第 6 条(F-8)。 +- **落地要求**:在 `convert_core_repository` 与 `errors.py` 各留一条中文注释说明「ENUM 值 convert 保留不用,原因见裁定 R-b」,防后人改回去。 + +**R-c · 批次维护的「数据不全降级」与「覆盖率补偿」(第一轮审核后修订)** + +- **问题**:PRD §4.4 要求批次由交易统一出入口维护(`subscribe`/转换转入 → 建批次;`redeem`/转换转出 → FIFO 扣减),但: + - 普通申购请求体**只有 `amount` 没有份额**,份额需 `amount ÷ 当日净值`;sqlite 测试库**无 `core_product_nav` 数据**; + - `tests/test_trade_gateway.py` 的 `env` fixture(`:40-72`)**既无 `core_holding` 也无批次**(F-13),而 redeem 侧要读持仓做 D8 兜底补建。 +- **裁定(两条并行,缺一不可)**: + + **(1) 降级规则 —— 只在「源数据缺失」时生效,用于保住既有 510** + | 场景 | 处置 | + | --- | --- | + | `subscribe` / 转换转入,**取不到当日净值** | `logger.warning`(记 `trade_id` + 原因)→ **跳过建批次**,不抛异常、不阻断交易 | + | `redeem`,**既无批次也无持仓行**(`core_holding` 无该 `(cid,pid)`) | `logger.warning` → **跳过批次扣减**,不抛异常、不阻断交易 | + | `redeem`,**无批次但有持仓** | **走 D8 兜底补建**(`lot_bootstrap.bootstrap_lots`)再扣 —— 这是 D8 的主场景,**必须执行** | + + **(2) 覆盖率补偿 —— 回答「降级会不会导致批次维护零覆盖」** + - **`test_share_lot.py` 必须自建完整种子**(`core_holding` + `core_share_lot` + `core_product_nav`), + **完整覆盖两条真实路径**:redeem FIFO 扣减(含 D8 兜底补建)× subscribe 建批次 × `rebuild_lots.py` 入口。 + - **不得**用降级路径充当测试覆盖(降级是「数据不全时的兜底」,不是被测对象)。 + - 断言 `core_share_lot.remain_qty` 之和 = 初始值 − 实际扣减量(不超扣)。 +- **理由**:① 批次扣减(redeem 侧)是 convert 正确性的**前提**(PRD §4.4 的核心缺陷——不扣就超扣); + ② 既有 510 的 fixture 无持仓/净值,若强行走「缺数据即报错」会**直接打穿 510**(违反 §1.2 硬约束); + ③ 但若只做降级不做补偿,批次维护会在单测层**零覆盖**,验收第 9 条(不超扣)形同虚设 —— + 这是**第一轮审核指出的实质问题**,故补入 (2)。 +- **一句话原则**:**降级只用于「既有测试数据不全」的场景;新测试必须造全数据、测真实路径。** +- **若被驳回**:替代方案 = 给 `test_trade_gateway.py` 的 `env` 等多个 fixture 统一补灌持仓与净值种子 + (T-10 工作量增加、改动面扩到 3+ 个测试文件,但覆盖更自然)。**可接受但要评估**。 +- **注意**:此裁定**只约束 T-10 的普通申赎**;换入端的新批次(`convert` 转入)**必须建**(它有完整折算结果,不存在取不到净值的问题)。 + +**R-d · `core_ro.sum_trades_on_date` 同步加 convert 去重(架构 §6 未覆盖的读取方)** + +- **问题**:F-9/F-10——该方法 SQL 侧按 `trade_type IN ('subscribe','redeem')` 求和,convert 落地后会**双计**,且它不经过 `_amount_view`。架构 §6 只列了 `core_tools.query_recent_trades`。 +- **裁定**: + ```sql + AND trade_type IN ('subscribe', 'redeem') + AND (convert_group_id IS NULL OR trade_type = 'redeem') -- 组内只计转出端,同 _amount_view 口径 + ``` +- **理由**:① 口径与 `_amount_view`(D7)**完全一致**,一处语义两处落地但可互相印证;② 既有 `test_core_ro_sum.py` 的 5 条断言数据**无 `convert_group_id`** → 恒等通过,**零回归**;③ 顺带补 1 条 convert 去重用例。 +- **落地要求**:`test_core_ro_sum.py` 追加 1 条用例(同日一组 convert 两条流水 + 1 笔普通赎回,断言只计转出端与本笔普通赎回)。 +- **若被驳回**:至少要在 docstring 加「不适用 convert 场景,金额聚合请用 `rules._amount_view`」,避免后人误用 —— **不接受「什么都不做」**。 + +**R-e · `conftest.py` 的真库 engine 改用 `role="admin"`,业务调用仍走拆分账号** + +- **问题**:T-0b 后 `xh_core_rw` **无 DELETE 权限**、`xh_agent_rw` 对 `audit_log` **只有 INSERT**。而 `tests/conftest.py` 的 teardown 要 `DELETE FROM core_trade`(`:219`)、`DELETE FROM risk_alert/audit_log`(`:221-223`),setup 还要跑 `prepare_risk_demo.sql`(含 INSERT)。**若 conftest 沿用默认角色,真库集成测试的 setup/teardown 会因权限被拒而失败。** +- **裁定**:`tests/conftest.py:183/184/232/233` 四处 `get_engine(...)` 显式传 `role="admin"`(= `root`);`app/` 内的业务调用**一律不用 admin**。 +- **理由**:① 测试的建数据/清数据是**运维动作**,不是应用行为,用管理员账号符合最小权限的**边界定义**(应用运行时不持有 root,测试脚手架可以);② 若给 `xh_core_rw` 加 DELETE 只为清理,会**直接破坏**「无 DELETE」这一最小权限目标(评审 C1 的初衷),否决。 +- **附带动作**:架构 §11.1 表里 `root` 的用途需**扩充一行**——「真 MySQL 集成测试的 setup/teardown(`tests/conftest.py`)」;本计划 §3.2 落实,并在实施时同步回写架构文档那一格。 + +**R-f · T-0 范围扩至 `core_product_nav`(架构 §15 只写了 `core_holding`)** + +- **问题**:F-4——sqlite 缺 `core_product_nav` 表,而 `get_latest_nav`(`core_ro.py:450`)与本次新增的 `get_nav_as_of` 都查它。 +- **裁定**:T-0 的 sqlite 对齐**一并补建 `core_product_nav`**(含 `uk_product_date` 唯一约束),并把 T-0 的门禁断言从「单表列名」扩为「**① `core_holding` 列名 + PK;② 关键表存在性**」。 +- **理由**:① 两者同属「sqlite/MySQL 结构失配」,是同一个 R1 风险的两种表现,**一次改完、一个门禁守住**,比分两次改省一轮回归;② 若留到 T-1 补,则 T-1 会同时承担「新表 DDL」与「补历史缺失表」两类工作,出问题时归因困难。 +- **若被驳回**:`core_product_nav` 挪到 T-1,T-0 只做 `core_holding`。**可接受**,但 T-0 的 DoD 需相应缩小。 + +**R-g · 自校验落在 `_ddl.create_sqlite_engine()` 内,`test_db.py` 作为可独立跑的门禁用例** + +- **问题**:架构 §9 要求「`conftest.py` 加**启动期**列名断言」。但 `conftest.py` 的 `sqlite_engine` fixture 只是**其中一个建库入口**——测试文件各自 `create_sqlite_engine()` 的场景更多(如 `test_trade_gateway.py:42`)。 +- **裁定**:双落点、**同源常量**—— + 1. **主**:`tests/_ddl.py::create_sqlite_engine()` 建表后调用新增的 `_assert_ddl_aligned()`(读权威列/表清单常量,缺项即 `AssertionError`)。**所有**建库路径自动被覆盖,无需改 conftest。 + 2. **辅**:`tests/test_db.py::test_core_holding_columns`(T-0 门禁用例),供 CI 单独指定运行(`pytest tests/test_db.py::test_core_holding_columns`)。 +- **理由**:① 建库入口唯一(`create_sqlite_engine` 是 `_ddl.py` 唯一建表函数),断言放这里 = 全量覆盖,**不留「某个测试文件自己建库绕过断言」的口子**;② CI 需要可单独指定的门禁用例 → 保留 `test_db.py` 那条;③ 两份断言读**同一份常量** → 不会漂移。 +- **若被驳回**:只在 `conftest.py` 加 fixture 断言(架构原文),但需接受「部分测试文件不走 conftest fixture、门禁被绕过」的风险。 + +**R-h · `plan_lots` 的「零剩余不触发强制处置」(**T-2 执行期新增裁定**)** + +- **问题**:PRD §12 I-4 的触发条件是「余额 < `min_hold_qty`」。客户**申请份额 == 全部可转份额**时余额 = 0,字面上 `0 < min_hold_qty` 成立 → 会被判为「强制全转 / 强制赎回剩余」,`forced_full_transfer = True` 落响应与审计;但此时 `actual_qty == requested_qty`,**客户指令其实没有被改动**。 +- **裁定**:触发条件**增加「余额 > 0」前置**: + ```python + trigger = leftover > 0 and threshold > 0 and leftover < threshold + ``` +- **理由**:① `forced_full_transfer` 的语义是「**客户指令被改变**」(PRD 二轮第 9 条),零剩余时该语义不成立——前端会误报「您的申请已被系统改为全额转出」;② 零剩余时 `min_hold_action` 的两个分支(强制全转 / 强制赎回剩余)**都无事可做**,标记只产生噪音;③ 不改变 PRD 审查例的结论(持 6000、申请 5000、阈值 1000 → 余额恰好 1000 → 仍不触发)。 +- **落地**:`calc.plan_lots()` 内有对应中文注释;`tests/test_convert_calc.py::TestMinHoldAction::test_zero_leftover_not_forced` 锁定该语义。 +- **若被驳回**:按 PRD 字面实现(余额 = 0 也触发),但需同步改前端文案,说明「申请全额转出时也会带 `forced_full_transfer=true`」——评审大概率判为误导。 + +--- + +## 2. 执行分组与排期策略 + +```text +第 0 批(门禁,不可并行跳过) + T-0 sqlite/MySQL 结构对齐 + 建库自校验 + test_db 门禁用例 + T-0b DB 账号分离(00-grant / settings / get_engine(db,role) / 3 权限断言) + │ ⛔ 双双绿才放行 + ▼ +第 1 批 T-1(DDL + 种子 + sqlite 同步)───┬─► T-2(纯函数包)─► T-2b(实算回填) + │ +第 2 批 T-3 / T-4 / T-5(并行组 A)───────┤ + ▼ +第 3 批 T-6(阶段一事务)─► T-7(编排 · 关键路径)─┬─► T-9(API + 网关分派) + └─► T-12(补偿脚本) +第 4 批 T-8(引擎改造,全程并行)─► T-11(工具/求和去重) +第 5 批 T-10(批次维护 · 回归风险最高,单独结项) +第 6 批 T-13(全量回归 + 集成 + 50 并发压测 + 性能补录) +``` + +**排期原则** + +| 原则 | 说明 | +| --- | --- | +| **门禁优先** | T-0/T-0b 不是「准备工作」,是**独立交付物**:四件事做完(sqlite 对齐 + 自校验 + 账号分离 + 权限断言),T-1 才能开始 | +| **纯函数先于集成** | T-2 不依赖任何表结构,可与 T-1 并行开工,且它的单测最容易穷举(金额逻辑最易错) | +| **高风险单列** | T-10 改 `trade_gateway` 主流程、直接打穿 510 → **单独结项**:先跑基线快照 → 改造 → 再跑全量 | +| **每任务一个回滚点** | 每个任务完成后**立即 commit**(不含下游任务改动),出现回归可精确回退到上一任务 | +| **不做「顺手重构」** | 除 §1.5 明列的补充(R-a~R-g),**不碰**任何非本任务范围的代码 | + +--- + +## 3. 第 0 批 · 门禁 + +### 3.1 T-0 · sqlite/MySQL 结构对齐 + 建库自校验 + +**目标**:让 sqlite 测试库的结构成为 MySQL 权威结构的**真子集**,且失配在**建库那一刻**就炸,而不是留到集成测试期。 + +**涉及文件** + +| 文件 | 动作 | +| --- | --- | +| `tests/_ddl.py` | 【改】重写 `core_holding` DDL · 新增 `core_product_nav` · 新增权威清单常量 + `_assert_ddl_aligned()` | +| `tests/test_chat_tools.py` | 【改】`:81` INSERT 列名 `quantity` → `qty` | +| `tests/test_concentration_c4.py` | 【改】`:74` 同上 | +| `tests/test_db.py` | 【改】**追加** `test_core_holding_columns`(**不覆盖**既有 3 个用例,见 F-2) | + +**改法(逐项)** + +1. **`core_holding` DDL 重写**(以 `scripts/core/01-ddl.sql` 为准;sqlite 无 `DECIMAL(18,4)` 精度语义,保留类型名即可): + ```python + "core_holding": """ + CREATE TABLE core_holding ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id VARCHAR(64) NOT NULL, product_id VARCHAR(64) NOT NULL, + qty DECIMAL NOT NULL, cost_amount DECIMAL NOT NULL, + market_value DECIMAL NOT NULL, pnl_pct DECIMAL NOT NULL, as_of DATE NOT NULL, + UNIQUE (customer_id, product_id)) + """, + ``` + > 保留 `market_value` 列名**不动**(F-6:`core_ro` 的 `list_holdings`/`concentration_profile` 依赖它)。 + > 不建外键(sqlite 测试库既有表均无 FK,保持一致,避免插入顺序约束)。 + +2. **新增 `core_product_nav`**(F-4 / R-f): + ```python + "core_product_nav": """ + CREATE TABLE core_product_nav ( + id INTEGER PRIMARY KEY AUTOINCREMENT, product_id VARCHAR(64) NOT NULL, + nav DECIMAL NOT NULL, daily_chg_pct DECIMAL NOT NULL, nav_date DATE NOT NULL, + UNIQUE (product_id, nav_date)) + """, + ``` + +3. **新增权威清单常量 + 自校验**(R-g): + ```python + # 与 scripts/core/01-ddl.sql 人工同步的权威结构清单(T-0 门禁比对基准) + EXPECTED_CORE_HOLDING_COLUMNS = { + "id", "customer_id", "product_id", "qty", + "cost_amount", "market_value", "pnl_pct", "as_of", + } + REQUIRED_CORE_TABLES = {"core_holding", "core_product_nav", "core_trade", "core_product"} + + def _assert_ddl_aligned(engine) -> None: + """建库后自校验:缺列/缺表立即 AssertionError(R1 门禁,防止失配留到集成测试期)。""" + ``` + `create_sqlite_engine()` 在 `for ddl in SQLITE_TABLES.values()` 之后调用它。 + +4. **新增门禁用例** `tests/test_db.py::test_core_holding_columns`: + - 断言 `PRAGMA table_info(core_holding)` 的列名集合 ⊇ `EXPECTED_CORE_HOLDING_COLUMNS` + - 断言主键 = `{id}`(`PRAGMA table_info` 的 `pk` 字段) + - 断言 `UNIQUE(customer_id, product_id)` 存在(`PRAGMA index_list(core_holding)` + `index_info`) + > **已实测可行**(sqlite 3.50.4):`UNIQUE(a,b)` 会生成 `sqlite_autoindex_*` 且 `PRAGMA index_list` 的 + > `unique` 字段为 `'u'`,可稳定断言。 + - 断言 `core_product_nav` 表存在且含 `(product_id, nav_date)` 唯一约束 + +5. **同步改 2 处测试 INSERT**(F-5):把 `market_value, quantity` 改成 `market_value, qty`,数值保持原样(`100` / `1000` / `500`)。 + + > ⚠️ **实施时发现的漏项(原计划未覆盖)**:这 2 处 INSERT 原本**只写 4 列**, + > 而新 DDL 的 `cost_amount` / `pnl_pct` / `as_of` 均为 **NOT NULL** —— + > 只改列名会直接撞 `NOT NULL constraint failed` 而全红。 + > 故须一并补全这三列,取中性值(`cost_amount` = `market_value`、`pnl_pct` = `0`、 + > `as_of` = `'2026-09-04'` 固定日期,不依赖当天日期以保证测试确定性)。 + +**依赖**:无 +**完成标准(DoD)** +- [ ] `pytest tests/test_db.py::test_core_holding_columns -q` 绿 +- [x] `pytest -q` **全量全绿**(批 0 前基线 510;含改后的 2 处 INSERT,另补 3 个 NOT NULL 列) +- [ ] 反向验证门禁真的生效 —— **已固化为自动化用例**(无需手工改删再恢复): + `test_db.py::test_ddl_alignment_guard_fails_when_column_missing` + (monkeypatch 抽掉 `qty` 列 → 断言建库即 `AssertionError`) +- [ ] `scripts/core/01-ddl.sql` 与 `EXPECTED_CORE_HOLDING_COLUMNS` 逐列对照,写入 `_ddl.py` 注释 + +**风险**:低。唯一变量是那 2 处测试 INSERT(已在 F-5 定位)。 +**回滚点**:本任务单独一个 commit;回滚即恢复到现状(convert 尚未开始,无下游依赖)。 + +--- + +### 3.2 T-0b · DB 账号分离(D20) + +**目标**:把「Core 只读」「审计只 INSERT」从**代码约定**升级为 **DB 级强制**。 + +**涉及文件** + +| 文件 | 动作 | +| --- | --- | +| `scripts/core/00-grant.sql` | 【新增】3 账号 + 逐表最小权限(管理员执行) | +| `app/config/settings.py` | 【改】新增 6 个配置项(3 组账号密码,默认 `""`) | +| `app/utils/db.py` | 【改】`get_engine(database, role="rw")`,缓存键 → `(database, role)` | +| `app/repository/core_ro.py:55` | 【改】`get_engine(core_db, "ro")` | +| `app/gateway/gateway_repository.py:24` | 【改】`get_engine(core_db, "rw")` | +| `app/repository/risk_repository.py:37` · `session_repository.py:25` | 【改】`get_engine(agent_db, "rw")`(显式,可读性) | +| `tests/conftest.py:183/184/232/233` | 【改】显式 `role="admin"`(**R-e**) | +| `tests/test_db.py` | 【改】追加 2 条用例:同库不同角色 → 不同 engine;角色未配置 → 回退 `mysql_user` | +| `docs/项目框架设计/架构设计-基金转换交易.md` §11.1 | 【改】`root` 用途行补「真 MySQL 集成测试 setup/teardown」(**R-e 附带**) | + +**改法(逐项)** + +1. **`00-grant.sql`**(**不进 `reset.ps1`**:`DROP DATABASE` 不清 `mysql.db` 授权行,授权一次即可;且它需管理员执行): + ```sql + -- 逐表授权:MySQL 无法用「库级 GRANT + 表级 REVOKE」做部分撤销,必须枚举 + -- jinrong_core:只读账号 + CREATE USER IF NOT EXISTS 'xh_core_ro'@'%' IDENTIFIED BY '<从 .env 取,禁止写死在仓库>'; + GRANT SELECT ON jinrong_core.* TO 'xh_core_ro'@'%'; + -- jinrong_core:写账号,限 4 表、无 DELETE、无 DDL + CREATE USER IF NOT EXISTS 'xh_core_rw'@'%' IDENTIFIED BY '<...>'; + GRANT SELECT, INSERT, UPDATE ON jinrong_core.core_trade TO 'xh_core_rw'@'%'; + GRANT SELECT, INSERT, UPDATE ON jinrong_core.core_share_lot TO 'xh_core_rw'@'%'; + GRANT SELECT, INSERT, UPDATE ON jinrong_core.core_holding TO 'xh_core_rw'@'%'; + GRANT SELECT, INSERT, UPDATE ON jinrong_core.core_convert_lot_detail TO 'xh_core_rw'@'%'; + -- jinrong_agent:业务表逐表授权,audit_log 只授 INSERT(红线升级为 DB 强制) + CREATE USER IF NOT EXISTS 'xh_agent_rw'@'%' IDENTIFIED BY '<...>'; + GRANT SELECT, INSERT, UPDATE ON jinrong_agent.<各业务表,逐一枚举> TO 'xh_agent_rw'@'%'; + -- ⚠️ 口径修正(实施时发现):架构 §11.1 原文「audit_log 只授 INSERT」若**字面执行**, + -- 会连 SELECT 一并剥夺 → has_engine_error_audit(:231) 与 list_audit_events(:420) 双双失败。 + -- 红线本意是「只能追加,不能修改或删除」→ 正确授权 = SELECT + INSERT(无 UPDATE/DELETE)。 + GRANT SELECT, INSERT ON jinrong_agent.audit_log TO 'xh_agent_rw'@'%'; + GRANT SELECT, INSERT, UPDATE ON jinrong_agent.risk_convert_detail TO 'xh_agent_rw'@'%'; + ``` + > 表级 GRANT 可针对**尚不存在**的表(T-1 才建),故 T-0b 可先于 T-1 执行 ✓ + +2. **`settings.py`** 新增(默认空 = 回退 `mysql_user`,**不阻塞本地开发**): + `mysql_core_ro_user` / `mysql_core_ro_password` / `mysql_core_rw_user` / `mysql_core_rw_password` / `mysql_agent_user` / `mysql_agent_password` + +3. **`db.py`**: + ```python + def get_engine(database: str, role: str = "rw") -> Engine: + """按 (库名, 角色) 缓存单例 Engine;role ∈ {"ro","rw","admin"}。 + 对应角色账号未配置 → 回退 settings.mysql_user(行为与现状一致)。""" + ``` + - `role="ro"` → 仅 core 库有意义(用 `mysql_core_ro_user`);agent 库无 ro 账号,回落 `mysql_agent_user`(或 `mysql_user`) + - `role="admin"` → 恒用 `mysql_user` + - 缓存键 `f"{database}:{role}"`;`dispose_engines()` 语义不变 + +4. **关键边界(架构 §11.1 明确)**:**gateway 的读走 `ro`、写走 `rw`** —— `trade_gateway.py:105` 的校验读 `core_ro or CoreReadOnlyRepository()` **保持 ro 不变**,只有 `gateway_repository.insert_trade` 与新增的 `convert_core_repository` 用 `rw`。否则 gateway 顺带获得全库读权限,最小权限落空。 + +**依赖**:无(可与 T-0 并行;但两者都必须在 T-1 之前) +**完成标准(DoD)** +- [x] `pytest -q` **全量全绿**(批 0 前基线 510 → 516;**默认配置下账号为空 → 全走 `mysql_user`,与现状完全一致**); + `tests/conftest.py:185/186/236/237` 四处已显式 `role="admin"`(R-e,防 teardown 失权) +- [ ] 本地配置 3 组账号后,真 MySQL 三条权限断言绿(**账号未配置或真库不可达时自动 skip**, + 不阻塞默认基线): + - `test_db.py::test_core_ro_account_is_readonly`(ro 执行 INSERT → 被拒) + - `test_db.py::test_audit_log_append_only`(`audit_log` UPDATE/DELETE → 被拒) + - `test_db.py::test_core_rw_scope`(rw 对第 5 张表 `core_product` UPDATE → 被拒) +- [ ] `test_db.py::test_get_engine_role_isolation`(同库不同角色 → 不同 engine) +- [x] `grep -rn "get_engine("` **全部调用点逐点有明确角色**(F-11 清单:app 4 处 = `ro`/`rw`;`conftest` 4 处 = `admin`;其余为 `test_db.py` 用例自身) +- [ ] 账号未配置时回退路径有单测(回退 = 现状行为) + +**风险**:低,但**若漏掉 conftest 的 admin 角色(R-e),真库集成测试会在 T-13 集体失败**——这是本任务最易漏的一处。 +**回滚点**:单独 commit;回滚 = 恢复单账号(`get_engine` 保留 `role` 参数但全部回落,无副作用)。 + +--- + +### 3.3 第 0 批执行记录(2026-09-10 完成) + +**基线**:开工前 `pytest -q` → **510 passed**。 + +| 任务 | 落地文件 | 结果 | +| --- | --- | --- | +| T-0 | `tests/_ddl.py`(重写 `core_holding` + 新增 `core_product_nav` + `EXPECTED_CORE_HOLDING_COLUMNS` / `REQUIRED_CORE_TABLES` + `_assert_ddl_aligned()`)· `tests/test_chat_tools.py` · `tests/test_concentration_c4.py` · `tests/test_db.py`(+3 用例) | ✅ | +| T-0b | `scripts/core/00-grant.sql`【新增】· `app/config/settings.py`(+6 项)· `app/utils/db.py`(`get_engine(db, role)` + `_resolve_credentials`)· `core_ro.py`→`ro` · `gateway_repository.py`→`rw` · `risk_repository.py`/`session_repository.py`→`rw` 显式 · `tests/conftest.py` 4 处→`admin` · `tests/test_db.py`(+3 引擎用例 +3 权限断言) | ✅ | + +**测试结果**:`pytest -q` → **516 passed / 3 skipped**(510 基线 + 3 条 T-0 用例 + 3 条 T-0b 引擎用例; +3 条真 MySQL 权限断言因账号未配置按设计 skip)。 + +**实施中发现并已回写本计划的 3 处偏差**: +1. **2 处测试 INSERT 缺 3 个 NOT NULL 列**(原计划只提「改列名」)→ 详见 §3.1 第 5 点。 +2. **`audit_log` 授权口径**:架构 §11.1 原文「只授 INSERT」若**字面执行**会让 + `has_engine_error_audit` / `list_audit_events` 失权 → 实际授权 **`SELECT, INSERT`**(无 UPDATE/DELETE)。 +3. **门禁反向验证改为自动化用例**(`test_ddl_alignment_guard_fails_when_column_missing`), + 不再需要「手工删列 → 验证 → 恢复」三步。 + +**开工前置完成情况**:T-0 与 T-0b **代码侧已全部落地,默认配置下全绿**。 +`00-grant.sql` 的**实际执行**(建账号)与 `.env` 写入密码属环境操作 —— 执行后 +3 条权限断言才会由 skip 转为真实运行;未执行不影响 T-1 开工(默认回退单账号)。 + +--- + +--- + +## 4. 第 1 批 · 数据与纯函数 + +### 4.1 T-1 · DDL + 种子 + sqlite 同步 + +**目标**:真库结构就位,种子可复现,sqlite 与之对齐。 + +**涉及文件与改动** + +| 文件 | 动作 | 要点 | +| --- | --- | --- | +| `scripts/core/01-ddl.sql` | 【改】 | ① 新建 `core_fee_rule` / `core_share_lot` / `core_convert_lot_detail`(含 `nav`/`nav_date`,D6)② `core_trade` **只加** `convert_group_id VARCHAR(64) NULL` + `KEY idx_convert_group`(**`qty` 已存在**,F-3)③ `core_product` 加 **8 列**:`can_subscribe`/`can_redeem`(TINYINT DEFAULT 1)、`min_hold_qty`/`min_redeem_qty`(DECIMAL DEFAULT 0)、`min_hold_action`(VARCHAR DEFAULT `'force_transfer'`)、`subscribe_fee_rate`(DECIMAL(6,4) DEFAULT 0)、`fund_company`/`ta_code`(VARCHAR NULL)④ `core_product.fee_rate` 补 COMMENT(Q8) | +| `scripts/core/07-seed-fee-rule.sql` | 【新增】 | 赎回费 5 档:`0.0150 / 0.0100 / 0.0050 / 0.0025 / 0`,`to_fund_ratio = 1.0`;**文件头带版本注释块** | +| `scripts/core/08-seed-share-lot.sql` | 【新增】 | 按 `core_holding` 反推批次,**按客户/产品错开持有期**以覆盖多档;文件头版本块 | +| `scripts/core/09-seed-org.sql` | 【新增】 | `fund_company`/`ta_code` + `subscribe_fee_rate`;**费率档严格按 `product_type` 取**(`stock`=0.0080 / `mixed`=0.0050 / `bond`·`index`=0.0030 / `money`=0),**造出不同费率对**:`PROD-110022`(bond)=0.0030 / `PROD-003095`(stock)=0.0080(**同属华夏组、可互转**);同费率对照 `PROD-510300`(index)=0.0030、零费率对照 `PROD-000001`=0;**分组按真实「管理人全产品线」重排**;文件头版本块 | +| `scripts/core/reset.ps1` | 【改】 | `$files` 追加 `07/08/09`(**00-grant 不加入**) | +| `docs/项目框架设计/表设计/02-mysql-agent专用.sql` | 【改】 | 追加 `risk_convert_detail` 建表(PRD §4.1 DDL 原文,`status` ENUM **5 值含 `expired`**) | +| `tests/_ddl.py` | 【改】 | `core_trade` 补 `qty`/`convert_group_id`;`core_product` 补 8 列;新增 `core_fee_rule`/`core_share_lot`/`core_convert_lot_detail`/`risk_convert_detail`(共 4 张) | + +**DoD(全部达成,见下方执行记录)** +- [x] `reset.ps1` 全流程跑通,且**可用 SQL 逐条断言**(判定阈值明确,非人工目测): + ① `SELECT COUNT(DISTINCT min_hold_days) FROM core_fee_rule WHERE fee_type='redeem'` = **5**(且每只产品各 5 档); + ② 按 `(customer_id, product_id)` 汇总 `core_share_lot.remain_qty` = `core_holding.qty`(逐行相等); + ③ `fund_company` / `ta_code` / `subscribe_fee_rate` 三列**无 NULL**,且存在 `subscribe_fee_rate` 不同的产品对 + ※ 执行方式:本机 `mysql` 客户端不在 PATH 且 `reset.ps1` 的 `-p` 需交互密码, + 改用**等价流程**(`pymysql` 执行同一组 00~09 SQL)并**固化为 `scripts/dev/verify_convert_seed.py`**。 +- [x] `pytest -q` 全量全绿(当前基线 **516**;sqlite 加表不改任何既有断言) +- [x] 老 INSERT(不带新列)仍可执行 → 向后兼容成立(架构风险 #9) +- [x] `risk_convert_detail.status` ENUM **建表即含 5 值**(免二期 ALTER) + +**执行记录(2026-09-10)** + +| 项 | 内容 | +| --- | --- | +| 落地文件 | `scripts/core/01-ddl.sql`(3 新表 + `core_trade.convert_group_id` + `core_product` 8 列 + `fee_rate` COMMENT)· `07/08/09-seed-*.sql`【新增】· `reset.ps1`(`$files` 追加 07/08/09)· `docs/项目框架设计/表设计/02-mysql-agent专用.sql`(追加 `risk_convert_detail`)· `tests/_ddl.py`(`core_trade` 补 `qty`/`convert_group_id`、`core_product` 补 8 列、新增 4 表、`REQUIRED_CONVERT_TABLES` 门禁)· `scripts/dev/verify_convert_seed.py`【新增】 | +| 断言结果 | **8/8 PASS**:① 5 档 × 14 产品(70 行,无产品档数≠5)② 58 行持仓 vs 61 行批次,**失配 0** ③ NULL 0 行、DISTINCT 费率 4 ④ 批次覆盖 5 档(13/12/12/12/12)⑤ CUST-9527 跨批次(005827×3、110022×2)⑥ 老 INSERT 兼容(新列取默认值 `None`/`1`/`force_transfer`/`0`)⑦ `status = enum('pending','completed','failed','cancelled','expired')` ⑧ **费率档 ↔ `product_type` 匹配**(越档 0 · 主示例两端同主体「华夏模拟基金/TA-CN-001」· 华夏组费率档 4) | +| pytest | `516 passed / 3 skipped`(与批 0 后基线**完全一致 → 零回归**;sqlite 加表与既有断言无耦合) | +| 真库落位 | `jinrong_agent.risk_convert_detail` **已实际建表**(`SHOW COLUMNS` 实测 `status = enum('pending','completed','failed','cancelled','expired')`);`jinrong_core` 已按 00~09 重灌并通过断言 | +| 文档同步 | `scripts/core/README.md`(手动顺序 + 种子规模:持仓 ~45→**58**、新增费率/批次两行)· `docs/项目框架设计/Core模拟底座/00-方案总览.md`(目录树补 00-grant/07/08/09)· `演示SOP-风控模块.md`(for 循环补 07/08/09)· **`docs/PRD/PRD-基金转换交易.md`(§4.3 费率分类归位 + §5.3 主示例转入方改 `PROD-003095` + §2.1.1/§9 引用同步)** | + +**实施中发现的 3 处需注意项**: + +1. **`reset.ps1` 的可执行性缺口(实跑踩坑)**:需 `mysql` 在 PATH 且 `-p` 交互输密码 → 非交互/CI 场景不可用。 + 已用 `scripts/dev/verify_convert_seed.py` 提供等价无交互路径(含 7 条 DoD 断言)。 + **更关键的连带坑**:重建 `jinrong_core` 后**必须补跑 `scripts/demo/prepare_risk_demo.sql`** + (reset 的既定伴随步骤,SOP §2)—— 否则 `03-seed-customers.sql` 写死的风评有效期会让 + `tests/conftest.py::ensure_risk_demo_ready` 判定演示数据未就位,**整个真库集成模块被 skip** + (实测 **516 → 505 passed**)。本脚本已内置该步骤,避免二次踩坑。**计划外增补,非返工**。 +2. **`core_fee_rule` 的读取路径已明确**(自检第 2 问「谁读」):`00-grant.sql` 中 + `xh_core_ro` 为 `SELECT ON jinrong_core.*`,**库级授权自动覆盖 T-1 新建表**; + `xh_core_rw` 刻意不授该表读权限 → convert 费率读取**必须走只读账号**(T-6 遵守)。**无需改 grant**。 +3. **费率分类错误 —— 已按真实业务修正(2026-09-10 用户裁定)**:v1.0 种子把 + `PROD-005827`(`product_type='mixed'`,平衡混合一号)按「主动偏股」取到 **0.0080** 上限, + 目的是凑出 0.50% 的补差幅度。**这是分类错误** —— 22 号文 §8 的费率档由**产品类型**决定, + `mixed` 只能套「其他混合型 ≤0.5%」。用户驳回原话:「**那是两种类型,怎么能混呢?**」 + **修正内容**(三处联动,缺一不可): + - `PROD-005827` → **0.0050**(其他混合型);主示例转入方改用**真正的主动偏股产品 + `PROD-003095`**(`stock` / R4 / 净值 **0.9500**)→ 与原示例除数相同,**§5.3 全部派生数字 + (`in_qty=53456.95` / `diff_fee=252.40` 等)一个都不用改**(已跑 `calc_convert_demo.py` 复核); + - **分组按真实业务重排**:v1.0 的「华夏组全低风险、易方组全股票型」不符合真实 + (真实基金管理人是**全产品线**)→ 现两组各 6 只、各覆盖多个风险层级; + 理财/私募单列(非公募主体,天然不可与公募互转); + - **连带约束核对**:`PROD-003095` 与转出方 `PROD-110022` **同属华夏模拟基金 / TA-CN-001** + (同管理人 + 同 TA → 满足转换前置硬约束);C3 客户 → R4 产品为 + `allowed_with_disclosure`(需签揭示书后**放行**,非阻断)。 + **机制落地**:该约束已固化为断言 **⑧「费率档 ↔ `product_type` 匹配」**(越档即 FAIL), + 不再依赖人工比对;并写入项目记忆**自检第 12 问:类型与口径必须匹配**。 + +**风险**:低。注意 `08-seed-share-lot.sql` 的持有期错开必须**真的落在不同档位**(否则费率档单测无数据)。 + +--- + +### 4.2 T-2 · `service/convert/` 纯函数包 + +**目标**:所有金额/费率/份额逻辑落到可穷举单测的纯函数里,**不查库、不碰 SQL**。 + +**新增文件**(`app/service/convert/`) + +| 文件 | 内容(架构 §7 签名) | +| --- | --- | +| `__init__.py` | 空 | +| `types.py` | `@dataclass(frozen=True)` 定义 `Lot` / `FeeRule` / `PlanResult`(评审 S3,**不用裸 dict**) | +| `calc.py` | `plan_lots()` · `lot_amount()` · `lot_fee()` · `convert_amount()` · `diff_fee(mode)` · `in_qty()` | +| `fee.py` | `pick_fee_rate(rules, hold_days)`(区间左闭右开) | +| `nav.py` | `is_stale()` + NAV_NOT_READY 判定(输入已取到的净值行,不查库) | +| `lot_bootstrap.py` | `bootstrap_lots(holding_row) -> list[Lot]`(D18:gateway 兜底补建与 `rebuild_lots.py` **同源**) | +| `errors.py` | 8 个 convert 专属异常(继承 `ApiError` 语义,§8.3 映射) | + +**必须写死的实现细节** + +1. **精度**:所有量化处显式 `rounding=ROUND_HALF_UP`(`Decimal` 默认是 `ROUND_HALF_EVEN`,架构风险 #3);**逐批先舍入后求和**。 +2. **补差费双口径**(D13): + - `mode="amount_diff"`(默认,口径 B):`Max[conv×in/(1+in) − conv×out/(1+out), 0]` + - `mode="rate_diff"`(口径 A):`conv × max(in−out,0) / (1 + max(in−out,0))` +3. **`in_qty` 2 位 HALF_UP**(v1.0 勘误:v0.2 的「4 位 ROUND_FLOOR」是残留,已废)。 +4. **`pick_fee_rate` 边界**:`[min_hold_days, max_hold_days)`;`hold_days = (交易日 − confirmed_at).days`(**自然日,不含申请日**;满 7 日归 7–30 档)。 +5. **`plan_lots` 一次性返回 `PlanResult`(含 `actual_qty`/`forced_full_transfer`/`action`/`batch_count`)**——`TOO_MANY_LOTS` 必须**先规划再判上限**,避免半途失败(§8.3)。 + +**另增 3 个纯函数(架构 §7 签名清单未列,T-2 执行期补入并已回填架构 §7)** + +| 函数 | 归属 | 为什么必须有 | +| --- | --- | --- | +| `hold_days(trade_date, confirmed_at)` | `calc.py` | `pick_fee_rate` 的入参是持有天数,而 `confirmed_at` 是时间戳;**日期差必须可单测**(T+1 起算、满 7 日归 7–30 档都靠它),不能散落在 service 里 | +| `rounding_diff(in_amount, in_nav, actual_in_qty)` | `calc.py` | PRD §5.3 响应字段,需与 `in_qty` **同一套舍入**算差值,独立实现在脚本侧会漂移 | +| `ensure_batch_limit(plan, max_lots)` | `calc.py` | 把「批次数超上限」的判据固定在 `plan_lots` 的产物上(`batch_count`),避免调用方各写一遍 `>` 判断 | + +**DoD(全部达成,见下方执行记录)** +- [x] `tests/test_convert_calc.py` ~35 条绿:FIFO / 跨批次计费 / 双口径各一组 / **`.5` 边界(HALF_UP vs HALF_EVEN 结果必须不同处命中 HALF_UP)** / 分档边界 `6/7/29/30/179/180/364/365` / 强制全转 + 强制赎回双动作 / T+1 起算 / 份额 2 位 +- [x] `plan_lots` 的 `batch_count` 在 `> 200` 时可供调用方抛 `TOO_MANY_LOTS` +- [x] 纯函数**零 import 仓储/引擎**(`grep -n "import" calc.py` 人工确认) + +**执行记录(2026-09-10)** + +**新增 7 个文件**(`app/service/convert/`): + +| 文件 | 内容 | +| --- | --- | +| `__init__.py` | 包职责 docstring(纯函数约束:不查库 / 不碰 SQL / 不读配置 / 不写日志) | +| `types.py` | `Lot` / `FeeRule` / `LotAllocation` / `PlanResult`(`@dataclass(frozen=True)`)+ `to_decimal` / `to_datetime` / `to_date` 三个归一工具 | +| `calc.py` | `round2` / `lot_amount` / `lot_fee` / `convert_amount` / `in_qty` / `rounding_diff` / `diff_fee` / `hold_days` / `plan_lots` / `ensure_batch_limit` | +| `fee.py` | `matches` / `pick_fee_rate`(左闭右开、多档命中取最具体档) | +| `nav.py` | `ensure_nav_ready`(无净值 → 503)/ `is_stale` / `evaluate_nav` | +| `lot_bootstrap.py` | `offset_for` / `bootstrap_lot_id` / `bootstrap_lots`(D18 单点) | +| `errors.py` | `ConvertError` 基类 + **11 个**子类(见下方差异说明) | + +**关键实现点** + +1. **精度**:所有量化点显式 `rounding=ROUND_HALF_UP`(`Decimal` 默认 `ROUND_HALF_EVEN`);测试里有一条**反向自证**用例——先证明 `HALF_EVEN` 会得到不同结果,再断言 `round2` 不等于它(漏传 `rounding` 时必红)。 +2. **`plan_lots` 不判上限**:只做 FIFO 分配 + 最低持有处置,上限判据由 `ensure_batch_limit` 在**规划之后**执行(§8.3「先规划再判上限」)。 +3. **`_fifo_order` 内部再排一次序**:SQL 已 `ORDER BY confirmed_at, lot_id`,但纯函数不依赖调用方是否记得写 ORDER BY(否则同注册日多批次顺序不可复现,评审 S1)。 +4. **`bootstrap_lots` 用 `zlib.crc32` 而非内置 `hash`**:`hash` 受 `PYTHONHASHSEED` 随机化,会让 gateway 与 `rebuild_lots.py`(两个进程)算出不同 `confirmed_at`,D18 的同源断言会随机失败。 +5. **`pick_fee_rate` 无命中 → 500 `FeeRuleMissing`**,**不降级为 0**:静默按 0 计费会少收赎回费且不留痕。 + +**与 §8.3 / 开发计划的两处差异(均已核实、非笔误)** + +| 项 | 文档原写法 | 实际实现 | 说明 | +| --- | --- | --- | --- | +| 异常个数 | 开发计划 §4.2 写「**8 个**」 | **11 个** | §8.3 表实有 10 条(7 个业务 400 + `LOT_CONFLICT` 409 + 2 个 503);另加 1 个 §8.3 表外的内部兜底 `FeeRuleMissing`(500)。开发计划「8 个」为计数笔误,以 §8.3 表为准 | +| `hold_days` 等 | 架构 §7 未列 | 见上方「另增 3 个纯函数」 | 已回填架构 §7(纯函数签名清单补 2 行)+ §8.3(补 `FEE_RULE_MISSING` 注) | + +**验证证据** + +| 项 | 结果 | +| --- | --- | +| `pytest tests/test_convert_calc.py -q` | **93 passed**(12 个测试类,参数化展开后 93 条) | +| `pytest -q` 全量 | **609 passed / 3 skipped**(T-1 后基线 516 → **+93**,零回归) | +| 纯函数零 IO 依赖 | 由 `TestPurity` **3 条断言**强制(import 白名单 + 禁用符号 + `hash(` 禁用),**不留人工核对口子** | +| PRD §5.3 数字自证 | 见 T-2b 执行记录(15/15 一致,脚本退出码 0) | + +**回归面(§12)**:**T-2 为纯新增包,未修改任何既有文件**(`app/service/convert/` 下 7 个新文件 + 1 个新测试文件), +故 §12 十六条回归面**一项都未触发**;全量 609 passed 中的 516 条既有用例**逐条原样通过**,证明这一点。 + +**依赖**:无(可与 T-1 并行) + +--- + +### 4.3 T-2b · 示例实算回填(禁止手算) + +**目标**:PRD §5.3 主示例的每个数字由**生产同一套 `calc.py`** 实算产出,回填 PRD 与验收断言。 + +**新增文件**:`scripts/dev/calc_convert_demo.py` +**输出**:主示例(口径 B,`in_qty = 53456.95`)+ 口径 A 对照(`53455.36`)+ 同费率对照(`diff_fee = 0.00`) + +**DoD(全部达成,见下方执行记录)** +- [x] 脚本输出与 PRD §5.3 表格**逐行一致**(不一致则说明公式或示例数据有问题 → 停下来查,**不得改示例数字迁就脚本**,也不得手算改脚本) +- [x] PRD §5.3 表格标注「由 `calc_convert_demo.py` 实算回填」 +- [x] 脚本纳入 `docs/项目框架设计/` 引用,供审核复核 + +**执行记录(2026-09-10)** + +**脚本被重写,不只是「跑一遍」——原实现有一处结构性隐患** + +| | 改前(T-1 期的临时脚本) | 改后 | +| --- | --- | --- | +| 公式来源 | **脚本内自带一份副本**(`q2()` + 逐批循环 + 两个口径的算式) | **只调用生产 `app.service.convert.calc` / `fee`**,脚本仅做输入准备与打印 | +| 风险 | 与生产实现**两处公式必然漂移**(生产 `calc.py` 改了、脚本没改 → PRD 里的数字变成**假证据**)——与 D18「规则只留一个副本」是同一类问题 | 单点来源,无副本可漂移 | +| 自校验 | 无(人工比对输出与 PRD) | **15 项期望值逐项比对,不一致即 `exit 1`** → 脚本同时是「PRD 数字 ↔ 生产口径」一致性门禁 | + +**顺带修正**:脚本补 `sys.path` 引导项目根(`ROOT = parents[2]`,同 `verify_convert_seed.py` 先例)——改前脚本不 import 项目代码,故未暴露;一旦改成调用生产函数,直跑会 `ModuleNotFoundError: No module named 'app'`。 + +**验证结果**(`python scripts/dev/calc_convert_demo.py`,退出码 **0**): + +``` +批次 1(LOT-DEMO-01)30000.0000 份 / 持有 100 天 / 费率 0.0050 → amount=30900.00 fee=154.50 +批次 2(LOT-DEMO-02)20000.0000 份 / 持有 3 天 / 费率 0.0150 → amount=20600.00 fee=309.00 +✅ 全部 15 项与 PRD §5.3 一致(口径 B in_qty=53456.95) +``` + +15 项 = 逐批 4 项(两批各 amount/fee)+ 主链 7 项(`out_amount` 51500.00 / `redeem_fee` 463.50 / `convert_amount` 51036.50 / `diff_fee(B)` 252.40 / `in_amount` 50784.10 / `in_qty(B)` 53456.95 / `rounding_diff` -0.0026)+ 口径 A 对照 2 项(253.91 / 53455.36)+ 同费率对照 2 项(0.00 / 53722.63)。 + +**依赖**:T-2 + +--- + +## 5. 第 2 批 · 仓储与锁(并行组 A) + +### 5.1 T-3 · `core_ro` 五个新方法 + `share_lot_repository` + +| 文件 | 动作 | 要点 | +| --- | --- | --- | +| `app/repository/core_ro.py` | 【改】新增 5 方法 | `get_nav_as_of(pid, trade_date)`(`nav_date <= :d` 降序取 1,D10)· `get_redeem_fee_rules(pid)` · `list_share_lots(cid, pid, max_lots)`(**`ORDER BY confirmed_at ASC, lot_id ASC`**,S1 确定性 tiebreaker)· `sum_remain_qty(cid, pid)` · `get_holding(cid, pid)` | +| | 【改】**不动** | `get_latest_nav`(D10:改它会动既有调用方)· `list_trades_range` 的 `trade_type` 白名单(F-8:convert 两条流水本就是 redeem/subscribe,无需改) | +| `app/repository/share_lot_repository.py` | 【新增】 | `core_share_lot` **读侧**:FIFO 选批 + 汇总(写侧归 `convert_core_repository`,D2) | + +**DoD** +- [ ] 5 个方法各有 sqlite 单测(`test_convert_calc.py` 或独立 `test_share_lot.py` 中) +- [ ] `list_share_lots` 在**同一 `confirmed_at` 多批次**时顺序可复现(构造两行同 `confirmed_at`,断言按 `lot_id` 升序) +- [ ] `sum_remain_qty` 只统计 `remain_qty > 0` +- [ ] `pytest -q` 全绿(新增方法不动既有行为) + +**依赖**:T-1 + +--- + +### 5.2 T-4 · `convert_repository`(agent 侧) + +| 方法 | 用途 | +| --- | --- | +| `insert_placeholder(group_id, client_request_id)` | 阶段零占位(`uk_idem` 兜底) | +| `complete_convert(group_id, ...)` | 阶段二回写 `completed` + 详情 | +| `mark_failed(group_id)` | 阶段一失败 → 占位置 `failed` | +| `get_by_group_id(group_id)` | 重试判定读取 | +| `get_by_client_request_id(cid_req)` | 幂等命中读取 | +| `list_expired_candidates(hours)` | 供 `cleanup_pending_convert.py` 取超 24h 的 `pending` | +| `mark_expired(group_id)` | 置 `expired`(**标记不硬删**,S2) | + +**DoD** +- [ ] sqlite 单测覆盖:占位 → completed / 占位 → failed / 超时 → expired +- [ ] `client_request_id` 为 `None` 时不受 `uk_idem` 约束(MySQL 允许多个 NULL;sqlite 侧需断言行为一致) +- [ ] 不与 `risk_repository` 混职责(后者不动) + +**依赖**:T-1 + +--- + +### 5.3 T-5 · `locks.try_lock` + +```python +def try_lock(key: str, ttl_seconds: int = LOCK_TTL_SECONDS) -> _Token | None: + """单次尝试抢锁:抢到返回 token,抢不到立即 None(不等、不降级)。 + Redis 不可用 → 退回进程内 Lock.acquire(blocking=False),语义一致。""" +``` +- **不改 `run_locked`**(D4:`run_locked` 会等 2s 并降级 `fn(False)`,与「抢不到立即 202」语义相反;且它已有 3 处调用:`alert_service.py:180` 的 `agg:event:` / `:239` 的 `agg:suitability:` 等) +- 返回 `_NoLock` 哨兵对象,`__enter__` 返回 `False`,避免 `with None` 报错 + +**DoD** +- [ ] 抢到 / 抢不到 / Redis 不可用回退 三条路径单测 +- [ ] key 形态覆盖:`convert:idem:{cid_req}` / `convert:rerun:{gid}` +- [ ] `run_locked` 的既有用例零改动 + +**依赖**:无 + +--- + +## 6. 第 3 批 · 事务与编排 + +### 6.1 T-6 · `convert_core_repository.apply_convert`(阶段一单事务 · 高风险) + +**新增文件**:`app/gateway/convert_core_repository.py` + +**单事务内容**(`with engine.begin()` 串行,D3) + +| 序 | SQL | 备注 | +| --- | --- | --- | +| 1-2 | `INSERT core_trade` × 2 | **同 `convert_group_id`**;`trade_type` 分别为 `redeem`(转出)/`subscribe`(转入)——**R-b** | +| 3 | `UPDATE core_share_lot SET remain_qty = remain_qty - :q WHERE lot_id=:lot AND remain_qty >= :q` × N | **条件 UPDATE**,`rowcount != 1` → 抛 `LotConflict`(409) | +| 4 | `INSERT core_share_lot` × 1 | 转入新批次(`confirmed_at = T+1`,D14) | +| 5-6 | `UPDATE core_holding ...` × 2 → `rowcount == 0` 则 `INSERT` | **不用方言 UPSERT**(R-a) | +| 7 | `INSERT core_convert_lot_detail` × N | 含 `nav`/`nav_date`(D6) | + +**必须守住** +- 引擎**必须**用 `get_engine(core_db, "rw")`(T-0b) +- 阶段一**任何一步失败 → 整体回滚**(`engine.begin()` 异常即 rollback) +- 转出端 `qty` 归零**保留行**(D9/P2),不 DELETE(`xh_core_rw` 本就无 DELETE 权限) +- `cost_amount` 等比例结转:`cost × (1 − actual/原qty)`;`pnl_pct = (mv − cost)/cost`,`cost=0` 置 0 +- 两端 `core_holding` 写入**必须走 R-a 的三步**(条件 UPDATE → 首次 INSERT → `IntegrityError` 回退 UPDATE), + 不得用 `ON DUPLICATE KEY UPDATE`(sqlite 语法错)也不得用 `WHERE NOT EXISTS` / `FROM DUAL`(方言互斥,§1.5 R-a 已实测排除) + +**DoD** +- [ ] sqlite 单测:正常路径落 2 流水 + N 明细 + 两端持仓 +- [ ] **冲突路径**:构造 `remain_qty < q` → `rowcount=0` → `LotConflict` +- [ ] **回滚路径**:在明细 INSERT 处注入异常 → 断言 `core_trade` / `core_share_lot` / `core_holding` **全部无残留**(这是「同事务」的硬证据) +- [ ] 两端 `core_holding`:转出端归零保留行;转入端首次 INSERT、再次 UPDATE +- [ ] **并发首次建行(R-a 兜底验证 · 第二轮审核补入)**:模拟两笔并发首次转入同一 `(cid, pid)` → + 断言最终**只有一行**、金额为两笔之和、**两笔都成功**(不得出现 5xx) + +**依赖**:T-1 / T-3 + +--- + +### 6.2 T-7 · `convert_service` 编排(关键路径 · 高风险) + +**新增文件**:`app/service/convert/convert_service.py` + `__init__.py` 导出 + +**八步顺序**(架构 §3 / PRD §7.0,**前四步不落库**) + +``` +① 参数与产品校验(from != to / can_redeem / can_subscribe / 同 fund_company+ta_code)→ 4xx +② 份额校验(Σ share_lot.remain_qty 为权威源,非 core_holding.qty)+ 最低份额(全额豁免)→ 4xx +③ 净值取数与折算(纯函数)→ 无净值 503 NAV_NOT_READY +④ 适当性校验(转入端,唯一业务阻断点)→ blocked 出 R-02 预警 + 审计 → return(不占位) +⑤ 阶段零:try_lock("convert:idem:{cid_req}") 未抢到 → 202 + group_id;占位失败(带键)→ 503 +⑥ 阶段一:apply_convert(失败 → 占位 failed + 4xx/5xx) +⑦ 阶段 1.5:process_convert_event(out, in) → 异常落 engine_error + 本地日志,**不阻断** +⑧ 阶段二:complete_convert + 审计(失败 → decision='convert_detail_write_failed' + logger.exception) +``` + +**关键实现点** +- **重试判定**:取得执行权后 `SELECT 1 FROM core_trade WHERE convert_group_id = :gid LIMIT 1` → 有行 = 阶段一已成,**只补跑阶段二**(走 `core_ro` 只读;依赖 `idx_convert_group`) +- **补跑阶段二必须 `try_lock("convert:rerun:{gid}")`**(PRD §11:两个并发重试会审计双写,审计表只 INSERT 无法去重,只能靠锁串行) +- **`nav_stale`**:`nav_date` 距交易日 > `convert_nav_stale_seconds`(默认 3 天)→ 额外落 `nav_stale` 副审计 +- **`rounding_diff`** 落审计(正负向 + 金额,Q10) +- **本地日志兜底**:阶段二失败时 `logger.exception` 输出 `group_id` + 全部折算参数(PRD §7.1 第三轮第 8 条) + +**DoD** +- [ ] `tests/test_convert_service.py` ~22 条:八步顺序(**blocked 不占位**)/ 三阶段 / 幂等命中 / 阶段二失败补偿 / `nav_stale` 审计条数 +- [ ] 幂等:同 `client_request_id` 重复提交 → 只一组流水(验收 12) +- [ ] 幂等窗口闭合:阶段二失败后带同键重试 → 不产生第二组流水,且 RISK-002 当日累计**不翻倍**(验收 15) +- [ ] 未抢到执行权 → 202 `{convert_group_id, status: "processing"}` +- [ ] 全额转出豁免:持有 6000 / 申请全转 6000 / `min_redeem_qty`=10000 → **成功**(验收 16) + +**依赖**:T-2 ~ T-6 + +--- + +## 7. 第 4 批 · 引擎与网关 + +### 7.1 T-8 · 规则引擎改造 + +| 文件 | 动作 | +| --- | --- | +| `app/service/risk/rules.py` | 【改】新增 `_amount_view(trades)`;`run_rules` 内部:`eligible`(全量)供 RISK-001/003/004,`_amount_view(eligible)` 供 **RISK-002/005**(RISK-006 读持仓快照,不受影响) | +| `app/service/risk/engine.py` | 【改】新增 `process_convert_event(out_trade, in_trade, *, core_ro=None, risk_repo=None, thresholds=None, on_error_hook=None)`,与 `process_trade_event` 共用内部 `_run()`(**不改现有签名**,W-2) | +| `app/service/risk/alert_service.py` | 【改】`record_trade_alerts(..., events: list[dict] | None = None)`,缺省 `None` → 退化为现有「单 event」行为(**现有调用零改动**) | + +**关键实现点** +- `_amount_view`:同 `convert_group_id` 组内**只保留 `trade_type='redeem'` 那条**;**无 gid 的交易原样通过**(非 convert 场景恒等 → 510 零影响);组内无 redeem 时取第一条(防御) +- `process_convert_event`:取当日全量流水(已含两条)→ `run_rules` → `record_trade_alerts(primary=out_trade, hits, events=[event_of(out), event_of(in)])` → **一张单、`payload.events` 两条** +- **`on_error_hook`(D19)**:一期传 `None`;**hook 调用必须包 `try/except`**,hook 自身失败**不得**反噬主流程(与「阶段 1.5 不阻断交易」同原则)——**必须有单测** + +**DoD** +- [ ] `_amount_view` 对无 gid 交易恒等(`assert _amount_view(x) == x` 型用例) +- [ ] 一组 convert 两条流水 → RISK-002 只计一次;RISK-001/RISK-003 仍看到两条(验收 5/6) +- [ ] `process_convert_event` 只出**一条**预警单,`payload.events` 长度 2(验收 7) +- [ ] hook 抛异常时主流程正常返回(断言不抛) +- [ ] `pytest -q` 全绿(引擎既有 13 处 `run_rules` 调用零改动) + +**依赖**:无(可与 T-2 之后任意阶段并行) + +--- + +### 7.2 T-9 · API 模型与网关分派 + +| 文件 | 动作 | +| --- | --- | +| `app/api/simulate.py` | 【改】`TradeRequest`:`product_id` 改 `Optional`、`amount` 改 `Decimal | None = Field(None, gt=0)`、新增 `from_product_id`/`to_product_id`/`qty`/`client_request_id`;`@model_validator(mode="after")` 按 `trade_type` 分支校验 | +| | 【改】错误映射:`UnsupportedTradeType` → 400 保留(未知类型);新增 convert 异常 → `ApiError`(§8.3 映射表) | +| `app/gateway/trade_gateway.py` | 【改】三处并列:① 移除 `:112-113` 的 convert 拒绝 ② subscribe/redeem 分支增补批次维护(**T-10**)③ 新增 convert 分派(调 `convert_service`) | +| `app/gateway/gateway_repository.py` | 【改】`insert_trade` 增**可选**参数 `qty=None` / `convert_group_id=None`(默认 None → 既有调用零改动) | + +**关键实现点** +- **`client_request_id` 校验复用 `app/main.py:44` 的 `_TRACE_ID_PATTERN`**(`^[A-Za-z0-9._-]{1,64}$`),**不自造正则**(S4;避免两套白名单漂移) +- `amount: Decimal | None = Field(None, gt=0)`:**保住**「amount=0 → 422」(F-12 第 4 条);pydantic v2 中 `gt` 对 `None` 不校验、对 `0` 校验 → 该用例自动通过 +- convert 响应:**纯 dict + 全部 Decimal 已 `str()`**,含 `lot_breakdown`/`out_amount`/`convert_amount`/`in_amount`/`in_qty`/`rounding_diff`/`batch_count`/`max_lots` +- `TOO_MANY_LOTS` 错误体带 `batch_count` + `max_lots`(前端提示「请拆分多笔申请」) +- **不改路由**:仍为 `POST /api/simulate/trade`(F-12/§1.2,路由清单断言零影响) + +**DoD** +- [ ] `tests/test_trade_gateway.py` 的 4 处断言按 §12 清单改写完毕(R3/R4/R5 + R7 补批次断言) +- [ ] `tests/test_integration_risk.py:426-437` 的 `test_convert_400_and_no_new_trade_audit` 按 §12 **R15** 处置(改写为端到端走通,或迁入 `test_convert_integration.py`) +- [ ] `test_convert_integration.py`:真 MySQL `CNV-TEST-`/`TRD-TEST-` 前缀隔离,端到端折算与 PRD §5.3 示例逐项吻合(~6 条) +- [ ] 错误码映射 8 条各有断言(含 `CROSS_ENTITY_NOT_SUPPORTED`,验收 14) +- [ ] 未知 trade_type(如 `purchase`)仍 400 + +**依赖**:T-7 + +--- + +### 7.3 T-11 · 工具汇总去重 + SQL 求和去重 + +| 文件 | 动作 | +| --- | --- | +| `app/tool/core_tools.py` | 【改】`query_recent_trades` 汇总走 `_amount_view`(FR-C15);持仓查询(`query_holdings`)过滤 `qty <= 0`(P2 归零行保留但需过滤) | +| `app/repository/core_ro.py` | 【改】`sum_trades_on_date` 加 convert 去重条件(**R-d**,架构 §6 未覆盖) | +| `tests/test_core_ro_sum.py` | 【改】追加 1 条 convert 去重用例 | + +**DoD** +- [ ] `core_tools` 汇总不翻倍(验收 11) +- [ ] 持仓查询不返回 `qty = 0` 的行 +- [ ] `sum_trades_on_date` 既有 5 条断言零改动 + 新 1 条去重用例绿 +- [ ] `pytest -q` 全绿 + +**依赖**:T-8 + +--- + +## 8. 第 5 批 · 高风险专项 + +### T-10 · 普通申赎批次维护(FR-C16)+ `rebuild_lots.py` + +> **单列原因**:这是**唯一直接改 `trade_gateway` 主流程**的任务,而 `trade_gateway` 是 +> `test_trade_gateway.py`(~20 条)与 `test_integration_risk.py`、`test_audit_middleware.py` 的公共入口 +> → **510 用例直接受影响**。架构风险表 #6 定为「先跑基线后改造」。 + +**目标**:批次表由**交易统一出入口**维护,覆盖全部交易类型;消除 PRD §4.4 描述的「普通赎回后批次与持仓失配 → convert 超扣」。 + +**涉及文件** + +| 文件 | 动作 | +| --- | --- | +| `app/gateway/trade_gateway.py` | 【改】subscribe 分支:新增批次;redeem 分支:FIFO 扣减;**两者均含 D8 兜底补建** | +| `scripts/core/rebuild_lots.py` | 【新增】按 `core_holding` 重建批次(**快照重建,非交易回滚**,L-7);**与 gateway 兜底补建同调 `lot_bootstrap.bootstrap_lots`**(D18) | +| `tests/test_share_lot.py` | 【新增】~12 条:普通申赎批次维护 / 无批次兜底补建 / `rebuild_lots` 入口 | +| `tests/test_trade_gateway.py` | 【改】补批次维护断言 | + +**风险控制(五道)** + +1. **开工前跑全量全绿基线并快照**(当前 **516**;架构风险 #6 硬要求) +2. **先写测试再改**:`test_share_lot.py` 先行 +3. **D18 同源断言**:构造同一 `core_holding` 行,断言「gateway 兜底补建」与「`rebuild_lots.py`」算出的批次**完全一致**(`confirmed_at` 必须相同——这是 D18 设立的唯一目的) +4. **降级规则(R-c(1))**:`subscribe` 取不到当日净值 → warning + 不建批次;`redeem` **既无批次也无持仓行** → warning + 跳过扣减;**两者均不抛异常、不阻断交易**(既有 510 用例的 `env` 无持仓无净值,F-13) +5. **覆盖率补偿(R-c(2),第一轮审核补入)**:`test_share_lot.py` **自建完整种子**(`core_holding` + `core_share_lot` + `core_product_nav`),覆盖两条**真实路径**——`redeem` FIFO 扣减(含 D8 兜底补建再扣)与 `subscribe` 建批次;**不得让降级路径充当测试覆盖** + +**D8 兜底补建规则(`lot_bootstrap.bootstrap_lots` 单点)** +- `qty` = `core_holding.qty` +- `confirmed_at` 按**错开规则**由 `core_holding.as_of` 反推(与 `08-seed-share-lot.sql` 同口径) +- 无批次时**补建再扣**,**不跳过、不阻断** + +**DoD** +- [ ] 开工前:全量全绿基线已记录(当前 **516**) +- [ ] 正常赎回后再 convert,**批次与持仓一致、不超扣**(验收 9) +- [ ] **真实路径全覆盖**(R-c(2)):`test_share_lot.py` 自建种子 → `subscribe` 建批次(`qty = amount ÷ nav`,2 位 HALF_UP,`confirmed_at = T`)· `redeem` FIFO 扣减 · 无批次有持仓 → **D8 兜底补建后再扣** · `rebuild_lots.py` 入口 +- [ ] 降级路径单测(R-c(1)):`subscribe` 无净值 → warning + 不建批次 + 不抛异常;`redeem` 无持仓无批次 → warning + 跳过扣减 +- [ ] D18 同源断言绿(两侧 `confirmed_at` 逐一相等) +- [ ] `pytest -q` **510 + 新增全绿** +- [ ] `test_trade_gateway.py` 既有断言按 §12 处置完毕(**R16 应零改动通过**) + +**依赖**:T-3(排期置于 T-7 之后) + +--- + +## 9. 第 6 批 · 补偿脚本(T-12) + +| 文件 | 动作 | +| --- | --- | +| `scripts/demo/rebuild_alerts.py` | 【改】支持 `--convert-group CNV-xxx`:仅凭 Core 侧(`core_trade` + `core_convert_lot_detail` 的 `nav`/`nav_date`)重算详情并回写 → `completed`;**必须 `try_lock("convert:rerun:{gid}")`** | +| `scripts/agent/cleanup_pending_convert.py` | 【新增】超 `convert_compensate_sla_hours`(24h) 的 `pending` 占位 → 置 `status='expired'`(**标记不硬删**,S2) | + +**幂等锚点(Q4)**:以**转出端 `out_trade_id`** 为锚点 —— 复用 `rebuild_alerts.py:54-66` 的 `find_alerts_by_trade(trade_id)`,命中即 `skipped`;**一次转换有两条流水,只认转出端**,避免重复出单。该脚本**本就幂等**(其 `:8-10` docstring),**不需要再加 `uk_idem`**。 + +**DoD** +- [ ] 阶段二失败 → `decision='convert_detail_write_failed'` 审计存在(验收 17 前置) +- [ ] `rebuild_alerts --convert-group` 能仅凭 Core 侧数据补出**完整详情** → `completed`(验收 17) +- [ ] 重复执行补偿 → `skipped`,不产生第二张预警单(幂等) +- [ ] `cleanup_pending_convert.py` 把超 24h `pending` 置 `expired`,**行仍在**(不硬删) + +**依赖**:T-4 / T-7 + +--- + +## 10. 第 7 批 · 回归与实测(T-13) + +### 10.1 全量回归 +- `pytest -q` 全绿,计数 ≥ 585(CI 内预计约 597,压测 10 条不进门禁) +- 集成测试(真 MySQL,`CNV-TEST-`/`TRD-TEST-` 前缀)绿 +- 按 SOP 重灌双库后复跑一次(避免残留数据污染) + +### 10.2 50 并发压测(评审 Q6 · **不进 CI 门禁**) +同一 `(customer_id, product_id)` 上 50 并发争抢同一批份额,断言三件事: +1. `LotConflict`(409) 命中数与剩余可转份额一致 → **不许超卖** +2. 按 `100/200/400ms` 退避重试 ≤3 次后的**最终成功率**(验证 §8.3 建议间隔是否够) +3. `core_trade` 中 `convert_group_id` **无重复**;`core_share_lot.remain_qty` 之和 = 初始值 − 实际成交份额 + +### 10.3 性能实测补录(PRD §9 第 18 条) +- 端到端响应 **< 2s**(本地模拟库) +- 阶段一单库事务实测耗时**补录真实值**(PRD 原文的「< 100ms」是**预估值、非验收硬指标**) +- **若实测超阈值 → 优化索引/锁策略后重定阈值;不得反向修改实测数据迁就指标** + +**DoD** +- [ ] 三项完成且**落点明确**:① 性能实测数据**回填 `docs/PRD/PRD-基金转换交易.md` §9 第 18 条**(用实测值替换「预估 < 100ms」); + ② 50 并发结论(是否超卖 / 退避间隔够不够)写入 `docs/交接文档-基金转换.md`;③ 若实测超阈值 → 优化索引/锁策略后**重定阈值**(**不得反向修改实测数据迁就指标**) +- [ ] 50 并发结果写入交接文档(含退避间隔是否够用的结论) + +--- + +## 11. 测试增量与基线预测 + +| 层 | 文件 | 覆盖点 | 预计 | +| --- | --- | --- | --- | +| 纯函数 | `test_convert_calc.py` | FIFO / 跨批计费 / 双口径 A·B / 舍入 `.5` 边界 / 分档边界(6/7/29/30/179/180/364/365)/ 双动作 / T+1 | ~35 | +| 编排 | `test_convert_service.py` | 八步 / 三阶段 + 阶段 1.5 / 幂等命中 / 阶段二失败 / `nav_stale` 条数 | ~22 | +| 并发 | `test_convert_concurrency.py` | 条件 UPDATE rowcount=0 → 409 / 同键并发 → 202 / 补跑加锁 / 50 并发 | ~10 | +| 批次 | `test_share_lot.py` | 普通申赎批次维护 / 兜底补建 / `rebuild_lots` | ~12 | +| 集成 | `test_convert_integration.py` | 真 MySQL 端到端,与 §5.3 逐项吻合 | ~6 | +| 回归 | `test_trade_gateway.py` | 批次维护断言 + convert 走通改写 | +5 | +| 门禁 | `test_db.py` | 列名/PK/表存在性 + 角色隔离 + 3 权限断言 | +6 | +| 仓储 | `test_core_ro_sum.py` | convert 去重 | +1 | +| **合计** | | | **+97**(含不进 CI 的压测 ~10 条)→ **516 → 613**;**扣除压测后 CI 内 ≈603**;落地区间 **591~616** | + +--- + +## 12. 回归面清单(既有用例的逐条处置 · **本计划核心交付物之一**) + +> 下表每一条都是**必然会被本次改造打穿**的既有断言。不做处置 = 510 会红。 +> 核对方式:`grep` + 读源码(行号为撰写时实测值)。 + +| # | 位置 | 现有断言 | 为何失效 | **处置** | +| --- | --- | --- | --- | --- | +| **R1** | `tests/test_chat_tools.py:81` | `INSERT INTO core_holding (customer_id, product_id, market_value, quantity)` | T-0 删 `quantity` 列 → `no such column`;**且新 DDL 的 `cost_amount`/`pnl_pct`/`as_of` 为 NOT NULL** | 改为 `... market_value, qty, cost_amount, pnl_pct, as_of)` 并补 3 个 NOT NULL 值;`market_value`/`qty` 数值不变 | +| **R2** | `tests/test_concentration_c4.py:74` | 同上 | 同上 | 同上 | +| **R3** | `tests/test_trade_gateway.py:92-99` `test_convert_rejected` | `pytest.raises(UnsupportedTradeType, match="转换交易暂不支持")` | T-9 **移除**了 convert 拒绝 | **拆分**:① 未知类型 `purchase` → 仍 400(保留该半条断言)② convert → 改由新用例覆盖走通路径;`:98` 的 `core_trade == 0` 断言随 ① 保留 | +| **R4** | `tests/test_trade_gateway.py:215-219` `test_api_convert_returns_400` | convert → 400 + `BAD_REQUEST` | 同上 | **改写为** `test_api_convert_returns_200`(需真 MySQL 或 sqlite 造齐产品/持仓/费率/净值数据)→ 若成本高,**降级为集成测试用例**(`test_convert_integration.py`)并在单测层删除该条 | +| **R5** | `tests/test_trade_gateway.py:261-263` `test_api_non_positive_amount_returns_422` | `amount=0` → 422 | `amount` 变 `Optional`(**若 `gt` 被顺手删掉**才会失效) | **保住**:`amount: Decimal \| None = Field(None, gt=0)`。**已实测**(pydantic 2.13.4):`0`/`-1` → `greater_than` 校验失败(422);`None` → 放行;`100` → 正常 → **零改动通过**。**这是 `gt` 必须保留的理由** | +| **R6** | `tests/test_trade_gateway.py:40-72` `env` fixture | 只灌 customer/customer_risk/product,**无 holding、无 nav** | T-10 后 subscribe 建批次需净值、redeem 兜底补建需持仓 | 按 **R-c** 降级(取不到净值 → warning 不建批次);**不改该 fixture** | +| **R7** | `tests/test_trade_gateway.py` 全文件 | — | T-10 改 `trade_gateway` 主流程 | 补批次维护断言(+5);**T-10 单独结项、先跑基线** | +| **R8** | `tests/test_db.py:30-54` 3 条既有用例 | `get_engine(database)` 缓存单例 + dispose | T-0b 缓存键变 `(db, role)` | `len(created) == 2` 等断言**仍成立**(不同 db → 不同 engine);**追加** 2 条新用例(角色隔离 / 回退),**不覆盖**既有 3 条 | +| **R9** | `tests/test_main.py:58-74` `test_all_routers_mounted` | 硬编码路由清单 | **不新增路由**(convert 复用 `/api/simulate/trade`) | **零改动**(需在评审中显式声明,防被误判为漏项) | +| **R10** | `tests/test_core_ro_sum.py` 5 条断言 | `sum_trades_on_date` SQL 求和 | R-d 加了 `convert_group_id` 条件 | 既有数据无 gid → **恒等通过**;追加 1 条去重用例 | +| **R11** | `tests/conftest.py:183/184/232/233` | `get_engine(db)` 默认角色 | T-0b 后 core 默认角色 = `xh_core_rw`(**无 DELETE**)→ teardown 失败 | 显式 `role="admin"`(**R-e**) | +| **R12** | `app/service/risk/rules.py:78-83` `_eligible` | `trade_type in ('subscribe','redeem')` | convert 两条流水若是 `convert` 类型 → 被过滤 | **不改 `_eligible`**;改为**约束写入端**(R-b:流水写 `redeem`/`subscribe`) | +| **R13** | `app/repository/core_ro.py:390/432` | `trade_type IN ('subscribe','redeem')` 硬写 | —(不需改:convert 两条流水本就是这两类) | **不改**(`:432` 的求和在 R-d 中另加 gid 条件) | +| **R14** | `app/main.py:88/94` 中间件顺序 | audit 先注册 / trace 后注册(T-202 守卫) | 本次不动 `main.py` | **零改动**(若因 T-0b 误改 `main.py`,`test_main.py` 的守卫用例会红 → 属自发现) | +| **R15** | `tests/test_integration_risk.py:426-437` `test_convert_400_and_no_new_trade_audit` | convert → **400** + `BAD_REQUEST` + **不落审计**(`after["n"] == before["n"]`) | T-9 移除 convert 拒绝后,**状态码与审计计数双重失败** | **改写为端到端走通用例**(`convert → 200` + 断言落一条 `trade_accepted` 审计),或**整体迁入** `test_convert_integration.py` 后从本文件删除。
※ 本条为**第一轮审核补入**(初版 §12 漏列该真 MySQL 集成用例) | +| **R16** | `tests/test_trade_gateway.py:145-154` `test_redeem_accepted_without_alert` | redeem 1000 元放行、`core_trade` 计 1 条、无预警 | T-10 给 redeem 加 FIFO 扣减后,`env` **无持仓无批次**(F-13)→ 无 R-c(1) 降级则直接失败 | 由 **R-c(1)「redeem 既无批次也无持仓 → warning 跳过扣减」** 保住,**该用例零改动**;其**真实扣减路径**由 `test_share_lot.py` 自建种子覆盖(R-c(2))
※ 本条为**第一轮审核补入** | + +--- + +## 13. 验收清单(PRD §9 19 条 → 任务/用例映射) + +| # | 验收条目 | 承载任务 | 用例/证据 | +| --- | --- | --- | --- | +| 1 | pytest 全绿(516 + 新增) | T-13 | `pytest -q` ≥591(CI 内约 603) | +| 2 | 两条流水同 `convert_group_id`、**同事务** | T-6 | 回滚路径用例(断言无残留) | +| 3 | 折算金额与 §2.1 逐项吻合 | T-2/T-2b/T-6 | `test_convert_calc.py` + `calc_convert_demo.py` 输出比对 | +| 4 | 转入端适当性不匹配 → blocked,两条流水都不落库 | T-7 | 八步顺序用例(blocked 不占位) | +| 5 | RISK-002 对转换**只计一次** | T-8 | `_amount_view` 用例 | +| 6 | RISK-001/003 仍看到两条(**未删行**) | T-8 | `eligible` 全量用例 | +| 7 | 一次转换只产生**一条**预警事件 | T-8 | `process_convert_event` 用例(`payload.events` 长度 2,单数 1) | +| 8 | 跨批次:各批按各自持有期计费,明细落库 | T-2/T-6 | 跨批用例 + `core_convert_lot_detail` 行数断言 | +| 9 | 普通赎回后再 convert,批次与持仓一致、**不超扣** | **T-10** | `test_share_lot.py` + 端到端 | +| 10 | 转换后 `core_holding` 两端已更新 | T-6 | 两端持仓断言 | +| 11 | `core_tools` 汇总不翻倍 | T-11 | 工具汇总用例 | +| 12 | 同 `client_request_id` 重复提交**不产生第二组流水** | T-7 | 幂等用例 | +| 13 | 强制全转 `actual_qty != requested_qty`,响应与审计均记录 | T-2/T-7 | 双动作用例 | +| 14 | `CROSS_ENTITY_NOT_SUPPORTED` 有用例覆盖 | T-9 | 跨机构用例(依赖 `09-seed-org.sql`) | +| 15 | 幂等窗口闭合:阶段二失败后同键重试**不翻倍** | T-7 | 阶段二失败补偿用例 | +| 16 | 全额转出豁免(≥ `min_redeem_qty` 校验豁免) | T-2/T-7 | 豁免用例 | +| 17 | 阶段二失败可补偿(仅凭 Core 侧数据) | T-12 | `rebuild_alerts --convert-group` | +| 18 | 性能:端到端 < 2s;阶段一实测**补录** | T-13 | 实测数据回填 PRD | +| 19 | 补差费非零场景有覆盖 + 同费率对照 | T-2/T-1 | 双口径用例(依赖 `09-seed-org.sql` 的费率对) | + +--- + +## 14. 风险登记与门禁总则 + +沿架构 §12 的 10 条风险,**每条的「验收点」已前移到具体任务的 DoD**: + +| # | 风险 | 门禁落点(本计划) | +| --- | --- | --- | +| 1 | sqlite/MySQL 列名失配(**阻断**) | **T-0**:`_ddl.py` 自校验 + `test_db.py::test_core_holding_columns`;未绿不得启动 T-1 | +| 2 | 批次补建规则写两处 → 漂移 | **T-10**:`lot_bootstrap.bootstrap_lots` 单点 + 两侧一致性断言 | +| 3 | `Decimal` 默认 HALF_EVEN 与四舍五入不符 | **T-2**:所有量化处显式 `ROUND_HALF_UP` + `.5` 边界断言 | +| 4 | 阶段 1.5 引擎异常不丢预警 | **T-8**:`on_error_hook` 存在且一期 `None` + **hook 抛异常不反噬**单测 | +| 5 | `TOO_MANY_LOTS` 超限场景 | **T-2/T-9**:`PlanResult.batch_count` 先规划后判限 + 契约含 `max_lots` + 文案 | +| 6 | 批次表改造打穿 510 | **T-10**:开工前基线快照 + 单独结项 + **五道风险控制**(第五道 = 覆盖率补偿,见 §8) | +| 7 | 双库不一致 | **T-6/T-7/T-12**:回滚断言 + `convert_detail_write_failed` + 补偿脚本可跑通 | +| 8 | 锁 TTL 30s 被阶段一超时突破 | **T-6/T-7**:抢到锁后**二次校验**占位与 Core 流水 | +| 9 | 合并前引入 DDL | **T-1**:DDL 全追加式(新表 + 可空列 + DEFAULT);老 INSERT 仍可跑 | +| 10 | Core「只读」仅代码约定(**阻断**) | **T-0b**:3 条真 MySQL 权限断言;账号未配置回退(不阻塞开发) | + +**本计划新增的实现级风险(架构 §12 未列,两轮审核产出)** + +| # | 风险 | 应对 | 验收点 | +| --- | --- | --- | --- | +| **R-a1** | **并发首次建行撞 `UNIQUE(customer_id, product_id)`**:执行权锁是**请求粒度**(`convert:idem:{cid_req}`),同一 `(cid, pid)`、不同 `cid_req` 的两笔并发转换会双双进入阶段一,首次建行时并发 `INSERT` 撞唯一键 → `IntegrityError` → 事务回滚返回 5xx(**非数据损坏**) | R-a 第 ③ 步「`IntegrityError` 回退 UPDATE」(方言无关,已实测同事务内可继续执行) | **T-6 DoD**:「并发首次建行」用例 → 断言最终**只有一行**、金额为两笔之和、**两笔都成功**(无 5xx) | +| **R-a2** | **`core_holding` 写入若误用 `ON DUPLICATE KEY UPDATE` / `WHERE NOT EXISTS` / `FROM DUAL`**,会在 sqlite 或 MySQL 单侧语法错(方言互斥) | §1.5 R-a 已列三种候选写法的实测对照表 | T-6「必须守住」已写死必须走 R-a 三步;sqlite 单测天然覆盖 | + +> **门禁总则**:#1 与 #10 是**硬门禁**(T-0 / T-0b 双双绿才放行 T-1);#2~#5 是**开发中持续守**; +> 任意一条失守都在**集成测试期才暴露**(成本最高),故已前移到各任务 DoD。 + +--- + +## 15. 待确认 + +### 15.1 需要用户拍板 + +| # | 事项 | 本计划默认 | 若改判的影响 | +| --- | --- | --- | --- | +| **Q-a** | **R-c**:批次维护的「降级规则 + 覆盖率补偿」双条方案(第一轮审核后修订) | **接受双条**(推荐) | 若只接受降级(不接受 (2) 补偿),则**批次维护在单测层零覆盖**,验收第 9 条失效;若两条都不接受,则须给 `test_trade_gateway.py` 等多个 fixture 补灌持仓与净值种子 → T-10 改动面从 1 个文件扩到 3+ 个测试文件 | +| **Q-b** | **R-f**:`core_product_nav` 并入 T-0 | **并入**(推荐) | 若移回 T-1,T-0 的 DoD 缩小;T-1 工作量增加 | +| **Q-c** | **R-d**:`sum_trades_on_date` 是否顺带修 | **修**(推荐) | 若不修,需在 docstring 标注「不适用 convert」,接受同类方法在二期再修的债务 | + +### 15.2 不需要拍板(架构/PRD 已定,仅登记) + +- 补差费默认口径 = B(P4)· 最低持有双动作(P5)· 份额 2 位 HALF_UP(P7)· T+1 建模(P8) +- 单笔 = 单事务 = 单 `convert_group_id`,超限不自动分拆(原则 12 / R4) +- 补偿一期人工 + SLA 24h,`pending` 超时置 `expired`(S2) + +--- + +## 16. 给审核 AI 的检查清单 + +> 审核要求:**全新上下文、只报告不改码**;按「**阻断级 / 重要 / 可选**」分档。 +> 背景包见随本计划一并提供的代码事实附录(§1.4 的全部行号可直接核对)。 + +**A. 事实核验(最重要 —— 请优先挑战这部分)** + +1. §1.4 的 15 条事实是否与代码一致?**尤其** F-7(UPSERT 方言)、F-8(流水 `trade_type` 必须是 redeem/subscribe)、F-9(`sum_trades_on_date` 双计)、F-11(`get_engine` 调用点,批 0 前 13 处)、F-13(`env` fixture 无持仓/净值)。 +2. §12 的 **16** 条回归面是否**完整**?有没有被我漏掉的既有断言会被打穿?(请用 `grep` 自查 `UnsupportedTradeType` / `convert` / `core_holding` / `amount` 等关键词) +3. §13 的 19 条验收是否**条条有承载任务**?有没有验收条目实际无实现落点? + +**B. 裁定合理性** + +4. §1.5 的 7 条实现级裁定(R-a~R-g)是否成立?**哪一条你认为是错的或代价过高的?** +5. §1.3 的 2 条文档差异裁定(W-1/W-2)以架构为准,是否同意? + +**C. 依赖与排序** + +6. §2 的批间依赖是否成立?T-2 声称「不依赖表结构,可与 T-1 并行」是否属实? +7. T-10 排期置于 T-7 之后、T-13 之前,是否最优?(它是否其实应该更早做,以免 T-7 的批次逻辑建立在未维护的批次表上?) + +**D. 遗漏面** + +8. 除本计划列出的文件外,还有哪些**读取方**会因 convert 或其他改动而语义变化?(自检第 2 问「谁读」) +9. 有没有任务在「改完 A 之后,B 必须同步改」的隐形耦合,而本计划把它排成串行且顺序错了? +10. 一期不做的项(自动分拆 / 撤销 / 巨额赎回比例 / 二期补偿自动化)是否被**意外卷入**本期范围? + +**E. 可执行性** + +11. 每个任务的 DoD 是否**可机械验证**(有明确命令或断言)?有没有「模糊 DoD」(如"确认正确")? + +--- + +## 附:本计划引用的关键文件与行号索引 + +| 文件 | 相关行 | 用途 | +| --- | --- | --- | +| `tests/_ddl.py` | `:15-138`(表字典)、`:50-54`(core_holding)、`:55-60`(core_trade)、`:141-149`(建库函数) | T-0 主战场 | +| `tests/test_db.py` | `:30-54`(既有 3 用例) | T-0/T-0b 追加用例 | +| `tests/test_chat_tools.py` | `:81` | R1 待改 | +| `tests/test_concentration_c4.py` | `:74` | R2 待改 | +| `tests/test_trade_gateway.py` | `:40-72`(env)、`:92-99`、`:145-154`、`:215-219`、`:261-263` | R3~R7 / R16 待改 | +| `tests/test_integration_risk.py` | `:426-437`(convert 400 集成用例) | R15 待改 | +| `tests/test_main.py` | `:58-74`(路由清单) | R9 零改动 | +| `tests/test_core_ro_sum.py` | `:53/61/66/73/74` | R10 恒等通过 | +| `tests/conftest.py` | `:181`(bootstrap 链)、`:183/184/232/233`(engine)、`:216-223`(清理) | R11 / R-e | +| `app/gateway/trade_gateway.py` | `:36-37`(白名单/文案)、`:105`(校验读)、`:112-113`(convert 拒绝)、`:167-169`(insert_trade 调用) | T-9 / T-10 | +| `app/gateway/gateway_repository.py` | `:24`(engine)、`:26-57`(insert_trade 7 列) | T-0b / T-9 | +| `app/repository/core_ro.py` | `:55`(engine)、`:276-298`(list_holdings `SELECT h.*`)、`:300-340`(concentration)、`:378-404`(list_trades_range)、`:420-442`(sum_trades_on_date)、`:450-460`(get_latest_nav) | T-3 / T-11 | +| `app/service/risk/rules.py` | `:78-83`(`_eligible`)、`:86-88`(`_amount`)、`:102-105`(RISK-002)、`:201-219`(run_rules) | T-8 | +| `app/service/risk/engine.py` | `:114-192`(process_trade_event) | T-8 | +| `app/service/risk/alert_service.py` | `:96-180`(record_trade_alerts)、`:180`(`agg:event:` 锁) | T-8 | +| `app/service/risk/locks.py` | `:24`(TTL)、`:69+`(run_locked) | T-5 | +| `app/api/simulate.py` | `:36-42`(TradeRequest)、`:45-60`(路由) | T-9 | +| `app/utils/db.py` | `:19-49`(引擎缓存) | T-0b | +| `app/config/settings.py` | `:13-18`(mysql_*) | T-0b | +| `scripts/core/01-ddl.sql` | core_holding / core_trade / core_product / core_product_nav 段 | T-1 权威 | +| `scripts/core/reset.ps1` | `:24-31`(`$files`) | T-1 | +| `docs/项目框架设计/表设计/02-mysql-agent专用.sql` | 6 张表 | T-1 落点 | diff --git a/docs/项目框架设计/开发计划-架构改进.md b/docs/项目框架设计/开发计划-架构改进.md index af58c72..9b32c35 100644 --- a/docs/项目框架设计/开发计划-架构改进.md +++ b/docs/项目框架设计/开发计划-架构改进.md @@ -348,10 +348,18 @@ def test_audit_middleware_runs_inside_trace_middleware(client, captured_audit): ### 5.3 C1 · `core_ro` DB 级只读账号 +> **状态(2026-09-10)**:✅ **已由基金转换线接手定案,并扩展为 D20**(账号矩阵 `xh_core_ro`/`xh_core_rw`/`xh_agent_rw` + +> `get_engine(db, role)` + 3 个权限断言 + **T-0b 阻断前置**)。落地清单见 +> `docs/项目框架设计/架构设计-基金转换交易.md` **§11.1**。**本节原始评估保留备查,勿重复设计。** + 需运维配合。注意:`core_ro.py:55` 与 `gateway_repository.py:24` **共用 `get_engine(settings.mysql_core_database)`**,要拆成两个连接串(只读账号 + 可写账号)。 ### 5.4 F1 · 支持 `convert` +> **状态(2026-09-10)**:✅ **已立项为独立线**(PRD v0.9 + 架构 v1.0 定稿,**代码未动**),开工入口 `docs/交接文档-基金转换.md`。 +> 本节指出的**两处硬编码正是该线 D7 `_amount_view` 要解决的问题**(`core_ro.py:390`/`:432` 的 `trade_type` 白名单, +> 不改则 convert 在 RISK-001/002/003 聚合中隐形且不报错)。本线不再跟进,本节保留备查。 + **除网关外还有两处硬编码**(最易漏):`core_ro.py:390`(`list_trades_range`)与 `:432`(`sum_trades_on_date`)写死 `AND trade_type IN ('subscribe','redeem')`。不改的话 convert 在 RISK-001/002/003 聚合中**完全隐形且不报错**。 **前置**:产品需明确三条口径(校验对象、是否计入当日累计、RISK-003 算几笔),且属接口协议变更。 diff --git a/docs/项目框架设计/架构设计-基金转换交易.md b/docs/项目框架设计/架构设计-基金转换交易.md index 5ad43a3..5a327b1 100644 --- a/docs/项目框架设计/架构设计-基金转换交易.md +++ b/docs/项目框架设计/架构设计-基金转换交易.md @@ -402,8 +402,17 @@ def diff_fee(conv_amount: Decimal, out_rate: Decimal, in_rate: Decimal, mode="rate_diff" (口径 A):conv*max(in−out,0)/(1+max(in−out,0)) 结果 2 位 ROUND_HALF_UP。""" def in_qty(in_amount: Decimal, in_nav: Decimal) -> Decimal: # 2 位 ROUND_HALF_UP(v1.0 修正) +def hold_days(trade_date: date, confirmed_at: datetime) -> int: # (交易日 − 确认日).days,不含申请日 +def rounding_diff(in_amount: Decimal, in_nav: Decimal, actual_in_qty: Decimal, + places: int = 4) -> Decimal: # 理论份额 − 实得份额(响应字段) +def ensure_batch_limit(plan: PlanResult, max_lots: int) -> None: # 批次数超限 → TooManyLots(§8.3) ``` +> **T-2 落地补充(2026-09-10)**:末三行签名由 T-2 执行期补入,`hold_days` 是 +> `pick_fee_rate` 的入参来源(T+1 起算、满 7 日归 7–30 档都靠它),`rounding_diff` +> 对应 PRD §5.3 响应字段,`ensure_batch_limit` 把「先规划再判上限」固定成一步。 +> 三者均在 `calc.py`,纯函数、零 IO,已在 `tests/test_convert_calc.py` 覆盖。 + > ⚠️ **v1.0 勘误**:v0.2 此处写的是 `4 位 ROUND_FLOOR`,与 §1 原则 10「份额 2 位 ROUND_HALF_UP」**直接矛盾**, > 属 v0.7 残留。PRD v0.8 已定 **2 位四舍五入**,v1.0 一并修正。示例数字由 `scripts/dev/calc_convert_demo.py` 实算回填: > `in_qty = 53456.95`(口径 B)/ `53455.36`(口径 A)。 @@ -508,6 +517,14 @@ class TradeRequest(BaseModel): 异常类定义在 `app/utils/exceptions.py`(复用现有 `ApiError`),convert 专属异常放 `service/convert/errors.py`。 +> **T-2 落地补充(2026-09-10)**:`errors.py` 实际实现 **11 个**异常 —— 上表 10 条 +> 全部落地(含 `LotConflict` / `IdempotencyUnavailable` 两个机制类),**另加 1 个上表之外的 +> 内部兜底**:`FeeRuleMissing`(500 / `FEE_RULE_MISSING`),仅在 `core_fee_rule` 缺少 +> `[0, 7)` 档(种子漏灌)时触发。**它不是业务错误体契约的一部分**,列在这里只为 +> 「触发条件可查」:`pick_fee_rate` 无命中时**绝不返回 0 费率**——静默按 0 计费会少收 +> 赎回费且不留痕,比直接失败危险得多。(开发计划 §4.2 原文写「8 个」,为该文档的计数笔误, +> 以本表为准;差异已在开发计划执行记录中留痕。) + --- ## 9. 数据模型落点清单(DDL 与种子) @@ -589,8 +606,8 @@ teardown 按前缀清理 `core_trade`/`core_share_lot`/`core_convert_lot_detail` | --- | --- | --- | --- | | `xh_core_ro` | `jinrong_core` | `SELECT`(全库) | `core_ro` · `core_tools` · `deps` 归属校验 · `risk` 扫描 · **`trade_gateway` 的校验读** | | `xh_core_rw` | `jinrong_core` | `SELECT/INSERT/UPDATE`,**限 4 表**:`core_trade` / `core_share_lot` / `core_holding` / `core_convert_lot_detail`;**无 DELETE、无 DDL、无 GRANT** | **仅 `app/gateway/` 的写路径**(`gateway_repository.insert_trade` · `convert_core_repository.apply_convert`) | -| `xh_agent_rw` | `jinrong_agent` | `SELECT/INSERT/UPDATE` 业务表;**`audit_log` 只授 `INSERT`** | Agent 侧全部(`risk_repository` / `session_repository` / `convert_repository`) | -| `root`(管理员) | 两库 | 全权 | **仅** `00-grant.sql` / `01-ddl.sql` / `reset.ps1`,**应用运行时不持有** | +| `xh_agent_rw` | `jinrong_agent` | `SELECT/INSERT/UPDATE` 业务表;**`audit_log` 只授 `SELECT, INSERT`**(无 `UPDATE`/`DELETE` —— 只能追加、不可改删) | Agent 侧全部(`risk_repository` / `session_repository` / `convert_repository`) | +| `root`(管理员) | 两库 | 全权 | **仅** `00-grant.sql` / `01-ddl.sql` / `reset.ps1`,以及 **`tests/conftest.py` 真 MySQL 集成测试的 setup/teardown**(teardown 需 DELETE,见下方修正);**应用运行时不持有** | > ⚠️ **关键边界**:**gateway 的读走 `ro`、写走 `rw`**,两个 engine 并存 —— > `trade_gateway.py:105` 现有的 `core_ro or CoreReadOnlyRepository()`(校验读)**保持只读账号不变**, @@ -613,8 +630,13 @@ def get_engine(database: str, role: str = "rw") -> Engine: 对应角色账号未配置 → 回退 settings.mysql_user(行为与现状一致)。""" ``` -> `dispose_engines()` 语义不变(清空全部缓存);`tests/conftest.py:183-233` 的两处调用 -> 保持单参数默认值即可,**现有 510 用例零改动**。 +> `dispose_engines()` 语义不变(清空全部缓存)。 +> +> ⚠️ **修正(T-0b 实施时发现)**:`tests/conftest.py` 的四处调用**不能**沿用默认角色 —— +> teardown 要 `DELETE FROM core_trade / risk_alert / audit_log`,而 `xh_core_rw` 与 +> `xh_agent_rw` **均无 DELETE 权限**,沿用默认角色会让真 MySQL 集成测试在 setup/teardown 失权。 +> 故四处**显式 `role="admin"`**(对应开发计划 R-e);`app/` 内业务调用一律不用 admin。 +> sqlite 单测路径无账号概念,默认配置下现有用例仍零改动。 **验收(并入 T-0b,真 MySQL 才跑)** @@ -627,6 +649,20 @@ def get_engine(database: str, role: str = "rw") -> Engine: > **sqlite 测试路径零影响**:`tests/conftest.py` 走 sqlite + `tests/_ddl.py`,**无账号概念**; > 账号分离只在真 MySQL(集成测试 / 生产)生效,不改变任何现有用例的连接方式。 +**实施记录(T-0b · 2026-09-10 完成)** + +- `scripts/core/00-grant.sql` 已落地:3 账号 + 逐表授权(agent 库 17 张业务表 + `risk_convert_detail`), + **不进 `reset.ps1`**(`DROP DATABASE` 不清 `mysql.db` 授权行,授权一次即可)。 +- **两处口径修正(实施时发现,已同步 `00-grant.sql` 注释)**: + 1. `audit_log` 实际授权为 **`SELECT, INSERT`** —— 原文「只授 INSERT」若**字面执行**会一并剥夺 + `SELECT`,使 `risk_repository.has_engine_error_audit`(`:231`)与 `list_audit_events`(`:420`) + 双双失败;红线本意是「只能追加、不可改删」,故正确授权含 SELECT。 + 2. `tests/conftest.py` 四处改 `role="admin"`(见上方修正框,对应开发计划 R-e)。 +- **不授 DELETE 的依据**:`app/` 与 `scripts/` 全仓**无任何 `DELETE` / `TRUNCATE` / `DROP`**(已 grep 核实), + 故 `xh_core_rw` / `xh_agent_rw` 去掉 DELETE 不影响任何现有代码路径。 +- 3 条权限断言用例已写入 `tests/test_db.py`,**账号未配置或真库不可达时自动 skip**; + 默认配置下全量测试保持全绿(本任务完成时 **516 passed / 3 skipped**,基线 510)。 + --- ## 12. 风险与应对 @@ -707,7 +743,7 @@ def get_engine(database: str, role: str = "rw") -> Engine: | 2 | **谁读**? | `core_ro`(批次/净值/费率/持仓)· `rules._amount_view`(消费 `convert_group_id`)· `core_tools.query_recent_trades`(汇总去重)· `rebuild_alerts.py`(补偿)· `cleanup_pending_convert.py`(超时置 `expired`) | | 3 | **枚举/常量 DDL**? | `risk_convert_detail.status` **ENUM 5 值**(`pending`/`completed`/`failed`/`cancelled`/**`expired`**,建表即全量、零 ALTER,S2)· `core_trade.trade_type` ENUM **已含 convert** · `audit_log.decision` **VARCHAR(64)**,`nav_stale`/`convert_detail_write_failed`/`engine_error` 加值不改 DDL | | 4 | **事务跨库**? | 跨 → 三阶段 + 阶段 1.5(不参与事务)+ 补偿(一期手动、SLA 24h) | -| 5 | **种子数据**? | 07/08/09 三新种子(**文件头带版本注释块**);`subscribe_fee_rate` 造出**合规上限内**的不同费率对(债基 0.0030 vs 主动偏股 0.0080),否则补差费恒 0;赎回费种子覆盖 5 档且 7–30 日档 ≥1% | +| 5 | **种子数据**? | 07/08/09 三新种子(**文件头带版本注释块**);`subscribe_fee_rate` **严格按 `product_type` 取档**(`stock` 0.0080 / `mixed` 0.0050 / `bond`·`index` 0.0030 / `money` 0)造出**合规上限内且同主体可互转**的不同费率对(`PROD-110022` 债基 0.0030 → `PROD-003095` 主动偏股 0.0080,同属华夏模拟基金/TA-CN-001),否则补差费恒 0;赎回费种子覆盖 5 档且 7–30 日档 ≥1% | | 6 | **并发安全**? | 批次条件 UPDATE + rowcount 校验 · 执行权 `try_lock` · `uk_idem` 兜底 · 补跑阶段二加锁 | | 7 | **汇总语义唯一**? | 响应不返回汇总 `hold_days` · `_amount_view` 组内取转出端 · `out_amount`/`convert_amount`/`in_amount` 三者语义分立(PRD §5.3) | | 8 | **向后兼容**? | `core_trade` 加可空列 + 索引 · `core_product` 8 列均有 DEFAULT · `get_latest_nav` 不动改新增 · `record_trade_alerts` 新增可选参数缺省退化 · **`get_engine(db, role="rw")` 新参数带默认值、角色账号未配置回退 `mysql_user`**(D20,现有调用点零改动) | @@ -720,10 +756,10 @@ def get_engine(database: str, role: str = "rw") -> Engine: | 任务 | 内容 | 依赖 | | --- | --- | --- | -| **T-0** | **列名统一 + 启动断言(评审 R1 · 阻断前置)**:以 MySQL 为准重写 `tests/_ddl.py` 的 `core_holding`(`qty`/`cost_amount`/`as_of`/`pnl_pct` + PK),`conftest.py` 加启动期列名断言,新增 **`tests/test_db.py::test_core_holding_columns`** | 无 | -| **T-0b** | **DB 账号分离(D20 · 主架构 C1 · 阻断前置)**:`scripts/core/00-grant.sql` 建 3 账号授权;`settings.py` 加 3 组账号;`db.py` 改 `get_engine(db, role)`;`core_ro` 走 `ro`、gateway 写路径走 `rw`(读仍 `ro`);新增 3 个权限断言用例(§11.1) | 无(可与 T-0 并行) | +| **T-0** ✅ | **列名统一 + 启动断言(评审 R1 · 阻断前置)**:以 MySQL 为准重写 `tests/_ddl.py` 的 `core_holding`(`qty`/`cost_amount`/`as_of`/`pnl_pct` + PK)+ 新增 `core_product_nav` + 建库自校验 `_assert_ddl_aligned()`,新增 **`tests/test_db.py::test_core_holding_columns`**。**2026-09-10 完成**(另发现:2 处测试 INSERT 还需补 3 个 NOT NULL 列) | 无 | +| **T-0b** ✅ | **DB 账号分离(D20 · 主架构 C1 · 阻断前置)**:`scripts/core/00-grant.sql` 建 3 账号授权;`settings.py` 加 3 组账号;`db.py` 改 `get_engine(db, role)`;`core_ro` 走 `ro`、gateway 写路径走 `rw`(读仍 `ro`);新增 3 个权限断言用例(§11.1)。**2026-09-10 完成**(`audit_log` 授权修正为 `SELECT, INSERT`;conftest 四处改 `role="admin"`) | 无(可与 T-0 并行) | | T-1 | DDL + 种子(**00-grant** / 01-ddl / 07 / 08 / 09 / 02-agent)+ reset.ps1 | **T-0 + T-0b(门禁绿)** | -| T-2 | `service/convert/` 纯函数:`types.py` + `calc.py` + `fee.py` + `nav.py` + `lot_bootstrap.py`(D18) | 无 | +| T-2 | `service/convert/` 纯函数:`types.py` + `calc.py` + `fee.py` + `nav.py` + `lot_bootstrap.py` + `errors.py`(D18) | 无 | ✅ **2026-09-10 完成**(7 文件 / 93 用例 / 全量 609 绿;T-2b 实算脚本 15/15 一致) | | T-2b | `scripts/dev/calc_convert_demo.py` 实算主示例 + 同费率对照,**回填 PRD §5.3 与验收断言** | T-2 | | T-3 | `core_ro` 五个新方法 + `share_lot_repository` | T-1 | | T-4 | `convert_repository`(agent 侧占位/回写/查询/清理) | T-1 | diff --git a/docs/项目框架设计/演示SOP-风控模块.md b/docs/项目框架设计/演示SOP-风控模块.md index d70f053..faab80e 100644 --- a/docs/项目框架设计/演示SOP-风控模块.md +++ b/docs/项目框架设计/演示SOP-风控模块.md @@ -25,7 +25,9 @@ MYSQL="C:/Program Files/MySQL/MySQL Server 8.0/bin/mysql.exe" # mysql.exe 不 "$MYSQL" -h 127.0.0.1 -u root -e "DROP DATABASE IF EXISTS jinrong_core; DROP DATABASE IF EXISTS jinrong_agent;" for f in scripts/core/00-create-database.sql scripts/core/01-ddl.sql scripts/core/02-seed-base.sql \ scripts/core/03-seed-customers.sql scripts/core/04-seed-holdings.sql scripts/core/05-seed-trades.sql \ - scripts/core/06-seed-nav.sql "docs/项目框架设计/表设计/01-mysql-共用底座.sql" \ + scripts/core/06-seed-nav.sql scripts/core/07-seed-fee-rule.sql \ + scripts/core/08-seed-share-lot.sql scripts/core/09-seed-org.sql \ + "docs/项目框架设计/表设计/01-mysql-共用底座.sql" \ "docs/项目框架设计/表设计/02-mysql-agent专用.sql" scripts/agent/seed-aml-list.sql \ scripts/demo/prepare_risk_demo.sql; do "$MYSQL" -h 127.0.0.1 -u root --default-character-set=utf8mb4 < "$f" diff --git a/docs/项目框架设计/表设计/02-mysql-agent专用.sql b/docs/项目框架设计/表设计/02-mysql-agent专用.sql index 9972839..6f0912a 100644 --- a/docs/项目框架设计/表设计/02-mysql-agent专用.sql +++ b/docs/项目框架设计/表设计/02-mysql-agent专用.sql @@ -106,3 +106,38 @@ CREATE TABLE risk_aml_list ( KEY idx_name (full_name), KEY idx_active (is_active) ) ENGINE=InnoDB COMMENT='【风控专用】AML 名单本地镜像 · 依据 docs/PRD/PRD-风控监测Agent.md §6.2'; + +-- ============================================================================ +-- 基金转换详情(Agent 侧)· T-1 新增(2026-09-10) +-- 依据:docs/PRD/PRD-基金转换交易.md §4.1 DDL 原文 · 架构设计-基金转换交易.md §9 +-- 说明: +-- · `status` **建表即含全部 5 值**(pending / completed / failed / cancelled / expired), +-- 二期撤单与占位超时清理**零 ALTER**(评审 S2 / L-5)。 +-- · `uk_idem(client_request_id)` 支撑 FR-C17 幂等;值为 NULL 时不触发唯一约束 +-- (MySQL 允许多个 NULL),故无幂等键的请求不受影响。 +-- · Core 侧的批次计费明细在 `jinrong_core.core_convert_lot_detail`(含 nav/nav_date), +-- 本表只存汇总与流程状态;两库各自 engine,**无法单事务**,补偿路径见 PRD §7。 +-- ============================================================================ +CREATE TABLE risk_convert_detail ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + convert_group_id VARCHAR(64) NOT NULL, + status ENUM('pending','completed','failed','cancelled','expired') NOT NULL DEFAULT 'pending' + COMMENT 'pending=阶段一前占位;completed=两阶段均成;failed=阶段二失败待补偿;cancelled=二期撤单预留;expired=占位超时清理(S2,建表即含,免 ALTER)', + out_trade_id VARCHAR(64) NULL COMMENT '占位阶段为 NULL', + in_trade_id VARCHAR(64) NULL COMMENT '占位阶段为 NULL', + related_trade_id VARCHAR(64) NULL COMMENT '对端 trade_id', + nav DECIMAL(10,4) NULL COMMENT '成交净值', + nav_date DATE NULL, + fee_amount DECIMAL(18,2) NULL COMMENT '本笔费用', + hold_days_min INT NULL COMMENT '跨批次时最短持有天数', + hold_days_max INT NULL COMMENT '跨批次时最长持有天数', + estimated TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1=模拟折算 0=真实确认', + nav_stale TINYINT(1) NOT NULL DEFAULT 0, + client_request_id VARCHAR(64) NULL COMMENT '幂等键', + cancelled_at DATETIME(3) NULL COMMENT '二期撤单时间预留(一期不写入)', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + UNIQUE KEY uk_group (convert_group_id), + UNIQUE KEY uk_idem (client_request_id), + KEY idx_out (out_trade_id), + KEY idx_in (in_trade_id) +) ENGINE=InnoDB COMMENT='基金转换详情(Agent 侧)'; diff --git a/scripts/core/00-grant.sql b/scripts/core/00-grant.sql new file mode 100644 index 0000000..cce26b4 --- /dev/null +++ b/scripts/core/00-grant.sql @@ -0,0 +1,93 @@ +-- ============================================================================ +-- 00-grant.sql · DB 账号与最小权限(D20 · 架构设计-基金转换交易 §11.1) +-- ============================================================================ +-- 目的:把「Core 只读」「审计不可删改」从**代码约定**升级为 **DB 级强制**。 +-- 依据主架构评审 C1:原备选「代码层 SQL 白名单」已否决(注释/子查询/ +-- 存储过程易绕过),独立最小权限账号是唯一可靠手段。 +-- +-- 执行:管理员(root)执行,且**不进 reset.ps1** +-- —— DROP DATABASE 不清除 mysql.db 中的授权行,本脚本只需执行一次; +-- 且建账号需管理员权限,与 reset.ps1 的运行账号不是一回事。 +-- 命令:mysql -u root -p < scripts/core/00-grant.sql +-- +-- ⚠️ 执行前:把三处 REPLACE_ME 换成 .env 中同名配置的真实密码。 +-- 本文件**禁止提交真实密码**;改完执行,执行完不要提交本次改动。 +-- 若 PyMySQL 报认证插件错误(MySQL 8.0+ caching_sha2_password), +-- 把 IDENTIFIED BY 改为 IDENTIFIED WITH mysql_native_password BY。 +-- +-- 与 settings.py 的对应(账号留空 → db.get_engine 回退 mysql_user): +-- xh_core_ro → mysql_core_ro_user / mysql_core_ro_password +-- xh_core_rw → mysql_core_rw_user / mysql_core_rw_password +-- xh_agent_rw → mysql_agent_user / mysql_agent_password +-- ============================================================================ + +-- --------------------------------------------------------------------------- +-- 1) xh_core_ro · jinrong_core 全库只读 +-- 使用者:core_ro / core_tools / deps 归属校验 / risk 扫描, +-- 以及 **gateway 的校验读**(架构 §11.1 关键边界) +-- --------------------------------------------------------------------------- +CREATE USER IF NOT EXISTS 'xh_core_ro'@'%' IDENTIFIED BY 'REPLACE_ME_core_ro'; +GRANT SELECT ON jinrong_core.* TO 'xh_core_ro'@'%'; + +-- --------------------------------------------------------------------------- +-- 2) xh_core_rw · 仅 gateway 写路径:限 4 表,无 DELETE / 无 DDL / 无 GRANT +-- 使用者:gateway_repository.insert_trade · convert_core_repository.apply_convert +-- +-- ⚠️ 关键边界:**gateway 读走 xh_core_ro、写走本账号**,两个 engine 并存。 +-- 本账号**刻意不授** core_product / core_fee_rule / core_product_nav 的读权限, +-- 故 T-6 的 convert 计算链路读取产品/费率/净值**必须走只读账号**; +-- 不得为图省事给本账号补 SELECT ——否则 gateway 顺带获得全库读权限, +-- 「最小权限」落空(这是本任务最易走偏的一处)。 +-- +-- 表级 GRANT 可针对**尚不存在**的表:core_share_lot / core_convert_lot_detail +-- 由 T-1 创建,故本脚本可合法地先于 T-1 执行。 +-- --------------------------------------------------------------------------- +CREATE USER IF NOT EXISTS 'xh_core_rw'@'%' IDENTIFIED BY 'REPLACE_ME_core_rw'; +GRANT SELECT, INSERT, UPDATE ON jinrong_core.core_trade TO 'xh_core_rw'@'%'; +GRANT SELECT, INSERT, UPDATE ON jinrong_core.core_share_lot TO 'xh_core_rw'@'%'; +GRANT SELECT, INSERT, UPDATE ON jinrong_core.core_holding TO 'xh_core_rw'@'%'; +GRANT SELECT, INSERT, UPDATE ON jinrong_core.core_convert_lot_detail TO 'xh_core_rw'@'%'; + +-- --------------------------------------------------------------------------- +-- 3) xh_agent_rw · jinrong_agent 业务表读写(无 DELETE) +-- 使用者:risk_repository / session_repository / convert_repository 等 Agent 侧全部 +-- +-- 不授 DELETE 的依据(T-0b 实测):app/ 与 scripts/ 全仓 **无任何 +-- DELETE / TRUNCATE / DROP**,故不授 DELETE 不影响任何现有代码路径。 +-- tests/conftest.py 的 teardown 需要 DELETE → 显式改用 role="admin"(R-e)。 +-- --------------------------------------------------------------------------- +CREATE USER IF NOT EXISTS 'xh_agent_rw'@'%' IDENTIFIED BY 'REPLACE_ME_agent_rw'; + +GRANT SELECT, INSERT, UPDATE ON jinrong_agent.agent_session TO 'xh_agent_rw'@'%'; +GRANT SELECT, INSERT, UPDATE ON jinrong_agent.agent_message TO 'xh_agent_rw'@'%'; +GRANT SELECT, INSERT, UPDATE ON jinrong_agent.agent_tool_call TO 'xh_agent_rw'@'%'; +GRANT SELECT, INSERT, UPDATE ON jinrong_agent.input_guard_log TO 'xh_agent_rw'@'%'; +GRANT SELECT, INSERT, UPDATE ON jinrong_agent.customer_advisor_rel TO 'xh_agent_rw'@'%'; +GRANT SELECT, INSERT, UPDATE ON jinrong_agent.customer_profile_l1 TO 'xh_agent_rw'@'%'; +GRANT SELECT, INSERT, UPDATE ON jinrong_agent.customer_profile_l2 TO 'xh_agent_rw'@'%'; +GRANT SELECT, INSERT, UPDATE ON jinrong_agent.customer_profile_l3 TO 'xh_agent_rw'@'%'; +GRANT SELECT, INSERT, UPDATE ON jinrong_agent.risk_alert TO 'xh_agent_rw'@'%'; +GRANT SELECT, INSERT, UPDATE ON jinrong_agent.risk_suitability_log TO 'xh_agent_rw'@'%'; +GRANT SELECT, INSERT, UPDATE ON jinrong_agent.customer_threshold_config TO 'xh_agent_rw'@'%'; +GRANT SELECT, INSERT, UPDATE ON jinrong_agent.customer_notify_log TO 'xh_agent_rw'@'%'; +GRANT SELECT, INSERT, UPDATE ON jinrong_agent.advisor_draft TO 'xh_agent_rw'@'%'; +GRANT SELECT, INSERT, UPDATE ON jinrong_agent.compliance_hit_log TO 'xh_agent_rw'@'%'; +GRANT SELECT, INSERT, UPDATE ON jinrong_agent.analytics_query_log TO 'xh_agent_rw'@'%'; +GRANT SELECT, INSERT, UPDATE ON jinrong_agent.risk_aml_list TO 'xh_agent_rw'@'%'; +-- risk_convert_detail 由 T-1 建表(本行可先于建表执行) +GRANT SELECT, INSERT, UPDATE ON jinrong_agent.risk_convert_detail TO 'xh_agent_rw'@'%'; + +-- 审计表:**追加写、不可改不可删** +-- 口径澄清(T-0b 修正架构表述):架构 §11.1 原文写「audit_log 只授 INSERT」, +-- 字面执行会连 SELECT 一并剥夺,导致 risk_repository.has_engine_error_audit +-- (:231 SELECT 1 FROM audit_log)与 list_audit_events(:420 SELECT *)双双失败。 +-- 红线本意是「**只能追加,不能修改或删除**」→ 正确授权 = SELECT + INSERT。 +GRANT SELECT, INSERT ON jinrong_agent.audit_log TO 'xh_agent_rw'@'%'; + +-- --------------------------------------------------------------------------- +-- 4) 执行后自检(管理员执行,非必需) +-- --------------------------------------------------------------------------- +-- SHOW GRANTS FOR 'xh_core_ro'@'%'; -- 期望:仅 SELECT ON jinrong_core.* +-- SHOW GRANTS FOR 'xh_core_rw'@'%'; -- 期望:4 表 SELECT/INSERT/UPDATE,无 DELETE、无 ALL +-- SHOW GRANTS FOR 'xh_agent_rw'@'%'; -- 期望:业务表三权限;audit_log 仅 SELECT, INSERT +-- 注:CREATE USER / GRANT 会自动刷新权限缓存,无需 FLUSH PRIVILEGES。 diff --git a/scripts/core/01-ddl.sql b/scripts/core/01-ddl.sql index 3ea3f8b..dfed8f7 100644 --- a/scripts/core/01-ddl.sql +++ b/scripts/core/01-ddl.sql @@ -120,7 +120,16 @@ CREATE TABLE core_product ( term_days INT UNSIGNED NULL COMMENT '产品期限(天),NULL=灵活开放', requires_disclosure TINYINT(1) NOT NULL DEFAULT 0 COMMENT '购买前需签署风险揭示书', industry_code VARCHAR(16) NULL, - fee_rate DECIMAL(6,4) NULL, + fee_rate DECIMAL(6,4) NULL COMMENT '历史费率展示位(换算逻辑不读;申购费率见 subscribe_fee_rate)', + -- ===== 基金转换(convert)扩展列 · T-1 ===== + can_subscribe TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否可申购(转换转入前置校验)', + can_redeem TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否可赎回(转换转出前置校验)', + min_hold_qty DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '最低持有份额阈值', + min_redeem_qty DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '最低赎回份额', + min_hold_action VARCHAR(32) NOT NULL DEFAULT 'force_transfer' COMMENT '低于 min_hold_qty 的动作:force_transfer=强制全转 / force_redeem=强制赎回剩余;用 VARCHAR 非 ENUM,加动作免 ALTER', + subscribe_fee_rate DECIMAL(6,4) NOT NULL DEFAULT 0 COMMENT '申购费率(补差费计算源,价外法)。22 号文 §8 上限:指数/债券 ≤0.0030、其他混合 ≤0.0050、主动偏股 ≤0.0080、货基 0', + fund_company VARCHAR(64) NULL COMMENT '基金管理人(同一笔转换需同管理人)', + ta_code VARCHAR(32) NULL COMMENT '注册登记机构代码(同一笔转换需同 TA)', is_open TINYINT(1) NOT NULL DEFAULT 1, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), KEY idx_min_risk (min_risk_code), @@ -152,6 +161,7 @@ CREATE TABLE core_trade ( trade_type ENUM('subscribe','redeem','convert') NOT NULL, amount DECIMAL(18,2) NOT NULL, qty DECIMAL(18,4) NULL, + convert_group_id VARCHAR(64) NULL COMMENT '基金转换组 ID:同组两条流水(转出 redeem / 转入 subscribe)共享;非 convert 场景为 NULL', channel ENUM('online','mobile','counter','other') NOT NULL DEFAULT 'online', counterparty_account_mask VARCHAR(32) NULL, counterparty_name VARCHAR(64) NULL, @@ -161,6 +171,7 @@ CREATE TABLE core_trade ( trade_status ENUM('confirmed','pending','cancelled') NOT NULL DEFAULT 'confirmed', traded_at DATETIME(3) NOT NULL, KEY idx_customer_time (customer_id, traded_at), + KEY idx_convert_group (convert_group_id), KEY idx_amount (amount, traded_at), KEY idx_channel_time (channel, traded_at), CONSTRAINT fk_trade_customer FOREIGN KEY (customer_id) REFERENCES core_customer(customer_id), @@ -192,3 +203,56 @@ CREATE TABLE core_product_nav ( UNIQUE KEY uk_product_date (product_id, nav_date), CONSTRAINT fk_nav_product FOREIGN KEY (product_id) REFERENCES core_product(product_id) ) ENGINE=InnoDB COMMENT='产品净值'; + +-- ============================================================ +-- 基金转换(convert)新增表 · T-1(2026-09-10) +-- 依据:docs/PRD/PRD-基金转换交易.md §4.1 · 架构设计-基金转换交易.md §9 +-- ============================================================ + +-- 费率规则:redeem 按持有期分档(本期启用);subscribe 金额分档为真实 Core 接入扩展位(本期不启用) +CREATE TABLE core_fee_rule ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + product_id VARCHAR(64) NOT NULL, + fee_type ENUM('subscribe','redeem') NOT NULL, + min_hold_days INT NULL COMMENT 'redeem:持有期下界(含)', + max_hold_days INT NULL COMMENT 'redeem:持有期上界(不含);NULL=无上限', + min_amount DECIMAL(18,2) NULL COMMENT 'subscribe:金额下界(含);本期不启用', + max_amount DECIMAL(18,2) NULL COMMENT 'subscribe:金额上界(不含);本期不启用', + rate DECIMAL(6,4) NOT NULL COMMENT '费率;redeem 档位按 22 号文 §10 下限(<7d 1.5% / 7-30d 1% / 30-180d 0.5% / ≥180d 合同约定)', + to_fund_ratio DECIMAL(5,4) NOT NULL DEFAULT 1.0000 COMMENT '赎回费计入基金财产比例。22 号文 §10:赎回费全额计入基金财产 → 恒 1.0(旧规 75%/50%/25% 分级已废止)。本期仅留痕、不参与计算', + KEY idx_product_type (product_id, fee_type), + CONSTRAINT fk_fee_rule_product FOREIGN KEY (product_id) REFERENCES core_product(product_id) +) ENGINE=InnoDB COMMENT='费率规则(赎回费分档 / 申购费扩展位)'; + +-- 份额批次:FIFO 计费的权威源(普通申赎无批次时由 lot_bootstrap 兜底补建,D8/D18) +CREATE TABLE core_share_lot ( + lot_id VARCHAR(64) NOT NULL PRIMARY KEY, + customer_id VARCHAR(64) NOT NULL, + product_id VARCHAR(64) NOT NULL, + qty DECIMAL(18,4) NOT NULL COMMENT '原始份额', + remain_qty DECIMAL(18,4) NOT NULL COMMENT '剩余份额(FIFO 扣减后;归零保留行不删,保可追溯)', + nav DECIMAL(10,4) NOT NULL COMMENT '成交净值', + confirmed_at DATETIME(3) NOT NULL COMMENT '份额确认时间(持有期自 T+1 起算,D14)', + source_trade_id VARCHAR(64) NULL COMMENT '来源流水(兜底补建时为 NULL)', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + KEY idx_cust_prod_time (customer_id, product_id, confirmed_at), + CONSTRAINT fk_lot_customer FOREIGN KEY (customer_id) REFERENCES core_customer(customer_id), + CONSTRAINT fk_lot_product FOREIGN KEY (product_id) REFERENCES core_product(product_id) +) ENGINE=InnoDB COMMENT='份额批次(FIFO 计费权威源)'; + +-- 转换批次计费明细:含 nav/nav_date 使 Core 侧自包含,供 rebuild_alerts 补偿(D6) +CREATE TABLE core_convert_lot_detail ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + convert_group_id VARCHAR(64) NOT NULL, + lot_id VARCHAR(64) NOT NULL COMMENT '被扣减的原批次(对应 core_share_lot.lot_id)', + qty DECIMAL(18,4) NOT NULL COMMENT '本批转出份额', + hold_days INT NOT NULL COMMENT '本批持有天数(自然日,不含申请日)', + amount DECIMAL(18,2) NOT NULL COMMENT '本批金额(已按 2 位四舍五入)', + fee_rate DECIMAL(6,4) NOT NULL COMMENT '本批适用费率', + fee_amount DECIMAL(18,2) NOT NULL COMMENT '本批费用(已按 2 位四舍五入)', + nav DECIMAL(10,4) NOT NULL COMMENT '本批成交净值', + nav_date DATE NOT NULL COMMENT '净值日期', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + KEY idx_group (convert_group_id), + KEY idx_lot (lot_id) +) ENGINE=InnoDB COMMENT='转换批次计费明细(Core 侧自包含,补偿数据源)'; diff --git a/scripts/core/07-seed-fee-rule.sql b/scripts/core/07-seed-fee-rule.sql new file mode 100644 index 0000000..4b95ec8 --- /dev/null +++ b/scripts/core/07-seed-fee-rule.sql @@ -0,0 +1,107 @@ +USE jinrong_core; + +-- ============================================================================ +-- 07-seed-fee-rule.sql · 赎回费分档种子 +-- 版本:v1.0(2026-09-10 · T-1 新增) +-- 依据:证监会公告〔2025〕22 号《公开募集证券投资基金销售费用管理规定》§10 +-- (2026-01-01 施行,替代 2013 年 26 号文的分级计入规则) +-- · 持有期 < 7 日 → ≥ 1.5% +-- · 持有期 7 ~ 30 日 → ≥ 1.0% +-- · 持有期 30 ~ 180 日 → ≥ 0.5% +-- · 持有期 ≥ 180 日 → 无强制下限(合同常见 0) +-- · 赎回费全额计入基金财产 → to_fund_ratio 恒 1.0000(旧规 75%/50%/25% 已废止) +-- 区间口径:左闭右开 [min_hold_days, max_hold_days),max_hold_days = NULL 表示无上限; +-- 与 app/service/convert/fee.py 的 pick_fee_rate 完全一致。 +-- 说明 1:14 只产品统一灌 5 档,目的是让分档演示/真库集成有数据可查。 +-- 分档边界单测(6/7/29/30/179/180/364/365)走纯函数自建种子,**不依赖本文件**。 +-- 说明 2:真实货基(PROD-000001)赎回费为 0、指数/债基持满 7 日对个人投资者有豁免, +-- 模拟库未做该豁免(豁免属投资者维度,非产品维度),保持 5 档统一。 +-- 说明 3:`fee_type='subscribe'` 的金额分档本期**不启用**(申购费简化在 +-- core_product.subscribe_fee_rate,见 09-seed-org.sql),此处不灌数据。 +-- ============================================================================ + +INSERT INTO core_fee_rule (product_id, fee_type, min_hold_days, max_hold_days, rate, to_fund_ratio) VALUES +-- PROD-110022 稳健债基 A(bond) +('PROD-110022', 'redeem', 0, 7, 0.0150, 1.0000), +('PROD-110022', 'redeem', 7, 30, 0.0100, 1.0000), +('PROD-110022', 'redeem', 30, 180, 0.0050, 1.0000), +('PROD-110022', 'redeem', 180, 365, 0.0025, 1.0000), +('PROD-110022', 'redeem', 365, NULL, 0.0000, 1.0000), +-- PROD-110023 信用债精选(bond) +('PROD-110023', 'redeem', 0, 7, 0.0150, 1.0000), +('PROD-110023', 'redeem', 7, 30, 0.0100, 1.0000), +('PROD-110023', 'redeem', 30, 180, 0.0050, 1.0000), +('PROD-110023', 'redeem', 180, 365, 0.0025, 1.0000), +('PROD-110023', 'redeem', 365, NULL, 0.0000, 1.0000), +-- PROD-005827 平衡混合一号(mixed · 转出主演示产品) +('PROD-005827', 'redeem', 0, 7, 0.0150, 1.0000), +('PROD-005827', 'redeem', 7, 30, 0.0100, 1.0000), +('PROD-005827', 'redeem', 30, 180, 0.0050, 1.0000), +('PROD-005827', 'redeem', 180, 365, 0.0025, 1.0000), +('PROD-005827', 'redeem', 365, NULL, 0.0000, 1.0000), +-- PROD-005828 稳健增利混合(mixed) +('PROD-005828', 'redeem', 0, 7, 0.0150, 1.0000), +('PROD-005828', 'redeem', 7, 30, 0.0100, 1.0000), +('PROD-005828', 'redeem', 30, 180, 0.0050, 1.0000), +('PROD-005828', 'redeem', 180, 365, 0.0025, 1.0000), +('PROD-005828', 'redeem', 365, NULL, 0.0000, 1.0000), +-- PROD-161725 科技成长主题(stock) +('PROD-161725', 'redeem', 0, 7, 0.0150, 1.0000), +('PROD-161725', 'redeem', 7, 30, 0.0100, 1.0000), +('PROD-161725', 'redeem', 30, 180, 0.0050, 1.0000), +('PROD-161725', 'redeem', 180, 365, 0.0025, 1.0000), +('PROD-161725', 'redeem', 365, NULL, 0.0000, 1.0000), +-- PROD-161726 消费升级主题(stock) +('PROD-161726', 'redeem', 0, 7, 0.0150, 1.0000), +('PROD-161726', 'redeem', 7, 30, 0.0100, 1.0000), +('PROD-161726', 'redeem', 30, 180, 0.0050, 1.0000), +('PROD-161726', 'redeem', 180, 365, 0.0025, 1.0000), +('PROD-161726', 'redeem', 365, NULL, 0.0000, 1.0000), +-- PROD-003095 医药健康精选(stock) +('PROD-003095', 'redeem', 0, 7, 0.0150, 1.0000), +('PROD-003095', 'redeem', 7, 30, 0.0100, 1.0000), +('PROD-003095', 'redeem', 30, 180, 0.0050, 1.0000), +('PROD-003095', 'redeem', 180, 365, 0.0025, 1.0000), +('PROD-003095', 'redeem', 365, NULL, 0.0000, 1.0000), +-- PROD-510300 沪深300指数(index · 转入主演示产品) +('PROD-510300', 'redeem', 0, 7, 0.0150, 1.0000), +('PROD-510300', 'redeem', 7, 30, 0.0100, 1.0000), +('PROD-510300', 'redeem', 30, 180, 0.0050, 1.0000), +('PROD-510300', 'redeem', 180, 365, 0.0025, 1.0000), +('PROD-510300', 'redeem', 365, NULL, 0.0000, 1.0000), +-- PROD-510500 中证500指数(index) +('PROD-510500', 'redeem', 0, 7, 0.0150, 1.0000), +('PROD-510500', 'redeem', 7, 30, 0.0100, 1.0000), +('PROD-510500', 'redeem', 30, 180, 0.0050, 1.0000), +('PROD-510500', 'redeem', 180, 365, 0.0025, 1.0000), +('PROD-510500', 'redeem', 365, NULL, 0.0000, 1.0000), +-- PROD-XYZ999 进取成长五号(stock) +('PROD-XYZ999', 'redeem', 0, 7, 0.0150, 1.0000), +('PROD-XYZ999', 'redeem', 7, 30, 0.0100, 1.0000), +('PROD-XYZ999', 'redeem', 30, 180, 0.0050, 1.0000), +('PROD-XYZ999', 'redeem', 180, 365, 0.0025, 1.0000), +('PROD-XYZ999', 'redeem', 365, NULL, 0.0000, 1.0000), +-- PROD-000001 现金宝货币(money) +('PROD-000001', 'redeem', 0, 7, 0.0150, 1.0000), +('PROD-000001', 'redeem', 7, 30, 0.0100, 1.0000), +('PROD-000001', 'redeem', 30, 180, 0.0050, 1.0000), +('PROD-000001', 'redeem', 180, 365, 0.0025, 1.0000), +('PROD-000001', 'redeem', 365, NULL, 0.0000, 1.0000), +-- PROD-000002 同业存单基金(bond) +('PROD-000002', 'redeem', 0, 7, 0.0150, 1.0000), +('PROD-000002', 'redeem', 7, 30, 0.0100, 1.0000), +('PROD-000002', 'redeem', 30, 180, 0.0050, 1.0000), +('PROD-000002', 'redeem', 180, 365, 0.0025, 1.0000), +('PROD-000002', 'redeem', 365, NULL, 0.0000, 1.0000), +-- PROD-WMG001 稳享180天理财(wealth_mgmt) +('PROD-WMG001', 'redeem', 0, 7, 0.0150, 1.0000), +('PROD-WMG001', 'redeem', 7, 30, 0.0100, 1.0000), +('PROD-WMG001', 'redeem', 30, 180, 0.0050, 1.0000), +('PROD-WMG001', 'redeem', 180, 365, 0.0025, 1.0000), +('PROD-WMG001', 'redeem', 365, NULL, 0.0000, 1.0000), +-- PROD-PRIV01 量化中性私募一号(private_fund) +('PROD-PRIV01', 'redeem', 0, 7, 0.0150, 1.0000), +('PROD-PRIV01', 'redeem', 7, 30, 0.0100, 1.0000), +('PROD-PRIV01', 'redeem', 30, 180, 0.0050, 1.0000), +('PROD-PRIV01', 'redeem', 180, 365, 0.0025, 1.0000), +('PROD-PRIV01', 'redeem', 365, NULL, 0.0000, 1.0000); diff --git a/scripts/core/08-seed-share-lot.sql b/scripts/core/08-seed-share-lot.sql new file mode 100644 index 0000000..361d1c9 --- /dev/null +++ b/scripts/core/08-seed-share-lot.sql @@ -0,0 +1,94 @@ +USE jinrong_core; + +-- ============================================================================ +-- 08-seed-share-lot.sql · 份额批次种子 +-- 版本:v1.0(2026-09-10 · T-1 新增) +-- 依据:PRD §4.1 `core_share_lot` + 架构 §5 D8/D18(批次为 FIFO 计费权威源) +-- 生成规则: +-- · 按 core_holding 逐行反推批次,**每个 (customer_id, product_id) 的 +-- Σ remain_qty 恒等于 core_holding.qty**(DoD ② 可逐行 SQL 断言)。 +-- · 主演示客户 CUST-9527 专门拆多批,用于跨批次 FIFO 与逐批计费演示: +-- PROD-005827 50000 = 30000 + 15000 + 5000(覆盖 ≥365 / 30-180 / <7 三档) +-- PROD-110022 80000 = 50000 + 30000 (覆盖 180-365 / 7-30 两档) +-- PROD-000001 20000 = 20000 (单批,≥365 档) +-- · 其余持仓每行 1 批,confirmed_at 按 D1→D5 循环,使整体覆盖全部 5 档。 +-- 日期模板(持有天数以 as_of = 2026-09-04 为参照计算,对应 07 档位表的区间): +-- D1 = 2025-05-01 → 491 天 → [365, ∞) → rate 0.0000 +-- D2 = 2026-02-15 → 201 天 → [180, 365) → rate 0.0025 +-- D3 = 2026-08-01 → 34 天 → [30, 180) → rate 0.0050 +-- D4 = 2026-08-25 → 10 天 → [7, 30) → rate 0.0100 +-- D5 = 2026-09-01 → 3 天 → [0, 7) → rate 0.0150 +-- 重要说明:静态日期会随运行日期推移而整体后移(今天跑 D5 已是 7-30 档)。 +-- 因此 **分档边界单测必须用自建种子(相对 now 生成日期)**,不得依赖本文件; +-- 本文件服务于模拟库人工演示与真库集成冒烟。 +-- 其他:nav 取 06-seed-nav.sql 的 2026-09-04 最新净值(模拟库近似,非逐批历史净值); +-- source_trade_id 统一 NULL —— 04/05 两个种子各自独立造数,批次不回溯流水, +-- 真实 TA 的批次由确认流水生成,模拟库不假装有这个链路。 +-- ============================================================================ + +INSERT INTO core_share_lot (lot_id, customer_id, product_id, qty, remain_qty, nav, confirmed_at, source_trade_id) VALUES +-- ---- CUST-9527 主 demo:跨批次专项 ---- +('LOT-9527-005827-01', 'CUST-9527', 'PROD-005827', 30000.0000, 30000.0000, 0.9500, '2025-05-01 10:00:00.000', NULL), +('LOT-9527-005827-02', 'CUST-9527', 'PROD-005827', 15000.0000, 15000.0000, 0.9500, '2026-08-01 10:00:00.000', NULL), +('LOT-9527-005827-03', 'CUST-9527', 'PROD-005827', 5000.0000, 5000.0000, 0.9500, '2026-09-01 10:00:00.000', NULL), +('LOT-9527-110022-01', 'CUST-9527', 'PROD-110022', 50000.0000, 50000.0000, 1.0300, '2026-02-15 10:00:00.000', NULL), +('LOT-9527-110022-02', 'CUST-9527', 'PROD-110022', 30000.0000, 30000.0000, 1.0300, '2026-08-25 10:00:00.000', NULL), +('LOT-9527-000001-01', 'CUST-9527', 'PROD-000001', 20000.0000, 20000.0000, 1.0020, '2025-05-01 10:00:00.000', NULL), +-- ---- CUST-1001 ~ 1024(单批,日期循环覆盖 5 档)---- +('LOT-1001-110022-01', 'CUST-1001', 'PROD-110022', 30000.0000, 30000.0000, 1.0300, '2025-05-01 10:00:00.000', NULL), +('LOT-1001-000001-01', 'CUST-1001', 'PROD-000001', 50000.0000, 50000.0000, 1.0020, '2026-02-15 10:00:00.000', NULL), +('LOT-1002-005828-01', 'CUST-1002', 'PROD-005828', 40000.0000, 40000.0000, 1.0200, '2026-08-01 10:00:00.000', NULL), +('LOT-1002-110023-01', 'CUST-1002', 'PROD-110023', 25000.0000, 25000.0000, 1.0200, '2026-08-25 10:00:00.000', NULL), +('LOT-1003-110022-01', 'CUST-1003', 'PROD-110022', 120000.0000, 120000.0000, 1.0300, '2026-09-01 10:00:00.000', NULL), +('LOT-1003-005828-01', 'CUST-1003', 'PROD-005828', 30000.0000, 30000.0000, 1.0200, '2025-05-01 10:00:00.000', NULL), +('LOT-1004-161725-01', 'CUST-1004', 'PROD-161725', 60000.0000, 60000.0000, 1.0500, '2026-02-15 10:00:00.000', NULL), +('LOT-1004-510300-01', 'CUST-1004', 'PROD-510300', 40000.0000, 40000.0000, 1.0300, '2026-08-01 10:00:00.000', NULL), +('LOT-1005-005827-01', 'CUST-1005', 'PROD-005827', 35000.0000, 35000.0000, 0.9500, '2026-08-25 10:00:00.000', NULL), +('LOT-1005-510300-01', 'CUST-1005', 'PROD-510300', 15000.0000, 15000.0000, 1.0300, '2026-09-01 10:00:00.000', NULL), +('LOT-1006-110023-01', 'CUST-1006', 'PROD-110023', 90000.0000, 90000.0000, 1.0200, '2025-05-01 10:00:00.000', NULL), +('LOT-1006-000002-01', 'CUST-1006', 'PROD-000002', 40000.0000, 40000.0000, 1.0050, '2026-02-15 10:00:00.000', NULL), +('LOT-1007-005828-01', 'CUST-1007', 'PROD-005828', 45000.0000, 45000.0000, 1.0200, '2026-08-01 10:00:00.000', NULL), +('LOT-1008-161726-01', 'CUST-1008', 'PROD-161726', 20000.0000, 20000.0000, 1.0500, '2026-08-25 10:00:00.000', NULL), +('LOT-1008-510500-01', 'CUST-1008', 'PROD-510500', 10000.0000, 10000.0000, 0.9800, '2026-09-01 10:00:00.000', NULL), +('LOT-1009-000001-01', 'CUST-1009', 'PROD-000001', 150000.0000, 150000.0000, 1.0020, '2025-05-01 10:00:00.000', NULL), +('LOT-1009-110022-01', 'CUST-1009', 'PROD-110022', 80000.0000, 80000.0000, 1.0300, '2026-02-15 10:00:00.000', NULL), +('LOT-1010-003095-01', 'CUST-1010', 'PROD-003095', 55000.0000, 55000.0000, 0.9500, '2026-08-01 10:00:00.000', NULL), +('LOT-1010-005827-01', 'CUST-1010', 'PROD-005827', 30000.0000, 30000.0000, 0.9500, '2026-08-25 10:00:00.000', NULL), +('LOT-1011-161725-01', 'CUST-1011', 'PROD-161725', 70000.0000, 70000.0000, 1.0500, '2026-09-01 10:00:00.000', NULL), +('LOT-1012-510300-01', 'CUST-1012', 'PROD-510300', 65000.0000, 65000.0000, 1.0300, '2025-05-01 10:00:00.000', NULL), +('LOT-1012-110023-01', 'CUST-1012', 'PROD-110023', 35000.0000, 35000.0000, 1.0200, '2026-02-15 10:00:00.000', NULL), +('LOT-1013-005828-01', 'CUST-1013', 'PROD-005828', 28000.0000, 28000.0000, 1.0200, '2026-08-01 10:00:00.000', NULL), +('LOT-1014-510500-01', 'CUST-1014', 'PROD-510500', 90000.0000, 90000.0000, 0.9800, '2026-08-25 10:00:00.000', NULL), +('LOT-1014-161726-01', 'CUST-1014', 'PROD-161726', 40000.0000, 40000.0000, 1.0500, '2026-09-01 10:00:00.000', NULL), +('LOT-1015-110022-01', 'CUST-1015', 'PROD-110022', 100000.0000, 100000.0000, 1.0300, '2025-05-01 10:00:00.000', NULL), +('LOT-1016-005827-01', 'CUST-1016', 'PROD-005827', 42000.0000, 42000.0000, 0.9500, '2026-02-15 10:00:00.000', NULL), +('LOT-1016-003095-01', 'CUST-1016', 'PROD-003095', 18000.0000, 18000.0000, 0.9500, '2026-08-01 10:00:00.000', NULL), +('LOT-1017-510300-01', 'CUST-1017', 'PROD-510300', 32000.0000, 32000.0000, 1.0300, '2026-08-25 10:00:00.000', NULL), +('LOT-1018-000001-01', 'CUST-1018', 'PROD-000001', 200000.0000, 200000.0000, 1.0020, '2026-09-01 10:00:00.000', NULL), +('LOT-1019-110023-01', 'CUST-1019', 'PROD-110023', 48000.0000, 48000.0000, 1.0200, '2025-05-01 10:00:00.000', NULL), +('LOT-1019-005828-01', 'CUST-1019', 'PROD-005828', 22000.0000, 22000.0000, 1.0200, '2026-02-15 10:00:00.000', NULL), +('LOT-1020-161725-01', 'CUST-1020', 'PROD-161725', 85000.0000, 85000.0000, 1.0500, '2026-08-01 10:00:00.000', NULL), +('LOT-1020-XYZ999-01', 'CUST-1020', 'PROD-XYZ999', 15000.0000, 15000.0000, 0.9000, '2026-08-25 10:00:00.000', NULL), +('LOT-1021-005827-01', 'CUST-1021', 'PROD-005827', 15000.0000, 15000.0000, 0.9500, '2026-09-01 10:00:00.000', NULL), +('LOT-1022-510300-01', 'CUST-1022', 'PROD-510300', 72000.0000, 72000.0000, 1.0300, '2025-05-01 10:00:00.000', NULL), +('LOT-1022-110022-01', 'CUST-1022', 'PROD-110022', 48000.0000, 48000.0000, 1.0300, '2026-02-15 10:00:00.000', NULL), +('LOT-1023-005828-01', 'CUST-1023', 'PROD-005828', 38000.0000, 38000.0000, 1.0200, '2026-08-01 10:00:00.000', NULL), +('LOT-1024-161726-01', 'CUST-1024', 'PROD-161726', 95000.0000, 95000.0000, 1.0500, '2026-08-25 10:00:00.000', NULL), +-- ---- CUST-3001 / 4001 / 4002(单批)---- +('LOT-3001-510300-01', 'CUST-3001', 'PROD-510300', 200000.0000, 200000.0000, 1.0300, '2026-09-01 10:00:00.000', NULL), +('LOT-3001-005827-01', 'CUST-3001', 'PROD-005827', 150000.0000, 150000.0000, 0.9500, '2025-05-01 10:00:00.000', NULL), +('LOT-4001-XYZ999-01', 'CUST-4001', 'PROD-XYZ999', 80000.0000, 80000.0000, 0.9000, '2026-02-15 10:00:00.000', NULL), +('LOT-4001-161725-01', 'CUST-4001', 'PROD-161725', 50000.0000, 50000.0000, 1.0500, '2026-08-01 10:00:00.000', NULL), +('LOT-4002-161725-01', 'CUST-4002', 'PROD-161725', 10000.0000, 10000.0000, 1.0500, '2026-08-25 10:00:00.000', NULL), +-- ---- 手册示范客户 CUST-DEMO-A ~ E(单批)---- +('LOT-DEMOA-510300-01', 'CUST-DEMO-A', 'PROD-510300', 35000.0000, 35000.0000, 1.0300, '2026-09-01 10:00:00.000', NULL), +('LOT-DEMOA-510500-01', 'CUST-DEMO-A', 'PROD-510500', 25000.0000, 25000.0000, 0.9800, '2025-05-01 10:00:00.000', NULL), +('LOT-DEMOA-000001-01', 'CUST-DEMO-A', 'PROD-000001', 10000.0000, 10000.0000, 1.0020, '2026-02-15 10:00:00.000', NULL), +('LOT-DEMOB-161725-01', 'CUST-DEMO-B', 'PROD-161725', 500000.0000, 500000.0000, 1.0500, '2026-08-01 10:00:00.000', NULL), +('LOT-DEMOB-PRIV01-01', 'CUST-DEMO-B', 'PROD-PRIV01', 2500000.0000, 2500000.0000, 1.0800, '2026-08-25 10:00:00.000', NULL), +('LOT-DEMOC-110022-01', 'CUST-DEMO-C', 'PROD-110022', 400000.0000, 400000.0000, 1.0300, '2026-09-01 10:00:00.000', NULL), +('LOT-DEMOC-WMG001-01', 'CUST-DEMO-C', 'PROD-WMG001', 200000.0000, 200000.0000, 1.0150, '2025-05-01 10:00:00.000', NULL), +('LOT-DEMOD-005827-01', 'CUST-DEMO-D', 'PROD-005827', 300000.0000, 300000.0000, 0.9500, '2026-02-15 10:00:00.000', NULL), +('LOT-DEMOD-510300-01', 'CUST-DEMO-D', 'PROD-510300', 150000.0000, 150000.0000, 1.0300, '2026-08-01 10:00:00.000', NULL), +('LOT-DEMOE-161725-01', 'CUST-DEMO-E', 'PROD-161725', 120000.0000, 120000.0000, 1.0500, '2026-08-25 10:00:00.000', NULL), +('LOT-DEMOE-510500-01', 'CUST-DEMO-E', 'PROD-510500', 80000.0000, 80000.0000, 0.9800, '2026-09-01 10:00:00.000', NULL); diff --git a/scripts/core/09-seed-org.sql b/scripts/core/09-seed-org.sql new file mode 100644 index 0000000..69b64c9 --- /dev/null +++ b/scripts/core/09-seed-org.sql @@ -0,0 +1,88 @@ +USE jinrong_core; + +-- ============================================================================ +-- 09-seed-org.sql · 产品机构信息与申购费率 +-- 版本:v1.1(2026-09-10 · T-1 新增;同日按「真实业务」修订 —— 分类归位 + 分组重排) +-- 依据: +-- · 证监会公告〔2025〕22 号 §8 申购费率上限(2026-01-01 施行): +-- 主动偏股型 ≤ 0.80% · 其他混合型 ≤ 0.50% · 指数型/债券型 ≤ 0.30% · 货基 0 +-- · PRD §4.2/§4.3:申购费简化为单一 core_product.subscribe_fee_rate(补差费计算源) +-- · PRD/架构:同一笔转换要求 **同销售机构 + 同管理人 + 同 TA** +-- +-- 【v1.1 修订原因 · 分类必须与 product_type 匹配】 +-- v1.0 曾把 `PROD-005827`(`product_type='mixed'`,平衡混合一号)按「主动偏股」取到 +-- **0.80%** 上限 —— 目的是凑出 0.50% 的补差幅度。这是**分类错误**: +-- 22 号文 §8 的费率档由**产品类型**决定,`mixed` 只能套「其他混合型 ≤0.5%」。 +-- 现修正:`PROD-005827` → **0.0050**;主示例转入方改用**真正的主动偏股产品** +-- `PROD-003095`(`stock` / R4 / 净值 0.9500 —— 与原示例除数相同,故 §5.3 数字不变)。 +-- ⚠️ 通用规则:**用某一档上限前,先证明该产品确实属于这一档**,不得「看名字像」 +-- 或「为了凑数值差」升档(自检第 12 问)。 +-- +-- 【v1.1 修订原因 · 分组按真实业务重排】 +-- v1.0 的分组是「华夏组全是低风险(债/混合/货基/指数)、易方组全是股票型」—— +-- **不符合真实**:真实的基金管理人都是**全产品线**(同一家公司旗下同时有债基、 +-- 混合、指数、股票、货基)。真实的分组必须以「管理人」为单位、覆盖多风险层级, +-- 转换约束「同管理人 + 同 TA」才有实际意义。 +-- 现分组:华夏 / 易方 各 6 只、均覆盖多个风险层级;理财与私募单列(真实形态: +-- 银行理财子公司、私募管理人,与公募不同主体,天然不可与公募互转)。 +-- +-- 【分组与费率一览】 +-- 华夏模拟基金 / TA-CN-001(主演示组,覆盖 5 类) +-- PROD-110022 稳健债基 A bond R1 0.0030 ← 主示例转出 +-- PROD-110023 信用债精选 bond R2 0.0030 +-- PROD-005827 平衡混合一号 mixed R3 0.0050 ← v1.1 由 0.0080 归位 +-- PROD-000001 现金宝货币 money R1 0.0000 ← 同费率对照组(0) +-- PROD-510300 沪深300指数 index R3 0.0030 ← 同费率对照组(与 110022 同) +-- PROD-003095 医药健康精选 stock R4 0.0080 ← 主示例转入(真主动偏股) +-- 易方模拟基金 / TA-CN-002(覆盖 4 类) +-- PROD-000002 同业存单基金 bond R1 0.0030 +-- PROD-005828 稳健增利混合 mixed R2 0.0050 +-- PROD-510500 中证500指数 index R4 0.0030 +-- PROD-161725 科技成长主题 stock R4 0.0080 +-- PROD-161726 消费升级主题 stock R4 0.0080 +-- PROD-XYZ999 进取成长五号 stock R5 0.0080 +-- 兴银理财 / TA-CN-003 : PROD-WMG001 稳享180天理财 wealth_mgmt R2 0.0000 +-- 量化私募 / TA-CN-004 : PROD-PRIV01 量化中性私募一号 private_fund R4 0.0000 +-- +-- 【可验证的费率关系(供补差费演示与对照)】 +-- 不同费率对:PROD-110022(0.0030)→ PROD-003095(0.0080)**同属华夏组、可互转** → 补差非 0 +-- 同费率对照:PROD-110022(0.0030)↔ PROD-510300(0.0030)→ 补差 = 0 +-- 零费率对照:PROD-000001(0)→ 任意产品补差非 0;任意 → PROD-000001 补差 = 0 +-- +-- 【跨主体负例保障(「同管理人 + 同 TA」硬约束的反例数据仍然充足)】 +-- CUST-1004 : 161725(易方) + 510300(华夏) → 跨管理人 ✗ +-- CUST-DEMO-A : 510500(易方) + 510300/000001(华夏) → 跨管理人 ✗ +-- CUST-DEMO-B : 161725(易方) + PRIV01(量化私募) → 跨管理人 + 跨机构类型 ✗ +-- +-- 【最低持有余额】让 FR-C15 的两种动作都有数据可触发(否则 DEFAULT 0 使该分支永不进入, +-- 等于无测试覆盖):PROD-110022 = force_transfer(低于阈值强制全转) +-- PROD-005827 = force_redeem (低于阈值强制赎回剩余) +-- ============================================================================ + +-- ---- ① 华夏模拟基金 / TA-CN-001(主演示组 · 全产品线)---- +UPDATE core_product SET fund_company = '华夏模拟基金', ta_code = 'TA-CN-001', subscribe_fee_rate = 0.0030 WHERE product_id = 'PROD-110022'; +UPDATE core_product SET fund_company = '华夏模拟基金', ta_code = 'TA-CN-001', subscribe_fee_rate = 0.0030 WHERE product_id = 'PROD-110023'; +UPDATE core_product SET fund_company = '华夏模拟基金', ta_code = 'TA-CN-001', subscribe_fee_rate = 0.0050 WHERE product_id = 'PROD-005827'; +UPDATE core_product SET fund_company = '华夏模拟基金', ta_code = 'TA-CN-001', subscribe_fee_rate = 0.0000 WHERE product_id = 'PROD-000001'; +UPDATE core_product SET fund_company = '华夏模拟基金', ta_code = 'TA-CN-001', subscribe_fee_rate = 0.0030 WHERE product_id = 'PROD-510300'; +UPDATE core_product SET fund_company = '华夏模拟基金', ta_code = 'TA-CN-001', subscribe_fee_rate = 0.0080 WHERE product_id = 'PROD-003095'; + +-- ---- ② 易方模拟基金 / TA-CN-002(次演示组 · 全产品线)---- +UPDATE core_product SET fund_company = '易方模拟基金', ta_code = 'TA-CN-002', subscribe_fee_rate = 0.0030 WHERE product_id = 'PROD-000002'; +UPDATE core_product SET fund_company = '易方模拟基金', ta_code = 'TA-CN-002', subscribe_fee_rate = 0.0050 WHERE product_id = 'PROD-005828'; +UPDATE core_product SET fund_company = '易方模拟基金', ta_code = 'TA-CN-002', subscribe_fee_rate = 0.0030 WHERE product_id = 'PROD-510500'; +UPDATE core_product SET fund_company = '易方模拟基金', ta_code = 'TA-CN-002', subscribe_fee_rate = 0.0080 WHERE product_id = 'PROD-161725'; +UPDATE core_product SET fund_company = '易方模拟基金', ta_code = 'TA-CN-002', subscribe_fee_rate = 0.0080 WHERE product_id = 'PROD-161726'; +UPDATE core_product SET fund_company = '易方模拟基金', ta_code = 'TA-CN-002', subscribe_fee_rate = 0.0080 WHERE product_id = 'PROD-XYZ999'; + +-- ---- ③ 非同主体产品(真实形态:非公募主体,天然不可与公募互转)---- +UPDATE core_product SET fund_company = '兴银理财', ta_code = 'TA-CN-003', subscribe_fee_rate = 0.0000 WHERE product_id = 'PROD-WMG001'; +UPDATE core_product SET fund_company = '量化私募', ta_code = 'TA-CN-004', subscribe_fee_rate = 0.0000 WHERE product_id = 'PROD-PRIV01'; + +-- ---- ④ 兜底:任何未显式赋值的产品也保证非 NULL(DoD ③)---- +UPDATE core_product SET fund_company = '其他模拟管理人', ta_code = 'TA-CN-099' WHERE fund_company IS NULL; +UPDATE core_product SET ta_code = 'TA-CN-099' WHERE ta_code IS NULL; + +-- ---- ⑤ 最低持有余额(FR-C15 两种动作的可触发数据)---- +UPDATE core_product SET min_hold_qty = 1000.0000, min_redeem_qty = 100.0000, min_hold_action = 'force_transfer' WHERE product_id = 'PROD-110022'; +UPDATE core_product SET min_hold_qty = 2000.0000, min_redeem_qty = 200.0000, min_hold_action = 'force_redeem' WHERE product_id = 'PROD-005827'; diff --git a/scripts/core/README.md b/scripts/core/README.md index ddbf83c..dedd960 100644 --- a/scripts/core/README.md +++ b/scripts/core/README.md @@ -18,6 +18,10 @@ mysql -u root -p < scripts/core/03-seed-customers.sql mysql -u root -p < scripts/core/04-seed-holdings.sql mysql -u root -p < scripts/core/05-seed-trades.sql mysql -u root -p < scripts/core/06-seed-nav.sql +# 基金转换(convert)新增种子 · T-1 +mysql -u root -p < scripts/core/07-seed-fee-rule.sql +mysql -u root -p < scripts/core/08-seed-share-lot.sql +mysql -u root -p < scripts/core/09-seed-org.sql # Agent 库(若未建) mysql -u root -p < docs/项目框架设计/表设计/01-mysql-共用底座.sql @@ -35,9 +39,16 @@ python scripts/sync/sync_neo4j.py | 分析/风控/合规/运营 | 2+2+2+1(风控另有演示账号 STAFF-90001,我方分支独有) | | 双角色 staff | 1(STAFF-10091 advisor+compliance) | | 产品 | 14 | -| 持仓记录 | ~45 | +| 持仓记录 | 58 | | 交易 | 25+ | | C×R 适当性矩阵 | 25 行(core_suitability_rule,L0 权威) | +| 赎回费规则(T-1) | 70 行(14 产品 × 5 档,`core_fee_rule`;档位按 22 号文 §10) | +| 份额批次(T-1) | 61 行(58 行持仓反推,Σ remain_qty 恒等于持仓 qty;CUST-9527 跨批次) | + +> 新增的 07/08/09 三个种子**已并入 `reset.ps1`**;`00-grant.sql` 是账号授权脚本, +> 需管理员单独执行一次,**刻意不进 reset.ps1**(DROP DATABASE 不清授权行)。 +> T-1 起 `reset.ps1` 需 mysql 客户端在 PATH;若不在 PATH 且需非交互执行, +> 用 `python scripts/dev/verify_convert_seed.py`(等价流程 + DoD 断言)。 ## RBAC 联调账号见 diff --git a/scripts/core/reset.ps1 b/scripts/core/reset.ps1 index 993dfb8..0d5f054 100644 --- a/scripts/core/reset.ps1 +++ b/scripts/core/reset.ps1 @@ -28,7 +28,10 @@ $files = @( "scripts/core/03-seed-customers.sql", "scripts/core/04-seed-holdings.sql", "scripts/core/05-seed-trades.sql", - "scripts/core/06-seed-nav.sql" + "scripts/core/06-seed-nav.sql", + "scripts/core/07-seed-fee-rule.sql", + "scripts/core/08-seed-share-lot.sql", + "scripts/core/09-seed-org.sql" ) foreach ($f in $files) { Invoke-SqlFile (Join-Path $Root $f) } diff --git a/scripts/dev/calc_convert_demo.py b/scripts/dev/calc_convert_demo.py index 4e349f4..ade4d14 100644 --- a/scripts/dev/calc_convert_demo.py +++ b/scripts/dev/calc_convert_demo.py @@ -1,98 +1,172 @@ # -*- coding: utf-8 -*- -"""基金转换主示例实算(PRD §5.3 数值来源)。 +"""基金转换主示例实算(T-2b · PRD §5.3 数值来源 · 禁止手算)。 -用途:PRD §5.3 响应示例中的**所有派生金额/份额**,必须由本脚本算出后回填, -**禁止手算**——v0.7 曾两次因手算与口径不符被外部审查质疑(见处置表 T-2b / 自检第 9 问)。 +**为什么必须调用生产 `app.service.convert.calc` 而不是脚本内再写一套公式** -公式与 PRD §2.1 / §2.1.1 / §2.1.3 / §2.5 完全一致: - 转出金额 = Σ(各批次份额 × 转出净值) - 赎回费 = Σ(各批次金额 × 该批适用赎回费率) # 逐批次先舍入、后求和 - 转换金额 = 转出金额 − 赎回费 - 补差费(B) = Max[ 转换金额×转入费率/(1+转入费率) - − 转换金额×转出费率/(1+转出费率), 0 ] # 价外法两端差(本期默认) - 补差费(A) = 转换金额 × max(转入费率−转出费率,0) / (1+补差费率) # 费率差法(配置保留) - 转入金额 = 转换金额 − 补差费 - 转入份额 = 转入金额 ÷ 转入净值 # 2 位,ROUND_HALF_UP +v0.7 曾两次因「手算与口径不符」被外部审查质疑;而"脚本自己实现一份公式" +是同一类问题的变体——**两处公式必然漂移**(生产 `calc.py` 改了、脚本没改, +PRD 里的数字就变成假证据)。这与 D18(批次补建规则只留一个副本)是同一条纪律。 + +故本脚本: +1. **只做输入准备与输出打印**,全部计算走 `calc` / `fee` 的生产函数; +2. 打印后与 PRD §5.3 的**期望值逐项比对**,不一致即 `exit 1` + —— 这样它同时是一道「PRD 数字 ↔ 生产口径」一致性门禁。 运行:<托管 Python> scripts/dev/calc_convert_demo.py """ -from decimal import Decimal, ROUND_HALF_UP -TWO = Decimal("0.01") -FOUR = Decimal("0.0001") +from __future__ import annotations + +import sys +from datetime import date, datetime, time, timedelta +from decimal import Decimal +from pathlib import Path + +# ① sys.path 引导项目根(直跑脚本时 sys.path[0] 为脚本目录;同 verify_convert_seed 先例) +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from app.service.convert import calc, fee # noqa: E402 +from app.service.convert.types import FeeRule, Lot # noqa: E402 + +#: 与 PRD §5.3 同一交易日(也是 06-seed-nav.sql 的净值日期)。 +TRADE_DATE = date(2026, 9, 4) + +#: 07-seed-fee-rule.sql 的赎回费五档(22 号文 §10 下限)。 +RULES = [ + FeeRule(0, 7, Decimal("0.0150")), + FeeRule(7, 30, Decimal("0.0100")), + FeeRule(30, 180, Decimal("0.0050")), + FeeRule(180, 365, Decimal("0.0025")), + FeeRule(365, None, Decimal("0.0000")), +] + +# 两端同属**华夏模拟基金 / TA-CN-001**(满足转换前置硬约束:同管理人 + 同 TA)。 +NAV_OUT = Decimal("1.0300") # PROD-110022 稳健债基 A(bond) +NAV_IN = Decimal("0.9500") # PROD-003095 医药健康精选(stock / 主动偏股型) +SUB_RATE_OUT = Decimal("0.0030") # 转出端申购费率(债基,22 号文 §8 上限 0.3%) +SUB_RATE_IN = Decimal("0.0080") # 转入端申购费率(主动偏股型,上限 0.8%) + +REQUESTED_QTY = Decimal("50000.0000") + +#: PRD §5.3 期望值 —— 实算结果必须逐项命中。 +EXPECTED = { + "批次 1 amount": "30900.00", + "批次 1 fee": "154.50", + "批次 2 amount": "20600.00", + "批次 2 fee": "309.00", + "out_amount": "51500.00", + "redeem_fee": "463.50", + "convert_amount": "51036.50", + "diff_fee(B)": "252.40", + "in_amount": "50784.10", + "in_qty(B)": "53456.95", + "rounding_diff": "-0.0026", + "diff_fee(A)": "253.91", + "in_qty(A)": "53455.36", + "同费率 diff_fee": "0.00", + "同费率 in_qty": "53722.63", +} -def q2(x: Decimal) -> Decimal: - """金额/份额统一保留 2 位、四舍五入(显式 ROUND_HALF_UP,禁用默认 HALF_EVEN)。""" - return x.quantize(TWO, rounding=ROUND_HALF_UP) +def _lot(lot_id: str, hold_days: int, qty: str, nav: Decimal) -> Lot: + """构造 `hold_days` 天前确认的批次(交易日固定为 TRADE_DATE)。""" + confirmed = datetime.combine(TRADE_DATE - timedelta(days=hold_days), time(10, 0, 0)) + return Lot( + lot_id=lot_id, + confirmed_at=confirmed, + remain_qty=Decimal(qty), + nav=nav, + qty=Decimal(qty), + ) -def main() -> None: - # —— 主示例输入(与 §5.3 / §4.3 种子一致)—— +def compute() -> dict[str, str]: + """跑一遍主示例全链,返回**结果字典**(键与 EXPECTED 对齐)。""" + # —— 输入:跨 2 个批次、持有期不同 → 费率不同(30000 份满 30 日 / 20000 份不足 7 日) lots = [ - # (份额, 持有天数, 该批赎回费率) —— 费率来自 §2.1.3 分档表 - (Decimal("30000"), 100, Decimal("0.0050")), # [30,180) 日 → 0.5% - (Decimal("20000"), 3, Decimal("0.0150")), # [0,7) 日 → 1.5% + _lot("LOT-DEMO-01", 100, "30000.0000", NAV_OUT), + _lot("LOT-DEMO-02", 3, "20000.0000", NAV_OUT), ] - nav_out = Decimal("1.03") # PROD-110022 债基 转出净值 - nav_in = Decimal("0.95") # PROD-005827 主动偏股 转入净值 - fee_out = Decimal("0.0030") # 转出端申购费率(债基,22 号文 §8 上限 0.3%) - fee_in = Decimal("0.0080") # 转入端申购费率(主动偏股,上限 0.8%) + plan = calc.plan_lots(lots, REQUESTED_QTY) + result: dict[str, str] = {} + + # —— 逐批:先各自 2 位舍入、再求和(PRD §12「舍入顺序」)—— out_amount = Decimal("0") redeem_fee = Decimal("0") - detail = [] - for qty, days, rate in lots: - amt = q2(qty * nav_out) - fee = q2(amt * rate) - out_amount += amt - redeem_fee += fee - detail.append((qty, days, rate, amt, fee)) + for idx, alloc in enumerate(plan.allocations, start=1): + hold_days = calc.hold_days(TRADE_DATE, alloc.confirmed_at) + rate = fee.pick_fee_rate(RULES, hold_days) + amount = calc.lot_amount(alloc.qty, alloc.nav) + fee_amount = calc.lot_fee(amount, rate) + out_amount += amount + redeem_fee += fee_amount + result[f"批次 {idx} amount"] = str(amount) + result[f"批次 {idx} fee"] = str(fee_amount) + print( + f"批次 {idx}({alloc.lot_id}){alloc.qty} 份 / 持有 {hold_days} 天 / " + f"费率 {rate}({rate * 100}%)→ amount={amount} fee={fee_amount}" + ) - convert_amount = q2(out_amount - redeem_fee) + convert_amount = calc.convert_amount(out_amount, redeem_fee) + result["out_amount"] = str(out_amount) + result["redeem_fee"] = str(redeem_fee) + result["convert_amount"] = str(convert_amount) - # —— 口径 B:价外法两端差(本期默认)—— - sub_in = convert_amount * fee_in / (Decimal("1") + fee_in) - sub_out = convert_amount * fee_out / (Decimal("1") + fee_out) - diff_b = q2(max(sub_in - sub_out, Decimal("0"))) - in_amount = q2(convert_amount - diff_b) - theoretical = in_amount / nav_in - in_qty = q2(theoretical) - rounding_diff = theoretical - in_qty + # —— 口径 B:价外法两端差(本期默认,PRD §2.1.1)—— + diff_b = calc.diff_fee(convert_amount, SUB_RATE_OUT, SUB_RATE_IN, "amount_diff") + in_amount = calc.convert_amount(convert_amount, diff_b) + in_qty_b = calc.in_qty(in_amount, NAV_IN) + result["diff_fee(B)"] = str(diff_b) + result["in_amount"] = str(in_amount) + result["in_qty(B)"] = str(in_qty_b) + result["rounding_diff"] = str(calc.rounding_diff(in_amount, NAV_IN, in_qty_b)) - # —— 口径 A:费率差法(配置 convert_diff_fee_mode='rate_diff' 保留)—— - rate_diff = max(fee_in - fee_out, Decimal("0")) - diff_a = q2(convert_amount * rate_diff / (Decimal("1") + rate_diff)) - in_qty_a = q2(q2(convert_amount - diff_a) / nav_in) + # —— 口径 A:费率差法(配置 convert_diff_fee_mode='rate_diff' 时启用)—— + diff_a = calc.diff_fee(convert_amount, SUB_RATE_OUT, SUB_RATE_IN, "rate_diff") + in_qty_a = calc.in_qty(calc.convert_amount(convert_amount, diff_a), NAV_IN) + result["diff_fee(A)"] = str(diff_a) + result["in_qty(A)"] = str(in_qty_a) - # —— 同费率对照(补差 = 0),用于 §9 第 19 条 —— - same_rate = Decimal("0.0030") - diff_same = q2(max(convert_amount * same_rate / (Decimal("1") + same_rate) - - convert_amount * same_rate / (Decimal("1") + same_rate), - Decimal("0"))) - in_amount_same = q2(convert_amount - diff_same) - in_qty_same = q2(in_amount_same / nav_in) + # —— 同费率对照(两端均为 0.0030)→ 补差 0,用于 PRD §9 第 19 条 —— + diff_same = calc.diff_fee(convert_amount, SUB_RATE_OUT, SUB_RATE_OUT, "amount_diff") + in_amount_same = calc.convert_amount(convert_amount, diff_same) + result["同费率 diff_fee"] = str(diff_same) + result["同费率 in_qty"] = str(calc.in_qty(in_amount_same, NAV_IN)) + + return result + + +def main() -> int: + print("=== 主示例实算(与生产 calc.py 同一套公式)===") + actual = compute() - print("=== 主示例(口径 B)===") - for qty, days, rate, amt, fee in detail: - print(f"批次 {qty} 份 / {days} 天 / 费率 {rate} -> amount={amt} fee={fee}") - print(f"out_amount = {out_amount}") - print(f"redeem_fee = {redeem_fee}") - print(f"convert_amount = {convert_amount}") - print(f" (转入端申购费) = {sub_in.quantize(FOUR)} -> q2 {q2(sub_in)}") - print(f" (转出端申购费) = {sub_out.quantize(FOUR)} -> q2 {q2(sub_out)}") - print(f"diff_fee(B) = {diff_b}") - print(f"in_amount = {in_amount}") - print(f"in_qty = {in_qty}") - print(f"rounding_diff = {rounding_diff.quantize(FOUR)} (理论 {theoretical.quantize(FOUR)} - 实际 {in_qty})") print() - print("=== 对照:口径 A(费率差法)===") - print(f"diff_fee(A) = {diff_a}") - print(f"in_qty(A) = {in_qty_a}") + print("=== 与 PRD §5.3 期望值逐项比对 ===") + mismatches = [] + for key, expected in EXPECTED.items(): + got = actual.get(key, "<缺失>") + ok = got == expected + if not ok: + mismatches.append((key, expected, got)) + print(f" [{'OK ' if ok else 'FAIL'}] {key:<18} 期望 {expected:<12} 实算 {got}") + + if mismatches: + print() + print(f"❌ {len(mismatches)} 项不一致 —— PRD 数字或生产口径已被改动,停下查清:") + for key, expected, got in mismatches: + print(f" · {key}:期望 {expected} / 实算 {got}") + return 1 + print() - print("=== 同费率对照(两端 0.0030,补差 = 0)===") - print(f"diff_fee={diff_same} in_amount={in_amount_same} in_qty={in_qty_same}") + print(f"✅ 全部 {len(EXPECTED)} 项与 PRD §5.3 一致(口径 B in_qty=53456.95)") + return 0 if __name__ == "__main__": - main() + try: # Windows 控制台默认 cp936,避免打印中文报错 + sys.stdout.reconfigure(encoding="utf-8") + except Exception: # noqa: BLE001 - 环境不支持时忽略,不影响计算 + pass + raise SystemExit(main()) diff --git a/scripts/dev/verify_convert_seed.py b/scripts/dev/verify_convert_seed.py new file mode 100644 index 0000000..16af2cf --- /dev/null +++ b/scripts/dev/verify_convert_seed.py @@ -0,0 +1,312 @@ +"""T-1 验证脚本:用 pymysql 复刻 `scripts/core/reset.ps1` 的 SQL 部分并跑 DoD 断言。 + +背景:本机 `mysql` 客户端不在 PATH,且 reset.ps1 用 `mysql -p`(交互式密码), +无法在非交互环境直接跑。本脚本用 pymysql 执行**同一组 SQL 文件**(00~09), +再对 T-1 的三条 DoD 判定阈值逐条断言,做到「判定阈值明确、非人工目测」。 + +用法: + python scripts/dev/verify_convert_seed.py # 重建 jinrong_core + 恢复演示态 + 全部断言 + python scripts/dev/verify_convert_seed.py --no-reset # 只跑断言(库已就绪时) + +注意 1:会 DROP DATABASE jinrong_core 并重建 —— 该库是 Core 模拟底座,本就设计为可重置 +(与 reset.ps1 行为一致)。**不触碰 jinrong_agent**。 +注意 2:重建后会自动补跑 `scripts/demo/prepare_risk_demo.sql`(reset 的既定伴随步骤)—— +不做的话 `03-seed-customers.sql` 写死的风评有效期会让真库集成模块整体 skip +(实测 516 → 505 passed)。 +""" + +from __future__ import annotations + +import argparse +import re +import sys +from datetime import date +from pathlib import Path + +import pymysql + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from app.config.settings import settings # noqa: E402 + +CORE_DIR = ROOT / "scripts" / "core" +SQL_FILES = [ + "00-create-database.sql", + "01-ddl.sql", + "02-seed-base.sql", + "03-seed-customers.sql", + "04-seed-holdings.sql", + "05-seed-trades.sql", + "06-seed-nav.sql", + "07-seed-fee-rule.sql", + "08-seed-share-lot.sql", + "09-seed-org.sql", +] + +# 档位覆盖参照日:与该批种子中 as_of / 净值日一致(2026-09-04) +AS_OF = date(2026, 9, 4) +BANDS = [(0, 7, "0.0150"), (7, 30, "0.0100"), (30, 180, "0.0050"), (180, 365, "0.0025"), (365, None, "0.0000")] + + +def split_statements(sql: str) -> list[str]: + """剥离 `--` 整行注释后按分号切分。种子文件均无存储过程/触发器,可安全切分。""" + body = "\n".join(ln for ln in sql.splitlines() if not ln.strip().startswith("--")) + return [s.strip() for s in body.split(";") if s.strip()] + + +def connect(with_db: bool = False) -> pymysql.connections.Connection: + return pymysql.connect( + host=settings.mysql_host, + port=settings.mysql_port, + user=settings.mysql_user, + password=settings.mysql_password, + database=settings.mysql_core_database if with_db else None, + charset="utf8mb4", + autocommit=True, + ) + + +def reset_core() -> None: + with connect() as conn, conn.cursor() as cur: + cur.execute(f"DROP DATABASE IF EXISTS `{settings.mysql_core_database}`") + print(f" DROP DATABASE {settings.mysql_core_database}") + for name in SQL_FILES: + path = CORE_DIR / name + for stmt in split_statements(path.read_text(encoding="utf-8")): + cur.execute(stmt) + print(f" ✓ {name}") + + +def run_demo_prepare() -> None: + """把演示态刷回去(风评有效期)。 + + 重建 jinrong_core 会让 `03-seed-customers.sql` 写死的风评有效期回到「已过期」状态, + 于是 `tests/conftest.py::ensure_risk_demo_ready` 判定演示数据未就位, + **整个真库集成模块被 skip**(实测 516 → 505 passed)。这是 reset 的既定伴随步骤 + (演示 SOP §2),reset.ps1 因不含 agent 库步骤而未内置,本脚本补上。 + """ + path = ROOT / "scripts" / "demo" / "prepare_risk_demo.sql" + with connect(with_db=True) as conn, conn.cursor() as cur: + for stmt in split_statements(path.read_text(encoding="utf-8")): + cur.execute(stmt) + print(" ✓ prepare_risk_demo.sql(演示客户风评刷为「剩余 275 天」)") + + +def band_of(hold_days: int) -> str: + for lo, hi, rate in BANDS: + if hold_days >= lo and (hi is None or hold_days < hi): + return rate + return "?" + + +def run_assertions() -> bool: + ok = True + with connect(with_db=True) as conn, conn.cursor() as cur: + # ---- DoD ① 赎回费 5 档,且每只产品各 5 档 ---- + cur.execute("SELECT COUNT(DISTINCT min_hold_days) FROM core_fee_rule WHERE fee_type='redeem'") + n_bands = cur.fetchone()[0] + cur.execute( + "SELECT product_id, COUNT(*) FROM core_fee_rule WHERE fee_type='redeem' " + "GROUP BY product_id HAVING COUNT(*) <> 5" + ) + uneven = cur.fetchall() + cur.execute("SELECT COUNT(*), COUNT(DISTINCT product_id) FROM core_fee_rule WHERE fee_type='redeem'") + total_rows, n_products = cur.fetchone() + p1 = n_bands == 5 and not uneven + print(f"\n① 赎回费档位:DISTINCT min_hold_days={n_bands}(期望 5)· " + f"{n_products} 只产品 × {total_rows // max(n_products, 1)} 档 · 非 5 档产品={uneven or '无'}") + if not p1: + print(" ✗ 不满足 5 档 / 每产品 5 档") + ok &= p1 + + # ---- DoD ② Σ remain_qty 逐行等于 core_holding.qty ---- + cur.execute( + """ + SELECT h.customer_id, h.product_id, h.qty, COALESCE(SUM(l.remain_qty), 0) AS lot_sum + FROM core_holding h + LEFT JOIN core_share_lot l + ON l.customer_id = h.customer_id AND l.product_id = h.product_id + GROUP BY h.customer_id, h.product_id, h.qty + HAVING ABS(lot_sum - h.qty) > 0.0001 + """ + ) + mismatch = cur.fetchall() + cur.execute("SELECT COUNT(*) FROM core_holding") + n_holding = cur.fetchone()[0] + cur.execute("SELECT COUNT(*), COUNT(DISTINCT CONCAT(customer_id, '|', product_id)) FROM core_share_lot") + n_lots, n_pairs = cur.fetchone() + p2 = not mismatch + print(f"② 批次守恒:{n_holding} 行持仓 vs {n_lots} 行批次({n_pairs} 个客户-产品对)· " + f"失配={mismatch or '无'}") + if not p2: + print(" ✗ 存在 Σ remain_qty ≠ qty 的持仓行") + ok &= p2 + + # ---- DoD ③ 三列无 NULL 且存在不同费率对 ---- + cur.execute( + "SELECT COUNT(*) FROM core_product " + "WHERE fund_company IS NULL OR ta_code IS NULL OR subscribe_fee_rate IS NULL" + ) + n_null = cur.fetchone()[0] + cur.execute("SELECT COUNT(DISTINCT subscribe_fee_rate) FROM core_product") + n_rates = cur.fetchone()[0] + cur.execute( + "SELECT product_id, subscribe_fee_rate FROM core_product " + "WHERE product_id IN ('PROD-110022','PROD-003095','PROD-005827','PROD-510300','PROD-000001') " + "ORDER BY product_id" + ) + demo = cur.fetchall() + p3 = n_null == 0 and n_rates > 1 + print(f"③ 机构/费率:NULL 行数={n_null}(期望 0)· DISTINCT 费率={n_rates}(期望 >1)") + print(f" 演示费率对:{[(p, str(r)) for p, r in demo]}") + if not p3: + print(" ✗ 存在 NULL 或不存在不同费率对") + ok &= p3 + + # ---- 附加:批次实际覆盖的档位(以 AS_OF 为参照) ---- + cur.execute("SELECT lot_id, confirmed_at FROM core_share_lot") + rows = cur.fetchall() + seen: dict[str, int] = {} + for _lot, ts in rows: + b = band_of((AS_OF - ts.date()).days) + seen[b] = seen.get(b, 0) + 1 + p4 = len(seen) == 5 + print(f"④ 批次档位覆盖(参照 {AS_OF}):{dict(sorted(seen.items()))}") + if not p4: + print(" ✗ 未覆盖全部 5 档 → 费率档演示无数据") + ok &= p4 + + # ---- 附加:主演示客户 CUST-9527 确为跨批次 ---- + cur.execute( + "SELECT product_id, COUNT(*) FROM core_share_lot WHERE customer_id='CUST-9527' " + "GROUP BY product_id ORDER BY product_id" + ) + c9527 = cur.fetchall() + p5 = any(n >= 2 for _p, n in c9527) + print(f"⑤ CUST-9527 批次数:{c9527}(至少一只产品 ≥2 批)") + if not p5: + print(" ✗ 主演示客户无跨批次数据") + ok &= p5 + + # ---- DoD ⑥ 向后兼容:不带新列的老 INSERT 仍可执行(架构风险 #9)---- + cur.execute("START TRANSACTION") + try: + cur.execute( + "INSERT INTO core_trade (trade_id, customer_id, product_id, trade_type, amount, traded_at) " + "VALUES ('TRD-BC-TEST', 'CUST-9527', 'PROD-110022', 'subscribe', 100.00, NOW(3))" + ) + cur.execute("SELECT convert_group_id FROM core_trade WHERE trade_id = 'TRD-BC-TEST'") + gid = cur.fetchone()[0] + cur.execute( + "INSERT INTO core_product (product_id, product_name, product_type, min_risk_code, " + "min_subscribe_amount, requires_disclosure) VALUES " + "('PROD-BC-TEST', '向后兼容测试', 'bond', 'R1', 100.00, 0)" + ) + cur.execute( + "SELECT can_subscribe, can_redeem, min_hold_action, subscribe_fee_rate, fund_company " + "FROM core_product WHERE product_id = 'PROD-BC-TEST'" + ) + row = cur.fetchone() + p6 = ( + gid is None + and row[0] == 1 and row[1] == 1 + and row[2] == "force_transfer" and row[3] == 0 and row[4] is None + ) + finally: + cur.execute("ROLLBACK") + print(f"⑥ 老 INSERT 向后兼容:core_trade.convert_group_id={gid}(期望 None)· " + f"core_product 新列默认值={row}(期望 1/1/force_transfer/0/None)") + if not p6: + print(" ✗ 不带新列的老 INSERT 失败或默认值不符") + ok &= p6 + + # ---- DoD ⑦ risk_convert_detail 建表即含 5 个 status 值(免二期 ALTER)---- + doc = (ROOT / "docs" / "项目框架设计" / "表设计" / "02-mysql-agent专用.sql").read_text(encoding="utf-8") + m = re.search(r"CREATE TABLE risk_convert_detail \(.*?\n\) ENGINE=InnoDB[^;]*;", doc, re.S) + assert m, "02-mysql-agent专用.sql 中未找到 risk_convert_detail 建表语句" + tmp_db = "jinrong_tmp_check" + with connect() as conn, conn.cursor() as cur: + cur.execute(f"DROP DATABASE IF EXISTS {tmp_db}") + cur.execute(f"CREATE DATABASE {tmp_db}") + cur.execute(f"USE {tmp_db}") + cur.execute(m.group(0)) + cur.execute( + "SELECT COLUMN_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = %s " + "AND TABLE_NAME = 'risk_convert_detail' AND COLUMN_NAME = 'status'", + (tmp_db,), + ) + col_type = cur.fetchone()[0] + cur.execute(f"DROP DATABASE {tmp_db}") + vals = set(re.findall(r"'(\w+)'", col_type)) + p7 = vals == {"pending", "completed", "failed", "cancelled", "expired"} + print(f"⑦ risk_convert_detail.status = {col_type}") + if not p7: + print(f" ✗ ENUM 值不全(实际 {vals})") + ok &= p7 + + # ---- DoD ⑧ 费率档必须与 product_type 匹配(自检第 12 问的机器化)---- + # 22 号文 §8 的费率档由产品类型决定:stock ≤0.80% / mixed ≤0.50% / + # bond·index ≤0.30% / money = 0。任何「升档凑数值差」都会在此暴露。 + CAPS = {"stock": 0.0080, "mixed": 0.0050, "bond": 0.0030, "index": 0.0030, "money": 0.0} + # 非公募主体:不适用 22 号文公募费率规定,本项目建模为费率 0 且不与公募互转 + NOT_APPLICABLE = {"wealth_mgmt", "private_fund"} + with connect(with_db=True) as conn, conn.cursor() as cur: + cur.execute("SELECT product_id, product_type, subscribe_fee_rate FROM core_product") + rows = cur.fetchall() + offenders = [ + (pid, ptype, float(rate)) + for pid, ptype, rate in rows + if ptype in CAPS and float(rate) > CAPS[ptype] + 1e-9 + ] + unmatched = [r for r in rows if r[1] not in CAPS and r[1] not in NOT_APPLICABLE] + na_offenders = [ + (pid, ptype, float(rate)) + for pid, ptype, rate in rows + if ptype in NOT_APPLICABLE and float(rate) != 0 + ] + # 主示例两端必须同主体(同管理人 + 同 TA),否则转换前置约束不成立 + cur.execute( + "SELECT DISTINCT fund_company, ta_code FROM core_product " + "WHERE product_id IN ('PROD-110022', 'PROD-003095') AND fund_company IS NOT NULL" + ) + entities = cur.fetchall() + # 主演示组内费率档必须多样(否则补差费/同费率对照无数据) + cur.execute( + "SELECT COUNT(DISTINCT subscribe_fee_rate) FROM core_product " + "WHERE fund_company = '华夏模拟基金' AND ta_code = 'TA-CN-001'" + ) + main_group_rates = cur.fetchone()[0] + p8 = ( + not offenders and not unmatched and not na_offenders + and len(entities) == 1 and main_group_rates >= 3 + ) + print(f"⑧ 费率档 ↔ product_type 匹配:越档={offenders or '无'} · 未归类={unmatched or '无'} · " + f"非公募费率非 0={na_offenders or '无'}") + print(f" 主示例两端主体={entities}(须唯一)· 华夏组费率档数={main_group_rates}(须 ≥3)") + if not p8: + print(" ✗ 存在「按更宽松档次取费率」或主示例两端非同主体 / 主组费率档不足") + ok &= p8 + + return ok + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--no-reset", action="store_true", help="跳过重建,只跑断言") + args = ap.parse_args() + + if not args.no_reset: + print("== 重建 jinrong_core(等价 reset.ps1 的 SQL 部分)==") + reset_core() + print("== 恢复演示态 ==") + run_demo_prepare() + + print("\n== T-1 DoD 断言 ==") + ok = run_assertions() + print("\n" + ("全部 PASS ✅" if ok else "存在 FAIL ❌")) + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/_ddl.py b/tests/_ddl.py index fa137bc..2c026ea 100644 --- a/tests/_ddl.py +++ b/tests/_ddl.py @@ -45,19 +45,77 @@ SQLITE_TABLES: dict[str, str] = { product_id VARCHAR(64) PRIMARY KEY, product_name VARCHAR(128), min_risk_code VARCHAR(8), product_type VARCHAR(32), min_subscribe_amount DECIMAL DEFAULT 1.00, term_days INTEGER, - requires_disclosure TINYINT DEFAULT 0) + requires_disclosure TINYINT DEFAULT 0, + can_subscribe TINYINT DEFAULT 1, can_redeem TINYINT DEFAULT 1, + min_hold_qty DECIMAL DEFAULT 0, min_redeem_qty DECIMAL DEFAULT 0, + min_hold_action VARCHAR(32) DEFAULT 'force_transfer', + subscribe_fee_rate DECIMAL DEFAULT 0, + fund_company VARCHAR(64), ta_code VARCHAR(32)) """, + # T-0(R1 门禁):以 scripts/core/01-ddl.sql:132-145 为权威逐列对齐 —— + # id PK / customer_id / product_id / qty / cost_amount / market_value / + # pnl_pct / as_of + UNIQUE(customer_id, product_id)。 + # 原列名 quantity 已废弃(MySQL 恒为 qty,列名失配是 convert 首个阻断项)。 + # 不建外键:sqlite 测试库既有表均无 FK,保持一致(避免插入顺序约束)。 "core_holding": """ CREATE TABLE core_holding ( - customer_id VARCHAR(64), product_id VARCHAR(64), - market_value DECIMAL, quantity DECIMAL) + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id VARCHAR(64) NOT NULL, product_id VARCHAR(64) NOT NULL, + qty DECIMAL NOT NULL, cost_amount DECIMAL NOT NULL, + market_value DECIMAL NOT NULL, pnl_pct DECIMAL NOT NULL, as_of DATE NOT NULL, + UNIQUE (customer_id, product_id)) """, "core_trade": """ CREATE TABLE core_trade ( trade_id VARCHAR(64) PRIMARY KEY, customer_id VARCHAR(64), product_id VARCHAR(64), - trade_type VARCHAR(16), amount DECIMAL, trade_status VARCHAR(16), + trade_type VARCHAR(16), amount DECIMAL, qty DECIMAL, + convert_group_id VARCHAR(64), trade_status VARCHAR(16), traded_at TIMESTAMP) """, + # ---- convert 新增 4 表(T-1):MySQL 侧落点为 + # scripts/core/01-ddl.sql(前 3 张)与 docs/项目框架设计/表设计/02-mysql-agent专用.sql + # (risk_convert_detail)。列集为测试所需列的并集,新列只改此处。 + "core_fee_rule": """ + CREATE TABLE core_fee_rule ( + id INTEGER PRIMARY KEY AUTOINCREMENT, product_id VARCHAR(64) NOT NULL, + fee_type VARCHAR(16) NOT NULL, min_hold_days INTEGER, max_hold_days INTEGER, + min_amount DECIMAL, max_amount DECIMAL, rate DECIMAL NOT NULL, + to_fund_ratio DECIMAL NOT NULL DEFAULT 1.0) + """, + "core_share_lot": f""" + CREATE TABLE core_share_lot ( + lot_id VARCHAR(64) PRIMARY KEY, customer_id VARCHAR(64) NOT NULL, + product_id VARCHAR(64) NOT NULL, qty DECIMAL NOT NULL, remain_qty DECIMAL NOT NULL, + nav DECIMAL NOT NULL, confirmed_at TIMESTAMP NOT NULL, + source_trade_id VARCHAR(64), created_at {_TS}) + """, + "core_convert_lot_detail": f""" + CREATE TABLE core_convert_lot_detail ( + id INTEGER PRIMARY KEY AUTOINCREMENT, convert_group_id VARCHAR(64) NOT NULL, + lot_id VARCHAR(64) NOT NULL, qty DECIMAL NOT NULL, hold_days INTEGER NOT NULL, + amount DECIMAL NOT NULL, fee_rate DECIMAL NOT NULL, fee_amount DECIMAL NOT NULL, + nav DECIMAL NOT NULL, nav_date DATE NOT NULL, created_at {_TS}) + """, + "risk_convert_detail": f""" + CREATE TABLE risk_convert_detail ( + id INTEGER PRIMARY KEY AUTOINCREMENT, convert_group_id VARCHAR(64) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'pending', + out_trade_id VARCHAR(64), in_trade_id VARCHAR(64), related_trade_id VARCHAR(64), + nav DECIMAL, nav_date DATE, fee_amount DECIMAL, + hold_days_min INTEGER, hold_days_max INTEGER, + estimated TINYINT NOT NULL DEFAULT 1, nav_stale TINYINT NOT NULL DEFAULT 0, + client_request_id VARCHAR(64), cancelled_at TIMESTAMP, + created_at {_TS}, + UNIQUE (convert_group_id), UNIQUE (client_request_id)) + """, + # T-0(R-f):core_product_nav 与 MySQL scripts/core/01-ddl.sql:186-194 对齐 + # (get_latest_nav 及新增 get_as_of 的读取目标;此前 sqlite 侧整表缺失)。 + "core_product_nav": """ + CREATE TABLE core_product_nav ( + id INTEGER PRIMARY KEY AUTOINCREMENT, product_id VARCHAR(64) NOT NULL, + nav DECIMAL NOT NULL, daily_chg_pct DECIMAL NOT NULL, nav_date DATE NOT NULL, + UNIQUE (product_id, nav_date)) + """, # ---- agent 库 ---- "audit_log": f""" CREATE TABLE audit_log ( @@ -138,14 +196,65 @@ SQLITE_TABLES: dict[str, str] = { } +# ---------- T-0 建库自校验(R1 门禁)---------- +# 与 scripts/core/01-ddl.sql 人工同步的权威结构清单(逐列对照,见上表注释): +# core_holding id BIGINT PK / customer_id / product_id / qty / cost_amount / +# market_value / pnl_pct / as_of / UNIQUE KEY uk_cust_product +# core_product_nav id BIGINT PK / product_id / nav / daily_chg_pct / nav_date / +# UNIQUE KEY uk_product_date +# 失配在建库那一刻即 AssertionError,不留到集成测试期。 +EXPECTED_CORE_HOLDING_COLUMNS = { + "id", "customer_id", "product_id", "qty", + "cost_amount", "market_value", "pnl_pct", "as_of", +} +REQUIRED_CORE_TABLES = {"core_holding", "core_product_nav", "core_trade", "core_product"} +# T-1:convert 新增 4 表同样纳入存在性门禁。落点:前 3 张在 scripts/core/01-ddl.sql, +# risk_convert_detail 在 docs/项目框架设计/表设计/02-mysql-agent专用.sql。 +REQUIRED_CONVERT_TABLES = { + "core_fee_rule", "core_share_lot", "core_convert_lot_detail", "risk_convert_detail", +} + + +def _assert_ddl_aligned(engine) -> None: + """建库后自校验:缺列/缺表立即 AssertionError(R1 门禁)。 + + 触发场景(真实踩过的坑):sqlite 列名曾为 quantity,而 MySQL 是 qty —— + 单测全绿但 convert 首次 UPDATE core_holding.qty 才炸;本校验把失败点前移。 + """ + with engine.connect() as conn: + tables = { + row[0] + for row in conn.execute(text("SELECT name FROM sqlite_master WHERE type = 'table'")) + } + missing_tables = REQUIRED_CORE_TABLES - tables + assert not missing_tables, ( + f"sqlite DDL 缺表:{sorted(missing_tables)}(以 scripts/core/01-ddl.sql 为准)" + ) + missing_convert = REQUIRED_CONVERT_TABLES - tables + assert not missing_convert, ( + f"sqlite DDL 缺 convert 表:{sorted(missing_convert)}" + "(以 scripts/core/01-ddl.sql 与 docs/项目框架设计/表设计/02-mysql-agent专用.sql 为准)" + ) + columns = {row[1] for row in conn.execute(text("PRAGMA table_info(core_holding)"))} + missing_columns = EXPECTED_CORE_HOLDING_COLUMNS - columns + assert not missing_columns, ( + f"core_holding 缺列:{sorted(missing_columns)}(以 scripts/core/01-ddl.sql 为准)" + ) + + def create_sqlite_engine(): - """内存 sqlite + 全部表(StaticPool 单连接共享,同现有测试模式)。""" + """内存 sqlite + 全部表(StaticPool 单连接共享,同现有测试模式)。 + + 建表后经 `_assert_ddl_aligned` 自校验(T-0 门禁):结构失配在建库即失败, + 而不是留到集成测试期。 + """ engine = create_engine( "sqlite://", poolclass=StaticPool, connect_args={"check_same_thread": False} ) with engine.begin() as conn: for ddl in SQLITE_TABLES.values(): conn.execute(text(ddl)) + _assert_ddl_aligned(engine) return engine diff --git a/tests/conftest.py b/tests/conftest.py index 6fae557..22cf0d1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -180,8 +180,10 @@ def ensure_risk_demo_ready() -> None: hint = "先跑 FLOW §0 ③④:scripts/core/reset.ps1 → 01-mysql-共用底座.sql →" " 02-mysql-agent专用.sql → scripts/agent/seed-aml-list.sql → prepare_risk_demo.sql" try: - core = get_engine(settings.mysql_core_database) - agent = get_engine(settings.mysql_database) + # T-0b(R-e):显式 admin —— 本函数与 fixture 的 setup/teardown 需 + # INSERT/DELETE,而 xh_core_rw 无 DELETE、xh_agent_rw 亦无 DELETE。 + core = get_engine(settings.mysql_core_database, role="admin") + agent = get_engine(settings.mysql_database, role="admin") with core.connect() as conn: # FM-03 口径(AL-05 换核):expires_at 未过期即就位(原 evaluated_at+365 天口径退役) days = conn.execute( @@ -229,8 +231,10 @@ def risk_demo_env(): from app.config.settings import settings from app.utils.db import dispose_engines, get_engine - core = get_engine(settings.mysql_core_database) - agent = get_engine(settings.mysql_database) + # T-0b(R-e):teardown 需 DELETE(core_trade / risk_alert / audit_log), + # 只读账号与业务读写账号均无 DELETE 权限 → 显式 admin(= mysql_user)。 + core = get_engine(settings.mysql_core_database, role="admin") + agent = get_engine(settings.mysql_database, role="admin") started_at = datetime.now() l3_snapshot: list = [] # 哨兵:setup 失败时 teardown 不因未绑定变量掩盖原始异常 try: diff --git a/tests/test_chat_tools.py b/tests/test_chat_tools.py index 36f0f2f..2b0d219 100644 --- a/tests/test_chat_tools.py +++ b/tests/test_chat_tools.py @@ -78,8 +78,9 @@ def tool_env(monkeypatch): ) conn.execute( text( - "INSERT INTO core_holding (customer_id, product_id, market_value, quantity)" - " VALUES ('CUST-9527', 'P-001', 50000.00, 100)" + "INSERT INTO core_holding (customer_id, product_id, market_value, qty," + " cost_amount, pnl_pct, as_of)" + " VALUES ('CUST-9527', 'P-001', 50000.00, 100, 50000.00, 0, '2026-09-04')" ) ) conn.execute( diff --git a/tests/test_concentration_c4.py b/tests/test_concentration_c4.py index 98e0be7..006cc3f 100644 --- a/tests/test_concentration_c4.py +++ b/tests/test_concentration_c4.py @@ -71,8 +71,10 @@ def env(monkeypatch): ) conn.execute( text( - "INSERT INTO core_holding (customer_id, product_id, market_value, quantity)" - " VALUES ('C1', 'P2', 900000, 1000), ('C1', 'P1', 100000, 500)" + "INSERT INTO core_holding (customer_id, product_id, market_value, qty," + " cost_amount, pnl_pct, as_of)" + " VALUES ('C1', 'P2', 900000, 1000, 900000, 0, '2026-09-04')," + " ('C1', 'P1', 100000, 500, 100000, 0, '2026-09-04')" ) ) core = CoreReadOnlyRepository(engine=engine) diff --git a/tests/test_convert_calc.py b/tests/test_convert_calc.py new file mode 100644 index 0000000..23d26b7 --- /dev/null +++ b/tests/test_convert_calc.py @@ -0,0 +1,619 @@ +"""T-2 基金转换纯函数单测(开发计划 §4.2 DoD)。 + +覆盖 12 类: + +1. **精度**:全部量化点必须 `ROUND_HALF_UP`(`Decimal` 默认是 `ROUND_HALF_EVEN`, + 架构风险 #3 —— `.5` 边界处两种舍入结果不同,必须命中 HALF_UP 那一侧); +2. **分档边界**:`6/7/29/30/179/180/364/365`(左闭右开,满 7 日归 7–30 档); +3. **FIFO**:主序 `confirmed_at`、同 `confirmed_at` 以 `lot_id` 兜底(评审 S1); +4. **跨批次计费**:逐批先舍入后求和(PRD §5.3 主示例 154.50 + 309.00); +5. **补差费双口径**:B `252.40` / A `253.91`(同输入,差约 0.6%);同费率归零; +6. **最低持有处置**:`force_transfer` / `force_redeem` 双动作、 + **恰好等于阈值不触发**、**零剩余不触发**(实现级裁定); +7. **全链自证**:PRD §5.3 主示例每个数字逐项复算(含 `rounding_diff = -0.0026`); +8. **持有期**:不含申请日、T+1 起算少 1 天; +9. **净值**:无净值 → 503;过期 → 只标 `nav_stale` 不阻断; +10. **批次补建**:D18 确定性(`crc32` 而非内置 `hash()`); +11. **错误码映射**:§8.3 全表; +12. **纯函数约束**:convert 包源码零仓储/引擎/配置依赖(DoD 第 3 条, + 从"人工 grep"升级为断言)。 + +本文件**不依赖 DB、不依赖 conftest 夹具**——纯函数包的全部输入由测试自建。 +""" + +from __future__ import annotations + +import re +import zlib +from datetime import date, datetime, time, timedelta +from decimal import Decimal +from pathlib import Path + +import pytest + +from app.service.convert import calc, fee, lot_bootstrap, nav +from app.service.convert.errors import ( + BelowMinQty, + ConvertError, + CrossEntityNotSupported, + FeeRuleMissing, + IdempotencyUnavailable, + InsufficientShares, + LotConflict, + NavNotReady, + ProductNotRedeemable, + ProductNotSubscribable, + SameProduct, + TooManyLots, +) +from app.service.convert.types import FeeRule, Lot + +# ── 公共夹具数据 ───────────────────────────────────────────────────── +#: 与 PRD §5.3 示例同一交易日(也是 `06-seed-nav.sql` 的净值日期)。 +TRADE_DATE = date(2026, 9, 4) +#: PRD §4.3 / 07-seed-fee-rule.sql 的五档赎回费(22 号文 §10 下限)。 +RULES = [ + FeeRule(min_hold_days=0, max_hold_days=7, rate=Decimal("0.0150")), + FeeRule(min_hold_days=7, max_hold_days=30, rate=Decimal("0.0100")), + FeeRule(min_hold_days=30, max_hold_days=180, rate=Decimal("0.0050")), + FeeRule(min_hold_days=180, max_hold_days=365, rate=Decimal("0.0025")), + FeeRule(min_hold_days=365, max_hold_days=None, rate=Decimal("0.0000")), +] +#: 主示例两端申购费率(09-seed-org.sql:110022 债基 0.0030 → 003095 主动偏股 0.0080)。 +OUT_SUB_RATE = Decimal("0.0030") +IN_SUB_RATE = Decimal("0.0080") + + +def _lot(lot_id: str, hold_days: int, qty: str, nav: str = "1.0300") -> Lot: + """构造 `hold_days` 天前确认的批次(交易日固定为 TRADE_DATE)。""" + confirmed = datetime.combine(TRADE_DATE - timedelta(days=hold_days), time(10, 0, 0)) + return Lot( + lot_id=lot_id, + confirmed_at=confirmed, + remain_qty=Decimal(qty), + nav=Decimal(nav), + qty=Decimal(qty), + ) + + +def _fee_of(lot_allocation, hold_days: int) -> Decimal: + """按 `plan_lots` 的分配结果算单批金额与赎回费。""" + amount = calc.lot_amount(lot_allocation.qty, lot_allocation.nav) + return calc.lot_fee(amount, fee.pick_fee_rate(RULES, hold_days)) + + +# ── 1. 精度:HALF_UP 而非 HALF_EVEN ───────────────────────────────── +class TestRounding: + """所有量化点显式 `ROUND_HALF_UP`(架构风险 #3)。""" + + def test_round2_half_up_away_from_zero(self): + # Decimal 默认 ROUND_HALF_EVEN 会得到 0.00(偶数侧);HALF_UP 必须是 0.01 + assert calc.round2(Decimal("0.005")) == Decimal("0.01") + assert calc.round2(Decimal("0.015")) == Decimal("0.02") + + def test_default_context_differs_from_half_up(self): + """反向自证:若实现漏传 rounding,本用例会红。""" + from decimal import ROUND_HALF_EVEN + + banker = Decimal("0.005").quantize(Decimal("0.01"), rounding=ROUND_HALF_EVEN) + assert banker == Decimal("0.00") + assert calc.round2(Decimal("0.005")) != banker + + def test_lot_amount_2_places_half_up(self): + # 0.5 份 × 1.0001 = 0.50005 → 0.50;1.5 份 × 0.0001 之类极小值也要能舍 + assert calc.lot_amount(Decimal("1.5"), Decimal("0.0034")) == Decimal("0.01") + assert calc.lot_amount(Decimal("30000.0000"), Decimal("1.0300")) == Decimal("30900.00") + + def test_lot_fee_2_places_half_up(self): + assert calc.lot_fee(Decimal("30900.00"), Decimal("0.0050")) == Decimal("154.50") + assert calc.lot_fee(Decimal("20600.00"), Decimal("0.0150")) == Decimal("309.00") + + def test_in_qty_2_places_half_up_not_floor(self): + # v1.0 勘误:份额是 2 位四舍五入,不是 4 位向下取整 + # 50784.10 / 0.95 = 53456.9473... → 53456.95(向下取整会得 53456.94) + assert calc.in_qty(Decimal("50784.10"), Decimal("0.9500")) == Decimal("53456.95") + + def test_in_qty_rejects_non_positive_nav(self): + with pytest.raises(ValueError): + calc.in_qty(Decimal("100"), Decimal("0")) + + 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")) + assert diff == Decimal("-0.0026") + + +# ── 2. 费率分档边界 ───────────────────────────────────────────────── +class TestFeeBands: + """左闭右开 `[min, max)`;满 7 日归 7–30 档(PRD §12 I-2)。""" + + @pytest.mark.parametrize( + ("hold_days", "expected"), + [ + (0, "0.0150"), + (6, "0.0150"), # < 7 日 + (7, "0.0100"), # 满 7 日 → 进 7–30 档 + (29, "0.0100"), + (30, "0.0050"), # 满 30 日 → 进 30–180 档 + (179, "0.0050"), + (180, "0.0025"), + (364, "0.0025"), + (365, "0.0000"), + (1000, "0.0000"), # max_hold_days = NULL 表示无上限 + ], + ) + def test_band_boundaries(self, hold_days, expected): + assert fee.pick_fee_rate(RULES, hold_days) == Decimal(expected) + + def test_missing_rule_raises_instead_of_zero(self): + # 无命中绝不能静默按 0 收费(少收赎回费且不留痕) + with pytest.raises(FeeRuleMissing) as exc: + fee.pick_fee_rate([FeeRule(365, None, Decimal("0.0000"))], 3) + assert exc.value.error_code == "FEE_RULE_MISSING" + assert exc.value.status_code == 500 + + def test_subscribe_rules_are_ignored(self): + """`fee_type='subscribe'` 本期不启用,混入规则表也不得被选中。""" + rules = [ + FeeRule(0, 7, Decimal("0.8000"), fee_type="subscribe"), + FeeRule(0, 7, Decimal("0.0150"), fee_type="redeem"), + ] + assert fee.pick_fee_rate(rules, 3) == Decimal("0.0150") + + def test_overlapping_bands_take_most_specific(self): + """区间误配重叠时取 `min_hold_days` 最大者,避免"取到哪档看运气"。""" + rules = [ + FeeRule(0, 365, Decimal("0.0050")), + FeeRule(30, 180, Decimal("0.0025")), + ] + # 排序后被 max(min_hold_days) 选中,与传入顺序无关 + assert fee.pick_fee_rate(rules, 100) == Decimal("0.0025") + assert fee.pick_fee_rate(list(reversed(rules)), 100) == Decimal("0.0025") + + +# ── 3. FIFO 排序与分配 ─────────────────────────────────────────────── +class TestFifoPlan: + def test_allocates_oldest_lot_first(self): + lots = [_lot("LOT-B", 3, "20000.0000"), _lot("LOT-A", 100, "30000.0000")] + plan = calc.plan_lots(lots, Decimal("40000")) + assert [a.lot_id for a in plan.allocations] == ["LOT-A", "LOT-B"] + assert plan.allocations[0].qty == Decimal("30000.0000") + assert plan.allocations[1].qty == Decimal("10000.0000") + + def test_same_confirmed_at_tiebreak_by_lot_id(self): + """同一注册日多批次顺序不可由存储引擎决定(评审 S1)。""" + same = datetime.combine(TRADE_DATE - timedelta(days=10), time(10, 0, 0)) + lots = [ + Lot("LOT-Z", same, Decimal("100.0000"), Decimal("1.0000")), + Lot("LOT-A", same, Decimal("100.0000"), Decimal("1.0000")), + ] + plan = calc.plan_lots(lots, Decimal("150")) + assert [a.lot_id for a in plan.allocations] == ["LOT-A", "LOT-Z"] + + def test_ignores_zero_remain_qty_lots(self): + lots = [ + _lot("LOT-USED", 100, "1000.0000"), + _lot("LOT-FRESH", 3, "500.0000"), + ] + lots[0] = Lot( + lots[0].lot_id, lots[0].confirmed_at, Decimal("0.0000"), lots[0].nav + ) + plan = calc.plan_lots(lots, Decimal("500")) + assert [a.lot_id for a in plan.allocations] == ["LOT-FRESH"] + + def test_insufficient_shares(self): + lots = [_lot("LOT-A", 100, "1000.0000")] + with pytest.raises(InsufficientShares) as exc: + calc.plan_lots(lots, Decimal("1000.0001")) + assert exc.value.error_code == "INSUFFICIENT_SHARES" + + def test_requested_must_be_positive(self): + with pytest.raises(ValueError): + calc.plan_lots([_lot("LOT-A", 100, "1000")], Decimal("0")) + + def test_batch_count_equals_allocations_len(self): + lots = [_lot(f"LOT-{i}", 100 - i, "1000.0000") for i in range(3)] + plan = calc.plan_lots(lots, Decimal("2500")) + assert plan.batch_count == len(plan.allocations) == 3 + + def test_available_qty_uses_remain_qty(self): + """份额足够判据以 `Σ remain_qty` 为准,不用 `core_holding.qty`(PRD §12)。""" + lots = [_lot("LOT-A", 100, "1000.0000")] + lots[0] = Lot( + lots[0].lot_id, + lots[0].confirmed_at, + Decimal("300.0000"), + lots[0].nav, + qty=Decimal("1000.0000"), # 原始份额更大,但已扣减 + ) + plan = calc.plan_lots(lots, Decimal("300")) + assert plan.available_qty == Decimal("300.0000") + + +# ── 4. 最低持有余额处置 ────────────────────────────────────────────── +class TestMinHoldAction: + """触发条件严格为「余额 < 阈值」(非 ≤);PRD §12 I-4。""" + + def test_below_threshold_forces_full_transfer(self): + lots = [_lot("LOT-A", 100, "6000.0000")] + plan = calc.plan_lots( + lots, Decimal("5500"), Decimal("1000"), "force_transfer" + ) + assert plan.forced_full_transfer is True + assert plan.action == "force_transfer" + assert plan.actual_qty == Decimal("6000.0000") # 全转,客户指令被改变 + assert plan.requested_qty == Decimal("5500") + + def test_below_threshold_force_redeem_action(self): + lots = [_lot("LOT-A", 100, "6000.0000")] + plan = calc.plan_lots(lots, Decimal("5500"), Decimal("1000"), "force_redeem") + assert plan.action == "force_redeem" + assert plan.actual_qty == Decimal("6000.0000") + + def test_leftover_equal_threshold_not_triggered(self): + """PRD 审查例:持 6000、申请 5000、阈值 1000 → 余额恰好 1000 → 不触发。""" + lots = [_lot("LOT-A", 100, "6000.0000")] + plan = calc.plan_lots(lots, Decimal("5000"), Decimal("1000"), "force_transfer") + assert plan.forced_full_transfer is False + assert plan.action == "transfer" + assert plan.actual_qty == Decimal("5000") + + def test_zero_leftover_not_forced(self): + """实现级裁定:客户本就是清仓(余额 = 0)不得被标记为"强制"。 + + 否则响应里的 `forced_full_transfer=True` 会被前端当成 + "你的指令被系统改了",而实际转出份额与申请完全一致。 + """ + lots = [_lot("LOT-A", 100, "6000.0000")] + plan = calc.plan_lots(lots, Decimal("6000"), Decimal("1000"), "force_transfer") + assert plan.forced_full_transfer is False + assert plan.actual_qty == plan.requested_qty == Decimal("6000.0000") + + def test_zero_threshold_disables_check(self): + lots = [_lot("LOT-A", 100, "6000.0000")] + plan = calc.plan_lots(lots, Decimal("5000"), Decimal("0"), "force_transfer") + assert plan.forced_full_transfer is False + + def test_unknown_action_rejected(self): + lots = [_lot("LOT-A", 100, "6000.0000")] + with pytest.raises(ValueError): + calc.plan_lots(lots, Decimal("5500"), Decimal("1000"), "force_dance") + + +# ── 5. 补差费双口径 ───────────────────────────────────────────────── +class TestDiffFee: + CONV = Decimal("51036.50") + + def test_amount_diff_is_default_and_matches_prd(self): + # 405.05(转入端 0.8% 价外费)− 152.65(转出端 0.3% 价外费) + assert calc.diff_fee(self.CONV, OUT_SUB_RATE, IN_SUB_RATE) == Decimal("252.40") + assert calc.diff_fee( + self.CONV, OUT_SUB_RATE, IN_SUB_RATE, "amount_diff" + ) == Decimal("252.40") + + def test_rate_diff_matches_prd(self): + assert calc.diff_fee( + self.CONV, OUT_SUB_RATE, IN_SUB_RATE, "rate_diff" + ) == Decimal("253.91") + + def test_two_modes_actually_differ(self): + """两口径差约 0.6% —— 必须显式选口径,不可混用(PRD §2.1.1 / Q9)。""" + b = calc.diff_fee(self.CONV, OUT_SUB_RATE, IN_SUB_RATE, "amount_diff") + a = calc.diff_fee(self.CONV, OUT_SUB_RATE, IN_SUB_RATE, "rate_diff") + assert a != b + + @pytest.mark.parametrize("mode", ["amount_diff", "rate_diff"]) + def test_zero_when_in_rate_not_higher(self, mode): + assert calc.diff_fee(self.CONV, IN_SUB_RATE, OUT_SUB_RATE, mode) == Decimal("0.00") + assert calc.diff_fee(self.CONV, OUT_SUB_RATE, OUT_SUB_RATE, mode) == Decimal("0.00") + + def test_zero_rate_inbound_gives_zero(self): + """转入端为货基(申购费 0)→ 不补差。""" + assert calc.diff_fee(self.CONV, OUT_SUB_RATE, Decimal("0"), "amount_diff") == Decimal("0.00") + + def test_invalid_mode_rejected(self): + with pytest.raises(ValueError): + calc.diff_fee(self.CONV, OUT_SUB_RATE, IN_SUB_RATE, "whatever") + + +# ── 6. 全链自证(PRD §5.3 逐行) ───────────────────────────────────── +class TestPrdMainExample: + """主示例每个派生值都由本块数据算出(v0.9.1 · 脚本实算回填)。""" + + @pytest.fixture + def chain(self): + lots = [_lot("LOT-A", 100, "30000.0000"), _lot("LOT-B", 3, "20000.0000")] + plan = calc.plan_lots(lots, Decimal("50000")) + out_amount = sum( + (calc.lot_amount(a.qty, a.nav) for a in plan.allocations), Decimal("0") + ) + redeem_fee = sum( + ( + _fee_of(a, calc.hold_days(TRADE_DATE, a.confirmed_at)) + for a in plan.allocations + ), + Decimal("0"), + ) + return plan, out_amount, redeem_fee + + def test_per_lot_breakdown(self, chain): + plan, _, _ = chain + assert [a.qty for a in plan.allocations] == [ + Decimal("30000.0000"), + Decimal("20000.0000"), + ] + assert calc.hold_days(TRADE_DATE, plan.allocations[0].confirmed_at) == 100 + assert calc.hold_days(TRADE_DATE, plan.allocations[1].confirmed_at) == 3 + assert _fee_of(plan.allocations[0], 100) == Decimal("154.50") + assert _fee_of(plan.allocations[1], 3) == Decimal("309.00") + + def test_amounts_chain(self, chain): + plan, out_amount, redeem_fee = chain + assert out_amount == Decimal("51500.00") + assert redeem_fee == Decimal("463.50") # 逐批舍入后求和 + conv = calc.convert_amount(out_amount, redeem_fee) + assert conv == Decimal("51036.50") + + diff_b = calc.diff_fee(conv, OUT_SUB_RATE, IN_SUB_RATE, "amount_diff") + assert diff_b == Decimal("252.40") + in_amount = calc.convert_amount(conv, diff_b) + assert in_amount == Decimal("50784.10") + assert calc.in_qty(in_amount, Decimal("0.9500")) == Decimal("53456.95") + assert ( + calc.rounding_diff(in_amount, Decimal("0.9500"), Decimal("53456.95")) + == Decimal("-0.0026") + ) + + def test_three_amount_fields_are_distinct(self, chain): + """`out_amount` / `convert_amount` / `in_amount` 三者不可混用(PRD M-4)。""" + _, out_amount, redeem_fee = chain + conv = calc.convert_amount(out_amount, redeem_fee) + in_amount = calc.convert_amount(conv, Decimal("252.40")) + assert out_amount != conv != in_amount + assert out_amount == Decimal("51500.00") + assert conv == Decimal("51036.50") + assert in_amount == Decimal("50784.10") + + def test_same_rate_control_group(self, chain): + """同费率对照(110022 0.0030 → 510300 0.0030):补差 0,份额 53722.63。""" + _, out_amount, redeem_fee = chain + conv = calc.convert_amount(out_amount, redeem_fee) + diff = calc.diff_fee(conv, OUT_SUB_RATE, OUT_SUB_RATE, "amount_diff") + assert diff == Decimal("0.00") + in_amount = calc.convert_amount(conv, diff) + assert in_amount == conv == Decimal("51036.50") + assert calc.in_qty(in_amount, Decimal("0.9500")) == Decimal("53722.63") + + +# ── 7. 持有期 ─────────────────────────────────────────────────────── +class TestHoldDays: + def test_excludes_application_day(self): + """(交易日 − 确认日).days,不含申请日:昨日确认今天申请 = 1 天。""" + assert calc.hold_days(date(2026, 9, 4), datetime(2026, 9, 3, 10)) == 1 + + def test_t_plus_one_confirmation_is_one_day_less(self): + """转换转入批次以 T+1 为确认日 → 同一交易日下持有期比"自 T 起算"少 1 天。 + + 这是更贴近真实的做法(真实持有期自确认日起算),费率档因此更严(PRD B-5)。 + """ + trade = date(2026, 9, 4) + from_t = calc.hold_days(trade, datetime(2026, 8, 25, 10)) + from_t1 = calc.hold_days(trade, datetime(2026, 8, 26, 10)) + assert from_t == 10 + assert from_t1 == 9 + + def test_full_seven_days_lands_in_second_band(self): + assert calc.hold_days(date(2026, 9, 10), datetime(2026, 9, 3, 10)) == 7 + assert fee.pick_fee_rate(RULES, 7) == Decimal("0.0100") + + def test_rejects_none(self): + with pytest.raises(ValueError): + calc.hold_days(None, datetime(2026, 9, 3, 10)) + with pytest.raises(ValueError): + calc.hold_days(date(2026, 9, 4), None) + + +# ── 8. 净值口径 ───────────────────────────────────────────────────── +class TestNav: + def test_missing_nav_raises_503(self): + with pytest.raises(NavNotReady) as exc: + nav.ensure_nav_ready(None, product_id="PROD-X") + assert exc.value.status_code == 503 + assert exc.value.error_code == "NAV_NOT_READY" + + def test_present_nav_passes(self): + nav.ensure_nav_ready(date(2026, 9, 4), product_id="PROD-110022") + + @pytest.mark.parametrize( + ("nav_date", "expected"), + [("2026-09-04", False), ("2026-09-01", False), ("2026-08-31", True)], + ) + def test_stale_boundary_is_strictly_greater(self, nav_date, expected): + # 阈值 3 天:距交易日恰好 3 天不算过期(PRD §2.2「超过此值」) + assert nav.is_stale(date.fromisoformat(nav_date), TRADE_DATE, 3) is expected + + def test_is_stale_rejects_missing_nav(self): + """"无净值"必须走 503,不得混进 stale 分支(两者处置完全不同)。""" + with pytest.raises(ValueError): + nav.is_stale(None, TRADE_DATE, 3) + + def test_evaluate_nav_returns_stale_flag(self): + assert nav.evaluate_nav(date(2026, 9, 4), TRADE_DATE, 3) is False + assert nav.evaluate_nav(date(2026, 8, 1), TRADE_DATE, 3) is True + + def test_evaluate_nav_raises_when_missing(self): + with pytest.raises(NavNotReady): + nav.evaluate_nav(None, TRADE_DATE, 3, product_id="PROD-X") + + +# ── 9. 批次兜底补建(D8 / D18) ────────────────────────────────────── +class TestBootstrapLots: + HOLDING = { + "customer_id": "CUST-9527", + "product_id": "PROD-110022", + "qty": Decimal("80000.0000"), + "cost_amount": Decimal("82400.00"), + "market_value": Decimal("82400.00"), + "pnl_pct": Decimal("0.0000"), + "as_of": date(2026, 9, 4), + } + + def test_single_lot_equals_holding_qty(self): + lots = lot_bootstrap.bootstrap_lots(self.HOLDING) + assert len(lots) == 1 + assert lots[0].remain_qty == lots[0].qty == Decimal("80000.0000") + # Σ remain_qty == core_holding.qty(08 种子的同一不变量) + assert sum(lot.remain_qty for lot in lots) == self.HOLDING["qty"] + + def test_deterministic_across_calls(self): + """同一持仓重复补建结果完全一致 —— D18 同源断言的立足点。""" + first = lot_bootstrap.bootstrap_lots(self.HOLDING) + for _ in range(50): + assert lot_bootstrap.bootstrap_lots(self.HOLDING) == first + + def test_offset_uses_crc32_not_builtin_hash(self): + """必须用 `zlib.crc32`:内置 `hash()` 受 PYTHONHASHSEED 随机化, + 会让 gateway 与 rebuild_lots.py(两个进程)算出不同 confirmed_at。""" + key = "CUST-9527|PROD-110022" + expected = lot_bootstrap.BOOTSTRAP_OFFSET_DAYS[ + zlib.crc32(key.encode("utf-8")) % len(lot_bootstrap.BOOTSTRAP_OFFSET_DAYS) + ] + assert lot_bootstrap.offset_for("CUST-9527", "PROD-110022") == expected + + def test_lot_id_is_deterministic_and_within_column_width(self): + lot_id = lot_bootstrap.bootstrap_lot_id("CUST-9527", "PROD-110022") + assert lot_id == lot_bootstrap.bootstrap_lot_id("CUST-9527", "PROD-110022") + assert len(lot_id) <= 64 + # 超长组合不抛异常(D8:兜底补建不跳过、不阻断) + long_id = lot_bootstrap.bootstrap_lot_id("C" * 64, "P" * 64) + assert len(long_id) <= 64 + + def test_nav_approximates_cost_per_share(self): + lots = lot_bootstrap.bootstrap_lots(self.HOLDING) + # 82400.00 / 80000 = 1.0300(4 位 HALF_UP) + assert lots[0].nav == Decimal("1.0300") + + def test_offset_covers_all_five_bands_across_customers(self): + """错开规则必须真能覆盖 5 档,否则分档分支在兜底路径上无数据。""" + offsets = { + lot_bootstrap.offset_for(f"CUST-{i:04d}", "PROD-110022") for i in range(1, 60) + } + assert offsets == set(lot_bootstrap.BOOTSTRAP_OFFSET_DAYS) + + def test_forced_offset_pins_band(self): + lots = lot_bootstrap.bootstrap_lots(self.HOLDING, offset_days=3) + assert calc.hold_days(TRADE_DATE, lots[0].confirmed_at) == 3 + assert fee.pick_fee_rate(RULES, 3) == Decimal("0.0150") + + def test_zero_qty_holding_returns_no_lot(self): + holding = dict(self.HOLDING, qty=Decimal("0.0000")) + assert lot_bootstrap.bootstrap_lots(holding) == [] + + def test_missing_as_of_rejected(self): + holding = dict(self.HOLDING, as_of=None) + with pytest.raises(ValueError): + lot_bootstrap.bootstrap_lots(holding) + + +# ── 10. 错误码映射(架构 §8.3) ────────────────────────────────────── +ERROR_CASES = [ + (ProductNotRedeemable, 400, "PRODUCT_NOT_REDEEMABLE"), + (ProductNotSubscribable, 400, "PRODUCT_NOT_SUBSCRIBABLE"), + (InsufficientShares, 400, "INSUFFICIENT_SHARES"), + (BelowMinQty, 400, "BELOW_MIN_QTY"), + (SameProduct, 400, "SAME_PRODUCT"), + (CrossEntityNotSupported, 400, "CROSS_ENTITY_NOT_SUPPORTED"), + (LotConflict, 409, "LOT_CONFLICT"), + (NavNotReady, 503, "NAV_NOT_READY"), + (IdempotencyUnavailable, 503, "IDEMPOTENCY_UNAVAILABLE"), + (FeeRuleMissing, 500, "FEE_RULE_MISSING"), +] + + +class TestErrorMapping: + @pytest.mark.parametrize(("cls", "status", "code"), ERROR_CASES) + def test_status_and_code(self, cls, status, code): + err = cls() + assert isinstance(err, ConvertError) + assert err.status_code == status + assert err.error_code == code + assert err.message # 必须有可读 message,不能是空串 + + def test_too_many_lots_carries_batch_context(self): + err = TooManyLots(batch_count=260, max_lots=200) + assert err.status_code == 400 + assert err.error_code == "TOO_MANY_LOTS" + assert err.extra == {"batch_count": 260, "max_lots": 200} + assert "260" in err.message and "200" in err.message + + def test_ensure_batch_limit_raises_over_limit(self): + lots = [_lot(f"LOT-{i}", 100 - i, "1000.0000") for i in range(5)] + plan = calc.plan_lots(lots, Decimal("5000")) + calc.ensure_batch_limit(plan, 5) # 恰好等于上限 → 放行 + with pytest.raises(TooManyLots) as exc: + calc.ensure_batch_limit(plan, 4) + assert exc.value.batch_count == 5 + + def test_batch_limit_checked_after_planning(self): + """先规划再判上限:异常里带的是**实际所需**批次数,而非上限本身(§8.3)。""" + lots = [_lot(f"LOT-{i}", 100 - i, "1000.0000") for i in range(7)] + plan = calc.plan_lots(lots, Decimal("7000")) + with pytest.raises(TooManyLots) as exc: + calc.ensure_batch_limit(plan, 3) + assert exc.value.batch_count == 7 + + +# ── 11. 纯函数约束(DoD 第 3 条) ──────────────────────────────────── +CONVERT_DIR = Path(__file__).resolve().parents[1] / "app" / "service" / "convert" + +#: import 行中一旦出现即说明纯函数包被 IO 污染("不查库、不碰 SQL"是 D1/D18 的前提)。 +#: 只比对 **import 语句**:docstring 里提到 `core_ro.get_nav_as_of(...)` 是在说明 +#: "输入由调用方取好再传入",属合法引用,不应误判。 +FORBIDDEN_IMPORT_PREFIXES = ( + "sqlalchemy", + "app.repository", + "app.config", + "app.utils.db", + "app.gateway", +) + +#: 全文禁用符号(连 import 都不允许,出现在任何位置都是漏了依赖边界)。 +FORBIDDEN_SYMBOLS = ("get_engine", "create_engine") + +_IMPORT_RE = re.compile(r"^\s*(?:from|import)\s+([A-Za-z_][\w.]*)", re.M) + + +class TestPurity: + @pytest.mark.parametrize("path", sorted(CONVERT_DIR.glob("*.py")), ids=lambda p: p.name) + def test_no_repository_or_engine_dependency(self, path: Path): + source = path.read_text(encoding="utf-8") + imported = _IMPORT_RE.findall(source) + bad_imports = [ + name for name in imported if name.startswith(FORBIDDEN_IMPORT_PREFIXES) + ] + bad_symbols = [sym for sym in FORBIDDEN_SYMBOLS if sym in source] + assert not bad_imports, f"{path.name} 引入了 IO 依赖:{bad_imports}" + assert not bad_symbols, f"{path.name} 出现引擎符号:{bad_symbols}" + + @pytest.mark.parametrize("path", sorted(CONVERT_DIR.glob("*.py")), ids=lambda p: p.name) + def test_imports_only_from_stdlib_or_own_package(self, path: Path): + source = path.read_text(encoding="utf-8") + allowed_roots = ( + "app.service.convert", + "__future__", + # 唯一允许的包外依赖:复用 `register_error_handlers` 的统一错误体出口 + # (架构 §8.3「不新增异常出口、不改中间件」)。它是纯类型基类,不触 IO。 + "app.utils.exceptions", + ) + stdlib = {"dataclasses", "datetime", "decimal", "typing", "zlib", "re"} + external = [ + name + for name in _IMPORT_RE.findall(source) + if not name.startswith(allowed_roots) and name.split(".")[0] not in stdlib + ] + assert not external, f"{path.name} 引入了包外依赖:{external}" + + def test_lot_bootstrap_avoids_builtin_hash(self): + """内置 `hash` 受 PYTHONHASHSEED 随机化,会破坏 D18 的跨进程同源。""" + source = (CONVERT_DIR / "lot_bootstrap.py").read_text(encoding="utf-8") + assert "hash(" not in source diff --git a/tests/test_db.py b/tests/test_db.py index 1fb84b0..0f93da1 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -2,11 +2,31 @@ create_engine 与缓存字典均 monkeypatch 替换,不触网;验证 dispose_engines 对每个缓存 Engine 显式调用 dispose() 并清空缓存(不再是仅 cache_clear)。 + +另含 T-0 门禁用例(sqlite 结构对齐 + 门禁自身有效性反向验证)、T-0b 引擎角色 +隔离用例(同库不同角色 / 账号未配置回退 / 非法角色报错),以及 3 条真 MySQL +权限断言(`xh_core_ro` 只读 · `audit_log` 只追加 · `xh_core_rw` 限 4 表)—— +后者在账号未配置或真库不可达时自动 skip,不阻塞单测基线。 """ +import pytest +from sqlalchemy import text + +from _ddl import EXPECTED_CORE_HOLDING_COLUMNS, create_sqlite_engine from app.utils import db +def _unique_column_sets(conn, table: str) -> set[tuple[str, ...]]: + """PRAGMA 取某表全部唯一约束的列组合(sqlite 自动索引 unique 字段为 1/'u')。""" + result: set[tuple[str, ...]] = set() + for row in conn.execute(text(f"PRAGMA index_list({table})")): + # 行结构:seq, name, unique, origin, partial + if row[2]: + cols = tuple(r[2] for r in conn.execute(text(f"PRAGMA index_info({row[1]})"))) + result.add(cols) + return result + + class SpyEngine: def __init__(self): self.dispose_calls = 0 @@ -52,3 +72,201 @@ def test_dispose_engines_calls_dispose_and_clears_cache(monkeypatch): def test_dispose_engines_on_empty_cache_is_noop(monkeypatch): _patch(monkeypatch) db.dispose_engines() # 不抛异常即可 + + +# ---------- T-0 门禁:sqlite 结构对齐(R1)---------- + + +def test_core_holding_columns(sqlite_engine): + """sqlite core_holding 与 MySQL scripts/core/01-ddl.sql:132-145 逐列对齐。 + + convert 的首个阻断项正是列名失配(sqlite 曾为 quantity、MySQL 为 qty), + 本用例把这类失配钉死在单测层,不让它留到集成测试期。 + """ + with sqlite_engine.connect() as conn: + info = conn.execute(text("PRAGMA table_info(core_holding)")).all() + columns = {row[1] for row in info} + assert EXPECTED_CORE_HOLDING_COLUMNS <= columns, ( + f"core_holding 缺列:{sorted(EXPECTED_CORE_HOLDING_COLUMNS - columns)}" + ) + primary_key = {row[1] for row in info if row[5] > 0} + assert primary_key == {"id"} + assert ("customer_id", "product_id") in _unique_column_sets(conn, "core_holding") + + +def test_core_product_nav_columns(sqlite_engine): + """core_product_nav 存在且含 (product_id, nav_date) 唯一约束(R-f)。""" + with sqlite_engine.connect() as conn: + tables = { + row[0] + for row in conn.execute(text("SELECT name FROM sqlite_master WHERE type = 'table'")) + } + assert "core_product_nav" in tables + columns = {row[1] for row in conn.execute(text("PRAGMA table_info(core_product_nav)"))} + assert {"id", "product_id", "nav", "daily_chg_pct", "nav_date"} <= columns + assert ("product_id", "nav_date") in _unique_column_sets(conn, "core_product_nav") + + +def test_ddl_alignment_guard_fails_when_column_missing(monkeypatch): + """反向验证门禁自身有效:抽掉 qty 列 → 建库即 AssertionError。""" + import _ddl + + broken = dict(_ddl.SQLITE_TABLES) + broken["core_holding"] = """ + CREATE TABLE core_holding ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id VARCHAR(64) NOT NULL, product_id VARCHAR(64) NOT NULL, + market_value DECIMAL NOT NULL, UNIQUE (customer_id, product_id)) + """ + monkeypatch.setattr(_ddl, "SQLITE_TABLES", broken) + with pytest.raises(AssertionError, match="core_holding 缺列"): + create_sqlite_engine() + + +# ---------- T-0b:引擎角色隔离(D20 · 缓存键 (库名, 角色))---------- + + +def test_get_engine_role_isolation(monkeypatch): + """同库不同角色 → 不同 engine(缓存键由「库名」改为「(库名, 角色)」)。""" + created = _patch(monkeypatch) + ro = db.get_engine("db_a", "ro") + rw = db.get_engine("db_a", "rw") + admin = db.get_engine("db_a", "admin") + assert ro is not rw + assert rw is not admin + assert ro is not admin + assert db.get_engine("db_a", "ro") is ro # 同角色仍复用 + assert len(created) == 3 + + +def test_get_engine_role_accounts_and_fallback(monkeypatch): + """ro/rw 各用自己账号;账号留空 → 回退 mysql_user(渐进启用,不阻塞开发)。""" + from app.config.settings import settings + + for name, value in ( + ("mysql_host", "127.0.0.1"), + ("mysql_port", 3306), + ("mysql_user", "root"), + ("mysql_password", "rootpw"), + ("mysql_database", "jinrong_agent"), + ("mysql_core_database", "jinrong_core"), + ("mysql_core_ro_user", ""), + ("mysql_core_ro_password", ""), + ("mysql_core_rw_user", ""), + ("mysql_core_rw_password", ""), + ("mysql_agent_user", ""), + ("mysql_agent_password", ""), + ): + monkeypatch.setattr(settings, name, value) + created = _patch(monkeypatch) + + db.get_engine("jinrong_core", "ro") + db.get_engine("jinrong_core", "rw") + db.get_engine("jinrong_agent", "rw") + assert len(created) == 3 + # 三组账号均未配置 → 全部回退 mysql_user(与单账号时代行为一致) + assert all("root:rootpw@" in url for url in created) + + # 配置账号后:各角色取各自账号 + db.dispose_engines() + created.clear() + for name, value in ( + ("mysql_core_ro_user", "xh_core_ro"), + ("mysql_core_ro_password", "p1"), + ("mysql_core_rw_user", "xh_core_rw"), + ("mysql_core_rw_password", "p2"), + ("mysql_agent_user", "xh_agent_rw"), + ("mysql_agent_password", "p3"), + ): + monkeypatch.setattr(settings, name, value) + + db.get_engine("jinrong_core", "ro") + db.get_engine("jinrong_core", "rw") + db.get_engine("jinrong_agent", "rw") + assert "xh_core_ro:p1@" in created[0] + assert "xh_core_rw:p2@" in created[1] + assert "xh_agent_rw:p3@" in created[2] + assert "/jinrong_core?" in created[0] + assert "/jinrong_core?" in created[1] + assert "/jinrong_agent?" in created[2] + # agent 库无独立只读账号 → ro 亦走 mysql_agent_user(不静默落到 root), + # 但缓存键 (库, 角色) 不同,因此仍是独立 engine + agent_ro = db.get_engine("jinrong_agent", "ro") + assert "xh_agent_rw:p3@" in created[3] + assert agent_ro is not db.get_engine("jinrong_agent", "rw") + + +def test_get_engine_rejects_unknown_role(monkeypatch): + """非法角色立即报错:防拼写错误静默取到默认账号。""" + _patch(monkeypatch) + with pytest.raises(ValueError, match="unknown db role"): + db.get_engine("db_a", "readonly") + + +# ---------- T-0b:真 MySQL 权限断言(账号未配置则 skip)---------- +# 前置:管理员执行 scripts/core/00-grant.sql,把 3 组账号写入 .env。 +# 未配置时不阻塞单测基线(本文件默认全绿)。 + + +def _role_engine_or_skip(role: str, database_setting: str, user_setting: str): + """取角色 engine;账号未配置或真库不可达 → skip 并给出 bootstrap 提示。""" + from app.config.settings import settings + from app.utils.db import get_engine + + if not getattr(settings, user_setting): + pytest.skip( + f"未配置 {user_setting}:先执行 scripts/core/00-grant.sql 并写入 .env" + ) + engine = get_engine(getattr(settings, database_setting), role) + try: + with engine.connect() as conn: + conn.execute(text("SELECT 1")) + except Exception as exc: + pytest.skip(f"真 MySQL 不可用({exc})") + return engine + + +def test_core_ro_account_is_readonly(): + """xh_core_ro 只有 SELECT:INSERT 被数据库拒绝(Core 只读 DB 级强制)。""" + from sqlalchemy.exc import SQLAlchemyError + + engine = _role_engine_or_skip("ro", "mysql_core_database", "mysql_core_ro_user") + with pytest.raises(SQLAlchemyError) as exc: + with engine.begin() as conn: + conn.execute( + text( + "INSERT INTO core_trade (trade_id, customer_id, product_id, trade_type," + " amount, trade_status, traded_at)" + " VALUES ('TRD-TEST-RO-DENY', '__x__', '__x__', 'subscribe', 1," + " 'confirmed', NOW())" + ) + ) + assert "denied" in str(exc.value).lower() + + +def test_audit_log_append_only(): + """audit_log 只允许追加:UPDATE / DELETE 均被拒(审计红线 DB 级强制)。""" + from sqlalchemy.exc import SQLAlchemyError + + engine = _role_engine_or_skip("rw", "mysql_database", "mysql_agent_user") + for stmt in ( + "UPDATE audit_log SET decision = 'tampered' WHERE id = -1", + "DELETE FROM audit_log WHERE id = -1", + ): + with pytest.raises(SQLAlchemyError) as exc: + with engine.begin() as conn: + conn.execute(text(stmt)) + assert "denied" in str(exc.value).lower(), stmt + + +def test_core_rw_scope(): + """xh_core_rw 限 4 表:对第 5 张表 core_product 的写被拒(最小权限)。""" + from sqlalchemy.exc import SQLAlchemyError + + engine = _role_engine_or_skip("rw", "mysql_core_database", "mysql_core_rw_user") + with pytest.raises(SQLAlchemyError) as exc: + with engine.begin() as conn: + conn.execute( + text("UPDATE core_product SET product_name = '__x__' WHERE product_id = '__x__'") + ) + assert "denied" in str(exc.value).lower()