647 lines
32 KiB
Python
647 lines
32 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""迟到成交对账回归(2026-08-25 P1:引擎 16s 超时弃跟踪 → 迟到 fill 永不进
|
||
engine.get_trades → 账本幻影 500+700 股/-18060 不自愈)。
|
||
|
||
a 窄修:下单返回非终态 → 进程内待对账名单;归因轮询按券商订单号直查 QMT
|
||
成交(broker.get_trades 原始行),绕开 engine.get_trades 视图。
|
||
b 宽修:15:05 EOD 对账——QMT 当日全量成交 vs live_trades 已落库行,
|
||
按 trade_id 或 时间+代码+方向+价+量 对齐,缺失行按归因规则补插。
|
||
|
||
事故时间线(前后端 session 08-25 21:35 定罪):orders 09:35:42-48 提交,
|
||
引擎同步等待 16s 超时弃跟踪,迟到 fill 09:36:24+ 成交——两条路都必须兜住。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime, timedelta
|
||
from types import SimpleNamespace
|
||
|
||
import pytest
|
||
|
||
from sanguo_portfolio import live_reconcile
|
||
from sanguo_portfolio.live_instance_ledger import LiveInstanceLedger
|
||
from sanguo_portfolio.live_reconcile import (
|
||
eod_reconcile, maybe_eod_reconcile, reconcile_pending, watch_pending_order,
|
||
)
|
||
|
||
|
||
# ------------------ 公共替身 ------------------
|
||
def _order(oid="o1", broker_oid="1001", security="000049.XSHE", is_buy=True,
|
||
amount=500, filled=0, status="open", add_time_=None):
|
||
"""替身字段名必须贴真实 bullet_trade Order(08-31 教训:曾被喂不存在的
|
||
``datetime``=,把守恒跨日过滤的属性错位 bug 锁进了断言)。"""
|
||
return SimpleNamespace(
|
||
order_id=oid, _broker_order_id=broker_oid, security=security,
|
||
is_buy=is_buy, amount=amount, filled=filled, status=status,
|
||
add_time=add_time_,
|
||
)
|
||
|
||
|
||
_TODAY = datetime.now().strftime("%Y-%m-%d")
|
||
|
||
|
||
def _qmt_trade(order_id="1001", security="000049.XSHE", amount=500, price=15.0,
|
||
trade_id="90001", time=None, commission=0.0, tax=0.0):
|
||
"""time 缺省=动态今天(EOD 对账按当日过滤,写死日期跨日必翻红,08-26 实锤)。"""
|
||
time = time or f"{_TODAY} 09:36:24"
|
||
return {
|
||
"trade_id": trade_id, "order_id": order_id, "security": security,
|
||
"amount": amount, "price": price, "time": time,
|
||
"commission": commission, "tax": tax,
|
||
}
|
||
|
||
|
||
def _engine(orders, broker_trades):
|
||
"""engine 替身:get_orders 返回本实例 Order;broker.get_trades 返回
|
||
QMT 当日全账户成交原始行(与本实例 engine.get_trades 无关)。"""
|
||
broker = SimpleNamespace(get_trades=lambda: list(broker_trades))
|
||
return SimpleNamespace(
|
||
get_orders=lambda: {o.order_id: o for o in orders},
|
||
broker=broker,
|
||
)
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _clean_pending():
|
||
live_reconcile._PENDING.clear()
|
||
live_reconcile._LAST_EOD_DATE = ""
|
||
live_reconcile._LAST_GAP_LOG_SIG = ""
|
||
live_reconcile._LAST_REJ_LOG_SIG = ""
|
||
yield
|
||
live_reconcile._PENDING.clear()
|
||
live_reconcile._LAST_EOD_DATE = ""
|
||
live_reconcile._LAST_GAP_LOG_SIG = ""
|
||
live_reconcile._LAST_REJ_LOG_SIG = ""
|
||
|
||
|
||
@pytest.fixture
|
||
def db(tmp_path):
|
||
from sanguo_live.persistence import init_db
|
||
path = str(tmp_path / "live.db")
|
||
init_db(path)
|
||
return path
|
||
|
||
|
||
# ------------------ a 窄修:待对账名单 ------------------
|
||
class TestWatchPendingOrder:
|
||
def test_timeout_order_enters_watch_list(self):
|
||
"""16s 超时形态:status=open / 部分成交 → 进名单。"""
|
||
assert watch_pending_order(_order(status="open", filled=0)) is True
|
||
assert watch_pending_order(_order(status="filling", filled=200)) is True
|
||
assert "1001" in live_reconcile._PENDING
|
||
|
||
def test_terminal_full_fill_not_watched(self):
|
||
"""已终态且足额成交 → 无需对账。"""
|
||
assert watch_pending_order(
|
||
_order(status="filled", filled=500)) is False
|
||
assert live_reconcile._PENDING == {}
|
||
|
||
def test_terminal_but_partial_fill_watched(self):
|
||
"""终态(撤单)但部分成交——残量成交仍可能迟到,进名单。"""
|
||
assert watch_pending_order(
|
||
_order(status="canceled", filled=300)) is True
|
||
|
||
def test_none_and_local_reject_are_noop(self):
|
||
assert watch_pending_order(None) is False
|
||
assert watch_pending_order(_order(status="rejected", filled=0)) is False
|
||
assert live_reconcile._PENDING == {}
|
||
|
||
|
||
class TestReconcilePending:
|
||
def test_late_fill_attributed_bypassing_engine_view(self, db):
|
||
"""事故原样:engine.get_trades 已见不到该单(此处干脆不经过 engine 视图),
|
||
但 QMT 原始行里有迟到 fill → 直查归因进账本+落库。"""
|
||
led = LiveInstanceLedger(initial_cash=100_000)
|
||
watch_pending_order(_order(is_buy=True, amount=500))
|
||
eng = _engine([_order(status="filled", filled=500)],
|
||
[_qmt_trade(amount=500, price=15.0)])
|
||
n = reconcile_pending(eng, led, db, 19, "momentum_timing")
|
||
assert n == 1
|
||
assert led.positions["000049.XSHE"]["volume"] == 500
|
||
from sanguo_live.persistence import list_trades
|
||
rows = list_trades(db, 19)
|
||
assert len(rows) == 1
|
||
assert rows[0]["vt_tradeid"] == "90001"
|
||
assert rows[0]["direction"] == "buy"
|
||
|
||
def test_idempotent_across_rounds_and_with_intraday_ids(self, db):
|
||
"""同 trade_id 二轮不重复;与即时归因(engine 视图已记 deal_no)互幂等。"""
|
||
led = LiveInstanceLedger(initial_cash=100_000)
|
||
watch_pending_order(_order())
|
||
eng = _engine([_order(status="filled", filled=500)], [_qmt_trade()])
|
||
assert reconcile_pending(eng, led, db, 19, "s") == 1
|
||
assert reconcile_pending(eng, led, db, 19, "s") == 0
|
||
# 即时归因以同一 deal_no 已入账 → 对账再见到零增量
|
||
assert led.apply_trade(True, "000049.XSHE", 15.0, 500, "90001",
|
||
"2026-08-25") is False
|
||
|
||
def test_foreign_trades_not_attributed(self, db):
|
||
"""名单单 1001;QMT 行里别家 8800099 的成交不归因。"""
|
||
led = LiveInstanceLedger(initial_cash=100_000)
|
||
watch_pending_order(_order())
|
||
eng = _engine(
|
||
[_order(status="filled", filled=500)],
|
||
[_qmt_trade(order_id="8800099", trade_id="99xxx",
|
||
security="600519.XSHG", amount=100, price=1500.0),
|
||
_qmt_trade()])
|
||
n = reconcile_pending(eng, led, db, 19, "s")
|
||
assert n == 1
|
||
assert "600519.XSHG" not in led.positions
|
||
assert led.positions["000049.XSHE"]["volume"] == 500
|
||
|
||
def test_watch_cleared_when_order_terminal(self, db):
|
||
"""订单终态 + 成交已见 → 出名单;名单空后不再查 QMT。"""
|
||
led = LiveInstanceLedger(initial_cash=100_000)
|
||
watch_pending_order(_order())
|
||
eng = _engine([_order(status="filled", filled=500)], [_qmt_trade()])
|
||
reconcile_pending(eng, led, db, 19, "s")
|
||
assert live_reconcile._PENDING == {}
|
||
# 名单已空:broker 不可查也不报错
|
||
eng2 = SimpleNamespace(get_orders=lambda: {}, broker=None)
|
||
assert reconcile_pending(eng2, led, db, 19, "s") == 0
|
||
|
||
def test_still_open_stays_watched(self, db):
|
||
"""订单还挂着(未终态) → 留在名单下轮继续。"""
|
||
led = LiveInstanceLedger(initial_cash=100_000)
|
||
watch_pending_order(_order())
|
||
eng = _engine([_order(status="open", filled=0)], [])
|
||
assert reconcile_pending(eng, led, db, 19, "s") == 0
|
||
assert "1001" in live_reconcile._PENDING
|
||
|
||
def test_missing_broker_oid_resolved_from_engine(self, db):
|
||
"""下单返回时 _broker_order_id 尚未回填(异步路径)→ 下轮从
|
||
engine 订单表按 engine order_id 解析后再直查。"""
|
||
led = LiveInstanceLedger(initial_cash=100_000)
|
||
assert watch_pending_order(_order(broker_oid=None)) is True
|
||
eng = _engine([_order(status="filled", filled=500)], [_qmt_trade()])
|
||
assert reconcile_pending(eng, led, db, 19, "s") == 1
|
||
assert led.positions["000049.XSHE"]["volume"] == 500
|
||
|
||
def test_cross_day_entry_dropped(self, db):
|
||
"""隔夜名单出清(A股订单当日有效,跨日残单不再对账)。"""
|
||
watch_pending_order(_order())
|
||
for entry in live_reconcile._PENDING.values():
|
||
entry["watched_date"] = "2026-08-24"
|
||
eng = _engine([_order(status="filled", filled=500)], [_qmt_trade()])
|
||
led = LiveInstanceLedger(initial_cash=100_000)
|
||
assert reconcile_pending(eng, led, db, 19, "s") == 0
|
||
assert live_reconcile._PENDING == {}
|
||
assert led.positions == {}
|
||
|
||
|
||
# ------------------ b 宽修:EOD 对账回填 ------------------
|
||
class TestEodReconcile:
|
||
def test_backfills_missing_rows(self, db):
|
||
"""QMT 有本实例成交、live_trades 无 → 补插账本+DB(vt_tradeid 带
|
||
eod: 前缀标记回填来源)。"""
|
||
led = LiveInstanceLedger(initial_cash=100_000)
|
||
eng = _engine([_order(status="filled", filled=700)],
|
||
[_qmt_trade(security="000039.XSHE", amount=700,
|
||
price=16.0, trade_id="90002")])
|
||
summary = eod_reconcile(eng, led, db, 20, "small_cap")
|
||
assert summary["backfilled"] == 1
|
||
assert led.positions["000039.XSHE"]["volume"] == 700
|
||
from sanguo_live.persistence import list_trades
|
||
rows = list_trades(db, 20)
|
||
assert len(rows) == 1
|
||
assert rows[0]["vt_tradeid"] == "eod:90002"
|
||
|
||
def test_existing_rows_not_duplicated(self, db):
|
||
"""DB 已有同 trade_id 行(intraday 已记)→ 不重插不重记。"""
|
||
from sanguo_live.persistence import save_trade
|
||
save_trade(db, 20, {
|
||
"strategy_name": "s", "symbol": "000049.XSHE",
|
||
"direction": "buy", "offset": "open", "price": 15.0,
|
||
"volume": 500, "traded_at": f"{_TODAY} 09:36:24",
|
||
"vt_tradeid": "90001"})
|
||
led = LiveInstanceLedger(initial_cash=100_000)
|
||
led.apply_trade(True, "000049.XSHE", 15.0, 500, "90001", "2026-08-25")
|
||
eng = _engine([_order(status="filled", filled=500)], [_qmt_trade()])
|
||
summary = eod_reconcile(eng, led, db, 20, "s")
|
||
assert summary["backfilled"] == 0
|
||
assert led.positions["000049.XSHE"]["volume"] == 500
|
||
|
||
def test_tuple_match_covers_rows_saved_without_trade_id(self, db):
|
||
"""intraday 行 vt_tradeid 为空(md5 兜底/旧数据)→ 按
|
||
时间+代码+方向+价+量 对齐视为已覆盖,不双记。"""
|
||
from sanguo_live.persistence import save_trade
|
||
save_trade(db, 20, {
|
||
"strategy_name": "s", "symbol": "000049.XSHE",
|
||
"direction": "buy", "offset": "open", "price": 15.0,
|
||
"volume": 500, "traded_at": f"{_TODAY} 09:36:24",
|
||
"vt_tradeid": ""})
|
||
led = LiveInstanceLedger(initial_cash=100_000)
|
||
eng = _engine([_order(status="filled", filled=500)], [_qmt_trade()])
|
||
assert eod_reconcile(eng, led, db, 20, "s")["backfilled"] == 0
|
||
assert led.positions == {}
|
||
|
||
def test_foreign_trades_skipped(self, db):
|
||
"""QMT 当日全账户成交含别家 → 只统计不归因。"""
|
||
led = LiveInstanceLedger(initial_cash=100_000)
|
||
eng = _engine(
|
||
[_order(status="filled", filled=500)],
|
||
[_qmt_trade(order_id="8800099", security="600519.XSHG",
|
||
amount=100, price=1500.0, trade_id="777")])
|
||
summary = eod_reconcile(eng, led, db, 20, "s")
|
||
assert summary["backfilled"] == 0
|
||
assert summary["foreign"] == 1
|
||
assert led.positions == {}
|
||
|
||
def test_no_broker_is_safe(self):
|
||
eod_reconcile(SimpleNamespace(get_orders=lambda: {}, broker=None),
|
||
LiveInstanceLedger(), "", 1, "s") # 不抛
|
||
|
||
def test_twin_fill_backfilled_not_swallowed(self, db):
|
||
"""孪生成交回补(08-27 001234 实锤):同分钟+同价+同量的两笔成交仅
|
||
成交编号不同,集合式五元组判重把第二笔吞成"已覆盖"→账本短 100 股
|
||
→幻影持仓。计数感知后第二笔必须补上。"""
|
||
from sanguo_live.persistence import save_trade
|
||
led = LiveInstanceLedger(initial_cash=1_000_000)
|
||
led.apply_trade(True, "001234.XSHE", 22.17, 200, "seed_buy", _TODAY)
|
||
save_trade(db, 20, {
|
||
"strategy_name": "s", "symbol": "001234.XSHE",
|
||
"direction": "buy", "offset": "open", "price": 22.17,
|
||
"volume": 200, "traded_at": f"{_TODAY} 09:20:00",
|
||
"vt_tradeid": "seed_buy"})
|
||
# 事件路径只记到孪生之一(vt_tradeid=哈希,非成交编号)
|
||
led.apply_trade(False, "001234.XSHE", 22.35, 100, "hash_a", _TODAY)
|
||
save_trade(db, 20, {
|
||
"strategy_name": "s", "symbol": "001234.XSHE",
|
||
"direction": "sell", "offset": "close", "price": 22.35,
|
||
"volume": 100, "traded_at": f"{_TODAY} 09:31:57",
|
||
"vt_tradeid": "hash_a"})
|
||
eng = _engine(
|
||
[_order(status="filled", amount=200, filled=200, is_buy=False,
|
||
security="001234.XSHE", broker_oid="235")],
|
||
[_qmt_trade(order_id="235", security="001234.XSHE", amount=100,
|
||
price=22.35, trade_id="1010000032376071",
|
||
time=f"{_TODAY} 09:31:57"),
|
||
_qmt_trade(order_id="235", security="001234.XSHE", amount=100,
|
||
price=22.35, trade_id="1010000032376079",
|
||
time=f"{_TODAY} 09:31:57")])
|
||
summary = eod_reconcile(eng, led, db, 20, "small_cap_ex")
|
||
assert summary["backfilled"] == 1
|
||
# 买200-卖200:清零后台账不再持有该键(volume 视为 0)
|
||
assert led.positions.get("001234.XSHE", {}).get("volume", 0) == 0
|
||
|
||
def test_conservation_gap_alarm_when_journal_also_missing(self, db, caplog):
|
||
"""守恒校验:订单 filled=1100 但台账只记 1000(连 QMT 流水都缺这行
|
||
的极端形态,补插无从下手)→ 计数守恒不平时大声告警,不再静默幻影。"""
|
||
from sanguo_live.persistence import save_trade
|
||
led = LiveInstanceLedger(initial_cash=1_000_000)
|
||
led.apply_trade(False, "001234.XSHE", 22.35, 1000, "h1", _TODAY)
|
||
save_trade(db, 20, {
|
||
"strategy_name": "s", "symbol": "001234.XSHE",
|
||
"direction": "sell", "offset": "close", "price": 22.35,
|
||
"volume": 1000, "traded_at": f"{_TODAY} 09:31:57",
|
||
"vt_tradeid": "h1"})
|
||
eng = _engine(
|
||
[_order(status="filled", amount=1100, filled=1100, is_buy=False,
|
||
security="001234.XSHE", broker_oid="235")],
|
||
[_qmt_trade(order_id="235", security="001234.XSHE", amount=1000,
|
||
price=22.35, trade_id="known1",
|
||
time=f"{_TODAY} 09:31:57")])
|
||
with caplog.at_level("WARNING"):
|
||
summary = eod_reconcile(eng, led, db, 20, "small_cap_ex")
|
||
assert summary["backfilled"] == 0
|
||
assert summary["conservation_gaps"] == [{
|
||
"symbol": "001234", "side": "sell",
|
||
"order_volume": 1100, "ledger_volume": 1000, "gap": 100}]
|
||
assert any("守恒缺口" in r.message for r in caplog.records)
|
||
|
||
def test_conservation_balanced_no_alarm(self, db, caplog):
|
||
"""守恒平时零告警(订单合计=台账合计,含 EOD 回填后的量)。"""
|
||
led = LiveInstanceLedger(initial_cash=1_000_000)
|
||
eng = _engine(
|
||
[_order(status="filled", amount=500, filled=500, is_buy=False,
|
||
security="000049.XSHE", broker_oid="1001")],
|
||
[_qmt_trade(amount=500, price=15.0)])
|
||
with caplog.at_level("WARNING"):
|
||
summary = eod_reconcile(eng, led, db, 20, "s")
|
||
assert summary["backfilled"] == 1 # 先回填
|
||
assert summary["conservation_gaps"] == [] # 回填后守恒平,零缺口
|
||
|
||
def test_twin_10_fills_vs_9_ledger_rows_backfill(self, db):
|
||
"""08-28 003011 原样复现:订单 1200 全成,事件路径只落 9 笔(哈希 id,
|
||
同分钟 120@20.45),QMT 快照 10 笔齐全 → 第 10 笔必须补插、守恒平。
|
||
(该测试通过=匹配逻辑对孪生正确;当日生产「补插 0」只能出自 15:05
|
||
快照内容缺行——由缺口重试制兜底,见 TestMaybeEodReconcile。)"""
|
||
from sanguo_live.persistence import save_trade
|
||
led = LiveInstanceLedger(initial_cash=1_000_000)
|
||
for i in range(9):
|
||
led.apply_trade(True, "003011.XSHE", 20.45, 120, f"hash_{i}", _TODAY)
|
||
save_trade(db, 20, {
|
||
"strategy_name": "s", "symbol": "003011.XSHE",
|
||
"direction": "buy", "offset": "open", "price": 20.45,
|
||
"volume": 120, "traded_at": f"{_TODAY} 09:32:38",
|
||
"vt_tradeid": f"hash_{i}"})
|
||
qmt = [_qmt_trade(order_id="326", security="003011.XSHE", amount=120,
|
||
price=20.45, trade_id=f"10100000325577{41 + i}",
|
||
time=f"{_TODAY} 09:32:{29 + i}") for i in range(10)]
|
||
eng = _engine(
|
||
[_order(status="filled", amount=1200, filled=1200, is_buy=True,
|
||
security="003011.XSHE", broker_oid="326")], qmt)
|
||
summary = eod_reconcile(eng, led, db, 20, "small_cap_ex")
|
||
assert summary["backfilled"] == 1
|
||
assert summary["conservation_gaps"] == []
|
||
assert led.positions["003011.XSHE"]["volume"] == 1200
|
||
|
||
def test_rejected_order_alarm(self, db, caplog):
|
||
"""08-28 600818 形态:当日买入废单(filled=0)→ EOD 汇总告警,当天可见。"""
|
||
led = LiveInstanceLedger()
|
||
eng = _engine(
|
||
[_order(status="rejected", filled=0, amount=10900, is_buy=True,
|
||
security="600818.XSHG", broker_oid="661")], [])
|
||
with caplog.at_level("WARNING"):
|
||
summary = eod_reconcile(eng, led, db, 19, "momentum")
|
||
assert summary["rejected_orders"] == [
|
||
{"symbol": "600818", "side": "buy", "amount": 10900,
|
||
"why": "废单(柜台报rejected)"}]
|
||
assert any("废单" in r.message for r in caplog.records)
|
||
|
||
def test_rejected_partial_or_crossday_not_in_alarm(self, db, caplog):
|
||
"""部分成交残量的废单(守恒/补插已覆盖)与昨日废单都不进清单。"""
|
||
led = LiveInstanceLedger()
|
||
eng = _engine(
|
||
[_order(status="rejected", filled=300, amount=500, broker_oid="a"),
|
||
_order(status="rejected", filled=0, amount=200, broker_oid="b",
|
||
add_time_="2020-01-01 10:00:00")],
|
||
[])
|
||
with caplog.at_level("WARNING"):
|
||
summary = eod_reconcile(eng, led, db, 19, "s")
|
||
assert summary["rejected_orders"] == []
|
||
assert not any("废单" in r.message for r in caplog.records)
|
||
|
||
def test_stale_order_suspected_alarm(self, db, caplog):
|
||
"""09-01 盲区补第二肢:当日订单 EOD 非终态+零成交 = 疑似废单。
|
||
|
||
08-31 T+1 探针实锤:柜台级拒单不进 QMT 当日订单 API,订单状态
|
||
永停非终态——等柜台报 rejected 等不到,只能提交侧自证。
|
||
"""
|
||
led = LiveInstanceLedger()
|
||
eng = _engine(
|
||
[_order(status="submitted", filled=0, amount=100, is_buy=False,
|
||
security="300059.XSHE", broker_oid="p1")], [])
|
||
with caplog.at_level("WARNING"):
|
||
summary = eod_reconcile(eng, led, db, 18, "channel_test")
|
||
assert summary["rejected_orders"] == [
|
||
{"symbol": "300059", "side": "sell", "amount": 100,
|
||
"why": "疑似废单(EOD非终态零成交)"}]
|
||
assert any("废单" in r.message for r in caplog.records)
|
||
|
||
def test_cancelled_or_crossday_stale_not_suspected(self, db, caplog):
|
||
"""正常撤单(零成交但已到终态)与昨日挂死单都不进疑似清单。"""
|
||
led = LiveInstanceLedger()
|
||
eng = _engine(
|
||
[_order(status="cancelled", filled=0, broker_oid="c1"),
|
||
_order(status="submitted", filled=0, broker_oid="c2",
|
||
add_time_="2020-01-01 10:00:00")],
|
||
[])
|
||
with caplog.at_level("WARNING"):
|
||
summary = eod_reconcile(eng, led, db, 18, "s")
|
||
assert summary["rejected_orders"] == []
|
||
assert not any("废单" in r.message for r in caplog.records)
|
||
|
||
def test_yesterday_order_no_conservation_gap(self, db, caplog):
|
||
"""08-31 守恒误报回归:引擎跨交易日存活(进程不死)时,**昨日**的
|
||
终态足额订单不得进当日守恒对比——台账只记当日行,历史订单计入
|
||
必然产生假缺口(原 ``datetime`` 属性误读恒 None,过滤从不生效)。"""
|
||
led = LiveInstanceLedger(initial_cash=1_000_000)
|
||
from datetime import timedelta as _td
|
||
yday = (datetime.now() - _td(days=1)).strftime("%Y-%m-%d 09:30:00")
|
||
eng = _engine(
|
||
[_order(status="filled", filled=500, broker_oid="old",
|
||
add_time_=yday),
|
||
_order(status="filled", filled=500, broker_oid="new")],
|
||
[_qmt_trade(order_id="new", trade_id="t-new")])
|
||
with caplog.at_level("WARNING"):
|
||
summary = eod_reconcile(eng, led, db, 19, "s")
|
||
assert summary["conservation_gaps"] == []
|
||
assert summary["rejected_orders"] == []
|
||
|
||
def test_gap_warning_dedup_on_identical_retry(self, db, caplog):
|
||
"""缺口重试轮:内容不变只首轮回 WARNING,重复轮降级 INFO 防刷屏。"""
|
||
from sanguo_live.persistence import save_trade
|
||
led = LiveInstanceLedger(initial_cash=1_000_000)
|
||
led.apply_trade(False, "001234.XSHE", 22.35, 1000, "h1", _TODAY)
|
||
save_trade(db, 20, {
|
||
"strategy_name": "s", "symbol": "001234.XSHE",
|
||
"direction": "sell", "offset": "close", "price": 22.35,
|
||
"volume": 1000, "traded_at": f"{_TODAY} 09:31:57",
|
||
"vt_tradeid": "h1"})
|
||
eng = _engine(
|
||
[_order(status="filled", amount=1100, filled=1100, is_buy=False,
|
||
security="001234.XSHE", broker_oid="235")],
|
||
[_qmt_trade(order_id="235", security="001234.XSHE", amount=1000,
|
||
price=22.35, trade_id="known1",
|
||
time=f"{_TODAY} 09:31:57")])
|
||
with caplog.at_level("INFO"):
|
||
eod_reconcile(eng, led, db, 20, "s")
|
||
eod_reconcile(eng, led, db, 20, "s")
|
||
warns = [r for r in caplog.records
|
||
if "守恒缺口" in r.message and r.levelname == "WARNING"]
|
||
infos = [r for r in caplog.records
|
||
if "守恒缺口" in r.message and r.levelname == "INFO"]
|
||
assert len(warns) == 1
|
||
assert len(infos) == 1
|
||
|
||
|
||
class TestMaybeEodReconcile:
|
||
def test_before_window_is_noop(self):
|
||
assert maybe_eod_reconcile(
|
||
SimpleNamespace(), LiveInstanceLedger(), "", 1, "s",
|
||
now=datetime(2026, 8, 25, 14, 59)) is None
|
||
|
||
def test_runs_once_per_day(self, db):
|
||
"""窗口内首跑生效并记日;当日再调直接跳过。"""
|
||
led = LiveInstanceLedger(initial_cash=100_000)
|
||
eng = _engine([_order(status="filled", filled=500)], [_qmt_trade()])
|
||
s1 = maybe_eod_reconcile(eng, led, db, 20, "s",
|
||
now=datetime(2026, 8, 25, 15, 6))
|
||
assert s1 is not None and s1["backfilled"] == 1
|
||
assert maybe_eod_reconcile(
|
||
eng, led, db, 20, "s", now=datetime(2026, 8, 25, 15, 7)) is None
|
||
|
||
def test_failure_retries_next_round(self, db):
|
||
"""首轮 QMT 查询抛错 → 不记日,下轮重试(日终前自愈)。"""
|
||
led = LiveInstanceLedger(initial_cash=100_000)
|
||
|
||
def boom():
|
||
raise RuntimeError("QMT 断连")
|
||
|
||
eng = SimpleNamespace(get_orders=lambda: {},
|
||
broker=SimpleNamespace(get_trades=boom))
|
||
with pytest.raises(RuntimeError):
|
||
maybe_eod_reconcile(eng, led, db, 20, "s",
|
||
now=datetime(2026, 8, 25, 15, 6))
|
||
assert live_reconcile._LAST_EOD_DATE == ""
|
||
ok = _engine([], [])
|
||
assert maybe_eod_reconcile(
|
||
ok, led, db, 20, "s", now=datetime(2026, 8, 25, 15, 8)) == {
|
||
"qmt_trades": 0, "ours": 0, "backfilled": 0, "foreign": 0,
|
||
"conservation_gaps": [], "rejected_orders": []}
|
||
|
||
def test_gap_keeps_retrying_until_closed(self, db):
|
||
"""守恒缺口敞开 → 不记日持续重查;视图补全后补插+守恒平+记日
|
||
(08-28 003011:15:05 快照缺第 10 笔,晚间补全即当晚自愈)。"""
|
||
from sanguo_live.persistence import save_trade
|
||
led = LiveInstanceLedger(initial_cash=1_000_000)
|
||
led.apply_trade(False, "001234.XSHE", 22.35, 1000, "h1", _TODAY)
|
||
save_trade(db, 20, {
|
||
"strategy_name": "s", "symbol": "001234.XSHE",
|
||
"direction": "sell", "offset": "close", "price": 22.35,
|
||
"volume": 1000, "traded_at": f"{_TODAY} 09:31:57",
|
||
"vt_tradeid": "h1"})
|
||
order = _order(status="filled", amount=1100, filled=1100, is_buy=False,
|
||
security="001234.XSHE", broker_oid="235")
|
||
# 15:06 快照:QMT 只有 1000(缺孪生 100)→ 有缺口,不记日
|
||
eng_missing = _engine([order], [
|
||
_qmt_trade(order_id="235", security="001234.XSHE", amount=1000,
|
||
price=22.35, trade_id="known1",
|
||
time=f"{_TODAY} 09:31:57")])
|
||
s1 = maybe_eod_reconcile(eng_missing, led, db, 20, "s",
|
||
now=datetime(2026, 8, 25, 15, 6))
|
||
assert s1 is not None and s1["conservation_gaps"]
|
||
assert live_reconcile._LAST_EOD_DATE == ""
|
||
# 22:30 快照:视图补全(孪生 100 出现)→ 补插+守恒平+记日
|
||
eng_full = _engine([order], [
|
||
_qmt_trade(order_id="235", security="001234.XSHE", amount=1000,
|
||
price=22.35, trade_id="known1",
|
||
time=f"{_TODAY} 09:31:57"),
|
||
_qmt_trade(order_id="235", security="001234.XSHE", amount=100,
|
||
price=22.35, trade_id="twin2",
|
||
time=f"{_TODAY} 09:31:57")])
|
||
s2 = maybe_eod_reconcile(eng_full, led, db, 20, "s",
|
||
now=datetime(2026, 8, 25, 22, 30))
|
||
assert s2["backfilled"] == 1
|
||
assert s2["conservation_gaps"] == []
|
||
assert live_reconcile._LAST_EOD_DATE != ""
|
||
# 记日后当日不再跑
|
||
assert maybe_eod_reconcile(eng_full, led, db, 20, "s",
|
||
now=datetime(2026, 8, 25, 22, 40)) is None
|
||
|
||
def test_after_retry_window_marks_done(self, db):
|
||
"""23:00 后仍不平 → 记日放弃(当日 API 零点翻篇,再查无意义)。"""
|
||
led = LiveInstanceLedger()
|
||
eng = _engine([], [])
|
||
assert maybe_eod_reconcile(eng, led, db, 20, "s",
|
||
now=datetime(2026, 8, 25, 23, 1)) is None
|
||
assert live_reconcile._LAST_EOD_DATE == "2026-08-25"
|
||
|
||
|
||
# ------------------ 事故重放 + seen_trade_ids ------------------
|
||
class TestIncidentReplay:
|
||
def test_full_chain_0935_timeout_0936_late_fill(self, db):
|
||
"""完整时间线:09:35:42 提交即超时(名单)→ 09:36:24 迟到 fill
|
||
(engine 视图缺失)→ 60s 轮询对账归因 → EOD 复核零缺口。"""
|
||
led = LiveInstanceLedger(initial_cash=1_000_000)
|
||
# 09:35:42 bt_order 返回:16s 等待超时,status=open filled=0
|
||
watch_pending_order(_order(status="open", filled=0))
|
||
# 09:36:24 迟到 fill:只在 QMT 原始行里(engine.get_trades 见不到)
|
||
eng = _engine(
|
||
[_order(status="filled", filled=500)],
|
||
[_qmt_trade(amount=500, price=15.0)])
|
||
assert reconcile_pending(eng, led, db, 19, "momentum_timing") == 1
|
||
# 名单出清 + EOD 复核:无缺口、无重复
|
||
summary = eod_reconcile(eng, led, db, 19, "momentum_timing")
|
||
assert summary["backfilled"] == 0
|
||
from sanguo_live.persistence import list_trades
|
||
assert len(list_trades(db, 19)) == 1
|
||
# 账本口径:100万 − 500×15 − max(7500×0.0003,5)=5
|
||
assert led.cash == pytest.approx(1_000_000 - 7500 - 5)
|
||
|
||
|
||
class TestLedgerSeenIds:
|
||
def test_seen_trade_ids_snapshot(self):
|
||
led = LiveInstanceLedger()
|
||
led.apply_trade(True, "000001.XSHE", 10.0, 100, "t1", "2026-08-25")
|
||
snap = led.seen_trade_ids()
|
||
assert snap == {"t1"}
|
||
snap.add("t2") # 副本可改,不污染账本
|
||
assert led.seen_trade_ids() == {"t1"}
|
||
|
||
|
||
# ------------------ 重启兜底:remark 指纹重建(2026-09-01 002646 事故) ------------------
|
||
def _qmt_order_row(order_id="1090", remark="bt:live_19:ab12cd34",
|
||
security="600276.XSHG", amount=600, filled=600,
|
||
status=56, is_buy=True):
|
||
"""贴 QmtBroker.sync_orders 行键(状态为 raw 码,56=全部成交)。"""
|
||
return {"order_id": order_id, "security": security, "amount": amount,
|
||
"filled": filled, "status": status, "is_buy": is_buy,
|
||
"order_remark": remark}
|
||
|
||
|
||
def _restarted_engine(qmt_orders, qmt_trades):
|
||
"""重启后的 engine 替身:订单注册表为空(进程态已失),broker 行为贴
|
||
QmtBroker(get_orders 带 remark + _map_order_status 归一)。"""
|
||
broker = SimpleNamespace(
|
||
get_orders=lambda: list(qmt_orders),
|
||
get_trades=lambda: list(qmt_trades),
|
||
_map_order_status=lambda raw: SimpleNamespace(
|
||
value="filled" if raw == 56 else f"raw_{raw}"),
|
||
)
|
||
return SimpleNamespace(get_orders=lambda: {}, broker=broker)
|
||
|
||
|
||
class TestRemarkRebuildAfterRestart:
|
||
"""002646 形态:轮换杀进程 → QMT 视图 18:48-19:15 才补全迟到成交 →
|
||
新进程 EOD「本实例=0」记日,孤儿成交永久失联。remark 指纹重建 own 集
|
||
后,重启照样能归因/补插/守恒。"""
|
||
|
||
def test_backfill_via_remark_after_restart(self, db):
|
||
"""engine 订单空 + QMT 订单 remark=本实例 → 迟到成交照常补插+守恒平。"""
|
||
led = LiveInstanceLedger(initial_cash=1_000_000)
|
||
eng = _restarted_engine(
|
||
[_qmt_order_row(order_id="1090", amount=600, filled=600)],
|
||
[_qmt_trade(order_id="1090", security="600276.XSHG",
|
||
amount=600, price=7.0, trade_id="99016")])
|
||
summary = eod_reconcile(eng, led, db, 19, "s")
|
||
assert summary["ours"] == 1 # remark 重建归因成功
|
||
assert summary["backfilled"] == 1 # 002646 那笔 600 股补回来
|
||
assert summary["conservation_gaps"] == []
|
||
rows = live_reconcile_eod_rows(db, 19)
|
||
assert rows and rows[-1]["volume"] == 600
|
||
|
||
def test_other_instance_remark_not_attributed(self, db):
|
||
"""别家实例的 remark 前缀(live_18)不归因给 live_19。"""
|
||
led = LiveInstanceLedger(initial_cash=1_000_000)
|
||
eng = _restarted_engine(
|
||
[_qmt_order_row(order_id="1080", remark="bt:live_18:ffff1111")],
|
||
[_qmt_trade(order_id="1080", security="600276.XSHG",
|
||
amount=600, price=7.0, trade_id="99017")])
|
||
summary = eod_reconcile(eng, led, db, 19, "s")
|
||
assert summary["ours"] == 0
|
||
assert summary["backfilled"] == 0
|
||
assert summary["foreign"] == 1
|
||
|
||
def test_row_without_side_excluded(self, db, caplog):
|
||
"""is_buy 缺失的 remark 行弃用——方向不明宁可漏归因不可错归因。"""
|
||
led = LiveInstanceLedger(initial_cash=1_000_000)
|
||
row = _qmt_order_row(order_id="1091")
|
||
row["is_buy"] = None
|
||
eng = _restarted_engine(
|
||
[row],
|
||
[_qmt_trade(order_id="1091", security="600276.XSHG",
|
||
amount=100, price=7.0, trade_id="99018")])
|
||
with caplog.at_level("WARNING"):
|
||
summary = eod_reconcile(eng, led, db, 19, "s")
|
||
assert summary["backfilled"] == 0
|
||
assert any("方向" in r.message or "弃用" in r.message
|
||
for r in caplog.records)
|
||
|
||
def test_sell_direction_propagates(self, db):
|
||
"""卖出方向的 remark 重建单,补插行 direction=sell(错方向入账=反向污染)。"""
|
||
led = LiveInstanceLedger(initial_cash=1_000_000)
|
||
eng = _restarted_engine(
|
||
[_qmt_order_row(order_id="1092", is_buy=False)],
|
||
[_qmt_trade(order_id="1092", security="600276.XSHG",
|
||
amount=600, price=7.0, trade_id="99019")])
|
||
summary = eod_reconcile(eng, led, db, 19, "s")
|
||
assert summary["backfilled"] == 1
|
||
rows = live_reconcile_eod_rows(db, 19)
|
||
assert rows[-1]["direction"] == "sell"
|
||
|
||
|
||
def live_reconcile_eod_rows(db, account_id):
|
||
from sanguo_live.persistence import list_trades
|
||
return list_trades(db, account_id)
|