Files
group_xinghuo_jinrong/scripts/core/rebuild_lots.py
T
GaoYiYuan_0626 5e6fa06d03 基金转换 T-10:普通申赎批次维护(FR-C16)
让 core_trade 的普通申赎同步维护 core_share_lot 批次,为 convert 提供权威份额口径。

落地(改码 3 处 + 新增 2 个脚本)
- gateway_repository:新增 insert_share_lots / deduct_share_lots,各自单事务、走 rw 账号;
  只落已分配结果、不含分配逻辑,避免出现第二份 FIFO 口径
- share_lot_repository:__init__ 增可选 core_ro 入参,让 trade_gateway 复用同一份 FIFO 选批口径(D18)
- trade_gateway:新增 _nav_as_of / _maintain_lots,在落流水后、规则引擎前调用
  · subscribe:取 T 日(含)前最新净值 → qty = amount ÷ 净值(2 位 HALF_UP)→ 建批次(confirmed_at = T)
  · redeem:FIFO 扣减;无批次但有持仓 → D8 兜底补建后再扣;两者皆无 → 降级跳过
  · 整段 try 包住,任何异常只 warning,绝不阻断交易(R-c(1))
- 新增 scripts/core/rebuild_lots.py:按 core_holding 快照重建批次(L-7),与网关 D8 同调 bootstrap_lots;
  默认只补建无批次的持仓行(幂等),--force 才先删后建,--dry-run 只报告
- 新增 scripts/dev/verify_convert_lots.py:真库验证脚本(MySQL 8.0.46)

口径订正(联网查证 4 家管理人业务规则后)
- redeem 的 amount ÷ 净值 折算属本项目简化建模,不是行业标准
- 行业铁律是「金额申购、份额赎回」:投资者以份额申报,登记机构按 T 日净值算金额
  (睿远业务规则 §65 / 华泰保兴 §69 / 东方基金 §57 / 国投瑞银 §33;无一家公募支持按金额赎回)
- 派生风险:未知价法下 T 日净值当日不可得(T+1 公告),_nav_as_of 实取 T−1 净值,
  故此处算出的份额只是估算值
- 已记入 PRD §10.1 已知差异清单;trade_gateway 注释同步订正

验证
- pytest -q → 714 passed / 3 skipped(基线 697 加 17,零回归)
- R16 test_redeem_accepted_without_alert 零改动通过(由 R-c(1) 降级保住)
- 真库 20/20;T-6 24/24、T-7 35/35 复跑零回归;真库隔离数据零残留
- 突变验证 2 组:切断接线 → 精准 2 条红;关掉 D8 兜底 → 精准 2 条红(含 D18 同源断言)
2026-09-10 18:28:48 +08:00

171 lines
6.7 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.
#!/usr/bin/env python3
"""按 `core_holding` 快照重建份额批次(D18 · L-7 · 架构 §2 scripts/core)。
**定位**:`core_share_lot` 是 FIFO 计费的权威源,`core_holding` 是持仓快照,
两者靠交易统一出入口(`trade_gateway` 的 FR-C16 批次维护)保持一致。
当历史数据缺批次(批次机制上线前的存量、或演示库被人工改动)时,用本脚本
**按持仓快照重建**——这是**快照重建、不是交易回滚**(PRD L-7):不重放流水,
只让 `Σ core_share_lot.remain_qty` 与 `core_holding.qty` 重新对齐。
**⚠️ D18 单一副本(改本文件前必读)**
补建规则**只有一处** —— `service/convert/lot_bootstrap.bootstrap_lots`。
本脚本与 `trade_gateway` 的 D8 兜底补建**同调该函数**。任何情况下都不要在
本文件里另写一份"由 `as_of` 反推 `confirmed_at`"的逻辑:两处各写一遍必然
漂移,同一持仓在两侧算出不同费率档 —— **演示里看不出来、生产里就是错账**。
**权限**:走 `role="admin"`(= `mysql_user`)。`--force` 需要 DELETE 权限,
而业务账号 `xh_core_rw` 刻意不持有(D20 最小权限),故本运维脚本不用业务账号。
用法:
python scripts/core/rebuild_lots.py # 仅补建「无批次」的持仓行(安全、幂等)
python scripts/core/rebuild_lots.py --customer CUST-4001
python scripts/core/rebuild_lots.py --force # 先删后建:全量快照重建
python scripts/core/rebuild_lots.py --dry-run # 只报告、不写库
退出码:0 = 正常;1 = 库不可达。
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from typing import Any
from sqlalchemy import text
from sqlalchemy.engine import Engine
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.service.convert.lot_bootstrap import bootstrap_lots # noqa: E402
from app.utils.db import get_engine # noqa: E402
_SELECT_HOLDINGS = "SELECT customer_id, product_id, qty, cost_amount, as_of FROM core_holding"
#: 一次取回全部已有批次的 `(客户, 产品)` 分布,避免逐行回查。
_GROUP_EXISTING_LOTS = """
SELECT customer_id, product_id, COUNT(*) AS n
FROM core_share_lot
GROUP BY customer_id, product_id
"""
_DELETE_LOTS = "DELETE FROM core_share_lot WHERE customer_id = :cid AND product_id = :pid"
def rebuild_lots(
engine: Engine,
*,
customer_id: str | None = None,
force: bool = False,
dry_run: bool = False,
) -> dict[str, int]:
"""按 `core_holding` 重建批次(CLI 与单测共用入口)。
- 缺省:**只补建「无批次」的持仓行**(安全、幂等;重复执行零写入)。
- `--force`:**先删后建**该 `(客户, 产品)` 的全部批次(全量快照重建)。
- `qty <= 0` 的持仓行**不补建**(D9/P2 归零行保留但不建批次,
与 `bootstrap_lots` 的语义一致)。
返回统计,键含义:`holdings` 扫描的持仓行数 · `bootstrapped` 实际补建的
持仓行数 · `skipped_existing` 因已有批次而跳过 · `skipped_zero` 因份额为 0
跳过 · `removed` 删除的批次行数(仅 `--force`)· `written` 写入的批次行数。
写入复用 `GatewayRepository.insert_share_lots` —— `core_share_lot` 的 INSERT
语句全仓只此一份,本脚本不另写一条(自检第 13 问)。
"""
stats = {
"holdings": 0,
"bootstrapped": 0,
"skipped_existing": 0,
"skipped_zero": 0,
"removed": 0,
"written": 0,
}
sql = _SELECT_HOLDINGS
params: dict[str, Any] = {}
if customer_id:
sql += " WHERE customer_id = :cid"
params["cid"] = customer_id
sql += " ORDER BY customer_id, product_id"
plans: list[tuple[str, str, list[Any]]] = []
with engine.connect() as conn:
holdings = [dict(r) for r in conn.execute(text(sql), params).mappings()]
existing = {
(r["customer_id"], r["product_id"]): int(r["n"])
for r in conn.execute(text(_GROUP_EXISTING_LOTS)).mappings()
}
for row in holdings:
stats["holdings"] += 1
cid, pid = str(row["customer_id"]), str(row["product_id"])
lots = bootstrap_lots(row)
if not lots:
stats["skipped_zero"] += 1
continue
has = existing.get((cid, pid), 0)
if has and not force:
stats["skipped_existing"] += 1
continue
if has:
stats["removed"] += has
stats["bootstrapped"] += 1
plans.append((cid, pid, lots))
if dry_run:
return stats
# `--force`:先删(独立事务)。放在写入之前,且不嵌套写事务 ——
# sqlite 测试库是 StaticPool 单连接,事务内再开事务会自锁。
if force:
with engine.begin() as conn:
for cid, pid, _ in plans:
conn.execute(text(_DELETE_LOTS), {"cid": cid, "pid": pid})
writer = GatewayRepository(engine=engine)
for _, _, lots in plans:
writer.insert_share_lots(lots)
stats["written"] += len(lots)
return stats
def main() -> None:
parser = argparse.ArgumentParser(
description="按 core_holding 快照重建份额批次(补建规则与网关同源,D18)"
)
parser.add_argument("--customer", help="只处理该客户(缺省全部)")
parser.add_argument(
"--force",
action="store_true",
help="先删后建(全量快照重建);缺省仅补建无批次的持仓行",
)
parser.add_argument("--dry-run", action="store_true", help="只报告、不写库")
args = parser.parse_args()
engine = get_engine(settings.mysql_core_database, "admin")
try:
with engine.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)
raise SystemExit(1)
stats = rebuild_lots(
engine, customer_id=args.customer, force=args.force, dry_run=args.dry_run
)
prefix = "[dry-run] " if args.dry_run else ""
print(
f"{prefix}持仓行 {stats['holdings']} · 补建 {stats['bootstrapped']} · "
f"跳过(已有批次) {stats['skipped_existing']} · 跳过(份额为0) {stats['skipped_zero']} · "
f"删除批次 {stats['removed']} · 写入批次 {stats['written']}"
)
if __name__ == "__main__":
main()