Files
group_xinghuo_jinrong/app/repository/share_lot_repository.py
T
GaoYiYuan_0626 34c7d0840d 基金转换 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 写入;突防验证区分度成立
2026-09-11 21:24:32 +08:00

114 lines
5.3 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.
"""份额批次读侧仓储(jinrong_core · 仅 SELECT)· T-3。
core_share_lot 的读侧封装:FIFO 选批 + 可用份额汇总。写侧(扣减 / 补建新批)
归 `convert_core_repository`(D2),本文件不写。
D18(单一副本):FIFO 排序规则(confirmed_at ASC, lot_id ASC)的唯一出处是
`CoreReadOnlyRepository.list_share_lots` 的 ORDER BY —— 本类复用它取批次,
**不另写一份排序 SQL**,贪心分配只在 Python 端做,避免排序口径漂移。
"""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from typing import Any
from sqlalchemy.engine import Engine
from app.config.settings import settings
from app.repository.core_ro import CoreReadOnlyRepository
from app.utils.db import get_engine
class ShareLotRepository:
"""core_share_lot 读侧:FIFO 选批 + 汇总(D2:写侧在 convert_core_repository)。
`core_ro` 入参(T-10 补入):普通申赎的批次维护(`trade_gateway`)已经持有
一个 `CoreReadOnlyRepository`(可能绑 sqlite 测试引擎),直接传入即可复用
**同一份 FIFO 分配口径** —— 否则 `trade_gateway` 只能另写一份贪心逻辑,
正是自检第 13 问「同一规则只留一个副本」要防的漂移。传 `core_ro` 时
`engine` 参数被忽略(读侧一律委托该实例)。
"""
def __init__(
self,
engine: Engine | None = None,
core_ro: CoreReadOnlyRepository | None = None,
) -> None:
if core_ro is not None:
self._core = core_ro
else:
self._core = CoreReadOnlyRepository(
engine=engine or get_engine(settings.mysql_core_database, "ro")
)
def select_for_convert(
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, available_from=available_from
):
if remaining <= 0:
break
available = Decimal(lot["remain_qty"])
if available <= 0:
continue
take = min(available, remaining)
selected.append(
{
"lot_id": lot["lot_id"],
"qty": take,
"nav": Decimal(lot["nav"]),
"confirmed_at": lot["confirmed_at"],
}
)
remaining -= take
return selected
def available_qty(self, customer_id: str, product_id: str) -> Decimal:
"""可用份额合计(委托 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)。
在途 = `core_convert_request` 中 status ∈ (accepted, nav_pending) 的 qty 之和,
由 `core_ro.sum_inflight_qty` 计算。撤单/rejected/expired 自动释放(不再计入)。
与 `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)
# clamp 到 0:并发窗口下占用短暂超调物理余量时,可用份额按 0 计(受理校验必拒)
return physical - inflight if physical > inflight else Decimal("0")