Files
group_xinghuo_jinrong/scripts/dev/verify_convert_lots.py
T

340 lines
14 KiB
Python
Raw Normal View History

"""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())