mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 15:44:18 +08:00
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:
@@ -251,3 +251,59 @@ def test_evaluate_portfolio_serializable():
|
||||
)
|
||||
text = json.dumps(report, default=str)
|
||||
assert "excess_return" in text
|
||||
|
||||
|
||||
# ── evaluate_multi(v1.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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ── MultiStrategyWalkForwardEngine(v1.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 == []
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user