"""影子柜台账户主管(P1-d)。 轮询 paper 库:发现 ``mode='shadow' & status='running'`` 的组合账户 → 为每个账户 拉起一个 ``python -m sanguo_trader.shadow --account N`` 子进程(独立虚拟账户); 账户停止/删除 → 终止对应子进程;子进程崩溃 → 重启(告警日志)。 用法(VPS,常驻): set SANGUO_SHADOW_DB=C:\\sanguo_vnpy_v2\\data\\backtest_results.db python -m sanguo_trader.shadow --auto 与 sanguo_live.supervisor 同思路:主管只管进程生命周期,不碰撮合。 """ from __future__ import annotations import json import logging import os import sqlite3 import subprocess import sys import time from typing import Any, Dict, Optional logger = logging.getLogger(__name__) POLL_SEC = 60 def load_shadow_accounts(db_path: str) -> list[dict[str, Any]]: """读所有该拉起影子柜台的账户(mode=shadow & running & portfolio)。""" with sqlite3.connect(db_path) as conn: conn.row_factory = sqlite3.Row rows = conn.execute( "SELECT * FROM paper_accounts " "WHERE mode='shadow' AND status='running' AND strategy_type='portfolio'" ).fetchall() return [dict(r) for r in rows] def _num(v: Any) -> str: """数字转 env 字符串,整数不带小数点(500000 而非 500000.0)。""" f = float(v) return str(int(f)) if f.is_integer() else repr(f) def account_env(acc: dict[str, Any], db_path: str) -> Dict[str, str]: """paper 账户行 → 影子柜台子进程 env(复用 SANGUO_LIVE_* 契约)。""" strategies = json.loads(acc.get("strategies") or "[]") name = (strategies[0].get("name") if strategies else None) or "all_weather" params = (strategies[0].get("params") if strategies else {}) or {} env = dict(os.environ) env.update({ "SANGUO_LIVE_STRATEGY": name, "SANGUO_LIVE_MAX_POOL": _num(params.get("max_pool", 30)), "SANGUO_LIVE_BENCHMARK": str(params.get("benchmark", "000300.XSHG")), "SANGUO_LIVE_CASH": _num(acc.get("initial_capital") or 1_000_000), "SANGUO_SHADOW_DB": db_path, "SANGUO_SHADOW_ACCOUNT_ID": str(acc["id"]), "SANGUO_SHADOW_COMMISSION": repr(float(acc.get("rate") or 0.0003)), "SANGUO_SHADOW_STAMP": repr(float(acc.get("stamp_duty_rate") or 0.001)), "SANGUO_SHADOW_MIN_COMM": _num(acc.get("min_commission") or 5), "SANGUO_SHADOW_SLIPPAGE": repr(float(acc.get("slippage") or 0.001)), }) return env def spawn_child(acc: dict[str, Any], db_path: str) -> subprocess.Popen: env = account_env(acc, db_path) argv = [sys.executable, "-X", "utf8", "-m", "sanguo_trader.shadow", "--account", str(acc["id"])] logger.info("[shadow-supervisor] 拉起账户 #%s(%s) 影子柜台", acc["id"], env["SANGUO_LIVE_STRATEGY"]) return subprocess.Popen(argv, env=env) def _maybe_daily_reconcile(db_path: str, done_dates: set, now: Optional["datetime.datetime"] = None) -> None: """日终对账(设计 §8.2):收盘后(≥15:10)每日一次全配对跑报表落库。 API/CLI 也可随时现算;这里只是自动化兜底,done_dates 防当日重复。 """ import datetime as _dt from .reconcile_report import ( build_reconcile_report, find_dual_track_pairs, save_reconcile_report, ) now = now or _dt.datetime.now() if now.hour < 15 or (now.hour == 15 and now.minute < 10): return today = now.strftime("%Y-%m-%d") if today in done_dates: return try: for pair in find_dual_track_pairs(db_path): report = build_reconcile_report( db_path, pair["live_account_id"], pair["shadow_account_id"], today) save_reconcile_report(db_path, report) logger.info("[shadow-supervisor] 日终对账 %s live#%s vs shadow#%s: %s", today, pair["live_account_id"], pair["shadow_account_id"], "PASS" if report["passed"] else "FAIL") done_dates.add(today) except Exception as exc: # noqa: BLE001 - 对账失败不退出主管,下一轮重试 logger.warning("[shadow-supervisor] 日终对账失败(下轮重试): %s", exc) def run_auto_supervisor(db_path: Optional[str] = None, poll_sec: float = POLL_SEC) -> None: """常驻主循环:同步账户 ↔ 子进程。""" db_path = db_path or os.environ.get("SANGUO_SHADOW_DB") \ or r"C:\sanguo_vnpy_v2\data\backtest_results.db" logger.info("[shadow-supervisor] 启动 db=%s", db_path) children: Dict[int, subprocess.Popen] = {} reconcile_done: set = set() while True: try: accounts = load_shadow_accounts(db_path) except Exception as exc: # noqa: BLE001 - db 抖动不退出主管 logger.warning("[shadow-supervisor] 读账户失败: %s", exc) accounts = [] want = {a["id"]: a for a in accounts} # 1) 终止不再需要的 for aid in list(children): if aid not in want: child = children.pop(aid) if child.poll() is None: child.terminate() logger.info("[shadow-supervisor] 账户 #%s 停止,子进程已终止", aid) # 2) 重启崩溃的 / 拉起新的 for aid, acc in want.items(): child = children.get(aid) if child is not None and child.poll() is None: continue if child is not None: logger.warning("[shadow-supervisor] 账户 #%s 子进程退出(rc=%s),重启", aid, child.returncode) try: children[aid] = spawn_child(acc, db_path) except Exception as exc: # noqa: BLE001 logger.error("[shadow-supervisor] 账户 #%s 拉起失败: %s", aid, exc) # 3) 日终双轨对账(收盘后每日一次) _maybe_daily_reconcile(db_path, reconcile_done) time.sleep(poll_sec)