Files
sanguo_vnpy_v2/sanguo_live/runner.py
T

502 lines
19 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 subprocess
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 _portfolio_env_for(account_row: dict[str, Any], db_path: str) -> dict[str, str]:
"""live_accounts 行(组合实盘) → runner_live 子进程 env。
独立成函数便于单测(env 映射是组合实盘的唯一契约)。
"""
return {
"SANGUO_QMT_ACCOUNT": account_row.get("account", ""),
"SANGUO_QMT_PATH": account_row.get("mini_path", ""),
"SANGUO_LIVE_STRATEGY": account_row.get("strategy_class", "all_weather"),
"SANGUO_LIVE_MAX_POOL": str(account_row.get("max_pool", 30) or 30),
"SANGUO_LIVE_BENCHMARK": account_row.get("benchmark", "000300.XSHG"),
"SANGUO_LIVE_CASH": str(account_row.get("initial_capital", 1_000_000)),
"SANGUO_LIVE_DB": db_path,
"SANGUO_LIVE_ACCOUNT_ID": str(account_row.get("id", "")),
}
def _start_portfolio_subprocess(
account_row: dict[str, Any], db_path: str
) -> subprocess.Popen:
"""组合实盘 = 独立子进程跑 bullet_trade LiveEngine(asyncio,与 supervisor 隔离)。"""
import json as _json
env = {**os.environ, **_portfolio_env_for(account_row, db_path)}
# setting JSON 里的额外参数(max_pool/benchmark 覆盖)并入 env
try:
setting = _json.loads(account_row.get("setting") or "{}")
if setting.get("max_pool") is not None:
env["SANGUO_LIVE_MAX_POOL"] = str(setting["max_pool"])
if setting.get("benchmark"):
env["SANGUO_LIVE_BENCHMARK"] = str(setting["benchmark"])
except (ValueError, TypeError):
pass
logger.info("[supervisor] 拉起组合实盘子进程 (account=%s strategy=%s)",
account_row.get("id"), env.get("SANGUO_LIVE_STRATEGY"))
return subprocess.Popen(
[sys.executable, "-m", "sanguo_portfolio.runner_live"],
env=env,
)
def _stop_portfolio_subprocess(proc: subprocess.Popen) -> None:
"""terminate → 等待 → kill 兜底。"""
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
logger.warning("[supervisor] 组合实盘子进程未在 10s 内退出,kill")
proc.kill()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
pass
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)。
组合实盘(strategy_type='portfolio')走子进程 ``sanguo_portfolio.runner_live``
(bullet_trade LiveEngine 是 asyncio 事件循环,与 supervisor 轮询线程模型隔离)。
"""
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] = {}
portfolio_procs: dict[int, subprocess.Popen] = {}
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(CTA=进程内 engine;组合=子进程)
for aid in running_ids - engines.keys() - portfolio_procs.keys():
acc = get_account(db, aid)
if not acc:
continue
if (acc.get("strategy_type") or "cta") == "portfolio":
try:
portfolio_procs[aid] = _start_portfolio_subprocess(acc, db)
except Exception as e: # noqa: BLE001
logger.error("[supervisor] 起组合实盘失败 (account=%s): %s", aid, e)
update_account_status(db, aid, "stopped", str(e))
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))
for aid in list(portfolio_procs.keys() - running_ids):
logger.info("[supervisor] 停止组合实盘子进程 (account=%s)", aid)
_stop_portfolio_subprocess(portfolio_procs.pop(aid))
# 组合子进程崩溃检测:退出即标 stopped(rc 写进 error_msg)
for aid, proc in list(portfolio_procs.items()):
rc = proc.poll()
if rc is not None:
logger.error("[supervisor] 组合实盘子进程退出 (account=%s rc=%s)", aid, rc)
portfolio_procs.pop(aid)
update_account_status(db, aid, "stopped", f"runner_live 退出 rc={rc}")
# 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()
for aid, proc in portfolio_procs.items():
logger.info("[supervisor] 退出清理组合实盘 (account=%s)", aid)
_stop_portfolio_subprocess(proc)
portfolio_procs.clear()
logger.info("[supervisor] 已退出")