让 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 同源断言)
703 lines
29 KiB
Python
703 lines
29 KiB
Python
"""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)
|
||
]
|