Files
group_xinghuo_jinrong/app/service/convert/calc.py
T
GaoYiYuan_0626 c5182f1910 feat(convert): 基金转换 T-0/T-0b 门禁 + T-1 数据层 + T-2/T-2b 纯函数与实算回填
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
2026-09-10 14:45:55 +08:00

275 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""基金转换金额与份额口径(架构 §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",
]