Files
group_xinghuo_jinrong/app/service/convert/fee.py
T

66 lines
2.5 KiB
Python
Raw Normal View History

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