"""批次兜底补建(架构 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", ]