From 147a49d7812f4420c242128afee00c8f9110b4cc Mon Sep 17 00:00:00 2001 From: claude_dev Date: Thu, 20 Aug 2026 11:41:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(strategy):=20channel=5Ftest=E9=9A=8F?= =?UTF-8?q?=E5=90=AF=E9=9A=8F=E9=AA=8C=E6=8E=A2=E9=92=88=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?(=E7=94=A8=E6=88=B7=E6=8B=8D=E6=9D=BF2026-08-20:=E5=BC=80?= =?UTF-8?q?=E7=9B=98=E7=AA=97=E5=8F=A3=E4=B8=8D=E5=86=8D=E6=98=AF=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E7=93=B6=E9=A2=88,=E9=9A=8F=E6=97=B6=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E9=9A=8F=E6=97=B6=E9=AA=8C=E5=85=A8=E5=9C=BA=E6=99=AF?= =?UTF-8?q?)=E2=80=94=E2=80=94on=5Fdemand=3DNone=E8=87=AA=E5=8A=A8:LiveEng?= =?UTF-8?q?ine=E8=BF=9B=E7=A8=8B(supervisor=E6=B3=A8=E5=85=A5SANGUO=5FLIVE?= =?UTF-8?q?=5FSTRATEGY)=E2=86=92=E5=90=AF=E5=8A=A8/=E9=87=8D=E5=90=AF?= =?UTF-8?q?=E5=90=8E~90s=E7=BB=8Ffacade.run=5Fdaily=E6=B3=A8=E5=86=8Cprobe?= =?UTF-8?q?=5Fall=E6=89=93=E4=B8=80=E8=BD=AE=E5=85=A8=E5=9C=BA=E6=99=AF;?= =?UTF-8?q?=E5=BF=85=E9=A1=BB=E8=B5=B0facade=E6=B3=A8=E5=86=8C=E8=80=8C?= =?UTF-8?q?=E9=9D=9E=E7=9B=B4=E6=8E=A5=E8=B0=83=E7=94=A8:=E5=9B=9E?= =?UTF-8?q?=E8=B0=83=E6=89=8D=E6=94=B6=E5=88=B0B2=E5=8C=85=E8=A3=85?= =?UTF-8?q?=E7=9A=84=E4=BB=A3=E7=90=86context(=E5=AE=9E=E4=BE=8B=E9=A2=84?= =?UTF-8?q?=E7=AE=97=E8=A7=86=E5=9B=BE),=E7=BB=95=E8=BF=87=E5=AE=83?= =?UTF-8?q?=E6=8E=A2=E9=92=88=E6=B5=8B=E4=B8=8D=E5=88=B0=E5=AE=9A=E5=AF=B8?= =?UTF-8?q?=E8=99=9A=E6=8B=9F=E5=8C=96;=E5=BD=B1=E5=AD=90=E6=9F=9C?= =?UTF-8?q?=E5=8F=B024h=E5=8F=AF=E9=AA=8C(=E6=94=B6=E7=9B=98=E4=BB=B7?= =?UTF-8?q?=E6=92=AE=E5=90=88),=E5=AE=9E=E7=9B=98=E9=97=AD=E5=B8=82?= =?UTF-8?q?=E4=B8=8B=E5=8D=95=E8=A2=ABQMT=E6=8B=92=E4=BA=A6=E5=B1=9E?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E9=A1=B9(=E5=86=B3=E7=AD=96=E5=B1=82?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E5=B7=B2=E5=9C=A8=E6=97=A5=E5=BF=97);?= =?UTF-8?q?=E9=87=8D=E5=90=AF=E5=AE=9E=E4=BE=8B=3D=E5=86=8D=E9=AA=8C?= =?UTF-8?q?=E4=B8=80=E8=BD=AE;[PROBE-0]=E5=AE=9E=E4=BE=8B=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E5=BF=AB=E7=85=A7(cash=3D=E9=A2=84=E7=AE=97=E5=88=87?= =?UTF-8?q?=E7=89=87=E9=9D=9E=E5=85=A8=E8=B4=A6=E6=88=B7=3DB2=E7=94=9F?= =?UTF-8?q?=E6=95=88=E6=A0=87=E5=BF=97)/[PROBE-1]rotate=E4=B8=BB=E8=B0=83?= =?UTF-8?q?=E4=BB=93=E5=AE=9A=E5=AF=B8=3D=E5=AE=9E=E4=BE=8B=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E7=AD=89=E6=9D=83/[PROBE-2]=E5=8F=AA=E5=8D=96?= =?UTF-8?q?=E8=87=AA=E5=B7=B1(=E5=8D=96=E5=87=BA=E5=AF=B9=E8=B1=A1?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E6=9D=A5=E8=87=AAown=E8=B4=A6=E6=9C=AC,?= =?UTF-8?q?=E5=AF=B9=E7=85=A7=E5=89=8D=E7=AB=AF=E8=B4=A6=E6=88=B7=E5=AE=9E?= =?UTF-8?q?=E5=86=B5=E5=AE=9E=E4=BE=8B=E5=88=86=E8=A7=A3)/[PROBE-3]T+1?= =?UTF-8?q?=E5=BD=93=E6=97=A5=E4=B9=B0=E7=AB=8B=E5=8D=B3=E5=8D=96=E5=BA=94?= =?UTF-8?q?=E6=8B=92(=E5=86=B7=E5=90=AF=E5=8A=A8=E7=A9=BA=E4=BB=93?= =?UTF-8?q?=E8=B7=B3=E8=BF=87,=E7=AC=AC=E4=BA=8C=E8=BD=AE=E8=B5=B7?= =?UTF-8?q?=E6=9C=89=E7=A5=A8)/[PROBE-4]swap=5Fone=E8=B5=84=E9=87=91?= =?UTF-8?q?=E5=A4=8D=E7=94=A8;=E5=9B=9E=E6=B5=8B/=E5=8D=95=E6=B5=8B?= =?UTF-8?q?=E6=97=A0env=E7=BB=B4=E6=8C=81=E5=9B=9B=E6=97=B6=E7=82=B9?= =?UTF-8?q?=E6=97=A7=E8=A1=8C=E4=B8=BA,=E6=98=BE=E5=BC=8FTrue/False?= =?UTF-8?q?=E8=A6=86=E7=9B=96;+3=E6=B5=8B=E8=AF=95(env=E4=B8=8B=E4=BB=85?= =?UTF-8?q?=E6=B3=A8=E5=86=8C1=E4=B8=AA=E6=9C=AA=E6=9D=A5~90s=E7=9A=84prob?= =?UTF-8?q?e=5Fall/=E6=98=BE=E5=BC=8Foff=E4=BF=9D=E5=9B=9B=E6=97=B6?= =?UTF-8?q?=E7=82=B9/probe=5Fall=E5=85=A8=E5=BA=8F=E5=88=97=E4=B8=8B?= =?UTF-8?q?=E5=8D=95=E6=96=AD=E8=A8=80);=E6=97=A7=E5=9B=9B=E6=97=B6?= =?UTF-8?q?=E7=82=B9=E6=B5=8B=E8=AF=95=E8=A1=A5delenv=E9=98=B2=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E4=B8=B2=E6=89=B0=20[vps]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sanguo_portfolio/strategies/channel_test.py | 59 ++++++++++++++++++- tests/portfolio/test_channel_test.py | 65 ++++++++++++++++++++- 2 files changed, 122 insertions(+), 2 deletions(-) diff --git a/sanguo_portfolio/strategies/channel_test.py b/sanguo_portfolio/strategies/channel_test.py index 0976802..e5bcc14 100644 --- a/sanguo_portfolio/strategies/channel_test.py +++ b/sanguo_portfolio/strategies/channel_test.py @@ -13,12 +13,17 @@ universe 按资产类型分组(每类多只,轮换时跨类型取样 → 各 - 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 typing import Any, Dict, List, Optional +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional, Tuple from .all_weather import BrokerFacade, _available_cash, _get_positions @@ -44,6 +49,11 @@ class ChannelTestConfig: 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: @@ -65,6 +75,13 @@ class ChannelTestStrategy: b.set_benchmark(self.config.benchmark) b.set_option("use_real_price", True) b.set_option("avoid_future_data", True) + if self._on_demand(): + # 随启随验:只注册一个"启动后约90秒"的探针任务。必须经 facade.run_daily + # 注册而非直接调用——回调才会收到 B2 包装的代理 context(实例预算视图), + # 绕过它探针就测不到定寸虚拟化。重启实例(停止→启动)即再验一轮。 + h, m = self._probe_time() + b.run_daily(self.probe_all, f"{h}:{m:02d}") + return b.run_daily(self.rotate, "9:35") # 主调仓:卖旧买新 if self.config.intraday_partial: b.run_daily(self.partial_adjust, "10:45") # 部分加减仓 @@ -73,6 +90,46 @@ class ChannelTestStrategy: 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")) + + @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] 留痕(日志即验收凭据)。 + + - PROBE-0 实例视图快照:cash/持仓应为**本实例预算切片**(B2 生效的标志, + ≠全账户 990 万;实盘闭市时下单被 QMT 拒不阻断,决策层输出已在日志可验); + - PROBE-1 主调仓(rotate):定寸=实例视图等权 → sizing 证明; + - PROBE-2 只卖自己:rotate 的卖出对象全部来自实例账本持仓(A1 读隔离, + 对照前端"账户实况"实例分解即可确认没碰别家的票); + - PROBE-3 T+1 拒单探针:当日买立即卖,两端应拒(冷启动首轮空仓会跳过, + 重启第二轮起有票可试); + - PROBE-4 资金复用(swap_one):卖昨日仓→即刻买新(空仓时跳过)。 + """ + 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。""" diff --git a/tests/portfolio/test_channel_test.py b/tests/portfolio/test_channel_test.py index 21e29d1..a57cec7 100644 --- a/tests/portfolio/test_channel_test.py +++ b/tests/portfolio/test_channel_test.py @@ -115,7 +115,8 @@ def test_t1_probe_fires(): assert broker.calls == [("otv", "510300.XSHG", 0)] -def test_initialize_registers_intraday_schedules(): +def test_initialize_registers_intraday_schedules(monkeypatch): + monkeypatch.delenv("SANGUO_LIVE_STRATEGY", raising=False) # 防环境串扰翻到随启随验 registered: list[tuple[str, str]] = [] class _RegBroker(_MockBroker): @@ -129,3 +130,65 @@ def test_initialize_registers_intraday_schedules(): 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 = datetime.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 # 资金复用:卖后另买未持有票