Files
group_xinghuo_jinrong/scripts/dev/calc_convert_demo.py
T
GaoYiYuan_0626 f056fcd007 基金转换 T+1 模型:T-1~T-6 落地(DDL/数据层/语义收窄/锁/受理事务)
AIcoding 第 5 步 todo 开发(开发计划 v2.0)前半段:

- T-1 3 张新表 DDL(core_convert_request 6 态 ENUM / core_trade_calendar /
  core_share_rule)+ 种子 + sqlite 单一事实源同步 + 列清单断言
- T-2/T-2b calc 扩展(product_round/redeem_amount/partial_qty)+ 真实净值实算回填
- T-3 convert_request_repository(6 态 + 条件 UPDATE 守卫)+ core_ro 三读方法
  + share_lot_repository.available_qty_with_inflight(R-3 在途占用推导)
- T-4 convert_repository.sync_mirror 成为 risk_convert_detail 唯一进度镜像写入口
  (旧三方法标 Deprecated,T-7 后删)
- T-5 locks.py 锁键构造器 convert_req_lock_key / convert_confirm_lock_key
- T-6 convert_service.accept_convert 受理事务(八步:锁→幂等→校验→受理日顺延
  →在途占用校验→落单→镜像+审计→202;不扣份额/不折算/不写流水)
  + tests/test_convert_accept.py(16 用例)
  + scripts/dev/verify_convert_accept.py(真库 36/36 一致)
  + trading_calendar.py 纯函数包(R-5)+ 21 用例

T-6 真库实测暴露并修复:confirm_eta 在日历数据边界抛 ValueError,会让已落库
的受理单在调用方眼里变 500;改为展示性字段容错 + 单测守护。

基线:798 passed / 10 skipped,零回归。
2026-09-11 19:21:15 +08:00

175 lines
7.1 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.
# -*- coding: utf-8 -*-
"""基金转换主示例实算(T-2b · PRD §5.3 数值来源 · 禁止手算)。
**为什么必须调用生产 `app.service.convert.calc` 而不是脚本内再写一套公式**
v0.7 曾两次因「手算与口径不符」被外部审查质疑;而"脚本自己实现一份公式"
是同一类问题的变体——**两处公式必然漂移**(生产 `calc.py` 改了、脚本没改,
PRD 里的数字就变成假证据)。这与 D18(批次补建规则只留一个副本)是同一条纪律。
故本脚本:
1. **只做输入准备与输出打印**,全部计算走 `calc` / `fee` 的生产函数;
2. 打印后与 PRD §5.3 的**期望值逐项比对**,不一致即 `exit 1`
—— 这样它同时是一道「PRD 数字 ↔ 生产口径」一致性门禁。
运行:<托管 Python> scripts/dev/calc_convert_demo.py
"""
from __future__ import annotations
import sys
from datetime import date, datetime, time, timedelta
from decimal import Decimal
from pathlib import Path
# ① sys.path 引导项目根(直跑脚本时 sys.path[0] 为脚本目录;同 verify_convert_seed 先例)
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from app.service.convert import calc, fee # noqa: E402
from app.service.convert.types import FeeRule, Lot # noqa: E402
#: 与 PRD §5.3 同一交易日(也是 06-seed-nav.sql 的净值日期)。
TRADE_DATE = date(2026, 9, 9)
#: 07-seed-fee-rule.sql 的赎回费五档(22 号文 §10 下限)。
RULES = [
FeeRule(0, 7, Decimal("0.0150")),
FeeRule(7, 30, Decimal("0.0100")),
FeeRule(30, 180, Decimal("0.0050")),
FeeRule(180, 365, Decimal("0.0025")),
FeeRule(365, None, Decimal("0.0000")),
]
# 两端同属**华夏模拟基金 / TA-CN-001**(满足转换前置硬约束:同管理人 + 同 TA)。
# T-2b:净值取 06-seed-nav.sql 在 T=2026-09-09 的**真实净值**(PRD §5.3.2 声明来源,
# 禁止用虚构值——验收 20「示例与种子强绑定」)。
NAV_OUT = Decimal("1.3604") # PROD-110022 稳健债基 A(bond)@2026-09-09
NAV_IN = Decimal("1.9194") # PROD-003095 医药健康精选(stock / 主动偏股型)@2026-09-09
SUB_RATE_OUT = Decimal("0.0030") # 转出端申购费率(债基,22 号文 §8 上限 0.3%)
SUB_RATE_IN = Decimal("0.0080") # 转入端申购费率(主动偏股型,上限 0.8%)
REQUESTED_QTY = Decimal("50000.0000")
#: PRD §5.3 期望值 —— 实算结果必须逐项命中(T-2b 由生产 calc 实算回填,禁手算)。
EXPECTED = {
"批次 1 amount": "40812.00",
"批次 1 fee": "204.06",
"批次 2 amount": "27208.00",
"批次 2 fee": "408.12",
"out_amount": "68020.00",
"redeem_fee": "612.18",
"convert_amount": "67407.82",
"diff_fee(B)": "333.36",
"in_amount": "67074.46",
"in_qty(B)": "34945.54",
"rounding_diff": "-0.0049",
"diff_fee(A)": "335.36",
"in_qty(A)": "34944.49",
"同费率 diff_fee": "0.00",
"同费率 in_qty": "35119.21",
}
def _lot(lot_id: str, hold_days: int, qty: str, nav: Decimal) -> Lot:
"""构造 `hold_days` 天前确认的批次(交易日固定为 TRADE_DATE)。"""
confirmed = datetime.combine(TRADE_DATE - timedelta(days=hold_days), time(10, 0, 0))
return Lot(
lot_id=lot_id,
confirmed_at=confirmed,
remain_qty=Decimal(qty),
nav=nav,
qty=Decimal(qty),
)
def compute() -> dict[str, str]:
"""跑一遍主示例全链,返回**结果字典**(键与 EXPECTED 对齐)。"""
# —— 输入:跨 2 个批次、持有期不同 → 费率不同(30000 份满 30 日 / 20000 份不足 7 日)
lots = [
_lot("LOT-DEMO-01", 100, "30000.0000", NAV_OUT),
_lot("LOT-DEMO-02", 3, "20000.0000", NAV_OUT),
]
plan = calc.plan_lots(lots, REQUESTED_QTY)
result: dict[str, str] = {}
# —— 逐批:先各自 2 位舍入、再求和(PRD §12「舍入顺序」)——
out_amount = Decimal("0")
redeem_fee = Decimal("0")
for idx, alloc in enumerate(plan.allocations, start=1):
hold_days = calc.hold_days(TRADE_DATE, alloc.confirmed_at)
rate = fee.pick_fee_rate(RULES, hold_days)
amount = calc.lot_amount(alloc.qty, alloc.nav)
fee_amount = calc.lot_fee(amount, rate)
out_amount += amount
redeem_fee += fee_amount
result[f"批次 {idx} amount"] = str(amount)
result[f"批次 {idx} fee"] = str(fee_amount)
print(
f"批次 {idx}({alloc.lot_id}){alloc.qty} 份 / 持有 {hold_days} 天 / "
f"费率 {rate}({rate * 100}%)→ amount={amount} fee={fee_amount}"
)
convert_amount = calc.convert_amount(out_amount, redeem_fee)
result["out_amount"] = str(out_amount)
result["redeem_fee"] = str(redeem_fee)
result["convert_amount"] = str(convert_amount)
# —— 口径 B:价外法两端差(本期默认,PRD §2.1.1)——
diff_b = calc.diff_fee(convert_amount, SUB_RATE_OUT, SUB_RATE_IN, "amount_diff")
in_amount = calc.convert_amount(convert_amount, diff_b)
in_qty_b = calc.in_qty(in_amount, NAV_IN)
result["diff_fee(B)"] = str(diff_b)
result["in_amount"] = str(in_amount)
result["in_qty(B)"] = str(in_qty_b)
result["rounding_diff"] = str(calc.rounding_diff(in_amount, NAV_IN, in_qty_b))
# —— 口径 A:费率差法(配置 convert_diff_fee_mode='rate_diff' 时启用)——
diff_a = calc.diff_fee(convert_amount, SUB_RATE_OUT, SUB_RATE_IN, "rate_diff")
in_qty_a = calc.in_qty(calc.convert_amount(convert_amount, diff_a), NAV_IN)
result["diff_fee(A)"] = str(diff_a)
result["in_qty(A)"] = str(in_qty_a)
# —— 同费率对照(两端均为 0.0030)→ 补差 0,用于 PRD §9 第 19 条 ——
diff_same = calc.diff_fee(convert_amount, SUB_RATE_OUT, SUB_RATE_OUT, "amount_diff")
in_amount_same = calc.convert_amount(convert_amount, diff_same)
result["同费率 diff_fee"] = str(diff_same)
result["同费率 in_qty"] = str(calc.in_qty(in_amount_same, NAV_IN))
return result
def main() -> int:
print("=== 主示例实算(与生产 calc.py 同一套公式)===")
actual = compute()
print()
print("=== 与 PRD §5.3 期望值逐项比对 ===")
mismatches = []
for key, expected in EXPECTED.items():
got = actual.get(key, "<缺失>")
ok = got == expected
if not ok:
mismatches.append((key, expected, got))
print(f" [{'OK ' if ok else 'FAIL'}] {key:<18} 期望 {expected:<12} 实算 {got}")
if mismatches:
print()
print(f"❌ {len(mismatches)} 项不一致 —— PRD 数字或生产口径已被改动,停下查清:")
for key, expected, got in mismatches:
print(f" · {key}:期望 {expected} / 实算 {got}")
return 1
print()
print(f"✅ 全部 {len(EXPECTED)} 项与 PRD §5.3 一致(口径 B in_qty=34945.54)")
return 0
if __name__ == "__main__":
try: # Windows 控制台默认 cp936,避免打印中文报错
sys.stdout.reconfigure(encoding="utf-8")
except Exception: # noqa: BLE001 - 环境不支持时忽略,不影响计算
pass
raise SystemExit(main())