Files
group_xinghuo_jinrong/scripts/dev/verify_convert_lots.py
T
GaoYiYuan_0626 5e6fa06d03 基金转换 T-10:普通申赎批次维护(FR-C16)
让 core_trade 的普通申赎同步维护 core_share_lot 批次,为 convert 提供权威份额口径。

落地(改码 3 处 + 新增 2 个脚本)
- gateway_repository:新增 insert_share_lots / deduct_share_lots,各自单事务、走 rw 账号;
  只落已分配结果、不含分配逻辑,避免出现第二份 FIFO 口径
- share_lot_repository:__init__ 增可选 core_ro 入参,让 trade_gateway 复用同一份 FIFO 选批口径(D18)
- trade_gateway:新增 _nav_as_of / _maintain_lots,在落流水后、规则引擎前调用
  · subscribe:取 T 日(含)前最新净值 → qty = amount ÷ 净值(2 位 HALF_UP)→ 建批次(confirmed_at = T)
  · redeem:FIFO 扣减;无批次但有持仓 → D8 兜底补建后再扣;两者皆无 → 降级跳过
  · 整段 try 包住,任何异常只 warning,绝不阻断交易(R-c(1))
- 新增 scripts/core/rebuild_lots.py:按 core_holding 快照重建批次(L-7),与网关 D8 同调 bootstrap_lots;
  默认只补建无批次的持仓行(幂等),--force 才先删后建,--dry-run 只报告
- 新增 scripts/dev/verify_convert_lots.py:真库验证脚本(MySQL 8.0.46)

口径订正(联网查证 4 家管理人业务规则后)
- redeem 的 amount ÷ 净值 折算属本项目简化建模,不是行业标准
- 行业铁律是「金额申购、份额赎回」:投资者以份额申报,登记机构按 T 日净值算金额
  (睿远业务规则 §65 / 华泰保兴 §69 / 东方基金 §57 / 国投瑞银 §33;无一家公募支持按金额赎回)
- 派生风险:未知价法下 T 日净值当日不可得(T+1 公告),_nav_as_of 实取 T−1 净值,
  故此处算出的份额只是估算值
- 已记入 PRD §10.1 已知差异清单;trade_gateway 注释同步订正

验证
- pytest -q → 714 passed / 3 skipped(基线 697 加 17,零回归)
- R16 test_redeem_accepted_without_alert 零改动通过(由 R-c(1) 降级保住)
- 真库 20/20;T-6 24/24、T-7 35/35 复跑零回归;真库隔离数据零残留
- 突变验证 2 组:切断接线 → 精准 2 条红;关掉 D8 兜底 → 精准 2 条红(含 D18 同源断言)
2026-09-10 18:28:48 +08:00

340 lines
14 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.
"""T-10 真 MySQL 验证脚本:普通申赎批次维护 + `rebuild_lots.py` 实跑 + DoD 断言。
**为什么 sqlite 单测全绿还不够**
T-10 的扣减 SQL 写的是 `remain_qty = remain_qty - (:q + 0.0)`,其中
`+ 0.0` 是**为绕开 sqlite 坑**而加的(sqlite 在 UPDATE 的算术表达式里不把
TEXT 绑定参数转数值 → 不加就扣 0;见开发计划 §B.8 第 11 条)。
**该写法在 MySQL 是否同样成立,sqlite 侧证明不了** —— 真库若因字符串绑定
没被隐式转换成数值,扣减会静默失效(批次永远扣不掉 → convert 超扣)。
此外真库还能验证 sqlite 测不到的:DECIMAL(18,4) 存取精度、InnoDB 真实提交语义,
以及 `rebuild_lots.py` 的 DELETE/INSERT 在真库是否跑通。
用法:
python scripts/dev/verify_convert_lots.py # 建隔离数据 → 跑断言 → 清理
注意:
- 全部数据用 **LOTT 前缀**(客户 `CUST-LOTT` / 产品 `PROD-LOTT*`),
跑完 DELETE 干净,不碰既有种子;
- 建/清数据走 `role="admin"`(需 DELETE,R-e);**被测路径走生产同款仓储**
(`GatewayRepository` rw 写 + `CoreReadOnlyRepository` ro 读);
- 退出码 1 = 有断言不一致(供 CI / 人工判定)。
"""
from __future__ import annotations
import sys
from datetime import date, datetime
from decimal import ROUND_HALF_UP, Decimal
from pathlib import Path
from sqlalchemy import text
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from app.config.settings import settings # noqa: E402
from app.gateway.gateway_repository import GatewayRepository # noqa: E402
from app.gateway.trade_gateway import _maintain_lots # noqa: E402
from app.repository.core_ro import CoreReadOnlyRepository # noqa: E402
from app.service.convert.lot_bootstrap import bootstrap_lots # noqa: E402
from app.utils.db import dispose_engines, get_engine # noqa: E402
CUSTOMER = "CUST-LOTT"
PROD_SUB = "PROD-LOTTA" # A 组:申购建批次
PROD_BOOT = "PROD-LOTTB" # D 组:无批次有持仓 → D8 兜底补建
PROD_REDEEM = "PROD-LOTTC" # B/C 组:赎回 FIFO 扣减
NAV_DATE = date(2026, 9, 4)
TRADE_AT = datetime(2026, 9, 4, 14, 0, 0)
_passed = 0
_failed = 0
def check(name: str, actual, expected) -> None:
"""逐条断言并打印(与既有 verify_convert_*.py 同款输出)。"""
global _passed, _failed
ok = actual == expected
if ok:
_passed += 1
else:
_failed += 1
flag = "✅" if ok else "❌"
print(f" {flag} {name}: 实际 {actual!r}" + ("" if ok else f" / 期望 {expected!r}"))
def dec(value, places: str = "0.01") -> Decimal:
"""真库读回是 `Decimal`(MySQL DECIMAL)→ 统一量化后比较。"""
return Decimal(str(value)).quantize(Decimal(places), rounding=ROUND_HALF_UP)
def q1(engine, sql: str, **params):
with engine.connect() as conn:
return conn.execute(text(sql), params).scalar_one_or_none()
# ── 数据准备 / 清理 ─────────────────────────────────────────────────
def seed(engine) -> None:
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_customer (customer_id, display_name, open_date) "
"VALUES (:c, 'T10真库验证', :d)"
),
{"c": CUSTOMER, "d": NAV_DATE},
)
for pid, name in [
(PROD_SUB, "T10申购基金"),
(PROD_BOOT, "T10兜底基金"),
(PROD_REDEEM, "T10赎回基金"),
]:
conn.execute(
text(
"INSERT INTO core_product (product_id, product_name, min_risk_code, "
"product_type, can_subscribe, can_redeem) "
"VALUES (:p, :n, 'R2', 'bond', 1, 1)"
),
{"p": pid, "n": name},
)
# 三个产品的 T 日净值:A 用 1.2 验折算,B/C 用 1.0 让份额=金额、断言直观
for pid, nav in [(PROD_SUB, "1.2000"), (PROD_BOOT, "1.0000"), (PROD_REDEEM, "1.0000")]:
conn.execute(
text(
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) "
"VALUES (:p, :nav, 0, :d)"
),
{"p": pid, "nav": nav, "d": NAV_DATE},
)
# C 组:两个已存在批次(FIFO 扣减用)
for lot_id, qty, nav, confirmed in [
("LOT-LOTT-C1", "100", "1.0000", datetime(2026, 9, 1, 10, 0, 0)),
("LOT-LOTT-C2", "50", "1.1000", datetime(2026, 9, 2, 10, 0, 0)),
]:
conn.execute(
text(
"INSERT INTO core_share_lot (lot_id, customer_id, product_id, qty, "
"remain_qty, nav, confirmed_at) VALUES (:l, :c, :p, :q, :q, :nav, :cat)"
),
{"l": lot_id, "c": CUSTOMER, "p": PROD_REDEEM, "q": qty, "nav": nav,
"cat": confirmed},
)
# D 组:只有持仓、没有批次(D8 兜底补建)
# A 组:持仓与申购批次同额(供 rebuild_lots 重建验证)
for pid, qty, cost in [(PROD_BOOT, "100", "100.00"), (PROD_SUB, "10000", "12000.00")]:
conn.execute(
text(
"INSERT INTO core_holding (customer_id, product_id, qty, cost_amount, "
"market_value, pnl_pct, as_of) VALUES (:c, :p, :q, :cost, :cost, 0, :d)"
),
{"c": CUSTOMER, "p": pid, "q": qty, "cost": cost, "d": date(2026, 8, 1)},
)
def cleanup(engine) -> None:
"""倒序删(子表 → 父表),只删 LOTT 前缀数据。"""
with engine.begin() as conn:
conn.execute(text("DELETE FROM core_share_lot WHERE customer_id = :c"), {"c": CUSTOMER})
conn.execute(text("DELETE FROM core_holding WHERE customer_id = :c"), {"c": CUSTOMER})
conn.execute(text("DELETE FROM core_product_nav WHERE product_id LIKE 'PROD-LOTT%'"))
conn.execute(text("DELETE FROM core_product WHERE product_id LIKE 'PROD-LOTT%'"))
conn.execute(text("DELETE FROM core_customer WHERE customer_id = :c"), {"c": CUSTOMER})
# ── 断言组 ──────────────────────────────────────────────────────────
def assert_subscribe(admin, writer, core) -> None:
print("【A】申购建批次(FR-C16 · qty = amount ÷ T 日净值)")
_maintain_lots(
writer=writer, core=core, trade_id="TRD-LOTT-SUB",
customer_id=CUSTOMER, product_id=PROD_SUB,
trade_type="subscribe", amount=Decimal("12000"), traded_at=TRADE_AT,
)
check(
"批次行数",
q1(admin, "SELECT COUNT(*) FROM core_share_lot WHERE customer_id = :c AND product_id = :p",
c=CUSTOMER, p=PROD_SUB),
1,
)
check(
"lot_id",
q1(admin, "SELECT lot_id FROM core_share_lot WHERE customer_id = :c AND product_id = :p",
c=CUSTOMER, p=PROD_SUB),
"LOT-SUB-TRD-LOTT-SUB",
)
check(
"qty = 12000 ÷ 1.2000",
dec(q1(admin, "SELECT qty FROM core_share_lot WHERE customer_id = :c AND product_id = :p",
c=CUSTOMER, p=PROD_SUB)),
dec("10000.00"),
)
check(
"remain_qty = qty",
dec(q1(admin, "SELECT remain_qty FROM core_share_lot WHERE customer_id = :c AND product_id = :p",
c=CUSTOMER, p=PROD_SUB)),
dec("10000.00"),
)
check(
"nav 4 位精度存取",
dec(q1(admin, "SELECT nav FROM core_share_lot WHERE customer_id = :c AND product_id = :p",
c=CUSTOMER, p=PROD_SUB), "0.0001"),
Decimal("1.2000"),
)
check(
"confirmed_at = T",
str(q1(admin, "SELECT confirmed_at FROM core_share_lot WHERE customer_id = :c AND product_id = :p",
c=CUSTOMER, p=PROD_SUB)),
"2026-09-04 14:00:00",
)
def assert_redeem_fifo(admin, writer, core) -> None:
print("【B/C】赎回 FIFO 扣减(★ 真库验证 `(:q + 0.0)` 方言语义)")
# 120 元 ÷ 1.0 = 120 份 → C1 扣满 100 归零,C2 再扣 20
_maintain_lots(
writer=writer, core=core, trade_id="TRD-LOTT-RED",
customer_id=CUSTOMER, product_id=PROD_REDEEM,
trade_type="redeem", amount=Decimal("120"), traded_at=TRADE_AT,
)
check(
"C1(最老批)remain_qty 归零 —— 扣减真库生效",
dec(q1(admin, "SELECT remain_qty FROM core_share_lot WHERE lot_id = 'LOT-LOTT-C1'")),
dec("0.00"),
)
check(
"C2 remain_qty = 50 − 20",
dec(q1(admin, "SELECT remain_qty FROM core_share_lot WHERE lot_id = 'LOT-LOTT-C2'")),
dec("30.00"),
)
check(
"归零批次**保留行不删**(D9/P2)",
q1(admin, "SELECT COUNT(*) FROM core_share_lot WHERE lot_id = 'LOT-LOTT-C1'"),
1,
)
check(
"不超扣:Σ remain = 150 − 120",
dec(q1(admin, "SELECT SUM(remain_qty) FROM core_share_lot WHERE customer_id = :c AND product_id = :p",
c=CUSTOMER, p=PROD_REDEEM)),
dec("30.00"),
)
def assert_bootstrap(admin, writer, core) -> None:
print("【D】D8 兜底补建:无批次有持仓 → 按 as_of 补建后再扣")
_maintain_lots(
writer=writer, core=core, trade_id="TRD-LOTT-BOOT",
customer_id=CUSTOMER, product_id=PROD_BOOT,
trade_type="redeem", amount=Decimal("30"), traded_at=TRADE_AT,
)
check(
"补建批次行数",
q1(admin, "SELECT COUNT(*) FROM core_share_lot WHERE customer_id = :c AND product_id = :p",
c=CUSTOMER, p=PROD_BOOT),
1,
)
check(
"lot_id = 确定性兜底 id",
q1(admin, "SELECT lot_id FROM core_share_lot WHERE customer_id = :c AND product_id = :p",
c=CUSTOMER, p=PROD_BOOT),
"LOT-BOOT-CUST-LOTT-PROD-LOTTB",
)
check(
"qty = 持仓快照 100",
dec(q1(admin, "SELECT qty FROM core_share_lot WHERE customer_id = :c AND product_id = :p",
c=CUSTOMER, p=PROD_BOOT)),
dec("100.00"),
)
check(
"补建 100 后扣 30 → remain 70",
dec(q1(admin, "SELECT remain_qty FROM core_share_lot WHERE customer_id = :c AND product_id = :p",
c=CUSTOMER, p=PROD_BOOT)),
dec("70.00"),
)
def assert_rebuild_lots(admin) -> None:
print("【E】rebuild_lots 真库跑通(D18 同源 + 幂等)")
import importlib.util
spec = importlib.util.spec_from_file_location(
"rebuild_lots_under_test", ROOT / "scripts" / "core" / "rebuild_lots.py"
)
rebuild = importlib.util.module_from_spec(spec)
spec.loader.exec_module(rebuild)
# 幂等:此刻三个产品都已有批次 → 零写入
stats = rebuild.rebuild_lots(admin, customer_id=CUSTOMER)
check("默认模式:已有批次全跳过", stats["written"], 0)
check("默认模式:补建 0 行", stats["bootstrapped"], 0)
# 人为删掉 A 组批次 → 模拟「批次缺失」,验证真库补建
with admin.begin() as conn:
conn.execute(
text("DELETE FROM core_share_lot WHERE customer_id = :c AND product_id = :p"),
{"c": CUSTOMER, "p": PROD_SUB},
)
stats = rebuild.rebuild_lots(admin, customer_id=CUSTOMER)
check("补建 1 行", stats["written"], 1)
rebuilt_lot_id = q1(
admin, "SELECT lot_id FROM core_share_lot WHERE customer_id = :c AND product_id = :p",
c=CUSTOMER, p=PROD_SUB,
)
check("补建 lot_id", rebuilt_lot_id, "LOT-BOOT-CUST-LOTT-PROD-LOTTA")
check(
"补建份额 = 持仓快照",
dec(q1(admin, "SELECT remain_qty FROM core_share_lot WHERE customer_id = :c AND product_id = :p",
c=CUSTOMER, p=PROD_SUB)),
dec("10000.00"),
)
# ★ D18 同源:真库补出的 confirmed_at 必须与 bootstrap_lots 直算一致
holding = {
"customer_id": CUSTOMER,
"product_id": PROD_SUB,
"qty": q1(admin, "SELECT qty FROM core_holding WHERE customer_id = :c AND product_id = :p",
c=CUSTOMER, p=PROD_SUB),
"cost_amount": q1(admin, "SELECT cost_amount FROM core_holding WHERE customer_id = :c AND product_id = :p",
c=CUSTOMER, p=PROD_SUB),
"as_of": q1(admin, "SELECT as_of FROM core_holding WHERE customer_id = :c AND product_id = :p",
c=CUSTOMER, p=PROD_SUB),
}
expected_confirmed = bootstrap_lots(holding)[0].confirmed_at
check(
"D18 同源:真库补建 confirmed_at == bootstrap_lots 直算",
str(q1(admin, "SELECT confirmed_at FROM core_share_lot WHERE customer_id = :c AND product_id = :p",
c=CUSTOMER, p=PROD_SUB)),
str(expected_confirmed),
)
def main() -> int:
admin = get_engine(settings.mysql_core_database, "admin")
try:
with admin.connect() as conn:
conn.execute(text("SELECT 1"))
except Exception as exc: # noqa: BLE001 - CLI 兜底
print(f"MySQL 不可达:{exc}", file=sys.stderr)
print("请确认本机 MySQL 服务已启动(见 FLOW §0 本机状态)。", file=sys.stderr)
return 1
cleanup(admin)
try:
seed(admin)
# 被测路径走生产同款仓储(rw 写 / ro 读)
writer = GatewayRepository()
core = CoreReadOnlyRepository()
assert_subscribe(admin, writer, core)
assert_redeem_fifo(admin, writer, core)
assert_bootstrap(admin, writer, core)
assert_rebuild_lots(admin)
finally:
cleanup(admin)
dispose_engines()
print(f"\n{'=' * 60}")
print(f"真库验证:{_passed} 项一致 / {_failed} 项不一致")
return 1 if _failed else 0
if __name__ == "__main__":
sys.exit(main())