Files
tick-stock-panel/backend/tests/test_composite_override.py
T
shy3130 4696fef959 feat(composite): 新增叠加策略(composite)支持选股与回测
引入第四种执行后端 composite: 声明式引用多个子策略, 复用现有注册表/缓存/撮合全链路。

核心设计:
- 退出采用来源投影(每个子策略 exit 仅在自己持仓窗口生效, 不串平其他子策略仓位)
- 评分用标准化排名加权(跨子策略可比, 不依赖 per-strategy 的 min-max 量纲)
- 来源归因(entry_signal_code 标记来源子策略)

后端:
- engine.py: CompositeSpec 数据模型, 两阶段引用校验(孤儿移除/嵌套禁止/asset_types 子集/≤8 上限), _run_composite_strategy 选股执行, find_dependents 删除防护, override_loader 注入保证子策略与单独跑同口径
- composite.py(新): 选股 merge_results + 回测 merge_signal_matrices 合并器(退出投影/排名融合/归因)
- backtest/strategy.py: composite 回测分支(特征计划合并/逐子信号/统一风控/basic_filter/归因)
- api/strategy.py: POST /composite/save 端点, _strategy_detail 返回 composite_children(含 name/source), 删除依赖 409 防护
- monitor.py: 实时监控 fail-closed(回退盘后缓存)
- walkforward.py: 对 composite 显式中文报错
- worker.py: _strategy_dirs 补 composite 目录(修复回测子进程找不到 composite 的 bug)

前端:
- CompositeStrategyDialog(新): 创建/编辑弹窗(自动生成 composite_ ID, 权重归一, 类型标签, 增删子策略)
- StrategySettingsDialog: composite 设置面板(子策略增删改/归一/类型标签)
- StrategyBacktest: 回测页 composite 适配(分组/参数区/结果区展示子策略构成)
- api.ts: 类型扩展(source/backend 加 composite, composite_children 含 name/source)

测试: 36 个 composite 专项测试(加载/选股/回测退出投影/排名/归因/override 透传/API/删除防护), 端到端验证真实内置策略回测通过
2026-08-02 10:56:03 +08:00

125 lines
4.9 KiB
Python

"""叠加策略子策略 override 透传测试。
验证修复: composite 执行时子策略应加载用户保存的 override(参数/评分等),
否则 composite 内跑子策略与单独跑子策略使用不同口径(CONTRIBUTING §5.1)。
场景: 子策略 filter 用 params["min_close"] 阈值。
- 默认 min_close=0 → 全选
- override 改 min_close=100 → 只选 close>100 的标的
验证 composite(引用该子策略)在有 override_loader 时使用 override 后的阈值。
"""
from __future__ import annotations
from datetime import date
import polars as pl
from app.strategy.engine import StrategyDataContext, StrategyEngine
def _param_filter_code(strategy_id: str) -> str:
"""子策略: filter 用 params["min_close"] 阈值, 命中 close > min_close。"""
return f'''import polars as pl
META = {{
"id": "{strategy_id}",
"name": "{strategy_id}",
"asset_types": ["stock"],
"timeframes": ["1d"],
"params": [{{"id": "min_close", "type": "float", "default": 0}}],
}}
EXECUTION_BACKEND = "polars_expr"
def filter(df, params):
return pl.col("close") > params.get("min_close", 0)
'''
def _composite_code(strategy_id: str, child_id: str) -> str:
return f'''META = {{
"id": "{strategy_id}",
"name": "{strategy_id}",
"asset_types": ["stock"],
"timeframes": ["1d"],
"params": [],
"children": [{{"strategy_id": "{child_id}", "weight": 1.0}}],
}}
EXECUTION_BACKEND = "composite"
'''
def _panel() -> pl.DataFrame:
return pl.DataFrame({
"symbol": ["LOW.SZ", "HIGH.SH"],
"date": [date(2026, 1, 2)] * 2,
"close": [10.0, 200.0],
})
def test_composite_child_uses_default_params_without_loader(tmp_path):
"""无 override_loader 时, 子策略用默认参数(min_close=0 → 全选)。"""
custom_dir = tmp_path / "strategies" / "custom"
comp_dir = tmp_path / "strategies" / "composite"
custom_dir.mkdir(parents=True)
comp_dir.mkdir(parents=True)
(custom_dir / "thresh.py").write_text(_param_filter_code("thresh"), encoding="utf-8")
(comp_dir / "composite_blend.py").write_text(_composite_code("composite_blend", "thresh"), encoding="utf-8")
engine = StrategyEngine(strategy_dirs=[custom_dir, comp_dir]) # 无 override_loader
ctx = StrategyDataContext(
asset_type="stock", timeframe="1d", as_of=date(2026, 1, 2),
current=_panel(),
)
result = engine.run("composite_blend", ctx, overrides={"basic_filter": {"enabled": False}})
# 默认 min_close=0 → 两个标的都命中
assert {"LOW.SZ", "HIGH.SH"} == {r["symbol"] for r in result.rows}
def test_composite_child_uses_override_params_with_loader(tmp_path):
"""有 override_loader 时, 子策略用用户 override 的参数(min_close=100 → 只选 HIGH)。"""
custom_dir = tmp_path / "strategies" / "custom"
comp_dir = tmp_path / "strategies" / "composite"
custom_dir.mkdir(parents=True)
comp_dir.mkdir(parents=True)
(custom_dir / "thresh.py").write_text(_param_filter_code("thresh"), encoding="utf-8")
(comp_dir / "composite_blend.py").write_text(_composite_code("composite_blend", "thresh"), encoding="utf-8")
# override_loader 返回 thresh 的 override: min_close=100
overrides_store = {"thresh": {"params": {"min_close": 100}}}
engine = StrategyEngine(
strategy_dirs=[custom_dir, comp_dir],
override_loader=lambda sid: overrides_store.get(sid, {}),
)
ctx = StrategyDataContext(
asset_type="stock", timeframe="1d", as_of=date(2026, 1, 2),
current=_panel(),
)
result = engine.run("composite_blend", ctx, overrides={"basic_filter": {"enabled": False}})
# override 后 min_close=100 → 只有 HIGH.SH(close=200) 命中, LOW.SZ(close=10) 被过滤
symbols = {r["symbol"] for r in result.rows}
assert "HIGH.SH" in symbols
assert "LOW.SZ" not in symbols, "子策略 override 的 min_close=100 应过滤掉 close=10 的标的"
def test_composite_child_override_loader_failure_is_safe(tmp_path):
"""override_loader 抛异常时, composite 应安全降级(用默认参数), 不崩溃。"""
custom_dir = tmp_path / "strategies" / "custom"
comp_dir = tmp_path / "strategies" / "composite"
custom_dir.mkdir(parents=True)
comp_dir.mkdir(parents=True)
(custom_dir / "thresh.py").write_text(_param_filter_code("thresh"), encoding="utf-8")
(comp_dir / "composite_blend.py").write_text(_composite_code("composite_blend", "thresh"), encoding="utf-8")
def bad_loader(sid: str) -> dict:
raise OSError("disk error")
engine = StrategyEngine(
strategy_dirs=[custom_dir, comp_dir],
override_loader=bad_loader,
)
ctx = StrategyDataContext(
asset_type="stock", timeframe="1d", as_of=date(2026, 1, 2),
current=_panel(),
)
# loader 抛异常应被捕获, 用默认参数跑(全选), 不报错
result = engine.run("composite_blend", ctx, overrides={"basic_filter": {"enabled": False}})
assert result.total > 0