feat(backtest): 参数网格寻优(optimizer + 前端寻优页)

对单个策略的 1-2 个参数做网格搜索,遍历用户指定的取值列表笛卡尔积,
每个组合跑一次回测,按 total_return 排序,返回排名表 + 热力图。

后端:
- ParamGridOptimizer(backtest/optimizer.py):itertools.product 遍历网格,
  每点 entry.build(params) + BacktestEngine.run(df),复用同一 DataFrame
- 网格大小上限 200 防组合爆炸,单点失败容错(跳过不中断)
- 2 参数时生成热力图矩阵(x/y 轴取值 + cell 收益率)
- POST /backtest/optimize/run/async 端点(后台任务)
- OptimizeBacktestRequest schema(param_grid 1-2 参数)

前端(/optimize 寻优页):
- ParamGridPicker:勾选 1-2 个寻优参数,逗号分隔填取值列表
- OptimizeResultTable:网格点排名表(按收益降序,最优高亮)
- OptimizeHeatmap:2 参数热力图(ECharts heatmap,绿→红映射收益)
- 最优点「查看」按钮跳转单标的页用该参数回测

测试:821 passed(+10 寻优器单测 + 3 寻优路由测试)
This commit is contained in:
Justin Gu
2026-07-03 03:55:35 +08:00
parent 6aab7a82b5
commit 87fafe9131
15 changed files with 1284 additions and 6 deletions
+90
View File
@@ -807,3 +807,93 @@ def test_portfolio_backtest_bad_strategy(client, monkeypatch):
break
time.sleep(0.05)
assert final["status"] == "failed"
# ---------------------------------------------------------------------------
# Phase 4: 参数网格寻优路由
# ---------------------------------------------------------------------------
def test_optimize_request_validation():
"""寻优请求校验:param_grid 非空、至少 1 个数据源。"""
from easy_tdx.web.backtest_schemas import OptimizeBacktestRequest
one_bar = [
{
"datetime": "2024-01-01",
"open": 1,
"high": 1,
"low": 1,
"close": 1,
"vol": 1,
"amount": 1,
}
]
req = OptimizeBacktestRequest(
strategy="ma_cross",
param_grid={"fast": [5, 10], "slow": [20, 30]},
ohlcv=one_bar,
)
assert len(req.param_grid) == 2
with pytest.raises(ValueError):
OptimizeBacktestRequest(strategy="ma_cross", param_grid={"fast": [5]}) # 缺数据源
with pytest.raises(ValueError):
OptimizeBacktestRequest( # param_grid > 2 参数
strategy="ma_cross",
param_grid={"a": [1], "b": [2], "c": [3]},
ohlcv=one_bar,
)
def test_optimize_endpoint(client, sample_ohlcv):
"""POST /backtest/optimize/run/async 端到端(内联数据)。"""
resp = client.post(
"/api/v1/backtest/optimize/run/async",
json={
"strategy": "ma_cross",
"param_grid": {"fast": [5, 10], "slow": [20, 30]},
"cash": 100000,
"ohlcv": sample_ohlcv,
},
)
assert resp.status_code == 202, resp.text
task_id = resp.json()["task_id"]
final = None
for _ in range(200):
poll = client.get(f"/api/v1/backtest/tasks/{task_id}")
final = poll.json()
if final["status"] in ("done", "failed"):
break
time.sleep(0.05)
assert final["status"] == "done", final
result = final["result"]
assert result["strategy"] == "ma_cross"
assert result["param_names"] == ["fast", "slow"]
assert len(result["results"]) == 4
assert result["best"] is not None
assert result["heatmap"] is not None
assert len(result["heatmap"]["data"]) == 4
def test_optimize_single_param_no_heatmap(client, sample_ohlcv):
"""单参数寻优不应返回热力图。"""
resp = client.post(
"/api/v1/backtest/optimize/run/async",
json={
"strategy": "rsi_reversal",
"param_grid": {"n": [7, 14, 21]},
"ohlcv": sample_ohlcv,
},
)
assert resp.status_code == 202
task_id = resp.json()["task_id"]
for _ in range(200):
poll = client.get(f"/api/v1/backtest/tasks/{task_id}")
final = poll.json()
if final["status"] in ("done", "failed"):
break
time.sleep(0.05)
assert final["status"] == "done"
assert final["result"]["heatmap"] is None