"""持有期 → 赎回费率查表(架构 §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"]