让 core_trade 的普通申赎同步维护 core_share_lot 批次,为 convert 提供权威份额口径。 落地(改码 3 处 + 新增 2 个脚本) - gateway_repository:新增 insert_share_lots / deduct_share_lots,各自单事务、走 rw 账号; 只落已分配结果、不含分配逻辑,避免出现第二份 FIFO 口径 - share_lot_repository:__init__ 增可选 core_ro 入参,让 trade_gateway 复用同一份 FIFO 选批口径(D18) - trade_gateway:新增 _nav_as_of / _maintain_lots,在落流水后、规则引擎前调用 · subscribe:取 T 日(含)前最新净值 → qty = amount ÷ 净值(2 位 HALF_UP)→ 建批次(confirmed_at = T) · redeem:FIFO 扣减;无批次但有持仓 → D8 兜底补建后再扣;两者皆无 → 降级跳过 · 整段 try 包住,任何异常只 warning,绝不阻断交易(R-c(1)) - 新增 scripts/core/rebuild_lots.py:按 core_holding 快照重建批次(L-7),与网关 D8 同调 bootstrap_lots; 默认只补建无批次的持仓行(幂等),--force 才先删后建,--dry-run 只报告 - 新增 scripts/dev/verify_convert_lots.py:真库验证脚本(MySQL 8.0.46) 口径订正(联网查证 4 家管理人业务规则后) - redeem 的 amount ÷ 净值 折算属本项目简化建模,不是行业标准 - 行业铁律是「金额申购、份额赎回」:投资者以份额申报,登记机构按 T 日净值算金额 (睿远业务规则 §65 / 华泰保兴 §69 / 东方基金 §57 / 国投瑞银 §33;无一家公募支持按金额赎回) - 派生风险:未知价法下 T 日净值当日不可得(T+1 公告),_nav_as_of 实取 T−1 净值, 故此处算出的份额只是估算值 - 已记入 PRD §10.1 已知差异清单;trade_gateway 注释同步订正 验证 - pytest -q → 714 passed / 3 skipped(基线 697 加 17,零回归) - R16 test_redeem_accepted_without_alert 零改动通过(由 R-c(1) 降级保住) - 真库 20/20;T-6 24/24、T-7 35/35 复跑零回归;真库隔离数据零残留 - 突变验证 2 组:切断接线 → 精准 2 条红;关掉 D8 兜底 → 精准 2 条红(含 D18 同源断言)
137 lines
5.5 KiB
Python
137 lines
5.5 KiB
Python
"""模拟交易网关写侧(PRD FR-1;FRAMEWORK §3 例外条款)。
|
||
|
||
仅本类可 INSERT jinrong_core.core_trade;core_ro 仍只读。生产环境由真实
|
||
交易系统回调替代,本类随 app/gateway/ 退役。
|
||
|
||
**T-10 起并承担 `core_share_lot` 的普通申赎批次维护写侧**(架构 §12 Q1:
|
||
`core_share_lot` 的写入方 = `convert_core_repository`(convert 转换)+
|
||
本类(普通申赎,含 D8 兜底补建)。分工刻意如此:
|
||
|
||
- **FIFO 选批/分配口径仍只有一处** —— `share_lot_repository`(D18);
|
||
本类**不含任何分配逻辑**,只把已分配好的结果落库,避免出现第二份口径。
|
||
- 批次维护属**尽力而为**(FR-C16 / R-c(1)):数据不全时由调用方
|
||
(`trade_gateway`)warning 跳过,**不抛异常、不阻断交易**。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
from decimal import Decimal
|
||
from typing import Any, Sequence
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.engine import Engine
|
||
|
||
from app.config.settings import settings
|
||
from app.service.convert.types import Lot
|
||
from app.utils.db import get_engine
|
||
|
||
# ── 批次写入 SQL(模块级常量:口径集中,便于单测逐条核对)──────────────
|
||
# ⚠️ `(:q + 0.0)` **不能省**:数值参数统一以字符串绑定(`str()`,跨库无损),
|
||
# 而 sqlite 在 **UPDATE 的算术表达式**里不会把 TEXT 绑定参数转数值 ——
|
||
# `remain_qty = remain_qty - :q` 传 '50' 会扣 0(见开发计划 §B.8 第 11 条)。
|
||
# 加 `+ 0.0` 强制数值化后两库行为一致(MySQL 本就隐式转换)。
|
||
_SHARE_LOT_INSERT = text(
|
||
"""
|
||
INSERT INTO core_share_lot
|
||
(lot_id, customer_id, product_id, qty, remain_qty, nav,
|
||
confirmed_at, source_trade_id)
|
||
VALUES (:lot, :cid, :pid, :q, :q, :nav, :cat, :src)
|
||
"""
|
||
)
|
||
|
||
_SHARE_LOT_DEDUCT = text(
|
||
"""
|
||
UPDATE core_share_lot
|
||
SET remain_qty = remain_qty - (:q + 0.0)
|
||
WHERE lot_id = :lot
|
||
"""
|
||
)
|
||
|
||
|
||
class GatewayRepository:
|
||
"""core_trade 唯一写入口;仅 INSERT,不改不删(模拟网关语义)。
|
||
|
||
D20(T-0b):绑定 `role="rw"`(账号 `xh_core_rw`,仅限 4 张表的
|
||
SELECT/INSERT/UPDATE,**无 DELETE、无 DDL**)。注意 gateway 的**读**走
|
||
`role="ro"`(`CoreReadOnlyRepository`),两个 engine 并存——这是「最小
|
||
权限」能否成立的关键边界(架构 §11.1)。
|
||
"""
|
||
|
||
def __init__(self, engine: Engine | None = None) -> None:
|
||
self._engine = engine or get_engine(settings.mysql_core_database, "rw")
|
||
|
||
def insert_trade(
|
||
self,
|
||
trade_id: str,
|
||
customer_id: str,
|
||
product_id: str,
|
||
trade_type: str,
|
||
amount: Decimal,
|
||
traded_at: datetime,
|
||
trade_status: str = "confirmed",
|
||
) -> None:
|
||
sql = text(
|
||
"""
|
||
INSERT INTO core_trade
|
||
(trade_id, customer_id, product_id, trade_type, amount,
|
||
trade_status, traded_at)
|
||
VALUES (:tid, :cid, :pid, :ttype, :amount, :status, :at)
|
||
"""
|
||
)
|
||
with self._engine.begin() as conn:
|
||
conn.execute(
|
||
sql,
|
||
{
|
||
"tid": trade_id,
|
||
"cid": customer_id,
|
||
"pid": product_id,
|
||
"ttype": trade_type,
|
||
# str 无损传递:MySQL DECIMAL 隐式转换;sqlite text SQL 不支持 Decimal 绑定
|
||
"amount": str(amount),
|
||
"status": trade_status,
|
||
"at": traded_at,
|
||
},
|
||
)
|
||
|
||
# ── 批次维护写侧(T-10 · FR-C16 · 架构 §12 Q1)────────────────────
|
||
|
||
def insert_share_lots(self, lots: Sequence[Lot]) -> None:
|
||
"""批量写入 `core_share_lot`(**单事务**)—— 申购建批次 / D8 兜底补建共用。
|
||
|
||
`qty` 与 `remain_qty` 写**同一个值**(新批次未发生任何扣减);
|
||
取值统一用 `remain_qty`(`bootstrap_lots` 保证两者相等)。
|
||
`nav` 以字符串绑定:sqlite 列有 NUMERIC affinity 会转换,MySQL 精确。
|
||
"""
|
||
if not lots:
|
||
return
|
||
with self._engine.begin() as conn:
|
||
for lot in lots:
|
||
conn.execute(
|
||
_SHARE_LOT_INSERT,
|
||
{
|
||
"lot": lot.lot_id,
|
||
"cid": lot.customer_id,
|
||
"pid": lot.product_id,
|
||
"q": str(lot.remain_qty),
|
||
"nav": str(lot.nav),
|
||
"cat": lot.confirmed_at,
|
||
"src": lot.source_trade_id,
|
||
},
|
||
)
|
||
|
||
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`)。两处口径不可互相套用。
|
||
"""
|
||
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)})
|