feat(backtest): 成交价精简 + 一键寻优全策略 + 预设网格

- 成交价精简为 next_open/next_close,删除 this_close/worst/best 三种
  非真实模式(this_close 有未来函数偏差,worst/best 为压力测试边界)
- 初始资金默认统一为 1,000,000(原 10万/20万)
- 新增 presets.py:18 策略各配 1-2 参数的预设寻优网格(笛卡尔积≤200)
- registry.to_schema() 返回 preset_grid 字段供前端自动填充
- 新增 POST /backtest/optimize-all/run/async:逐策略预设网格寻优 +
  全局排名(OptimizeAllResult:ranking/best/per_strategy/total_grid_points)
- 新增 optimize-all 端到端单测 2 例(838 单测全绿)
This commit is contained in:
Justin Gu
2026-07-04 00:08:38 +08:00
parent c15bd8232f
commit f6ae69845d
10 changed files with 346 additions and 130 deletions
+2 -2
View File
@@ -32,7 +32,7 @@ import click
@click.option(
"--execution",
default="next_open",
type=click.Choice(["next_open", "next_close", "this_close", "worst", "best"]),
type=click.Choice(["next_open", "next_close"]),
help="成交价规则",
)
@click.option("--period", default="DAILY", help="K线周期")
@@ -298,7 +298,7 @@ def _print_table(result: Any) -> None:
@click.option(
"--execution",
default="next_open",
type=click.Choice(["next_open", "next_close", "this_close", "worst", "best"]),
type=click.Choice(["next_open", "next_close"]),
help="成交价规则",
)
@click.option("--period", default="DAILY", help="K线周期")
+1 -4
View File
@@ -73,7 +73,7 @@ class BacktestEngine:
min_commission: Minimum commission per trade
stamp_tax: Stamp tax rate (for sells)
slippage: Slippage rate
execution: Execution mode ('next_open', 'this_close')
execution: Execution mode ('next_open', 'next_close')
position_mode: Position mode ('full', 'long_only', 'short_only')
reject_policy: Reject policy ('reduce', 'reject')
benchmark: Benchmark data for performance comparison
@@ -135,7 +135,6 @@ class BacktestEngine:
if self._execution_model is not None:
# ExecutionModel path
trades = self._execute_with_model(signals, df)
future_leak = False
else:
# OrderSimulator path (default)
simulator = OrderSimulator(
@@ -154,7 +153,6 @@ class BacktestEngine:
cash=self._cash,
position=0.0,
)
future_leak = simulator.future_leak_warning
# Step 3: Portfolio tracking
trades = self._compute_pnls(trades)
@@ -176,7 +174,6 @@ class BacktestEngine:
"execution": self._execution,
"position_mode": self._position_mode,
"reject_policy": self._reject_policy,
"future_leak_warning": future_leak,
}
return BacktestResult(
+7 -20
View File
@@ -26,14 +26,13 @@ class OrderSimulator:
Attributes:
df: K线数据 DataFrame
execution: 成交价规则 (next_open/next_close/this_close/worst/best)
execution: 成交价规则 (next_open/next_close)
position_mode: 仓位模式 (full/fixed/percent)
reject_policy: 拒绝策略 (reduce/skip)
commission: 佣金费率
min_commission: 最低佣金
stamp_tax: 印花税率(仅卖出)
slippage: 滑点(每股)
future_leak_warning: 是否使用了未来数据(this_close 模式)
"""
df: pd.DataFrame
@@ -45,7 +44,6 @@ class OrderSimulator:
stamp_tax: float = 0.001
slippage: float = 0.0
slippage_model: SlippageModel | None = None
future_leak_warning: bool = False
def simulate(
self,
@@ -195,26 +193,23 @@ class OrderSimulator:
def _resolve_exec_index(self, bar_idx: int) -> int | None:
"""根据执行模式确定成交的 K 线索引。
开盘价 / 收盘价模式均在信号后一根 K 线成交(next_open 取次根开盘价,
next_close 取次根收盘价),避免使用信号当根的未完成/未来数据。
Args:
bar_idx: 信号对应的 K 线索引
Returns:
成交 K 线索引
"""
if self.execution == "this_close":
# 当信号 K 线收盘时成交
self.future_leak_warning = True
return bar_idx
else:
# 其他模式在下一根 K 线成交
return bar_idx + 1
return bar_idx + 1
def _get_price(self, exec_idx: int, direction: str) -> float | None:
"""根据执行模式和方向获取成交价。
"""根据执行模式获取成交价。
Args:
exec_idx: 成交 K 线索引
direction: 交易方向
direction: 交易方向(仅作保留,当前两种模式均不依赖方向)
Returns:
成交价格
@@ -228,14 +223,6 @@ class OrderSimulator:
return float(row["open"])
elif self.execution == "next_close":
return float(row["close"])
elif self.execution == "this_close":
return float(row["close"])
elif self.execution == "worst":
# 买入取最高价,卖出取最低价
return float(row["high"]) if direction == "BUY" else float(row["low"])
elif self.execution == "best":
# 买入取最低价,卖出取最高价
return float(row["low"]) if direction == "BUY" else float(row["high"])
else:
return None
+101
View File
@@ -0,0 +1,101 @@
"""策略参数寻优预设网格。
每个内置策略 1-2 个关键参数的合理取值列表,供:
1. Web UI 参数寻优页(``ParamGridPicker``)切换策略时自动填充——用户开箱即用,
仍可编辑/取消勾选。
2. 「一键寻优所有策略」(``/backtest/optimize-all/run/async``)逐策略做网格
寻优再全局排名。
约束:单策略笛卡尔积 ≤ 200``ParamGridOptimizer.MAX_GRID_POINTS``)。行尾注释
标注该策略的网格点数。修改本文件即同步生效,无需重启策略注册表。
新增策略时:在 :data:`STRATEGY_PRESETS` 增加同名 key 即可;未登记的策略在
「一键寻优」中会被跳过并记录 warning。
"""
from __future__ import annotations
from typing import Any
# 策略名 → {参数名: 取值列表}
STRATEGY_PRESETS: dict[str, dict[str, list[Any]]] = {
# ── 均线类 ──────────────────────────────────────────────────────────────
"ma_cross": {
# 快线 < 慢线才有意义;笛卡尔积去重快≥慢的无效组合后约 30+
"fast": [5, 10, 15, 20, 30, 60],
"slow": [10, 20, 30, 60, 120, 250],
}, # 36
"ema_cross": {
"fast": [5, 10, 12, 20],
"slow": [20, 26, 30, 60],
}, # 16
"triple_ma": {
"short": [5, 10, 15],
"long": [60, 120, 250],
}, # 9mid 用默认 20
"bbi": {
"m1": [3, 6],
"m4": [20, 30, 50],
}, # 6
# ── 通道/突破类 ─────────────────────────────────────────────────────────
"boll_breakout": {
"n": [10, 15, 20, 30],
"p": [1.5, 2.0, 2.5, 3.0],
}, # 16
"donchian": {
"n": [10, 15, 20, 30, 40, 55],
}, # 6
"keltner": {
"n": [10, 15, 20, 30],
"m": [7, 10, 14, 20],
}, # 16
"atr_breakout": {
"n_ma": [10, 20],
"k": [1.5, 2.0, 2.5, 3.0],
}, # 8
# ── 振荡/反转类 ─────────────────────────────────────────────────────────
"macd": {
"short": [8, 10, 12, 15],
"long": [20, 26, 30, 40],
}, # 16
"rsi_reversal": {
"n": [7, 14, 21],
"oversold": [20, 25, 30],
}, # 9
"kdj_cross": {
"n": [5, 9, 14, 18, 21],
}, # 5
"cci": {
"n": [14, 20],
"oversold": [-150, -100, -50],
}, # 6
"wr_reversal": {
"n": [7, 14],
"oversold": [-90, -80, -70],
}, # 6
"bias_reversal": {
"n": [6, 12],
"threshold": [3.0, 5.0, 7.0, 10.0],
}, # 8
# ── 趋向/平滑类 ─────────────────────────────────────────────────────────
"dmi": {
"m1": [14, 20],
"m2": [6, 10, 14],
}, # 6
"trix": {
"m1": [12, 18],
"m2": [12, 20, 30],
}, # 6
"emv": {
"n": [10, 14, 20, 28],
}, # 4
"dpo": {
"m1": [15, 20, 30],
}, # 3
}
def get_preset(strategy_name: str) -> dict[str, list[Any]]:
"""返回某策略的预设网格;未登记返回空 dict。"""
return STRATEGY_PRESETS.get(strategy_name, {})
@@ -225,11 +225,15 @@ class RegisteredStrategy:
def to_schema(self) -> dict[str, Any]:
"""序列化为 JSON 兼容的策略描述(供前端策略下拉框 + 参数表单)。"""
# 延迟导入避免 presets ↔ registry 循环依赖
from easy_tdx.backtest.strategies.presets import get_preset
return {
"name": self.name,
"label": self.label,
"description": self.description,
"params": [p.to_schema() for p in self.params],
"preset_grid": get_preset(self.name),
}
def build(
+66 -10
View File
@@ -17,6 +17,9 @@ __all__ = [
"StrategySchemaResponse",
"TaskSubmitResponse",
"TaskStateResponse",
"OptimizeAllBacktestRequest",
"OptimizeAllResult",
"OptimizeAllRankEntry",
"serialize_result",
]
@@ -35,12 +38,12 @@ class BacktestRequest(BaseModel):
strategy: str = Field(..., description="策略名(见 /backtest/strategies")
params: dict[str, Any] = Field(default_factory=dict, description="策略参数")
cash: float = Field(default=100000.0, gt=0, description="初始资金")
cash: float = Field(default=1_000_000.0, gt=0, description="初始资金")
commission: float = Field(default=0.0003, ge=0, le=0.01, description="佣金费率")
min_commission: float = Field(default=5.0, ge=0, description="单笔最低佣金")
stamp_tax: float = Field(default=0.001, ge=0, le=0.01, description="印花税(卖出)")
slippage: float = Field(default=0.0, ge=0, le=0.05, description="滑点费率")
execution: Literal["next_open", "next_close", "this_close", "worst", "best"] = Field(
execution: Literal["next_open", "next_close"] = Field(
default="next_open", description="成交模式"
)
@@ -78,14 +81,12 @@ class PortfolioBacktestRequest(BaseModel):
strategy: str = Field(..., description="策略名")
params: dict[str, Any] = Field(default_factory=dict, description="策略参数")
cash: float = Field(default=200000.0, gt=0, description="组合总资金")
cash: float = Field(default=1_000_000.0, gt=0, description="组合总资金")
commission: float = Field(default=0.0003, ge=0, le=0.01)
min_commission: float = Field(default=5.0, ge=0)
stamp_tax: float = Field(default=0.001, ge=0, le=0.01)
slippage: float = Field(default=0.0, ge=0, le=0.05)
execution: Literal["next_open", "next_close", "this_close", "worst", "best"] = Field(
default="next_open"
)
execution: Literal["next_open", "next_close"] = Field(default="next_open")
stocks: list[str] = Field(
...,
min_length=1,
@@ -115,12 +116,10 @@ class OptimizeBacktestRequest(BaseModel):
"""
strategy: str = Field(..., description="策略名")
cash: float = Field(default=100000.0, gt=0)
cash: float = Field(default=1_000_000.0, gt=0)
commission: float = Field(default=0.0003, ge=0, le=0.01)
slippage: float = Field(default=0.0, ge=0, le=0.05)
execution: Literal["next_open", "next_close", "this_close", "worst", "best"] = Field(
default="next_open"
)
execution: Literal["next_open", "next_close"] = Field(default="next_open")
param_grid: dict[str, list[int | float | str]] = Field(
...,
min_length=1,
@@ -147,6 +146,39 @@ class OptimizeBacktestRequest(BaseModel):
return self
class OptimizeAllBacktestRequest(BaseModel):
"""一键寻优所有策略请求。
在单个标的上,对所有策略的预设参数网格(见
``easy_tdx.backtest.strategies.presets.STRATEGY_PRESETS``)依次做网格寻优,
取各策略最优点汇总成全局排名,找出最佳策略 + 参数组合。数据来源与单标的
回测一致(ohlcv 内联或 symbol 取行情)。
"""
cash: float = Field(default=1_000_000.0, gt=0)
commission: float = Field(default=0.0003, ge=0, le=0.01)
slippage: float = Field(default=0.0, ge=0, le=0.05)
execution: Literal["next_open", "next_close"] = Field(default="next_open")
# 数据来源 A:内联 OHLCV
ohlcv: list[dict[str, Any]] | None = Field(default=None, max_length=2000)
# 数据来源 B:按标的取行情
symbol: str | None = Field(default=None, pattern=r"^(SZ|SH|BJ):\d{6}$")
category: Literal["DAY", "WEEK", "MONTH", "MIN_5", "MIN_15", "MIN_30", "MIN_60"] = Field(
default="DAY"
)
count: int = Field(default=250, ge=20, le=800)
start_date: str | None = Field(default=None)
end_date: str | None = Field(default=None)
@model_validator(mode="after")
def _check_data_source(self) -> OptimizeAllBacktestRequest:
if self.ohlcv is None and self.symbol is None:
raise ValueError("必须提供 ohlcv 或 symbol 之一")
return self
# ── 响应模型 ───────────────────────────────────────────────────────────────────
@@ -202,6 +234,30 @@ class TaskListResponse(BaseModel):
count: int
class OptimizeAllRankEntry(BaseModel):
"""一键寻优全局排名单行:某策略的最优点摘要。"""
strategy: str
strategy_label: str
params: dict[str, Any]
total_return: float = 0.0
sharpe: float = 0.0
max_drawdown: float = 0.0
total_trades: int = 0
win_rate: float = 0.0
profit_factor: float = 0.0
grid_points: int = 0 # 该策略本轮寻优的网格点数
class OptimizeAllResult(BaseModel):
"""一键寻优所有策略的结果:全局排名 + 最佳 + 各策略最优点。"""
ranking: list[OptimizeAllRankEntry] # 按 total_return 降序
best: OptimizeAllRankEntry | None = None
per_strategy: dict[str, OptimizeAllRankEntry] = {} # 策略名 → 最优点
total_grid_points: int = 0 # 所有策略网格点合计
# ── 结果序列化 ─────────────────────────────────────────────────────────────────
+114
View File
@@ -19,6 +19,9 @@ from fastapi import APIRouter, Depends
from easy_tdx.web.backtest_schemas import (
BacktestRequest,
BacktestResultResponse,
OptimizeAllBacktestRequest,
OptimizeAllRankEntry,
OptimizeAllResult,
OptimizeBacktestRequest,
PortfolioBacktestRequest,
StrategySchemaResponse,
@@ -222,6 +225,47 @@ async def run_optimize_async(
return TaskSubmitResponse(task_id=task_id, status=status)
# ── 一键寻优所有策略 ───────────────────────────────────────────────────────────
@router.post("/backtest/optimize-all/run/async", response_model=TaskSubmitResponse, status_code=202)
async def run_optimize_all_async(
req: OptimizeAllBacktestRequest,
client: Any = Depends(get_client),
) -> TaskSubmitResponse:
"""提交「一键寻优所有策略」后台任务。
在单个标的上,对所有策略的预设参数网格(见 presets.STRATEGY_PRESETS)依次
做网格寻优,取各策略最优点汇总成全局排名。数据获取支持内联 ohlcv 或按
symbol 取行情。通过 GET /backtest/tasks/{task_id} 轮询结果。
"""
# 1. 取数据
if req.ohlcv is not None:
df = _ohlcv_to_df(req.ohlcv)
desc_bars = f"{len(df)}"
elif req.symbol is not None:
df = await _fetch_bars(client, req.symbol, req.category, 800)
desc_bars = f"{req.symbol}"
if req.start_date or req.end_date:
df = _filter_df_by_date(df, req.start_date, req.end_date)
else:
raise ValueError("必须提供 ohlcv 或 symbol")
# 2. 捕获快照
snapshot = req.model_copy()
description = f"一键寻优全部策略 | {desc_bars}"
# 3. 提交后台任务
runner = get_runner()
task_id = runner.submit(
lambda: _run_optimize_all(df, snapshot),
description=description,
)
state = runner.get(task_id)
status: Any = state.status if state.status in ("pending", "running") else "running"
return TaskSubmitResponse(task_id=task_id, status=status)
# ── 内部实现 ───────────────────────────────────────────────────────────────────
@@ -399,6 +443,76 @@ def _run_optimize(df: pd.DataFrame, req: OptimizeBacktestRequest) -> dict[str, A
return result.to_dict()
def _run_optimize_all(df: pd.DataFrame, req: OptimizeAllBacktestRequest) -> dict[str, Any]:
"""对所有策略的预设网格逐策略寻优,汇总成全局排名(后台线程内调用)。
遍历 ``STRATEGY_PRESETS`` 中每个策略,用其预设参数网格跑
:class:`ParamGridOptimizer`,取各策略的最优点(best)组装排名。单个策略
无有效结果(如全网格回测失败)则跳过。
"""
from easy_tdx.backtest.optimizer import ParamGridOptimizer
from easy_tdx.backtest.strategies import get_registry
from easy_tdx.backtest.strategies.presets import STRATEGY_PRESETS
registry = get_registry()
ranking: list[OptimizeAllRankEntry] = []
per_strategy: dict[str, OptimizeAllRankEntry] = {}
total_grid = 0
for strategy_name, grid in STRATEGY_PRESETS.items():
# 预设里登记但未注册的策略跳过(理论上不应发生)
if strategy_name not in registry.names():
continue
label = registry.get(strategy_name).label
try:
optimizer = ParamGridOptimizer(
strategy_name=strategy_name,
param_grid=grid,
df=df,
cash=req.cash,
commission=req.commission,
slippage=req.slippage,
execution=req.execution,
)
except ValueError:
# 单策略网格超限(不应发生,预设已控制规模)→ 跳过
continue
result = optimizer.run()
if result.best is None:
continue
# 该策略本轮真实跑的网格点数(笛卡尔积,去掉失败点后的有效点)
total_grid += len(result.results)
entry = OptimizeAllRankEntry(
strategy=strategy_name,
strategy_label=label,
params=result.best.params,
total_return=result.best.total_return,
sharpe=result.best.sharpe,
max_drawdown=result.best.max_drawdown,
total_trades=result.best.total_trades,
win_rate=result.best.win_rate,
profit_factor=result.best.profit_factor,
grid_points=len(result.results),
)
ranking.append(entry)
per_strategy[strategy_name] = entry
# 按 total_return 降序
ranking.sort(key=lambda r: r.total_return, reverse=True)
best = ranking[0] if ranking else None
result_obj = OptimizeAllResult(
ranking=ranking,
best=best,
per_strategy=per_strategy,
total_grid_points=total_grid,
)
return result_obj.model_dump()
def _filter_df_by_date(df: pd.DataFrame, start: str | None, end: str | None) -> pd.DataFrame:
"""按日期范围过滤 DataFrame(闭区间,比较 YYYY-MM-DD)。"""
if not start and not end: