Files
sanguo_vnpy_v2/sanguo_trader/shadow/supervisor.py
T

148 lines
5.9 KiB
Python

"""影子柜台账户主管(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)