Files
sanguo_vnpy_v2/tests/portfolio/test_live_reconcile.py
T

647 lines
32 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.
# -*- 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)