基金转换 T+1 模型:T-10 普通申赎批次维护 + T+2 校验 + rebuild_lots

FR-C16 份额批次全生命周期 + FR-C25 T+2 可赎回 + redeem 份额申报 D26 落地普通赎回侧:

- trade_gateway:_redeem_quote 走 available_qty_with_inflight 硬校验(R-3,
  申报超可赎抛 InsufficientShares 不静默裁剪);_maintain_lots redeem 分支
  三重裁剪 = T+2 过滤 → D8 补建 → 在途占用 → 哨兵 FIFO 扣减;subscribe 落新批次
- core_ro.list_share_lots / share_lot_repository.select_for_convert 加
  available_from T+2 半开区间过滤(R-4,日历缺行降级不过滤,金额/扣减同口径)
- gateway_repository 扣批次加哨兵 remain_qty >= :q(rowcount!=1 回滚防超扣)

验证:test_share_lot +10 / test_trade_gateway +1 → 全量 844 passed / 10 skipped
(基线 834 + 11 零回归);真库 verify_convert_api 89/89(H 节提前 + H2 在途
占用拦截断言);rebuild_lots --dry-run 58 持仓 0 补建 0 写入;突防验证区分度成立
This commit is contained in:
2026-09-11 21:24:32 +08:00
parent 6ecde37885
commit 34c7d0840d
7 changed files with 519 additions and 34 deletions
+22 -7
View File
@@ -40,11 +40,17 @@ _SHARE_LOT_INSERT = text(
"""
)
# ⚠️ `remain_qty >= (:q + 0.0)` 哨兵(T-10 · FR-C16 · R-6):
# 对齐 convert 侧 `convert_core_repository._deduct_lots`(开发计划 F-2 / 风险 6
# 「普通赎回超扣」)。`rowcount != 1` → 调用方抛错回滚 —— 防并发窗口下
# 「选批读到余量、扣减时已被别处扣走」的超扣(PRD 验收 9:转换后普通赎回不超扣)。
# `(:q + 0.0)` 不能省:数值参数统一 `str()` 绑定,sqlite 的 UPDATE 算术表达式
# 不会把 TEXT 转数值(详见本文件头部模块注释)。
_SHARE_LOT_DEDUCT = text(
"""
UPDATE core_share_lot
SET remain_qty = remain_qty - (:q + 0.0)
WHERE lot_id = :lot
WHERE lot_id = :lot AND remain_qty >= (:q + 0.0)
"""
)
@@ -123,14 +129,23 @@ class GatewayRepository:
def deduct_share_lots(self, deductions: Sequence[tuple[str, Decimal]]) -> None:
"""按 FIFO 分配结果逐批扣减 `remain_qty`(**单事务**)。
⚠️ 与 `convert_core_repository._deduct_lots` 的**关键差别**:本方法
**不带 `remain_qty >= :q` 哨兵**。传入的份额已由 `share_lot_repository`
按「不超过可用余额」裁剪,普通赎回**不因份额不足阻断交易**(R-c(1),
与既有 redeem 语义一致);而 convert 的份额不足是**严格校验**(409
`LotConflict`)。两处口径不可互相套用。
⚠️ **T-10 变更**:本方法**已带 `remain_qty >= :q` 哨兵**(对齐
`convert_core_repository._deduct_lots`)。扣不足(`rowcount != 1`)抛
`ValueError` 使整个事务回滚 —— 普通赎回**不再**「有多少扣多少」,
防并发窗口下的超扣(风险 6,PRD 验收 9)。
⚠️ 调用方 `_maintain_lots` 已把申报份额裁剪到(T+2 可扣 ∩ 在途后)可用
范围,**正常路径不会触发哨兵**;此处是**最后一道闸门**,与 convert 侧
的 `LotConflict` 语义等价(前者是业务异常、本处是数据不变量守卫)。
"""
if not deductions:
return
with self._engine.begin() as conn:
for lot_id, qty in deductions:
conn.execute(_SHARE_LOT_DEDUCT, {"lot": lot_id, "q": str(qty)})
result = conn.execute(
_SHARE_LOT_DEDUCT, {"lot": lot_id, "q": str(qty)}
)
if result.rowcount != 1:
raise ValueError(
f"批次 {lot_id} 扣减失败:剩余份额不足 {qty}(哨兵触发,事务回滚)"
)
+103 -6
View File
@@ -39,6 +39,7 @@ from app.repository.convert_request_repository import ConvertRequestRepository
from app.repository.core_ro import CoreReadOnlyRepository
from app.repository.risk_repository import RiskRepository
from app.repository.share_lot_repository import ShareLotRepository
from app.service.convert import trading_calendar
from app.service.convert.calc import (
hold_days,
lot_amount,
@@ -47,7 +48,7 @@ from app.service.convert.calc import (
round2,
)
from app.service.convert.convert_service import accept_convert
from app.service.convert.errors import NavNotReady
from app.service.convert.errors import InsufficientShares, NavNotReady
from app.service.convert.fee import pick_fee_rate
from app.service.convert.lot_bootstrap import bootstrap_lots
from app.service.convert.types import FeeRule, Lot, to_decimal
@@ -172,6 +173,36 @@ def _nav_as_of(
return nav if nav > 0 else None
def _t2_available_from(
core: CoreReadOnlyRepository, traded_at: datetime
) -> date | None:
"""T+2 可扣过滤基准(R-4 · FR-C25):业务日前一交易日(date);缺日历 → None。
**语义**:`core_share_lot.confirmed_at` 只精确到「确认日」;T+2 可赎 =
确认日 ≤ T+1(业务日前一交易日)的批次可扣。故传入 `core_ro.list_share_lots
(available_from=…)` 前,先把**业务日**(traded_at.date())用交易日历上推
一个交易日得到 `previous`,再让仓储层取 `confirmed_at < previous+1天`
(半开区间在 `list_share_lots` 内拼接)。
⚠️ **交易日历缺行 / 非交易日 → 返回 None(不过滤)**:演示库必灌
`10-seed-trade-calendar.sql`,缺行属环境问题而非业务常态;但「T+2 过滤失败」
绝不能阻断交易(与申购取不到净值同哲学),且**两处消费方(`_redeem_quote` /
`_maintain_lots`)必须同口径** —— 都传本函数返回值给 `available_from`
(None = 不过滤),保证金额反算与扣减选批**永远一致**,不会出现「金额按
T+2 全批算、扣减只扣可扣批」的账目分叉。
"""
is_open = core.is_open
try:
return trading_calendar.previous_biz_day(traded_at.date(), is_open)
except ValueError:
logger.warning(
"T+2 过滤降级为不过滤(交易日历缺失): traded_at=%s. "
"请确认 core_trade_calendar 已灌 10-seed-trade-calendar.sql",
traded_at,
)
return None
def _redeem_quote(
core: CoreReadOnlyRepository,
*,
@@ -208,11 +239,32 @@ def _redeem_quote(
f"赎回折算需 {product_id} 的净值(T 日含之前),当前无可用净值"
)
rules = [FeeRule.from_row(r) for r in core.get_redeem_fee_rules(product_id)]
lots = [Lot.from_row(r) for r in core.list_share_lots(customer_id, product_id)]
# T-10:与 `_maintain_lots` 扣减**同口径**(T+2 可扣过滤)—— 金额按「可扣批次」
# 计,绝不比实际扣减的批次多计(否则金额覆盖不到的份额会造成账目虚高)。
# `_t2_available_from` 无日历降级为 `None`(不过滤),两处随之口径一致。
lots = [
Lot.from_row(r)
for r in core.list_share_lots(
customer_id, product_id, available_from=_t2_available_from(core, traded_at)
)
]
if not lots:
holding = core.get_holding(customer_id, product_id)
if holding is not None:
lots = bootstrap_lots(holding) # D8 内存补建(不落库)
# T-10 硬校验口径(R-3):金额侧**拒绝**「申报超在途后可用」—— 已被未终态
# 受理单占用的份额不可赎回(同一份份额不能既等转换又已赎回)。静默裁剪会
# 把客户申报 10000 却只按 8000 记账,属改写客户指令,**必须显式拒绝**。
# 与 `plan_lots` 的 T+2 过滤后余量校验(下方)合并构成完整硬校验:
# 申报 ≤ min(在途后可用, T+2 可扣批次余量) 才放行。
avail = ShareLotRepository(core_ro=core).available_qty_with_inflight(
customer_id, product_id
)
if qty > avail:
raise InsufficientShares(
f"申报赎回 {qty} 份超过可赎回份额 {avail} 份(含在途占用):"
f"customer={customer_id} product={product_id}"
)
plan = plan_lots(lots, qty) # 份额不足 → InsufficientShares(400,正确业务行为)
gross = Decimal("0")
fee = Decimal("0")
@@ -253,8 +305,22 @@ def _maintain_lots(
- `subscribe`:取 T 日(含)前最新净值 → `qty = amount ÷ nav`(2 位 HALF_UP)→ 建批次
(`confirmed_at = T`)。
- `redeem`:按申报 `qty` FIFO 扣减;**无批次但有持仓 → D8 兜底补建后再扣**
(主场景,必须执行);既无批次也无持仓 → 降级跳过。
- `redeem`:按申报 `qty` FIFO 扣减,**三重裁剪**(T-10 · R-3/R-4/FR-C25):
① 申报 qty · ② 在途占用后可用(`available_qty_with_inflight`,R-3:已被
未终态受理单占用的份额不可赎回)· ③ T+2 可扣(`available_from` 过滤,R-4:
转入批次 T+2 起才可扣,`confirmed_at < 业务日前一交易日+1`)。扣减走
**哨兵 UPDATE**(`remain_qty >= :q`,gateway_repository 新增),并发窗口
下被别处扣走 → rowcount!=1 → 抛错回滚(批次维护异常被外壳吞掉,交易仍成立)。
**无批次但有持仓 → D8 兜底补建后再扣**(主场景,必须执行);既无批次也无
持仓 → 降级跳过。
**T-10 口径决策(已在代码注释留痕)**:
- **在途占用也拦截普通赎回**(R-3 扩展):同一份份额不能「既等转换、又已赎回」;
受理单终态(confirmed/rejected/cancelled/expired)自动释放,不重复占用。
- **T+2 过滤仅对「在册批次」生效**:D8 补建的历史批次 `confirmed_at = as_of`
(早于当前业务日),天然通过过滤 → 补建后立即可扣,语义正确。
- **金额与扣减同口径**:`_redeem_quote` 与本节都用 `_t2_available_from` 的结果
传入 `available_from`(None 双双不过滤),账目一致。
⚠️ **行业铁律「金额申购、份额赎回」**(2026-09-10 联网查证):
投资者赎回时以**份额**申报,登记机构按 T 日净值反算金额
@@ -322,8 +388,19 @@ def _maintain_lots(
if qty <= 0:
return
# ══ T-10 三重裁剪(R-3 / R-4 / FR-C25)═══════════════════════════
# ① T+2 可扣过滤基准(业务日前一交易日;缺日历 → None 不过滤)
t2_from = _t2_available_from(core, traded_at)
lots_repo = ShareLotRepository(core_ro=core)
if lots_repo.available_qty(customer_id, product_id) <= 0:
# ② 在途占用 + 物理余量双口径判定(R-3):
# - 物理在册余量 ≤ 0 → 触发 D8 兜底补建(历史数据无批次的主场景);
# - 在途占用后可用 ≤ 0 且申报 > 0 → 份额全被未终态受理单占用,无法赎回。
# ⚠️ 不能只看 `available_qty_with_inflight`:D8 无批次场景下物理余量为 0、
# 在途也为 0,会误判「全被占用」而跳过补建 —— 必须先按物理余量判补建。
physical = lots_repo.available_qty(customer_id, product_id)
avail = lots_repo.available_qty_with_inflight(customer_id, product_id)
if physical <= 0:
# D8 兜底补建(lot_bootstrap 单点,D18)
holding = core.get_holding(customer_id, product_id)
if holding is None:
@@ -345,8 +422,26 @@ def _maintain_lots(
)
return
writer.insert_share_lots(bootstrap)
# 补建后重读可用量(T+2 过滤对补建批次无影响:confirmed_at=as_of 早已过去)
physical = lots_repo.available_qty(customer_id, product_id)
avail = lots_repo.available_qty_with_inflight(customer_id, product_id)
selected = lots_repo.select_for_convert(customer_id, product_id, qty)
# ③ 可扣上限 = min(申报, 在途后可用) —— 超量部分按「最多扣可用」处理
#(普通赎回不阻断,R-c(1);剩余部分留给调用方在 `_redeem_quote` 金额上裁剪,
# 金额已按同一 T+2 口径先算好,两处一致)
redeemable = min(qty, avail)
if redeemable <= 0:
logger.warning(
"批次维护跳过(可赎回份额 ≤ 0,被在途占用): trade_id=%s cid=%s pid=%s",
trade_id,
customer_id,
product_id,
)
return
selected = lots_repo.select_for_convert(
customer_id, product_id, redeemable, available_from=t2_from
)
if not selected:
logger.warning(
"批次维护跳过(赎回无可用批次可扣): trade_id=%s cid=%s pid=%s",
@@ -355,6 +450,8 @@ def _maintain_lots(
product_id,
)
return
# 哨兵扣减(gateway_repository `remain_qty >= :q`):并发窗口下被别处扣走
# → rowcount!=1 → ValueError → 整个事务回滚 → 被外壳吞掉,交易仍成立
writer.deduct_share_lots(
[(item["lot_id"], to_decimal(item["qty"])) for item in selected]
)
+30 -3
View File
@@ -531,22 +531,49 @@ class CoreReadOnlyRepository:
return [dict(r) for r in conn.execute(sql, {"pid": product_id}).mappings()]
def list_share_lots(
self, customer_id: str, product_id: str, max_lots: int | None = None
self,
customer_id: str,
product_id: str,
max_lots: int | None = None,
available_from: date | None = None,
) -> list[dict[str, Any]]:
"""份额批次读侧(FIFO 权威源):按 `confirmed_at ASC, lot_id ASC` 返回。
S1:同一 `confirmed_at` 多批次以 `lot_id` 升序兜底,保证顺序可复现
(PRD §5.3 / T-2 测试 fixture 依赖确定性顺序)。`max_lots` 为可选截断上限。
`available_from`(T-10 · R-4 · FR-C25):**T+2 可扣过滤** —— 仅返回
`confirmed_at < available_from`(即 `available_from` 前一交易日结束前
确认的批次)的批次。`available_from` 取「业务日前一交易日」(由
`trade_gateway` 用 `trading_calendar.previous_biz_day` 算好传入),
语义 = **T+2 起可扣**。缺省 `None` 不过滤(默认行为不变:confirm_service /
`_redeem_quote` / 既有测试零改动,F-9 谓词语义保留)。
"""
params: dict[str, Any] = {"cid": customer_id, "pid": product_id}
extra = ""
af_end: datetime | None = None
if available_from is not None:
# T+2 可扣过滤(R-4 · FR-C25):传入 `available_from` = **业务日前一
# 交易日**(date,由调用方用 `trading_calendar.previous_biz_day` 算好)。
# 半开区间 `confirmed_at < :af_end`(af_end = available_from 次日 00:00):
# · 确认日 T+1 的转入批次,在业务日 D = T+1 时 previous = T →
# af_end = T+1 00:00 → 该批次**不可扣**(T+2 之前);
# · 业务日 D = T+2 时 previous = T+1 → af_end = T+2 00:00 → **可扣**。
# 不能直接 `confirmed_at <= available_from`:confirmed_at 带时刻,
# 与 date 比较会把 T+1 当天任意时刻的批次都排除(字符串比较语义)。
extra = "AND confirmed_at < :af_end"
af_end = datetime.combine(available_from + timedelta(days=1), time.min)
sql = text(
"""
SELECT * FROM core_share_lot
WHERE customer_id = :cid AND product_id = :pid
{extra}
ORDER BY confirmed_at ASC, lot_id ASC
"""
""".format(extra=extra)
)
params["af_end"] = af_end
with self._engine.connect() as conn:
rows = conn.execute(sql, {"cid": customer_id, "pid": product_id}).mappings().all()
rows = conn.execute(sql, params).mappings().all()
if max_lots is not None:
rows = rows[:max_lots]
return [dict(r) for r in rows]
+25 -5
View File
@@ -10,6 +10,7 @@ D18(单一副本):FIFO 排序规则(confirmed_at ASC, lot_id ASC)的
from __future__ import annotations
from datetime import date
from decimal import Decimal
from typing import Any
@@ -43,19 +44,30 @@ class ShareLotRepository:
)
def select_for_convert(
self, customer_id: str, product_id: str, qty: Decimal
self,
customer_id: str,
product_id: str,
qty: Decimal,
available_from: date | None = None,
) -> list[dict[str, Any]]:
"""FIFO 选批:按 confirmed_at ASC, lot_id ASC 贪心覆盖 `qty`。
返回被选中的批次(dict:lot_id / qty 本次可用 / nav / confirmed_at)。
若可用份额不足 `qty`,返回全部可用批次(差额由调用方校验,读侧不抛业务异常)。
零请求量返回空列表。
`available_from`(T-10 · R-4 · FR-C25):**T+2 可扣过滤**透传 `list_share_lots`
—— 普通赎回只从 `confirmed_at < available_from` 的批次中选。缺省 None
不过滤(转换受理/确认段 `select_for_convert` 可扣的是**全部在册批次**,
在途占用已由 `available_qty_with_inflight` 单独体现,两者不冲突)。
"""
if qty <= 0:
return []
remaining = Decimal(qty)
selected: list[dict[str, Any]] = []
for lot in self._core.list_share_lots(customer_id, product_id):
for lot in self._core.list_share_lots(
customer_id, product_id, available_from=available_from
):
if remaining <= 0:
break
available = Decimal(lot["remain_qty"])
@@ -74,18 +86,26 @@ class ShareLotRepository:
return selected
def available_qty(self, customer_id: str, product_id: str) -> Decimal:
"""可用份额合计(委托 core_ro.sum_remain_qty,仅统计 remain_qty > 0)。"""
"""可用份额合计(委托 core_ro.sum_remain_qty,仅统计 remain_qty > 0)。
这是**物理在册余量**(不含在途占用)。调用点按用途显式选:
· 转换**确认段**部分成交判定(confirm_service ④ 步)→ available_qty
(确认时才真扣份额,占用已在受理段扣过,不能重复扣);
· 普通赎回的**选批上限** → 见 `_maintain_lots`(T-10 在其内叠加
T+2 过滤 + 在途占用后裁剪,不直接调本方法判可用)。
"""
return self._core.sum_remain_qty(customer_id, product_id)
def available_qty_with_inflight(self, customer_id: str, product_id: str) -> Decimal:
"""**可用份额 = 物理余量 − 在途占用**(R-3 / FR-C21,受理校验专用)。
"""**可用份额 = 物理余量 − 在途占用**(R-3 / FR-C21)。
在途 = `core_convert_request` 中 status ∈ (accepted, nav_pending) 的 qty 之和,
由 `core_ro.sum_inflight_qty` 计算。撤单/rejected/expired 自动释放(不再计入)。
与 `available_qty`(物理余量)语义不同,调用点须按用途显式选:
· 普通赎回 / 再转换可扣批次校验 → available_qty
· 转换**受理**校验 → available_qty_with_inflight(紧池 80% 病根修复的关键:
争抢从数据行搬到「客户+产品」短临界区,见开发计划 v2.0 §0.1)
· **普通赎回**可用校验(T-10 · R-3)→ available_qty_with_inflight:
已被未终态受理单占用的份额**不可赎回**(同一份份额不能既等转换又已赎回)
"""
physical = self.available_qty(customer_id, product_id)
inflight = self._core.sum_inflight_qty(customer_id, product_id)
+32 -12
View File
@@ -345,6 +345,24 @@ def main() -> int:
client = TestClient(app)
try:
# ── H. redeem 份额申报金额(D26/R-6)· **必须最先**(只读、依赖未扣份额)──
# T-10 起 `_redeem_quote` 走 `available_qty_with_inflight`(R-3)硬校验:
# 必须放在任何受理之前执行(此时在途=0,可赎=物理余量 50000),
# 否则会被后续受理单的在途占用拦截 —— 见 H2 真库断言。
print("\n【H】redeem 份额申报:`_redeem_quote = qty × D 净值 − 赎回费`(FIFO 逐批)")
# FIFO 从批次 A1(持 100 天)取前 100 份 → 费率档 0.0050(持有 100 天)。
# 期望 = 100 × 1.3604 = 136.04,扣费 0.68 → 实得 135.36。
# ⚠️ `_nav_as_of` 的语义是「traded_at 当日(含)前最新净值」→ traded_at 必须锚在
# **受理日(=交易日)**:受理日已顺延到 9/14(今天 9/11 20:24 过截点),种子净值
# 也插在 9/14 —— 若传 `datetime.now()`(9/11)就取不到净值(首版实跑即 NavNotReady)。
from app.gateway.trade_gateway import _redeem_quote
quote = _redeem_quote(
core_ro, customer_id=CUSTOMER, product_id=PROD_OUT, qty=Decimal("100"),
traded_at=datetime.combine(accept_date, time(10, 0)),
)
check("redeem 报价 = qty×净值−赎回费(FIFO 0.0050 档)", dec(quote), Decimal("135.36"))
# ── A. 受理:HTTP 202 + 受理回执 + 真库只落受理单(PRD §5.3.1)──
print("\n【A】受理:202 + 受理回执;真库 ENUM 落单、零折算副作用")
code, body = push(client, **_body(cid="ACP-REQ-A"))
@@ -397,20 +415,22 @@ def main() -> int:
for key in CONFIRMED_ONLY:
check(f"未确认查询不含 {key}", key in body, False)
# ── H. redeem 份额申报金额(D26/R-6)· **必须最先**(只读、依赖未扣份额)──
print("\n【H】redeem 份额申报:`_redeem_quote = qty × D 净值 − 赎回费`(FIFO 逐批)")
# FIFO 从批次 A1(持 100 天)取前 100 份 → 费率档 0.0050(持有 100 天)。
# 期望 = 100 × 1.3604 = 136.04,扣费 0.68 → 实得 135.36。
# ⚠️ `_nav_as_of` 的语义是「traded_at 当日(含)前最新净值」→ traded_at 必须锚在
# **受理日(=交易日)**:受理日已顺延到 9/14(今天 9/11 20:24 过截点),种子净值
# 也插在 9/14 —— 若传 `datetime.now()`(9/11)就取不到净值(首版实跑即 NavNotReady)。
# ── H2. T-10 在途占用真库校验:受理 REQ-A 占满 50000 在途 → redeem 申报必须被拒 ──
# R-3 硬校验(available_qty_with_inflight):受理后 可赎 = 50000 − 50000 = 0,
# `_redeem_quote` 抛 InsufficientShares —— 与扣减侧同口径(拒绝超量,不静默裁剪)。
print("\n【H2】T-10:在途占用拦截赎回申报(InsufficientShares 硬校验)")
from app.gateway.trade_gateway import _redeem_quote
from app.service.convert.errors import InsufficientShares
quote = _redeem_quote(
core_ro, customer_id=CUSTOMER, product_id=PROD_OUT, qty=Decimal("100"),
traded_at=datetime.combine(accept_date, time(10, 0)),
)
check("redeem 报价 = qty×净值−赎回费(FIFO 0.0050 档)", dec(quote), Decimal("135.36"))
try:
_redeem_quote(
core_ro, customer_id=CUSTOMER, product_id=PROD_OUT, qty=Decimal("100"),
traded_at=datetime.combine(accept_date, time(10, 0)),
)
except InsufficientShares as exc:
check("在途占用拦截(InsufficientShares 抛出)", "申报赎回" in str(exc), True)
else:
check("在途占用拦截(InsufficientShares 抛出)", False, True)
# ── G. 鉴权:两道闸门分离(红线 7)· **确认之前**(REQ-A 份额未转走)──
# 复用 REQ-A:非本人两闸门都拒 / 顾问可查不可撤 / risk_demo 无查询 scope /
+280 -1
View File
@@ -23,7 +23,7 @@ from __future__ import annotations
import importlib.util
import logging
from datetime import date, datetime
from datetime import date, datetime, timedelta
from decimal import Decimal
from pathlib import Path
@@ -35,6 +35,7 @@ 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
@@ -361,6 +362,18 @@ def _seed_nav(engine, pid: str, nav: str, nav_date: date = NAV_DATE) -> None:
)
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:
@@ -707,3 +720,269 @@ def test_rebuild_lots_uses_same_pure_function_as_gateway():
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")
+27
View File
@@ -217,6 +217,33 @@ def test_redeem_accepted_without_alert(env):
assert _counts(engine, "risk_alert") == 0
def test_redeem_amount_formula_qty_times_nav_minus_fee(env):
"""**DoD 3(D26)**:普通赎回金额 = `qty × T 日净值 − 赎回费`(FIFO 逐批)。
单批 5000 份、净值 1.0000、费率 0.5%([0,NULL) 档):
金额 = 1000 × 1.0000 = 1000.00 · 费用 = 1000.00 × 0.005 = 5.00
core_trade.amount = 1000.00 − 5.00 = **995.00**
(`_redeem_quote` 复用 convert 转出端同一套纯函数:plan_lots → hold_days →
pick_fee_rate → lot_amount / lot_fee,见该函数 docstring。)
"""
core, repo, writer, _, engine = env
_seed_nav(engine, "PROD-510300", "1.0000", date(2026, 9, 6))
_seed_fee_rule(engine, "PROD-510300")
_seed_lot(engine, "LOT-RED-Q", "CUST-3001", "PROD-510300", "5000", "1.0000",
datetime(2026, 8, 1, 10, 0, 0)) # 持有 36 天 → 命中 [0,∞) 0.5% 档
resp = submit_trade(
_req(customer="CUST-3001", product="PROD-510300", ttype="redeem", qty="1000"),
core_ro=core, risk_repo=repo, gateway_repo=writer, now=datetime(2026, 9, 6, 14, 0, 0),
)
assert resp["blocked"] is False
with engine.connect() as conn:
row = conn.execute(
text("SELECT amount FROM core_trade WHERE trade_id=:t"),
{"t": resp["trade_id"]},
).mappings().one()
assert Decimal(str(row["amount"])) == Decimal("995.00") # qty×净值 − 赎回费
# ---------- T-10:普通申赎批次维护(FR-C16 · R7 补断言) ----------