"""通路测试策略(ChannelTestStrategy)单元测试。 验证跨类型轮换 + 每日多场景调度(主调仓/部分加减/卖后买/T+1探针), 用 mock broker 记录下单调用。坑:BrokerFacade 是 dataclass,子类方法重写会被 父类 __init__ 写入的实例属性遮蔽 → mock 必须在 super().__init__() 后实例注入。 """ 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 class _MockBroker(BrokerFacade): def __init__(self) -> None: self.calls: list[tuple[str, str, float]] = [] # (method, code, value) super().__init__() self.order_target_value = self._record("otv") self.order_value = self._record("ov") def _record(self, method: str): def rec(code: str, value: float): self.calls.append((method, code, value)) return None return rec class _Pos: def __init__(self, value: float) -> None: self.value = value class _Ctx: def __init__(self, positions: dict, cash: float) -> None: self.portfolio = type("P", (), {"positions": positions, "available_cash": cash, "total_value": cash + sum(p.value for p in positions.values())})() def test_universe_no_star_bj_stocks(): """实盘铁律:个股只允许主板+创业板(无 688/920/8 开头)。""" for codes in UNIVERSE_BY_TYPE.values(): for c in codes: num = c.split(".")[0] if c.endswith(".XSHE") and len(num) == 6 and not num.startswith(("15", "16")): assert not num.startswith(("30", "00", "002")) or num.startswith(("30", "00")), c # 个股(非 ETF:51/15/56/58 开头是基金)不允许 688/689/920/8 开头 is_etf = num.startswith(("51", "15", "56", "58")) if not is_etf: assert not num.startswith(("688", "689", "92", "4", "8")), f"非主板/创业板个股: {c}" def test_universe_covers_all_types_with_multiple(): """每类至少 3 只、类型覆盖宽基/行业/跨境/主板/创业板。""" for grp, codes in UNIVERSE_BY_TYPE.items(): assert len(codes) >= 3, grp assert len(UNIVERSE_BY_TYPE) >= 5 def test_target_set_spans_types(): s = ChannelTestStrategy(provider=None, config=ChannelTestConfig(hold_n=6)) s._day = 1 target = s._target_set() assert len(target) == 6 # 跨类型:6 只来自不同类型组 code2grp = {c: g for g, cs in UNIVERSE_BY_TYPE.items() for c in cs} groups = {code2grp[c] for c in target} assert len(groups) == 6 # hold_n=6 且组数≥6 → 每组一只 def test_rotate_sells_non_target_and_buys_target(): broker = _MockBroker() s = ChannelTestStrategy(provider=None, broker=broker, config=ChannelTestConfig(hold_n=2, period=1, probe_t1=False, intraday_partial=False, intraday_swap=False)) ctx = _Ctx({"510300.XSHG": _Pos(1000), "600519.XSHG": _Pos(1000)}, cash=8000) s.rotate(ctx) zero_calls = {c for m, c, v in broker.calls if m == "otv" and v == 0} assert zero_calls # 有全量卖出(旧持仓不在新目标) buys = [c for m, c, v in broker.calls if m == "otv" and v > 0] assert len(buys) == 2 # 等权买入 hold_n 只 def test_partial_adjust_buys_more_and_sells_half(): broker = _MockBroker() s = ChannelTestStrategy(provider=None, broker=broker, config=ChannelTestConfig(intraday_partial=True)) ctx = _Ctx({"A": _Pos(1000), "B": _Pos(1000), "C": _Pos(1000)}, cash=1000) s.partial_adjust(ctx) ups = [(c, v) for m, c, v in broker.calls if v > 1000] # 加仓 >原值 downs = [(c, v) for m, c, v in broker.calls if 0 < v < 1000] # 部分减仓 assert ups and all(v >= 1000 for _, v in ups) assert downs # 部分卖出(非清仓) def test_swap_one_sells_then_buys(): broker = _MockBroker() s = ChannelTestStrategy(provider=None, broker=broker, config=ChannelTestConfig(intraday_swap=True)) s._day = 1 ctx = _Ctx({"510300.XSHG": _Pos(5000), "600519.XSHG": _Pos(5000)}, cash=3000) s.swap_one(ctx) sells = [c for m, c, v in broker.calls if v == 0] buys = [(c, v) for m, c, v in broker.calls if v > 0] assert sells == ["510300.XSHG"] # 卖第一只 assert buys and buys[0][0] not in ("510300.XSHG", "600519.XSHG") # 买未持有的 def test_t1_probe_fires(): broker = _MockBroker() s = ChannelTestStrategy(provider=None, broker=broker, config=ChannelTestConfig(probe_t1=True)) ctx = _Ctx({"510300.XSHG": _Pos(5000)}, cash=0) s.t1_probe(ctx) assert broker.calls == [("otv", "510300.XSHG", 0)] def test_initialize_registers_intraday_schedules(monkeypatch): monkeypatch.delenv("SANGUO_LIVE_STRATEGY", raising=False) # 防环境串扰翻到随启随验 registered: list[tuple[str, str]] = [] class _RegBroker(_MockBroker): def __init__(self) -> None: super().__init__() self.run_daily = lambda fn, t: registered.append( (getattr(fn, "__name__", str(fn)), t)) s = ChannelTestStrategy(provider=None, broker=_RegBroker(), config=ChannelTestConfig()) s.initialize(object()) times = {t for _, t in registered} assert {"9:35", "10:45", "13:45", "14:30"} <= times # 四个盘中时点全注册 # ---------------- 随启随验(2026-08-20) ---------------- def test_initialize_on_demand_registers_single_probe(monkeypatch): """LiveEngine env 下只注册一个"启动后约90秒"的 probe_all,不再挂固定时点。""" from datetime import datetime monkeypatch.setenv("SANGUO_LIVE_STRATEGY", "channel_test") registered: list[tuple[str, str]] = [] class _RegBroker(_MockBroker): def __init__(self) -> None: super().__init__() self.run_daily = lambda fn, t: registered.append( (getattr(fn, "__name__", str(fn)), t)) s = ChannelTestStrategy(provider=None, broker=_RegBroker(), config=ChannelTestConfig()) s.initialize(object()) assert len(registered) == 1 name, t = registered[0] assert name == "probe_all" hh, mm = t.split(":") now = ct._now() # 墙钟可注入(盘后防护 _now 缝),断言跟注入钟走 delta = (int(hh) * 60 + int(mm)) - (now.hour * 60 + now.minute) assert 0 <= delta <= 3 # 未来 ~90 秒(±跨分钟容差) def test_initialize_on_demand_explicit_off_keeps_schedule(monkeypatch): """on_demand=False 显式覆盖:即使 LiveEngine env 在,也维持四时点旧行为。""" monkeypatch.setenv("SANGUO_LIVE_STRATEGY", "channel_test") registered: list[tuple[str, str]] = [] class _RegBroker(_MockBroker): def __init__(self) -> None: super().__init__() self.run_daily = lambda fn, t: registered.append( (getattr(fn, "__name__", str(fn)), t)) s = ChannelTestStrategy(provider=None, broker=_RegBroker(), config=ChannelTestConfig(on_demand=False)) s.initialize(object()) assert {"9:35", "10:45", "13:45", "14:30"} <= {t for _, t in registered} def test_probe_all_runs_sizing_sell_t1_swap_sequence(): """一轮探针打满:等权买入(定寸) + 卖出(只卖自己源=own 持仓) + T+1 + 换仓。""" broker = _MockBroker() s = ChannelTestStrategy(provider=None, broker=broker, config=ChannelTestConfig(hold_n=2, probe_t1=True, intraday_partial=False, intraday_swap=True, on_demand=True)) ctx = _Ctx({"510300.XSHG": _Pos(5000), "600519.XSHG": _Pos(5000)}, cash=4000) s.probe_all(ctx) buys = [c for m, c, v in broker.calls if v > 0] sells = [c for m, c, v in broker.calls if v == 0] assert buys # 定寸买入发生(实例视图等权) 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