Files
group_xinghuo_jinrong/scripts/dev/verify_convert_seed.py
T
GaoYiYuan_0626 c5182f1910 feat(convert): 基金转换 T-0/T-0b 门禁 + T-1 数据层 + T-2/T-2b 纯函数与实算回填
T-0 / T-0b(门禁 · 2026-09-10)
- T-0:sqlite 与 MySQL 结构对齐 —— core_holding 统一为 qty/cost_amount/as_of/pnl_pct
  + PK + UNIQUE(customer_id, product_id);补 core_product_nav;新增建库自校验
  _assert_ddl_aligned()(R-g);test_db.py 增 3 条门禁用例(含反向验证门禁失效)
- T-0b:DB 账号分离(D20)—— 新增 scripts/core/00-grant.sql(三账号逐表授权);
  settings.py 增 3 组账号;db.py 改 get_engine(db, role),缓存键改为 (库名, 角色),
  账号未配置回退单账号;core_ro→ro / gateway_repository→rw / risk·session_repository→rw;
  tests/conftest.py 四处显式 role="admin"(R-e)

T-1(数据层)
- scripts/core/01-ddl.sql:新建 core_fee_rule / core_share_lot / core_convert_lot_detail;
  core_trade 加 convert_group_id + idx_convert_group;core_product 加 8 列 + fee_rate 补 COMMENT
- 新增 07-seed-fee-rule.sql(赎回费 5 档 × 14 产品,按 22 号文 §10)/ 08-seed-share-lot.sql
  (58 行持仓 → 61 行批次,Σ remain_qty 恒等于 qty)/ 09-seed-org.sql(管理人 + TA +
  申购费率 + 最低持有余额,v1.1 按「管理人全产品线」重排)
- reset.ps1 追加 07/08/09;02-mysql-agent专用.sql 追加 risk_convert_detail
- tests/_ddl.py 同步 4 表 + 新增 REQUIRED_CONVERT_TABLES 建库门禁
- 新增 scripts/dev/verify_convert_seed.py(pymysql 等价 reset 流程 + 8 条 DoD 断言,
  含断言 ⑧「费率档 ↔ product_type 匹配」,越档即 FAIL)

T-2 / T-2b(纯函数包 + 示例实算回填)
- 新增 app/service/convert/ 7 文件:__init__ / types / calc / fee / nav / lot_bootstrap / errors
  (纯函数,不查库、不碰 SQL;所有量化显式 ROUND_HALF_UP;lot_bootstrap 用 zlib.crc32
   保证 D18 跨进程同源)
- 新增 tests/test_convert_calc.py 93 用例(12 类:HALF_UP 反向自证 / 分档边界 /
  FIFO 含同 confirmed_at 兜底 / 双口径 / 强制全转与强制赎回 / PRD §5.3 全链自证 /
  纯函数零 IO 依赖断言)
- 重写 scripts/dev/calc_convert_demo.py:去掉脚本内公式副本,改为调用生产 calc.py,
  末尾与 PRD §5.3 逐项比对(不一致即退出码 1),兼作一致性门禁

验证
- pytest 609 passed / 3 skipped(516 → +93,零回归)
- verify_convert_seed.py 8/8 PASS;calc_convert_demo.py 15/15 与 PRD §5.3 一致

文档:PRD v0.9.1(费率分类修正)· 架构 §7 签名回填 / §8.3 错误码注 / §15 T-2 完成 ·
开发计划 §1.5 新增 R-h + §4.2·§4.3 执行记录 · AGENTS.md · docs/memory
2026-09-10 14:45:55 +08:00

313 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-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",
]
# 档位覆盖参照日:与该批种子中 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 ⑥ 向后兼容:不带新列的老 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())