96b1924fd5
[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 起服务联调
422 lines
15 KiB
Python
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] 已退出")
|