Files
group_xinghuo_jinrong/app/service/convert/fee.py
T
GaoYiYuan_0626 c5182f1910 feat(convert): 基金转换 T-0/T-0b 门禁 + T-1 数据层 + T-2/T-2b 纯函数与实算回填
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
2026-09-10 14:45:55 +08:00

66 lines
2.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""持有期 → 赎回费率查表(架构 §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"]