第九章:过拟合与稳健性检验
做策略回测最怕什么?
不是亏钱,是回测曲线漂亮得像假的一样。
我见过太多新手,拿着一个年化80%、回撤不到5%的策略兴奋得睡不着。结果实盘一跑,三个月亏掉一半。嗯,这就是典型的过拟合——你的策略不是在捕捉市场规律,而是在「记住」历史数据里的噪音。
9.1 过拟合:回测的「照骗」
说白了,过拟合就是你的策略太「聪明」了。聪明到把随机波动都当成了规律来学习。
我在做市商策略里遇到过最典型的例子:有人把参数优化到极致,每个品种、每个时间段都用不同的参数。回测曲线完美得像个艺术品。但换到样本外数据,直接崩盘。
过拟合的典型症状:
- 回测收益极高,但样本外表现极差
- 参数稍微一改,结果天差地别
- 策略在特定时间段表现特别好,其他时间一般
- 交易频率异常高,或者持仓时间异常短
为什么会这样?因为市场里有大量的随机噪声。你想想看,如果你用100个参数去拟合一段行情,总能把曲线画得漂漂亮亮。但这就像用高阶多项式去拟合5个点——数学上完美,预测上完蛋。
9.2 交叉验证:给策略做「体检」
交叉验证不是机器学习专属的。做策略回测一样要用。
我个人习惯用K折交叉验证。把数据切成K份,轮流用K-1份训练、1份验证。这样能看出策略在不同数据切片上的稳定性。
# 一个简单的K折交叉验证框架
import numpy as np
import pandas as pd
def k_fold_backtest(data, strategy_func, k=5):
"""
data: 完整的历史数据
strategy_func: 策略函数,输入训练数据,输出交易信号
k: 折数
"""
fold_size = len(data) // k
results = []
for i in range(k):
# 划分训练集和验证集
val_start = i * fold_size
val_end = (i + 1) * fold_size
train_data = pd.concat([data[:val_start], data[val_end:]])
val_data = data[val_start:val_end]
# 在训练集上优化参数
params = strategy_func.optimize(train_data)
# 在验证集上测试
perf = strategy_func.run(val_data, params)
results.append(perf)
# 看各折结果的稳定性
sharpe_ratios = [r['sharpe'] for r in results]
print(f"平均夏普: {np.mean(sharpe_ratios):.2f}")
print(f"夏普标准差: {np.std(sharpe_ratios):.2f}")
return results
我的经验:K值选5或10比较合适。太小了训练数据不够,太大了验证集太短。我曾经试过K=20,结果每折只有两周数据,波动率估计完全失真。
9.3 滚动回测:更贴近实战的检验
交叉验证有个问题——它假设数据是独立同分布的。但金融数据不是。昨天的行情会影响今天,今天的会影响明天。
所以滚动回测更实用。它模拟了真实的交易场景:用过去的数据训练,在未来的数据上验证,然后窗口往前滚动。
def rolling_backtest(data, strategy_func, window_size=252, step=63):
"""
滚动回测实现
window_size: 训练窗口长度(交易日)
step: 滚动步长
"""
results = []
for start in range(0, len(data) - window_size, step):
train_end = start + window_size
val_end = min(train_end + step, len(data))
train_data = data[start:train_end]
val_data = data[train_end:val_end]
# 训练和验证
params = strategy_func.optimize(train_data)
perf = strategy_func.run(val_data, params)
results.append({
'train_period': f"{data.index[start]} - {data.index[train_end]}",
'val_period': f"{data.index[train_end]} - {data.index[val_end]}",
'sharpe': perf['sharpe'],
'max_drawdown': perf['max_drawdown']
})
return pd.DataFrame(results)
注意:滚动窗口的大小要跟你的策略持仓周期匹配。做市商策略通常用3-6个月的窗口。太短了参数不稳定,太长了又跟不上市场变化。
9.4 蒙特卡洛模拟:给策略「压力测试」
蒙特卡洛模拟,说白了就是「如果历史重演,但顺序打乱,你的策略还能赚钱吗?」
我习惯用两种方式做蒙特卡洛:
- 收益序列重采样:把历史每日收益率打乱顺序,重新生成多条路径
- 参数随机化:在最优参数附近随机采样,看策略的鲁棒性
def monte_carlo_shuffle(returns, n_simulations=1000):
"""
收益序列重采样蒙特卡洛
returns: 策略的历史日收益率序列
"""
results = []
for i in range(n_simulations):
# 打乱收益率顺序
shuffled = np.random.permutation(returns)
# 计算累计收益
cum_returns = np.cumprod(1 + shuffled)
# 计算夏普比率
sharpe = np.mean(shuffled) / np.std(shuffled) * np.sqrt(252)
results.append(sharpe)
results = np.array(results)
print(f"原始夏普: {np.mean(returns) / np.std(returns) * np.sqrt(252):.2f}")
print(f"蒙特卡洛平均夏普: {np.mean(results):.2f}")
print(f"蒙特卡洛夏普标准差: {np.std(results):.2f}")
print(f"95%置信区间: [{np.percentile(results, 2.5):.2f}, {np.percentile(results, 97.5):.2f}]")
return results
关键判断标准:
- 如果蒙特卡洛模拟中,超过20%的路径是亏损的——策略可能过度依赖特定的行情顺序
- 如果参数随机化后,夏普比率波动超过0.5——参数敏感度过高,容易过拟合
- 如果95%置信区间包含负值——策略的稳健性存疑
9.5 知识体系总览
下面这张图是我自己总结的过拟合检验流程。每次开发新策略,我都会走一遍这个流程。
9.6 实战中的避坑指南
我曾经踩过一个坑,说出来你可能不信——我用滚动回测检验一个做市策略,结果发现每个滚动窗口的夏普都在2.0以上。当时我高兴坏了,觉得发现了圣杯。
后来仔细一看,原来我犯了个低级错误:训练集和验证集之间有数据泄露。我在训练时用了未来信息来计算波动率。嗯,这种错误在回测框架里特别隐蔽,尤其是当你用pandas的shift函数时,一不小心就shift反了方向。
几个实用建议:
- 做交叉验证时,一定要保证训练集在验证集之前——别用未来的数据预测过去
- 蒙特卡洛模拟至少跑1000次,太少的话统计意义不大
- 如果策略在滚动回测中,有超过30%的窗口是亏损的——放弃它,别犹豫
- 参数敏感性分析:把每个参数在最优值±20%范围内扫描,看策略表现是否稳定
最后说一句:没有哪个策略能通过所有检验。我们的目标不是找到完美的策略,而是找到那些在多种检验下都表现稳健的策略。说白了,就是找那个「怎么折腾都不容易死」的策略。