Files
sanguo_vnpy_v2/sanguo_trader/shadow/supervisor.py
T
claude_dev f214e2f0e4
CI/CD / test (push) Failing after 12m2s
CI/CD / nas-deploy (push) Has been skipped
CI/CD / nas-verify (push) Has been skipped
fix(live): max_pool 全链默认 30→0(0=不限)——08-24巡检定罪MVP限流泄漏生产,选股池=代码序前30只失真 [vps]
根因(策略session 08-24 午休探针实证):runtime/live_strategy.py SANGUO_LIVE_MAX_POOL
默认30经env注入全部实盘+影子+paper实例,_stock_pool截断成份池为「代码序前30只」:
small_cap「全市场最小市值」实际在000001平安银行等30只固定代码里选(平安银行≈3800亿
出现在小市值买入=market_cap开盘NaN排序失效叠bug);momentum每行业RPS只在代码序前
30里排;value 0/30+零委托史同源。注释自曝「MVP验证用」=限流遗留泄漏生产,上线
首日起全部选股失真。

改动(9处默认位一致30→0;语义0=不限,与策略层max_pool>0才截断一致):
- sanguo_portfolio/live_strategy.py 适配器env默认+docstring
- sanguo_live/runner.py _portfolio_env_for(存量DB显式值不篡改,缺列/0→"0")
- sanguo_trader/shadow/supervisor.py 影子env默认
- sanguo_portfolio/runner_live.py live_env默认
- sanguo_trader/portfolio_paper.py + sanguo_api/routes_paper.py paper默认
- sanguo_api/routes_live.py create setdefault
- frontend live/paper New.vue 表单默认

测试:env mapping三态断言(缺列/0→"0",显式30不篡改)+live_env默认"0"
(RED→GREEN);CI范围642绿。存量实例DB仍存显式30,激活需配套DB迁移,必须与数据
session的get_security_info_batch SQL治本(101s→亚秒)同车部署——池放大×慢SQL=更糟。
2026-08-24 13:34:01 +08:00

171 lines
7.3 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", 0)),
"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"])
# #88 可观测性:子进程 stdout/stderr 落 logs/shadow_{aid}.log(>5MB 轮转截断)。
# 原实现继承 schtask 控制台(=黑洞),子进程秒崩时零线索——2026-08-16
# VPS 实况 41/42 拉不起,连一行报错都没有,只能瞎猜。
from pathlib import Path as _P
log_dir = _P(__file__).resolve().parents[2] / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / f"shadow_{acc['id']}.log"
try:
if log_path.exists() and log_path.stat().st_size > 5 * 1024 * 1024:
log_path.write_text("", encoding="utf-8") # 超限重置,避免崩溃循环刷爆盘
fh = open(log_path, "ab")
fh.write(f"\n==== spawn {time.strftime('%Y-%m-%d %H:%M:%S')} ====\n".encode())
return subprocess.Popen(argv, env=env, stdout=fh, stderr=subprocess.STDOUT)
except Exception:
logger.warning("[shadow-supervisor] 日志重定向失败,退回继承控制台", exc_info=True)
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_identity_report, build_reconcile_report, find_dual_track_pairs,
save_identity_report, 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:
# B5 恒等式先行:全账户 = Σ实例账本 + 未归因;再逐对 live↔shadow 行为对比
identity = build_identity_report(db_path, today)
save_identity_report(db_path, identity)
for row in identity["rows"]:
logger.info("[shadow-supervisor] 恒等式 %s %s: 未归因=%.0f%s",
today, row["account"],
row["unattributed_mv"] or 0, row["status"])
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)