Files
group_xinghuo_jinrong/tests/test_convert_calc.py
T
GaoYiYuan_0626 048f1a9e1e 基金转换 T+1 模型:T-7 确认段落地(confirm_service 三段编排 + T+1 批处理)
【新增】
- app/service/convert/confirm_service.py:confirm_one(8 步确认)+ confirm_batch
  (按业务日捞单 / 整批锁 / 串行 / 单笔容错)。关键裁定:
  · T 日净值用 get_nav_on 精确匹配,缺则 nav_pending(绝不回退旧净值);
  · 扣批次 + 2 条流水 + 转入批次 + 两端持仓 + 明细 + 受理单置 confirmed
    同处一个 Core 单库事务,状态被抢(rowcount!=1)→ 整事务回滚;
  · 引擎在事务 commit 后跑,异常不阻断已成立的交易(FR-C28);
  · 部分成交 actual=min(申请,可用),被抢部分占用自然释放(R-10)。
- app/service/convert/format.py / audit.py / engine_call.py:展示规格、审计、
  引擎调用三处共用出口抽出(受理/确认两段不再各写一份,避免口径漂移)。
- scripts/dev/verify_convert_confirm.py:真库验证 81 项断言,含 PRD §5.3.2
  示例在真库上逐字节重放(68020.00/612.18/67407.82/333.36/67074.46/34945.54/-0.0049)。

【修复 · 受理-确认接口契约缺口】
强制全转是**受理段决策**(受理时 qty 已收敛为实际全转量),确认段拿不到原始
申请量、无法复现该判定。修法:
- 受理段把 forced_full_transfer 落受理单 remark(新增 REMARK_FULL_TRANSFER);
- 确认段改为**继承受理决策、不再重判最低持有**(plan_lots 不传 min_hold_qty)
  —— 重判会因 T→T+1 可用份额变化得出与受理承诺不一致的结论(擅自扩大客户指令);
- remark 支持多标记 `;` 连接(full_transfer;partial)。
- MySQL rowcount=changed rows 陷阱:nav_pending 重试不得复用 transition_status
  的冲突判定,改为 status 未变时不迁移、返回 transitioned=False。

【其他】
- convert_service:新增 cancel_convert(T 日撤单,两道闸门)、_t1_t2_dates
  (日历边界 None 容错);受理响应改为 PRD §5.3.1 字段;convert_fund 标 Deprecated。
- convert_core_repository:ConvertApplyInput 加 convert_request_id/diff_fee/
  request_remark;_apply_convert_once 末步 _confirm_request 事务内置状态守卫。
- convert_request_repository:_end_of_day 统一闭区间语义;新增 reject()。
- 测试:新增 tests/test_convert_confirm.py(24 例);全量 827 passed / 10 skipped。
2026-09-11 19:35:37 +08:00

716 lines
33 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-2 基金转换纯函数单测(开发计划 §4.2 DoD)。
覆盖 12 类:
1. **精度**:全部量化点必须 `ROUND_HALF_UP`(`Decimal` 默认是 `ROUND_HALF_EVEN`,
架构风险 #3 —— `.5` 边界处两种舍入结果不同,必须命中 HALF_UP 那一侧);
2. **分档边界**:`6/7/29/30/179/180/364/365`(左闭右开,满 7 日归 7–30 档);
3. **FIFO**:主序 `confirmed_at`、同 `confirmed_at` 以 `lot_id` 兜底(评审 S1);
4. **跨批次计费**:逐批先舍入后求和(PRD §5.3 主示例 154.50 + 309.00);
5. **补差费双口径**:B `252.40` / A `253.91`(同输入,差约 0.6%);同费率归零;
6. **最低持有处置**:`force_transfer` / `force_redeem` 双动作、
**恰好等于阈值不触发**、**零剩余不触发**(实现级裁定);
7. **全链自证**:PRD §5.3 主示例每个数字逐项复算(含 `rounding_diff = -0.0026`);
8. **持有期**:不含申请日、T+1 起算少 1 天;
9. **净值**:无净值 → 503;过期 → 只标 `nav_stale` 不阻断;
10. **批次补建**:D18 确定性(`crc32` 而非内置 `hash()`);
11. **错误码映射**:§8.3 全表;
12. **纯函数约束**:convert 包源码零仓储/引擎/配置依赖(DoD 第 3 条,
从"人工 grep"升级为断言)。
本文件**不依赖 DB、不依赖 conftest 夹具**——纯函数包的全部输入由测试自建。
"""
from __future__ import annotations
import re
import zlib
from datetime import date, datetime, time, timedelta
from decimal import Decimal
from pathlib import Path
import pytest
from app.service.convert import calc, fee, lot_bootstrap, nav
from app.service.convert.errors import (
BelowMinQty,
ConvertError,
CrossEntityNotSupported,
FeeRuleMissing,
IdempotencyUnavailable,
InsufficientShares,
LotConflict,
NavNotReady,
ProductNotRedeemable,
ProductNotSubscribable,
SameProduct,
TooManyLots,
)
from app.service.convert.types import FeeRule, Lot
# ── 公共夹具数据 ─────────────────────────────────────────────────────
#: 与 PRD §5.3 示例同一交易日(也是 `06-seed-nav.sql` 的净值日期)。
TRADE_DATE = date(2026, 9, 4)
#: PRD §4.3 / 07-seed-fee-rule.sql 的五档赎回费(22 号文 §10 下限)。
RULES = [
FeeRule(min_hold_days=0, max_hold_days=7, rate=Decimal("0.0150")),
FeeRule(min_hold_days=7, max_hold_days=30, rate=Decimal("0.0100")),
FeeRule(min_hold_days=30, max_hold_days=180, rate=Decimal("0.0050")),
FeeRule(min_hold_days=180, max_hold_days=365, rate=Decimal("0.0025")),
FeeRule(min_hold_days=365, max_hold_days=None, rate=Decimal("0.0000")),
]
#: 主示例两端申购费率(09-seed-org.sql:110022 债基 0.0030 → 003095 主动偏股 0.0080)。
OUT_SUB_RATE = Decimal("0.0030")
IN_SUB_RATE = Decimal("0.0080")
def _lot(lot_id: str, hold_days: int, qty: str, nav: str = "1.0300") -> Lot:
"""构造 `hold_days` 天前确认的批次(交易日固定为 TRADE_DATE)。"""
confirmed = datetime.combine(TRADE_DATE - timedelta(days=hold_days), time(10, 0, 0))
return Lot(
lot_id=lot_id,
confirmed_at=confirmed,
remain_qty=Decimal(qty),
nav=Decimal(nav),
qty=Decimal(qty),
)
def _fee_of(lot_allocation, hold_days: int) -> Decimal:
"""按 `plan_lots` 的分配结果算单批金额与赎回费。"""
amount = calc.lot_amount(lot_allocation.qty, lot_allocation.nav)
return calc.lot_fee(amount, fee.pick_fee_rate(RULES, hold_days))
# ── 1. 精度:HALF_UP 而非 HALF_EVEN ─────────────────────────────────
class TestRounding:
"""所有量化点显式 `ROUND_HALF_UP`(架构风险 #3)。"""
def test_round2_half_up_away_from_zero(self):
# Decimal 默认 ROUND_HALF_EVEN 会得到 0.00(偶数侧);HALF_UP 必须是 0.01
assert calc.round2(Decimal("0.005")) == Decimal("0.01")
assert calc.round2(Decimal("0.015")) == Decimal("0.02")
def test_default_context_differs_from_half_up(self):
"""反向自证:若实现漏传 rounding,本用例会红。"""
from decimal import ROUND_HALF_EVEN
banker = Decimal("0.005").quantize(Decimal("0.01"), rounding=ROUND_HALF_EVEN)
assert banker == Decimal("0.00")
assert calc.round2(Decimal("0.005")) != banker
def test_lot_amount_2_places_half_up(self):
# 0.5 份 × 1.0001 = 0.50005 → 0.50;1.5 份 × 0.0001 之类极小值也要能舍
assert calc.lot_amount(Decimal("1.5"), Decimal("0.0034")) == Decimal("0.01")
assert calc.lot_amount(Decimal("30000.0000"), Decimal("1.0300")) == Decimal("30900.00")
def test_lot_fee_2_places_half_up(self):
assert calc.lot_fee(Decimal("30900.00"), Decimal("0.0050")) == Decimal("154.50")
assert calc.lot_fee(Decimal("20600.00"), Decimal("0.0150")) == Decimal("309.00")
def test_in_qty_2_places_half_up_not_floor(self):
# v1.0 勘误:份额是 2 位四舍五入,不是 4 位向下取整
# 50784.10 / 0.95 = 53456.9473... → 53456.95(向下取整会得 53456.94)
assert calc.in_qty(Decimal("50784.10"), Decimal("0.9500")) == Decimal("53456.95")
def test_in_qty_rejects_non_positive_nav(self):
with pytest.raises(ValueError):
calc.in_qty(Decimal("100"), Decimal("0"))
def test_rounding_diff_negative_means_client_gains(self):
# 理论 53456.9474 − 实得 53456.95 = -0.0026(负 = 客户多得)
diff = calc.rounding_diff(Decimal("50784.10"), Decimal("0.9500"), Decimal("53456.95"))
assert diff == Decimal("-0.0026")
# ── 1.5 T-2 新增纯函数(D27 产品舍入 / D26 redeem_amount / D28 partial)----
class TestProductRound:
"""product_round:产品级份额位数舍入(D27 / R-13 · T-2 只建入口不接链路)。"""
def test_default_two_places_matches_round2(self):
"""位数=2 时与既有 round2 行为逐字节一致(T-14 一致性断言前置)。"""
assert calc.product_round(Decimal("53456.9474"), 2) == calc.round2(
Decimal("53456.9474")
)
assert calc.product_round(Decimal("0.005"), 2) == Decimal("0.01")
def test_four_places(self):
"""指数/货基 4 位份额(11-seed-share-rule.sql 注释依据)。"""
assert calc.product_round(Decimal("12345.67891"), 4) == Decimal("12345.6789")
assert calc.product_round(Decimal("12345.67895"), 4) == Decimal("12345.6790")
def test_zero_places(self):
"""个别整份产品 0 位取整。"""
assert calc.product_round(Decimal("12.5"), 0) == Decimal("13")
assert calc.product_round(Decimal("12.4"), 0) == Decimal("12")
def test_negative_digits_rejected(self):
with pytest.raises(ValueError):
calc.product_round(Decimal("1.0"), -1)
class TestRedeemAmount:
"""redeem_amount:份额申报 → 赎回金额(D26 · FR-C29)。"""
def test_qty_times_nav_minus_fee(self):
# 500份 × 1.0300 − 154.50 赎回费 = 515.00 − 154.50 = 360.50
assert calc.redeem_amount(Decimal("500.00"), Decimal("1.0300"), Decimal("154.50")) == (
Decimal("360.50")
)
assert calc.redeem_amount(Decimal("0.00"), Decimal("1.0300"), Decimal("10")) == (
Decimal("-10.00")
)
def test_fee_exceeds_amount_stays_negative(self):
"""费用 > 金额:结果为负(调用方按业务分支处理,如全额转出豁免)。"""
assert calc.redeem_amount(Decimal("1.00"), Decimal("1.00"), Decimal("5.00")) == (
Decimal("-4.00")
)
def test_two_places_half_up(self):
assert calc.redeem_amount(Decimal("0.005"), Decimal("1"), Decimal("0")) == Decimal(
"0.01"
)
class TestPartialQty:
"""partial_qty:部分成交辅助(D28 / R-10)。"""
def test_enough_available_returns_requested(self):
assert calc.partial_qty(Decimal("500"), Decimal("800")) == Decimal("500")
assert calc.partial_qty(Decimal("500"), Decimal("500")) == Decimal("500")
def test_short_available_returns_available(self):
assert calc.partial_qty(Decimal("500"), Decimal("300")) == Decimal("300")
def test_zero_available_returns_zero(self):
"""占用全被抢 → 0(调用方据此 rejected,T-15 边界)。"""
assert calc.partial_qty(Decimal("500"), Decimal("0")) == Decimal("0")
# ── 2. 费率分档边界 ─────────────────────────────────────────────────
class TestFeeBands:
"""左闭右开 `[min, max)`;满 7 日归 7–30 档(PRD §12 I-2)。"""
@pytest.mark.parametrize(
("hold_days", "expected"),
[
(0, "0.0150"),
(6, "0.0150"), # < 7 日
(7, "0.0100"), # 满 7 日 → 进 7–30 档
(29, "0.0100"),
(30, "0.0050"), # 满 30 日 → 进 30–180 档
(179, "0.0050"),
(180, "0.0025"),
(364, "0.0025"),
(365, "0.0000"),
(1000, "0.0000"), # max_hold_days = NULL 表示无上限
],
)
def test_band_boundaries(self, hold_days, expected):
assert fee.pick_fee_rate(RULES, hold_days) == Decimal(expected)
def test_missing_rule_raises_instead_of_zero(self):
# 无命中绝不能静默按 0 收费(少收赎回费且不留痕)
with pytest.raises(FeeRuleMissing) as exc:
fee.pick_fee_rate([FeeRule(365, None, Decimal("0.0000"))], 3)
assert exc.value.error_code == "FEE_RULE_MISSING"
assert exc.value.status_code == 500
def test_subscribe_rules_are_ignored(self):
"""`fee_type='subscribe'` 本期不启用,混入规则表也不得被选中。"""
rules = [
FeeRule(0, 7, Decimal("0.8000"), fee_type="subscribe"),
FeeRule(0, 7, Decimal("0.0150"), fee_type="redeem"),
]
assert fee.pick_fee_rate(rules, 3) == Decimal("0.0150")
def test_overlapping_bands_take_most_specific(self):
"""区间误配重叠时取 `min_hold_days` 最大者,避免"取到哪档看运气"。"""
rules = [
FeeRule(0, 365, Decimal("0.0050")),
FeeRule(30, 180, Decimal("0.0025")),
]
# 排序后被 max(min_hold_days) 选中,与传入顺序无关
assert fee.pick_fee_rate(rules, 100) == Decimal("0.0025")
assert fee.pick_fee_rate(list(reversed(rules)), 100) == Decimal("0.0025")
# ── 3. FIFO 排序与分配 ───────────────────────────────────────────────
class TestFifoPlan:
def test_allocates_oldest_lot_first(self):
lots = [_lot("LOT-B", 3, "20000.0000"), _lot("LOT-A", 100, "30000.0000")]
plan = calc.plan_lots(lots, Decimal("40000"))
assert [a.lot_id for a in plan.allocations] == ["LOT-A", "LOT-B"]
assert plan.allocations[0].qty == Decimal("30000.0000")
assert plan.allocations[1].qty == Decimal("10000.0000")
def test_same_confirmed_at_tiebreak_by_lot_id(self):
"""同一注册日多批次顺序不可由存储引擎决定(评审 S1)。"""
same = datetime.combine(TRADE_DATE - timedelta(days=10), time(10, 0, 0))
lots = [
Lot("LOT-Z", same, Decimal("100.0000"), Decimal("1.0000")),
Lot("LOT-A", same, Decimal("100.0000"), Decimal("1.0000")),
]
plan = calc.plan_lots(lots, Decimal("150"))
assert [a.lot_id for a in plan.allocations] == ["LOT-A", "LOT-Z"]
def test_ignores_zero_remain_qty_lots(self):
lots = [
_lot("LOT-USED", 100, "1000.0000"),
_lot("LOT-FRESH", 3, "500.0000"),
]
lots[0] = Lot(
lots[0].lot_id, lots[0].confirmed_at, Decimal("0.0000"), lots[0].nav
)
plan = calc.plan_lots(lots, Decimal("500"))
assert [a.lot_id for a in plan.allocations] == ["LOT-FRESH"]
def test_insufficient_shares(self):
lots = [_lot("LOT-A", 100, "1000.0000")]
with pytest.raises(InsufficientShares) as exc:
calc.plan_lots(lots, Decimal("1000.0001"))
assert exc.value.error_code == "INSUFFICIENT_SHARES"
def test_requested_must_be_positive(self):
with pytest.raises(ValueError):
calc.plan_lots([_lot("LOT-A", 100, "1000")], Decimal("0"))
def test_batch_count_equals_allocations_len(self):
lots = [_lot(f"LOT-{i}", 100 - i, "1000.0000") for i in range(3)]
plan = calc.plan_lots(lots, Decimal("2500"))
assert plan.batch_count == len(plan.allocations) == 3
def test_available_qty_uses_remain_qty(self):
"""份额足够判据以 `Σ remain_qty` 为准,不用 `core_holding.qty`(PRD §12)。"""
lots = [_lot("LOT-A", 100, "1000.0000")]
lots[0] = Lot(
lots[0].lot_id,
lots[0].confirmed_at,
Decimal("300.0000"),
lots[0].nav,
qty=Decimal("1000.0000"), # 原始份额更大,但已扣减
)
plan = calc.plan_lots(lots, Decimal("300"))
assert plan.available_qty == Decimal("300.0000")
# ── 4. 最低持有余额处置 ──────────────────────────────────────────────
class TestMinHoldAction:
"""触发条件严格为「余额 < 阈值」(非 ≤);PRD §12 I-4。"""
def test_below_threshold_forces_full_transfer(self):
lots = [_lot("LOT-A", 100, "6000.0000")]
plan = calc.plan_lots(
lots, Decimal("5500"), Decimal("1000"), "force_transfer"
)
assert plan.forced_full_transfer is True
assert plan.action == "force_transfer"
assert plan.actual_qty == Decimal("6000.0000") # 全转,客户指令被改变
assert plan.requested_qty == Decimal("5500")
def test_below_threshold_force_redeem_action(self):
lots = [_lot("LOT-A", 100, "6000.0000")]
plan = calc.plan_lots(lots, Decimal("5500"), Decimal("1000"), "force_redeem")
assert plan.action == "force_redeem"
assert plan.actual_qty == Decimal("6000.0000")
def test_leftover_equal_threshold_not_triggered(self):
"""PRD 审查例:持 6000、申请 5000、阈值 1000 → 余额恰好 1000 → 不触发。"""
lots = [_lot("LOT-A", 100, "6000.0000")]
plan = calc.plan_lots(lots, Decimal("5000"), Decimal("1000"), "force_transfer")
assert plan.forced_full_transfer is False
assert plan.action == "transfer"
assert plan.actual_qty == Decimal("5000")
def test_zero_leftover_not_forced(self):
"""实现级裁定:客户本就是清仓(余额 = 0)不得被标记为"强制"。
否则响应里的 `forced_full_transfer=True` 会被前端当成
"你的指令被系统改了",而实际转出份额与申请完全一致。
"""
lots = [_lot("LOT-A", 100, "6000.0000")]
plan = calc.plan_lots(lots, Decimal("6000"), Decimal("1000"), "force_transfer")
assert plan.forced_full_transfer is False
assert plan.actual_qty == plan.requested_qty == Decimal("6000.0000")
def test_zero_threshold_disables_check(self):
lots = [_lot("LOT-A", 100, "6000.0000")]
plan = calc.plan_lots(lots, Decimal("5000"), Decimal("0"), "force_transfer")
assert plan.forced_full_transfer is False
def test_unknown_action_rejected(self):
lots = [_lot("LOT-A", 100, "6000.0000")]
with pytest.raises(ValueError):
calc.plan_lots(lots, Decimal("5500"), Decimal("1000"), "force_dance")
# ── 5. 补差费双口径 ─────────────────────────────────────────────────
class TestDiffFee:
CONV = Decimal("51036.50")
def test_amount_diff_is_default_and_matches_prd(self):
# 405.05(转入端 0.8% 价外费)− 152.65(转出端 0.3% 价外费)
assert calc.diff_fee(self.CONV, OUT_SUB_RATE, IN_SUB_RATE) == Decimal("252.40")
assert calc.diff_fee(
self.CONV, OUT_SUB_RATE, IN_SUB_RATE, "amount_diff"
) == Decimal("252.40")
def test_rate_diff_matches_prd(self):
assert calc.diff_fee(
self.CONV, OUT_SUB_RATE, IN_SUB_RATE, "rate_diff"
) == Decimal("253.91")
def test_two_modes_actually_differ(self):
"""两口径差约 0.6% —— 必须显式选口径,不可混用(PRD §2.1.1 / Q9)。"""
b = calc.diff_fee(self.CONV, OUT_SUB_RATE, IN_SUB_RATE, "amount_diff")
a = calc.diff_fee(self.CONV, OUT_SUB_RATE, IN_SUB_RATE, "rate_diff")
assert a != b
@pytest.mark.parametrize("mode", ["amount_diff", "rate_diff"])
def test_zero_when_in_rate_not_higher(self, mode):
assert calc.diff_fee(self.CONV, IN_SUB_RATE, OUT_SUB_RATE, mode) == Decimal("0.00")
assert calc.diff_fee(self.CONV, OUT_SUB_RATE, OUT_SUB_RATE, mode) == Decimal("0.00")
def test_zero_rate_inbound_gives_zero(self):
"""转入端为货基(申购费 0)→ 不补差。"""
assert calc.diff_fee(self.CONV, OUT_SUB_RATE, Decimal("0"), "amount_diff") == Decimal("0.00")
def test_invalid_mode_rejected(self):
with pytest.raises(ValueError):
calc.diff_fee(self.CONV, OUT_SUB_RATE, IN_SUB_RATE, "whatever")
# ── 6. 全链自证(PRD §5.3 逐行) ─────────────────────────────────────
class TestPrdMainExample:
"""主示例每个派生值都由本块数据算出(v0.9.1 · 脚本实算回填)。"""
@pytest.fixture
def chain(self):
lots = [_lot("LOT-A", 100, "30000.0000"), _lot("LOT-B", 3, "20000.0000")]
plan = calc.plan_lots(lots, Decimal("50000"))
out_amount = sum(
(calc.lot_amount(a.qty, a.nav) for a in plan.allocations), Decimal("0")
)
redeem_fee = sum(
(
_fee_of(a, calc.hold_days(TRADE_DATE, a.confirmed_at))
for a in plan.allocations
),
Decimal("0"),
)
return plan, out_amount, redeem_fee
def test_per_lot_breakdown(self, chain):
plan, _, _ = chain
assert [a.qty for a in plan.allocations] == [
Decimal("30000.0000"),
Decimal("20000.0000"),
]
assert calc.hold_days(TRADE_DATE, plan.allocations[0].confirmed_at) == 100
assert calc.hold_days(TRADE_DATE, plan.allocations[1].confirmed_at) == 3
assert _fee_of(plan.allocations[0], 100) == Decimal("154.50")
assert _fee_of(plan.allocations[1], 3) == Decimal("309.00")
def test_amounts_chain(self, chain):
plan, out_amount, redeem_fee = chain
assert out_amount == Decimal("51500.00")
assert redeem_fee == Decimal("463.50") # 逐批舍入后求和
conv = calc.convert_amount(out_amount, redeem_fee)
assert conv == Decimal("51036.50")
diff_b = calc.diff_fee(conv, OUT_SUB_RATE, IN_SUB_RATE, "amount_diff")
assert diff_b == Decimal("252.40")
in_amount = calc.convert_amount(conv, diff_b)
assert in_amount == Decimal("50784.10")
assert calc.in_qty(in_amount, Decimal("0.9500")) == Decimal("53456.95")
assert (
calc.rounding_diff(in_amount, Decimal("0.9500"), Decimal("53456.95"))
== Decimal("-0.0026")
)
def test_three_amount_fields_are_distinct(self, chain):
"""`out_amount` / `convert_amount` / `in_amount` 三者不可混用(PRD M-4)。"""
_, out_amount, redeem_fee = chain
conv = calc.convert_amount(out_amount, redeem_fee)
in_amount = calc.convert_amount(conv, Decimal("252.40"))
assert out_amount != conv != in_amount
assert out_amount == Decimal("51500.00")
assert conv == Decimal("51036.50")
assert in_amount == Decimal("50784.10")
def test_same_rate_control_group(self, chain):
"""同费率对照(110022 0.0030 → 510300 0.0030):补差 0,份额 53722.63。"""
_, out_amount, redeem_fee = chain
conv = calc.convert_amount(out_amount, redeem_fee)
diff = calc.diff_fee(conv, OUT_SUB_RATE, OUT_SUB_RATE, "amount_diff")
assert diff == Decimal("0.00")
in_amount = calc.convert_amount(conv, diff)
assert in_amount == conv == Decimal("51036.50")
assert calc.in_qty(in_amount, Decimal("0.9500")) == Decimal("53722.63")
# ── 7. 持有期 ───────────────────────────────────────────────────────
class TestHoldDays:
def test_excludes_application_day(self):
"""(交易日 − 确认日).days,不含申请日:昨日确认今天申请 = 1 天。"""
assert calc.hold_days(date(2026, 9, 4), datetime(2026, 9, 3, 10)) == 1
def test_t_plus_one_confirmation_is_one_day_less(self):
"""转换转入批次以 T+1 为确认日 → 同一交易日下持有期比"自 T 起算"少 1 天。
这是更贴近真实的做法(真实持有期自确认日起算),费率档因此更严(PRD B-5)。
"""
trade = date(2026, 9, 4)
from_t = calc.hold_days(trade, datetime(2026, 8, 25, 10))
from_t1 = calc.hold_days(trade, datetime(2026, 8, 26, 10))
assert from_t == 10
assert from_t1 == 9
def test_full_seven_days_lands_in_second_band(self):
assert calc.hold_days(date(2026, 9, 10), datetime(2026, 9, 3, 10)) == 7
assert fee.pick_fee_rate(RULES, 7) == Decimal("0.0100")
def test_rejects_none(self):
with pytest.raises(ValueError):
calc.hold_days(None, datetime(2026, 9, 3, 10))
with pytest.raises(ValueError):
calc.hold_days(date(2026, 9, 4), None)
# ── 8. 净值口径 ─────────────────────────────────────────────────────
class TestNav:
def test_missing_nav_raises_503(self):
with pytest.raises(NavNotReady) as exc:
nav.ensure_nav_ready(None, product_id="PROD-X")
assert exc.value.status_code == 503
assert exc.value.error_code == "NAV_NOT_READY"
def test_present_nav_passes(self):
nav.ensure_nav_ready(date(2026, 9, 4), product_id="PROD-110022")
@pytest.mark.parametrize(
("nav_date", "expected"),
[("2026-09-04", False), ("2026-09-01", False), ("2026-08-31", True)],
)
def test_stale_boundary_is_strictly_greater(self, nav_date, expected):
# 阈值 3 天:距交易日恰好 3 天不算过期(PRD §2.2「超过此值」)
assert nav.is_stale(date.fromisoformat(nav_date), TRADE_DATE, 3) is expected
def test_is_stale_rejects_missing_nav(self):
""""无净值"必须走 503,不得混进 stale 分支(两者处置完全不同)。"""
with pytest.raises(ValueError):
nav.is_stale(None, TRADE_DATE, 3)
def test_evaluate_nav_returns_stale_flag(self):
assert nav.evaluate_nav(date(2026, 9, 4), TRADE_DATE, 3) is False
assert nav.evaluate_nav(date(2026, 8, 1), TRADE_DATE, 3) is True
def test_evaluate_nav_raises_when_missing(self):
with pytest.raises(NavNotReady):
nav.evaluate_nav(None, TRADE_DATE, 3, product_id="PROD-X")
# ── 9. 批次兜底补建(D8 / D18) ──────────────────────────────────────
class TestBootstrapLots:
HOLDING = {
"customer_id": "CUST-9527",
"product_id": "PROD-110022",
"qty": Decimal("80000.0000"),
"cost_amount": Decimal("82400.00"),
"market_value": Decimal("82400.00"),
"pnl_pct": Decimal("0.0000"),
"as_of": date(2026, 9, 4),
}
def test_single_lot_equals_holding_qty(self):
lots = lot_bootstrap.bootstrap_lots(self.HOLDING)
assert len(lots) == 1
assert lots[0].remain_qty == lots[0].qty == Decimal("80000.0000")
# Σ remain_qty == core_holding.qty(08 种子的同一不变量)
assert sum(lot.remain_qty for lot in lots) == self.HOLDING["qty"]
def test_deterministic_across_calls(self):
"""同一持仓重复补建结果完全一致 —— D18 同源断言的立足点。"""
first = lot_bootstrap.bootstrap_lots(self.HOLDING)
for _ in range(50):
assert lot_bootstrap.bootstrap_lots(self.HOLDING) == first
def test_offset_uses_crc32_not_builtin_hash(self):
"""必须用 `zlib.crc32`:内置 `hash()` 受 PYTHONHASHSEED 随机化,
会让 gateway 与 rebuild_lots.py(两个进程)算出不同 confirmed_at。"""
key = "CUST-9527|PROD-110022"
expected = lot_bootstrap.BOOTSTRAP_OFFSET_DAYS[
zlib.crc32(key.encode("utf-8")) % len(lot_bootstrap.BOOTSTRAP_OFFSET_DAYS)
]
assert lot_bootstrap.offset_for("CUST-9527", "PROD-110022") == expected
def test_lot_id_is_deterministic_and_within_column_width(self):
lot_id = lot_bootstrap.bootstrap_lot_id("CUST-9527", "PROD-110022")
assert lot_id == lot_bootstrap.bootstrap_lot_id("CUST-9527", "PROD-110022")
assert len(lot_id) <= 64
# 超长组合不抛异常(D8:兜底补建不跳过、不阻断)
long_id = lot_bootstrap.bootstrap_lot_id("C" * 64, "P" * 64)
assert len(long_id) <= 64
def test_nav_approximates_cost_per_share(self):
lots = lot_bootstrap.bootstrap_lots(self.HOLDING)
# 82400.00 / 80000 = 1.0300(4 位 HALF_UP)
assert lots[0].nav == Decimal("1.0300")
def test_offset_covers_all_five_bands_across_customers(self):
"""错开规则必须真能覆盖 5 档,否则分档分支在兜底路径上无数据。"""
offsets = {
lot_bootstrap.offset_for(f"CUST-{i:04d}", "PROD-110022") for i in range(1, 60)
}
assert offsets == set(lot_bootstrap.BOOTSTRAP_OFFSET_DAYS)
def test_forced_offset_pins_band(self):
lots = lot_bootstrap.bootstrap_lots(self.HOLDING, offset_days=3)
assert calc.hold_days(TRADE_DATE, lots[0].confirmed_at) == 3
assert fee.pick_fee_rate(RULES, 3) == Decimal("0.0150")
def test_zero_qty_holding_returns_no_lot(self):
holding = dict(self.HOLDING, qty=Decimal("0.0000"))
assert lot_bootstrap.bootstrap_lots(holding) == []
def test_missing_as_of_rejected(self):
holding = dict(self.HOLDING, as_of=None)
with pytest.raises(ValueError):
lot_bootstrap.bootstrap_lots(holding)
# ── 10. 错误码映射(架构 §8.3) ──────────────────────────────────────
ERROR_CASES = [
(ProductNotRedeemable, 400, "PRODUCT_NOT_REDEEMABLE"),
(ProductNotSubscribable, 400, "PRODUCT_NOT_SUBSCRIBABLE"),
(InsufficientShares, 400, "INSUFFICIENT_SHARES"),
(BelowMinQty, 400, "BELOW_MIN_QTY"),
(SameProduct, 400, "SAME_PRODUCT"),
(CrossEntityNotSupported, 400, "CROSS_ENTITY_NOT_SUPPORTED"),
(LotConflict, 409, "LOT_CONFLICT"),
(NavNotReady, 503, "NAV_NOT_READY"),
(IdempotencyUnavailable, 503, "IDEMPOTENCY_UNAVAILABLE"),
(FeeRuleMissing, 500, "FEE_RULE_MISSING"),
]
class TestErrorMapping:
@pytest.mark.parametrize(("cls", "status", "code"), ERROR_CASES)
def test_status_and_code(self, cls, status, code):
err = cls()
assert isinstance(err, ConvertError)
assert err.status_code == status
assert err.error_code == code
assert err.message # 必须有可读 message,不能是空串
def test_too_many_lots_carries_batch_context(self):
err = TooManyLots(batch_count=260, max_lots=200)
assert err.status_code == 400
assert err.error_code == "TOO_MANY_LOTS"
assert err.extra == {"batch_count": 260, "max_lots": 200}
assert "260" in err.message and "200" in err.message
def test_ensure_batch_limit_raises_over_limit(self):
lots = [_lot(f"LOT-{i}", 100 - i, "1000.0000") for i in range(5)]
plan = calc.plan_lots(lots, Decimal("5000"))
calc.ensure_batch_limit(plan, 5) # 恰好等于上限 → 放行
with pytest.raises(TooManyLots) as exc:
calc.ensure_batch_limit(plan, 4)
assert exc.value.batch_count == 5
def test_batch_limit_checked_after_planning(self):
"""先规划再判上限:异常里带的是**实际所需**批次数,而非上限本身(§8.3)。"""
lots = [_lot(f"LOT-{i}", 100 - i, "1000.0000") for i in range(7)]
plan = calc.plan_lots(lots, Decimal("7000"))
with pytest.raises(TooManyLots) as exc:
calc.ensure_batch_limit(plan, 3)
assert exc.value.batch_count == 7
# ── 11. 纯函数约束(DoD 第 3 条) ────────────────────────────────────
CONVERT_DIR = Path(__file__).resolve().parents[1] / "app" / "service" / "convert"
#: import 行中一旦出现即说明纯函数包被 IO 污染("不查库、不碰 SQL"是 D1/D18 的前提)。
#: 只比对 **import 语句**:docstring 里提到 `core_ro.get_nav_as_of(...)` 是在说明
#: "输入由调用方取好再传入",属合法引用,不应误判。
FORBIDDEN_IMPORT_PREFIXES = (
"sqlalchemy",
"app.repository",
"app.config",
"app.utils.db",
"app.gateway",
)
#: 全文禁用符号(连 import 都不允许,出现在任何位置都是漏了依赖边界)。
FORBIDDEN_SYMBOLS = ("get_engine", "create_engine")
_IMPORT_RE = re.compile(r"^\s*(?:from|import)\s+([A-Za-z_][\w.]*)", re.M)
#: 编排层 / IO 层模块(按职责必须依赖仓储 / 配置 / 网关 / 风险引擎)。
#:
#: ⚠️ 用**显式排除名单**而非「只排除 convert_service.py」:默认被当作纯函数包会让
#: 新增文件「先炸测试」而不是「先想清楚边界」;默认不排除又会让真正的 IO 层被静默放过。
#: 每新增一个 IO 层文件必须在此登记并说明职责(T-7 新增 confirm_service / audit / engine_call)。
#: 名单定义在**模块级**:类体里的推导式不继承类作用域,放类里会 NameError(实测)。
IO_LAYER_MODULES = frozenset(
{
"convert_service.py", # 受理 / 撤单编排(v1.0 旧三阶段亦在此,待 T-9 后删)
"confirm_service.py", # T-7:T+1 确认段编排(批处理 + 逐单确认)
"audit.py", # T-7:审计写入(agent 库 INSERT)
"engine_call.py", # T-7:规则引擎调用出口(唯一调用点)
}
)
class TestPurity:
# 纯函数约束(DoD 第 3 条)只针对**纯函数包**,排除名单见 `IO_LAYER_MODULES`。
_PURE_ONLY = sorted(
p for p in CONVERT_DIR.glob("*.py") if p.name not in IO_LAYER_MODULES
)
def test_io_layer_modules_exist(self):
"""排除名单里的文件必须真实存在 —— 防止改名后约束静默失效。"""
actual = {p.name for p in CONVERT_DIR.glob("*.py")}
missing = IO_LAYER_MODULES - actual
assert not missing, f"纯度约束排除名单指向不存在的文件:{sorted(missing)}"
def test_pure_package_is_not_empty(self):
"""排除名单不得把整包排除(否则纯度断言形同虚设)。"""
assert len(self._PURE_ONLY) >= 6, "纯函数包文件数异常偏少,检查排除名单"
@pytest.mark.parametrize("path", _PURE_ONLY, ids=lambda p: p.name)
def test_no_repository_or_engine_dependency(self, path: Path):
source = path.read_text(encoding="utf-8")
imported = _IMPORT_RE.findall(source)
bad_imports = [
name for name in imported if name.startswith(FORBIDDEN_IMPORT_PREFIXES)
]
bad_symbols = [sym for sym in FORBIDDEN_SYMBOLS if sym in source]
assert not bad_imports, f"{path.name} 引入了 IO 依赖:{bad_imports}"
assert not bad_symbols, f"{path.name} 出现引擎符号:{bad_symbols}"
@pytest.mark.parametrize("path", _PURE_ONLY, ids=lambda p: p.name)
def test_imports_only_from_stdlib_or_own_package(self, path: Path):
source = path.read_text(encoding="utf-8")
allowed_roots = (
"app.service.convert",
"__future__",
# 唯一允许的包外依赖:复用 `register_error_handlers` 的统一错误体出口
# (架构 §8.3「不新增异常出口、不改中间件」)。它是纯类型基类,不触 IO。
"app.utils.exceptions",
)
stdlib = {"dataclasses", "datetime", "decimal", "typing", "uuid", "zlib", "re"}
external = [
name
for name in _IMPORT_RE.findall(source)
if not name.startswith(allowed_roots) and name.split(".")[0] not in stdlib
]
assert not external, f"{path.name} 引入了包外依赖:{external}"
def test_lot_bootstrap_avoids_builtin_hash(self):
"""内置 `hash` 受 PYTHONHASHSEED 随机化,会破坏 D18 的跨进程同源。"""
source = (CONVERT_DIR / "lot_bootstrap.py").read_text(encoding="utf-8")
assert "hash(" not in source