148 lines
5.9 KiB
Python
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)
|