370 lines
15 KiB
Python
370 lines
15 KiB
Python
"""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, time as dtime, timedelta
|
||
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.service.convert.trading_calendar import previous_biz_day # 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 扣减
|
||
# 造数日期在 main() 里按真库交易日历链式回推动态确定(T-12 模式:不猜自然日,
|
||
# 对任意假期安排与日期漂移鲁棒)。相对关系与原固定基准(2026-09-04)一致:
|
||
# TRADE_DATE = 回推 3 个交易日;C1/C2 批次 = 再回推 3/2 个交易日(保证 T+2 可扣);
|
||
# D 组持仓 as_of = 回推 34 个自然日(hold_days 为自然日语义,档位断言不变)。
|
||
NAV_DATE: "date | None" = None
|
||
TRADE_AT: "datetime | None" = None
|
||
_C1_AT: "datetime | None" = None
|
||
_C2_AT: "datetime | None" = None
|
||
_BOOT_AS_OF: "date | None" = None
|
||
|
||
|
||
def _init_dates(admin) -> None:
|
||
"""按真库交易日历把造数日期锚定到确定过去的历史开市日(必须在 seed 前调用)。"""
|
||
global NAV_DATE, TRADE_AT, _C1_AT, _C2_AT, _BOOT_AS_OF
|
||
is_open = CoreReadOnlyRepository(engine=admin).is_open
|
||
d = date.today()
|
||
for _ in range(3):
|
||
d = previous_biz_day(d, is_open)
|
||
NAV_DATE = d
|
||
TRADE_AT = datetime.combine(d, dtime(10, 0))
|
||
c1 = c2 = d
|
||
for _ in range(3):
|
||
c1 = previous_biz_day(c1, is_open)
|
||
for _ in range(2):
|
||
c2 = previous_biz_day(c2, is_open)
|
||
_C1_AT = datetime.combine(c1, dtime(10, 0))
|
||
_C2_AT = datetime.combine(c2, dtime(10, 0))
|
||
_BOOT_AS_OF = d - timedelta(days=34)
|
||
|
||
_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", _C1_AT),
|
||
("LOT-LOTT-C2", "50", "1.1000", _C2_AT),
|
||
]:
|
||
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": _BOOT_AS_OF},
|
||
)
|
||
|
||
|
||
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)),
|
||
str(TRADE_AT),
|
||
)
|
||
|
||
|
||
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")
|
||
_init_dates(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())
|