Files
group_xinghuo_jinrong/scripts/dev/verify_convert_lots.py
T

342 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)` 方言语义)")
# D26/R-6(T-9 起):赎回**份额申报**——直接申报 120 份 → C1 扣满 100 归零,C2 再扣 20
# (T-13 全量复跑修正:v1.0 的 amount= 入参会被 redeem 分支静默跳过)
_maintain_lots(
writer=writer, core=core, trade_id="TRD-LOTT-RED",
customer_id=CUSTOMER, product_id=PROD_REDEEM,
trade_type="redeem", qty=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 补建后再扣")
# D26/R-6:赎回份额申报(v1.0 的 amount= 入参会被 redeem 分支静默跳过)
_maintain_lots(
writer=writer, core=core, trade_id="TRD-LOTT-BOOT",
customer_id=CUSTOMER, product_id=PROD_BOOT,
trade_type="redeem", qty=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())