# -*- 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"): return SimpleNamespace( order_id=oid, _broker_order_id=broker_oid, security=security, is_buy=is_buy, amount=amount, filled=filled, status=status, ) def _qmt_trade(order_id="1001", security="000049.XSHE", amount=500, price=15.0, trade_id="90001", time="2026-08-25 09:36:24", commission=0.0, tax=0.0): 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 = "" yield live_reconcile._PENDING.clear() live_reconcile._LAST_EOD_DATE = "" @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": "2026-08-25 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": "2026-08-25 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") # 不抛 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} # ------------------ 事故重放 + 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(time="2026-08-25 09:36:24", 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"}