Files
group_xinghuo_jinrong/scripts/dev/calc_convert_demo.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

173 lines
6.9 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, 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())