"""组合策略实盘适配文件(bullet_trade LiveEngine 加载的聚宽风格 strategy_file)。 bullet_trade 0.9.x LiveEngine 只认策略文件:本文件 ``initialize(context)`` 里把 sanguo_portfolio 的 StrategyTemplate 策略挂到 run_daily/run_monthly 定时器, 下单走 bullet_trade 顶层 API(live 模式自动路由 LiveEngine → QmtBroker)。 配置从 env 读(supervisor 注入,或手动 set 后直跑 ``python -m sanguo_portfolio.runner_live``): SANGUO_LIVE_STRATEGY all_weather / momentum_timing / value_selection / small_cap SANGUO_LIVE_MAX_POOL 选股池上限(默认 0=不限;>0 截断成份池前 N 只) 数据 provider 由 runner_live ``set_data_provider`` 先行注入(miniQMT live 模式)。 ⚠️ **process_initialize 是定时任务的生命线**(2026-08-17 VPS 16 引擎空转事故): LiveEngine 重启时若 runtime 里恢复了 g(live_state.json/g.pkl),会**跳过 initialize** 走"断点续跑"路径,并把持久化的旧任务按 ``module+func`` 反射恢复——而我们的任务 是策略实例的 bound method,恢复必然失败(``无法恢复调度任务``)。结果:进程活着、 分钟心跳正常,但调度任务列表为空,开盘后零成交零日志。 聚宽语义的正解是 ``process_initialize``(每次进程启动必调,含 resume):任务注册 放这里,resume 重启后才能补挂。initialize 钩子保留(新策略首启由引擎调用)。 """ from __future__ import annotations import logging import os logger = logging.getLogger(__name__) # 进程内单例:initialize/process_initialize 双钩子共用一份策略实例与装配状态 _STATE: dict = {"strategy": None, "wired": False} def _build_live_strategy(provider): """env 配置 → StrategyTemplate 实例(对齐 runner_backtest._build_strategy)。""" from sanguo_portfolio.strategies import ( AllWeatherConfig, AllWeatherStrategy, AllWeatherExConfig, AllWeatherExStrategy, ChannelTestConfig, ChannelTestStrategy, MomentumTimingConfig, MomentumTimingStrategy, MomentumTimingExConfig, MomentumTimingExStrategy, SmallCapConfig, SmallCapStrategy, SmallCapExConfig, SmallCapExStrategy, ValueSelectionConfig, ValueSelectionStrategy, ValueSelectionExConfig, ValueSelectionExStrategy, ) name = os.environ.get("SANGUO_LIVE_STRATEGY", "all_weather") max_pool = int(os.environ.get("SANGUO_LIVE_MAX_POOL", "0") or 0) factories = { "all_weather": lambda: AllWeatherStrategy( provider=provider, config=AllWeatherConfig(max_pool=max_pool)), "momentum_timing": lambda: MomentumTimingStrategy( provider=provider, config=MomentumTimingConfig(max_pool=max_pool)), "value_selection": lambda: ValueSelectionStrategy( provider=provider, config=ValueSelectionConfig(max_pool=max_pool)), "small_cap": lambda: SmallCapStrategy( provider=provider, config=SmallCapConfig(max_pool=max_pool)), "channel_test": lambda: ChannelTestStrategy( provider=provider, config=ChannelTestConfig()), # TET Phase2 副本(issue#19):影子/实盘账户可用 _ex 副本发起 # (2026-08-16 VPS 实况:shadow#42=all_weather_ex 因工厂表缺项拉起即崩) "all_weather_ex": lambda: AllWeatherExStrategy( provider=provider, config=AllWeatherExConfig(max_pool=max_pool)), "momentum_timing_ex": lambda: MomentumTimingExStrategy( provider=provider, config=MomentumTimingExConfig(max_pool=max_pool)), "value_selection_ex": lambda: ValueSelectionExStrategy( provider=provider, config=ValueSelectionExConfig(max_pool=max_pool)), "small_cap_ex": lambda: SmallCapExStrategy( provider=provider, config=SmallCapExConfig(max_pool=max_pool)), } if name not in factories: raise ValueError( f"未知 SANGUO_LIVE_STRATEGY: {name}" f"(支持: {' / '.join(factories)})" ) return factories[name]() def _ledger_tools(): """账本通道依赖(2026-08-20 事故修复):必须绝对导入。 本文件会被逐字节复制到 runtime/<实例>/live_strategy.py(runner_live 与 shadow/runner 的 _instance_adapter),bullet_trade 以**顶层模块**加载副本 (无包上下文)——历史上这里用 ``from . import`` 相对导入,包内(测试/回测) 合法,孤立副本上直接 ImportError → 2026-08-20 晨 12 引擎(6实盘+6影子) 崩溃循环 4 小时、当日 0 成交。绝对导入两种上下文都成立(引擎进程的项目根 在 sys.path,同文件其它 ``from sanguo_portfolio...`` 导入生产已验证可跑)。 单独成函数 = 两种加载上下文的单测都能直接打到这一行。 """ from sanguo_portfolio import live_instance_ledger from sanguo_portfolio.live_portfolio_proxy import wrap_scheduler return live_instance_ledger, wrap_scheduler def _instance_order_wrappers(ledger, bt_otv, bt_ov): """P0 互卖根治(2026-08-20 14:06 实锤):把 facade 的 target 系列下单换成 **实例相对**语义——共享 QMT 账户下引擎按**全账户**算 target,#22 的 ``order_target_value(513030, 0)``(自己仅 100 股)把 #18 的 43,500 股一起 卖了;所有真实策略的卖出全是同款写法(aw:191/409·mom:472·sc:392·vs:383)。 有账本时:卖出量硬顶本实例 ``closeable_amount``(只卖自己,清仓允许零股 尾巴,减仓整手化);买入按 目标-持有 差额下显式股数单。无账本(回测/单测) 原样透传 bullet_trade 原函数,行为零改动。价格取 get_current_data 实时价, 拿不到回退账本加权成本。 """ if ledger is None: return bt_otv, bt_ov from datetime import datetime as _dt from bullet_trade.core.api import order as bt_order # type: ignore from bullet_trade.data.api import get_current_data # type: ignore lot = 100 # A股整手 def _own(security): today = _dt.now().strftime("%Y-%m-%d") return (ledger.positions_view(today).get(security) or {"amount": 0, "closeable_amount": 0, "avg_cost": 0.0}) def _price(security, own): try: p = float(get_current_data()[security].last_price or 0.0) except Exception: # noqa: BLE001 - 行情容器异常不阻断下单决策 p = 0.0 return p if p > 0 else float(own.get("avg_cost") or 0.0) def _sell_shares(security, want, own, action): """卖出量 = min(想卖, 本实例可卖);非全额清仓整手化,可卖 0 不下单。""" closeable = int(own["closeable_amount"]) sell = min(int(want), closeable) if sell <= 0: logger.info("[instance-order] %s %s 但本实例可卖 0 → 不下单(绝不卖别家)", security, action) return 0 if sell < closeable: sell = sell // lot * lot if sell <= 0: logger.info("[instance-order] %s %s 整手化后为 0 → 不下单", security, action) return 0 return sell def _done(order): """B 修法(2026-08-25 卖后买现金窗口):真实委托返回后立刻即时归因—— 引擎已见的成交即时进台账,cash 秒级新鲜,同轮「全卖→马上全买」不再 等不到卖出回款;无钩子(回测/影子/单测)为 no-op;不下单的路径不触发。 迟到成交对账(a 窄修):非终态/部分成交单挂待对账名单,归因轮询按 券商订单号直查 QMT——引擎 16s 超时弃跟踪的 fill 不再漏账。""" ledger.notify_order_done() from sanguo_portfolio import live_reconcile live_reconcile.watch_pending_order(order) return order def order_target_value(security, value, *args, **kwargs): own = _own(security) if value <= 0: sell = _sell_shares(security, int(own["amount"]), own, "清仓") if sell <= 0: return None logger.info("[instance-order] otv清仓 %s → 只卖自己 %d 股(目标值 %s)", security, sell, value) return _done(bt_order(security, -sell, *args, **kwargs)) price = _price(security, own) if price <= 0: # issue#38 fail-closed:绝不透传引擎 target 语义——引擎按全账户 # 视图算 target,账户有量会替别家卖到目标值(300059 账户级放行的 # 变体)。无价=市价拿不到且账本无此票无成本可回退 → 拒单, # 宁可少买不可互卖。 logger.warning( "[instance-order] %s 无有效价格(账本无成本可回退) → 拒单 " "fail-closed,不透传引擎账户级 target 语义", security) return None target = int(value / price) // lot * lot diff = target - int(own["amount"]) if diff >= lot: return _done(bt_order(security, diff // lot * lot, *args, **kwargs)) if diff < 0: sell = _sell_shares(security, -diff, own, "减仓") if sell <= 0: return None logger.info("[instance-order] otv减仓 %s → 卖 %d 股(目标 %d 现持 %d)", security, sell, target, int(own["amount"])) return _done(bt_order(security, -sell, *args, **kwargs)) logger.info("[instance-order] otv %s 目标 %d ≈ 现持 %d → 不下单", security, target, int(own["amount"])) return None def order_value(security, value, *args, **kwargs): if value >= 0: return _done(bt_ov(security, value, *args, **kwargs)) own = _own(security) price = _price(security, own) if price <= 0: # issue#38 fail-closed:负 order_value 无价折算不出股数,透传引擎 # =按全账户卖出,可卖别家归属——拒单。 logger.warning( "[instance-order] %s 无有效价格(账本无成本可回退) → 负 order_value " "拒单 fail-closed,不透传引擎账户级卖出", security) return None want = int(abs(value) / price + 0.999) # 向上取整再由可卖量硬顶 sell = _sell_shares(security, want, own, "按价值卖出") if sell <= 0: return None logger.info("[instance-order] 负order_value %s → 卖 %d 股(价值 %s)", security, sell, value) return _done(bt_order(security, -sell, *args, **kwargs)) return order_target_value, order_value def _setup(context): """装配策略(幂等,每进程一次):建实例 + broker facade + 定时任务。 initialize(新策略首启)与 process_initialize(每次进程启动)共用;第二个 钩子进来时直接返回,避免重复装配。各策略的 initialize 均为幂等配置,且定时 任务由策略自身经注入的 facade.run_daily/run_monthly 注册(单一事实源,无需 再调 runner_backtest._register_schedule 代注册)。 """ from bullet_trade.core.api import ( # type: ignore order_target_value as bt_otv, order_value as bt_ov, set_order_cost, set_slippage, ) from bullet_trade.core import run_daily as bt_run_daily # type: ignore from bullet_trade.core import run_monthly as bt_run_monthly # type: ignore from bullet_trade.core.settings import ( # type: ignore OrderCost, FixedSlippage, set_option as bt_set_option, ) from bullet_trade.data.api import get_data_provider # type: ignore from sanguo_portfolio.strategies.all_weather import BrokerFacade if _STATE["wired"]: return strategy = _build_live_strategy(get_data_provider()) _STATE["strategy"] = strategy _lil, _wrap_sched = _ledger_tools() _ledger = _lil.get_active() # broker 注入(与回测同构):下单委托 bullet_trade 顶层 API,live 下路由 engine; # run_daily/run_monthly 同步注入——策略在自身 initialize 里经 facade 自挂定时 # 任务,facade 缺注入时静默 no-op(2026-08-17 前 channel_test 等永不开仓的根因之一)。 # 有账本时 target 系列换实例相对语义(P0 互卖根治,见 _instance_order_wrappers) _otv_fn, _ov_fn = _instance_order_wrappers(_ledger, bt_otv, bt_ov) strategy.broker = BrokerFacade( order_target_value=lambda c, v: _otv_fn(c, v), order_value=lambda c, v: _ov_fn(c, v), set_option=lambda k, v: bt_set_option(k, v), run_daily=bt_run_daily, run_monthly=bt_run_monthly, ) # 实例持仓通道(2026-08-19 共享QMT账户互卖根治):runner_live 已 set_active # 时注入,策略侧 getattr(broker,'get_instance_positions',None) 消费; # 回测/测试无账本 → 保持 None,策略回退 context.portfolio if _ledger is not None: strategy.broker.get_instance_positions = _ledger.positions_view logger.info("instance 台账通道已注入: get_instance_positions (cash=%.2f 持仓 %d 只)", _ledger.cash, len(_ledger.positions)) # B2 定寸虚拟化:经 facade 注册的定时回调收到代理 context(portfolio=实例 # 账本视图),策略定寸/资金守卫不再被全账户污染;引擎内部仍看真实账户。 # 无账本(回测/影子) → wrap_scheduler 原样返回,行为零改动。 strategy.broker.run_daily = _wrap_sched(bt_run_daily, _ledger) strategy.broker.run_monthly = _wrap_sched(bt_run_monthly, _ledger) # A 股费用 + 滑点(与回测默认一致) set_order_cost( OrderCost( open_tax=0.0, close_tax=0.001, open_commission=0.0003, close_commission=0.0003, min_commission=5.0, ), type="stock", ) set_slippage(FixedSlippage(value=0.001)) strategy.initialize(context) _STATE["wired"] = True logger.info("live strategy 已挂载: %s", type(strategy).__name__) def initialize(context): """LiveEngine 新策略首启回调(策略文件级,g 未恢复时才被调)。""" _setup(context) def process_initialize(context): """LiveEngine 每次进程启动必调(resume 重启含)——定时任务在这里补挂。""" _setup(context)