Walk-Forward 最佳化:唯一誠實的策略測試方法
你優化了一個策略。12 個分離參數,9 個元參數——總共 21 個。在單一交易對上 25 個月的回測顯示 PnL 達 +3342%(最大槓桿)。權益曲線幾乎沒有回撤地上升。夏普比率超過 3。一切看起來完美。
你啟動了機器人。兩週後,策略虧損了 18% 的資金。一個月後——虧損 34%。那些在歷史資料上"有效"的參數,原來只是擬合了特定的市場事件序列。你並沒有發現規律——你只是記住了噪聲。
這就是經典的過擬合。而在投入生產之前發現它的唯一系統性方法就是 Walk-Forward 最佳化(WFO)。
單次訓練/測試分割的陷阱

標準方法:將資料分為 70% 訓練集和 30% 測試集。在訓練集上最佳化,在測試集上驗證。如果結果為正——就上線。
問題是:這只是在一個分割上的一次測試。結果取決於你在哪裡劃分邊界。將邊界移動一個月——樣本外 PnL 就可能從 +40% 變為 -15%。
数据: |===== 训练 (70%) =====|== 测试 (30%) ==|
分割 1: |===2024-01..2025-09====|==2025-10..26-01==| → OOS PnL: +38%
分割 2: |===2024-01..2025-06====|==2025-07..26-01==| → OOS PnL: -12%
分割 3: |===2024-04..2025-12====|==2026-01..26-04==| → OOS PnL: +7%
三種不同的分割——三種不同的結論。該信哪個?都不該信。單次訓練/測試分割就是同樣的單點估計,我們在 蒙特卡洛 Bootstrap 中已經討論過它的問題。你需要的不是一次檢驗,而是在連續資料段上的系統性系列檢驗。
這正是 Walk-Forward 最佳化存在的意義。
什麼是 Walk-Forward 最佳化

WFO 是一種在滑動(或擴充)資料視窗上對策略進行順序最佳化和驗證的程式。其理念是:模擬真實的交易過程,你定期在可用資料上重新最佳化參數,然後交易直到下一次重新最佳化。
每個"視窗"由兩部分組成:
- 樣本內(IS) ——用於最佳化參數的時期
- 樣本外(OOS) ——用於不做擬合地測試已找到參數的時期
關鍵屬性:OOS 時期不重疊,且共同覆蓋資料的很大一部分。最終的權益曲線僅由 OOS 段構建——這才是對策略的誠實評估。
錨定式 WFO(擴充視窗)

在錨定式 WFO 中,訓練期的起點固定,而其終點隨每個視窗擴充:
窗口 1: 训练 [2024-01] → 测试 [2024-04]
窗口 2: 训练 [2024-01..04] → 测试 [2024-07] (训练期增长)
窗口 3: 训练 [2024-01..07] → 测试 [2024-10]
窗口 4: 训练 [2024-01..10] → 测试 [2025-01]
窗口 5: 训练 [2024-01..2025-01] → 测试 [2025-04]
優勢:
- 每個後續訓練期包含更多資料——最佳化更穩定
- 早期模式不會丟失——它們始終在訓練集中
- 實現更簡單
劣勢:
- 舊資料可能"稀釋"當前模式
- 如果市場結構性改變——舊資料有害
- 訓練期無限增長,增加最佳化時間
滾動式 WFO(滑動視窗)
在滾動式 WFO 中,固定長度的訓練期在資料上"滑動":
窗口 1: 训练 [2024-01..06] → 测试 [2024-07..09]
窗口 2: 训练 [2024-04..09] → 测试 [2024-10..12]
窗口 3: 训练 [2024-07..12] → 测试 [2025-01..03]
窗口 4: 训练 [2024-10..2025-03] → 测试 [2025-04..06]
窗口 5: 训练 [2025-01..06] → 测试 [2025-07..09]
優勢:
- 適應當前市場狀態
- 最佳化時間恆定
- 舊的、不相關的資料不會產生影響
劣勢:
- 訓練資料較少——最優參數方差更高
- 對視窗長度選擇敏感
- 可能"遺忘"罕見但重要的事件(閃崩)
組合清洗交叉驗證(CPCV)

這是 Marcos Lopez de Prado 提出的高階方法。資料被分成 組,從中選擇 組用於測試。與普通交叉驗證的關鍵區別在於 清洗(purging,刪除訓練/測試邊界的資料)和 隔離(embargo,防止資料洩漏的額外間隔):
當 時:45 種訓練/測試組合。每種組合產生一個 OOS 結果,最終估計是所有組合的平均值。
from itertools import combinations
import numpy as np
def cpcv_splits(n_groups: int, k_test: int, purge_pct: float = 0.01):
"""
生成带清洗的 CPCV 分割。
Args:
n_groups: 分组数
k_test: 每次分割中的测试组数
purge_pct: 清洗数据比例(在训练/测试边界)
"""
groups = list(range(n_groups))
splits = []
for test_groups in combinations(groups, k_test):
train_groups = [g for g in groups if g not in test_groups]
splits.append({
"train": train_groups,
"test": list(test_groups),
"purge_groups": _get_purge_groups(train_groups, test_groups),
})
return splits
def _get_purge_groups(train, test):
"""训练/测试边界处需要清洗的分组。"""
purge = set()
for t in test:
if t - 1 in train:
purge.add(t - 1)
if t + 1 in train:
purge.add(t + 1)
return list(purge)
當資料較少時,CPCV 優於滾動式 WFO,但計算成本更高。對於具有 21 個參數和 25 個月資料的策略,我們建議從滾動式 WFO 開始,將 CPCV 作為額外檢驗。
WFO 的關鍵參數

訓練期長度
訓練期太短——資料不足以進行可靠的最佳化。太長——舊資料稀釋當前模式。
**經驗法則:**訓練期應至少包含 200-300 筆交易。如果策略每天進行 2 筆交易:
對於具有狀態切換特徵的加密貨幣,我們建議滾動視窗不超過 6-12 個月。
測試期長度
測試期必須足以進行統計顯著性評估,但不能太長——否則參數有時間退化。
**規則:**測試期 = 訓練期的 20-33%。如果訓練期 = 6 個月,測試期 = 1.5-2 個月。
重疊(Overlap)
在滾動式 WFO 中,視窗可以重疊。重疊增加 OOS 資料點數量,但引入估計之間的相關性:
无重叠:
训练 [01..06] → 测试 [07..09]
训练 [07..12] → 测试 [01..03]
50% 重叠:
训练 [01..06] → 测试 [07..09]
训练 [04..09] → 测试 [10..12]
训练 [07..12] → 测试 [01..03]
建議:訓練期 50% 重疊——在視窗數量和估計獨立性之間取得良好平衡。
重新最佳化頻率
決定你多久重新計算一次參數。在加密市場中,最佳頻率為每 1-3 個月。更頻繁的重新最佳化增加過擬合噪聲的風險;更不頻繁——參數過時的風險。
Walk-Forward 效率比和退化率

Walk-Forward 效率比(WFER)
WFO 的關鍵指標——OOS 收益與 IS 收益的比率:
解讀:
| WFER | 解讀 |
|---|---|
| > 0.8 | 出色的穩健性。參數能遷移到新資料。 |
| 0.5 — 0.8 | 可接受的穩健性。策略有效但有退化。 |
| 0.3 — 0.5 | 邊界情況。可能存在部分過擬合。 |
| < 0.3 | 過擬合。參數擬合了 IS 資料。 |
| < 0 | 策略在 OOS 上虧損。完全過擬合或邏輯錯誤。 |
**如果 WFER < 0.5——策略很可能過擬合了。**這是我們的主要過濾器。
退化率
顯示最優參數隨時間失效的速度:
實踐中:將測試期分為子區間並追蹤 PnL 動態:
def degradation_rate(oos_returns: np.ndarray, n_subperiods: int = 4) -> float:
"""
估计参数退化率。
将 OOS 期间分为子区间,计算 PnL 对子区间编号的
线性回归斜率。
Returns:
slope: 负值 = 退化,正值 = 改善
"""
chunk_size = len(oos_returns) // n_subperiods
subperiod_pnls = []
for i in range(n_subperiods):
start = i * chunk_size
end = start + chunk_size
sub_pnl = np.sum(oos_returns[start:end])
subperiod_pnls.append(sub_pnl)
x = np.arange(n_subperiods)
slope = np.polyfit(x, subperiod_pnls, 1)[0]
return slope
如果退化率強烈為負——參數很快過時,你需要更頻繁的重新最佳化或更短的訓練期。
完整的 WFO 管道 Python 實現

import numpy as np
import pandas as pd
from dataclasses import dataclass, field
from typing import Callable, List, Optional
import warnings
@dataclass
class WFOWindow:
"""单个 walk-forward 窗口。"""
window_id: int
train_start: int # 训练起始索引
train_end: int # 训练结束索引(不包含)
test_start: int # 测试起始索引
test_end: int # 测试结束索引(不包含)
best_params: dict = field(default_factory=dict)
is_pnl: float = 0.0 # 样本内 PnL
oos_pnl: float = 0.0 # 样本外 PnL
oos_returns: np.ndarray = field(default_factory=lambda: np.array([]))
wfer: float = 0.0 # walk-forward 效率比
@dataclass
class WFOResult:
"""整个 WFO 的结果。"""
windows: List[WFOWindow]
aggregate_oos_pnl: float
aggregate_is_pnl: float
wfer: float
degradation_rate: float
oos_equity: np.ndarray
oos_sharpe: float
oos_max_dd: float
n_windows: int
passed: bool # 策略是否通过了过滤器
class WalkForwardOptimizer:
"""
Walk-Forward 优化管道。
支持锚定式(扩展)和滚动式(滑动)模式。
"""
def __init__(
self,
data: np.ndarray,
optimize_fn: Callable,
evaluate_fn: Callable,
mode: str = "rolling", # "rolling" 或 "anchored"
train_size: int = 180, # 天
test_size: int = 60, # 天
step_size: int = 60, # 窗口步长,天
min_trades: int = 30, # OOS 中最少交易数
wfer_threshold: float = 0.5, # 接受/拒绝的 WFER 阈值
):
self.data = data
self.optimize_fn = optimize_fn
self.evaluate_fn = evaluate_fn
self.mode = mode
self.train_size = train_size
self.test_size = test_size
self.step_size = step_size
self.min_trades = min_trades
self.wfer_threshold = wfer_threshold
def generate_windows(self) -> List[WFOWindow]:
"""生成 walk-forward 窗口。"""
n = len(self.data)
windows = []
window_id = 0
if self.mode == "rolling":
start = 0
while start + self.train_size + self.test_size <= n:
w = WFOWindow(
window_id=window_id,
train_start=start,
train_end=start + self.train_size,
test_start=start + self.train_size,
test_end=min(start + self.train_size + self.test_size, n),
)
windows.append(w)
start += self.step_size
window_id += 1
elif self.mode == "anchored":
train_end = self.train_size
while train_end + self.test_size <= n:
w = WFOWindow(
window_id=window_id,
train_start=0,
train_end=train_end,
test_start=train_end,
test_end=min(train_end + self.test_size, n),
)
windows.append(w)
train_end += self.step_size
window_id += 1
return windows
def run(self) -> WFOResult:
"""运行完整的 WFO 管道。"""
windows = self.generate_windows()
all_oos_returns = []
for w in windows:
train_data = self.data[w.train_start:w.train_end]
test_data = self.data[w.test_start:w.test_end]
best_params, is_pnl = self.optimize_fn(train_data)
w.best_params = best_params
w.is_pnl = is_pnl
oos_pnl, oos_returns = self.evaluate_fn(test_data, best_params)
w.oos_pnl = oos_pnl
w.oos_returns = oos_returns
if is_pnl != 0:
w.wfer = oos_pnl / is_pnl
else:
w.wfer = 0.0
all_oos_returns.extend(oos_returns)
all_oos = np.array(all_oos_returns)
oos_equity = np.cumprod(1 + all_oos)
peak = np.maximum.accumulate(oos_equity)
max_dd = ((oos_equity - peak) / peak).min()
aggregate_is = sum(w.is_pnl for w in windows)
aggregate_oos = sum(w.oos_pnl for w in windows)
wfer = aggregate_oos / aggregate_is if aggregate_is != 0 else 0
if np.std(all_oos) > 0:
oos_sharpe = np.mean(all_oos) / np.std(all_oos) * np.sqrt(252)
else:
oos_sharpe = 0
deg_rate = self._degradation_rate(windows)
passed = wfer >= self.wfer_threshold and aggregate_oos > 0
return WFOResult(
windows=windows,
aggregate_oos_pnl=aggregate_oos,
aggregate_is_pnl=aggregate_is,
wfer=wfer,
degradation_rate=deg_rate,
oos_equity=oos_equity,
oos_sharpe=oos_sharpe,
oos_max_dd=max_dd,
n_windows=len(windows),
passed=passed,
)
def _degradation_rate(self, windows: List[WFOWindow]) -> float:
"""OOS PnL 按窗口编号的斜率。"""
if len(windows) < 3:
return 0.0
pnls = [w.oos_pnl for w in windows]
x = np.arange(len(pnls))
slope = np.polyfit(x, pnls, 1)[0]
return slope
使用示例
import numpy as np
np.random.seed(42)
prices = 100 * np.cumprod(1 + np.random.normal(0.0002, 0.02, 750))
def my_optimize(train_data):
"""
在训练数据上优化策略。
返回 (best_params, is_pnl)。
"""
best_pnl = -np.inf
best_params = {}
for fast in range(5, 30, 5):
for slow in range(20, 100, 10):
if fast >= slow:
continue
pnl, _ = _run_strategy(train_data, fast, slow)
if pnl > best_pnl:
best_pnl = pnl
best_params = {"fast": fast, "slow": slow}
return best_params, best_pnl
def my_evaluate(test_data, params):
"""
使用固定参数在测试数据上评估策略。
返回 (oos_pnl, oos_returns)。
"""
pnl, returns = _run_strategy(test_data, params["fast"], params["slow"])
return pnl, returns
def _run_strategy(data, fast_period, slow_period):
"""简单的 MA 交叉策略。"""
fast_ma = pd.Series(data).rolling(fast_period).mean().values
slow_ma = pd.Series(data).rolling(slow_period).mean().values
position = 0
returns = []
for i in range(slow_period, len(data) - 1):
if fast_ma[i] > slow_ma[i] and position <= 0:
position = 1
elif fast_ma[i] < slow_ma[i] and position >= 0:
position = -1
daily_ret = (data[i + 1] - data[i]) / data[i]
returns.append(position * daily_ret)
total_pnl = np.sum(returns)
return total_pnl, np.array(returns)
wfo = WalkForwardOptimizer(
data=prices,
optimize_fn=my_optimize,
evaluate_fn=my_evaluate,
mode="rolling",
train_size=180, # 6 个月
test_size=60, # 2 个月
step_size=60, # 步长 = 测试期
)
result = wfo.run()
print(f"Windows: {result.n_windows}")
print(f"OOS PnL: {result.aggregate_oos_pnl:.4f}")
print(f"IS PnL: {result.aggregate_is_pnl:.4f}")
print(f"WFER: {result.wfer:.3f}")
print(f"OOS Sharpe: {result.oos_sharpe:.2f}")
print(f"OOS MaxDD: {result.oos_max_dd:.2%}")
print(f"Degradation: {result.degradation_rate:.5f}")
print(f"Passed: {result.passed}")
for w in result.windows:
print(f" Window {w.window_id}: IS={w.is_pnl:.4f} OOS={w.oos_pnl:.4f} "
f"WFER={w.wfer:.2f} params={w.best_params}")
解讀結果:何時信任,何時拒絕
策略通過了 WFO
如果所有視窗的 WFER >= 0.5,OOS PnL 為正且穩定:
Window 0: IS=0.0812 OOS=0.0531 WFER=0.65 params={'fast': 10, 'slow': 50}
Window 1: IS=0.0744 OOS=0.0489 WFER=0.66 params={'fast': 10, 'slow': 50}
Window 2: IS=0.0698 OOS=0.0401 WFER=0.57 params={'fast': 15, 'slow': 50}
Window 3: IS=0.0823 OOS=0.0512 WFER=0.62 params={'fast': 10, 'slow': 60}
Window 4: IS=0.0756 OOS=0.0478 WFER=0.63 params={'fast': 10, 'slow': 50}
→ 聚合 WFER: 0.63,所有窗口 > 0.5,参数稳定
良好跡象:
- WFER 跨視窗穩定(無劇烈跳躍)
- 各視窗間參數相似(fast = 10-15, slow = 50-60)
- 大多數視窗 OOS PnL 為正
- 退化率接近零
策略未通過 WFO
Window 0: IS=0.2341 OOS=-0.0312 WFER=-0.13 params={'fast': 5, 'slow': 95}
Window 1: IS=0.1987 OOS=0.0089 WFER=0.04 params={'fast': 25, 'slow': 30}
Window 2: IS=0.2156 OOS=-0.0567 WFER=-0.26 params={'fast': 10, 'slow': 90}
Window 3: IS=0.1834 OOS=0.0234 WFER=0.13 params={'fast': 20, 'slow': 40}
→ 聚合 WFER: -0.07,IS 很高,OOS 接近零 → 过拟合
過擬合跡象:
- IS PnL 高,OOS PnL 低/為負 ——經典過擬合
- 各視窗間參數差異很大 ——沒有穩定的最優點
- 大多數視窗 WFER < 0.3 ——參數無法遷移
- 退化率強烈為負 ——快速退化
更多關於參數穩定性分析的內容——請參閱文章 Plateau 分析。如果最優點是"尖銳的"(參數微小變化就急劇下降)——這是過擬合的額外訊號。
加密貨幣 WFO 的特殊性

加密貨幣為 WFO 帶來了傳統市場不存在的獨特問題。
狀態切換
加密市場在截然不同的狀態之間切換:牛市趨勢、熊市趨勢、高/低波動率的橫盤。在一種狀態下最優的參數在另一種狀態下可能是虧損的。
**解決方案:**使用滾動式 WFO(非錨定式),視窗為 4-6 個月。這可以"遺忘"舊狀態。此外——按波動率聚類資料,對每個聚類分別執行 WFO。
歷史資料短
大多數山寨幣的交易歷史不到 3 年。訓練期 = 6 個月、測試期 = 2 個月的情況下,你只能得到 4-5 個視窗——統計估計很弱。
**解決方案:**使用 CPCV 代替或補充滾動式 WFO。CPCV 從相同資料中生成更多組合。對於 10 組、k=2:45 種組合而非 4-5 個視窗。
流動性結構性變化
加密交易對的流動性是非平穩的:一個交易對可能在 6 個月內流動性良好,然後交易量下降 10 倍。在流動性好的市場上最佳化的參數在流動性差的市場上不起作用。
**解決方案:**在 WFO 管道中新增流動性過濾器。排除平均日交易量低於閾值的視窗。驗證測試期的流動性與訓練期可比。
資金費率的影響
對於帶槓桿的期貨策略,資金費率可能從根本上改變 OOS 結果。策略顯示 2 個月 OOS +5%,但在 10 倍槓桿下,資金費率消耗 3.6%。
資金費率影響的詳細分析——請參閱我們的文章 資金費率扼殺你的槓桿。在 WFO 中評估 OOS PnL 時,務必考慮資金費率成本。
多參數策略:為什麼 12+ 參數時 WFO 至關重要

具有 21 個參數(12 個分離參數 + 9 個元參數)、單一交易對 25 個月資料的策略是一個搜尋空間巨大的模型。
維度災難
參數組合數量隨參數數量呈指數增長:
如果 21 個參數中每個至少取 10 個值:
即使使用貝葉斯最佳化(詳見 座標下降 vs 貝葉斯),你也只探索了空間中微不足道的一小部分。找到的最優點是噪聲偽像而非真真實模式的機率隨參數數量增加而增長。
多重比較的 Bonferroni 公式
如果你測試了 種參數組合,虛假"發現"的機率(偶然找到好結果):
當 且 種已嘗試的組合時:
保證能找到"有效"的參數——但實際上是擬合了噪聲。沒有 WFO,你無法區分真實的優勢和統計偽像。
規則:OOS 資料點數與參數數之比
信任 WFO 結果的經驗法則:
對於 21 個參數,你至少需要 210 筆 OOS 交易。如果你的 WFO 產生的更少——結果不可信。
PnL@ML 為 +3342% 的策略:21 個參數,25 個月資料。假設 5 個 OOS 視窗,每個 60 天,每天 2 筆交易——總共 筆 OOS 交易。比率 ——可接受,但僅當 WFER > 0.5 時。
將 WFO 與 Optuna 整合

在每個 WFO 視窗中,你需要最佳化參數。對於 21 個參數,網格搜尋不可行,座標下降效率低下。最優選擇是通過 Optuna 進行貝葉斯最佳化。
import optuna
from optuna.samplers import TPESampler
def optuna_optimize(train_data: np.ndarray, n_trials: int = 500) -> tuple:
"""
使用 Optuna 优化策略参数。
在每个 WFO 窗口内使用。
"""
def objective(trial):
fast = trial.suggest_int("fast_period", 3, 50)
slow = trial.suggest_int("slow_period", 20, 200)
atr_period = trial.suggest_int("atr_period", 5, 50)
atr_mult = trial.suggest_float("atr_multiplier", 0.5, 4.0)
rsi_period = trial.suggest_int("rsi_period", 5, 30)
rsi_upper = trial.suggest_int("rsi_upper", 60, 85)
rsi_lower = trial.suggest_int("rsi_lower", 15, 40)
vol_window = trial.suggest_int("vol_window", 10, 100)
position_size = trial.suggest_float("position_size", 0.1, 1.0)
take_profit = trial.suggest_float("take_profit", 0.005, 0.05)
stop_loss = trial.suggest_float("stop_loss", 0.003, 0.03)
trailing_pct = trial.suggest_float("trailing_pct", 0.002, 0.02)
if fast >= slow:
return -1e6 # 无效组合
params = {
"fast_period": fast, "slow_period": slow,
"atr_period": atr_period, "atr_multiplier": atr_mult,
"rsi_period": rsi_period, "rsi_upper": rsi_upper,
"rsi_lower": rsi_lower, "vol_window": vol_window,
"position_size": position_size,
"take_profit": take_profit, "stop_loss": stop_loss,
"trailing_pct": trailing_pct,
}
pnl, _ = run_strategy(train_data, params)
_, returns = run_strategy(train_data, params)
if len(returns) < 30 or np.std(returns) == 0:
return -1e6
sharpe = np.mean(returns) / np.std(returns) * np.sqrt(252)
return sharpe
optuna.logging.set_verbosity(optuna.logging.WARNING)
study = optuna.create_study(
direction="maximize",
sampler=TPESampler(seed=42),
)
study.optimize(objective, n_trials=n_trials, show_progress_bar=False)
best_params = study.best_params
best_pnl, _ = run_strategy(train_data, best_params)
return best_params, best_pnl
wfo = WalkForwardOptimizer(
data=prices,
optimize_fn=optuna_optimize, # 用 Optuna 代替网格搜索
evaluate_fn=my_evaluate,
mode="rolling",
train_size=180,
test_size=60,
step_size=60,
)
result = wfo.run()
**重要:**在 WFO 內部最佳化 夏普比率,而非 PnL。PnL 最佳化找到的是在特定交易序列上最大化利潤的參數。夏普比率最佳化找到的是具有最佳收益風險比的參數——它們在 OOS 上更穩健。
Optuna TPE 與座標下降的詳細比較——請參閱文章 座標下降 vs 貝葉斯。
WFO 結果視覺化
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
def plot_wfo_results(result: WFOResult, data: np.ndarray):
"""可视化 Walk-Forward 优化结果。"""
fig, axes = plt.subplots(3, 1, figsize=(16, 14))
ax = axes[0]
ax.plot(result.oos_equity, color='#4FC3F7', linewidth=1.5)
ax.axhline(1.0, color='#FF5252', linestyle='--', alpha=0.5, label='Break-even')
ax.set_title(f'OOS Equity Curve (WFER={result.wfer:.2f}, Sharpe={result.oos_sharpe:.2f})')
ax.set_ylabel('Equity')
ax.legend()
ax.grid(True, alpha=0.3)
ax = axes[1]
wfers = [w.wfer for w in result.windows]
colors = ['#69F0AE' if w >= 0.5 else '#FFAB40' if w >= 0.3 else '#FF5252'
for w in wfers]
ax.bar(range(len(wfers)), wfers, color=colors, edgecolor='#1A237E', alpha=0.8)
ax.axhline(0.5, color='#E040FB', linestyle='--', label='Threshold (0.5)')
ax.axhline(0, color='gray', linestyle='-', alpha=0.3)
ax.set_title('各窗口的 Walk-Forward 效率比')
ax.set_xlabel('Window')
ax.set_ylabel('WFER')
ax.legend()
ax = axes[2]
x = np.arange(len(result.windows))
width = 0.35
ax.bar(x - width/2, [w.is_pnl for w in result.windows],
width, label='IS PnL', color='#7C4DFF', alpha=0.7)
ax.bar(x + width/2, [w.oos_pnl for w in result.windows],
width, label='OOS PnL', color='#4FC3F7', alpha=0.7)
ax.set_title('样本内 vs 样本外 PnL')
ax.set_xlabel('Window')
ax.set_ylabel('PnL')
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('wfo_results.png', dpi=150)
plt.show()
實踐建議
策略上線前的檢查清單
1. 執行 WFO(滾動式 + 錨定式)
比較兩種模式的結果。如果滾動式 WFO 失敗而錨定式通過——策略很可能只在早期資料上有效。
2. 檢查每個視窗的 WFER
不僅是聚合 WFER,還要檢查每個視窗。如果 6 個視窗中有 2 個 WFER < 0——即使聚合值 > 0.5,這也是問題。
3. 比較各視窗間的參數
如果最優參數在各視窗間"跳躍"——沒有穩定的優勢。使用 Plateau 分析 來驗證最優點的穩定性。
4. 檢查退化率
強烈為負的退化率 = 參數快速失效。需要更頻繁的重新最佳化或重新審視策略。
5. 對 OOS 結果應用蒙特卡洛 Bootstrap
聚合 OOS PnL 也是單點估計。對 OOS 收益陣列應用 蒙特卡洛 Bootstrap 以獲取置信區間。
6. 考慮成本
OOS PnL 必須包含手續費、滑點和資金費率。沒有成本的漂亮 OOS PnL 是幻覺。更多詳情——資金費率扼殺你的槓桿。
最低資料要求
| 參數數量 | 最少 OOS 交易數 | 最少 WFO 視窗數 | 最少資料量(每天 2 筆交易) |
|---|---|---|---|
| 2-5 | 50 | 3 | 約 6 個月 |
| 6-10 | 100 | 4 | 約 12 個月 |
| 11-15 | 150 | 5 | 約 18 個月 |
| 16-21 | 210 | 6 | 約 24 個月 |
| 22+ | 300+ | 8+ | 約 36+ 個月 |
具有 21 個參數和 25 個月資料的策略
回到文章開頭的問題:在單一交易對 25 個月資料上最佳化的 21 個參數。PnL@ML = +3342%。如何驗證?
步驟 1. 滾動式 WFO:訓練 = 8 個月,測試 = 2 個月,步長 = 2 個月。得到約 8 個視窗。
步驟 2. 錨定式 WFO:首個訓練 = 8 個月,測試 = 2 個月。得到約 8 個視窗。
步驟 3. CPCV:10 組,每組約 2.5 個月,k = 2。得到 45 種組合。
步驟 4. 對每種方法驗證:
- WFER >= 0.5?
- 各視窗間參數穩定?
- 退化率可接受?
- OOS 交易數 / 參數數 >= 10?
步驟 5. 對聚合 OOS 收益進行蒙特卡洛 Bootstrap。第 5 百分位 PnL > 0?
如果這些測試中有任何一個失敗——+3342% 的策略很可能過擬合了。單一交易對 25 個月上的 21 個參數——這是極高的參數與資料之比。沒有通過 WFO,就不能信任。
我們還建議結合 按活躍時間計算的 PnL 來檢查策略效率——這將揭示 +3342% 中有多少是由持倉時間貢獻的,多少是真實優勢。
結論
Walk-Forward 最佳化不是可選項——它是必需品。這是唯一能系統性驗證參數向新資料遷移能力的方法。單次訓練/測試分割是抽彩票。在所有資料上的完整回測是自我欺騙。
關鍵要點:
-
**WFER < 0.5 = 過擬合。**如果樣本外 PnL 低於樣本內的一半——參數被擬合了。
-
**參數穩定性比最大值更重要。**在每個視窗產生 +15% 的參數優於在一個視窗產生 +40% 而在另一個產生 -10% 的參數。
-
**加密貨幣用滾動式 WFO。**狀態切換使錨定式 WFO 不太可靠。4-6 個月的滾動視窗是最佳平衡。
-
**參數越多——要求越嚴格。**21 個參數需要至少 210 筆 OOS 交易和 6+ 個 WFO 視窗。否則結果無法驗證。
-
WFO + 蒙特卡洛 Bootstrap + Plateau 分析 ——三層過擬合防護。每層都能捕獲其他層遺漏的問題。
通過 WFO 且所有視窗 WFER > 0.5、參數穩定、第 5 百分位 Bootstrap 為正的策略——這才是你可以用真錢信任的策略。其他一切都是帶有漂亮權益曲線的曲線擬合。
參考連結
- Pardo, R. — The Evaluation and Optimization of Trading Strategies (Wiley)
- Lopez de Prado, M. — Advances in Financial Machine Learning, Chapter 12: Backtesting
- Bailey, D.H. et al. — The Probability of Backtest Overfitting
- Lopez de Prado, M. — The Combinatorial Purged Cross-Validation (CPCV)
- Aronson, D.R. — Evidence-Based Technical Analysis
- Optuna: A Next-generation Hyperparameter Optimization Framework
- Kevin Davey — Building Winning Algorithmic Trading Systems: Walk-Forward Analysis
- White, H. — A Reality Check for Data Snooping (2000)
- Harvey, C.R. & Liu, Y. — Backtesting (2015)
- NumPy — numpy.cumprod
引用
@article{soloviov2026walkforwardoptimization,
author = {Soloviov, Eugen},
title = {Walk-Forward Optimization: The Only Honest Strategy Test},
year = {2026},
url = {https://marketmaker.cc/zh/blog/post/walk-forward-optimization},
version = {0.1.0},
description = {Why a single train/test split does not protect against overfitting, how walk-forward optimization systematically verifies parameter robustness, and why a strategy with +3342\% PnL@ML on 21 parameters is a ticking time bomb without WFO.}
}
Authors
Trading-systems engineer
Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.