24、过拟合检测:交叉验证、滚动窗口、夏普比率衰减
做量化策略最怕什么?
怕回测曲线漂亮得像假的一样。我见过太多新手,拿着一个在历史数据上夏普比率超过3的策略,兴奋得睡不着觉。结果实盘一跑,直接亏到怀疑人生。
说白了,这就是过拟合。你的策略不是在学习市场规律,而是在死记硬背历史走势。今天我们就聊聊怎么揪出这些「伪策略」。
过拟合的本质
先想一个问题:为什么高频策略特别容易过拟合?
因为高频数据里的噪声太多了。价格跳动、买卖盘口变化,很多都是随机波动。你硬要从里面找规律,就像在沙滩上找形状特别的石头——总能找到,但下次涨潮就没了。
我个人习惯把过拟合分成两类:
- 参数过拟合:把参数调得刚刚好,换一个时间段就失效
- 策略过拟合:策略逻辑本身就是在拟合历史噪声
嗯,这里要注意,参数过拟合还能救,策略过拟合基本就废了。
核心观点:过拟合检测不是事后诸葛亮,而是策略开发中必须嵌入的质检环节。
交叉验证:量化版的「考试」
交叉验证在机器学习里很常见,但在量化里用法不太一样。我们不能随机打乱数据——时间序列的顺序就是生命。
前向验证(Walk-Forward)
我最常用的方法。把数据切成几段,一段训练,一段验证,然后往前滚动。
import pandas as pd
import numpy as np
def walk_forward_validation(data, train_ratio=0.7, step=0.1):
"""
前向验证:逐步滚动训练集和验证集
"""
n = len(data)
train_size = int(n * train_ratio)
step_size = int(n * step)
results = []
start = 0
while start + train_size + step_size <= n:
train = data.iloc[start:start + train_size]
test = data.iloc[start + train_size:start + train_size + step_size]
# 在训练集上优化参数
best_params = optimize_on_train(train)
# 在验证集上测试
perf = evaluate_on_test(test, best_params)
results.append(perf)
start += step_size
return results
我在项目中遇到过一个问题:如果市场结构发生了根本性变化,前向验证也会失效。比如2020年疫情前后的市场,完全是两个世界。这时候我会加一个「结构断点检测」,先判断市场状态是否一致。
K折时间序列交叉验证
这个更严格。把数据分成K份,每次用前i份训练,第i+1份验证。
| 折数 | 训练集 | 验证集 |
|---|---|---|
| 第1折 | 第1份 | 第2份 |
| 第2折 | 第1-2份 | 第3份 |
| 第3折 | 第1-3份 | 第4份 |
| ... | ... | ... |
你想想看,这样每一折的验证集都是「未来数据」,完全避免了未来函数。但代价是计算量很大,高频数据动辄几百万行,跑一次要很久。
我的经验:对于高频策略,K折的K不要太大,3-5折就够了。折数太多,训练集太小,反而容易过拟合到验证集上。
滚动窗口:动态检测过拟合
交叉验证是一次性检测,但过拟合可能随时间慢慢显现。滚动窗口就是持续监控策略的健康状况。
滚动夏普比率
计算每个时间窗口内的夏普比率,看它是否稳定。
def rolling_sharpe(returns, window=252):
"""
滚动夏普比率:检测策略表现的稳定性
"""
rolling_mean = returns.rolling(window).mean()
rolling_std = returns.rolling(window).std()
rolling_sharpe = rolling_mean / rolling_std * np.sqrt(252)
# 计算夏普比率的衰减趋势
sharpe_trend = rolling_sharpe.diff().mean()
return rolling_sharpe, sharpe_trend
如果滚动夏普比率持续下降,说明策略在「失血」。我曾经见过一个策略,回测夏普2.5,但滚动夏普从第3个月开始一路下滑,到第12个月已经跌到0.8。这就是典型的过拟合——策略只在特定的市场环境下有效。
参数稳定性测试
过拟合的策略往往对参数极其敏感。稍微改一点参数,表现就天差地别。
避坑指南:我曾经犯过一个错误——只测试了最优参数附近的几个点。后来发现,最优参数就像一个孤岛,周围全是悬崖。真正的稳健策略,参数应该像高原,周围一片平坦。
测试方法很简单:在最优参数周围随机采样,看表现是否稳定。
def parameter_stability_test(strategy, param_grid, n_samples=100):
"""
参数稳定性测试:随机采样参数组合
"""
results = []
for _ in range(n_samples):
params = random_sample(param_grid)
perf = backtest(strategy, params)
results.append(perf)
# 计算表现的变异系数
cv = np.std(results) / np.mean(results)
# 如果变异系数大于0.3,说明参数敏感度过高
return cv < 0.3
夏普比率衰减:最直观的过拟合指标
夏普比率衰减,说白了就是策略从「回测」到「实盘」的夏普比率下降幅度。这是最残酷的检测,因为它是用真金白银来验证的。
衰减计算公式
我习惯用这个公式:
def sharpe_decay(backtest_sharpe, live_sharpe):
"""
夏普比率衰减率
"""
decay = (backtest_sharpe - live_sharpe) / backtest_sharpe
return decay
# 经验阈值
# decay < 0.2: 正常衰减
# 0.2 < decay < 0.5: 轻度过拟合
# decay > 0.5: 严重过拟合
为什么会有衰减?因为回测时你用了全部数据来优化,实盘时面对的是全新的数据。过拟合越严重,衰减越厉害。
如何量化衰减的显著性
光看衰减率还不够,还要看统计显著性。我常用的是「夏普比率置信区间」方法。
def sharpe_confidence_interval(sharpe, n_observations, confidence=0.95):
"""
夏普比率的置信区间
"""
se = np.sqrt((1 + 0.5 * sharpe**2) / n_observations)
z = stats.norm.ppf((1 + confidence) / 2)
lower = sharpe - z * se
upper = sharpe + z * se
return lower, upper
如果回测夏普的置信区间和实盘夏普的置信区间没有重叠,那基本可以断定过拟合了。
实战建议:我一般要求策略的夏普比率衰减不超过30%。超过这个数,不管回测多漂亮,我都不会实盘。
知识体系总览
下面这张图总结了过拟合检测的完整框架:
实战中的组合拳
单独用任何一种方法都有局限。我个人的标准流程是这样的:
- 先用交叉验证:在开发阶段就排除掉明显过拟合的策略
- 再用滚动窗口:在模拟交易阶段持续监控,看策略是否稳定
- 最后看夏普衰减:实盘后跟踪,一旦衰减超过阈值就预警
举个例子。我之前开发一个高频做市策略,回测夏普4.2,漂亮得不像真的。交叉验证时发现,第3折的验证集上夏普只有1.8。我立刻警觉了,仔细一查,原来策略在低波动率市场表现极差,而回测数据里大部分是高波动率。
后来我加了一个波动率过滤条件,策略才真正变得稳健。实盘跑了半年,夏普衰减只有15%,算是合格了。
一个小技巧:过拟合检测不是一次性的。我建议每周跑一次全量检测,把结果画成趋势图。如果某个指标突然恶化,往往意味着市场结构发生了变化,需要重新审视策略。
记住一句话:回测是策略的简历,实盘是策略的面试。过拟合检测,就是帮你筛掉那些「简历造假」的策略。