Files
group_xinghuo_jinrong/tests/test_share_lot.py
T
GaoYiYuan_0626 1a615bbbef 基金转换 T+1 模型:T-14 D27 产品舍入真正接入 + 联网核实纠偏(837 绿 / 真库 9/9 / 种子 v1.1 全 2 位)
- 接入面 4 处单点收敛:core_ro.get_share_digits(唯一回退链:规则行→core_product.share_digits→PLACES=2)· calc.in_qty 加 digits=PLACES 可选参数 · 确认段(business_type=convert)· 网关申购折算(subscribe);redeem 份额申报无折算无消费方
- 边界裁定:只接落库侧(开发计划 DoD 即边界),响应展示维持 q(D2) 收敛(2 位产品逐字节零漂移);PRD §2.5.2 展示按产品覆盖为登记开口
- 联网核实(用户铁律):主流 2 位 ✅ · 位数按各基金合同配置 ✅(南方 2012 调整公告)· 舍去 TRUNCATE 型真实存在 ✅ · 货基 2 位 ✅ · 「指数基金份额 4 位」证伪 ❌(ETF 联接/指数合同全 2 位)→ 11-seed-share-rule.sql v1.1:3 产品 9 行 4→2 + 头注重写为核实出处;「易方达 ETF 场外整数位」无证据有反证(登记开口待拍板)
- 测试 +7(830→837 零回归):calc 层 2 + confirm 段 3(回退链三层各 1 + business_type 干扰行)+ 申购段 2;4 位种子行 = D27 机制验证载体(docstring 钉明)
- 突变验证 2 组(confirm/gateway digits 改固定 2 → 各 2 红,还原零残留);真库 9/9 退出码 0(seed 重灌 42 行全 2 位生效)
- 开口 2 项待用户拍板:① PRD §2.5.3 易方达句修订 ② rounding_mode=truncate 消费接入(真实存在、T-14 范围外)
2026-09-12 12:41:20 +08:00

1044 lines
45 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, 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.1.1 D27 产品级舍入(T-14):申购折算份额按 core_share_rule 回退链 ──
def _seed_share_rule(engine, pid: str, business_type: str, digits: int) -> None:
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_share_rule (product_id, business_type, share_digits, "
"rounding_mode) VALUES (:pid, :bt, :digits, 'half_up')"
),
{"pid": pid, "bt": business_type, "digits": digits},
)
def test_subscribe_qty_four_places_via_share_rule(sqlite_engine):
"""T-14 DoD1(非降级):`subscribe` 规则行 4 位 → 折算份额 4 位。
同产品插一条 `convert` 型 2 位干扰行 —— 申购必须取 `subscribe` 型
(business_type 区分度;错取干扰行会得 833.33 而红)。
⚠️ 4 位是 **D27 机制验证载体**,非真实业务常态 —— 联网核实(2026-09-12):
公募场外申购份额主流 2 位(指数/联接/货基同样 2 位),真库种子 11-seed
全 2 位(v1.0 的「指数基金 4 位」依据已证伪并纠偏)。
"""
_seed_customer(sqlite_engine, CUSTOMER)
_seed_product(sqlite_engine, PRODUCT_A)
_seed_nav(sqlite_engine, PRODUCT_A, "1.2000")
_seed_share_rule(sqlite_engine, PRODUCT_A, "subscribe", 4)
_seed_share_rule(sqlite_engine, PRODUCT_A, "convert", 2)
_maintain(
sqlite_engine, trade_id="TRD-T14-SUB4", trade_type="subscribe", amount="1000"
)
row = _rows(sqlite_engine, "SELECT * FROM core_share_lot")[0]
# 1000 / 1.2 = 833.3333… → 4 位 833.3333(2 位会是 833.33)
assert _dec(row["remain_qty"]) == Decimal("833.3333")
def test_subscribe_qty_falls_back_to_product_share_digits(sqlite_engine):
"""T-14 回退链第二层:无规则行 → `core_product.share_digits`(显式置 4)。"""
_seed_customer(sqlite_engine, CUSTOMER)
_seed_product(sqlite_engine, PRODUCT_A)
_seed_nav(sqlite_engine, PRODUCT_A, "1.2000")
with sqlite_engine.begin() as conn:
conn.execute(
text("UPDATE core_product SET share_digits = 4 WHERE product_id = :pid"),
{"pid": PRODUCT_A},
)
_maintain(
sqlite_engine, trade_id="TRD-T14-SUBP4", trade_type="subscribe", amount="1000"
)
row = _rows(sqlite_engine, "SELECT * FROM core_share_lot")[0]
assert _dec(row["remain_qty"]) == Decimal("833.3333")
# ── 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")