Files
sanguo_vnpy_v2/sanguo_live/runner.py
T
claude_dev 96b1924fd5 feat: 实盘模拟(live) + 组合回测MVP(portfolio)
[live] 实盘模拟 vnpy+miniQMT 直连(supervisor 轮询, 前后端):
- sanguo_live: LiveTradingEngine + AShareCtaTemplate(定寸/禁做空) + runner_supervisor(DB驱动) + persistence(4表WAL)
- sanguo_api/routes_live: 9路由(create/start/stop/positions/trades/account/status)
- frontend live: New/List/Monitor + api/live.ts; config/live.yaml

[portfolio] 组合回测 MVP(BulletTrade, 链路代码完成待验证):
- runner_backtest 加 JSON 入口(--json, BacktestEngine 顶层 import)
- sanguo_api/routes_portfolio: POST /portfolio/backtest SSH 触发 VPS 跑
- frontend PortfolioBacktest.vue + api/portfolio.ts: 表单+结果+净值曲线
- 路由/菜单注册(/backtest/portfolio 组合回测)
- 已知: MVP 链路未端到端验证, agent 改至中途被停; 待 Mac 起服务联调
2026-07-18 20:04:16 +08:00

422 lines
15 KiB
Python

"""实盘交易入口:connect → add_strategy → subscribe → init_all → start_all → 常驻。
配置来源(优先级递增):
1) ``sanguo_live/runner.py`` 内 DEFAULT_CONFIG
2) ``config/live.yaml``(可选)
3) 环境变量 ``SANGUO_QMT_ACCOUNT`` / ``SANGUO_QMT_PATH``
用法:
python -m sanguo_live # 用 config/live.yaml
python -m sanguo_live /path/to/cfg.yaml # 指定配置
python -m sanguo_live --supervisor [db] # DB 驱动 supervisor
SANGUO_QMT_ACCOUNT=66639661 python -m sanguo_live
注意:真实下单需要 miniQMT 同机运行且在交易时段。非交易时段运行只校验链路搭建。
"""
from __future__ import annotations
import logging
import os
import signal
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Any
import yaml
from sanguo_live.engine import LiveTradingEngine
from sanguo_live.strategies import AShareDoubleMaStrategy
logger = logging.getLogger(__name__)
DEFAULT_CONFIG_PATH = (
Path(__file__).resolve().parent.parent / "config" / "live.yaml"
)
DEFAULT_CONFIG: dict[str, Any] = {
"account": "",
"mini_path": "",
"strategy_name": "dm_15min_default",
"vt_symbol": "600000.SSE",
"strategy_class": "AShareDoubleMaStrategy",
# 连接后 sleep(秒):等 QmtGateway 完成 contract 拉取
"connect_wait_sec": 10,
# init_all 后 sleep(秒):等策略 load_bar 加载历史
"init_wait_sec": 60,
"setting": {
"fast_window": 10,
"slow_window": 20,
"window": 15, # BarGenerator 分钟窗口(A 股 15min)
"size": 100, # 1 手 = 100 股
"forbid_short": True,
},
}
# 策略类注册表(可扩展)
_STRATEGY_REGISTRY: dict[str, type] = {
"AShareDoubleMaStrategy": AShareDoubleMaStrategy,
}
def load_config(path: str | Path | None = None) -> dict[str, Any]:
"""加载配置:yaml 文件 + 环境变量覆盖(env 优先)。"""
cfg: dict[str, Any] = {k: (dict(v) if isinstance(v, dict) else v)
for k, v in DEFAULT_CONFIG.items()}
p = Path(path) if path else DEFAULT_CONFIG_PATH
if p.exists():
try:
with open(p, encoding="utf-8") as f:
file_cfg = yaml.safe_load(f) or {}
except OSError as e:
logger.warning("读取配置失败 %s: %s(使用默认)", p, e)
file_cfg = {}
for k, v in file_cfg.items():
if k == "setting" and isinstance(v, dict):
cfg["setting"].update(v)
else:
cfg[k] = v
# env 优先
if os.environ.get("SANGUO_QMT_ACCOUNT"):
cfg["account"] = os.environ["SANGUO_QMT_ACCOUNT"]
if os.environ.get("SANGUO_QMT_PATH"):
cfg["mini_path"] = os.environ["SANGUO_QMT_PATH"]
return cfg
def build_strategy_class(name: str) -> type:
"""策略类名 → 类。未知类抛 ValueError。"""
if name not in _STRATEGY_REGISTRY:
raise ValueError(
f"未知策略类: {name}; 可用: {list(_STRATEGY_REGISTRY)}"
)
return _STRATEGY_REGISTRY[name]
def _install_signal_handlers(engine: LiveTradingEngine) -> None:
"""SIGINT / SIGTERM → stop_all + close + exit。"""
def _shutdown(signum: int, frame: Any) -> None:
logger.info("收到信号 %s,停止所有策略并退出", signum)
try:
engine.stop_all()
engine.close()
finally:
sys.exit(0)
# SIGINT (Ctrl+C) 全平台;SIGTERM 仅 POSIX(Windows 上 Python 有定义但语义弱)
signal.signal(signal.SIGINT, _shutdown)
if hasattr(signal, "SIGTERM"):
try:
signal.signal(signal.SIGTERM, _shutdown)
except (ValueError, OSError):
pass # 非主线程或 Windows 子进程 — 忽略
def run(config_path: str | Path | None = None) -> None:
"""主流程:connect → add → subscribe → init → start → 常驻循环。"""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
cfg = load_config(config_path)
if not cfg["account"]:
logger.error(
"未配置 QMT 交易账号。请设 SANGUO_QMT_ACCOUNT 或在 %s 写 account",
DEFAULT_CONFIG_PATH,
)
sys.exit(2)
engine = LiveTradingEngine()
# 优雅退出
_install_signal_handlers(engine)
# 1) 连接 miniQMT
engine.connect({"交易账号": cfg["account"], "mini路径": cfg["mini_path"]})
logger.info("等待 QMT 连接就绪 %ds...", cfg["connect_wait_sec"])
time.sleep(int(cfg["connect_wait_sec"]))
# 2) 注册策略
strategy_cls = build_strategy_class(cfg["strategy_class"])
engine.add_strategy(
strategy_cls,
cfg["strategy_name"],
cfg["vt_symbol"],
cfg["setting"],
)
# 3) 订阅行情(tick → BarGenerator → 15min bar → on_bar)
engine.subscribe([cfg["vt_symbol"]])
# 4) 初始化策略(load_bar 拉 10 天历史)
engine.init_all()
logger.info("等待策略 init 完成 %ds...", cfg["init_wait_sec"])
time.sleep(int(cfg["init_wait_sec"]))
# 5) 启动策略,进入实盘
engine.start_all()
logger.info("=== 实盘已启动 (策略=%s 标的=%s)。Ctrl+C 退出 ===",
cfg["strategy_name"], cfg["vt_symbol"])
# 6) 常驻:主线程保活,行情/下单都在 EventEngine 工作线程
while True:
time.sleep(10)
__all__ = ["run", "load_config", "build_strategy_class",
"DEFAULT_CONFIG", "DEFAULT_CONFIG_PATH", "run_supervisor",
"default_supervisor_db_path"]
# =============================================================================
# supervisor 模式:DB 驱动,独立常驻进程轮询 live_accounts.status
# (task #4 持久化扩展)。API 只改 status 字段,supervisor 据此起停 engine。
# =============================================================================
_SUPERVISOR_CONFIG_PATH = (
Path(__file__).resolve().parent.parent / "config" / "data_platform.yaml"
)
def default_supervisor_db_path() -> str:
"""从 config/data_platform.yaml 的 live_trading.db_path 读;fallback 主库。"""
try:
if _SUPERVISOR_CONFIG_PATH.exists():
with open(_SUPERVISOR_CONFIG_PATH, encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}
lt = (cfg.get("live_trading") or {})
if lt.get("db_path"):
return str(lt["db_path"])
dp = cfg.get("data_paths") or {}
if dp.get("vnpy_db"):
return str(dp["vnpy_db"])
except OSError as e:
logger.warning("读 supervisor 配置失败: %s", e)
return "quant_trading.db"
def _account_to_cfg(account_row: dict[str, Any]) -> dict[str, Any]:
"""live_accounts 行 → runner 内部 cfg 结构(setting JSON 解析)。"""
import json as _json
try:
setting = _json.loads(account_row.get("setting") or "{}")
except (ValueError, TypeError):
setting = {}
return {
"account": account_row.get("account", ""),
"mini_path": account_row.get("mini_path", ""),
"strategy_name": account_row.get("strategy_name", ""),
"vt_symbol": account_row.get("vt_symbol", ""),
"strategy_class": account_row.get("strategy_class",
"AShareDoubleMaStrategy"),
"connect_wait_sec": int(account_row.get("connect_wait_sec", 10)),
"init_wait_sec": int(account_row.get("init_wait_sec", 60)),
"setting": setting,
}
def _start_engine_for_account(account_row: dict[str, Any]) -> LiveTradingEngine:
"""根据 live_accounts 行起 LiveTradingEngine(connect→add→subscribe→init→start)。"""
from sanguo_live.engine import LiveTradingEngine
cfg = _account_to_cfg(account_row)
engine = LiveTradingEngine()
engine.connect({"交易账号": cfg["account"], "mini路径": cfg["mini_path"]})
logger.info("[supervisor] 等待 QMT 连接就绪 %ds (account=%s)...",
cfg["connect_wait_sec"], cfg["account"])
time.sleep(cfg["connect_wait_sec"])
strategy_cls = build_strategy_class(cfg["strategy_class"])
engine.add_strategy(strategy_cls, cfg["strategy_name"],
cfg["vt_symbol"], cfg["setting"])
engine.subscribe([cfg["vt_symbol"]])
engine.init_all()
logger.info("[supervisor] 等待策略 init %ds...", cfg["init_wait_sec"])
time.sleep(cfg["init_wait_sec"])
engine.start_all()
logger.info("[supervisor] engine 已启动 (account=%s strategy=%s)",
cfg["account"], cfg["strategy_name"])
return engine
def _register_trade_handler(
engine: LiveTradingEngine, account_id: int, db_path: str
) -> Any:
"""注册 EVENT_TRADE 回调:成交落 live_trades。返回 handler(供 unregister)。
EVENT_TRADE 定义在 ``vnpy.trader/event.py``(常量 "eTrade."),
``vnpy.event`` 只导出 Event/EventEngine/EVENT_TIMER — 从 vnpy.event
import EVENT_TRADE 会 ImportError,导致成交回调静默不注册。
"""
from sanguo_live.persistence import save_trade
try:
from vnpy.trader.event import EVENT_TRADE # type: ignore
except ImportError:
logger.warning(
"[supervisor] 无 vnpy.trader.event,EVENT_TRADE 回调未注册"
)
return None
def _on_trade(event: Any) -> None:
try:
t = event.data
save_trade(db_path, account_id, {
"strategy_name": "",
"symbol": getattr(t, "vt_symbol", "") or "",
"direction": _enum_tail(getattr(t, "direction", "")),
"offset": _enum_tail(getattr(t, "offset", "")),
"price": float(getattr(t, "price", 0)),
"volume": float(getattr(t, "volume", 0)),
"traded_at": (t.datetime.isoformat()
if getattr(t, "datetime", None) else ""),
"vt_tradeid": getattr(t, "vt_tradeid", ""),
})
except Exception as e: # noqa: BLE001
logger.warning("[supervisor] save_trade 失败: %s", e)
engine.event_engine.register(EVENT_TRADE, _on_trade)
return _on_trade
def _enum_tail(val: Any) -> str:
"""Direction.LONG → 'long';Offset.OPEN → 'open';非 enum → str(val).lower()。"""
name = getattr(val, "name", None)
if name:
return str(name).lower()
return str(val).lower() if val else ""
def _snapshot_to_db(
engine: LiveTradingEngine, account_id: int, db_path: str
) -> None:
"""定时把 OMS 持仓 + 账户快照落库(供 API 读)。"""
from sanguo_live.persistence import save_positions, save_balance
try:
positions: dict[str, dict] = {}
for p in engine.get_positions():
sym = getattr(p, "vt_symbol", "") or getattr(p, "symbol", "")
if not sym:
continue
# A 股只关心多头持仓(PositionDirection.LONG / NET)
direction = getattr(p, "direction", None)
dname = getattr(direction, "name", "")
if dname == "SHORT":
continue
vol = float(getattr(p, "volume", 0) or 0)
if vol <= 0:
continue
positions[sym] = {
"volume": vol,
"frozen": float(getattr(p, "frozen", 0) or 0),
"avg_price": float(getattr(p, "price", 0) or 0),
}
save_positions(db_path, account_id, positions)
date_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
for acc in engine.get_all_accounts():
total = float(getattr(acc, "balance", 0) or 0)
cash = float(getattr(acc, "available", 0) or 0)
save_balance(db_path, account_id, date_str, cash,
market_value=max(total - cash, 0.0), total=total)
except Exception as e: # noqa: BLE001
logger.warning("[supervisor] snapshot 落库失败 (account=%s): %s",
account_id, e)
def _stop_engine(engine: LiveTradingEngine) -> None:
"""stop_all + close(容错)。"""
try:
engine.stop_all()
finally:
try:
engine.close()
except Exception as e: # noqa: BLE001
logger.warning("[supervisor] engine.close 异常: %s", e)
def run_supervisor(
db_path: str | None = None,
poll_interval_sec: float = 5.0,
snapshot_interval_sec: float = 30.0,
) -> None:
"""DB 驱动的 supervisor 常驻进程。
轮询 ``live_accounts.status``:
- 新 running → 起 LiveTradingEngine + 注册 EVENT_TRADE 回调
- 变 stopped → 停 engine + close
定时(snapshot_interval_sec)把 OMS 持仓/账户落 DB 供 API 读。
信号(SIGINT/SIGTERM)→ 停所有 engine 后退出。
MVP:每实例一个 engine,表结构支持多行(多实例同时跑只是内存多 engine)。
"""
from sanguo_live.persistence import (
init_db, list_running_accounts, get_account,
update_account_status,
)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
db = db_path or default_supervisor_db_path()
init_db(db)
logger.info("[supervisor] 启动 (db=%s poll=%.1fs snapshot=%.1fs)",
db, poll_interval_sec, snapshot_interval_sec)
engines: dict[int, LiveTradingEngine] = {}
stop_flag = {"stop": False}
def _shutdown(signum: int, frame: Any) -> None:
logger.info("[supervisor] 收到信号 %s,停止所有 engine", signum)
stop_flag["stop"] = True
signal.signal(signal.SIGINT, _shutdown)
if hasattr(signal, "SIGTERM"):
try:
signal.signal(signal.SIGTERM, _shutdown)
except (ValueError, OSError):
pass
last_snapshot: float = 0.0
while not stop_flag["stop"]:
now = time.time()
# 1) 同步 status
running_ids = {r["id"] for r in list_running_accounts(db)}
# 启动新 running
for aid in running_ids - engines.keys():
acc = get_account(db, aid)
if not acc:
continue
try:
eng = _start_engine_for_account(acc)
_register_trade_handler(eng, aid, db)
engines[aid] = eng
except Exception as e: # noqa: BLE001
logger.error("[supervisor] 起 engine 失败 (account=%s): %s",
aid, e)
update_account_status(db, aid, "stopped", str(e))
# 停止变 stopped 的
for aid in list(engines.keys() - running_ids):
logger.info("[supervisor] 停止 engine (account=%s)", aid)
_stop_engine(engines.pop(aid))
# 2) 定时 snapshot
if now - last_snapshot >= snapshot_interval_sec:
for aid, eng in engines.items():
_snapshot_to_db(eng, aid, db)
last_snapshot = now
time.sleep(poll_interval_sec)
# 退出清理
for aid, eng in engines.items():
logger.info("[supervisor] 退出清理 (account=%s)", aid)
_stop_engine(eng)
engines.clear()
logger.info("[supervisor] 已退出")