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]
This commit is contained in:
@@ -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 # 四个盘中时点全注册
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user