Files
group_xinghuo_jinrong/app/service/convert/lot_bootstrap.py
T

131 lines
5.5 KiB
Python
Raw Normal View History

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