"""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, timedelta 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.errors import InsufficientShares 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_fee_rule(engine, pid: str, rate: str = "0.0050") -> None: """灌一档赎回费率(`[0, NULL)`)—— `_redeem_quote` 金额反算需要费率档。""" with 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, 'redeem', 0, NULL, :rate)" ), {"pid": pid, "rate": float(rate)}, ) 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 | None = None, qty: str | None = None, product_id: str = PRODUCT_A, customer_id: str = CUSTOMER, traded_at: datetime = NOW_T, ) -> None: """直调批次维护入口(单元级:可控、可断言语义,端到端接线见 test_trade_gateway.py)。 **入参按申报方式分池(T-9 · D26/R-6)**: - `subscribe`(金额申购)传 `amount` —— 内部按净值折份额; - `redeem`(份额赎回)传 `qty` —— 直接复用申报份额,**不再 `amount ÷ nav` 反算**。 """ 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) if amount is not None else None, qty=Decimal(qty) if qty is not None else None, 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)) # D26:申报 120 份(份额申报,不再 120 元 ÷ 1.0 折算)→ L-OLD 扣满 100 归零,L-NEW 再扣 20 _maintain( sqlite_engine, trade_id="TRD-T10-RED", trade_type="redeem", qty="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", qty="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", qty="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", qty="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", qty="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", qty="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) ] # ═══════════════════════════════════════════════════════════════════════ # 9. T-10 新增:T+2 可扣过滤(R-4 · FR-C25)+ 哨兵(R-6)+ 在途占用(R-3) # ═══════════════════════════════════════════════════════════════════════ #: 固定日历:2026-09-04(五)/09-07(一)/09-08(二)/09-09(三) 为交易日(T/T+1/T+2/T+3) CAL_DATES = [ date(2026, 9, 4), date(2026, 9, 7), date(2026, 9, 8), date(2026, 9, 9), ] def _seed_calendar(engine) -> None: """灌 T 日(0924? 用 CAL_DATES)交易日历(R-5 数据源)—— T+2 过滤依赖。""" with engine.begin() as conn: for d in CAL_DATES: conn.execute( text( "INSERT INTO core_trade_calendar (cal_date, is_open) VALUES (:d, 1)" ), {"d": d}, ) def _seed_request( engine, gid: str, cid: str, pid: str, qty: str, status: str = "accepted" ) -> None: """灌一张受理单(在途占用 · R-3 需要)。""" with engine.begin() as conn: conn.execute( text( "INSERT INTO core_convert_request (convert_group_id, customer_id, " "from_product_id, to_product_id, qty, status, requested_at, updated_at) " "VALUES (:gid, :cid, :pid, 'PROD-T3B', :qty, :status, :at, :at)" ), { "gid": gid, "cid": cid, "pid": pid, "qty": float(qty), "status": status, "at": datetime(2026, 9, 4, 10, 0, 0), }, ) # 9.1 仓储层:T+2 可扣过滤透传(available_from 半开区间)───────────────── def test_list_share_lots_t2_filter_half_open_interval(sqlite_engine): """T+2 过滤(R-4):`available_from` 半开区间 `confirmed_at < af+1天`。 T 日 = 9-04,确认日 T+1 = 9-07(前一日 = 9-04)→ `available_from` 取 9-04:9-07 确认的转入批次(af+1=9-08 00:00 之前)**不可扣**。 """ _seed_customer(sqlite_engine, CUSTOMER) _seed_product(sqlite_engine, PRODUCT_A) _seed_lot(sqlite_engine, "L-T1", CUSTOMER, PRODUCT_A, "10", "1.00", datetime(2026, 9, 4, 10, 0, 0)) # T 日 _seed_lot(sqlite_engine, "L-T2", CUSTOMER, PRODUCT_A, "20", "1.00", datetime(2026, 9, 7, 10, 0, 0)) # T+1(9-07,确认日→不可扣) repo = CoreReadOnlyRepository(engine=sqlite_engine) # 业务日 D = 9-07(T+1):前一交易日 = 9-04 → af+1 = 9-05 → 只含 L-T1 only = repo.list_share_lots(CUSTOMER, PRODUCT_A, available_from=date(2026, 9, 4)) assert [l["lot_id"] for l in only] == ["L-T1"] # 业务日 D = 9-08(T+2):前一交易日 = 9-07 → af+1 = 9-08 → 含 L-T1 + L-T2 both = repo.list_share_lots(CUSTOMER, PRODUCT_A, available_from=date(2026, 9, 7)) assert [l["lot_id"] for l in both] == ["L-T1", "L-T2"] def test_select_for_convert_t2_filter_skips_t1_lot(sqlite_engine): """`select_for_convert(available_from=…)` 透传:T+1 批次在 T+2 前不可选。""" _seed_customer(sqlite_engine, CUSTOMER) _seed_product(sqlite_engine, PRODUCT_A) _seed_lot(sqlite_engine, "L-T1", CUSTOMER, PRODUCT_A, "100", "1.00", datetime(2026, 9, 4, 10, 0, 0)) _seed_lot(sqlite_engine, "L-T2", CUSTOMER, PRODUCT_A, "50", "1.00", datetime(2026, 9, 7, 10, 0, 0)) repo = ShareLotRepository(engine=sqlite_engine) # 请求 150 份 > 可扣 100 份(T+2 未到,T+2 批次不可扣)→ 只返回 100(差额由调用方判) sel = repo.select_for_convert( CUSTOMER, PRODUCT_A, Decimal("150"), available_from=date(2026, 9, 4) ) assert [s["lot_id"] for s in sel] == ["L-T1"] assert len(sel) == 1 # 9.2 网关层:T+2 边界 + 在途占用 + 哨兵(真实路径,自建完整种子)──────── def test_redeem_t2_not_yet_available_blocks_t1_lot(sqlite_engine): """**验收 27**:T+1 确认的转入批次在 T+2 前**不可扣**。 **区分度设计**:申报 150 > T 日批次余量 100 —— · T+2 过滤生效(业务日 9-07,af=9-04):只扣 L-T1(100 全扣),L-T2(9-07 确认、T+2 未到)**不动** → {L-T1: 0, L-T2: 50}; · 过滤失效:FIFO 继续扣到 L-T2 50 → {L-T1: 0, L-T2: 0}。 断言前值可区分「过滤是否真的生效」(防假绿)。 """ _seed_customer(sqlite_engine, CUSTOMER) _seed_product(sqlite_engine, PRODUCT_A) _seed_calendar(sqlite_engine) _seed_nav(sqlite_engine, PRODUCT_A, "1.0000") # T 日批次(9-04 确认)+ T+1 批次(9-07 确认,模拟 convert 转入) _seed_lot(sqlite_engine, "L-T1", CUSTOMER, PRODUCT_A, "100", "1.00", datetime(2026, 9, 4, 10, 0, 0)) _seed_lot(sqlite_engine, "L-T2", CUSTOMER, PRODUCT_A, "50", "1.00", datetime(2026, 9, 7, 10, 0, 0)) # 业务日 9-07(T+1):申报 150 → 可扣上限 = min(150, 物理 150) = 150; # 但 T+2 过滤只放行 T 日批次(L-T1 100),逐批 FIFO 后 L-T2 未被选中 _maintain( sqlite_engine, trade_id="TRD-T10-T2B", trade_type="redeem", qty="150", traded_at=datetime(2026, 9, 7, 14, 0, 0), ) rows = {r["lot_id"]: _dec(r["remain_qty"]) for r in _rows(sqlite_engine, "SELECT * FROM core_share_lot")} assert rows == {"L-T1": Decimal("0.00"), "L-T2": Decimal("50.00")} def test_redeem_t2_available_includes_t1_lot(sqlite_engine): """**验收 27 反向**:T+2 起 T+1 转入批次**可扣**。""" _seed_customer(sqlite_engine, CUSTOMER) _seed_product(sqlite_engine, PRODUCT_A) _seed_calendar(sqlite_engine) _seed_nav(sqlite_engine, PRODUCT_A, "1.0000") _seed_lot(sqlite_engine, "L-T1", CUSTOMER, PRODUCT_A, "100", "1.00", datetime(2026, 9, 4, 10, 0, 0)) _seed_lot(sqlite_engine, "L-T2", CUSTOMER, PRODUCT_A, "50", "1.00", datetime(2026, 9, 7, 10, 0, 0)) # 业务日 9-08(T+2):T+1 批次已 T+2 可扣 → 先扣老批次再扣新批次(FIFO) _maintain( sqlite_engine, trade_id="TRD-T10-T2A", trade_type="redeem", qty="120", traded_at=datetime(2026, 9, 8, 14, 0, 0), ) rows = {r["lot_id"]: _dec(r["remain_qty"]) for r in _rows(sqlite_engine, "SELECT * FROM core_share_lot")} assert rows == {"L-T1": Decimal("0.00"), "L-T2": Decimal("30.00")} def test_redeem_insufficient_from_t2_filter_raises(sqlite_engine): """T+2 过滤后余量不足 → `_redeem_quote` 抛 `InsufficientShares`(400)。 金额侧**硬校验**(不静默裁剪改写客户指令):请求 150 份,T+2 可扣只有 100 份 (T+1 批次 9-07 未到 T+2)→ **显式拒绝**。`_redeem_quote` 是 `submit_trade` 的金额入口,此异常沿错误映射出 400(见 CONVERT_ERROR_MATRIX)。 """ _seed_customer(sqlite_engine, CUSTOMER) _seed_product(sqlite_engine, PRODUCT_A) _seed_calendar(sqlite_engine) _seed_nav(sqlite_engine, PRODUCT_A, "1.0000") _seed_fee_rule(engine=sqlite_engine, pid=PRODUCT_A) _seed_lot(sqlite_engine, "L-T1", CUSTOMER, PRODUCT_A, "100", "1.00", datetime(2026, 9, 4, 10, 0, 0)) _seed_lot(sqlite_engine, "L-T2", CUSTOMER, PRODUCT_A, "50", "1.00", datetime(2026, 9, 7, 10, 0, 0)) with pytest.raises(InsufficientShares): tg._redeem_quote( CoreReadOnlyRepository(engine=sqlite_engine), customer_id=CUSTOMER, product_id=PRODUCT_A, qty=Decimal("150"), traded_at=datetime(2026, 9, 7, 14, 0, 0), # T+1:T+2 批次 9-07 未可扣 ) def test_redeem_respects_inflight_occupation(sqlite_engine): """**在途占用(R-3)**:已被未终态受理单占用的份额**不可赎回**(裁剪语义)。 `_maintain_lots` 的可扣上限 = min(申报 80, 可赎 100−30) = 70 → 只扣 70 (而非扣满 80)。「申报 > 可赎」的**硬拒绝**在金额侧 `_redeem_quote` (见 test_trade_gateway.py 的全链路用例),这里是批次侧的截止。 """ _seed_customer(sqlite_engine, CUSTOMER) _seed_product(sqlite_engine, PRODUCT_A) _seed_calendar(sqlite_engine) _seed_nav(sqlite_engine, PRODUCT_A, "1.0000") _seed_fee_rule(engine=sqlite_engine, pid=PRODUCT_A) _seed_lot(sqlite_engine, "L-IN", CUSTOMER, PRODUCT_A, "100", "1.00", datetime(2026, 9, 4, 10, 0, 0)) # 30 份转出占用(在途)→ 可赎 = 100 − 30 = 70 _seed_request(sqlite_engine, "CNV-IN1", CUSTOMER, PRODUCT_A, "30", "accepted") _maintain( sqlite_engine, trade_id="TRD-T10-IN1", trade_type="redeem", qty="80", traded_at=datetime(2026, 9, 4, 14, 0, 0), ) # 只扣 70(可赎上限),L-IN 100 → 30;不是 100−80=20 row = _rows(sqlite_engine, "SELECT * FROM core_share_lot")[0] assert _dec(row["remain_qty"]) == Decimal("30.00") def test_redeem_inflight_released_after_terminal_state(sqlite_engine): """在途占用只计未终态:受理单转终态(confirmed/rejected/cancelled/expired)后 占用自动释放,份额可再次赎回。""" _seed_customer(sqlite_engine, CUSTOMER) _seed_product(sqlite_engine, PRODUCT_A) _seed_calendar(sqlite_engine) _seed_nav(sqlite_engine, PRODUCT_A, "1.0000") _seed_fee_rule(engine=sqlite_engine, pid=PRODUCT_A) _seed_lot(sqlite_engine, "L-TERM", CUSTOMER, PRODUCT_A, "100", "1.00", datetime(2026, 9, 4, 10, 0, 0)) # 终态(cancelled)不占用 → 可赎 100 _seed_request(sqlite_engine, "CNV-TERM", CUSTOMER, PRODUCT_A, "30", "cancelled") _maintain( sqlite_engine, trade_id="TRD-T10-TERM", trade_type="redeem", qty="90", traded_at=datetime(2026, 9, 4, 14, 0, 0), ) rows = _rows(sqlite_engine, "SELECT * FROM core_share_lot") assert len(rows) == 1 and _dec(rows[0]["remain_qty"]) == Decimal("10.00") def test_deduct_sentinel_blocks_overdraw(sqlite_engine): """**哨兵(R-6)**:扣减 `remain_qty >= :q` 不满足 → rowcount=0 → 抛错回滚。 直测网关写侧(`deduct_share_lots`):传入期望扣 150 但批次只剩 100 → ValueError。 """ _seed_customer(sqlite_engine, CUSTOMER) _seed_product(sqlite_engine, PRODUCT_A) _seed_lot(sqlite_engine, "L-SENT", CUSTOMER, PRODUCT_A, "100", "1.00", datetime(2026, 9, 4, 10, 0, 0)) writer = GatewayRepository(engine=sqlite_engine) with pytest.raises(ValueError, match="哨兵触发"): writer.deduct_share_lots([("L-SENT", Decimal("150"))]) # 事务回滚:remain_qty 不变 row = _rows(sqlite_engine, "SELECT * FROM core_share_lot")[0] assert _dec(row["remain_qty"]) == Decimal("100.00") def test_redeem_after_convert_leaves_no_overdraw(sqlite_engine): """**验收 9(DoD 4)**:convert 转出后普通赎回 —— 批次已反映转出(remain 减少), 普通赎回只从**剩余份额**扣、不超扣(remain 恒非负)。 模拟 convert 确认段扣减后的状态(T-7 已测确认段扣减本身):初始批次 100 → 转出 30 → remain 70,holding 同步 70(真实场景两者由确认事务共同维护)。 随后普通赎回只按 70 的余量选批扣减;申报超过余量 → 裁剪到可赎上限 (`_maintain_lots` 三重裁剪 ③),归零也不越界。 """ _seed_customer(sqlite_engine, CUSTOMER) _seed_product(sqlite_engine, PRODUCT_A) _seed_calendar(sqlite_engine) _seed_nav(sqlite_engine, PRODUCT_A, "1.0000") # convert 确认段转出后:批次 remain 70(初始 100 − 30),holding 同步 70 _seed_lot(sqlite_engine, "L-CNV", CUSTOMER, PRODUCT_A, "70", "1.00", datetime(2026, 9, 4, 10, 0, 0)) _seed_holding(sqlite_engine, CUSTOMER, PRODUCT_A, "70", "70.00", date(2026, 9, 4)) # 赎回 50 ≤ 剩余 70 → 只扣 50,剩 20(不超扣) _maintain( sqlite_engine, trade_id="TRD-T10-CNV-R1", trade_type="redeem", qty="50", traded_at=datetime(2026, 9, 8, 14, 0, 0), ) row = _rows(sqlite_engine, "SELECT * FROM core_share_lot")[0] assert _dec(row["remain_qty"]) == Decimal("20.00") # 再赎回 30 > 剩 20 → 裁剪到可赎上限 20 → 归零(remain 恒非负,不超扣) _maintain( sqlite_engine, trade_id="TRD-T10-CNV-R2", trade_type="redeem", qty="30", traded_at=datetime(2026, 9, 8, 14, 0, 0), ) row = _rows(sqlite_engine, "SELECT * FROM core_share_lot")[0] assert _dec(row["remain_qty"]) == Decimal("0.00")