502 lines
19 KiB
Python
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] 已退出")
|