Files
sanguo_vnpy_v2/sanguo_portfolio/live_strategy.py
T
claude_dev 41495b0c08
CI/CD / test (push) Failing after 11m5s
CI/CD / nas-deploy (push) Has been skipped
CI/CD / nas-verify (push) Has been skipped
fix(live): 卖出闸门无价透传改fail-closed拒单——issue#38修复一A收尾 [vps]
08-24 300059 跨实例互卖定罪(issue#38):闸门虽有台账硬顶,但两处「无有效价格」
路径透传引擎原函数=数据源退化回全账户视图(引擎按账户算target,账户有量会替
别家卖到目标值/按账户卖出)——正是「账户可卖视图放行18归属4400股」的变体洞。

改动(_instance_order_wrappers):
- otv value>0 无价(市价拿不到且账本无此票无成本可回退)→ 拒单fail-closed,
  不再透传bt_otv引擎target语义(宁可少买不可互卖,策略session定夺=拒单语义)
- 负order_value无价 → 拒单,不再透传bt_ov按全账户卖出
- 事故主洞(同窗双卖吃到60s归因间隙)已由191270c即时归因关闭:新增钉死测试
  「双卖窗口阻断」——第一笔4400即时归因(on_order_done钩子)→第二笔闸门读
  账本可卖0拒单;闸门每调用现读positions_view的B链路行为从此有回归锚

测试:test_live_instance_orders +3(双卖窗口阻断/otv无价拒单/负ov无价拒单),
17/17绿;portfolio+data_platform+api全量819绿。B2 closeable口径已走账本
(InstancePosition直读账本info,无改动);账本bootstrap/重归=15:10恒等式
reconcile既有runbook。issue#38验收(恒等式连续3日PASS)按issue标准观察。
2026-08-25 21:30:49 +08:00

283 lines
14 KiB
Python

"""组合策略实盘适配文件(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;不下单的路径不触发。"""
ledger.notify_order_done()
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)