feat(shadow-desk): P1-d 影子主管+通路策略增强(用户拍板): channel_test universe分6类资产各3只(宽基/行业/跨境商品ETF/主板蓝筹/中盘/创业板,个股只主板+创业板无科创北交铁律)hold 6只每日跨类型轮换;盘中4时点场景(9:35主调仓卖全买等权/10:45部分加减仓/13:45卖后买资金复用/14:30 T+1拒单探针)每天全场景,适配15m; shadow supervisor --auto轮询paper库自动拉起/停止/重启影子账户子进程(env映射SANGUO_LIVE_+SANGUO_SHADOW_契约); 11新测试 [vps]
CI/CD / test (push) Successful in 12s
CI/CD / nas-deploy (push) Successful in 32s
CI/CD / nas-verify (push) Successful in 15s

This commit is contained in:
2026-08-14 19:14:13 +08:00
parent 2ee6043592
commit f00730482f
5 changed files with 378 additions and 95 deletions
+90 -45
View File
@@ -1,25 +1,28 @@
"""通路测试策略(ChannelTestStrategy)单元测试。
验证轮换目标集 + 卖旧买新调度 + T+1 探针,用 mock broker 记录下单调用。
验证跨类型轮换 + 每日多场景调度(主调仓/部分加减/卖后买/T+1探针),
用 mock broker 记录下单调用。坑:BrokerFacade 是 dataclass,子类方法重写会被
父类 __init__ 写入的实例属性遮蔽 → mock 必须在 super().__init__() 后实例注入。
"""
from __future__ import annotations
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:
# 注意:BrokerFacade 是 dataclass,父类 __init__ 会用字段默认值覆盖同名
# 实例属性,子类方法重写会被遮蔽 → 必须在 super().__init__() 之后
# 用实例属性注入记录函数。
self.calls: list[tuple[str, str, float]] = [] # (method, code, value)
super().__init__()
self.order_target_value = self._record_otv
self.order_target_value = self._record("otv")
self.order_value = self._record("ov")
def _record_otv(self, code: str, value: float):
self.calls.append(("otv", code, value))
return None
def _record(self, method: str):
def rec(code: str, value: float):
self.calls.append((method, code, value))
return None
return rec
class _Pos:
@@ -34,53 +37,95 @@ class _Ctx:
"total_value": cash + sum(p.value for p in positions.values())})()
def test_target_set_rotates_with_day():
s = ChannelTestStrategy(provider=None, config=ChannelTestConfig(
universe=["A", "B", "C", "D"], hold_n=2, period=1))
s._day = 0
assert set(s._target_set()) <= {"A", "B", "C", "D"}
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
t1 = s._target_set()
s._day = 2
t2 = s._target_set()
assert len(t1) == 2 and len(t2) == 2
assert t1 != t2 # 不同周期目标偏移
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(
universe=["A", "B", "C", "D"], hold_n=2, period=1, probe_t1=False))
# rotate#1 → day=1 → offset=1 → target=[B,C];当前持仓 C,D
# → 卖出 D(C 在目标内保留),对 B,C 调仓(买入)
ctx = _Ctx({"C": _Pos(1000), "D": _Pos(1000)}, cash=8000)
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 == {"D"} # 只卖非目标的 D
buys = {c for m, c, v in broker.calls if m == "otv" and v > 0}
assert buys == {"B", "C"} # 买新 B + 调仓 C
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_rotate_t1_probe_fires():
def test_partial_adjust_buys_more_and_sells_half():
broker = _MockBroker()
s = ChannelTestStrategy(provider=None, broker=broker, config=ChannelTestConfig(
universe=["A", "B"], hold_n=1, period=1, probe_t1=True))
ctx = _Ctx({}, cash=10000)
s.rotate(ctx)
# rotate#1 → target=[B];探针对 target[0]=B 再次 order_target_value(0)(当日卖,预期被 T+1 拒)
otv_calls = [c for m, c, v in broker.calls if m == "otv"]
assert otv_calls.count("B") >= 2 # 一次买入调仓 + 一次 T+1 探针
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_period_skips_off_cycle_days():
def test_swap_one_sells_then_buys():
broker = _MockBroker()
s = ChannelTestStrategy(provider=None, broker=broker, config=ChannelTestConfig(
universe=["A", "B"], hold_n=1, period=3, probe_t1=False))
ctx = _Ctx({}, cash=10000)
s.rotate(ctx) # day1 → 触发
n1 = len(broker.calls)
s.rotate(ctx) # day2 → 跳过
s.rotate(ctx) # day3 → 跳过
assert len(broker.calls) == n1
s.rotate(ctx) # day4 → 触发
assert len(broker.calls) > n1
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():
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 # 四个盘中时点全注册
+64
View File
@@ -0,0 +1,64 @@
"""影子柜台主管(supervisor)纯逻辑测试:账户筛选 + env 映射。"""
from __future__ import annotations
import json
from sanguo_trader.persistence import init_db, save_account
from sanguo_trader.shadow.supervisor import account_env, load_shadow_accounts
def _mk_account(**kw) -> dict:
base = dict(
name="shadow-acc", mode="shadow", strategy_type="portfolio",
interval="15m", symbols=["hs300_subset"],
strategies=[{"name": "channel_test", "params": {"max_pool": 6,
"benchmark": "000905.XSHG"}}],
initial_capital=500_000, rate=0.00025, stamp_duty_rate=0.001,
min_commission=5, slippage=0.002,
start="2026-08-14", end="2026-12-31", engine="shadow",
)
base.update(kw)
return base
def test_load_shadow_accounts_filters(tmp_path):
db = str(tmp_path / "p.db")
init_db(db)
from sanguo_trader.persistence import update_account_status
a_shadow = save_account(db, _mk_account()) # 应选中
update_account_status(db, a_shadow, "running") # 创建后 API 置 running
a_stopped = save_account(db, _mk_account(name="stopped", strategies=[
{"name": "x", "params": {}}]))
update_account_status(db, a_stopped, "stopped") # 停止 → 不选
a_live = save_account(db, _mk_account(name="live", mode="live")) # 实走 → 不选
_ = a_live
accounts = load_shadow_accounts(db)
assert [a["id"] for a in accounts] == [a_shadow]
def test_account_env_mapping(tmp_path):
db = str(tmp_path / "p.db")
init_db(db)
aid = save_account(db, _mk_account())
from sanguo_trader.persistence import update_account_status
update_account_status(db, aid, "running")
acc = load_shadow_accounts(db)[0]
env = account_env(acc, db)
assert env["SANGUO_LIVE_STRATEGY"] == "channel_test"
assert env["SANGUO_LIVE_MAX_POOL"] == "6"
assert env["SANGUO_LIVE_BENCHMARK"] == "000905.XSHG"
assert env["SANGUO_LIVE_CASH"] == "500000"
assert env["SANGUO_SHADOW_DB"] == db
assert env["SANGUO_SHADOW_ACCOUNT_ID"] == str(aid)
assert env["SANGUO_SHADOW_COMMISSION"] == "0.00025"
assert env["SANGUO_SHADOW_SLIPPAGE"] == "0.002"
def test_account_env_defaults_on_sparse_row():
acc = {"id": 9, "strategies": json.dumps([]),
"initial_capital": None, "rate": None}
env = account_env(acc, "db")
assert env["SANGUO_LIVE_STRATEGY"] == "all_weather"
assert env["SANGUO_LIVE_CASH"] == "1000000"
assert env["SANGUO_SHADOW_ACCOUNT_ID"] == "9"