基金转换 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:
@@ -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}(哨兵触发,事务回滚)"
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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 补断言) ----------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user