Files
sanguo_vnpy_v2/sanguo_portfolio/strategies/channel_test.py
T

269 lines
14 KiB
Python

"""通路测试策略(影子柜台 vs 实盘 双轨验证专用,docs/design/paper-shadow-desk-design.md §8)。
不是为赚钱,是为**把买卖全通路在真实 miniQMT 数据上每天跑满一遍**,让 ShadowBroker
与 QmtBroker 在同一段时间、同一批订单上各自成交,事后对账验证通路正确性。
universe 按资产类型分组(每类多只,轮换时跨类型取样 → 各类型买卖都覆盖):
- 宽基 ETF / 行业 ETF / 跨境·商品 ETF / 主板蓝筹 / 主板中盘 / 创业板
- ⚠️ 只含主板+创业板个股(实盘账户无科创/北交权限,铁律);ETF 无板块权限限制
每日场景覆盖(多个盘中时点触发,15m 线账户建议配 interval=15m):
- 09:35 主调仓:卖掉全部非目标持仓(卖全量) + 等权买入 6 只跨类型标的(买/整手/资金)
- 10:45 部分调仓:对已持仓标的加减仓(部分买卖,非清仓式)
- 13:45 卖后买:清掉 1 只持仓换买另 1 只(当日资金复用;清的是昨日仓,T+1 可卖)
- 14:30 T+1 探针:当日买入立即试卖 → 两端都应被 T+1 拒(验证拒单通路)
- 涨跌停/停牌/部分成交:靠真实行情自然出现,差异进双轨对账报告
随启随验(2026-08-20,on_demand 自动于 LiveEngine 进程生效):不再等盘中固定
时点——实例每次启动/重启后 ~90 秒经 B2 代理 context 打一轮全场景探针
(probe_all,[PROBE-0..4] 日志留痕),影子柜台 24h 可验,重启实例=再验一轮。
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from .all_weather import BrokerFacade, _available_cash, _get_positions
logger = logging.getLogger(__name__)
# ---------------- 盘后下单防护(2026-09-01 实锤) ----------------
def _now() -> datetime:
"""当前时刻(独立函数便于测试 monkeypatch;测试禁依赖墙钟)。"""
return datetime.now()
def _in_trading_session(now: datetime) -> bool:
"""A股交易时段(含集合竞价/收盘缓冲):工作日 09:25-11:35 / 12:55-15:05。
09-01 实锤:盘后 18:48 轮换重启 → 随启随验 run_daily 锚定 18:50 → 探针
16 笔真单盘后全挂柜台(~107 万排队次日集合竞价)。live 探针的**注册**与
**执行**双处守卫;jq_strategy 调度恢复失败回退注册到当前时刻的路径由
执行侧守卫兜底。影子/回测撮合 24h 安全不受影响。
"""
if now.weekday() >= 5:
return False
hm = now.hour * 60 + now.minute
return (9 * 60 + 25 <= hm <= 11 * 60 + 35
or 12 * 60 + 55 <= hm <= 15 * 60 + 5)
# universe 按类型分组;轮换时从每组轮流取 → 每天的持仓组合跨类型
UNIVERSE_BY_TYPE: Dict[str, List[str]] = {
"宽基ETF": ["510300.XSHG", "510050.XSHG", "510500.XSHG", "159915.XSHE"],
"行业ETF": ["512880.XSHG", "512690.XSHG", "515790.XSHG"],
"跨境商品ETF": ["513100.XSHG", "513030.XSHG", "518880.XSHG"],
"主板蓝筹": ["600519.XSHG", "601318.XSHG", "600036.XSHG"],
"主板中盘": ["000001.XSHE", "600100.XSHG", "601668.XSHG"],
"创业板": ["300750.XSHE", "300059.XSHE", "002415.XSHE"], # 002=中小板并入主板
}
_TYPE_ORDER = list(UNIVERSE_BY_TYPE.keys())
@dataclass
class ChannelTestConfig:
hold_n: int = 6 # 每轮等权持有几只(跨类型取样)
period: int = 1 # 每 N 个交易日轮换主组合(1=每日)
probe_t1: bool = True # 是否做 T+1 当日卖探针
intraday_partial: bool = True # 10:45 部分调仓(加减仓)
intraday_swap: bool = True # 13:45 卖后买(资金复用)
benchmark: str = "000300.XSHG"
# 随启随验(2026-08-20 用户拍板):None=自动——LiveEngine 进程(supervisor 注入
# SANGUO_LIVE_STRATEGY)下 True,启动/重启后 ~90s 打一轮全场景探针,不绑盘中
# 固定时点(开盘窗口不再是验证瓶颈:影子柜台 24h 可验,实盘闭市下单被拒也是
# 验证项);回测/单测(无 env)False,维持四时点旧行为。显式 True/False 覆盖。
on_demand: Optional[bool] = None
class ChannelTestStrategy:
"""通路测试策略:每日跨类型轮换 + 盘中多时点场景触发。"""
def __init__(
self,
provider: Any,
broker: Optional[BrokerFacade] = None,
config: Optional[ChannelTestConfig] = None,
) -> None:
self.provider = provider
self.broker = broker or BrokerFacade()
self.config = config or ChannelTestConfig()
self._day = 0
def initialize(self, context: Any) -> None:
b = self.broker
b.set_benchmark(self.config.benchmark)
b.set_option("use_real_price", True)
b.set_option("avoid_future_data", True)
if self._on_demand():
# 随启随验:只注册一个探针任务。必须经 facade.run_daily 注册而非
# 直接调用——回调才会收到 B2 包装的代理 context(实例预算视图),
# 绕过它探针就测不到定寸虚拟化。重启实例(停止→启动)即再验一轮。
# 锚点策略:盘中重启锚 now+90s(快验);盘外重启锚固定 9:35——
# 09-01 实锤 now+90s 落盘外=16 笔真单挂柜台,而 5d0a0bf 初版
# 「盘外不注册」在 09-02 被 dev 巡检实锤副作用:轮换按运维规则放
# 23:05 后 → 每次 boot 都盘外 → 永不注册 → 测试通道停摆
# (live_18/22 零任务,四场景至少停一天)。
probe_at = _now() + timedelta(seconds=90)
if not _in_trading_session(probe_at):
logger.warning(
"[PROBE] 重启于非交易时段(探针锚定 %s),改锚固定时刻 9:35"
"——防盘后挂单排队次日集合竞价,同时保住测试通道每日在验",
probe_at.strftime("%H:%M"))
b.run_daily(self.probe_all, "9:35")
return
b.run_daily(self.probe_all,
probe_at.strftime("%H:%M"))
return
b.run_daily(self.rotate, "9:35") # 主调仓:卖旧买新
if self.config.intraday_partial:
b.run_daily(self.partial_adjust, "10:45") # 部分加减仓
if self.config.intraday_swap:
b.run_daily(self.swap_one, "13:45") # 卖后买(资金复用)
if self.config.probe_t1:
b.run_daily(self.t1_probe, "14:30") # T+1 拒单探针
# ---------------- 随启随验 ----------------
def _on_demand(self) -> bool:
"""随启随验开关(见 ChannelTestConfig.on_demand 注释)。"""
if self.config.on_demand is not None:
return self.config.on_demand
return bool(os.environ.get("SANGUO_LIVE_STRATEGY"))
def probe_all(self, context: Any) -> None:
"""一轮打满全场景,每步 [PROBE-n] 留痕(日志即验收凭据)。
非交易时段整轮跳过(执行侧守卫):调度错锚/jq_strategy 恢复失败回退
注册到当前时刻等路径把探针拖到盘外时,绝不让真单挂柜台排队次日
集合竞价(2026-09-01 18:50 实锤,16 笔 ~107 万)。
- PROBE-0 实例视图快照:cash/持仓应为**本实例预算切片**(B2 生效的标志,
≠全账户 990 万;实盘闭市时下单被 QMT 拒不阻断,决策层输出已在日志可验);
- PROBE-1 主调仓(rotate):定寸=实例视图等权 → sizing 证明;
- PROBE-2 只卖自己:rotate 的卖出对象全部来自实例账本持仓(A1 读隔离,
对照前端"账户实况"实例分解即可确认没碰别家的票);
- PROBE-3 T+1 拒单探针:当日买立即卖,两端应拒(冷启动首轮空仓会跳过,
重启第二轮起有票可试);
- PROBE-4 资金复用(swap_one):卖昨日仓→即刻买新(空仓时跳过)。
"""
if not _in_trading_session(_now()):
logger.warning(
"[PROBE] 非交易时段触发(调度错锚/恢复回退),整轮跳过"
"——防盘后挂单排队次日集合竞价")
return
cash = _available_cash(context)
positions = _get_positions(context, self.broker)
logger.info("[PROBE-0] 实例视图 cash=%.2f 持仓=%s (应=本实例预算切片,非全账户)",
cash, sorted(positions.keys()))
logger.info("[PROBE-1] 主调仓:定寸=实例视图等权")
self.rotate(context)
logger.info("[PROBE-2] 只卖自己:卖出对象全部来自实例账本持仓")
if self.config.probe_t1:
logger.info("[PROBE-3] T+1 探针:当日买立即卖,预期被拒(空仓跳过)")
self.t1_probe(context)
if self.config.intraday_swap:
logger.info("[PROBE-4] 资金复用:卖后买(空仓跳过)")
self.swap_one(context)
logger.info("[PROBE] 本轮探针序列完成;成交归因见 live_trades,前端三层+账户实况")
# ---------------- 目标组合 ----------------
def _target_set(self) -> List[str]:
"""跨类型取样 hold_n 只:每个类型组按 day 偏移轮流供一只,凑满 hold_n。"""
cycle = self._day // max(1, self.config.period)
picked: List[str] = []
for i in range(self.config.hold_n):
grp = _TYPE_ORDER[(i + cycle) % len(_TYPE_ORDER)]
codes = UNIVERSE_BY_TYPE[grp]
picked.append(codes[(cycle + i // len(_TYPE_ORDER)) % len(codes)])
return picked
# ---------------- 场景1:主调仓(卖全量旧 + 买新) ----------------
def rotate(self, context: Any) -> None:
self._day += 1
if (self._day - 1) % max(1, self.config.period) != 0:
return
positions = _get_positions(context, self.broker)
target = self._target_set()
target_set = set(target)
logger.info("[channel_test] day=%d 主调仓 target=%s holding=%s",
self._day, target, list(positions.keys()))
for code in list(positions.keys()):
if code not in target_set:
logger.info("[channel_test] 全量卖出 %s", code)
self.broker.order_target_value(code, 0)
self._buy_equal_weight(context, target)
# ---------------- 场景2:部分加减仓(非清仓) ----------------
def partial_adjust(self, context: Any) -> None:
positions = _get_positions(context, self.broker)
if not positions:
return
total = _available_cash(context) + sum(_safe_value(p) for p in positions.values())
codes = sorted(positions.keys())
half = max(1, (len(codes) + 1) // 2)
for code in codes[:half]:
# 加仓 10%(走"对已持仓追加买入");市值未知(实例视图缺现价)→ 跳过,
# 绝不 order_target_value(code, 0) 把"加仓"变成清仓
pos_val = _safe_value(positions[code])
if pos_val > 0:
self.broker.order_target_value(code, pos_val * 1.1)
logger.info("[channel_test] 加仓 %s → +10%%", code)
for code in codes[half:half + 1]:
# 减仓到一半(走"部分卖出",非清仓)
pos_val = _safe_value(positions[code])
if pos_val > 0:
self.broker.order_target_value(code, pos_val * 0.5)
logger.info("[channel_test] 减半仓 %s", code)
_ = total
# ---------------- 场景3:卖后买(当日资金复用) ----------------
def swap_one(self, context: Any) -> None:
positions = _get_positions(context, self.broker)
if not positions:
return
# 清掉第一只(昨日买的,T+1 可卖) → 换买 universe 里下一只未持有的
out_code = sorted(positions.keys())[0]
logger.info("[channel_test] 换仓卖出 %s", out_code)
self.broker.order_target_value(out_code, 0)
flat = [c for grp in _TYPE_ORDER for c in UNIVERSE_BY_TYPE[grp]
if c not in positions]
if flat:
in_code = flat[self._day % len(flat)]
cash = _available_cash(context)
logger.info("[channel_test] 换仓买入 %s (卖后资金复用)", in_code)
self.broker.order_value(in_code, min(cash * 0.9, 20000))
# ---------------- 场景4:T+1 拒单探针 ----------------
def t1_probe(self, context: Any) -> None:
"""当日(含今早刚买)持仓立即试卖 → 两端都应被 T+1 拒,验证拒单通路一致。"""
positions = _get_positions(context, self.broker)
# 优先挑"今天买的":无买入时间信息就取第一只(今早主调仓必然买过新仓)
if not positions:
return
probe_code = sorted(positions.keys())[0]
try:
self.broker.order_target_value(probe_code, 0)
logger.info("[channel_test] T+1 探针 %s 当日卖已提交(预期被拒)", probe_code)
except Exception as exc: # noqa: BLE001 - 探针失败不阻断
logger.debug("[channel_test] T+1 探针异常(正常): %s", exc)
# ---------------- 买入工具 ----------------
def _buy_equal_weight(self, context: Any, target: List[str]) -> None:
positions = _get_positions(context, self.broker)
cash = _available_cash(context)
total = cash + sum(_safe_value(p) for p in positions.values())
per = total / max(1, len(target))
for code in target:
logger.info("[channel_test] 等权调仓 %s%.2f", code, per)
self.broker.order_target_value(code, per)
def _safe_value(pos: Any) -> float:
"""从 position 对象取市值,兼容多种属性名。"""
for attr in ("value", "market_value", "total_value"):
v = getattr(pos, attr, None)
if isinstance(v, (int, float)):
return float(v)
return 0.0