Files
group_xinghuo_jinrong/tests/test_share_lot.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

703 lines
29 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.
"""T-3 读侧单测 + T-10 普通申赎批次维护(开发计划 §5.1 / §8 DoD)。
sqlite 内存库(conftest.sqlite_engine 单一事实源建表 + C×R 矩阵种子),
数据由本文件自建,不依赖真 MySQL。
覆盖:
1. get_nav_as_of(D10:nav_date <= 交易日降序取 1)
2. get_redeem_fee_rules(仅 redeem 档、按持有期升序)
3. list_share_lots(S1:同 confirmed_at 以 lot_id 升序兜底、max_lots 截断)
4. sum_remain_qty(仅统计 remain_qty > 0,归零批不计入)
5. get_holding(单行 / 不存在 None)
6. ShareLotRepository.select_for_convert(FIFO 贪心选批、不足额返回全部可用)
7. **T-10 普通申赎批次维护(FR-C16)**:申购建批次 / 赎回 FIFO 扣减 /
无批次有持仓 → D8 兜底补建后再扣 / 降级两条(无净值、无批次无持仓)/
`rebuild_lots.py` 入口 / **D18 同源断言**(两侧 confirmed_at 逐一相等)
R-c(2) 覆盖率补偿:第 7 组的真实路径**全部自建完整种子**
(`core_holding` + `core_share_lot` + `core_product_nav`),
**不得让降级路径充当测试覆盖**(降级是数据不全时的兜底,不是被测对象)。
"""
from __future__ import annotations
import importlib.util
import logging
from datetime import date, datetime
from decimal import Decimal
from pathlib import Path
import pytest
from sqlalchemy import text
from _ddl import create_sqlite_engine
from app.gateway import trade_gateway as tg
from app.gateway.gateway_repository import GatewayRepository
from app.repository.core_ro import CoreReadOnlyRepository, _as_date
from app.repository.share_lot_repository import ShareLotRepository
from app.service.convert.lot_bootstrap import bootstrap_lot_id, bootstrap_lots
def _as_dt(value: object) -> datetime:
"""sqlite TIMESTAMP 读回为字符串、MySQL 为 datetime —— 测试内统一规范化。"""
if isinstance(value, datetime):
return value
return datetime.fromisoformat(str(value)[:19])
CUSTOMER = "CUST-T3"
PRODUCT_A = "PROD-T3A"
PRODUCT_B = "PROD-T3B"
def _seed_customer(engine, cid: str) -> None:
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_customer (customer_id, display_name) "
"VALUES (:cid, '测试客户')"
),
{"cid": cid},
)
def _seed_product(engine, pid: str, ptype: str = "bond") -> None:
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_product (product_id, product_name, min_risk_code, "
"product_type, can_subscribe, can_redeem) "
"VALUES (:pid, '测试产品', 'R2', :ptype, 1, 1)"
),
{"pid": pid, "ptype": ptype},
)
def _seed_lot(
engine, lot_id: str, cid: str, pid: str,
remain: str, nav: str, confirmed_at: datetime, qty: str | None = None,
) -> None:
# sqlite 不直接绑定 Decimal:统一转 float(与既有 Decimal(total) 读回约定一致)
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_share_lot (lot_id, customer_id, product_id, qty, "
"remain_qty, nav, confirmed_at) "
"VALUES (:lot_id, :cid, :pid, :qty, :remain, :nav, :confirmed_at)"
),
{
"lot_id": lot_id, "cid": cid, "pid": pid,
"qty": float(qty or remain), "remain": float(remain),
"nav": float(nav), "confirmed_at": confirmed_at,
},
)
# ── 1. get_nav_as_of(D10) ────────────────────────────────────────────
def test_get_nav_as_of_returns_latest_on_or_before_trade_date(sqlite_engine):
d = date(2026, 9, 4)
repo = CoreReadOnlyRepository(engine=sqlite_engine)
with sqlite_engine.begin() as conn:
for nav_date, nav in [
(date(2026, 9, 2), 1.0100),
(date(2026, 9, 3), 1.0200),
(date(2026, 9, 5), 1.0300),
]:
conn.execute(
text(
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) "
"VALUES (:pid, :nav, 0, :nd)"
),
{"pid": PRODUCT_A, "nav": nav, "nd": nav_date},
)
# 落在 9-4:应取 9-3(<= 的最近一期)
row = repo.get_nav_as_of(PRODUCT_A, d)
assert row is not None
assert _as_date(row["nav_date"]) == date(2026, 9, 3)
assert row["nav"] == 1.0200
def test_get_nav_as_of_after_latest_returns_newest(sqlite_engine):
d = date(2026, 9, 10)
repo = CoreReadOnlyRepository(engine=sqlite_engine)
with sqlite_engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) "
"VALUES (:pid, :nav, 0, :nd)"
),
{"pid": PRODUCT_A, "nav": 1.0300, "nd": date(2026, 9, 5)},
)
row = repo.get_nav_as_of(PRODUCT_A, d)
assert row is not None
assert _as_date(row["nav_date"]) == date(2026, 9, 5)
def test_get_nav_as_of_no_earlier_nav_returns_none(sqlite_engine):
d = date(2026, 9, 1)
repo = CoreReadOnlyRepository(engine=sqlite_engine)
with sqlite_engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) "
"VALUES (:pid, :nav, 0, :nd)"
),
{"pid": PRODUCT_A, "nav": 1.0300, "nd": date(2026, 9, 5)},
)
assert repo.get_nav_as_of(PRODUCT_A, d) is None
# ── 2. get_redeem_fee_rules ────────────────────────────────────────────
def test_get_redeem_fee_rules_only_redeem_and_ordered(sqlite_engine):
repo = CoreReadOnlyRepository(engine=sqlite_engine)
with sqlite_engine.begin() as conn:
# 申购费档(本期不启用)应被排除
conn.execute(
text(
"INSERT INTO core_fee_rule (product_id, fee_type, min_hold_days, "
"max_hold_days, rate) VALUES (:pid, 'subscribe', 0, NULL, :rate)"
),
{"pid": PRODUCT_A, "rate": 0.0080},
)
# 赎回费五档(乱序插入,断言按 min_hold_days 升序返回)
for mh, mh_max, rate in [
(180, 365, "0.0025"), (0, 7, "0.0150"),
(30, 180, "0.0050"), (7, 30, "0.0100"), (365, None, "0.0000"),
]:
conn.execute(
text(
"INSERT INTO core_fee_rule (product_id, fee_type, min_hold_days, "
"max_hold_days, rate) VALUES (:pid, 'redeem', :mh, :mh_max, :rate)"
),
{"pid": PRODUCT_A, "mh": mh, "mh_max": mh_max, "rate": float(rate)},
)
rules = repo.get_redeem_fee_rules(PRODUCT_A)
assert len(rules) == 5
assert [r["min_hold_days"] for r in rules] == [0, 7, 30, 180, 365]
assert all(r["fee_type"] == "redeem" for r in rules)
def test_get_redeem_fee_rules_empty(sqlite_engine):
repo = CoreReadOnlyRepository(engine=sqlite_engine)
assert repo.get_redeem_fee_rules(PRODUCT_B) == []
# ── 3. list_share_lots(S1 确定性 + 截断) ─────────────────────────────
def test_list_share_lots_ordered_by_confirmed_at(sqlite_engine):
_seed_customer(sqlite_engine, CUSTOMER)
_seed_product(sqlite_engine, PRODUCT_A)
repo = CoreReadOnlyRepository(engine=sqlite_engine)
# 乱序确认时间插入
_seed_lot(sqlite_engine, "L3", CUSTOMER, PRODUCT_A, "50", "1.03",
datetime(2026, 9, 3, 10, 0, 0))
_seed_lot(sqlite_engine, "L1", CUSTOMER, PRODUCT_A, "100", "1.01",
datetime(2026, 9, 1, 10, 0, 0))
_seed_lot(sqlite_engine, "L2", CUSTOMER, PRODUCT_A, "80", "1.02",
datetime(2026, 9, 2, 10, 0, 0))
lots = repo.list_share_lots(CUSTOMER, PRODUCT_A)
assert [l["lot_id"] for l in lots] == ["L1", "L2", "L3"]
assert [_as_dt(l["confirmed_at"]) for l in lots] == [
datetime(2026, 9, 1, 10, 0, 0),
datetime(2026, 9, 2, 10, 0, 0),
datetime(2026, 9, 3, 10, 0, 0),
]
def test_list_share_lots_same_confirmed_at_tiebreak_by_lot_id(sqlite_engine):
"""S1:同一 confirmed_at 多批次以 lot_id 升序兜底,顺序可复现。"""
_seed_customer(sqlite_engine, CUSTOMER)
_seed_product(sqlite_engine, PRODUCT_A)
repo = CoreReadOnlyRepository(engine=sqlite_engine)
same = datetime(2026, 9, 1, 10, 0, 0)
# 故意让 lot_id 顺序与插入顺序相反
_seed_lot(sqlite_engine, "LOT-09", CUSTOMER, PRODUCT_A, "10", "1.00", same)
_seed_lot(sqlite_engine, "LOT-02", CUSTOMER, PRODUCT_A, "10", "1.00", same)
_seed_lot(sqlite_engine, "LOT-05", CUSTOMER, PRODUCT_A, "10", "1.00", same)
lots = repo.list_share_lots(CUSTOMER, PRODUCT_A)
assert [l["lot_id"] for l in lots] == ["LOT-02", "LOT-05", "LOT-09"]
def test_list_share_lots_max_lots_truncates(sqlite_engine):
_seed_customer(sqlite_engine, CUSTOMER)
_seed_product(sqlite_engine, PRODUCT_A)
repo = CoreReadOnlyRepository(engine=sqlite_engine)
for i in range(3):
_seed_lot(sqlite_engine, f"L{i}", CUSTOMER, PRODUCT_A, "10", "1.00",
datetime(2026, 9, 1 + i, 10, 0, 0))
assert len(repo.list_share_lots(CUSTOMER, PRODUCT_A, max_lots=2)) == 2
# ── 4. sum_remain_qty(仅 >0) ─────────────────────────────────────────
def test_sum_remain_qty_excludes_zeroed_lots(sqlite_engine):
_seed_customer(sqlite_engine, CUSTOMER)
_seed_product(sqlite_engine, PRODUCT_A)
repo = CoreReadOnlyRepository(engine=sqlite_engine)
# 已归零(D9 保留行)与正常批次混放
_seed_lot(sqlite_engine, "L1", CUSTOMER, PRODUCT_A, "100", "1.00",
datetime(2026, 9, 1, 10, 0, 0))
_seed_lot(sqlite_engine, "L2", CUSTOMER, PRODUCT_A, "0", "1.00",
datetime(2026, 9, 2, 10, 0, 0)) # 归零批
_seed_lot(sqlite_engine, "L3", CUSTOMER, PRODUCT_A, "50", "1.00",
datetime(2026, 9, 3, 10, 0, 0))
assert repo.sum_remain_qty(CUSTOMER, PRODUCT_A) == Decimal("150")
def test_sum_remain_qty_only_zeros_returns_zero(sqlite_engine):
_seed_customer(sqlite_engine, CUSTOMER)
_seed_product(sqlite_engine, PRODUCT_A)
repo = CoreReadOnlyRepository(engine=sqlite_engine)
_seed_lot(sqlite_engine, "L1", CUSTOMER, PRODUCT_A, "0", "1.00",
datetime(2026, 9, 1, 10, 0, 0))
assert repo.sum_remain_qty(CUSTOMER, PRODUCT_A) == Decimal("0")
# ── 5. get_holding ─────────────────────────────────────────────────────
def test_get_holding_returns_row(sqlite_engine):
_seed_customer(sqlite_engine, CUSTOMER)
_seed_product(sqlite_engine, PRODUCT_A)
repo = CoreReadOnlyRepository(engine=sqlite_engine)
with sqlite_engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_holding (customer_id, product_id, qty, cost_amount, "
"market_value, pnl_pct, as_of) VALUES (:cid, :pid, :qty, 0, 0, 0, :as_of)"
),
{"cid": CUSTOMER, "pid": PRODUCT_A, "qty": 100.0,
"as_of": date(2026, 9, 4)},
)
row = repo.get_holding(CUSTOMER, PRODUCT_A)
assert row is not None
assert row["product_id"] == PRODUCT_A
assert row["qty"] == 100.0
def test_get_holding_missing_returns_none(sqlite_engine):
_seed_customer(sqlite_engine, CUSTOMER)
repo = CoreReadOnlyRepository(engine=sqlite_engine)
assert repo.get_holding(CUSTOMER, PRODUCT_B) is None
# ── 6. ShareLotRepository.select_for_convert(FIFO 选批) ────────────────
def test_select_for_convert_fifo_partial_covers_first_lots(sqlite_engine):
_seed_customer(sqlite_engine, CUSTOMER)
_seed_product(sqlite_engine, PRODUCT_A)
_seed_lot(sqlite_engine, "L1", CUSTOMER, PRODUCT_A, "100", "1.0000",
datetime(2026, 9, 1, 10, 0, 0))
_seed_lot(sqlite_engine, "L2", CUSTOMER, PRODUCT_A, "50", "1.1000",
datetime(2026, 9, 2, 10, 0, 0))
repo = ShareLotRepository(engine=sqlite_engine)
selected = repo.select_for_convert(CUSTOMER, PRODUCT_A, Decimal("120"))
# 先吃满 L1(100),再从 L2 取 20
assert [s["lot_id"] for s in selected] == ["L1", "L2"]
assert selected[0]["qty"] == 100.0
assert selected[1]["qty"] == Decimal("20")
assert repo.available_qty(CUSTOMER, PRODUCT_A) == Decimal("150")
def test_select_for_convert_shortfall_returns_all_available(sqlite_engine):
_seed_customer(sqlite_engine, CUSTOMER)
_seed_product(sqlite_engine, PRODUCT_A)
_seed_lot(sqlite_engine, "L1", CUSTOMER, PRODUCT_A, "100", "1.0000",
datetime(2026, 9, 1, 10, 0, 0))
_seed_lot(sqlite_engine, "L2", CUSTOMER, PRODUCT_A, "50", "1.1000",
datetime(2026, 9, 2, 10, 0, 0))
repo = ShareLotRepository(engine=sqlite_engine)
selected = repo.select_for_convert(CUSTOMER, PRODUCT_A, Decimal("1000"))
# 不足额:返回全部可用批次,差额由调用方校验
assert [s["lot_id"] for s in selected] == ["L1", "L2"]
assert [s["qty"] for s in selected] == [100.0, Decimal("50")]
def test_select_for_convert_zero_qty_returns_empty(sqlite_engine):
repo = ShareLotRepository(engine=sqlite_engine)
assert repo.select_for_convert(CUSTOMER, PRODUCT_A, Decimal("0")) == []
# ═══════════════════════════════════════════════════════════════════════
# 7. T-10 普通申赎批次维护(FR-C16 · 含 D8 兜底补建)
# ═══════════════════════════════════════════════════════════════════════
#: 交易日(T 日):批次 confirmed_at 与净值取数基准
NOW_T = datetime(2026, 9, 4, 14, 0, 0)
NAV_DATE = date(2026, 9, 4)
def _dec(value: object) -> Decimal:
"""sqlite 读回 DECIMAL 列是 float —— 统一转 Decimal 再比,避免二进制误差误判。"""
return Decimal(str(value))
def _rows(engine, sql: str, params: dict | None = None) -> list[dict]:
with engine.connect() as conn:
return [dict(r) for r in conn.execute(text(sql), params or {}).mappings()]
def _seed_nav(engine, pid: str, nav: str, nav_date: date = NAV_DATE) -> None:
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) "
"VALUES (:pid, :nav, 0, :nd)"
),
{"pid": pid, "nav": float(nav), "nd": nav_date},
)
def _seed_holding(
engine, cid: str, pid: str, qty: str, cost: str, as_of: date
) -> None:
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_holding (customer_id, product_id, qty, cost_amount, "
"market_value, pnl_pct, as_of) "
"VALUES (:cid, :pid, :qty, :cost, :cost, 0, :as_of)"
),
{"cid": cid, "pid": pid, "qty": float(qty), "cost": float(cost), "as_of": as_of},
)
def _maintain(
engine,
*,
trade_id: str,
trade_type: str,
amount: str,
product_id: str = PRODUCT_A,
customer_id: str = CUSTOMER,
traded_at: datetime = NOW_T,
) -> None:
"""直调批次维护入口(单元级:可控、可断言语义,端到端接线见 test_trade_gateway.py)。"""
tg._maintain_lots(
writer=GatewayRepository(engine=engine),
core=CoreReadOnlyRepository(engine=engine),
trade_id=trade_id,
customer_id=customer_id,
product_id=product_id,
trade_type=trade_type,
amount=Decimal(amount),
traded_at=traded_at,
)
# ── 7.1 真实路径:申购建批次 ───────────────────────────────────────────
def test_subscribe_creates_lot_with_amount_over_nav(sqlite_engine):
"""申购:`qty = amount ÷ T 日净值`(2 位 HALF_UP),`confirmed_at = T`。"""
_seed_customer(sqlite_engine, CUSTOMER)
_seed_product(sqlite_engine, PRODUCT_A)
_seed_nav(sqlite_engine, PRODUCT_A, "1.2500")
_maintain(
sqlite_engine, trade_id="TRD-T10-SUB", trade_type="subscribe", amount="10000"
)
rows = _rows(sqlite_engine, "SELECT * FROM core_share_lot")
assert len(rows) == 1
row = rows[0]
assert row["lot_id"] == "LOT-SUB-TRD-T10-SUB"
assert row["customer_id"] == CUSTOMER and row["product_id"] == PRODUCT_A
assert _dec(row["qty"]) == Decimal("8000.00") # 10000 / 1.25
assert _dec(row["remain_qty"]) == Decimal("8000.00")
assert _dec(row["nav"]) == Decimal("1.25")
assert _as_dt(row["confirmed_at"]) == NOW_T
assert row["source_trade_id"] == "TRD-T10-SUB"
def test_subscribe_rounds_qty_half_up(sqlite_engine):
"""份额 2 位 HALF_UP:1000 ÷ 1.2 = 833.333… → 833.33(不是银行家舍入)。"""
_seed_customer(sqlite_engine, CUSTOMER)
_seed_product(sqlite_engine, PRODUCT_A)
_seed_nav(sqlite_engine, PRODUCT_A, "1.2000")
_maintain(
sqlite_engine, trade_id="TRD-T10-RND", trade_type="subscribe", amount="1000"
)
row = _rows(sqlite_engine, "SELECT * FROM core_share_lot")[0]
assert _dec(row["remain_qty"]) == Decimal("833.33")
# ── 7.2 真实路径:赎回 FIFO 扣减 ───────────────────────────────────────
def test_redeem_deducts_fifo_across_lots(sqlite_engine):
"""赎回:按 `amount ÷ T 日净值` 折算份额后 FIFO 扣减,最老批次先扣。"""
_seed_customer(sqlite_engine, CUSTOMER)
_seed_product(sqlite_engine, PRODUCT_A)
_seed_nav(sqlite_engine, PRODUCT_A, "1.0000")
_seed_lot(sqlite_engine, "L-OLD", CUSTOMER, PRODUCT_A, "100", "1.0000",
datetime(2026, 9, 1, 10, 0, 0))
_seed_lot(sqlite_engine, "L-NEW", CUSTOMER, PRODUCT_A, "50", "1.1000",
datetime(2026, 9, 2, 10, 0, 0))
# 120 元 ÷ 1.0 = 120 份 → L-OLD 扣满 100 归零,L-NEW 再扣 20
_maintain(
sqlite_engine, trade_id="TRD-T10-RED", trade_type="redeem", amount="120"
)
rows = {
r["lot_id"]: _dec(r["remain_qty"])
for r in _rows(sqlite_engine, "SELECT * FROM core_share_lot")
}
assert rows == {"L-OLD": Decimal("0.00"), "L-NEW": Decimal("30.00")}
# 归零批次**保留行不删**(D9/P2)
assert len(rows) == 2
# 不超扣:Σ remain = 初始 150 − 实际扣减 120
assert sum(rows.values()) == Decimal("30.00")
def test_redeem_shortfall_deducts_available_only(sqlite_engine):
"""普通赎回不阻断:请求份额 > 可用份额时,按可用额度全部扣减、不报错。"""
_seed_customer(sqlite_engine, CUSTOMER)
_seed_product(sqlite_engine, PRODUCT_A)
_seed_nav(sqlite_engine, PRODUCT_A, "1.0000")
_seed_lot(sqlite_engine, "L1", CUSTOMER, PRODUCT_A, "30", "1.0000",
datetime(2026, 9, 1, 10, 0, 0))
_maintain(
sqlite_engine, trade_id="TRD-T10-SHORT", trade_type="redeem", amount="5000"
)
row = _rows(sqlite_engine, "SELECT * FROM core_share_lot")[0]
assert _dec(row["remain_qty"]) == Decimal("0.00")
# ── 7.3 真实路径:无批次有持仓 → D8 兜底补建后再扣 ─────────────────────
def test_redeem_bootstraps_lot_from_holding_then_deducts(sqlite_engine):
"""D8 主场景:无批次但有持仓 → 按 `core_holding.as_of` 补建初始批次再扣。"""
_seed_customer(sqlite_engine, CUSTOMER)
_seed_product(sqlite_engine, PRODUCT_A)
_seed_nav(sqlite_engine, PRODUCT_A, "1.0000")
_seed_holding(sqlite_engine, CUSTOMER, PRODUCT_A, "100", "100.00", date(2026, 8, 1))
_maintain(
sqlite_engine, trade_id="TRD-T10-D8", trade_type="redeem", amount="30"
)
rows = _rows(sqlite_engine, "SELECT * FROM core_share_lot")
assert len(rows) == 1
row = rows[0]
assert row["lot_id"] == bootstrap_lot_id(CUSTOMER, PRODUCT_A) # 确定性 id,防重复补建
assert _dec(row["qty"]) == Decimal("100") # 原份额 = 持仓快照
assert _dec(row["remain_qty"]) == Decimal("70.00") # 补建 100 后扣 30
assert row["source_trade_id"] is None # 兜底补建无来源流水(与 08 种子一致)
def test_redeem_zero_holding_skips_bootstrap(sqlite_engine, caplog):
"""持仓份额为 0(D9 归零行保留)→ 不补建、不抛异常。"""
_seed_customer(sqlite_engine, CUSTOMER)
_seed_product(sqlite_engine, PRODUCT_A)
_seed_nav(sqlite_engine, PRODUCT_A, "1.0000")
_seed_holding(sqlite_engine, CUSTOMER, PRODUCT_A, "0", "0.00", date(2026, 8, 1))
with caplog.at_level(logging.WARNING, logger="app.gateway.trade_gateway"):
_maintain(
sqlite_engine, trade_id="TRD-T10-ZERO", trade_type="redeem", amount="30"
)
assert _rows(sqlite_engine, "SELECT * FROM core_share_lot") == []
assert "无可补建批次" in caplog.text
# ── 7.4 降级路径(R-c(1) · 保住既有 510 用例的关键)────────────────────
def test_subscribe_without_nav_skips_with_warning(sqlite_engine, caplog):
"""申购取不到 T 日净值 → warning + 不建批次 + 不抛异常。"""
_seed_customer(sqlite_engine, CUSTOMER)
_seed_product(sqlite_engine, PRODUCT_A) # 刻意不灌 core_product_nav
with caplog.at_level(logging.WARNING, logger="app.gateway.trade_gateway"):
_maintain(
sqlite_engine, trade_id="TRD-T10-NONAV", trade_type="subscribe",
amount="10000",
)
assert _rows(sqlite_engine, "SELECT * FROM core_share_lot") == []
assert "申购取不到 T 日净值" in caplog.text
def test_redeem_without_lot_and_holding_skips_with_warning(sqlite_engine, caplog):
"""赎回既无批次也无持仓 → warning + 跳过扣减 + 不抛异常(R16 的兜底)。"""
_seed_customer(sqlite_engine, CUSTOMER)
_seed_product(sqlite_engine, PRODUCT_A)
_seed_nav(sqlite_engine, PRODUCT_A, "1.0000") # 有净值,但仍无批次无持仓
with caplog.at_level(logging.WARNING, logger="app.gateway.trade_gateway"):
_maintain(
sqlite_engine, trade_id="TRD-T10-EMPTY", trade_type="redeem", amount="1000"
)
assert _rows(sqlite_engine, "SELECT * FROM core_share_lot") == []
assert "既无批次也无持仓" in caplog.text
def test_maintain_lots_never_raises_on_broken_writer(sqlite_engine, caplog):
"""兜底:维护过程抛任何异常都被吞掉(交易主流程不受影响)。"""
_seed_customer(sqlite_engine, CUSTOMER)
_seed_product(sqlite_engine, PRODUCT_A)
_seed_nav(sqlite_engine, PRODUCT_A, "1.0000")
class BoomGateway(GatewayRepository):
def insert_share_lots(self, lots): # noqa: D102
raise RuntimeError("boom")
with caplog.at_level(logging.WARNING, logger="app.gateway.trade_gateway"):
tg._maintain_lots(
writer=BoomGateway(engine=sqlite_engine),
core=CoreReadOnlyRepository(engine=sqlite_engine),
trade_id="TRD-T10-BOOM",
customer_id=CUSTOMER,
product_id=PRODUCT_A,
trade_type="subscribe",
amount=Decimal("10000"),
traded_at=NOW_T,
)
assert "批次维护失败" in caplog.text
# ═══════════════════════════════════════════════════════════════════════
# 8. `scripts/core/rebuild_lots.py`(D18 同源 + 幂等)
# ═══════════════════════════════════════════════════════════════════════
_REBUILD_PATH = Path(__file__).resolve().parents[1] / "scripts" / "core" / "rebuild_lots.py"
def _load_rebuild_lots():
"""按路径加载脚本模块(`scripts/` 非包,无法直接 import)。"""
spec = importlib.util.spec_from_file_location("rebuild_lots_under_test", _REBUILD_PATH)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_rebuild_lots_bootstraps_missing_only(sqlite_engine):
"""缺省模式:只补建「无批次」的持仓行;已有批次的持仓不动。"""
rebuild = _load_rebuild_lots()
_seed_customer(sqlite_engine, CUSTOMER)
_seed_product(sqlite_engine, PRODUCT_A)
_seed_product(sqlite_engine, PRODUCT_B)
_seed_holding(sqlite_engine, CUSTOMER, PRODUCT_A, "100", "100.00", date(2026, 8, 1))
_seed_holding(sqlite_engine, CUSTOMER, PRODUCT_B, "20", "22.00", date(2026, 8, 2))
# PRODUCT_B 已有批次 → 应被跳过
_seed_lot(sqlite_engine, "L-EXIST", CUSTOMER, PRODUCT_B, "20", "1.1000",
datetime(2026, 8, 2, 10, 0, 0))
stats = rebuild.rebuild_lots(sqlite_engine, customer_id=CUSTOMER)
assert stats["holdings"] == 2
assert stats["bootstrapped"] == 1
assert stats["skipped_existing"] == 1
assert stats["written"] == 1
lot_ids = {r["lot_id"] for r in _rows(sqlite_engine, "SELECT * FROM core_share_lot")}
assert lot_ids == {"L-EXIST", bootstrap_lot_id(CUSTOMER, PRODUCT_A)}
# 幂等:再跑一次零写入
again = rebuild.rebuild_lots(sqlite_engine, customer_id=CUSTOMER)
assert again["written"] == 0 and again["bootstrapped"] == 0
def test_rebuild_lots_force_replaces_existing(sqlite_engine):
"""`--force`:先删后建,批次按持仓快照重建(L-7)。"""
rebuild = _load_rebuild_lots()
_seed_customer(sqlite_engine, CUSTOMER)
_seed_product(sqlite_engine, PRODUCT_A)
_seed_holding(sqlite_engine, CUSTOMER, PRODUCT_A, "100", "100.00", date(2026, 8, 1))
_seed_lot(sqlite_engine, "L-STALE", CUSTOMER, PRODUCT_A, "77", "9.9999",
datetime(2026, 1, 1, 10, 0, 0))
stats = rebuild.rebuild_lots(sqlite_engine, customer_id=CUSTOMER, force=True)
assert stats["removed"] == 1 and stats["written"] == 1
rows = _rows(sqlite_engine, "SELECT * FROM core_share_lot")
assert [r["lot_id"] for r in rows] == [bootstrap_lot_id(CUSTOMER, PRODUCT_A)]
assert _dec(rows[0]["remain_qty"]) == Decimal("100")
def test_rebuild_lots_dry_run_writes_nothing(sqlite_engine):
"""`--dry-run`:只报告,不写库。"""
rebuild = _load_rebuild_lots()
_seed_customer(sqlite_engine, CUSTOMER)
_seed_product(sqlite_engine, PRODUCT_A)
_seed_holding(sqlite_engine, CUSTOMER, PRODUCT_A, "100", "100.00", date(2026, 8, 1))
stats = rebuild.rebuild_lots(sqlite_engine, customer_id=CUSTOMER, dry_run=True)
assert stats["bootstrapped"] == 1 and stats["written"] == 0
assert _rows(sqlite_engine, "SELECT * FROM core_share_lot") == []
def test_d18_gateway_bootstrap_and_rebuild_lots_agree():
"""**D18 同源断言**:同一 `core_holding` 行,网关兜底补建与 `rebuild_lots.py`
算出的批次必须**完全一致**(`confirmed_at` 逐一相等)。
这是 D18 设立的唯一目的 —— 两处各写一份「由 `as_of` 反推」必然漂移,
同一持仓会落到不同费率档(演示看不出、生产是错账)。故本用例同时验证
「两侧都在调 `bootstrap_lots`」这一事实:任一侧改成自己实现即变红。
"""
rebuild = _load_rebuild_lots()
gateway_engine = create_sqlite_engine()
script_engine = create_sqlite_engine()
try:
for engine in (gateway_engine, script_engine):
_seed_customer(engine, CUSTOMER)
_seed_product(engine, PRODUCT_A)
_seed_nav(engine, PRODUCT_A, "1.0000")
_seed_holding(engine, CUSTOMER, PRODUCT_A, "100", "123.45", date(2026, 8, 1))
# 侧 A:网关 D8 兜底补建(经 redeem 触发;补建 100 后会扣 30)
_maintain(
gateway_engine, trade_id="TRD-T10-D18", trade_type="redeem", amount="30"
)
# 侧 B:rebuild_lots 快照重建(不扣减)
rebuild.rebuild_lots(script_engine, customer_id=CUSTOMER)
got = _rows(gateway_engine, "SELECT * FROM core_share_lot")[0]
want = _rows(script_engine, "SELECT * FROM core_share_lot")[0]
assert got["lot_id"] == want["lot_id"]
assert got["confirmed_at"] == want["confirmed_at"] # ← D18 的核心断言
assert _dec(got["nav"]) == _dec(want["nav"])
assert _dec(got["qty"]) == _dec(want["qty"])
# 差异只应来自「侧 A 扣了 30 份」这件事本身
assert _dec(got["remain_qty"]) == _dec(want["remain_qty"]) - Decimal("30")
finally:
gateway_engine.dispose()
script_engine.dispose()
def test_rebuild_lots_uses_same_pure_function_as_gateway():
"""机制验证(防「两侧碰巧算出同值」的假绿):脚本模块内的补建**必须**来自
`bootstrap_lots`,而不是自带的第二份实现。"""
rebuild = _load_rebuild_lots()
assert rebuild.bootstrap_lots is bootstrap_lots
row = {
"customer_id": CUSTOMER,
"product_id": PRODUCT_A,
"qty": "100",
"cost_amount": "123.45",
"as_of": date(2026, 8, 1),
}
assert [lot.lot_id for lot in rebuild.bootstrap_lots(row)] == [
lot.lot_id for lot in bootstrap_lots(row)
]