T-13 补修:verify_convert_lots/apply 字面造数日期改真库日历链式回推(用户指出硬编码 · 全仓排查留痕)
This commit is contained in:
@@ -722,7 +722,7 @@
|
||||
**① 退役落地(convert_service.py 净删 468 行)**:删除 `convert_fund`(八步编排)+ 其 v1.0 专属依赖 `_plan_and_quote` / `_pick_rate`(死代码)/ `_finalize_from_core` / `_write_main_audit` + 17 个孤儿 import;模块 docstring 重写为 T+1 编排说明,`__all__` 收敛为 6 个在役符号(accept_convert / cancel_convert / t1_t2_dates / compensate_convert / rebuild_convert_response / PROCESSING)。**按实际修订 1 处**:开发计划原文「`rebuild_convert_response` T-13 退役」**不成立**——它是 T-9 查询接口 confirmed 分支的在役读路径,保留在役(`PROCESSING` 仅作 simulate 网关 202 判断常量,同样保留)。`verify_convert_service.py`(v1.0 链路真库脚本)`git rm`,git 历史留档。
|
||||
**② 测试面改写(约 25 处映射落地)**:`test_convert_service.py` 全量重写(805→约 440 行):v1.0 十组用例退役,替代断言分布 = T-6/T-7/T-8 既有用例 + 补偿组 6 用例保留 + 新增 2(`confirm_mirror_write_failure_keeps_core_confirmed` 镜像失败不回滚 Core / `confirm_rejects_too_many_lots` 批次上限 rejected 零流水);**显式依赖注入踩坑留痕**:confirm_one 的 `core_writer`/`request_repo` 缺省会连真 MySQL(症状分别 = 写侧 FK 1452、rejected 被真库 rowcount=0 误判 skipped)——`_services` helper 全部五仓储钉同一 sqlite 引擎。`test_convert_concurrency.py` 全量重写为 T+1 并发面(v1.0 九用例 → 六用例):50 并发扣减争抢 → **并发 confirm_one 不超卖**(受理段允许 R-3 TOCTOU 超授、确认段 FIFO 哨兵兜底,硬不变量钉在确认后);紧池 80% → **紧池 100%**(受理全 accepted + `confirm_batch` 串行);同键并发单次转换 → **同键并发受理单唯一**(uk_idem + 客户级锁;3b/3c 占位竞态确定性交错用例随 v1.0 占位语义退役,无 T+1 对应物);死锁重试用例改多客户并发 confirm_one;性能探针改两段计量。trade_id 生成走 `format.new_id`(与 `convert_service._new_id` 两个符号),monkeypatch 罩不到 → `_confirm` 显式传 `id_factory`。
|
||||
**③ 全量 pytest:830 passed / 7 skipped**(基线 849 → 830:v1.0 用例组退役净减,skipped 10 → 7 = 并发文件 stress 标记 7 → 4);全仓真库套件 **9/9 退出码 0**。
|
||||
**④ 全量复跑抓出并修复过期脚本 1 处(本轮计划外产出)**:`verify_convert_lots.py`(T-10 产物)B/C/D 组仍按 v1.0「金额赎回」传 `amount=` —— T-9 已按 D26/R-6 改**份额申报**(`qty=`),`_maintain_lots` redeem 分支遇 `qty is None` 静默跳过(尽力而为语义)→ B/C FIFO 扣减失效、D 组兜底补建断言红(A 组申购不受影响,故首轮仅此脚本红)。修正为 `qty=` 申报后 **20/20 退出码 0**。「金额申购、份额赎回」行业铁律(2026-09-10 联网查证)的又一处落地。
|
||||
**④ 全量复跑抓出并修复过期脚本 1 处(本轮计划外产出)**:`verify_convert_lots.py`(T-10 产物)B/C/D 组仍按 v1.0「金额赎回」传 `amount=` —— T-9 已按 D26/R-6 改**份额申报**(`qty=`),`_maintain_lots` redeem 分支遇 `qty is None` 静默跳过(尽力而为语义)→ B/C FIFO 扣减失效、D 组兜底补建断言红(A 组申购不受影响,故首轮仅此脚本红)。修正为 `qty=` 申报后 **20/20 退出码 0**。「金额申购、份额赎回」行业铁律(2026-09-10 联网查证)的又一处落地。**同轮补修(用户指出硬编码问题后全仓排查)**:`verify_convert_lots.py` 与 `verify_convert_apply.py` 的造数日期(原 `NAV_DATE=date(2026,9,4)` 等 9 处字面日期)违反 T-12 立下的「造数日期走真库交易日历、不猜自然日」模式 → 全部改 `previous_biz_day` 链式回推动态锚定(hold_days 自然日相对量保持不变,档位断言不受影响),复跑 **20/20 + 24/24** 退出码 0(动态生效自证:confirmed_at 随回推日漂移、断言同源跟随)。**全仓硬编码排查结论**:生产代码 `app/` 零字面日期、cutoff/SLA/死锁重试全走 settings(B1/R-14 整改完整);T-6~T-12 五个真库脚本本就零字面日期;`verify_convert_seed.py` 的 `AS_OF` 与种子 SQL 强绑定、`calc_convert_demo.py` 的示例日为 PRD 验收 20 强制锚定(改动态反而违约)——均属**有意固定**,保留;sqlite 单测固定日期+自插配套数据为确定性测试惯例,保留。
|
||||
**⑤ 性能实测(n=59,剔除首个连接冷启动样本;本机 MySQL 8.0.46 / 模拟库 / 单线程顺序 / 每笔 1 份 / 空引擎 hook)**:受理段 P50 37.9 / P95 53.8 / max 57.9 ms(校验+落单+镜像+审计);确认事务(`apply_convert`)P50 10.0 / P95 25.3 / max 25.5 ms;确认全程 P50 41.4 / P95 61.3 / max 74.4 ms(折算+复核+扣减+流水+镜像+审计);**端到端(受理+确认两段)P50 79.2 / P95 110.3 / max 114.7 / mean 83.0 ms** —— PRD §9 第 18 条 **< 2s 硬门禁达成**(余量约 17 倍),数字已回填 PRD §9。
|
||||
**⑥ 紧池重测(验收 31 载体)**:50 并发受理各 1000、池恰 50000(需求=供给)→ **受理 50/50(100%)**(R-3 占用校验:即使完全串行第 i 笔 available = 50000−(i−1)×1000 ≥ 1000,全 accepted 是确定性结果)→ `confirm_batch` 串行确认 **50/50(100%)**、扣减恰=池、守恒无超卖。**v1.0 同场景实测 80%(40/50,批次碎片争抢)→ T+1 100%,验收 31 设计目标达成**;v1.0 病根(确认前就扣份额 + 批次碎片争抢)由「受理不扣份额 + 确认串行(FR-C23)」结构性消除。
|
||||
**⑦ 并发确认兜底(S2)**:50 路并发受理各 2000(允许 R-3 占用读数滞后超授,实测 25/50 accepted、其余 InsufficientShares 拦截)→ 并发 confirm_one 争抢 → 扣减 40000 ≤ 池 50000 + 守恒 + 每组恰 2 流水;LotConflict 让路单(退避 3 次耗尽)受理单保持 accepted(占用保持,下轮批处理可再确认,零扣减不碰资金安全)——并发 confirm_one 本身是超纲场景(生产由批处理锁保证串行),本用例证明的是「即使绕过锁,资金安全仍有兜底」。
|
||||
|
||||
@@ -26,7 +26,7 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import threading
|
||||
from datetime import date, datetime
|
||||
from datetime import date, datetime, time as dtime, timedelta
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from pathlib import Path
|
||||
|
||||
@@ -50,7 +50,9 @@ from app.service.convert.calc import ( # noqa: E402
|
||||
lot_fee,
|
||||
plan_lots,
|
||||
)
|
||||
from app.repository.core_ro import CoreReadOnlyRepository # noqa: E402
|
||||
from app.service.convert.errors import LotConflict # noqa: E402
|
||||
from app.service.convert.trading_calendar import previous_biz_day # noqa: E402
|
||||
from app.service.convert.fee import pick_fee_rate # noqa: E402
|
||||
from app.service.convert.types import FeeRule, Lot # noqa: E402
|
||||
from app.utils.db import dispose_engines, get_engine # noqa: E402
|
||||
@@ -58,8 +60,29 @@ from app.utils.db import dispose_engines, get_engine # noqa: E402
|
||||
CUSTOMER = "CUST-T6M"
|
||||
PROD_OUT = "PROD-T6MO"
|
||||
PROD_IN = "PROD-T6MI"
|
||||
TRADE_AT = datetime(2026, 9, 4, 10, 0, 0)
|
||||
TRADE_DATE = date(2026, 9, 4)
|
||||
# 造数日期在 main() 里按真库交易日历链式回推动态确定(T-12 模式:不猜自然日)。
|
||||
# 相对关系与原固定基准(2026-09-04)一致 —— hold_days 是自然日语义,档位断言不变:
|
||||
# TRADE_DATE = 回推 3 个交易日;A1 批次 = −34 自然日(档 [30,180));
|
||||
# A2 批次 = −3 自然日(档 [0,7));转入批次 confirmed_at = +1 自然日。
|
||||
TRADE_AT: "datetime | None" = None
|
||||
TRADE_DATE: "date | None" = None
|
||||
_A1_AT: "datetime | None" = None
|
||||
_A2_AT: "datetime | None" = None
|
||||
_IN_CONFIRMED_AT: "datetime | None" = None
|
||||
|
||||
|
||||
def _init_dates(admin) -> None:
|
||||
"""按真库交易日历把造数日期锚定到确定过去的历史开市日(必须在 seed 前调用)。"""
|
||||
global TRADE_AT, TRADE_DATE, _A1_AT, _A2_AT, _IN_CONFIRMED_AT
|
||||
is_open = CoreReadOnlyRepository(engine=admin).is_open
|
||||
d = date.today()
|
||||
for _ in range(3):
|
||||
d = previous_biz_day(d, is_open)
|
||||
TRADE_DATE = d
|
||||
TRADE_AT = datetime.combine(d, dtime(10, 0))
|
||||
_A1_AT = datetime.combine(d - timedelta(days=34), dtime(10, 0))
|
||||
_A2_AT = datetime.combine(d - timedelta(days=3), dtime(10, 0))
|
||||
_IN_CONFIRMED_AT = datetime.combine(d + timedelta(days=1), dtime(10, 0))
|
||||
OUT_RATE = Decimal("0.0030")
|
||||
IN_RATE = Decimal("0.0080")
|
||||
IN_NAV = Decimal("0.9500")
|
||||
@@ -127,8 +150,8 @@ def seed(engine) -> None:
|
||||
{"p": PROD_IN, "nav": str(IN_NAV), "d": TRADE_DATE},
|
||||
)
|
||||
for lot_id, qty, nav, confirmed in [
|
||||
("LOT-T6M-A1", "100", "1.0300", datetime(2026, 8, 1, 10, 0, 0)),
|
||||
("LOT-T6M-A2", "50", "1.0000", datetime(2026, 9, 1, 10, 0, 0)),
|
||||
("LOT-T6M-A1", "100", "1.0300", _A1_AT),
|
||||
("LOT-T6M-A2", "50", "1.0000", _A2_AT),
|
||||
]:
|
||||
conn.execute(
|
||||
text(
|
||||
@@ -217,7 +240,7 @@ def build_input(engine, group_id: str, requested: str, in_lot_id: str = "LOT-T6M
|
||||
in_nav=IN_NAV,
|
||||
in_nav_date=TRADE_DATE,
|
||||
in_lot_id=in_lot_id,
|
||||
in_confirmed_at=datetime(2026, 9, 5, 10, 0, 0),
|
||||
in_confirmed_at=_IN_CONFIRMED_AT,
|
||||
charges=tuple(charges),
|
||||
)
|
||||
|
||||
@@ -229,6 +252,7 @@ def q1(engine, sql: str, **params):
|
||||
|
||||
def main() -> int:
|
||||
admin = get_engine(settings.mysql_core_database, "admin")
|
||||
_init_dates(admin)
|
||||
repo = ConvertCoreRepository() # 真实路径:role="rw"(账号未配时回退 mysql_user)
|
||||
try:
|
||||
cleanup(admin)
|
||||
|
||||
@@ -25,7 +25,7 @@ TEXT 绑定参数转数值 → 不加就扣 0;见开发计划 §B.8 第 11 条
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import date, datetime
|
||||
from datetime import date, datetime, time as dtime, timedelta
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from pathlib import Path
|
||||
|
||||
@@ -39,14 +39,41 @@ from app.gateway.gateway_repository import GatewayRepository # noqa: E402
|
||||
from app.gateway.trade_gateway import _maintain_lots # noqa: E402
|
||||
from app.repository.core_ro import CoreReadOnlyRepository # noqa: E402
|
||||
from app.service.convert.lot_bootstrap import bootstrap_lots # noqa: E402
|
||||
from app.service.convert.trading_calendar import previous_biz_day # noqa: E402
|
||||
from app.utils.db import dispose_engines, get_engine # noqa: E402
|
||||
|
||||
CUSTOMER = "CUST-LOTT"
|
||||
PROD_SUB = "PROD-LOTTA" # A 组:申购建批次
|
||||
PROD_BOOT = "PROD-LOTTB" # D 组:无批次有持仓 → D8 兜底补建
|
||||
PROD_REDEEM = "PROD-LOTTC" # B/C 组:赎回 FIFO 扣减
|
||||
NAV_DATE = date(2026, 9, 4)
|
||||
TRADE_AT = datetime(2026, 9, 4, 14, 0, 0)
|
||||
# 造数日期在 main() 里按真库交易日历链式回推动态确定(T-12 模式:不猜自然日,
|
||||
# 对任意假期安排与日期漂移鲁棒)。相对关系与原固定基准(2026-09-04)一致:
|
||||
# TRADE_DATE = 回推 3 个交易日;C1/C2 批次 = 再回推 3/2 个交易日(保证 T+2 可扣);
|
||||
# D 组持仓 as_of = 回推 34 个自然日(hold_days 为自然日语义,档位断言不变)。
|
||||
NAV_DATE: "date | None" = None
|
||||
TRADE_AT: "datetime | None" = None
|
||||
_C1_AT: "datetime | None" = None
|
||||
_C2_AT: "datetime | None" = None
|
||||
_BOOT_AS_OF: "date | None" = None
|
||||
|
||||
|
||||
def _init_dates(admin) -> None:
|
||||
"""按真库交易日历把造数日期锚定到确定过去的历史开市日(必须在 seed 前调用)。"""
|
||||
global NAV_DATE, TRADE_AT, _C1_AT, _C2_AT, _BOOT_AS_OF
|
||||
is_open = CoreReadOnlyRepository(engine=admin).is_open
|
||||
d = date.today()
|
||||
for _ in range(3):
|
||||
d = previous_biz_day(d, is_open)
|
||||
NAV_DATE = d
|
||||
TRADE_AT = datetime.combine(d, dtime(10, 0))
|
||||
c1 = c2 = d
|
||||
for _ in range(3):
|
||||
c1 = previous_biz_day(c1, is_open)
|
||||
for _ in range(2):
|
||||
c2 = previous_biz_day(c2, is_open)
|
||||
_C1_AT = datetime.combine(c1, dtime(10, 0))
|
||||
_C2_AT = datetime.combine(c2, dtime(10, 0))
|
||||
_BOOT_AS_OF = d - timedelta(days=34)
|
||||
|
||||
_passed = 0
|
||||
_failed = 0
|
||||
@@ -108,8 +135,8 @@ def seed(engine) -> None:
|
||||
)
|
||||
# C 组:两个已存在批次(FIFO 扣减用)
|
||||
for lot_id, qty, nav, confirmed in [
|
||||
("LOT-LOTT-C1", "100", "1.0000", datetime(2026, 9, 1, 10, 0, 0)),
|
||||
("LOT-LOTT-C2", "50", "1.1000", datetime(2026, 9, 2, 10, 0, 0)),
|
||||
("LOT-LOTT-C1", "100", "1.0000", _C1_AT),
|
||||
("LOT-LOTT-C2", "50", "1.1000", _C2_AT),
|
||||
]:
|
||||
conn.execute(
|
||||
text(
|
||||
@@ -127,7 +154,7 @@ def seed(engine) -> None:
|
||||
"INSERT INTO core_holding (customer_id, product_id, qty, cost_amount, "
|
||||
"market_value, pnl_pct, as_of) VALUES (:c, :p, :q, :cost, :cost, 0, :d)"
|
||||
),
|
||||
{"c": CUSTOMER, "p": pid, "q": qty, "cost": cost, "d": date(2026, 8, 1)},
|
||||
{"c": CUSTOMER, "p": pid, "q": qty, "cost": cost, "d": _BOOT_AS_OF},
|
||||
)
|
||||
|
||||
|
||||
@@ -183,7 +210,7 @@ def assert_subscribe(admin, writer, core) -> None:
|
||||
"confirmed_at = T",
|
||||
str(q1(admin, "SELECT confirmed_at FROM core_share_lot WHERE customer_id = :c AND product_id = :p",
|
||||
c=CUSTOMER, p=PROD_SUB)),
|
||||
"2026-09-04 14:00:00",
|
||||
str(TRADE_AT),
|
||||
)
|
||||
|
||||
|
||||
@@ -310,6 +337,7 @@ def assert_rebuild_lots(admin) -> None:
|
||||
|
||||
def main() -> int:
|
||||
admin = get_engine(settings.mysql_core_database, "admin")
|
||||
_init_dates(admin)
|
||||
try:
|
||||
with admin.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
|
||||
Reference in New Issue
Block a user