mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 19:04:15 +08:00
引入第四种执行后端 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/删除防护), 端到端验证真实内置策略回测通过
231 lines
8.5 KiB
Python
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")
|