"""基金转换金额与份额口径(架构 §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", ]