diff --git a/src/easy_tdx/backtest/cli.py b/src/easy_tdx/backtest/cli.py index 1d59d76..177a2cc 100644 --- a/src/easy_tdx/backtest/cli.py +++ b/src/easy_tdx/backtest/cli.py @@ -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线周期") diff --git a/src/easy_tdx/backtest/engine.py b/src/easy_tdx/backtest/engine.py index d7d9a10..a6e36ce 100644 --- a/src/easy_tdx/backtest/engine.py +++ b/src/easy_tdx/backtest/engine.py @@ -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( diff --git a/src/easy_tdx/backtest/orders.py b/src/easy_tdx/backtest/orders.py index cdf449f..05c45b8 100644 --- a/src/easy_tdx/backtest/orders.py +++ b/src/easy_tdx/backtest/orders.py @@ -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 diff --git a/src/easy_tdx/backtest/strategies/presets.py b/src/easy_tdx/backtest/strategies/presets.py new file mode 100644 index 0000000..e70eaad --- /dev/null +++ b/src/easy_tdx/backtest/strategies/presets.py @@ -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], + }, # 9(mid 用默认 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, {}) diff --git a/src/easy_tdx/backtest/strategies/registry.py b/src/easy_tdx/backtest/strategies/registry.py index 9e5a181..040c634 100644 --- a/src/easy_tdx/backtest/strategies/registry.py +++ b/src/easy_tdx/backtest/strategies/registry.py @@ -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( diff --git a/src/easy_tdx/web/backtest_schemas.py b/src/easy_tdx/web/backtest_schemas.py index 3e26767..32ba94a 100644 --- a/src/easy_tdx/web/backtest_schemas.py +++ b/src/easy_tdx/web/backtest_schemas.py @@ -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 # 所有策略网格点合计 + + # ── 结果序列化 ───────────────────────────────────────────────────────────────── diff --git a/src/easy_tdx/web/routers/backtest.py b/src/easy_tdx/web/routers/backtest.py index 5f8357f..f7dbba6 100644 --- a/src/easy_tdx/web/routers/backtest.py +++ b/src/easy_tdx/web/routers/backtest.py @@ -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: diff --git a/tests/unit/test_backtest_engine.py b/tests/unit/test_backtest_engine.py index 3eeff63..6609c60 100644 --- a/tests/unit/test_backtest_engine.py +++ b/tests/unit/test_backtest_engine.py @@ -185,17 +185,6 @@ def test_chanlun_injection(): assert len(result.trades) >= 1 -def test_this_close_warning_in_config(): - """Test future_leak_warning in config when using this_close.""" - df = _make_df(n=50) - engine = BacktestEngine(MACrossStrategy, execution="this_close") - result = engine.run(df) - - # Config should have future_leak_warning - # Note: MACrossStrategy may not generate signals, so warning might be False - assert "future_leak_warning" in result.config - - def test_config_snapshot(): """Test config contains correct cash and commission.""" df = _make_df(n=50) diff --git a/tests/unit/test_backtest_orders.py b/tests/unit/test_backtest_orders.py index 64aea20..f1c9ee0 100644 --- a/tests/unit/test_backtest_orders.py +++ b/tests/unit/test_backtest_orders.py @@ -77,74 +77,6 @@ class TestExecutionModes: assert len(trades) == 1 assert trades[0].price == 102.0 # df["close"].iloc[1] - def test_this_close(self) -> None: - """this_close: 当前K线的收盘价。""" - df = _make_df(10) - sim = OrderSimulator(df, execution="this_close") - - signals = [_buy_signal(0, size=100)] - trades = sim.simulate(signals, cash=20000, position=0) - - assert len(trades) == 1 - assert trades[0].price == 101.0 # df["close"].iloc[0] - - def test_this_close_future_leak_warning(self) -> None: - """this_close 模式应设置 future_leak_warning 标志。""" - df = _make_df(10) - sim = OrderSimulator(df, execution="this_close") - - assert sim.future_leak_warning is False - - # 执行模拟后应设置标志 - signals = [_buy_signal(0, size=100)] - sim.simulate(signals, cash=20000, position=0) - - assert sim.future_leak_warning is True - - def test_worst_price_buy(self) -> None: - """worst: 买入取最高价。""" - df = _make_df(10) - sim = OrderSimulator(df, execution="worst") - - signals = [_buy_signal(0, size=100)] - trades = sim.simulate(signals, cash=20000, position=0) - - assert len(trades) == 1 - assert trades[0].price == 103.0 # df["high"].iloc[1] - - def test_worst_price_sell(self) -> None: - """worst: 卖出取最低价。""" - df = _make_df(10) - sim = OrderSimulator(df, execution="worst") - - signals = [_sell_signal(0, size=100)] - trades = sim.simulate(signals, cash=0, position=200) - - assert len(trades) == 1 - assert trades[0].price == 100.0 # df["low"].iloc[1] - - def test_best_price_buy(self) -> None: - """best: 买入取最低价。""" - df = _make_df(10) - sim = OrderSimulator(df, execution="best") - - signals = [_buy_signal(0, size=100)] - trades = sim.simulate(signals, cash=20000, position=0) - - assert len(trades) == 1 - assert trades[0].price == 100.0 # df["low"].iloc[1] - - def test_best_price_sell(self) -> None: - """best: 卖出取最高价。""" - df = _make_df(10) - sim = OrderSimulator(df, execution="best") - - signals = [_sell_signal(0, size=100)] - trades = sim.simulate(signals, cash=0, position=200) - - assert len(trades) == 1 - assert trades[0].price == 103.0 # df["high"].iloc[1] - # ── Test Position Modes ──────────────────────────────────────────────────────── @@ -489,16 +421,3 @@ class TestNonContinuousIndex: # position 1 的 open = 101.0;旧代码会用 label 10 当位置 → iloc[10] 越界 assert trades[0].price == 101.0 assert trades[0].rejected is False - - def test_this_close_with_non_continuous_index(self) -> None: - """this_close 模式下信号在 bar 2(label=30),应在同根 close 成交。""" - df = _make_df(10) - df.index = [10 * (i + 1) for i in range(len(df))] - sim = OrderSimulator(df, execution="this_close") - - signals = [_buy_signal(2, size=100)] - trades = sim.simulate(signals, cash=20000, position=0) - - assert len(trades) == 1 - # position 2 的 close = 103.0 - assert trades[0].price == 103.0 diff --git a/tests/unit/test_web_backtest.py b/tests/unit/test_web_backtest.py index e16e4ec..2f93334 100644 --- a/tests/unit/test_web_backtest.py +++ b/tests/unit/test_web_backtest.py @@ -191,7 +191,7 @@ def test_backtest_request_defaults(): from easy_tdx.web.backtest_schemas import BacktestRequest req = BacktestRequest(strategy="ma_cross", symbol="SZ:000001") - assert req.cash == 100000.0 + assert req.cash == 1_000_000.0 assert req.commission == 0.0003 assert req.execution == "next_open" assert req.category == "DAY" @@ -704,7 +704,7 @@ def test_portfolio_request_defaults(): from easy_tdx.web.backtest_schemas import PortfolioBacktestRequest req = PortfolioBacktestRequest(strategy="ma_cross", stocks=["SZ:000001"]) - assert req.cash == 200000.0 + assert req.cash == 1_000_000.0 assert req.category == "DAY" @@ -899,6 +899,55 @@ def test_optimize_single_param_no_heatmap(client, sample_ohlcv): assert final["result"]["heatmap"] is None +def test_optimize_all_endpoint(client, sample_ohlcv): + """POST /backtest/optimize-all/run/async 端到端:逐策略预设网格寻优 + 全局排名。""" + resp = client.post( + "/api/v1/backtest/optimize-all/run/async", + json={ + "cash": 1_000_000, + "ohlcv": sample_ohlcv, + }, + ) + assert resp.status_code == 202, resp.text + task_id = resp.json()["task_id"] + + final = None + for _ in range(400): + 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"] + # 排名按 total_return 降序、best 指向第一名、各策略最优点齐全 + assert "ranking" in result and len(result["ranking"]) > 0 + assert "best" in result and result["best"] is not None + assert "per_strategy" in result and len(result["per_strategy"]) == len(result["ranking"]) + assert "total_grid_points" in result and result["total_grid_points"] > 0 + # ranking 降序校验 + returns = [r["total_return"] for r in result["ranking"]] + assert returns == sorted(returns, reverse=True) + # best == ranking[0] + assert result["best"]["strategy"] == result["ranking"][0]["strategy"] + # 合计网格点 == 各策略 grid_points 之和 + assert result["total_grid_points"] == sum(r["grid_points"] for r in result["ranking"]) + + +def test_optimize_all_request_validation(): + """optimize-all 请求必须提供数据源。""" + from easy_tdx.web.backtest_schemas import OptimizeAllBacktestRequest + + # 缺数据源 + with pytest.raises(ValueError): + OptimizeAllBacktestRequest() + # 合法 + req = OptimizeAllBacktestRequest(symbol="SZ:000001") + assert req.cash == 1_000_000.0 + assert req.execution == "next_open" + + # --------------------------------------------------------------------------- # Phase 5: 任务列表端点(对比页用) # ---------------------------------------------------------------------------