From 34eaba3010315c570be78f5f728a0c85e30aa014 Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Fri, 17 Jul 2026 14:00:16 +0800 Subject: [PATCH] fix: handle uncovered walk-forward folds --- backend/app/backtest/walkforward.py | 38 +++++++++++++++---- backend/tests/backtest/test_worker_process.py | 34 +++++++++++++++++ frontend/src/pages/Backtest.tsx | 4 +- .../pages/backtest/StrategyWalkForward.tsx | 4 +- 4 files changed, 68 insertions(+), 12 deletions(-) diff --git a/backend/app/backtest/walkforward.py b/backend/app/backtest/walkforward.py index ca2b1a1..0f61a1a 100644 --- a/backend/app/backtest/walkforward.py +++ b/backend/app/backtest/walkforward.py @@ -10,6 +10,7 @@ from __future__ import annotations import logging import time +from bisect import bisect_left from dataclasses import dataclass, field from datetime import date, timedelta @@ -196,6 +197,15 @@ class WalkForwardService: shared_market_data = ( shared_prepared.market_data if shared_prepared is not None else None ) + shared_date_labels = ( + tuple(label[:10] for label in shared_market_data.timestamp_labels) + if shared_market_data is not None + else () + ) + + def _shared_window_has_data(window_start: date, window_end: date) -> bool: + index = bisect_left(shared_date_labels, window_start.isoformat()) + return index < len(shared_date_labels) and shared_date_labels[index] <= window_end.isoformat() # 遥测: 首尾快照 PanelCache, 量化跨折重叠区间重复扫盘的 IO 占比 (是否值得进一步优化)。 cache_before = self.service.engine.cache_stats() @@ -213,6 +223,26 @@ class WalkForwardService: if cancel_event is not None and cancel_event.is_set(): break + base = { + "index": f.index, + "train_start": str(f.train_start), + "train_end": str(f.train_end), + "test_start": str(f.test_start), + "test_end": str(f.test_end), + } + missing_window = None + if shared_market_data is not None: + if not _shared_window_has_data(f.train_start, f.train_end): + missing_window = "训练区间无可用行情数据" + elif not _shared_window_has_data(f.test_start, f.test_end): + missing_window = "测试区间无可用行情数据" + if missing_window is not None: + skipped.append({**base, "reason": missing_window}) + done += 1 + if progress_cb is not None: + progress_cb({"type": "walkforward_progress", "done": done, "total": n_total, "fold": f.index}) + continue + # 训练区间: 网格优化选最优参数 opt_cfg = OptimizeConfig( strategy_id=cfg.strategy_id, @@ -239,14 +269,6 @@ class WalkForwardService: is_score = opt_res.get("best_score") done += 1 - base = { - "index": f.index, - "train_start": str(f.train_start), - "train_end": str(f.train_end), - "test_start": str(f.test_start), - "test_end": str(f.test_end), - } - # 训练区间没优化出参数 (全组失败/取消) -> 跳过, 不用默认参数硬跑 OOS 伪装成有效折 if best_params is None: skipped.append({**base, "reason": "训练区间未优化出参数"}) diff --git a/backend/tests/backtest/test_worker_process.py b/backend/tests/backtest/test_worker_process.py index e3c5d97..30bba1a 100644 --- a/backend/tests/backtest/test_worker_process.py +++ b/backend/tests/backtest/test_worker_process.py @@ -186,3 +186,37 @@ def test_spawn_walkforward_reuses_shared_matrix_across_folds(tmp_path): assert result["shared_market_data_bytes"] > 0 assert all(fold["oos_stats"]["shared_market_data"] for fold in result["folds"]) assert result["worker"]["worker_exitcode"] == 0 + + +def test_spawn_walkforward_skips_folds_before_available_matrix_data(tmp_path): + configured_start = date(2024, 1, 1) + market_start = configured_start + timedelta(days=4) + data_dir = tmp_path / "data" + _write_worker_strategy(data_dir) + _write_market_data(data_dir, market_start, days=8) + config = WalkForwardConfig( + strategy_id="worker_always_entry", + symbols=["600000.SH"], + start=configured_start, + end=configured_start + timedelta(days=11), + param_grid={"gate": [1, 2]}, + objective="total_return", + train_days=2, + test_days=1, + step_days=2, + overrides={"basic_filter": {"enabled": False}}, + backtest_kwargs={ + "matching": "close_t", + "fees_pct": 0, + "slippage_bps": 0, + "max_positions": 1, + }, + ) + + result = run_worker_task(make_worker_task("walkforward", data_dir, config)) + + assert result["n_planned_folds"] == 4 + assert result["n_skipped"] == 1 + assert result["skipped"][0]["reason"] == "训练区间无可用行情数据" + assert result["n_folds"] == 3 + assert result["worker"]["worker_exitcode"] == 0 diff --git a/frontend/src/pages/Backtest.tsx b/frontend/src/pages/Backtest.tsx index 2fd921a..c12e745 100644 --- a/frontend/src/pages/Backtest.tsx +++ b/frontend/src/pages/Backtest.tsx @@ -25,7 +25,7 @@ const MODES: Record = { hint: '在独立 worker 中复用基础数据并串行回测参数组合,按夏普/索提诺等目标排序。', }, walkforward: { - title: 'Walk-forward', + title: '步进优化', subtitle: '滚动窗口样本外验证', hint: '每折训练区间优化、测试区间验证,看样本外是否退化以识别过拟合。', }, @@ -58,7 +58,7 @@ export function Backtest() { > {MODES[tab].title} - {tab === 'optimizer' && ( + {(tab === 'optimizer' || tab === 'walkforward') && ( diff --git a/frontend/src/pages/backtest/StrategyWalkForward.tsx b/frontend/src/pages/backtest/StrategyWalkForward.tsx index dcca10c..e6dde95 100644 --- a/frontend/src/pages/backtest/StrategyWalkForward.tsx +++ b/frontend/src/pages/backtest/StrategyWalkForward.tsx @@ -186,7 +186,7 @@ export function StrategyWalkForward() { ) : ( )} @@ -210,7 +210,7 @@ export function StrategyWalkForward() { {!result && !task?.isPending && ( )}