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