2026-09-10 11:29:55 +08:00
|
|
|
|
# -*- coding: utf-8 -*-
|
2026-09-10 14:45:55 +08:00
|
|
|
|
"""基金转换主示例实算(T-2b · PRD §5.3 数值来源 · 禁止手算)。
|
2026-09-10 11:29:55 +08:00
|
|
|
|
|
2026-09-10 14:45:55 +08:00
|
|
|
|
**为什么必须调用生产 `app.service.convert.calc` 而不是脚本内再写一套公式**
|
2026-09-10 11:29:55 +08:00
|
|
|
|
|
2026-09-10 14:45:55 +08:00
|
|
|
|
v0.7 曾两次因「手算与口径不符」被外部审查质疑;而"脚本自己实现一份公式"
|
|
|
|
|
|
是同一类问题的变体——**两处公式必然漂移**(生产 `calc.py` 改了、脚本没改,
|
|
|
|
|
|
PRD 里的数字就变成假证据)。这与 D18(批次补建规则只留一个副本)是同一条纪律。
|
|
|
|
|
|
|
|
|
|
|
|
故本脚本:
|
|
|
|
|
|
1. **只做输入准备与输出打印**,全部计算走 `calc` / `fee` 的生产函数;
|
|
|
|
|
|
2. 打印后与 PRD §5.3 的**期望值逐项比对**,不一致即 `exit 1`
|
|
|
|
|
|
—— 这样它同时是一道「PRD 数字 ↔ 生产口径」一致性门禁。
|
2026-09-10 11:29:55 +08:00
|
|
|
|
|
|
|
|
|
|
运行:<托管 Python> scripts/dev/calc_convert_demo.py
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-09-10 14:45:55 +08:00
|
|
|
|
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",
|
|
|
|
|
|
}
|
2026-09-10 11:29:55 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 14:45:55 +08:00
|
|
|
|
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),
|
|
|
|
|
|
)
|
2026-09-10 11:29:55 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 14:45:55 +08:00
|
|
|
|
def compute() -> dict[str, str]:
|
|
|
|
|
|
"""跑一遍主示例全链,返回**结果字典**(键与 EXPECTED 对齐)。"""
|
|
|
|
|
|
# —— 输入:跨 2 个批次、持有期不同 → 费率不同(30000 份满 30 日 / 20000 份不足 7 日)
|
2026-09-10 11:29:55 +08:00
|
|
|
|
lots = [
|
2026-09-10 14:45:55 +08:00
|
|
|
|
_lot("LOT-DEMO-01", 100, "30000.0000", NAV_OUT),
|
|
|
|
|
|
_lot("LOT-DEMO-02", 3, "20000.0000", NAV_OUT),
|
2026-09-10 11:29:55 +08:00
|
|
|
|
]
|
|
|
|
|
|
|
2026-09-10 14:45:55 +08:00
|
|
|
|
plan = calc.plan_lots(lots, REQUESTED_QTY)
|
|
|
|
|
|
result: dict[str, str] = {}
|
|
|
|
|
|
|
|
|
|
|
|
# —— 逐批:先各自 2 位舍入、再求和(PRD §12「舍入顺序」)——
|
2026-09-10 11:29:55 +08:00
|
|
|
|
out_amount = Decimal("0")
|
|
|
|
|
|
redeem_fee = Decimal("0")
|
2026-09-10 14:45:55 +08:00
|
|
|
|
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}"
|
|
|
|
|
|
)
|
2026-09-10 11:29:55 +08:00
|
|
|
|
|
2026-09-10 14:45:55 +08:00
|
|
|
|
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)
|
2026-09-10 11:29:55 +08:00
|
|
|
|
|
2026-09-10 14:45:55 +08:00
|
|
|
|
# —— 口径 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))
|
2026-09-10 11:29:55 +08:00
|
|
|
|
|
2026-09-10 14:45:55 +08:00
|
|
|
|
# —— 口径 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)
|
2026-09-10 11:29:55 +08:00
|
|
|
|
|
2026-09-10 14:45:55 +08:00
|
|
|
|
# —— 同费率对照(两端均为 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()
|
2026-09-10 11:29:55 +08:00
|
|
|
|
|
|
|
|
|
|
print()
|
2026-09-10 14:45:55 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-09-10 11:29:55 +08:00
|
|
|
|
print()
|
2026-09-10 14:45:55 +08:00
|
|
|
|
print(f"✅ 全部 {len(EXPECTED)} 项与 PRD §5.3 一致(口径 B in_qty=53456.95)")
|
|
|
|
|
|
return 0
|
2026-09-10 11:29:55 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
2026-09-10 14:45:55 +08:00
|
|
|
|
try: # Windows 控制台默认 cp936,避免打印中文报错
|
|
|
|
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
|
|
|
|
except Exception: # noqa: BLE001 - 环境不支持时忽略,不影响计算
|
|
|
|
|
|
pass
|
|
|
|
|
|
raise SystemExit(main())
|