Files
tick-stock-panel/backend/tests/test_composite_strategy_api.py
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

231 lines
8.5 KiB
Python

"""叠加策略 API 与删除防护测试 (M2)。
覆盖:
- _render_composite_code 渲染声明式 .py 正确
- _save_composite_strategy 创建/更新/校验(前缀/嵌套/子策略不存在/重复创建)
- _strategy_detail 回显 composite_children
- delete_strategy 删除被引用子策略时 409 fail-closed
复用 test_strategy_code_save.py 的 SimpleNamespace 构造模式。
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from app.api.strategy import (
CompositeChildItem,
StrategyCompositeSaveRequest,
_render_composite_code,
_save_composite_strategy,
_strategy_detail,
)
from app.strategy.engine import StrategyEngine
def _filter_code(strategy_id: str) -> str:
meta = (
f'{{"id": "{strategy_id}", "name": "{strategy_id}", '
'"asset_types": ["stock"], "timeframes": ["1d"]}'
)
return f'''import polars as pl
META = {meta}
EXECUTION_BACKEND = "polars_expr"
def filter(df, params):
return pl.lit(True)
'''
def _setup_engine(tmp_path):
"""构造含 custom + composite 目录的 engine 和假 request。"""
custom_dir = tmp_path / "strategies" / "custom"
comp_dir = tmp_path / "strategies" / "composite"
custom_dir.mkdir(parents=True)
comp_dir.mkdir(parents=True)
engine = StrategyEngine(strategy_dirs=[custom_dir, comp_dir])
repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path))
request = SimpleNamespace(
app=SimpleNamespace(state=SimpleNamespace(repo=repo, strategy_engine=engine))
)
return engine, request, custom_dir, comp_dir
def _composite_req(sid, children, **kw):
return StrategyCompositeSaveRequest(
strategy_id=sid,
name=kw.get("name", sid),
description=kw.get("description", ""),
children=[CompositeChildItem(strategy_id=c, weight=w) for c, w in children],
merge_mode=kw.get("merge_mode", "union"),
min_confirm=kw.get("min_confirm", 0),
mode=kw.get("mode", "create"),
)
# ───────────────────────── 渲染 ─────────────────────────
def test_render_composite_code_contains_children_and_backend():
code = _render_composite_code(
"composite_blend", "我的叠加", "描述",
[{"strategy_id": "child_a", "weight": 0.4}, {"strategy_id": "child_b", "weight": 0.6}],
"union", 0,
)
assert 'EXECUTION_BACKEND = "composite"' in code
assert "composite_blend" in code
assert "child_a" in code
assert "child_b" in code
assert "0.4" in code
# ───────────────────────── 保存 ─────────────────────────
def test_save_composite_creates_and_loads(tmp_path):
engine, request, custom_dir, _ = _setup_engine(tmp_path)
(custom_dir / "child_a.py").write_text(_filter_code("child_a"), encoding="utf-8")
engine.reload()
result = _save_composite_strategy(
_composite_req("composite_blend", [("child_a", 1.0)]), request
)
assert result["ok"] is True
assert result["source"] == "composite"
assert engine.has("composite_blend")
blend = engine.get("composite_blend")
assert blend.execution_backend == "composite"
assert blend.composite.children[0].strategy_id == "child_a"
def test_save_composite_rejects_missing_composite_prefix(tmp_path):
engine, request, custom_dir, _ = _setup_engine(tmp_path)
(custom_dir / "child_a.py").write_text(_filter_code("child_a"), encoding="utf-8")
engine.reload()
with pytest.raises(ValueError, match="composite_"):
_save_composite_strategy(
_composite_req("bad_prefix", [("child_a", 1.0)]), request
)
def test_save_composite_rejects_missing_child(tmp_path):
_engine, request, _, _ = _setup_engine(tmp_path)
with pytest.raises(ValueError, match="不存在"):
_save_composite_strategy(
_composite_req("composite_blend", [("ghost", 1.0)]), request
)
def test_save_composite_rejects_nested_composite(tmp_path):
engine, request, custom_dir, _comp_dir = _setup_engine(tmp_path)
(custom_dir / "leaf.py").write_text(_filter_code("leaf"), encoding="utf-8")
engine.reload()
# 先创建一个合法 composite
_save_composite_strategy(
_composite_req("composite_inner", [("leaf", 1.0)]), request
)
with pytest.raises(ValueError, match="嵌套"):
_save_composite_strategy(
_composite_req("composite_outer", [("composite_inner", 1.0)]), request
)
def test_save_composite_update_mode(tmp_path):
engine, request, custom_dir, _ = _setup_engine(tmp_path)
(custom_dir / "child_a.py").write_text(_filter_code("child_a"), encoding="utf-8")
(custom_dir / "child_b.py").write_text(_filter_code("child_b"), encoding="utf-8")
engine.reload()
_save_composite_strategy(
_composite_req("composite_blend", [("child_a", 1.0)]), request
)
# 更新: 改子策略
result = _save_composite_strategy(
_composite_req("composite_blend", [("child_a", 0.5), ("child_b", 0.5)], mode="update"),
request,
)
assert result["ok"]
blend = engine.get("composite_blend")
assert {c.strategy_id for c in blend.composite.children} == {"child_a", "child_b"}
def test_save_composite_update_rejects_non_composite(tmp_path):
engine, request, custom_dir, _ = _setup_engine(tmp_path)
# 一个普通(polars_expr)策略, 用 composite_ 前缀以通过 id 校验进入后续检查
(custom_dir / "composite_plain.py").write_text(_filter_code("composite_plain"), encoding="utf-8")
engine.reload()
with pytest.raises(ValueError, match="不是叠加策略"):
_save_composite_strategy(
_composite_req("composite_plain", [], mode="update"), request
)
# ───────────────────────── _strategy_detail ─────────────────────────
def test_strategy_detail_shows_composite_children(tmp_path):
engine, request, custom_dir, _ = _setup_engine(tmp_path)
(custom_dir / "child_a.py").write_text(_filter_code("child_a"), encoding="utf-8")
(custom_dir / "child_b.py").write_text(_filter_code("child_b"), encoding="utf-8")
engine.reload()
_save_composite_strategy(
_composite_req("composite_blend", [("child_a", 0.4), ("child_b", 0.6)]), request
)
detail = _strategy_detail(engine.get("composite_blend"))
assert detail["execution_backend"] == "composite"
assert detail["composite_children"] is not None
children_map = {c["id"]: c["weight"] for c in detail["composite_children"]}
assert children_map == {"child_a": 0.4, "child_b": 0.6}
def test_strategy_detail_non_composite_has_null_children(tmp_path):
engine, _request, custom_dir, _ = _setup_engine(tmp_path)
(custom_dir / "child_a.py").write_text(_filter_code("child_a"), encoding="utf-8")
engine.reload()
detail = _strategy_detail(engine.get("child_a"))
assert detail["composite_children"] is None
# ───────────────────────── 删除防护 ─────────────────────────
def test_delete_referenced_child_blocked(tmp_path):
"""删除被 composite 引用的子策略 → 409 fail-closed。"""
engine, request, custom_dir, _ = _setup_engine(tmp_path)
(custom_dir / "child_a.py").write_text(_filter_code("child_a"), encoding="utf-8")
engine.reload()
_save_composite_strategy(
_composite_req("composite_blend", [("child_a", 1.0)]), request
)
# 直接调端点函数(delete_strategy 需要 request)
from app.api.strategy import delete_strategy
with pytest.raises(HTTPException) as exc_info:
delete_strategy("child_a", request)
assert exc_info.value.status_code == 409
assert "composite_blend" in exc_info.value.detail
def test_delete_composite_itself_succeeds(tmp_path):
"""删除 composite 本身(不被引用)应成功。"""
engine, request, custom_dir, _ = _setup_engine(tmp_path)
(custom_dir / "child_a.py").write_text(_filter_code("child_a"), encoding="utf-8")
engine.reload()
_save_composite_strategy(
_composite_req("composite_blend", [("child_a", 1.0)]), request
)
from app.api.strategy import delete_strategy
result = delete_strategy("composite_blend", request)
assert result["ok"] is True
assert not engine.has("composite_blend")