策略优化:参数调优、滚动窗口、动态阈值
各位同学,欢迎来到第十五讲。前面我们讲了基差交易的逻辑、统计检验、协整建模,这些都属于「搭框架」的阶段。但框架搭好了,策略就能赚钱吗?
说实话,不一定。我见过太多人,模型跑回测时曲线漂亮得不行,一上实盘就崩。问题出在哪?出在参数上。今天我们就来聊聊策略优化这件事——参数调优、滚动窗口、动态阈值,这三个东西,说白了就是让你的策略「活起来」。
一、参数调优:别让策略死在「死参数」上
先问大家一个问题:你写了一个均值回归策略,阈值设成2倍标准差。为什么是2倍?不是1.5倍?不是2.5倍?
很多人回答:因为教科书上写的。嗯,教科书没错,但市场会变。我2018年做螺纹钢基差套利时,一开始也用的2倍阈值,回测漂亮,实盘前三个月也还行。结果第四个月开始连续亏损,亏得我头皮发麻。后来一查,是市场波动率变了,2倍阈值已经不合适了。
核心观点:参数不是一成不变的。你需要用历史数据去「训练」出最优参数,并且定期更新。
参数调优的常见方法有两种:
- 网格搜索:把参数范围分成网格,遍历所有组合,选回测效果最好的。
- 贝叶斯优化:用概率模型指导搜索,效率更高,适合参数多的情况。
我个人习惯先用网格搜索摸个底,再用贝叶斯精调。下面给个网格搜索的代码示例:
import numpy as np
import pandas as pd
from itertools import product
def grid_search(spread, entry_thresholds, exit_thresholds, window_sizes):
best_params = None
best_sharpe = -np.inf
for entry, exit, window in product(entry_thresholds, exit_thresholds, window_sizes):
# 计算滚动均值和标准差
mean = spread.rolling(window).mean()
std = spread.rolling(window).std()
# 生成信号
z_score = (spread - mean) / std
signals = pd.Series(0, index=spread.index)
signals[z_score > entry] = -1 # 做空价差
signals[z_score < -entry] = 1 # 做多价差
signals[abs(z_score) < exit] = 0 # 平仓
# 计算收益率(简化版)
returns = signals.shift(1) * spread.pct_change()
sharpe = returns.mean() / returns.std() * np.sqrt(252)
if sharpe > best_sharpe:
best_sharpe = sharpe
best_params = (entry, exit, window)
return best_params, best_sharpe
# 使用示例
entry_range = np.arange(1.5, 3.0, 0.25)
exit_range = np.arange(0.5, 1.5, 0.25)
window_range = [20, 30, 40, 50, 60]
best, sharpe = grid_search(spread, entry_range, exit_range, window_range)
print(f"最优参数: entry={best[0]}, exit={best[1]}, window={best[2]}")
print(f"最优夏普: {sharpe:.2f}")
注意:网格搜索容易过拟合。我曾经犯过这个错——把参数调得完美匹配历史数据,结果实盘一塌糊涂。建议用滚动窗口验证,或者留一段「未见数据」做最终测试。
二、滚动窗口:让模型跟上市场的节奏
参数调优解决了「用什么参数」的问题,但还有一个问题:参数多久更新一次?
你想想看,市场风格会变。2019年的波动率水平和2023年能一样吗?协整关系也会漂移。如果你用三年前的参数做今天的交易,那跟刻舟求剑有什么区别?
滚动窗口就是解决这个问题的。它的思路很简单:
- 固定一个窗口长度(比如60个交易日)
- 每天或每周,用最近N天的数据重新计算参数
- 用新参数生成交易信号
这样做的好处是:模型始终在用「最新」的信息。坏处是:计算量大,而且窗口太短容易受噪声影响。
我的经验:窗口长度一般选20到60个交易日。做高频的可以短一些,做日频的长一些。我做过一个测试:用30天窗口 vs 固定参数,滚动窗口的年化收益高出约15%,最大回撤低了8%。
下面是一个滚动窗口的实现:
def rolling_strategy(spread, window=30, entry_threshold=2.0, exit_threshold=1.0):
signals = pd.Series(0, index=spread.index)
positions = pd.Series(0, index=spread.index)
for i in range(window, len(spread)):
# 取最近window个数据点
recent = spread.iloc[i-window:i]
mean = recent.mean()
std = recent.std()
# 计算当前z-score
z = (spread.iloc[i] - mean) / std
# 生成信号
if z > entry_threshold:
signals.iloc[i] = -1
elif z < -entry_threshold:
signals.iloc[i] = 1
elif abs(z) < exit_threshold:
signals.iloc[i] = 0
else:
signals.iloc[i] = signals.iloc[i-1] # 持仓不变
return signals
三、动态阈值:让阈值跟着波动率走
前面我们用了固定阈值(比如2倍标准差)。但市场波动率会变,牛市和熊市的波动率能差好几倍。固定阈值在低波动时可能频繁开仓,在高波动时又开不了仓。
动态阈值就是让阈值本身也「自适应」。怎么做?
- 用近期波动率(比如20日标准差)来调整阈值
- 波动率大时,阈值放大;波动率小时,阈值缩小
- 或者用分位数法:取近期z-score的90%分位数作为阈值
举个例子:
def dynamic_threshold_strategy(spread, window=30, quantile=0.9):
signals = pd.Series(0, index=spread.index)
for i in range(window, len(spread)):
recent = spread.iloc[i-window:i]
mean = recent.mean()
std = recent.std()
z = (spread.iloc[i] - mean) / std
# 用近期z-score的分位数作为动态阈值
recent_z = (recent - mean) / std
threshold = recent_z.quantile(quantile)
if z > threshold:
signals.iloc[i] = -1
elif z < -threshold:
signals.iloc[i] = 1
else:
signals.iloc[i] = signals.iloc[i-1]
return signals
关键点:动态阈值不是万能的。如果市场结构发生根本性变化(比如从趋势市变成震荡市),阈值调整可能跟不上。我建议配合「市场状态识别」一起用,比如用波动率聚类来区分不同市场环境。
四、三者如何配合?一张图说清楚
参数调优、滚动窗口、动态阈值,这三者不是孤立的。它们应该形成一个闭环:
流程是这样的:先用历史数据做参数调优,得到一组初始参数。然后进入滚动窗口模式,定期用最新数据重算参数。同时,动态阈值根据近期波动率调整开平仓阈值。最终生成交易信号,跑完一段后做绩效评估,再把结果反馈回去,继续优化参数。
这个闭环跑起来,你的策略就不再是「死」的了。它会跟着市场一起呼吸。
五、避坑指南
最后,分享几个我踩过的坑:
- 不要过度优化:我曾经把参数调得完美拟合过去三年的数据,结果未来一年亏了20%。记住,回测是历史,不是未来。
- 滚动窗口不是越大越好:窗口太大,模型反应慢;窗口太小,噪声太多。我一般从30天开始试,根据策略频率调整。
- 动态阈值要设上下限:极端行情下,阈值可能变得非常大或非常小。设个上下限,比如1.5到3.0倍标准差,防止策略「抽风」。
- 别忘了交易成本:参数调优时一定要把滑点和手续费算进去。不然你调出来的「最优参数」,实盘可能根本不赚钱。
一个小技巧:做参数调优时,别只看夏普比率。看看最大回撤、胜率、盈亏比。有时候夏普高但回撤大的策略,实盘你根本拿不住。
好了,这一讲就到这里。参数调优、滚动窗口、动态阈值,这三板斧用好了,你的基差套利策略就能从「实验室产品」变成「实战武器」。下一讲我们聊聊风险管理,到时候见。
公众号:蓝海资料掘金营,微信deep3321