236 lines
9.1 KiB
Python
236 lines
9.1 KiB
Python
"""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"]
|