fix: 策略库「重跑到今天」补齐组合分析 — 多策略组合级WF/一条龙/AI解读

策略库(/strategies)多策略组合卡片此前只有主回测:本轮把 v1.31.0
的组合分析链路延伸到多策略组合(N 策略 × 各自原标的):

- walkforward:组合 WF 泛化为槽位模型(_ComboSlot/_ComboWalkForwardBase),
  PortfolioWalkForwardEngine 行为不变;新增 MultiStrategyWalkForwardEngine
  (N 个策略各跑各自原标的,key 形如 label@symbol),复用切窗语义与
  WalkForwardResult 结构(前端 WalkForwardPanel 直接渲染)
- benchmark:新增 evaluate_multi 一条龙(MultiStrategyEngine 回测 + 多策略
  组合 WF + 逐槽位三段体检多数口径聚合 + 综合评分 + 组合评级 + 各槽位标的
  等权买入持有基准对比),报告结构与单标的 evaluate_strategy 同构
- Web:新增 POST /backtest/multi-strategy/wf/run/async 与
  /backtest/multi-strategy/evaluate/run/async;多策略组合回测响应附带
  grade/score(与单标的/多标的组合响应同构)
- 前端 StrategiesView:组合结果区新增「WF 样本外验证 / 一条龙评估 / AI 解读」
  按钮与同构面板(按需触发,复用最近一次组合回测的 items/cash);
  绩效指标表补齐 v1.28 深度 6 项(SQN/最大连胜连亏/Ulcer/VaR/CVaR);
  aiPrompt 新增 multi 模式(策略明细语境 + 槽位表现段)
- 测试:多策略 WF 引擎 3 例、evaluate_multi 3 例、新端点 Web 级 2 例、
  aiPrompt multi 模式 1 例(pytest 1611 绿、node --test 5/5、E2E 9/9)
This commit is contained in:
GitHub
2026-09-03 23:54:29 +08:00
parent 81fceb626a
commit 497ac21e5a
11 changed files with 1150 additions and 196 deletions
@@ -251,3 +251,59 @@ def test_evaluate_portfolio_serializable():
)
text = json.dumps(report, default=str)
assert "excess_return" in text
# ── evaluate_multiv1.31.1:多策略组合一条龙)────────────────────────────────
def _slots_for_multi() -> list[Any]:
from easy_tdx.backtest.multi_strategy_engine import StrategySlot
return [
StrategySlot(
label="动量", symbol="SZ:000001", strategy=_CycleTrader(), df=_df(400, drift=0.002)
),
StrategySlot(
label="反转", symbol="SH:600000", strategy=_CycleTrader(), df=_df(400, drift=0.003)
),
]
def test_evaluate_multi_full_report_structure():
"""多策略组合一条龙报告与 evaluate_portfolio 同构(前端面板可复用)。"""
from easy_tdx.backtest.benchmark import evaluate_multi
report = evaluate_multi(_slots_for_multi(), total_cash=500_000)
for key in ("performance", "score", "grade", "walkforward", "fitness", "benchmark", "config"):
assert key in report
assert "sqn" in report["performance"]
assert "max_consecutive_losses" in report["performance"]
assert report["performance"]["total_stocks"] == 2
assert 0 <= report["score"]["total"] <= 100
assert report["score"]["wf_provided"] is True
assert report["grade"]["grade"] in ("S", "A", "B", "C", "D")
assert report["grade"]["scenario"] == "portfolio"
assert len(report["walkforward"]["windows"]) > 0
assert report["fitness"]["total_checks"] == 8
assert "只标的通过" in report["fitness"]["checks"][0]["detail"]
assert "buy_hold" in report["benchmark"]
assert report["config"]["slots"] == ["动量@SZ:000001", "反转@SH:600000"]
def test_evaluate_multi_buy_hold_excess_near_zero():
"""各槽位换成买入持有后,组合收益 ≈ 等权买入持有基准(excess 近 0)。"""
from easy_tdx.backtest.benchmark import evaluate_multi
from easy_tdx.backtest.multi_strategy_engine import StrategySlot
bh_slots = [
StrategySlot(label=s.label, symbol=s.symbol, strategy=_BuyFirstBar(), df=s.df)
for s in _slots_for_multi()
]
report = evaluate_multi(bh_slots, total_cash=500_000)
assert abs(report["benchmark"]["excess_return"]) < 0.05
def test_evaluate_multi_serializable():
from easy_tdx.backtest.benchmark import evaluate_multi
report = evaluate_multi(_slots_for_multi(), total_cash=500_000, n_windows=3)
text = json.dumps(report, default=str)
assert "excess_return" in text
+51
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import json
from typing import Any
import numpy as np
import pandas as pd
@@ -131,3 +132,53 @@ class TestPortfolioWalkForward:
strategy=PeriodicStrategy, stocks=_stocks(), n_windows=0
).run()
assert wf.n_windows == 2
# ── MultiStrategyWalkForwardEnginev1.31.1:多策略组合槽位 WF)───────────────
def _slots() -> list[Any]:
from easy_tdx.backtest.multi_strategy_engine import StrategySlot
return [
StrategySlot(
label="双均线交叉",
symbol="SH:601088",
strategy=PeriodicStrategy(),
df=_make_df(400, seed=42),
),
StrategySlot(
label="RSI反转",
symbol="SZ:000001",
strategy=PeriodicStrategy(),
df=_make_df(400, seed=99),
),
]
def test_multi_strategy_wf_basic_structure() -> None:
from easy_tdx.backtest.walkforward import MultiStrategyWalkForwardEngine
wf = MultiStrategyWalkForwardEngine(strategies=_slots(), n_windows=4, total_cash=200_000).run()
assert len(wf.windows) == 4
assert wf.total_trades > 0
assert wf.total_trades == sum(w.total_trades for w in wf.windows)
# 窗口时间升序
starts = [pd.Timestamp(w.start) for w in wf.windows]
assert starts == sorted(starts)
def test_multi_strategy_wf_matches_portfolio_structure() -> None:
"""与 PortfolioWalkForwardEngine 输出同构(前端面板可复用)。"""
from easy_tdx.backtest.walkforward import MultiStrategyWalkForwardEngine
wf = MultiStrategyWalkForwardEngine(strategies=_slots(), n_windows=3).run()
d = wf.to_dict()
json.dumps(d)
assert "sqn" in d["windows"][0]["performance"]
assert "max_consecutive_wins" in d["windows"][0]["performance"]
def test_multi_strategy_wf_empty_slots() -> None:
from easy_tdx.backtest.walkforward import MultiStrategyWalkForwardEngine
wf = MultiStrategyWalkForwardEngine(strategies=[], n_windows=3).run()
assert wf.windows == []
+169
View File
@@ -11,6 +11,7 @@
from __future__ import annotations
import time
from typing import Any
import numpy as np
import pandas as pd
@@ -1196,3 +1197,171 @@ def test_portfolio_evaluate_endpoint(client, monkeypatch):
assert report["grade"]["scenario"] == "portfolio"
assert report["fitness"]["total_checks"] == 8
assert report["config"]["stocks"] == ["SZ000001", "SH600519"]
# ── 多策略组合级 WF / 一条龙评估端点(v1.31.1)────────────────────────────────
def _fake_multi_slots(n: int = 400):
"""构造 _fetch_multi_strategy_bars 的 mock 替身(两槽位合成行情)。"""
import pandas as pd
from easy_tdx.backtest.multi_strategy_engine import StrategySlot
async def fake_fetch(client_arg, items): # noqa: ANN001
slots = []
for item in items:
mkt, code = item.symbol.split(":")
close = 10 + np.cumsum(np.random.randn(n) * 0.3 + 0.02)
df = pd.DataFrame(
{
"datetime": pd.date_range("2023-01-02", periods=n, freq="B"),
"open": close - 0.1,
"high": close + 0.2,
"low": close - 0.2,
"close": close,
"vol": np.full(n, 5000.0),
"amount": close * 5000,
}
)
slots.append(
StrategySlot(label=item.strategy, symbol=item.symbol, strategy=None, df=df)
)
return slots
return fake_fetch
def _multi_request() -> dict[str, Any]:
from datetime import date as _date
start = f"{_date.today().year - 3}-01-02"
return {
"items": [
{
"strategy": "ma_cross",
"strategy_label": "双均线交叉",
"params": {"fast": 5, "slow": 20},
"symbol": "SH:601088",
"category": "DAY",
"start_date": start,
},
{
"strategy": "macd",
"strategy_label": "MACD 金叉",
"params": {},
"symbol": "SZ:000001",
"category": "DAY",
"start_date": start,
},
],
"cash": 200000,
}
def test_multi_strategy_wf_endpoint(client, monkeypatch):
"""POST /backtest/multi-strategy/wf/run/async 端到端(mock 取数 + 真实引擎)。
StrategySlot 由取数阶段绑定策略实例(_build 时替换 mock 的 None),
这里用 router 内的真实 _fetch_multi_strategy_bars 不可行(需 client),
故 fake_fetch 直接构造策略实例。
"""
import pandas as pd
import easy_tdx.web.routers.backtest as bt_router
from easy_tdx.backtest.multi_strategy_engine import StrategySlot
from easy_tdx.backtest.strategies import get_registry
async def fake_fetch(client_arg, items): # noqa: ANN001
registry = get_registry()
slots = []
for item in items:
entry = registry.get(item.strategy)
strategy = entry.build(item.params)
close = 10 + np.cumsum(np.random.randn(400) * 0.3 + 0.02)
df = pd.DataFrame(
{
"datetime": pd.date_range("2023-01-02", periods=400, freq="B"),
"open": close - 0.1,
"high": close + 0.2,
"low": close - 0.2,
"close": close,
"vol": np.full(400, 5000.0),
"amount": close * 5000,
}
)
slots.append(
StrategySlot(
label=item.strategy_label or item.strategy,
symbol=item.symbol,
strategy=strategy,
df=df,
)
)
return slots
monkeypatch.setattr(bt_router, "_fetch_multi_strategy_bars", fake_fetch)
resp = client.post(
"/api/v1/backtest/multi-strategy/wf/run/async?n_windows=3",
json=_multi_request(),
)
assert resp.status_code == 202, resp.text
final = _wait_task(client, resp.json()["task_id"])
assert final["status"] == "done", final
wf = final["result"]["walkforward"]
assert wf["n_windows"] == 3
assert len(wf["windows"]) == 3
assert "consistency" in wf
assert "sqn" in wf["windows"][0]["performance"]
def test_multi_strategy_evaluate_endpoint(client, monkeypatch):
"""POST /backtest/multi-strategy/evaluate/run/async 端到端。"""
import pandas as pd
import easy_tdx.web.routers.backtest as bt_router
from easy_tdx.backtest.multi_strategy_engine import StrategySlot
from easy_tdx.backtest.strategies import get_registry
async def fake_fetch(client_arg, items): # noqa: ANN001
registry = get_registry()
slots = []
for item in items:
entry = registry.get(item.strategy)
strategy = entry.build(item.params)
close = 10 + np.cumsum(np.random.randn(400) * 0.3 + 0.02)
df = pd.DataFrame(
{
"datetime": pd.date_range("2023-01-02", periods=400, freq="B"),
"open": close - 0.1,
"high": close + 0.2,
"low": close - 0.2,
"close": close,
"vol": np.full(400, 5000.0),
"amount": close * 5000,
}
)
slots.append(
StrategySlot(
label=item.strategy_label or item.strategy,
symbol=item.symbol,
strategy=strategy,
df=df,
)
)
return slots
monkeypatch.setattr(bt_router, "_fetch_multi_strategy_bars", fake_fetch)
resp = client.post(
"/api/v1/backtest/multi-strategy/evaluate/run/async",
json=_multi_request(),
)
assert resp.status_code == 202, resp.text
final = _wait_task(client, resp.json()["task_id"])
assert final["status"] == "done", final
report = final["result"]
for key in ("performance", "score", "grade", "walkforward", "fitness", "benchmark", "config"):
assert key in report
assert report["grade"]["scenario"] == "portfolio"
assert report["fitness"]["total_checks"] == 8
assert report["config"]["slots"] == ["双均线交叉@SH:601088", "MACD 金叉@SZ:000001"]