Files
group_xinghuo_jinrong/scripts/dev/verify_convert_seed.py
T
GaoYiYuan_0626 f056fcd007 基金转换 T+1 模型:T-1~T-6 落地(DDL/数据层/语义收窄/锁/受理事务)
AIcoding 第 5 步 todo 开发(开发计划 v2.0)前半段:

- T-1 3 张新表 DDL(core_convert_request 6 态 ENUM / core_trade_calendar /
  core_share_rule)+ 种子 + sqlite 单一事实源同步 + 列清单断言
- T-2/T-2b calc 扩展(product_round/redeem_amount/partial_qty)+ 真实净值实算回填
- T-3 convert_request_repository(6 态 + 条件 UPDATE 守卫)+ core_ro 三读方法
  + share_lot_repository.available_qty_with_inflight(R-3 在途占用推导)
- T-4 convert_repository.sync_mirror 成为 risk_convert_detail 唯一进度镜像写入口
  (旧三方法标 Deprecated,T-7 后删)
- T-5 locks.py 锁键构造器 convert_req_lock_key / convert_confirm_lock_key
- T-6 convert_service.accept_convert 受理事务(八步:锁→幂等→校验→受理日顺延
  →在途占用校验→落单→镜像+审计→202;不扣份额/不折算/不写流水)
  + tests/test_convert_accept.py(16 用例)
  + scripts/dev/verify_convert_accept.py(真库 36/36 一致)
  + trading_calendar.py 纯函数包(R-5)+ 21 用例

T-6 真库实测暴露并修复:confirm_eta 在日历数据边界抛 ValueError,会让已落库
的受理单在调用方眼里变 500;改为展示性字段容错 + 单测守护。

基线:798 passed / 10 skipped,零回归。
2026-09-11 19:21:15 +08:00

353 lines
16 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-1 验证脚本:用 pymysql 复刻 `scripts/core/reset.ps1` 的 SQL 部分并跑 DoD 断言。
背景:本机 `mysql` 客户端不在 PATH,且 reset.ps1 用 `mysql -p`(交互式密码),
无法在非交互环境直接跑。本脚本用 pymysql 执行**同一组 SQL 文件**(00~09),
再对 T-1 的三条 DoD 判定阈值逐条断言,做到「判定阈值明确、非人工目测」。
用法:
python scripts/dev/verify_convert_seed.py # 重建 jinrong_core + 恢复演示态 + 全部断言
python scripts/dev/verify_convert_seed.py --no-reset # 只跑断言(库已就绪时)
注意 1:会 DROP DATABASE jinrong_core 并重建 —— 该库是 Core 模拟底座,本就设计为可重置
(与 reset.ps1 行为一致)。**不触碰 jinrong_agent**。
注意 2:重建后会自动补跑 `scripts/demo/prepare_risk_demo.sql`(reset 的既定伴随步骤)——
不做的话 `03-seed-customers.sql` 写死的风评有效期会让真库集成模块整体 skip
(实测 516 → 505 passed)。
"""
from __future__ import annotations
import argparse
import re
import sys
from datetime import date
from pathlib import Path
import pymysql
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from app.config.settings import settings # noqa: E402
CORE_DIR = ROOT / "scripts" / "core"
SQL_FILES = [
"00-create-database.sql",
"01-ddl.sql",
"02-seed-base.sql",
"03-seed-customers.sql",
"04-seed-holdings.sql",
"05-seed-trades.sql",
"06-seed-nav.sql",
"07-seed-fee-rule.sql",
"08-seed-share-lot.sql",
"09-seed-org.sql",
"10-seed-trade-calendar.sql",
"11-seed-share-rule.sql",
]
# 档位覆盖参照日:与该批种子中 as_of / 净值日一致(2026-09-04)
AS_OF = date(2026, 9, 4)
BANDS = [(0, 7, "0.0150"), (7, 30, "0.0100"), (30, 180, "0.0050"), (180, 365, "0.0025"), (365, None, "0.0000")]
def split_statements(sql: str) -> list[str]:
"""剥离 `--` 整行注释后按分号切分。种子文件均无存储过程/触发器,可安全切分。"""
body = "\n".join(ln for ln in sql.splitlines() if not ln.strip().startswith("--"))
return [s.strip() for s in body.split(";") if s.strip()]
def connect(with_db: bool = False) -> pymysql.connections.Connection:
return pymysql.connect(
host=settings.mysql_host,
port=settings.mysql_port,
user=settings.mysql_user,
password=settings.mysql_password,
database=settings.mysql_core_database if with_db else None,
charset="utf8mb4",
autocommit=True,
)
def reset_core() -> None:
with connect() as conn, conn.cursor() as cur:
cur.execute(f"DROP DATABASE IF EXISTS `{settings.mysql_core_database}`")
print(f" DROP DATABASE {settings.mysql_core_database}")
for name in SQL_FILES:
path = CORE_DIR / name
for stmt in split_statements(path.read_text(encoding="utf-8")):
cur.execute(stmt)
print(f" ✓ {name}")
def run_demo_prepare() -> None:
"""把演示态刷回去(风评有效期)。
重建 jinrong_core 会让 `03-seed-customers.sql` 写死的风评有效期回到「已过期」状态,
于是 `tests/conftest.py::ensure_risk_demo_ready` 判定演示数据未就位,
**整个真库集成模块被 skip**(实测 516 → 505 passed)。这是 reset 的既定伴随步骤
(演示 SOP §2),reset.ps1 因不含 agent 库步骤而未内置,本脚本补上。
"""
path = ROOT / "scripts" / "demo" / "prepare_risk_demo.sql"
with connect(with_db=True) as conn, conn.cursor() as cur:
for stmt in split_statements(path.read_text(encoding="utf-8")):
cur.execute(stmt)
print(" ✓ prepare_risk_demo.sql(演示客户风评刷为「剩余 275 天」)")
def band_of(hold_days: int) -> str:
for lo, hi, rate in BANDS:
if hold_days >= lo and (hi is None or hold_days < hi):
return rate
return "?"
def run_assertions() -> bool:
ok = True
with connect(with_db=True) as conn, conn.cursor() as cur:
# ---- DoD ① 赎回费 5 档,且每只产品各 5 档 ----
cur.execute("SELECT COUNT(DISTINCT min_hold_days) FROM core_fee_rule WHERE fee_type='redeem'")
n_bands = cur.fetchone()[0]
cur.execute(
"SELECT product_id, COUNT(*) FROM core_fee_rule WHERE fee_type='redeem' "
"GROUP BY product_id HAVING COUNT(*) <> 5"
)
uneven = cur.fetchall()
cur.execute("SELECT COUNT(*), COUNT(DISTINCT product_id) FROM core_fee_rule WHERE fee_type='redeem'")
total_rows, n_products = cur.fetchone()
p1 = n_bands == 5 and not uneven
print(f"\n① 赎回费档位:DISTINCT min_hold_days={n_bands}(期望 5)· "
f"{n_products} 只产品 × {total_rows // max(n_products, 1)} 档 · 非 5 档产品={uneven or '无'}")
if not p1:
print(" ✗ 不满足 5 档 / 每产品 5 档")
ok &= p1
# ---- DoD ② Σ remain_qty 逐行等于 core_holding.qty ----
cur.execute(
"""
SELECT h.customer_id, h.product_id, h.qty, COALESCE(SUM(l.remain_qty), 0) AS lot_sum
FROM core_holding h
LEFT JOIN core_share_lot l
ON l.customer_id = h.customer_id AND l.product_id = h.product_id
GROUP BY h.customer_id, h.product_id, h.qty
HAVING ABS(lot_sum - h.qty) > 0.0001
"""
)
mismatch = cur.fetchall()
cur.execute("SELECT COUNT(*) FROM core_holding")
n_holding = cur.fetchone()[0]
cur.execute("SELECT COUNT(*), COUNT(DISTINCT CONCAT(customer_id, '|', product_id)) FROM core_share_lot")
n_lots, n_pairs = cur.fetchone()
p2 = not mismatch
print(f"② 批次守恒:{n_holding} 行持仓 vs {n_lots} 行批次({n_pairs} 个客户-产品对)· "
f"失配={mismatch or '无'}")
if not p2:
print(" ✗ 存在 Σ remain_qty ≠ qty 的持仓行")
ok &= p2
# ---- DoD ③ 三列无 NULL 且存在不同费率对 ----
cur.execute(
"SELECT COUNT(*) FROM core_product "
"WHERE fund_company IS NULL OR ta_code IS NULL OR subscribe_fee_rate IS NULL"
)
n_null = cur.fetchone()[0]
cur.execute("SELECT COUNT(DISTINCT subscribe_fee_rate) FROM core_product")
n_rates = cur.fetchone()[0]
cur.execute(
"SELECT product_id, subscribe_fee_rate FROM core_product "
"WHERE product_id IN ('PROD-110022','PROD-003095','PROD-005827','PROD-510300','PROD-000001') "
"ORDER BY product_id"
)
demo = cur.fetchall()
p3 = n_null == 0 and n_rates > 1
print(f"③ 机构/费率:NULL 行数={n_null}(期望 0)· DISTINCT 费率={n_rates}(期望 >1)")
print(f" 演示费率对:{[(p, str(r)) for p, r in demo]}")
if not p3:
print(" ✗ 存在 NULL 或不存在不同费率对")
ok &= p3
# ---- 附加:批次实际覆盖的档位(以 AS_OF 为参照) ----
cur.execute("SELECT lot_id, confirmed_at FROM core_share_lot")
rows = cur.fetchall()
seen: dict[str, int] = {}
for _lot, ts in rows:
b = band_of((AS_OF - ts.date()).days)
seen[b] = seen.get(b, 0) + 1
p4 = len(seen) == 5
print(f"④ 批次档位覆盖(参照 {AS_OF}):{dict(sorted(seen.items()))}")
if not p4:
print(" ✗ 未覆盖全部 5 档 → 费率档演示无数据")
ok &= p4
# ---- 附加:主演示客户 CUST-9527 确为跨批次 ----
cur.execute(
"SELECT product_id, COUNT(*) FROM core_share_lot WHERE customer_id='CUST-9527' "
"GROUP BY product_id ORDER BY product_id"
)
c9527 = cur.fetchall()
p5 = any(n >= 2 for _p, n in c9527)
print(f"⑤ CUST-9527 批次数:{c9527}(至少一只产品 ≥2 批)")
if not p5:
print(" ✗ 主演示客户无跨批次数据")
ok &= p5
# ---- DoD ⑨ 交易日历种子(T-1 · 10-seed):全区间(2026~2027)交易日 = 503 ----
cur.execute("SELECT COUNT(*) FROM core_trade_calendar WHERE is_open = 1")
n_trade_days = cur.fetchone()[0]
p9a = n_trade_days == 503
print(f"⑨a 交易日历:全区间交易日(is_open=1)={n_trade_days}(期望 503)")
if not p9a:
print(" ✗ 交易日历种子与 10-seed 头注释 503 不符")
ok &= p9a
# ---- DoD ⑩ core_share_rule 种子(T-1 · 11-seed):覆盖 ≥ 1 个产品 × 3 类型 ----
cur.execute(
"SELECT COUNT(DISTINCT product_id), COUNT(*) FROM core_share_rule "
"WHERE business_type IN ('subscribe','redeem','convert')"
)
n_rule_products, n_rule_rows = cur.fetchone()
p10 = n_rule_products >= 1 and n_rule_rows >= 3
print(f"⑩ 份额舍入规则:{n_rule_products} 只产品 × {n_rule_rows} 行(期望 ≥1 产品且 ≥3 行)")
if not p10:
print(" ✗ core_share_rule 种子不足")
ok &= p10
# ---- DoD ⑪ core_convert_request 建表即含 6 态 ENUM(T-1 · 免二期 ALTER)----
cur.execute(
"SELECT COLUMN_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() "
"AND TABLE_NAME = 'core_convert_request' AND COLUMN_NAME = 'status'"
)
req_row = cur.fetchone()
if req_row:
vals = set(re.findall(r"'(\w+)'", req_row[0]))
p11 = vals == {"accepted", "nav_pending", "confirmed", "rejected", "cancelled", "expired"}
print(f"⑪ core_convert_request.status = {req_row[0]}")
if not p11:
print(f" ✗ ENUM 值不全(实际 {vals})")
else:
p11 = False
print(f" ✗ 查不到 core_convert_request({req_row})")
ok &= p11
# ---- DoD ⑥ 向后兼容:不带新列的老 INSERT 仍可执行(架构风险 #9)----
cur.execute("START TRANSACTION")
try:
cur.execute(
"INSERT INTO core_trade (trade_id, customer_id, product_id, trade_type, amount, traded_at) "
"VALUES ('TRD-BC-TEST', 'CUST-9527', 'PROD-110022', 'subscribe', 100.00, NOW(3))"
)
cur.execute("SELECT convert_group_id FROM core_trade WHERE trade_id = 'TRD-BC-TEST'")
gid = cur.fetchone()[0]
cur.execute(
"INSERT INTO core_product (product_id, product_name, product_type, min_risk_code, "
"min_subscribe_amount, requires_disclosure) VALUES "
"('PROD-BC-TEST', '向后兼容测试', 'bond', 'R1', 100.00, 0)"
)
cur.execute(
"SELECT can_subscribe, can_redeem, min_hold_action, subscribe_fee_rate, fund_company "
"FROM core_product WHERE product_id = 'PROD-BC-TEST'"
)
row = cur.fetchone()
p6 = (
gid is None
and row[0] == 1 and row[1] == 1
and row[2] == "force_transfer" and row[3] == 0 and row[4] is None
)
finally:
cur.execute("ROLLBACK")
print(f"⑥ 老 INSERT 向后兼容:core_trade.convert_group_id={gid}(期望 None)· "
f"core_product 新列默认值={row}(期望 1/1/force_transfer/0/None)")
if not p6:
print(" ✗ 不带新列的老 INSERT 失败或默认值不符")
ok &= p6
# ---- DoD ⑦ risk_convert_detail 建表即含 5 个 status 值(免二期 ALTER)----
doc = (ROOT / "docs" / "项目框架设计" / "表设计" / "02-mysql-agent专用.sql").read_text(encoding="utf-8")
m = re.search(r"CREATE TABLE risk_convert_detail \(.*?\n\) ENGINE=InnoDB[^;]*;", doc, re.S)
assert m, "02-mysql-agent专用.sql 中未找到 risk_convert_detail 建表语句"
tmp_db = "jinrong_tmp_check"
with connect() as conn, conn.cursor() as cur:
cur.execute(f"DROP DATABASE IF EXISTS {tmp_db}")
cur.execute(f"CREATE DATABASE {tmp_db}")
cur.execute(f"USE {tmp_db}")
cur.execute(m.group(0))
cur.execute(
"SELECT COLUMN_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = %s "
"AND TABLE_NAME = 'risk_convert_detail' AND COLUMN_NAME = 'status'",
(tmp_db,),
)
col_type = cur.fetchone()[0]
cur.execute(f"DROP DATABASE {tmp_db}")
vals = set(re.findall(r"'(\w+)'", col_type))
p7 = vals == {"pending", "completed", "failed", "cancelled", "expired"}
print(f"⑦ risk_convert_detail.status = {col_type}")
if not p7:
print(f" ✗ ENUM 值不全(实际 {vals})")
ok &= p7
# ---- DoD ⑧ 费率档必须与 product_type 匹配(自检第 12 问的机器化)----
# 22 号文 §8 的费率档由产品类型决定:stock ≤0.80% / mixed ≤0.50% /
# bond·index ≤0.30% / money = 0。任何「升档凑数值差」都会在此暴露。
CAPS = {"stock": 0.0080, "mixed": 0.0050, "bond": 0.0030, "index": 0.0030, "money": 0.0}
# 非公募主体:不适用 22 号文公募费率规定,本项目建模为费率 0 且不与公募互转
NOT_APPLICABLE = {"wealth_mgmt", "private_fund"}
with connect(with_db=True) as conn, conn.cursor() as cur:
cur.execute("SELECT product_id, product_type, subscribe_fee_rate FROM core_product")
rows = cur.fetchall()
offenders = [
(pid, ptype, float(rate))
for pid, ptype, rate in rows
if ptype in CAPS and float(rate) > CAPS[ptype] + 1e-9
]
unmatched = [r for r in rows if r[1] not in CAPS and r[1] not in NOT_APPLICABLE]
na_offenders = [
(pid, ptype, float(rate))
for pid, ptype, rate in rows
if ptype in NOT_APPLICABLE and float(rate) != 0
]
# 主示例两端必须同主体(同管理人 + 同 TA),否则转换前置约束不成立
cur.execute(
"SELECT DISTINCT fund_company, ta_code FROM core_product "
"WHERE product_id IN ('PROD-110022', 'PROD-003095') AND fund_company IS NOT NULL"
)
entities = cur.fetchall()
# 主演示组内费率档必须多样(否则补差费/同费率对照无数据)
cur.execute(
"SELECT COUNT(DISTINCT subscribe_fee_rate) FROM core_product "
"WHERE fund_company = '华夏模拟基金' AND ta_code = 'TA-CN-001'"
)
main_group_rates = cur.fetchone()[0]
p8 = (
not offenders and not unmatched and not na_offenders
and len(entities) == 1 and main_group_rates >= 3
)
print(f"⑧ 费率档 ↔ product_type 匹配:越档={offenders or '无'} · 未归类={unmatched or '无'} · "
f"非公募费率非 0={na_offenders or '无'}")
print(f" 主示例两端主体={entities}(须唯一)· 华夏组费率档数={main_group_rates}(须 ≥3)")
if not p8:
print(" ✗ 存在「按更宽松档次取费率」或主示例两端非同主体 / 主组费率档不足")
ok &= p8
return ok
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--no-reset", action="store_true", help="跳过重建,只跑断言")
args = ap.parse_args()
if not args.no_reset:
print("== 重建 jinrong_core(等价 reset.ps1 的 SQL 部分)==")
reset_core()
print("== 恢复演示态 ==")
run_demo_prepare()
print("\n== T-1 DoD 断言 ==")
ok = run_assertions()
print("\n" + ("全部 PASS ✅" if ok else "存在 FAIL ❌"))
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(main())