"""实盘交易入口: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] 已退出")