Walk-forward 优化

Backtest.optimize()整个数据集上挑选最佳参数组合 —— 然后汇报样本内表现。这是拟合噪声的经典配方。看到的「最优 Sharpe 2.4」实盘几乎从不成立。

Backtest.walk_forward_optimize() 在滚动窗口内做同样的网格搜索,在策略从未调过参的数据上汇报表现:

                        train          test
window 0:   [─────────────────────][──────]
window 1:           [─────────────────────][──────]
window 2:                   [─────────────────────][──────]
                                                step

每个窗口对里,QTrade 在训练片段挑选最佳参数,并在紧接的测试片段上评估。测试片段的聚合结果是策略真实表现的诚实样本外(OoS)估计。

API

result = bt.walk_forward_optimize(
    train_window=200,
    test_window=50,
    maximize='Sharpe Ratio',
    step=50,
    constraint=lambda p: p['n1'] < p['n2'],
    n1=range(5, 30, 5),
    n2=range(20, 60, 10),
)

必填:

  • train_window:每个训练片段的 bar 数。

  • test_window:每个测试片段的 bar 数(紧跟训练片段之后)。

  • maximize:在每个训练片段内最大化的 calculate_stats() 指标名。常见选择:Sharpe RatioTotal Return [%]Calmar Ratio。含义详见 统计指标词汇表

  • **params_grid:和 optimize() 同语法 —— 关键字参数,值是候选项的可迭代对象。

可选:

  • step:相邻训练起点之间推进多少 bar。默认 test_window(测试窗口不重叠)。更小值产生重叠(相关)的测试窗口;更大值留下空隙。

  • constraint:评估前应用到每个参数字典的过滤器(lambda p: bool)。用于跳过无意义组合(如快线 SMA 窗口 > 慢线窗口)。

解读结果

result 是一个 dict:

result['windows']    # list of per-window dicts
result['summary']    # aggregate OoS metrics

每个 window dict 包含:

train_start, train_end

训练片段的时间戳。

test_start, test_end

测试片段的时间戳。

best_params

在训练片段上最大化 maximize 的参数组合。

train_stats

best_params 跑训练片段得到的完整 stats dict。

test_stats

测试片段跑出来的完整 stats dict。

test_equity

测试片段的 equity_history Series。

summary 包含:

含义

n_windows

评估的训练 / 测试对总数。

mean_oos_return

测试窗口的 Total Return [%] 平均值。

hit_rate

正收益的测试窗口占比。

min_oos_return, max_oos_return

最差和最好的测试窗口。

典型工作流

from qtrade.backtest import Backtest
from qtrade.utils.stats import calculate_stats

# 1. Cheap in-sample search to confirm the strategy is even worth tuning.
best_params, best_stats, _ = bt.optimize(
    maximize='Sharpe Ratio',
    n1=range(5, 30, 5),
    n2=range(20, 60, 10),
    constraint=lambda p: p['n1'] < p['n2'],
)
print("In-sample Sharpe:", best_stats['Sharpe Ratio'])

# 2. Walk-forward to get the realistic number.
result = bt.walk_forward_optimize(
    train_window=200,
    test_window=50,
    maximize='Sharpe Ratio',
    n1=range(5, 30, 5),
    n2=range(20, 60, 10),
    constraint=lambda p: p['n1'] < p['n2'],
)
print("OoS hit rate:", result['summary']['hit_rate'])
print("OoS mean return:", result['summary']['mean_oos_return'], '%')

# 3. Inspect window-by-window for parameter stability.
for w in result['windows']:
    print(
        f"{w['test_start'].date()}{w['test_end'].date()}: "
        f"params={w['best_params']} "
        f"oos_return={w['test_stats']['Total Return [%]']:.2f}%"
    )

如果样本内 Sharpe 和样本外均值差距悬殊,你已经找到了过拟合。要相信样本外的数字。

如何选择窗口

合适的 train_windowtest_window 取决于:

  • 策略「记忆长度」:策略拟合一个参数需要多少 bar?20 bar 的 SMA 至少需要 ~50 bar 给系统留余地。200 bar 回看的模型需要大得多的 train_window。

  • 市场 regime 长度:训练窗口太小只能看到一个 regime;太大跨越多个 regime(不同 regime 的最优解相互抵消)。日 bar 数据的话,6–12 个月通常是合适起点。

  • 你能支撑的窗口数量:长窗口 = 窗口更少,但每次拟合数据更多;短窗口 = 窗口更多但每个噪声更大。至少 5–10 个窗口汇总统计才有意义。

经验法则:train_window 4–10 × test_window 是常见比例。

常见坑

「OoS 命中率是 0.5」

和抛硬币没区别。要么策略真的没有 edge,要么你的参数网格没覆盖有意义的空间。试试:

  1. 哪些参数在每个窗口胜出 —— 如果窗口之间的最优参数差异巨大,策略不稳定。

  2. 扩大网格。如果最优值老是落在网格边缘,说明你探索的范围不够远。

  3. 试更长的 train_window

「OoS 均值是正的但最差窗口惨不忍睹」

这是「大部分时间 work 但偶尔爆仓」类策略的典型特征(如马丁格尔式加仓、裸卖空波动率)。均值掩盖了尾部风险。

永远要看每个窗口的明细,不要只看 summary。

「Walk-forward Sharpe 比样本内 Sharpe 低很多」

正常现象。差距就是过拟合的程度。2:1 的比例(样本内 2.0 → OoS 1.0)尚可;5:1 说明你在拟合噪声。降低参数自由度(缩小网格),或者增加策略的归纳偏置(用 constraint、跨资产共享参数等)。

「Step 小于 test_window」

这会产生重叠的测试窗口。summary 指标会重复计算 bar 并夸大 n_windows。有时这是有意为之(如每周重训的自适应 walk-forward),但需要明白窗口在统计上不独立。

另请参考