Files
sanguo_vnpy_v2/sanguo_portfolio/live_reconcile.py
T

289 lines
12 KiB
Python

"""迟到成交对账(a 窄修 + b 宽修)——2026-08-25 P1 根治。
事故(前后端 session 21:35 定罪):LiveEngine 同步等待 16s(TRADE_MAX_WAIT_TIME)
超时后弃跟踪,迟到 fill 永不进 ``engine.get_trades`` → 归因链
(runner_live._sync_instance_trades → ledger.apply_trade → save_trade)见不到
→ 实例账本幻影持仓(500+700 股,-18060 元)且无自愈。
两条修法都**直查 QMT 原始行**(broker.get_trades,与 engine 视图无关):
- a 窄修 ``watch_pending_order`` + ``reconcile_pending``:下单返回非终态
(超时/部分成交)→ 进程内待对账名单;归因轮询每轮对名单按券商订单号
直查 QMT 成交,见到即 apply_trade+save_trade;订单终态出名单,隔夜出清
(A股委托当日有效)。
- b 宽修 ``eod_reconcile``(15:05,先于 15:10 恒等式):QMT 当日全账户成交
vs live_trades 已落库行逐笔比对,按 trade_id 或 时间+代码+方向+价+量
对齐,缺失行按归因规则补插(vt_tradeid 加 ``eod:`` 前缀留痕)——兜住
一切「引擎没报」形态。
归因边界:本实例订单 = engine.get_orders() 里的 ``_broker_order_id``(进程
提交过的委托);别家实例/手动单的成交只统计不归因。进程重启后名单与订单表
随进程消失,当日孤儿单只能靠 15:10 恒等式报警走人工(次日 QMT 即查不到
当日成交,无法自动回溯)。全在 sanguo_portfolio 层,bullet_trade 零改动。
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
# 引擎 _maybe_wait 的终态集(canceled 两种拼写都对齐)
_TERMINAL = {"filled", "cancelled", "canceled", "partly_canceled",
"rejected", "failed", "error"}
# EOD 对账窗口:15:05 起(60s 轮询首落在 15:05-15:06),赶在 15:10 恒等式前
EOD_AFTER = (15, 5)
# broker_oid -> {"engine_oid", "is_buy", "amount", "watched_date"}
# 一个进程一个实例(runner_live 单引擎),模块级即实例级
_PENDING: Dict[str, Dict[str, Any]] = {}
_LAST_EOD_DATE: str = ""
def _status_str(status: Any) -> str:
return str(getattr(status, "value", status) or "").strip().lower()
def _row_time(row: Dict[str, Any]) -> datetime:
"""QMT 原始行时间守卫(对齐 runner_live._effective_trade_time 哲学):
datetime 有效原样;YYYYMMDD 数字串/纯数字解析;其余回退当前时刻。"""
v = row.get("time")
if isinstance(v, datetime):
return v if v.year >= 2000 else datetime.now()
digits = None
if isinstance(v, str):
if len(v) >= 14 and v[:14].isdigit():
digits = v[:14]
else:
try:
return datetime.fromisoformat(v.replace("T", " ")[:19])
except ValueError:
return datetime.now()
elif isinstance(v, (int, float)):
digits = str(int(v))
if digits:
digits = digits.zfill(14)[:14]
try:
return datetime.strptime(digits, "%Y%m%d%H%M%S")
except ValueError:
pass
return datetime.now()
# ------------------ a 窄修:待对账名单 ------------------
def watch_pending_order(order: Any) -> bool:
"""下单返回后登记待对账名单(实时线程调用,绝不抛)。
非终态 或 终态但未足额(撤单残量)→ 入名单;None/本地拒单不入。
``_broker_order_id`` 异步路径下可能尚未回填 → 先按 engine 订单号入,
``reconcile_pending`` 下轮从 engine 订单表解析。
"""
try:
if order is None:
return False
status = _status_str(getattr(order, "status", None))
filled = int(getattr(order, "filled", 0) or 0)
amount = int(getattr(order, "amount", 0) or 0)
if status in _TERMINAL and not (0 < filled < amount):
return False # 终态:足额或零成交,无需对账;部分成交残量仍挂
key = str(getattr(order, "_broker_order_id", None) or
getattr(order, "order_id", "") or "")
if not key:
return False
_PENDING[key] = {
"engine_oid": str(getattr(order, "order_id", "") or ""),
"is_buy": bool(getattr(order, "is_buy", True)),
"amount": amount,
"watched_date": datetime.now().strftime("%Y-%m-%d"),
}
logger.info("[live-reconcile] 挂对账名单 %s(%s filled=%s/%s)",
key, status, filled, amount)
return True
except Exception as e: # noqa: BLE001 - 名单失败不阻断下单主流程
logger.warning("[live-reconcile] 登记对账名单失败: %s", e)
return False
def _apply_rows(rows, ledger, db, account_id, strategy_name, is_buy,
trade_id_prefix: str = "") -> int:
"""把 QMT 原始成交行按归因规则入账本+落库,返回新入账笔数。
幂等由 ledger.apply_trade 的 trade_id 判重保证(与即时归因/归因轮询
并发安全);trade_id 用 QMT deal_no → 三条路径见同一笔只记一次。
"""
from sanguo_live.persistence import save_trade
n = 0
for r in rows:
t_time = _row_time(r)
tid = trade_id_prefix + str(r.get("trade_id") or "")
applied = ledger.apply_trade(
is_buy=is_buy,
symbol=str(r.get("security") or ""),
price=float(r.get("price") or 0),
volume=int(r.get("amount") or 0),
trade_id=tid,
trade_date=t_time.strftime("%Y-%m-%d"),
fee=(float(r.get("commission") or 0)
+ float(r.get("tax") or 0)) or None,
)
if not applied:
continue
save_trade(db, account_id, {
"strategy_name": strategy_name,
"symbol": str(r.get("security") or ""),
"direction": "buy" if is_buy else "sell",
"offset": "open" if is_buy else "close",
"price": float(r.get("price") or 0),
"volume": int(r.get("amount") or 0),
"traded_at": t_time.strftime("%Y-%m-%d %H:%M:%S"),
"vt_tradeid": tid,
})
logger.info("[live-reconcile] 迟到成交入账 (account=%s %s %s x%s@%s)",
account_id, "买入" if is_buy else "卖出",
r.get("security"), r.get("amount"), r.get("price"))
n += 1
return n
def reconcile_pending(engine: Any, ledger: Any, db: str, account_id: int,
strategy_name: str) -> int:
"""归因轮询每轮调用:对名单直查 QMT 成交,新见即入账;终态出名单。
名单空时零开销(不查 QMT);任何异常上抛由轮询统一 warning(下轮再来)。
"""
if not _PENDING:
return 0
orders = engine.get_orders() or {}
by_broker: Dict[str, Any] = {}
for o in orders.values():
boid = getattr(o, "_broker_order_id", None)
if boid:
by_broker[str(boid)] = o
today = datetime.now().strftime("%Y-%m-%d")
# 隔夜出清(A股委托当日有效)
for key in [k for k, v in _PENDING.items()
if v.get("watched_date") != today]:
logger.warning("[live-reconcile] 隔夜名单出清 %s(残量未对账,恒等式兜底)",
key)
del _PENDING[key]
# 异步路径 broker_oid 回填:按 engine 订单号解析(迁移到 broker_oid 键)
for key, entry in list(_PENDING.items()):
if key not in by_broker and entry.get("engine_oid"):
o = orders.get(entry["engine_oid"])
boid = getattr(o, "_broker_order_id", None) if o else None
if boid:
entry["engine_oid"] = ""
_PENDING[str(boid)] = entry
del _PENDING[key]
broker = getattr(engine, "broker", None)
trades_all = []
getter = getattr(broker, "get_trades", None)
if callable(getter):
trades_all = getter() or []
total = 0
for key in list(_PENDING):
entry = _PENDING.get(key)
if entry is None:
continue
rows = [r for r in trades_all if str(r.get("order_id") or "") == key]
total += _apply_rows(rows, ledger, db, account_id, strategy_name,
entry["is_buy"])
order_obj = by_broker.get(key)
status = _status_str(getattr(order_obj, "status", None)) \
if order_obj is not None else ""
if status in _TERMINAL:
logger.info("[live-reconcile] 订单终态(%s)出名单 %s", status, key)
_PENDING.pop(key, None)
return total
# ------------------ b 宽修:EOD 对账回填 ------------------
def eod_reconcile(engine: Any, ledger: Any, db: str, account_id: int,
strategy_name: str) -> Dict[str, int]:
"""收盘对账:QMT 当日全账户成交 vs live_trades 已落库行,缺失补插。
已覆盖判定(任一即覆盖):① trade_id(vt_tradeid/账本已见)一致
② 时间(分钟)+代码+方向+价+量 五元组一致。别家实例/手动单只统计。
"""
from sanguo_live.persistence import list_trades
orders = engine.get_orders() or {}
own_by_broker: Dict[str, Any] = {}
for o in orders.values():
boid = getattr(o, "_broker_order_id", None)
if boid:
own_by_broker[str(boid)] = o
broker = getattr(engine, "broker", None)
trades_all = []
getter = getattr(broker, "get_trades", None)
if callable(getter):
trades_all = getter() or []
today = datetime.now().strftime("%Y-%m-%d")
seen_ids = ledger.seen_trade_ids()
tuples = set()
for r in (list_trades(db, account_id) if db else []):
traded_at = str(r.get("traded_at") or "")
if not traded_at.startswith(today):
continue
seen_ids.add(str(r.get("vt_tradeid") or ""))
tuples.add((
traded_at[:16], str(r.get("symbol") or ""),
str(r.get("direction") or ""), round(float(r.get("price") or 0), 4),
int(float(r.get("volume") or 0)),
))
ours, backfilled = 0, 0
for r in trades_all:
boid = str(r.get("order_id") or "")
order_obj = own_by_broker.get(boid)
if order_obj is None:
continue # 别家实例/手动单
ours += 1
is_buy = bool(getattr(order_obj, "is_buy", True))
tid = str(r.get("trade_id") or "")
t_time = _row_time(r)
tkey = (
t_time.strftime("%Y-%m-%d %H:%M"),
str(r.get("security") or ""), "buy" if is_buy else "sell",
round(float(r.get("price") or 0), 4), int(r.get("amount") or 0),
)
if (tid and tid in seen_ids) or tkey in tuples:
continue
n = _apply_rows([r], ledger, db, account_id, strategy_name, is_buy,
trade_id_prefix="eod:")
backfilled += n
if n:
seen_ids.add(f"eod:{tid}")
tuples.add(tkey)
summary = {"qmt_trades": len(trades_all), "ours": ours,
"backfilled": backfilled,
"foreign": len(trades_all) - ours}
if backfilled:
logger.warning(
"[live-reconcile] EOD对账补插 %d 笔 (account=%s QMT全量%d 本实例%d "
"别家%d)——存在引擎未报形态,查当日名单/日志", backfilled,
account_id, summary["qmt_trades"], ours, summary["foreign"])
else:
logger.info("[live-reconcile] EOD对账干净 (account=%s QMT全量%d 本实例%d)",
account_id, summary["qmt_trades"], ours)
return summary
def maybe_eod_reconcile(engine: Any, ledger: Any, db: str, account_id: int,
strategy_name: str,
now: Optional[datetime] = None) -> Optional[Dict[str, int]]:
"""归因轮询每轮调用:15:05 后当日首跑一次;失败不记日下轮重试。"""
global _LAST_EOD_DATE
now = now or datetime.now()
today = now.strftime("%Y-%m-%d")
if _LAST_EOD_DATE == today:
return None
if (now.hour, now.minute) < EOD_AFTER:
return None
summary = eod_reconcile(engine, ledger, db, account_id, strategy_name)
_LAST_EOD_DATE = today
return summary