4. 交叉验证:让数据说话更可靠
做回测最怕什么?怕过拟合。
模型在历史数据上跑得风生水起,一上实盘就原形毕露。这种痛,我经历过太多次了。说白了,过拟合就是模型把历史数据里的噪音当成了规律,学了一堆没用的细节。
那怎么防?交叉验证就是一把利器。
4.1 为什么普通回测不够用?
传统的回测方式,就是拿全部历史数据跑一遍。然后看夏普比率、最大回撤这些指标。看起来挺合理,对吧?
但这里有个坑:你其实是在用同一份数据既训练模型,又评估模型。
我打个比方。这就像你拿着答案去考试,分数再高也不能说明你真学会了。模型记住了历史数据里的每一个波动,但未来不会简单重复过去。
核心问题:单次回测无法区分「真规律」和「假噪音」。
你想想看,如果模型只是碰巧拟合了某段特定行情,那换一段数据它就会露馅。交叉验证要解决的,正是这个问题。
4.2 时序交叉验证的核心思想
标准的K折交叉验证,在金融数据上不能用。为什么?因为时间序列有顺序依赖。
你不能拿未来的数据去预测过去,这违反了因果关系。我在项目里见过有人犯这个错,回测结果漂亮得不像话,实盘直接亏到怀疑人生。
正确的做法是:时序交叉验证。
它的逻辑很简单:
- 把数据按时间顺序切分成多个窗口
- 每次用前面的窗口训练,后面的窗口验证
- 窗口不断向前滚动
这样既保证了时间顺序,又充分利用了数据。
4.3 几种实用的交叉验证方法
我这些年用过不少方法,挑几个最实用的说说。
4.3.1 滚动窗口法
这是最基础的方法。固定一个训练窗口长度,不断向前滚动。
def rolling_window_cv(data, train_size, test_size, n_splits):
"""
滚动窗口交叉验证
参数:
data: 完整数据集
train_size: 训练窗口大小
test_size: 验证窗口大小
n_splits: 拆分次数
"""
for i in range(n_splits):
train_start = i * test_size
train_end = train_start + train_size
test_start = train_end
test_end = test_start + test_size
train_data = data[train_start:train_end]
test_data = data[test_start:test_end]
yield train_data, test_data
这个方法的好处是简单直观。但有个问题:越早的数据,被用来训练的次数越少。
4.3.2 扩展窗口法
这个方法我更喜欢。训练集不断累积,越往后数据越多。
def expanding_window_cv(data, initial_train_size, test_size, n_splits):
"""
扩展窗口交叉验证
参数:
data: 完整数据集
initial_train_size: 初始训练集大小
test_size: 验证窗口大小
n_splits: 拆分次数
"""
for i in range(n_splits):
train_end = initial_train_size + i * test_size
test_start = train_end
test_end = test_start + test_size
train_data = data[:train_end]
test_data = data[test_start:test_end]
yield train_data, test_data
扩展窗口更贴近真实交易场景。你想想看,随着时间推移,你能用的历史数据确实越来越多。
我的建议:高频交易中,我一般用扩展窗口法。因为市场结构在变,早期数据可能已经不太适用了,但完全丢弃又可惜。扩展窗口给了早期数据「参与但不主导」的位置。
4.3.3 组合交叉验证
有时候单一方法不够用。我会把多种方法组合起来。
| 方法 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 滚动窗口 | 市场结构稳定 | 计算量小 | 早期数据利用率低 |
| 扩展窗口 | 市场结构变化 | 数据利用率高 | 计算量大 |
| 组合验证 | 不确定市场状态 | 结果更稳健 | 实现复杂 |
4.4 交叉验证的坑与避坑指南
方法讲完了,说说实战中容易踩的坑。
坑1:数据泄露
我曾经犯过一个低级错误。在计算技术指标时,用了整个数据集来归一化。结果训练集里混进了验证集的信息,回测结果虚高。
解决方法:所有数据预处理必须在每个fold内部独立完成。用训练集的统计量去变换验证集。
坑2:窗口大小选择
窗口太小,模型学不到足够信息。窗口太大,计算成本高,而且可能包含过时的市场结构。
解决方法:我一般会试3-5种不同的窗口大小,看结果是否稳定。如果不同窗口下结果差异很大,说明模型本身就不靠谱。
坑3:只看平均分
很多人只关注交叉验证的平均表现。但方差同样重要。
解决方法:记录每一折的结果。如果某几折表现特别差,说明模型在某些市场状态下会失效。这比平均夏普比率下降0.1要严重得多。
4.5 实战:一个完整的交叉验证流程
最后,我分享一个实际项目中用过的流程。
import numpy as np
from sklearn.metrics import sharpe_ratio
def cross_validate_strategy(data, strategy, cv_method, n_splits=5):
"""
策略交叉验证完整流程
"""
results = []
for fold, (train, test) in enumerate(cv_method(data, n_splits)):
# 1. 在训练集上训练/优化参数
strategy.fit(train)
# 2. 在验证集上回测
test_returns = strategy.backtest(test)
# 3. 计算评估指标
sharpe = sharpe_ratio(test_returns)
max_dd = max_drawdown(test_returns)
results.append({
'fold': fold,
'sharpe': sharpe,
'max_dd': max_dd,
'returns': test_returns
})
print(f"Fold {fold+1}: Sharpe={sharpe:.2f}, MaxDD={max_dd:.2%}")
# 4. 汇总结果
avg_sharpe = np.mean([r['sharpe'] for r in results])
std_sharpe = np.std([r['sharpe'] for r in results])
print(f"\n平均夏普: {avg_sharpe:.2f} ± {std_sharpe:.2f}")
return results
这个流程看起来简单,但每个细节都很关键。嗯,这里要注意:千万不要在交叉验证循环外面做任何数据预处理。我吃过这个亏,希望你别重蹈覆辙。
核心要点:交叉验证不是万能的,但它能帮你筛掉至少70%的过拟合策略。剩下的30%,需要结合样本外测试和实盘模拟来进一步验证。
说白了,交叉验证就是让数据自己说话。它告诉你:这个策略是真的找到了规律,还是只是在背答案。我做了这么多年回测系统,最深的体会就是——不要相信任何一次回测的结果,要相信多次交叉验证的共识。