f214e2f0e4
根因(策略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=更糟。
171 lines
7.3 KiB
Python
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)
|