mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 21:24:16 +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/删除防护), 端到端验证真实内置策略回测通过
171 lines
6.9 KiB
Python
171 lines
6.9 KiB
Python
"""叠加策略回测端到端集成测试 (M2 验证)。
|
|
|
|
用真实的内置 matrix_native 子策略(ma_golden_cross + macd_golden) + 合成行情 panel,
|
|
完整跑通 StrategyBacktestService.run() 的 composite 分支:
|
|
特征计划合并 → 超集矩阵加载 → 逐子策略信号 → 合并(退出投影) → 撮合 → 结果归因。
|
|
|
|
验证两种模式(position/full)和两种合并模式(union/intersect)的关键路径。
|
|
合成数据用上涨趋势制造金叉信号。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, timedelta
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import polars as pl
|
|
import pytest
|
|
|
|
from app.backtest.engine import BacktestEngine
|
|
from app.backtest.matrix import build_market_data_matrix
|
|
from app.backtest.strategy import StrategyBacktestConfig, StrategyBacktestService
|
|
from app.strategy.engine import StrategyEngine
|
|
|
|
|
|
def _synthetic_panel() -> pl.DataFrame:
|
|
"""构造上涨趋势的合成 panel: 前 50 天横盘, 后 50 天上涨, 制造金叉信号。"""
|
|
np.random.seed(42)
|
|
n_days = 100
|
|
symbols = ["000001.SZ", "000002.SZ", "600000.SH"]
|
|
rows = []
|
|
for sym_idx, sym in enumerate(symbols):
|
|
base = 10.0 + sym_idx * 5
|
|
for t in range(n_days):
|
|
d = date(2025, 1, 2) + timedelta(days=int(t * 1.5))
|
|
if t < 50:
|
|
close = base + np.random.uniform(-0.3, 0.3)
|
|
else:
|
|
close = base * (1 + (t - 50) * 0.015) + np.random.uniform(-0.3, 0.3)
|
|
open_p = close + np.random.uniform(-0.2, 0.2)
|
|
high = max(open_p, close) + np.random.uniform(0.1, 0.5)
|
|
low = min(open_p, close) - np.random.uniform(0.1, 0.5)
|
|
vol = float(np.random.uniform(1e6, 5e6))
|
|
rows.append({
|
|
"symbol": sym, "date": d, "open": open_p, "high": high,
|
|
"low": low, "close": close, "volume": vol,
|
|
"raw_close": close, "raw_high": high, "raw_low": low,
|
|
})
|
|
return pl.DataFrame(rows).sort(["symbol", "date"])
|
|
|
|
|
|
class _RepoStub:
|
|
def get_index_daily(self, *args, **kwargs) -> pl.DataFrame:
|
|
return pl.DataFrame()
|
|
|
|
|
|
def _make_service(panel: pl.DataFrame, builtin: Path, comp_dir: Path):
|
|
"""构造 StrategyBacktestService, 用 monkeypatch 的数据加载。"""
|
|
bt_engine = BacktestEngine(_RepoStub())
|
|
|
|
def _load_mdm(self, symbols, start, end, feature_plan, asset_type="stock", **kw):
|
|
df = panel.filter((pl.col("date") >= start) & (pl.col("date") <= end))
|
|
if symbols:
|
|
df = df.filter(pl.col("symbol").is_in(symbols))
|
|
cols = [c for c in sorted(set(feature_plan.base_columns)) if c in df.columns]
|
|
return build_market_data_matrix(df.select(cols), field_columns=feature_plan.matrix_columns)
|
|
|
|
bt_engine.load_market_data_matrix_for_backtest = _load_mdm.__get__(bt_engine)
|
|
strategy_engine = StrategyEngine(strategy_dirs=[builtin, comp_dir])
|
|
return StrategyBacktestService(bt_engine, strategy_engine)
|
|
|
|
|
|
def _write_composite(
|
|
comp_dir: Path, sid: str, children: list, *, merge_mode="union", min_confirm=0
|
|
):
|
|
children_repr = ", ".join(
|
|
f'{{"strategy_id": "{c}", "weight": {w}}}' for c, w in children
|
|
)
|
|
opts = '["union", "intersect"]'
|
|
params_block = (
|
|
f'{{"id": "merge_mode", "type": "select", '
|
|
f'"options": {opts}, "default": "{merge_mode}"}}, '
|
|
f'{{"id": "min_confirm", "type": "int", "default": {min_confirm}}}'
|
|
)
|
|
(comp_dir / f"{sid}.py").write_text(
|
|
f'''META = {{"id": "{sid}", "name": "{sid}", "asset_types": ["stock"], "timeframes": ["1d"],
|
|
"params": [{params_block}], "children": [{children_repr}]}}
|
|
EXECUTION_BACKEND = "composite"
|
|
''',
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def composite_setup(tmp_path):
|
|
builtin = Path(__file__).resolve().parents[2] / "app" / "strategy" / "builtin"
|
|
comp_dir = tmp_path / "composite"
|
|
comp_dir.mkdir(parents=True)
|
|
_write_composite(comp_dir, "custom_e2e_blend", [("ma_golden_cross", 0.5), ("macd_golden", 0.5)])
|
|
panel = _synthetic_panel()
|
|
svc = _make_service(panel, builtin, comp_dir)
|
|
return svc, comp_dir
|
|
|
|
|
|
def test_composite_backtest_position_mode(composite_setup):
|
|
"""position 模式: composite 回测产出交易、净值曲线和归因。"""
|
|
svc, _ = composite_setup
|
|
cfg = StrategyBacktestConfig(
|
|
strategy_id="custom_e2e_blend", symbols=None,
|
|
start=date(2025, 2, 1), end=date(2025, 5, 1),
|
|
mode="position", max_positions=5, holding_days=10,
|
|
overrides={"basic_filter": {"enabled": False}},
|
|
)
|
|
result = svc.run(cfg)
|
|
assert result.error is None, f"回测失败: {result.error}"
|
|
assert len(result.trades) > 0, "应产出交易"
|
|
assert len(result.equity_curve) > 0, "应有净值曲线"
|
|
assert result.strategy_info is not None
|
|
children = result.strategy_info.get("composite_children")
|
|
assert children is not None and len(children) == 2
|
|
assert {c["id"] for c in children} == {"ma_golden_cross", "macd_golden"}
|
|
|
|
|
|
def test_composite_backtest_full_mode(composite_setup):
|
|
"""full 模式(独立候选): composite 回测能跑通。"""
|
|
svc, _ = composite_setup
|
|
cfg = StrategyBacktestConfig(
|
|
strategy_id="custom_e2e_blend", symbols=None,
|
|
start=date(2025, 2, 1), end=date(2025, 5, 1),
|
|
mode="full", holding_days=10,
|
|
overrides={"basic_filter": {"enabled": False}},
|
|
)
|
|
result = svc.run(cfg)
|
|
assert result.error is None, f"full 模式失败: {result.error}"
|
|
assert len(result.trades) > 0
|
|
|
|
|
|
def test_composite_backtest_intersect_min1_equals_union(composite_setup):
|
|
"""intersect min_confirm=1 应等价于 union(都产出交易)。"""
|
|
svc, comp_dir = composite_setup
|
|
_write_composite(
|
|
comp_dir, "custom_e2e_blend",
|
|
[("ma_golden_cross", 0.5), ("macd_golden", 0.5)],
|
|
merge_mode="intersect", min_confirm=1,
|
|
)
|
|
svc.strategy_engine.reload()
|
|
cfg = StrategyBacktestConfig(
|
|
strategy_id="custom_e2e_blend", symbols=None,
|
|
start=date(2025, 2, 1), end=date(2025, 5, 1),
|
|
mode="position", max_positions=5, holding_days=10,
|
|
overrides={"basic_filter": {"enabled": False}},
|
|
)
|
|
result = svc.run(cfg)
|
|
assert result.error is None, f"intersect 模式失败: {result.error}"
|
|
assert len(result.trades) > 0, "min_confirm=1 应产出交易(等价 union)"
|
|
|
|
|
|
def test_worker_strategy_dirs_includes_composite(tmp_path):
|
|
"""回归: 回测 worker 子进程重建引擎时必须扫描 composite 目录。
|
|
|
|
缺陷历史: worker._strategy_dirs 曾遗漏 composite 目录, 导致回测子进程
|
|
找不到 composite 策略(报 unknown strategy)。main.py 与 worker.py 必须一致。
|
|
"""
|
|
from app.backtest.worker import _strategy_dirs
|
|
|
|
dirs = _strategy_dirs(tmp_path)
|
|
dir_names = [d.name for d in dirs]
|
|
assert "builtin" in dir_names
|
|
assert "composite" in dir_names, f"worker._strategy_dirs 缺 composite 目录: {dir_names}"
|
|
assert "custom" in dir_names
|
|
assert "ai" in dir_names
|