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

275 lines
12 KiB
Python
Raw Normal View History

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