"""B1 全局账户监视器(spec §multi-strategy-instance-budget §B1)。 supervisor 内 daemon 线程:独立 probe 连接(专属 int session id)每 60s 查一次 QMT 账户资金+持仓,upsert 单行全局快照 ``qmt_account_snapshot``(不挂实例)。 设计要点: - 与实盘实例解耦:零实盘实例时照常运行(删光重建期预算校验数据不断供)。 - 账户来源三并集(后者覆盖前者路径): 1. 已有快照行(sticky:实例删光后监视器仍记得账号+mini_path) 2. live_accounts 行(account + mini_path) 3. config ``live_trading.watch_accounts`` / env ``SANGUO_QMT_ACCOUNT`` - xtquant 导入失败或 QMT 客户端不在(NAS/Mac/夜间)→ 告警后空转,不炸 supervisor。 - 失败日志节流:同一 mini_path 首败 WARN,此后每 30 败心跳一次。 """ from __future__ import annotations import logging import os import threading from typing import Any logger = logging.getLogger(__name__) # 专属 probe 会话 id:int,量级刻意远离 bullet_trade 默认的 int(time*1000)(~1.7e12), # 不与引擎连接撞 session。 PROBE_SESSION_ID = 880811 _DEFAULT_INTERVAL_SEC = 60.0 _HEARTBEAT_EVERY_N_FAILURES = 30 def _import_qmt() -> tuple[type, type]: """懒加载 xtquant(测试用 monkeypatch 本函数注入假实现)。""" from xtquant.xttrader import XtQuantTrader # type: ignore from xtquant.xttype import StockAccount # type: ignore return XtQuantTrader, StockAccount def _extra_from_config() -> dict[str, str]: """config/data_platform.yaml live_trading.watch_accounts + env 兜底。 返回 {account: mini_path};读不到/没配 → 空 dict(不炸)。 """ import yaml extra: dict[str, str] = {} cfg_path = os.path.join(os.path.dirname(os.path.dirname( os.path.abspath(__file__))), "config", "data_platform.yaml") try: with open(cfg_path, encoding="utf-8") as f: lt = (yaml.safe_load(f) or {}).get("live_trading") or {} path = str(lt.get("watch_mini_path") or "") for acc in lt.get("watch_accounts") or []: if str(acc).strip(): extra[str(acc).strip()] = path except (OSError, ValueError) as e: logger.debug("[account-monitor] 读 watch 配置失败(忽略): %s", e) if os.environ.get("SANGUO_QMT_ACCOUNT"): extra.setdefault(os.environ["SANGUO_QMT_ACCOUNT"].strip(), os.environ.get("SANGUO_QMT_PATH", "")) return extra class AccountMonitor(threading.Thread): """全局账户快照监视线程。用法:monitor = AccountMonitor(db); monitor.start()。""" def __init__( self, db_path: str, interval_sec: float = _DEFAULT_INTERVAL_SEC, session_id: int = PROBE_SESSION_ID, extra_accounts: dict[str, str] | None = None, ) -> None: super().__init__(daemon=True, name="account-monitor") self.db_path = db_path self.interval_sec = interval_sec self.session_id = session_id # None=首 poll 时读 config;{}=禁用(测试用) self.extra_accounts = extra_accounts self._stop_event = threading.Event() self._traders: dict[str, Any] = {} # mini_path → XtQuantTrader self._fail_counts: dict[str, int] = {} # mini_path → 连续失败计数 # ---------------- 生命周期 ---------------- def stop(self) -> None: self._stop_event.set() def run(self) -> None: try: _import_qmt() except Exception as e: # noqa: BLE001 logger.warning( "[account-monitor] xtquant 不可用(%s),账户快照不采集", e) return logger.info("[account-monitor] 启动 (interval=%.0fs db=%s)", self.interval_sec, self.db_path) while True: try: self.poll_once() except Exception: # noqa: BLE001 logger.warning("[account-monitor] poll 异常", exc_info=True) if self._stop_event.wait(self.interval_sec): break self._close_all() def _close_all(self) -> None: for path, trader in list(self._traders.items()): try: trader.stop() except Exception: # noqa: BLE001 pass self._traders.clear() # ---------------- 采集 ---------------- def poll_once(self) -> int: """扫全部 watch 目标,写快照。返回成功写入的行数(测试用)。""" from sanguo_live.persistence import ( list_accounts, list_snapshot_accounts, upsert_account_snapshot, ) if self.extra_accounts is None: self.extra_accounts = _extra_from_config() # 三并集:sticky 快照行 < live_accounts < config/env(后者路径覆盖) targets: dict[str, str] = {} for row in list_snapshot_accounts(self.db_path): acc = (row.get("account") or "").strip() if acc: targets[acc] = (row.get("mini_path") or "").strip() for row in list_accounts(self.db_path): acc = (row.get("account") or "").strip() if acc: targets[acc] = (row.get("mini_path") or "").strip() targets.update(self.extra_accounts) written = 0 for account, mini_path in sorted(targets.items()): try: if self._poll_account(account, mini_path, upsert_account_snapshot): written += 1 except Exception: # noqa: BLE001 logger.warning("[account-monitor] 账户 %s 采集异常", account, exc_info=True) return written def _poll_account(self, account: str, mini_path: str, upsert: Any) -> bool: """单账户采集。返回是否写入。mini_path 为空 → 告警跳过(连不上 QMT)。""" if not mini_path: logger.warning( "[account-monitor] 账户 %s 无 mini_path,跳过(需 live_accounts " "行携带或快照 sticky 记录)", account) return False trader = self._ensure_trader(mini_path) if trader is None: return False XtQuantTrader, StockAccount = _import_qmt() acc_obj = StockAccount(account) asset = trader.query_stock_asset(acc_obj) if asset is None: self._note_failure(mini_path, "query_stock_asset None") self._reset_trader(mini_path) # 可能断连,下轮重建 return False positions = trader.query_stock_positions(acc_obj) or [] rows = [] for p in positions: vol = float(getattr(p, "volume", 0) or 0) if vol <= 0: continue rows.append({ "symbol": str(getattr(p, "stock_code", "") or ""), "volume": vol, "can_use": float(getattr(p, "can_use_volume", 0) or 0), "avg_price": float(getattr(p, "avg_price", 0) or 0), "mv": float(getattr(p, "market_value", 0) or 0), }) upsert( self.db_path, account, cash=float(getattr(asset, "cash", 0) or 0), market_value=float(getattr(asset, "market_value", 0) or 0), total=float(getattr(asset, "total_asset", 0) or 0), positions=rows, mini_path=mini_path, ) self._fail_counts.pop(mini_path, None) logger.info( "[account-monitor] 快照 %s: cash=%.0f mv=%.0f total=%.0f 持仓%d只", account, float(getattr(asset, "cash", 0) or 0), float(getattr(asset, "market_value", 0) or 0), float(getattr(asset, "total_asset", 0) or 0), len(rows)) return True # ---------------- 连接管理 ---------------- def _ensure_trader(self, mini_path: str) -> Any: """按 mini_path 复用/新建 probe 连接;失败返回 None(带节流告警)。""" trader = self._traders.get(mini_path) if trader is not None: return trader XtQuantTrader, _StockAccount = _import_qmt() try: t = XtQuantTrader(mini_path, self.session_id) t.start() if t.connect() not in (0, None): raise RuntimeError(f"connect 返回 {t.connect()}") self._traders[mini_path] = t return t except Exception as e: # noqa: BLE001 self._note_failure(mini_path, repr(e)) try: t.stop() except Exception: # noqa: BLE001 pass return None def _reset_trader(self, mini_path: str) -> None: trader = self._traders.pop(mini_path, None) if trader is not None: try: trader.stop() except Exception: # noqa: BLE001 pass def _note_failure(self, mini_path: str, reason: str) -> None: n = self._fail_counts.get(mini_path, 0) + 1 self._fail_counts[mini_path] = n if n == 1 or n % _HEARTBEAT_EVERY_N_FAILURES == 0: logger.warning( "[account-monitor] 连接失败 %s (第%d次): %s", mini_path, n, reason) __all__ = ["AccountMonitor", "PROBE_SESSION_ID", "_import_qmt", "_extra_from_config"]