Files
group_xinghuo_jinrong/app/service/convert/lot_bootstrap.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

131 lines
5.5 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.
"""批次兜底补建(架构 D18 · D8 · PRD §4.4 历史数据兜底)。
**唯一目的:让"补建规则"只有一个副本。**
`core_holding` 是持仓快照、`core_share_lot` 是 FIFO 计费的权威源。存量持仓
(批次机制上线前就存在)没有批次行,此时:
- `trade_gateway` 的普通申赎分支要**兜底补建后再扣**(D8,不跳过、不阻断);
- `scripts/core/rebuild_lots.py` 要按持仓**快照重建**整表(L-7,非交易回滚)。
两处都要"由 `core_holding` 反推初始批次的 `confirmed_at`"。若各写一份,
**必然漂移**——同一持仓在两处算出不同的 `confirmed_at`,费率档随之不同,
而这类差异在演示里看不出来、在生产里就是错账。故本模块被两侧共同调用(D18)。
**错开规则**与 `scripts/core/08-seed-share-lot.sql` 同族:以 `core_holding.as_of`
为基准日向前错开 `(491, 201, 34, 10, 3)` 天,分别落在
`[365,∞)` / `[180,365)` / `[30,180)` / `[7,30)` / `[0,7)` 五档,
避免"全部持仓都落在同一费率档"导致分档分支无数据。
"""
from __future__ import annotations
import zlib
from datetime import datetime, time, timedelta
from decimal import ROUND_HALF_UP, Decimal
from typing import Any, Mapping, Sequence
from app.service.convert.types import Lot, to_date, to_decimal
#: 错开天数(与 08 种子同族,依次对应 ≥365 / 180-365 / 30-180 / 7-30 / <7 五档)。
BOOTSTRAP_OFFSET_DAYS: tuple[int, ...] = (491, 201, 34, 10, 3)
#: 确认时点统一取 10:00:00,与 08 种子的 `confirmed_at` 写法保持一致
#: (持有期按自然日算,时分秒不参与计费,统一值只为可比对)。
BOOTSTRAP_CONFIRM_TIME = time(10, 0, 0)
#: `lot_id` 前缀:便于在库里一眼区分「兜底补建」与「真实流水生成」的批次。
BOOTSTRAP_LOT_PREFIX = "LOT-BOOT"
#: `lot_id` / `product_id` 列宽(`core_share_lot.lot_id VARCHAR(64)`)。
_MAX_LOT_ID_LEN = 64
def offset_for(
customer_id: str,
product_id: str,
offsets: Sequence[int] = BOOTSTRAP_OFFSET_DAYS,
) -> int:
"""按 `(customer_id, product_id)` 稳定选择一个错开天数。
⚠️ **必须用 `zlib.crc32`,不可用内置的 `hash` 函数**:CPython 对 `str` 启用了
哈希随机化(`PYTHONHASHSEED`),内置哈希在不同进程间结果不同 ——
这会让 gateway 与 `rebuild_lots.py`(两个进程)算出**不同**的 `confirmed_at`,
D18 的同源断言变成"时绿时红"的随机失败。
"""
key = f"{customer_id}|{product_id}".encode("utf-8")
return offsets[zlib.crc32(key) % len(offsets)]
def bootstrap_lot_id(customer_id: str, product_id: str) -> str:
"""兜底批次的 `lot_id`(确定性:同一持仓重复补建得到同一个 id,天然防重)。
超长时退化为「截断 + crc32 摘要」而**不抛异常** —— D8 明确要求兜底补建
「不跳过、不阻断」,让一个 64 字符列宽问题打断交易是不对的。
两个 id 列各 64 字符,拼起来可能超 `VARCHAR(64)`,故保留摘要段保证唯一性。
"""
raw = f"{customer_id}-{product_id}"
lot_id = f"{BOOTSTRAP_LOT_PREFIX}-{raw}"
if len(lot_id) > _MAX_LOT_ID_LEN:
digest = f"{zlib.crc32(raw.encode('utf-8')):08x}"
keep = _MAX_LOT_ID_LEN - len(BOOTSTRAP_LOT_PREFIX) - len(digest) - 2
lot_id = f"{BOOTSTRAP_LOT_PREFIX}-{raw[:keep]}-{digest}"
return lot_id
def bootstrap_lots(
holding_row: Mapping[str, Any],
*,
offset_days: int | None = None,
) -> list[Lot]:
"""按 `core_holding` 一行反推初始批次(D8 兜底补建 / `rebuild_lots.py` 共用)。
返回**单批次**(历史存量视为一次确认),`qty = remain_qty = holding.qty`,
因此天然满足 `Σ remain_qty == core_holding.qty`(08 种子的同一不变量)。
`nav` 用 **成本单价 `cost_amount / qty`**(4 位 HALF_UP)近似成交净值:
`core_holding` 没有净值列,而 `core_share_lot.nav` 是 NOT NULL;
补建批次是"历史成本"语义,成本单价虽不等于当时净值,但**口径一致、可复算**,
且不会被误当成当日净值参与定价(定价一律用 `core_product_nav`)。
`offset_days` 显式传入时覆盖默认错开(供单测固定档位用)。
"""
qty = to_decimal(holding_row["qty"])
if qty <= 0:
# 份额归零的持仓行**保留但不再补建批次**(D9/P2:归零行不删)。
return []
customer_id = str(holding_row["customer_id"])
product_id = str(holding_row["product_id"])
as_of = to_date(holding_row["as_of"])
if as_of is None:
raise ValueError("core_holding.as_of 不能为 None(反推批次的基准日)")
cost_amount = to_decimal(holding_row.get("cost_amount") or 0)
offset = offset_days if offset_days is not None else offset_for(customer_id, product_id)
confirmed_at = datetime.combine(as_of - timedelta(days=offset), BOOTSTRAP_CONFIRM_TIME)
nav = (cost_amount / qty).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
return [
Lot(
lot_id=bootstrap_lot_id(customer_id, product_id),
confirmed_at=confirmed_at,
remain_qty=qty,
nav=nav,
qty=qty,
customer_id=customer_id,
product_id=product_id,
source_trade_id=None, # 兜底补建无来源流水(与 08 种子一致)
)
]
__all__ = [
"BOOTSTRAP_OFFSET_DAYS",
"BOOTSTRAP_CONFIRM_TIME",
"BOOTSTRAP_LOT_PREFIX",
"offset_for",
"bootstrap_lot_id",
"bootstrap_lots",
]