第21章:策略过拟合防范:交叉验证、滚动窗口测试、蒙特卡洛模拟
做量化最怕什么?不是策略亏钱,而是回测漂亮、实盘崩盘。
我见过太多人,拿着一条完美曲线来找我,说“老师你看,年化50%,回撤只有5%”。我一看,参数优化了200次,样本内拟合得跟艺术品似的。结果呢?实盘一个月就亏了20%。
这就是过拟合。说白了,你让模型记住了历史噪音,而不是真正的规律。
今天我们就来聊聊怎么防这个坑。三种方法:交叉验证、滚动窗口测试、蒙特卡洛模拟。我个人习惯是三个一起用,缺一个都不踏实。
21.1 过拟合的本质:你是在学习规律,还是在背答案?
先想一个问题:为什么基差交易特别容易过拟合?
因为基差数据本身就有很强的序列相关性,而且样本量通常不大。你拿过去3年的5分钟数据去优化一个参数,很容易找到一组“恰好”表现好的参数组合。但换个时间段,立马失效。
我有个血的教训。2019年做螺纹钢基差套利,我优化了移动平均窗口参数,回测曲线漂亮得不行。结果2020年一上线,连续止损6次。后来复盘发现,那组参数刚好匹配了2019年的某个震荡区间,完全不具备泛化能力。
所以,防范过拟合不是锦上添花,是生死攸关。
核心原则:策略的复杂度不能超过数据的有效信息量。参数越多、优化次数越多,过拟合风险越大。
21.2 交叉验证:把数据切碎了测
交叉验证不是机器学习的专利,量化策略一样能用。思路很简单:把历史数据分成K份,轮流用K-1份训练、1份验证,最后取平均表现。
但注意,金融数据不能乱切。时间序列有前后依赖关系,你不能用未来的数据去预测过去。所以要用前向链式交叉验证(也叫滚动交叉验证)。
具体做法:
- 把数据按时间顺序分成N个窗口
- 第一次:用窗口1训练,窗口2验证
- 第二次:用窗口1+2训练,窗口3验证
- 以此类推,直到用完所有数据
这样既保证了时间顺序,又充分利用了数据。我一般设5折或10折,数据量少就3折。
import numpy as np
import pandas as pd
from sklearn.model_selection import TimeSeriesSplit
# 假设我们有5年日线数据
data = pd.DataFrame(index=pd.date_range('2018-01-01', '2022-12-31', freq='D'))
data['spread'] = np.random.randn(len(data)) * 0.5 + 0.1 # 模拟基差序列
# 前向链式交叉验证
tscv = TimeSeriesSplit(n_splits=5)
for train_idx, test_idx in tscv.split(data):
train = data.iloc[train_idx]
test = data.iloc[test_idx]
# 在train上训练策略参数
# 在test上计算收益
# 记录每次的夏普比率、最大回撤等
print(f"训练期: {train.index[0].date()} ~ {train.index[-1].date()}")
print(f"测试期: {test.index[0].date()} ~ {test.index[-1].date()}")
print("---")
我的习惯:交叉验证的结果如果波动很大——比如某次夏普2.0,另一次只有0.3——那这个策略大概率有问题。真正稳健的策略,各次结果应该比较接近。
21.3 滚动窗口测试:模拟实盘的真实节奏
交叉验证是静态的,而滚动窗口测试更贴近实盘。它的逻辑是:固定一个训练窗口长度,然后不断向前滚动,每次重新训练并测试下一段。
举个例子:
- 训练窗口:250个交易日(约1年)
- 测试窗口:20个交易日(约1个月)
- 每次滚动20天,重新训练一次
这样你就能看到策略在不同市场环境下的表现变化。如果某段时间连续亏损,说明策略可能不适应那个阶段的行情特征。
def rolling_window_test(data, train_window=250, test_window=20):
results = []
start = 0
while start + train_window + test_window <= len(data):
train = data.iloc[start:start+train_window]
test = data.iloc[start+train_window:start+train_window+test_window]
# 在train上优化参数
# 在test上回测
sharpe = np.random.randn() * 0.5 + 1.0 # 模拟结果
results.append(sharpe)
start += test_window
return np.array(results)
# 执行滚动测试
sharpe_series = rolling_window_test(data)
print(f"平均夏普: {sharpe_series.mean():.2f}")
print(f"夏普标准差: {sharpe_series.std():.2f}")
print(f"夏普为正的比例: {(sharpe_series > 0).mean()*100:.1f}%")
你看,这里的关键不是平均夏普有多高,而是夏普的稳定性。如果标准差太大,说明策略在不同时间段表现差异巨大,实盘风险很高。
我曾经踩过的坑:有一次滚动测试结果看起来不错,平均夏普1.2。但我没注意到,最后3个窗口的夏普是负的。结果实盘正好赶上了那种市场环境,连续亏了2个月。所以一定要看滚动测试的时间序列,别只看平均值。
21.4 蒙特卡洛模拟:给策略做压力测试
前两种方法都是基于历史数据的。但历史不会简单重复。蒙特卡洛模拟能帮你回答一个问题:如果未来的市场走势和过去不完全一样,策略还能赚钱吗?
具体做法:
- 从历史数据中随机抽取交易信号(或者随机打乱收益序列)
- 重复1000次、5000次
- 观察策略收益的分布情况
如果大部分模拟结果都是正的,说明策略有统计显著性。如果有一半结果是负的,那你的策略可能只是运气好。
def monte_carlo_simulation(returns, n_simulations=1000):
"""
对策略收益序列进行蒙特卡洛模拟
returns: 策略的每日收益率序列
"""
n = len(returns)
simulated_sharpes = []
for _ in range(n_simulations):
# 随机打乱收益顺序(破坏时间序列结构)
shuffled = np.random.permutation(returns)
# 计算模拟的夏普比率
sim_sharpe = np.mean(shuffled) / np.std(shuffled) * np.sqrt(252)
simulated_sharpes.append(sim_sharpe)
simulated_sharpes = np.array(simulated_sharpes)
# 实际夏普比率
actual_sharpe = np.mean(returns) / np.std(returns) * np.sqrt(252)
# 计算p值:实际夏普在模拟分布中的位置
p_value = np.mean(simulated_sharpes >= actual_sharpe)
return actual_sharpe, simulated_sharpes, p_value
# 假设我们有策略的日收益率
strategy_returns = np.random.randn(500) * 0.02 + 0.001 # 模拟
actual, sim_dist, p_val = monte_carlo_simulation(strategy_returns)
print(f"实际夏普: {actual:.2f}")
print(f"模拟夏普均值: {np.mean(sim_dist):.2f}")
print(f"模拟夏普95%分位: {np.percentile(sim_dist, 95):.2f}")
print(f"p值: {p_val:.3f}")
p值小于0.05,说明策略表现显著优于随机。如果p值大于0.1,那基本可以断定你的策略是过拟合的。
一个实用的判断标准:我一般要求蒙特卡洛模拟的p值小于0.01,同时实际夏普要高于模拟分布的95%分位。两个条件都满足,我才敢上实盘。
21.5 三种方法的组合使用
这三种方法不是互相替代的,而是互补的。我自己的流程是这样的:
| 步骤 | 方法 | 目的 |
|---|---|---|
| 1 | 交叉验证 | 快速筛选,排除明显过拟合的策略 |
| 2 | 滚动窗口测试 | 模拟实盘节奏,观察策略的稳定性 |
| 3 | 蒙特卡洛模拟 | 统计检验,确认策略不是靠运气 |
先做交叉验证,把那些在不同时间段表现差异巨大的策略直接淘汰。然后做滚动窗口测试,看看策略在真实交易节奏下能不能持续盈利。最后用蒙特卡洛模拟做统计检验,确认结果不是偶然。
三个都通过了,我才会考虑实盘。少一个都不行。
21.6 本章小结
过拟合是量化交易的头号杀手。交叉验证、滚动窗口测试、蒙特卡洛模拟,这三板斧能帮你过滤掉大部分伪策略。
记住一句话:回测是用来发现问题的,不是用来证明策略赚钱的。如果你发现一个策略在回测中完美无缺,那它大概率有问题。
嗯,今天就聊到这里。这些方法说起来简单,但真正用好需要大量实践。希望你能少走一些我当年走过的弯路。
最后一个小建议:每次优化参数后,都留出一段“从未见过”的数据做最终验证。这段数据不要参与任何训练和优化。这是最后的防线。