T-0 / T-0b(门禁 · 2026-09-10) - T-0:sqlite 与 MySQL 结构对齐 —— core_holding 统一为 qty/cost_amount/as_of/pnl_pct + PK + UNIQUE(customer_id, product_id);补 core_product_nav;新增建库自校验 _assert_ddl_aligned()(R-g);test_db.py 增 3 条门禁用例(含反向验证门禁失效) - T-0b:DB 账号分离(D20)—— 新增 scripts/core/00-grant.sql(三账号逐表授权); settings.py 增 3 组账号;db.py 改 get_engine(db, role),缓存键改为 (库名, 角色), 账号未配置回退单账号;core_ro→ro / gateway_repository→rw / risk·session_repository→rw; tests/conftest.py 四处显式 role="admin"(R-e) T-1(数据层) - scripts/core/01-ddl.sql:新建 core_fee_rule / core_share_lot / core_convert_lot_detail; core_trade 加 convert_group_id + idx_convert_group;core_product 加 8 列 + fee_rate 补 COMMENT - 新增 07-seed-fee-rule.sql(赎回费 5 档 × 14 产品,按 22 号文 §10)/ 08-seed-share-lot.sql (58 行持仓 → 61 行批次,Σ remain_qty 恒等于 qty)/ 09-seed-org.sql(管理人 + TA + 申购费率 + 最低持有余额,v1.1 按「管理人全产品线」重排) - reset.ps1 追加 07/08/09;02-mysql-agent专用.sql 追加 risk_convert_detail - tests/_ddl.py 同步 4 表 + 新增 REQUIRED_CONVERT_TABLES 建库门禁 - 新增 scripts/dev/verify_convert_seed.py(pymysql 等价 reset 流程 + 8 条 DoD 断言, 含断言 ⑧「费率档 ↔ product_type 匹配」,越档即 FAIL) T-2 / T-2b(纯函数包 + 示例实算回填) - 新增 app/service/convert/ 7 文件:__init__ / types / calc / fee / nav / lot_bootstrap / errors (纯函数,不查库、不碰 SQL;所有量化显式 ROUND_HALF_UP;lot_bootstrap 用 zlib.crc32 保证 D18 跨进程同源) - 新增 tests/test_convert_calc.py 93 用例(12 类:HALF_UP 反向自证 / 分档边界 / FIFO 含同 confirmed_at 兜底 / 双口径 / 强制全转与强制赎回 / PRD §5.3 全链自证 / 纯函数零 IO 依赖断言) - 重写 scripts/dev/calc_convert_demo.py:去掉脚本内公式副本,改为调用生产 calc.py, 末尾与 PRD §5.3 逐项比对(不一致即退出码 1),兼作一致性门禁 验证 - pytest 609 passed / 3 skipped(516 → +93,零回归) - verify_convert_seed.py 8/8 PASS;calc_convert_demo.py 15/15 与 PRD §5.3 一致 文档:PRD v0.9.1(费率分类修正)· 架构 §7 签名回填 / §8.3 错误码注 / §15 T-2 完成 · 开发计划 §1.5 新增 R-h + §4.2·§4.3 执行记录 · AGENTS.md · docs/memory
66 lines
2.5 KiB
Python
66 lines
2.5 KiB
Python
"""持有期 → 赎回费率查表(架构 §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"]
|