移动平均波动率策略和稳定性策略,这两个名字听起来像是两个独立的策略,但实际做量化的人都知道,它们经常被放在一起用——一个负责判断"现在能不能进场",另一个负责判断"进场之后能不能拿得住"。我最早接触这两个概念是在做期货CTA策略的时候,当时单纯用均线交叉做趋势,回测曲线看着漂亮,实盘一跑就发现:震荡市里频繁被打脸,趋势来了又拿不住单子。后来才意识到,问题不在均线本身,而在于没有对"波动状态"和"策略稳定性"做量化判断。
这篇文章就把我这些年在这两个策略上的实操经验完整拆一遍。从核心逻辑、参数选择、代码实现,到实盘踩过的坑和排查方法,都会讲到。如果你正在做量化策略,不管是用Python还是其他工具,只要涉及趋势跟踪或者波动率过滤,这篇内容应该都能给你一些直接能用的参考。
1. 两个策略到底在解决什么问题
1.1 移动平均波动率策略的核心逻辑
先说移动平均波动率策略。这个名字其实包含了两层含义:一是"移动平均",二是"波动率"。很多人第一次看到会以为是"移动平均线"和"波动率"两个独立指标的简单叠加,但实际上它的核心思路是:用移动平均来平滑波动率序列,从而判断当前市场是处于高波动还是低波动状态。
为什么要平滑波动率?因为原始的波动率序列(比如日收益率的标准差)噪音太大了。今天市场突然有个消息,波动率飙一下,明天又回落,如果你直接用原始波动率做判断,信号会频繁翻转,根本没法用。移动平均在这里的作用就是过滤掉这些短期噪音,让你看到波动率的"趋势"。
具体来说,常见的做法是计算收益率的标准差,然后对这个标准差序列再做一次移动平均。比如:
import numpy as np import pandas as pd def moving_average_volatility(returns, vol_window=20, ma_window=10): """ returns: 收益率序列 vol_window: 计算波动率的滚动窗口 ma_window: 对波动率做移动平均的窗口 """ # 第一步:计算滚动波动率 rolling_vol = returns.rolling(window=vol_window).std() # 第二步:对波动率序列做移动平均 ma_vol = rolling_vol.rolling(window=ma_window).mean() return rolling_vol, ma_vol这段代码看起来简单,但里面有两个关键参数:vol_window和ma_window。vol_window决定了你用什么周期的波动率来代表"当前状态",ma_window决定了你对这个状态的平滑程度。这两个参数的选择直接影响到策略的灵敏度和稳定性。
我自己的经验是,vol_window一般取10到20个交易日比较合适。太短了噪音大,太长了反应迟钝。ma_window一般取vol_window的一半左右,比如vol_window=20,ma_window=10。这样既能平滑噪音,又不会过度滞后。
那这个波动率算出来之后怎么用?最常见的用法是作为过滤器:当波动率高于某个阈值时,说明市场处于高波动状态,趋势策略容易被打脸,这时候要么降低仓位,要么直接不进场;当波动率低于阈值时,说明市场相对平稳,趋势策略的胜率更高,可以正常进场。
1.2 稳定性策略的核心逻辑
再来说稳定性策略。这个名字听起来很抽象,什么叫"稳定性"?在量化语境下,稳定性通常指的是策略绩效的稳定性,也就是策略在不同市场环境下表现的一致性。但作为策略本身,稳定性策略的核心思路是:通过某种机制来评估当前策略的运行状态,当策略表现不稳定时,主动降低风险暴露。
具体实现上,稳定性策略有很多种做法。我见过最多的两种:一种是基于滚动窗口的绩效评估,比如计算过去N天的夏普比率或最大回撤,当这些指标恶化到一定程度时,触发减仓或平仓;另一种是基于策略信号的一致性,比如当均线频繁交叉、信号方向反复变化时,说明市场没有明确趋势,这时候降低仓位。
我个人的做法更偏向第一种,因为绩效指标是直接可观测的,逻辑也更清晰。比如:
def stability_filter(returns, window=60, sharpe_threshold=0.5): """ returns: 策略的日收益率序列 window: 滚动评估窗口 sharpe_threshold: 夏普比率阈值 """ rolling_sharpe = returns.rolling(window=window).mean() / returns.rolling(window=window).std() * np.sqrt(252) # 当滚动夏普比率低于阈值时,认为策略不稳定 stable_signal = rolling_sharpe > sharpe_threshold return stable_signal这个逻辑很直白:如果过去60天策略的滚动夏普比率低于0.5,说明策略当前的表现不够好,那就降低仓位或者暂停交易。等滚动夏普比率回升到阈值以上,再恢复正常仓位。
但这里有个问题:滚动夏普比率本身是滞后的。当你发现策略不稳定的时候,可能已经亏了不少了。所以稳定性策略通常需要配合其他机制一起用,比如硬止损、最大回撤控制等。
1.3 两个策略为什么要配合使用
单独用移动平均波动率策略,你能判断市场状态,但判断不了自己的策略状态。单独用稳定性策略,你能判断策略状态,但判断不了市场状态。两个配合起来,才能形成一个完整的决策框架:
- 波动率低 + 策略稳定 → 正常仓位,积极进场
- 波动率高 + 策略稳定 → 降低仓位,谨慎进场
- 波动率低 + 策略不稳定 → 降低仓位,观望为主
- 波动率高 + 策略不稳定 → 空仓或极低仓位,等待机会
这个框架看起来简单,但实际用起来效果很好。我自己的实盘经验是,加了这两个过滤器之后,策略的最大回撤能降低30%到40%,虽然收益率也会有所下降,但夏普比率是提升的。对于追求稳健的账户来说,这个 trade-off 是值得的。
2. 核心参数怎么选:从拍脑袋到有依据
2.1 波动率窗口的选择逻辑
波动率窗口的选择,本质上是在"灵敏度"和"稳定性"之间做权衡。窗口越短,波动率对近期价格变化越敏感,但噪音也越大;窗口越长,波动率越稳定,但对市场变化的反应越慢。
我试过很多组参数,最后总结出一个经验法则:波动率窗口应该和你策略的持仓周期匹配。如果你的策略平均持仓周期是5天,那波动率窗口取10到15天比较合适;如果平均持仓周期是20天,那波动率窗口取30到40天比较合适。
为什么?因为波动率的作用是判断"当前市场状态是否适合你的策略"。如果你的策略是短线策略,那你要关注的是短期波动率;如果是中长线策略,那你要关注的是中期波动率。用错周期的波动率,就像用日线均线做日内交易,信号和操作根本对不上。
具体到参数计算,我一般会这样做:
def optimize_vol_window(returns, strategy_returns, vol_windows=[5, 10, 15, 20, 30, 40, 60]): """ 通过网格搜索找到最优的波动率窗口 """ results = [] for vw in vol_windows: vol = returns.rolling(window=vw).std() # 用波动率中位数作为阈值 threshold = vol.median() # 只在低波动时交易 filter_signal = vol < threshold filtered_returns = strategy_returns * filter_signal.shift(1) sharpe = filtered_returns.mean() / filtered_returns.std() * np.sqrt(252) max_dd = (filtered_returns.cumsum() - filtered_returns.cumsum().cummax()).min() results.append({ 'vol_window': vw, 'sharpe': sharpe, 'max_drawdown': max_dd, 'total_return': filtered_returns.sum() }) return pd.DataFrame(results)这个网格搜索的逻辑是:遍历不同的波动率窗口,用波动率中位数作为过滤阈值,只在低波动时交易,然后看哪个窗口下的夏普比率最高、最大回撤最小。当然,这只是初步筛选,最终还要看参数在不同市场环境下的稳健性。
注意:网格搜索容易过拟合。我一般会把数据分成样本内和样本外,样本内找参数,样本外验证。如果样本外表现和样本内差距很大,说明参数过拟合了,要重新选。
2.2 稳定性评估的指标选择
稳定性策略的核心是"用什么指标来评估稳定性"。常见的选择有滚动夏普比率、滚动最大回撤、滚动胜率、滚动盈亏比等。每个指标都有各自的优缺点。
滚动夏普比率是最常用的,因为它同时考虑了收益和风险。但它有个问题:夏普比率对收益率分布的正态性有假设,而实际的策略收益率往往有肥尾,所以夏普比率有时候会失真。
滚动最大回撤更直观,直接反映策略当前的亏损程度。但它的问题是滞后性太强,等你发现最大回撤扩大的时候,可能已经亏了很多了。
滚动胜率和滚动盈亏比更敏感,但噪音也更大。胜率从60%降到50%,可能只是运气不好,不一定代表策略失效了。
我自己的做法是组合使用:以滚动夏普比率为主,滚动最大回撤为辅。当滚动夏普比率低于阈值时,触发减仓;当滚动最大回撤超过阈值时,触发平仓。这样既有预警,又有硬止损。
def combined_stability_filter(returns, window=60, sharpe_threshold=0.5, dd_threshold=-0.15): """ 组合稳定性过滤器 """ rolling_sharpe = returns.rolling(window=window).mean() / returns.rolling(window=window).std() * np.sqrt(252) cumulative = (1 + returns).cumprod() rolling_max = cumulative.rolling(window=window).max() rolling_dd = (cumulative - rolling_max) / rolling_max # 夏普比率低于阈值,减仓 reduce_signal = rolling_sharpe < sharpe_threshold # 回撤超过阈值,平仓 close_signal = rolling_dd < dd_threshold return reduce_signal, close_signal这段代码里,sharpe_threshold=0.5和dd_threshold=-0.15是我常用的默认值。但这两个阈值不是固定的,要根据策略的预期收益和风险偏好来调整。如果你的策略预期夏普比率是1.5,那阈值可以设高一点,比如0.8;如果你的策略预期夏普比率只有0.8,那阈值设0.5就差不多了。
2.3 参数稳健性检验的实操方法
参数选出来之后,一定要做稳健性检验。我见过太多人,回测的时候参数调得漂漂亮亮,实盘一跑就崩,就是因为没有做稳健性检验。
稳健性检验的方法有很多,我常用的有三种:
第一种是参数敏感性分析。把参数在合理范围内微调,看策略表现是否稳定。比如波动率窗口从20调到18或22,如果夏普比率变化不大,说明参数不敏感,比较稳健;如果夏普比率大幅下降,说明参数过拟合了。
第二种是不同市场环境的检验。把数据分成趋势市、震荡市、高波动市、低波动市,分别看策略表现。如果策略只在某一种市场环境下表现好,那就要小心了。
第三种是蒙特卡洛模拟。对收益率序列做随机重排,看策略表现的分布。如果策略在大部分重排下都能盈利,说明策略有真实的alpha;如果只有少数重排下盈利,说明策略可能是运气。
def monte_carlo_test(returns, n_simulations=1000): """ 蒙特卡洛模拟检验 """ n = len(returns) sharpes = [] for _ in range(n_simulations): # 随机重排收益率序列 shuffled = np.random.permutation(returns) sharpe = shuffled.mean() / shuffled.std() * np.sqrt(252) sharpes.append(sharpe) # 计算原始夏普比率在模拟分布中的百分位 original_sharpe = returns.mean() / returns.std() * np.sqrt(252) percentile = (np.array(sharpes) < original_sharpe).mean() return original_sharpe, np.percentile(sharpes, [5, 50, 95]), percentile这个蒙特卡洛检验的逻辑是:如果原始策略的夏普比率在模拟分布的95%分位以上,说明策略表现显著优于随机;如果在50%分位附近,说明策略可能只是运气。
3. 完整策略实现:从数据到信号
3.1 数据准备与预处理
做量化策略,数据是基础。我一般用日线数据,因为日线数据噪音相对小,而且交易成本可控。数据来源可以是本地数据库,也可以是从数据接口获取。这里假设你已经有了一个包含OHLCV的DataFrame。
数据预处理主要包括几步:处理缺失值、处理异常值、计算收益率。
def prepare_data(df): """ df: 包含open, high, low, close, volume的DataFrame """ # 处理缺失值 df = df.dropna() # 处理异常值:用3倍标准差法 returns = df['close'].pct_change() mean = returns.mean() std = returns.std() outliers = abs(returns - mean) > 3 * std df.loc[outliers, 'close'] = df['close'].shift(1)[outliers] * (1 + mean) # 重新计算收益率 df['returns'] = df['close'].pct_change() return df异常值处理这一步很多人会忽略,但实际很重要。A股市场有时候会有涨跌停,或者数据源本身有错误,如果不处理,波动率计算会失真。我用的是3倍标准差法,把超过3倍标准差的收益率替换为均值附近的数值。这个方法简单粗暴,但效果不错。
3.2 波动率计算与信号生成
数据准备好之后,就可以计算波动率和生成信号了。
def generate_signals(df, vol_window=20, ma_window=10, vol_threshold_pct=0.5): """ 生成交易信号 """ # 计算滚动波动率 df['vol'] = df['returns'].rolling(window=vol_window).std() # 对波动率做移动平均 df['ma_vol'] = df['vol'].rolling(window=ma_window).mean() # 计算波动率阈值:用历史分位数 df['vol_threshold'] = df['ma_vol'].rolling(window=252).quantile(vol_threshold_pct) # 波动率低于阈值时,允许交易 df['vol_filter'] = df['ma_vol'] < df['vol_threshold'] # 基础均线信号 df['ma_fast'] = df['close'].rolling(window=10).mean() df['ma_slow'] = df['close'].rolling(window=30).mean() df['trend_signal'] = np.where(df['ma_fast'] > df['ma_slow'], 1, -1) # 最终信号:趋势信号 + 波动率过滤 df['signal'] = df['trend_signal'] * df['vol_filter'].astype(int) return df这段代码里,vol_threshold_pct=0.5表示用过去252天波动率的中位数作为阈值。低于中位数,说明当前波动率处于历史较低水平,允许交易;高于中位数,说明波动率偏高,过滤掉信号。
为什么用中位数而不是固定值?因为不同品种的波动率水平差异很大。比如螺纹钢的波动率和豆粕的波动率完全不在一个量级,用固定阈值根本没法通用。用历史分位数就解决了这个问题,不管什么品种,都是跟自己的历史比。
3.3 稳定性过滤与仓位管理
信号生成之后,还要加上稳定性过滤和仓位管理。
def apply_stability_filter(df, window=60, sharpe_threshold=0.5, dd_threshold=-0.15): """ 应用稳定性过滤 """ # 计算策略的滚动收益率 df['strategy_returns'] = df['signal'].shift(1) * df['returns'] # 滚动夏普比率 rolling_mean = df['strategy_returns'].rolling(window=window).mean() rolling_std = df['strategy_returns'].rolling(window=window).std() df['rolling_sharpe'] = rolling_mean / rolling_std * np.sqrt(252) # 滚动最大回撤 cumulative = (1 + df['strategy_returns']).cumprod() rolling_max = cumulative.rolling(window=window).max() df['rolling_dd'] = (cumulative - rolling_max) / rolling_max # 仓位系数 df['position_size'] = 1.0 # 默认满仓 # 夏普比率低于阈值,仓位减半 df.loc[df['rolling_sharpe'] < sharpe_threshold, 'position_size'] = 0.5 # 回撤超过阈值,空仓 df.loc[df['rolling_dd'] < dd_threshold, 'position_size'] = 0.0 # 最终仓位 df['final_position'] = df['signal'] * df['position_size'] return df这段代码的逻辑是:先计算策略的滚动夏普比率和滚动最大回撤,然后根据这两个指标调整仓位。夏普比率低于0.5,仓位减半;回撤超过15%,空仓。
这里有个细节要注意:df['signal'].shift(1)这一步很重要。信号是基于当天收盘价计算的,但交易要等到第二天开盘才能执行,所以要用前一天的信号来计算今天的收益。如果不shift,就会引入未来函数,回测结果会虚高。
3.4 回测结果与绩效评估
策略实现完之后,要做回测和绩效评估。
def backtest_performance(df): """ 计算回测绩效指标 """ strategy_returns = df['final_position'].shift(1) * df['returns'] strategy_returns = strategy_returns.dropna() # 累计收益 cumulative_return = (1 + strategy_returns).cumprod() - 1 # 年化收益 annual_return = strategy_returns.mean() * 252 # 年化波动率 annual_vol = strategy_returns.std() * np.sqrt(252) # 夏普比率 sharpe = annual_return / annual_vol # 最大回撤 cumulative = (1 + strategy_returns).cumprod() max_dd = (cumulative - cumulative.cummax()).min() / cumulative.cummax().max() # 胜率 win_rate = (strategy_returns > 0).mean() # 盈亏比 avg_win = strategy_returns[strategy_returns > 0].mean() avg_loss = abs(strategy_returns[strategy_returns < 0].mean()) profit_loss_ratio = avg_win / avg_loss return { 'total_return': cumulative_return.iloc[-1], 'annual_return': annual_return, 'annual_vol': annual_vol, 'sharpe': sharpe, 'max_drawdown': max_dd, 'win_rate': win_rate, 'profit_loss_ratio': profit_loss_ratio }这些指标里,我最关注的是夏普比率和最大回撤。夏普比率反映的是风险调整后的收益,最大回撤反映的是最坏情况下的亏损。对于实盘来说,最大回撤比收益率更重要,因为回撤太大,心理上扛不住,很容易在底部割肉。
4. 实盘踩坑与排查技巧
4.1 波动率过滤失效的三种典型场景
波动率过滤不是万能的,我实盘遇到过三种失效场景。
第一种是波动率突变。比如某天突然出个政策消息,波动率从低位直接跳到高位,但你的移动平均波动率还没反应过来,因为它是滞后的。这时候策略可能在高波动率环境下开了仓,然后被打脸。
应对方法:加一个硬性的波动率上限。比如当天的波动率超过过去252天波动率的90%分位时,不管移动平均波动率是多少,直接过滤掉信号。
第二种是波动率持续低位。有时候市场会进入一个长期低波动的状态,波动率一直低于阈值,策略一直满仓。但低波动不代表没有风险,有时候低波动之后突然来一波大行情,策略来不及反应。
应对方法:加一个时间过滤。如果连续N天波动率都低于阈值,主动降低仓位,防止过度暴露。
第三种是波动率阈值本身失效。用历史分位数做阈值,前提是波动率的分布是稳定的。但如果市场结构发生变化,比如从高波动 regime 切换到低波动 regime,历史分位数就不准了。
应对方法:用滚动窗口计算分位数,而不是用全历史。比如用过去252天的分位数,而不是过去5年的分位数。这样阈值能跟上市场结构的变化。
4.2 稳定性过滤的滞后问题与解决方案
稳定性过滤最大的问题是滞后。滚动夏普比率是基于过去60天的数据计算的,当你发现夏普比率低于阈值的时候,可能已经亏了20天了。
我试过几种解决方案:
第一种是缩短评估窗口。把60天缩短到20天,灵敏度提高了,但噪音也大了。有时候策略只是正常回撤,但20天窗口的夏普比率已经低于阈值了,导致过早减仓。
第二种是加一个确认机制。不是夏普比率一低于阈值就减仓,而是连续N天低于阈值才减仓。这样能过滤掉一些噪音,但滞后更严重了。
第三种是结合其他指标。比如同时看滚动胜率和滚动盈亏比,多个指标同时恶化才减仓。这样能提高准确性,但复杂度也提高了。
我最后采用的是组合方案:20天窗口的夏普比率做预警,60天窗口的夏普比率做确认。20天窗口低于阈值时,仓位减到70%;60天窗口也低于阈值时,仓位减到30%。这样既有灵敏度,又有确认机制。
4.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 回测收益高,实盘亏损 | 过拟合 | 样本外检验 | 简化参数,增加稳健性检验 |
| 波动率过滤后交易次数太少 | 阈值太严 | 统计过滤掉的信号比例 | 调整分位数阈值,如从0.5调到0.6 |
| 稳定性过滤频繁触发 | 窗口太短 | 检查滚动夏普比率的波动 | 加长窗口,或加确认机制 |
| 最大回撤超过预期 | 止损不及时 | 检查回撤阈值设置 | 降低回撤阈值,或加硬止损 |
| 策略在震荡市表现差 | 趋势策略通病 | 分市场环境回测 | 加震荡市过滤,或改用均值回归 |
| 参数敏感度高 | 过拟合 | 参数敏感性分析 | 选择参数平原,而非参数尖峰 |
这张表是我实盘排查问题的速查表,基本上覆盖了80%的常见问题。每次策略表现不及预期,我都会按这个表过一遍,通常能快速定位问题。
4.4 实盘执行中的几个关键细节
最后说几个实盘执行中的细节,这些都是回测中容易忽略,但实盘中很致命的。
第一个是滑点。回测中我们通常假设按收盘价成交,但实盘中你不可能正好在收盘价成交。尤其是波动率高的品种,滑点可能吃掉你一半的利润。我的做法是在回测中加0.1%到0.2%的滑点,这样回测结果更接近实盘。
第二个是手续费。不同品种的手续费差异很大,有的按手数收,有的按成交金额收。回测中一定要把手续费算进去,否则回测收益会虚高。
第三个是流动性。有些品种流动性差,你想开仓的时候开不进去,想平仓的时候平不出来。我的做法是只交易日均成交额在1亿以上的品种,流动性有保障。
第四个是隔夜跳空。期货市场有隔夜跳空,股票市场有开盘跳空。如果你的策略是收盘价信号、第二天开盘价交易,那跳空会影响你的成交价。我的做法是在回测中用第二天开盘价作为成交价,而不是用收盘价。
def realistic_backtest(df, slippage=0.001, commission=0.0003): """ 更真实的回测:考虑滑点和手续费 """ # 用第二天开盘价成交 df['trade_price'] = df['open'].shift(-1) # 计算实际收益率 df['actual_returns'] = (df['trade_price'].shift(-1) / df['trade_price'] - 1) # 扣除滑点和手续费 df['actual_returns'] = df['actual_returns'] - slippage - commission # 策略收益 df['strategy_returns'] = df['final_position'].shift(1) * df['actual_returns'] return df这段代码里,slippage=0.001表示单边滑点0.1%,commission=0.0003表示单边手续费0.03%。这两个数值可以根据实际交易品种调整。加上这些之后,回测结果会下降不少,但更接近实盘。
我个人在实际操作中的体会是,量化策略的核心不是找到什么神奇的指标,而是把简单的逻辑执行到位,把风险控制做好。移动平均波动率策略和稳定性策略,本质上都是风险控制的工具。它们不会让你的策略收益翻倍,但能让你的策略活得更久。在这个市场里,活得久比赚得快更重要。