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
313 lines
14 KiB
Python
313 lines
14 KiB
Python
"""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())
|