fix(strategy): channel_test探针盘后下单双守卫——09-01 18:48轮换重启实锤:随启随验run_daily锚定now+90s=18:50,探针16笔真单盘后全挂柜台(~107万买单冻结排队次日集合竞价);且run_daily为每日重复锚定,08-30周日13:07重启即埋下「每天13:09盘后风格漂移」隐患,jq_strategy调度恢复失败回退注册当前时刻是第三路径;修=①注册侧:锚点落非交易时段(工作日09:25-11:35/12:55-15:05)不注册+大声告警②执行侧:probe_all首行守卫,盘外触发整轮跳过零下单(兜调度错锚/回退路径);墙钟抽_now()可注入,测试autouse钉盘中防CI时段漂移;+3守卫测试+11边界参数化;25/25 [vps]
CI/CD / test (push) Successful in 2s
CI/CD / nas-deploy (push) Successful in 7s
CI/CD / nas-verify (push) Failing after 12m28s

This commit is contained in:
2026-09-01 19:04:40 +08:00
parent 8dc3f494b7
commit 5d0a0bf6d3
2 changed files with 122 additions and 10 deletions
+41 -9
View File
@@ -23,12 +23,33 @@ import logging
import os
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple
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"],
@@ -79,8 +100,16 @@ class ChannelTestStrategy:
# 随启随验:只注册一个"启动后约90秒"的探针任务。必须经 facade.run_daily
# 注册而非直接调用——回调才会收到 B2 包装的代理 context(实例预算视图),
# 绕过它探针就测不到定寸虚拟化。重启实例(停止→启动)即再验一轮。
h, m = self._probe_time()
b.run_daily(self.probe_all, f"{h}:{m:02d}")
# 盘前/盘后/周末重启不注册(锚点在时段外=盘后真单挂柜台,09-01 实锤)。
probe_at = _now() + timedelta(seconds=90)
if not _in_trading_session(probe_at):
logger.warning(
"[PROBE] 重启于非交易时段(探针锚定 %s),跳过随启随验注册"
"——防盘后挂单排队次日集合竞价;下次盘中重启再验",
probe_at.strftime("%H:%M"))
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:
@@ -97,15 +126,13 @@ class ChannelTestStrategy:
return self.config.on_demand
return bool(os.environ.get("SANGUO_LIVE_STRATEGY"))
@staticmethod
def _probe_time() -> Tuple[int, int]:
"""探针触发时刻=启动后约 90 秒(留足引擎 bootstrap;时:分对齐 "9:35" 风格)。"""
t = datetime.now() + timedelta(seconds=90)
return int(t.strftime("%H")), int(t.strftime("%M"))
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 证明;
@@ -115,6 +142,11 @@ class ChannelTestStrategy:
重启第二轮起有票可试);
- 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 (应=本实例预算切片,非全账户)",
+81 -1
View File
@@ -6,6 +6,11 @@
"""
from __future__ import annotations
from datetime import datetime
import pytest
import sanguo_portfolio.strategies.channel_test as ct
from sanguo_portfolio.strategies import ChannelTestConfig, ChannelTestStrategy
from sanguo_portfolio.strategies.all_weather import BrokerFacade
from sanguo_portfolio.strategies.channel_test import UNIVERSE_BY_TYPE
@@ -154,7 +159,7 @@ def test_initialize_on_demand_registers_single_probe(monkeypatch):
name, t = registered[0]
assert name == "probe_all"
hh, mm = t.split(":")
now = datetime.now()
now = ct._now() # 墙钟可注入(盘后防护 _now 缝),断言跟注入钟走
delta = (int(hh) * 60 + int(mm)) - (now.hour * 60 + now.minute)
assert 0 <= delta <= 3 # 未来 ~90 秒(±跨分钟容差)
@@ -192,3 +197,78 @@ def test_probe_all_runs_sizing_sell_t1_swap_sequence():
assert "510300.XSHG" in sells # T+1 探针卖 own 第一只(或 rotate 换仓卖出)
swap_buy = [c for m, c, v in broker.calls if m == "ov" and v > 0]
assert swap_buy # 资金复用:卖后另买未持有票
# ======================== 盘后下单防护(2026-09-01 实锤) ========================
@pytest.fixture(autouse=True)
def _pin_now_in_session(monkeypatch):
"""钉住守卫墙钟=盘中(周二 10:00),既有探针测试不随 CI 运行时段漂移;
守卫自身的界外用例各自显式 monkeypatch 覆盖。"""
ct._now = lambda: datetime(2026, 9, 1, 10, 0) # type: ignore[assignment]
@pytest.mark.parametrize("ts,expected", [
("2026-09-01 09:24", False), # 集合竞价前
("2026-09-01 09:26", True), # 竞价缓冲内
("2026-09-01 11:34", True), # 午前收盘缓冲
("2026-09-01 11:36", False), # 午休
("2026-09-01 12:54", False),
("2026-09-01 12:56", True), # 午后
("2026-09-01 15:04", True), # 收盘缓冲
("2026-09-01 15:06", False),
("2026-09-01 18:50", False), # 09-01 事故时刻(盘后轮换锚点)
("2026-09-05 10:00", False), # 周六
("2026-09-06 10:00", False), # 周日
])
def test_in_trading_session_boundaries(ts, expected):
assert ct._in_trading_session(datetime.fromisoformat(ts)) is expected
def test_probe_all_skips_out_of_session(monkeypatch, caplog):
"""盘外触发(调度错锚/恢复回退)整轮跳过:零下单 + 告警留痕。"""
ct._now = lambda: datetime(2026, 9, 1, 18, 50) # type: ignore[assignment]
broker = _MockBroker()
s = ChannelTestStrategy(provider=None, broker=broker,
config=ChannelTestConfig(hold_n=2, on_demand=True))
ctx = _Ctx({"510300.XSHG": _Pos(5000)}, cash=100000)
with caplog.at_level("WARNING"):
s.probe_all(ctx)
assert broker.calls == [] # 一笔单都不许挂
assert any("非交易时段" in r.message for r in caplog.records)
def test_initialize_on_demand_after_hours_skips_registration(monkeypatch, caplog):
"""盘后重启(live env)不注册随启随验——run_daily 锚点落在时段外必跳过,
否则 16 笔真单挂柜台排队次日集合竞价(09-01 18:48 轮换实锤)。"""
monkeypatch.setenv("SANGUO_LIVE_STRATEGY", "channel_test")
ct._now = lambda: datetime(2026, 9, 1, 18, 48) # type: ignore[assignment]
registered: list = []
class _RegBroker(_MockBroker):
def __init__(self) -> None:
super().__init__() # dataclass 字段会遮蔽子类方法,注入须在 super 后
self.run_daily = lambda fn, t: registered.append(
(getattr(fn, "__name__", str(fn)), t))
s = ChannelTestStrategy(provider=None, broker=_RegBroker(),
config=ChannelTestConfig(on_demand=True))
with caplog.at_level("WARNING"):
s.initialize(object())
assert registered == [] # 不注册
assert any("非交易时段" in r.message for r in caplog.records)
def test_initialize_on_demand_in_session_still_registers():
"""盘中重启注册行为不变(回归锚定时间=now+90s 落在盘中)。"""
registered: list = []
class _RegBroker2(_MockBroker):
def __init__(self) -> None:
super().__init__() # dataclass 字段会遮蔽子类方法,注入须在 super 后
self.run_daily = lambda fn, t: registered.append(
(getattr(fn, "__name__", str(fn)), t))
s = ChannelTestStrategy(provider=None, broker=_RegBroker2(),
config=ChannelTestConfig(on_demand=True))
s.initialize(object())
assert registered == [("probe_all", "10:01")] # 10:00 + 90s