Files
group_xinghuo_jinrong/tests/test_convert_calc.py
T
GaoYiYuan_0626 c5182f1910 feat(convert): 基金转换 T-0/T-0b 门禁 + T-1 数据层 + T-2/T-2b 纯函数与实算回填
T-0 / T-0b(门禁 · 2026-09-10)
- T-0:sqlite 与 MySQL 结构对齐 —— core_holding 统一为 qty/cost_amount/as_of/pnl_pct
  + PK + UNIQUE(customer_id, product_id);补 core_product_nav;新增建库自校验
  _assert_ddl_aligned()(R-g);test_db.py 增 3 条门禁用例(含反向验证门禁失效)
- T-0b:DB 账号分离(D20)—— 新增 scripts/core/00-grant.sql(三账号逐表授权);
  settings.py 增 3 组账号;db.py 改 get_engine(db, role),缓存键改为 (库名, 角色),
  账号未配置回退单账号;core_ro→ro / gateway_repository→rw / risk·session_repository→rw;
  tests/conftest.py 四处显式 role="admin"(R-e)

T-1(数据层)
- scripts/core/01-ddl.sql:新建 core_fee_rule / core_share_lot / core_convert_lot_detail;
  core_trade 加 convert_group_id + idx_convert_group;core_product 加 8 列 + fee_rate 补 COMMENT
- 新增 07-seed-fee-rule.sql(赎回费 5 档 × 14 产品,按 22 号文 §10)/ 08-seed-share-lot.sql
  (58 行持仓 → 61 行批次,Σ remain_qty 恒等于 qty)/ 09-seed-org.sql(管理人 + TA +
  申购费率 + 最低持有余额,v1.1 按「管理人全产品线」重排)
- reset.ps1 追加 07/08/09;02-mysql-agent专用.sql 追加 risk_convert_detail
- tests/_ddl.py 同步 4 表 + 新增 REQUIRED_CONVERT_TABLES 建库门禁
- 新增 scripts/dev/verify_convert_seed.py(pymysql 等价 reset 流程 + 8 条 DoD 断言,
  含断言 ⑧「费率档 ↔ product_type 匹配」,越档即 FAIL)

T-2 / T-2b(纯函数包 + 示例实算回填)
- 新增 app/service/convert/ 7 文件:__init__ / types / calc / fee / nav / lot_bootstrap / errors
  (纯函数,不查库、不碰 SQL;所有量化显式 ROUND_HALF_UP;lot_bootstrap 用 zlib.crc32
   保证 D18 跨进程同源)
- 新增 tests/test_convert_calc.py 93 用例(12 类:HALF_UP 反向自证 / 分档边界 /
  FIFO 含同 confirmed_at 兜底 / 双口径 / 强制全转与强制赎回 / PRD §5.3 全链自证 /
  纯函数零 IO 依赖断言)
- 重写 scripts/dev/calc_convert_demo.py:去掉脚本内公式副本,改为调用生产 calc.py,
  末尾与 PRD §5.3 逐项比对(不一致即退出码 1),兼作一致性门禁

验证
- pytest 609 passed / 3 skipped(516 → +93,零回归)
- verify_convert_seed.py 8/8 PASS;calc_convert_demo.py 15/15 与 PRD §5.3 一致

文档:PRD v0.9.1(费率分类修正)· 架构 §7 签名回填 / §8.3 错误码注 / §15 T-2 完成 ·
开发计划 §1.5 新增 R-h + §4.2·§4.3 执行记录 · AGENTS.md · docs/memory
2026-09-10 14:45:55 +08:00

620 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""T-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")
# ── 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)
class TestPurity:
@pytest.mark.parametrize("path", sorted(CONVERT_DIR.glob("*.py")), 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", sorted(CONVERT_DIR.glob("*.py")), 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", "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