"""迟到成交对账(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 types import SimpleNamespace 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) # 重试窗口上界:QMT 当日成交 API 零点翻篇,23:00 后再查无意义 → 放弃留证。 # 08-28 003011 实锤:15:05 快照缺第 10 笔成交,用户 22 点导出已齐全 # (=QMT 当日视图会滞后补全)——守恒缺口非空时持续重试即可当晚自愈 EOD_RETRY_UNTIL = (23, 0) # broker_oid -> {"engine_oid", "is_buy", "amount", "watched_date"} # 一个进程一个实例(runner_live 单引擎),模块级即实例级 _PENDING: Dict[str, Dict[str, Any]] = {} _LAST_EOD_DATE: str = "" # 缺口/废单告警签名:重试轮里内容不变降级 INFO,防 60s×8h 刷屏 _LAST_GAP_LOG_SIG: str = "" _LAST_REJ_LOG_SIG: 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 # ------------------ 重启兜底:remark 指纹重建本实例订单集 ------------------ def _own_orders_remarked(engine: Any, account_id: int) -> Dict[str, Any]: """(2026-09-01 002646 事故)按 remark 指纹从 QMT 当日订单行重建本实例订单集。 engine 订单注册表是**进程内存态**——轮换/崩溃重启后 ``engine.get_orders()`` 为空,EOD 报「本实例=0」→ 无守恒缺口 → 记日,当日孤儿成交永久失去自动 补插(当日 15:05 快照后视图才补全的迟到成交正是这形态)。QMT 当日订单行 (``broker.get_orders`` → query_stock_orders)带 remark=``bt:{label}:{hash}``, label=runner_live 注入的 ``live_{account_id}``,跨重启可查——以它为兜底 归因源,trades 行自身不带 remark,靠 order_id join 本表。 仅对注入后提交的订单生效(注入前的历史单 label 六实例共享不可分); ``is_buy`` 缺失的行弃用——方向不明宁可漏归因,不可错归因(错方向入账 会反向污染台账)。状态映射复用 broker 自己的 ``_map_order_status``, 缺失时保 raw(EOD 守恒只认终态字符串,raw 码只致少计不误计)。 """ broker = getattr(engine, "broker", None) getter = getattr(broker, "get_orders", None) if not callable(getter): return {} try: rows = getter() or [] except Exception as e: # noqa: BLE001 - 兜底源失败不阻断主 EOD 流程 logger.warning("[live-reconcile] remark 重建查 QMT 订单失败: %s", e) return {} prefix = f"bt:live_{account_id}:" mapper = getattr(broker, "_map_order_status", None) out: Dict[str, Any] = {} for row in rows: if not isinstance(row, dict): continue if not str(row.get("order_remark") or "").startswith(prefix): continue oid = str(row.get("order_id") or "") is_buy = row.get("is_buy") security = row.get("security") if not oid or is_buy is None or not security: logger.warning( "[live-reconcile] remark 重建弃用行(方向/代码缺失) order=%s", oid) continue status = row.get("status") if callable(mapper): try: mapped = mapper(status) status = getattr(mapped, "value", mapped) except Exception: # noqa: BLE001 - 映射失败保 raw pass out[oid] = SimpleNamespace( order_id=oid, _broker_order_id=oid, is_buy=bool(is_buy), amount=int(row.get("amount") or 0), filled=int(row.get("filled") or 0), status=status, security=str(security), add_time=None, ) return out # ------------------ 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/账本已见)一致 ② 时间(分钟)+代码+方向+价+量 五元组一致——**计数感知**:同五元组的 第 N 笔 QMT 成交消耗一条台账记录,超出部分视为缺失补插(08-27 001234 实锤:同分钟同价同量的孪生成交仅成交编号不同,集合式判重把第二笔吞成 "已覆盖"→账本短 100 股幻影)。别家实例/手动单只统计。 守恒校验:本实例当日终态订单 filled 合计 vs 台账当日成交量合计, 不平即告警(连 QMT 流水都缺行的极端形态,补插无从下手时的最后防线)。 """ from collections import Counter 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 # 重启兜底:engine 订单表是进程态,缺的按 remark 指纹从 QMT 当日订单补 # (engine 自己的单信息更全,优先;setdefault 只填洞) for oid, shim in _own_orders_remarked(engine, account_id).items(): own_by_broker.setdefault(oid, shim) 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() tuple_counts: Counter = Counter() ledger_vol: Dict[tuple, int] = {} 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 "")) tuple_counts[( 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)), )] += 1 vkey = (str(r.get("symbol") or "")[:6], str(r.get("direction") or "") == "buy") ledger_vol[vkey] = ledger_vol.get(vkey, 0) + 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: continue if tuple_counts.get(tkey, 0) > 0: tuple_counts[tkey] -= 1 # 消费一条同名记录;孪生第二笔不再被吞 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}") tuple_counts[tkey] += 1 vkey = (str(r.get("security") or "")[:6], is_buy) ledger_vol[vkey] = ledger_vol.get(vkey, 0) + int(r.get("amount") or 0) # 守恒校验:订单成交合计 vs 台账记录合计(前缀 6 位码归一,跨日订单不计) # ⚠️ 订单时间字段=bullet_trade Order.add_time(08-31 修:原误读不存在的 # ``datetime`` 属性恒 None → 跨日过滤从不生效,引擎跨交易日存活时历史 # 订单全进对比 → 每日假缺口告警;测试替身当时被喂了 datetime= 字段, # 把 bug 行为锁进了断言——替身必须贴真实字段名) order_vol: Dict[tuple, int] = {} for o in own_by_broker.values(): if _status_str(getattr(o, "status", None)) not in _TERMINAL: continue o_dt = getattr(o, "add_time", None) if o_dt is not None and not str(o_dt).startswith(today): continue filled = int(getattr(o, "filled", 0) or 0) if filled <= 0: continue okey = (str(getattr(o, "security", "") or "")[:6], bool(getattr(o, "is_buy", True))) order_vol[okey] = order_vol.get(okey, 0) + filled conservation_gaps = [] for (sym, is_buy), ov in sorted(order_vol.items()): lv = ledger_vol.get((sym, is_buy), 0) if ov != lv: conservation_gaps.append({ "symbol": sym, "side": "buy" if is_buy else "sell", "order_volume": ov, "ledger_volume": lv, "gap": ov - lv, }) # 当日废单告警:08-28 600818 实锤——盘中废单只有 ERROR 日志躺在文件里, # 复盘才发现当日目标缺仓 ~8 万;EOD 汇总让它当天可见。部分成交残量不进 # 此清单(守恒/补插路径已覆盖)。两肢: # ① 柜台报 rejected/failed/error(交易所级拒单,QMT 订单视图可见); # ② EOD 仍非终态且零成交的当日自提交订单 = 疑似废单——柜台级拒单 # 不进 QMT 当日订单 API(09-01 前实测 08-31 T+1 探针:query_stock_orders # 15 笔全 SUCCEEDED,被拒的卖单根本不在列表),状态永停非终态,等 # 柜台报状态等不到,只能提交侧自证:下过的单收盘零成交又没到终态, # 就是废单/挂死(当日目标缺仓同形态;正常撤单已到终态不计)。 rejected_orders = [] for o in own_by_broker.values(): st = _status_str(getattr(o, "status", None)) o_dt = getattr(o, "add_time", None) if o_dt is not None and not str(o_dt).startswith(today): continue if int(getattr(o, "filled", 0) or 0) > 0: continue if st in ("rejected", "failed", "error"): why = "废单(柜台报rejected)" elif st not in _TERMINAL: why = "疑似废单(EOD非终态零成交)" else: continue rejected_orders.append({ "symbol": str(getattr(o, "security", "") or "")[:6], "side": "buy" if bool(getattr(o, "is_buy", True)) else "sell", "amount": int(getattr(o, "amount", 0) or 0), "why": why}) summary = {"qmt_trades": len(trades_all), "ours": ours, "backfilled": backfilled, "foreign": len(trades_all) - ours, "conservation_gaps": conservation_gaps, "rejected_orders": rejected_orders} global _LAST_GAP_LOG_SIG, _LAST_REJ_LOG_SIG if backfilled or conservation_gaps: # 取证:缺口标的的 QMT 原始行直接打进告警(08-28 教训——15:05 快照 # 内容事后不可回溯,当时没 dump 就只能靠猜哪条通道吞了行) detail = "" if conservation_gaps: gap_syms = {str(g["symbol"]) for g in conservation_gaps} rows_txt = [] for r in trades_all: if str(r.get("security") or "")[:6] in gap_syms: rows_txt.append("%s x%s@%s#%s" % ( _row_time(r).strftime("%H:%M:%S"), r.get("amount"), r.get("price"), str(r.get("trade_id") or "")[-6:])) if rows_txt: detail = " QMT行[%s]" % ";".join(rows_txt[:12]) sig = "%s|%d|%s|%d" % (today, backfilled, conservation_gaps, len(trades_all)) log_fn = logger.warning if sig != _LAST_GAP_LOG_SIG else logger.info log_fn( "[live-reconcile] EOD对账补插 %d 笔 (account=%s QMT全量%d 本实例%d " "别家%d) 守恒缺口=%s%s——存在引擎未报形态,查当日名单/日志", backfilled, account_id, summary["qmt_trades"], ours, summary["foreign"], conservation_gaps, detail) _LAST_GAP_LOG_SIG = sig else: logger.info("[live-reconcile] EOD对账干净 (account=%s QMT全量%d 本实例%d)", account_id, summary["qmt_trades"], ours) if rejected_orders: rsig = "%s|%s" % (today, rejected_orders) if rsig != _LAST_REJ_LOG_SIG: logger.warning( "[live-reconcile] 当日废单/疑似废单 %d 笔 (account=%s): %s——当日" "目标缺仓,查柜台废单原因(600818/柜台拒单不进API 两形态)", len(rejected_orders), account_id, rejected_orders) _LAST_REJ_LOG_SIG = rsig 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 后当日首跑;**守恒缺口非空不记日**,下轮重试。 重试依据(08-28 003011 实锤):15:05 快照缺第 10 笔成交 → 自动补插 没生效,但用户 22 点导出已齐全 = QMT「当日成交」视图会滞后补全 (零点才翻篇)——缺口敞开时每轮重查,期间补全即补插自愈,当晚闭环。 23:00(EOD_RETRY_UNTIL)后仍不平 → 记日放弃,缺口留给恒等式/人工。 查询抛错同样不记日(原有语义),下轮再试。 """ global _LAST_EOD_DATE now = now or datetime.now() today = now.strftime("%Y-%m-%d") if _LAST_EOD_DATE == today: return None hm = (now.hour, now.minute) if hm < EOD_AFTER: return None if hm >= EOD_RETRY_UNTIL: _LAST_EOD_DATE = today return None summary = eod_reconcile(engine, ledger, db, account_id, strategy_name) if not summary.get("conservation_gaps"): _LAST_EOD_DATE = today return summary