Files
sanguo_vnpy_v2/tests/portfolio/test_live_reconcile.py
T

307 lines
14 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"):
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"}