# -*- 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, 4) #: 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)。 NAV_OUT = Decimal("1.0300") # PROD-110022 稳健债基 A(bond) NAV_IN = Decimal("0.9500") # PROD-003095 医药健康精选(stock / 主动偏股型) 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 期望值 —— 实算结果必须逐项命中。 EXPECTED = { "批次 1 amount": "30900.00", "批次 1 fee": "154.50", "批次 2 amount": "20600.00", "批次 2 fee": "309.00", "out_amount": "51500.00", "redeem_fee": "463.50", "convert_amount": "51036.50", "diff_fee(B)": "252.40", "in_amount": "50784.10", "in_qty(B)": "53456.95", "rounding_diff": "-0.0026", "diff_fee(A)": "253.91", "in_qty(A)": "53455.36", "同费率 diff_fee": "0.00", "同费率 in_qty": "53722.63", } 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=53456.95)") return 0 if __name__ == "__main__": try: # Windows 控制台默认 cp936,避免打印中文报错 sys.stdout.reconfigure(encoding="utf-8") except Exception: # noqa: BLE001 - 环境不支持时忽略,不影响计算 pass raise SystemExit(main())