Files
group_xinghuo_jinrong/scripts/dev/verify_convert_apply.py
T
GaoYiYuan_0626 82c9467fb7 基金转换 T-6:apply_convert 阶段一单事务(core 库唯一写入口)
新增 app/gateway/convert_core_repository.py:两条流水(redeem/subscribe,
R-b 不使用 ENUM 的 convert)+ N×条件 UPDATE 批次扣减 + 转入新批次
+ 两端 core_holding + N×计费明细,全部收口在 with engine.begin() 单事务。

实现级要点(与开发计划 §6.1 的三处差异,已回写该节执行记录):
- core_holding 改用「基于列当前值的增量 UPDATE」,入参不带持仓快照:
  MySQL 的 UPDATE 是当前读,并发两笔自然累加;用快照算绝对值会互相覆盖。
- 数值参数参与算术时写成 (:x + 0.0):实测 sqlite 在 UPDATE 算术表达式中
  不把 TEXT 绑定参数转数值(传 '120' 时 c 不变),加 +0.0 后两库一致。
- pnl_pct 拆成独立 UPDATE 重算,避开 MySQL「SET 从左到右」的顺序坑。

验证:pytest 639 passed / 3 skipped(基线 634 +5,零回归);
新增 scripts/dev/verify_convert_apply.py 真 MySQL 验证 24/24 一致,
含真并发两笔首次转入同一产品的终态断言(MySQL RR 的 gap lock 实测留痕)。
2026-09-10 16:26:42 +08:00

428 lines
18 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-6 真 MySQL 验证脚本:`convert_core_repository.apply_convert` 实跑 + DoD 断言。
**为什么 sqlite 单测全绿还不够**
T-6 的 `core_holding` UPDATE 有两条**只有 MySQL 才成立的语义依赖**,
sqlite 单测绿**证明不了**它们:
1. **SET 从左到右求值**(MySQL 特性;sqlite 恒用更新前的值)。
本实现靠它让"成本/市值按**原 qty** 等比例结转、最后再减 qty"两库结果一致;
若 MySQL 语义与预期不符,成本结转会算错(sqlite 侧完全看不出来)。
2. **`(:dq + 0.0)` 语法**(为绕开 sqlite 不转 TEXT 参数的坑)在 MySQL 必须同样合法。
此外真库还能验证 sqlite 测不到的:InnoDB 事务回滚、DECIMAL(18,2/18,4) 精度、
`UNIQUE(customer_id, product_id)` 冲突回退(R-a ②③步)。
用法:
python scripts/dev/verify_convert_apply.py # 建隔离数据 → 跑 4 组断言 → 清理
注意:
- 用 **T6M 前缀**的隔离数据(客户/产品/批次),跑完全部 DELETE,不碰既有 61 行种子;
- 建/清数据走 `role="admin"`(R-e:需 DELETE),事务本身走 `ConvertCoreRepository`
默认的 `role="rw"`(账号未配时回退 `mysql_user`,与 T-0b 一致)。
"""
from __future__ import annotations
import sys
import threading
from datetime import date, datetime
from decimal import ROUND_HALF_UP, Decimal
from pathlib import Path
from sqlalchemy import event, text
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from app.config.settings import settings # noqa: E402
from app.gateway.convert_core_repository import ( # noqa: E402
ConvertApplyInput,
ConvertCoreRepository,
LotCharge,
)
from app.service.convert.calc import ( # noqa: E402
convert_amount,
diff_fee,
hold_days,
in_qty,
lot_amount,
lot_fee,
plan_lots,
)
from app.service.convert.errors import LotConflict # noqa: E402
from app.service.convert.fee import pick_fee_rate # noqa: E402
from app.service.convert.types import FeeRule, Lot # noqa: E402
from app.utils.db import dispose_engines, get_engine # noqa: E402
CUSTOMER = "CUST-T6M"
PROD_OUT = "PROD-T6MO"
PROD_IN = "PROD-T6MI"
TRADE_AT = datetime(2026, 9, 4, 10, 0, 0)
TRADE_DATE = date(2026, 9, 4)
OUT_RATE = Decimal("0.0030")
IN_RATE = Decimal("0.0080")
IN_NAV = Decimal("0.9500")
FEE_TIERS = [
(0, 7, "0.0150"), (7, 30, "0.0100"), (30, 180, "0.0050"),
(180, 365, "0.0025"), (365, None, "0.0000"),
]
_passed = 0
_failed = 0
def check(name: str, actual, expected) -> None:
"""逐条断言并打印(与 `verify_convert_seed.py` / `calc_convert_demo.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 seed(engine) -> None:
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_customer (customer_id, display_name, open_date) "
"VALUES (:c, 'T6真库验证', :d)"
),
{"c": CUSTOMER, "d": TRADE_DATE},
)
for pid, name, ptype, rate in [
(PROD_OUT, "T6转出基金", "bond", OUT_RATE),
(PROD_IN, "T6转入基金", "stock", IN_RATE),
]:
conn.execute(
text(
"INSERT INTO core_product (product_id, product_name, min_risk_code, "
"product_type, can_subscribe, can_redeem, subscribe_fee_rate) "
"VALUES (:p, :n, 'R2', :t, 1, 1, :r)"
),
{"p": pid, "n": name, "t": ptype, "r": str(rate)},
)
for mh, mh_max, rate in FEE_TIERS:
conn.execute(
text(
"INSERT INTO core_fee_rule (product_id, fee_type, min_hold_days, "
"max_hold_days, rate) VALUES (:p, 'redeem', :mh, :mh_max, :rate)"
),
{"p": PROD_OUT, "mh": mh, "mh_max": mh_max, "rate": rate},
)
conn.execute(
text(
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) "
"VALUES (:p, :nav, 0, :d)"
),
{"p": PROD_IN, "nav": str(IN_NAV), "d": TRADE_DATE},
)
for lot_id, qty, nav, confirmed in [
("LOT-T6M-A1", "100", "1.0300", datetime(2026, 8, 1, 10, 0, 0)),
("LOT-T6M-A2", "50", "1.0000", datetime(2026, 9, 1, 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_OUT, "q": qty, "nav": nav, "cat": confirmed},
)
conn.execute(
text(
"INSERT INTO core_holding (customer_id, product_id, qty, cost_amount, "
"market_value, pnl_pct, as_of) VALUES (:c, :p, 150, 150.00, 154.50, 0, :d)"
),
{"c": CUSTOMER, "p": PROD_OUT, "d": TRADE_DATE},
)
def cleanup(engine) -> None:
"""倒序删(子表 → 父表),只删 T6M 前缀数据,不碰既有种子。"""
with engine.begin() as conn:
for sql, params in [
("DELETE FROM core_convert_lot_detail WHERE convert_group_id LIKE 'CNV-T6M%'", {}),
("DELETE FROM core_trade WHERE customer_id = :c", {"c": CUSTOMER}),
("DELETE FROM core_share_lot WHERE customer_id = :c", {"c": CUSTOMER}),
("DELETE FROM core_holding WHERE customer_id = :c", {"c": CUSTOMER}),
("DELETE FROM core_fee_rule WHERE product_id LIKE 'PROD-T6M%'", {}),
("DELETE FROM core_product_nav WHERE product_id LIKE 'PROD-T6M%'", {}),
("DELETE FROM core_product WHERE product_id LIKE 'PROD-T6M%'", {}),
("DELETE FROM core_customer WHERE customer_id = :c", {"c": CUSTOMER}),
]:
conn.execute(text(sql), params)
# ── 折算(全部走生产纯函数)────────────────────────────────────────────
def build_input(engine, group_id: str, requested: str, in_lot_id: str = "LOT-T6M-IN") -> ConvertApplyInput:
with engine.connect() as conn:
lot_rows = [
Lot.from_row(r)
for r in conn.execute(
text(
"SELECT * FROM core_share_lot WHERE customer_id = :c AND product_id = :p "
"ORDER BY confirmed_at ASC, lot_id ASC"
),
{"c": CUSTOMER, "p": PROD_OUT},
).mappings()
]
rules = [
FeeRule.from_row(r)
for r in conn.execute(
text(
"SELECT * FROM core_fee_rule WHERE product_id = :p AND fee_type = 'redeem' "
"ORDER BY min_hold_days ASC"
),
{"p": PROD_OUT},
).mappings()
]
plan = plan_lots(lot_rows, Decimal(requested))
charges = []
for alloc in plan.allocations:
days = hold_days(TRADE_DATE, alloc.confirmed_at)
rate = pick_fee_rate(rules, days, product_id=PROD_OUT)
amount = lot_amount(alloc.qty, alloc.nav)
charges.append(
LotCharge(
lot_id=alloc.lot_id, qty=alloc.qty, hold_days=days, amount=amount,
fee_rate=rate, fee_amount=lot_fee(amount, rate), nav=alloc.nav,
nav_date=TRADE_DATE,
)
)
out_amount = sum((c.amount for c in charges), Decimal("0"))
conv = convert_amount(out_amount, sum((c.fee_amount for c in charges), Decimal("0")))
in_amount = conv - diff_fee(conv, OUT_RATE, IN_RATE, "amount_diff")
return ConvertApplyInput(
convert_group_id=group_id,
out_trade_id=f"{group_id}-OUT",
in_trade_id=f"{group_id}-IN",
customer_id=CUSTOMER,
from_product_id=PROD_OUT,
to_product_id=PROD_IN,
traded_at=TRADE_AT,
out_qty=plan.actual_qty,
out_amount=out_amount,
in_qty=in_qty(in_amount, IN_NAV),
in_amount=in_amount,
in_nav=IN_NAV,
in_nav_date=TRADE_DATE,
in_lot_id=in_lot_id,
in_confirmed_at=datetime(2026, 9, 5, 10, 0, 0),
charges=tuple(charges),
)
def q1(engine, sql: str, **params):
with engine.connect() as conn:
return conn.execute(text(sql), params).scalar()
def main() -> int:
admin = get_engine(settings.mysql_core_database, "admin")
repo = ConvertCoreRepository() # 真实路径:role="rw"(账号未配时回退 mysql_user)
try:
cleanup(admin)
seed(admin)
# ── A. 正常路径(含 MySQL 专属语义:SET 顺序 + (:x + 0.0))────────
print("\n【A】正常路径:2 流水 + 2 明细 + 两端持仓")
req = build_input(admin, "CNV-T6M-1", "120")
repo.apply_convert(req)
check("流水条数", q1(admin, "SELECT COUNT(*) FROM core_trade WHERE convert_group_id = 'CNV-T6M-1'"), 2)
check(
"R-b trade_type",
sorted(r[0] for r in _rows(admin, "SELECT trade_type FROM core_trade WHERE convert_group_id = 'CNV-T6M-1'")),
["redeem", "subscribe"],
)
check("LOT-A1 扣满归零", dec(q1(admin, "SELECT remain_qty FROM core_share_lot WHERE lot_id = 'LOT-T6M-A1'")), Decimal("0.00"))
check("LOT-A2 剩 30", dec(q1(admin, "SELECT remain_qty FROM core_share_lot WHERE lot_id = 'LOT-T6M-A2'")), Decimal("30.00"))
check("转入新批次份额", dec(q1(admin, "SELECT remain_qty FROM core_share_lot WHERE lot_id = 'LOT-T6M-IN'")), dec(req.in_qty))
check("明细条数", q1(admin, "SELECT COUNT(*) FROM core_convert_lot_detail WHERE convert_group_id = 'CNV-T6M-1'"), 2)
check(
"转出端 qty",
dec(q1(admin, "SELECT qty FROM core_holding WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_OUT)),
Decimal("30.00"),
)
# ★ MySQL SET 从左到右语义的验证点:成本按**原 qty** 等比例结转
check(
"转出端 cost(等比例结转)",
dec(q1(admin, "SELECT cost_amount FROM core_holding WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_OUT)),
Decimal("30.00"),
)
check(
"转出端 pnl_pct",
dec(q1(admin, "SELECT pnl_pct FROM core_holding WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_OUT), "0.0001"),
Decimal("0.0300"),
)
check(
"转入端 qty",
dec(q1(admin, "SELECT qty FROM core_holding WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_IN)),
dec(req.in_qty),
)
check(
"转入端 cost",
dec(q1(admin, "SELECT cost_amount FROM core_holding WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_IN)),
dec(req.in_amount),
)
# ── B. 冲突路径(真实 InnoDB 事务回滚)────────────────────────
print("\n【B】冲突路径:remain_qty 不足 → LotConflict + 无残留")
conflict = build_input(admin, "CNV-T6M-2", "30")
conflict = ConvertApplyInput(
**{
**conflict.__dict__,
"charges": tuple(
LotCharge(
lot_id=c.lot_id, qty=Decimal("999"), hold_days=c.hold_days,
amount=c.amount, fee_rate=c.fee_rate, fee_amount=c.fee_amount,
nav=c.nav, nav_date=c.nav_date,
)
for c in conflict.charges
),
}
)
raised = None
try:
repo.apply_convert(conflict)
except LotConflict as exc:
raised = exc.status_code
check("抛 LotConflict(409)", raised, 409)
check("冲突后无流水残留", q1(admin, "SELECT COUNT(*) FROM core_trade WHERE convert_group_id = 'CNV-T6M-2'"), 0)
check("冲突后无明细残留", q1(admin, "SELECT COUNT(*) FROM core_convert_lot_detail WHERE convert_group_id = 'CNV-T6M-2'"), 0)
check("批次未被误扣", dec(q1(admin, "SELECT remain_qty FROM core_share_lot WHERE lot_id = 'LOT-T6M-A2'")), Decimal("30.00"))
# ── C. 转入端再次转换:增量累加(非覆盖)───────────────────────
print("\n【C】第二笔转换:转出端归零保留行 + 转入端累加")
before_in = dec(q1(admin, "SELECT qty FROM core_holding WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_IN))
second = build_input(admin, "CNV-T6M-3", "30", in_lot_id="LOT-T6M-IN2")
repo.apply_convert(second)
check(
"转出端归零保留行",
dec(q1(admin, "SELECT qty FROM core_holding WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_OUT)),
Decimal("0.00"),
)
check(
"转入端 qty 累加",
dec(q1(admin, "SELECT qty FROM core_holding WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_IN)),
dec(before_in + second.in_qty),
)
check(
"转入端只有一行",
q1(admin, "SELECT COUNT(*) FROM core_holding WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_IN),
1,
)
# ── D. R-a 前提:真库约束是否真的存在(回退分支依赖它们)──────────
print("\n【D】R-a 依赖的约束在真库是否存在")
check(
"core_holding 有 UNIQUE(customer_id, product_id)",
q1(
admin,
# information_schema.statistics 按「索引×列」各一行,
# 复合唯一索引会有 2 行 → 用 DISTINCT index_name 计数
"SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics "
"WHERE table_schema = :db AND table_name = 'core_holding' "
"AND index_name = 'uk_cust_product' AND non_unique = 0",
db=settings.mysql_core_database,
),
1,
)
check(
"core_trade 有 idx_convert_group(T-7 重试判定依赖)",
q1(
admin,
"SELECT COUNT(*) FROM information_schema.statistics "
"WHERE table_schema = :db AND table_name = 'core_trade' "
"AND index_name = 'idx_convert_group'",
db=settings.mysql_core_database,
),
1,
)
# ── E. 真并发两笔首次转入同一产品(gap lock / 死锁下的终态正确性)──
print("\n【E】真并发:两笔同时首次转入同一产品 → 终态只有一行、失败笔无残留")
cleanup(admin)
seed(admin)
with admin.begin() as conn:
# 批次加到 200 份,让两笔都"理论可扣",把冲突点逼到持仓首次建行
conn.execute(
text("UPDATE core_share_lot SET qty = 200, remain_qty = 200 WHERE lot_id = 'LOT-T6M-A1'"),
)
conn.execute(
text("UPDATE core_holding SET qty = 200, cost_amount = 200 WHERE product_id = :p"),
{"p": PROD_OUT},
)
first_a = build_input(admin, "CNV-T6M-5", "100", in_lot_id="LOT-T6M-IN5")
first_b = build_input(admin, "CNV-T6M-6", "100", in_lot_id="LOT-T6M-IN6")
# 会话级锁等待超时:真并发撞 gap lock 时快速失败,不等默认 50s
core_engine = get_engine(settings.mysql_core_database, "rw")
def _set_lock_timeout(dbapi_conn, _rec): # noqa: ANN001
dbapi_conn.cursor().execute("SET SESSION innodb_lock_wait_timeout = 3")
event.listen(core_engine, "connect", _set_lock_timeout)
results: dict[str, str | None] = {}
def _run(gid: str, req: ConvertApplyInput) -> None:
try:
ConvertCoreRepository().apply_convert(req)
results[gid] = None
except Exception as exc: # noqa: BLE001
results[gid] = f"{type(exc).__name__}"
threads = [
threading.Thread(target=_run, args=("CNV-T6M-5", first_a)),
threading.Thread(target=_run, args=("CNV-T6M-6", first_b)),
]
for t in threads:
t.start()
for t in threads:
t.join()
ok_groups = [g for g, err in results.items() if err is None]
print(f" 并发结果:{ {g: (e or '成功') for g, e in results.items()} }")
check(
"转入端只有一行(无论成败)",
q1(admin, "SELECT COUNT(*) FROM core_holding WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_IN),
1,
)
if ok_groups:
check(
f"qty = 成功笔数 × 单笔份额(成功 {len(ok_groups)} 笔)",
dec(q1(admin, "SELECT qty FROM core_holding WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_IN)),
dec(first_a.in_qty * len(ok_groups)),
)
for gid, err in results.items():
if err is None:
check(f"{gid} 成功 → 落 2 条流水", q1(admin, "SELECT COUNT(*) FROM core_trade WHERE convert_group_id = :g", g=gid), 2)
else:
check(f"{gid} 失败({err}) → 流水无残留", q1(admin, "SELECT COUNT(*) FROM core_trade WHERE convert_group_id = :g", g=gid), 0)
finally:
cleanup(admin)
dispose_engines()
print(f"\n{'=' * 60}")
print(f"真库验证:{_passed} 项一致 / {_failed} 项不一致")
return 1 if _failed else 0
def _rows(engine, sql: str, **params):
with engine.connect() as conn:
return conn.execute(text(sql), params).fetchall()
if __name__ == "__main__":
sys.exit(main())