移动平均波动率策略与稳定性策略:量化风险控制实战指南
移动平均波动率策略和稳定性策略这两个名字听起来像是两个独立的策略但实际做量化的人都知道它们经常被放在一起用——一个负责判断现在能不能进场另一个负责判断进场之后能不能拿得住。我最早接触这两个概念是在做期货CTA策略的时候当时单纯用均线交叉做趋势回测曲线看着漂亮实盘一跑就发现震荡市里频繁被打脸趋势来了又拿不住单子。后来才意识到问题不在均线本身而在于没有对波动状态和策略稳定性做量化判断。这篇文章就把我这些年在这两个策略上的实操经验完整拆一遍。从核心逻辑、参数选择、代码实现到实盘踩过的坑和排查方法都会讲到。如果你正在做量化策略不管是用Python还是其他工具只要涉及趋势跟踪或者波动率过滤这篇内容应该都能给你一些直接能用的参考。1. 两个策略到底在解决什么问题1.1 移动平均波动率策略的核心逻辑先说移动平均波动率策略。这个名字其实包含了两层含义一是移动平均二是波动率。很多人第一次看到会以为是移动平均线和波动率两个独立指标的简单叠加但实际上它的核心思路是用移动平均来平滑波动率序列从而判断当前市场是处于高波动还是低波动状态。为什么要平滑波动率因为原始的波动率序列比如日收益率的标准差噪音太大了。今天市场突然有个消息波动率飙一下明天又回落如果你直接用原始波动率做判断信号会频繁翻转根本没法用。移动平均在这里的作用就是过滤掉这些短期噪音让你看到波动率的趋势。具体来说常见的做法是计算收益率的标准差然后对这个标准差序列再做一次移动平均。比如import numpy as np import pandas as pd def moving_average_volatility(returns, vol_window20, ma_window10): returns: 收益率序列 vol_window: 计算波动率的滚动窗口 ma_window: 对波动率做移动平均的窗口 # 第一步计算滚动波动率 rolling_vol returns.rolling(windowvol_window).std() # 第二步对波动率序列做移动平均 ma_vol rolling_vol.rolling(windowma_window).mean() return rolling_vol, ma_vol这段代码看起来简单但里面有两个关键参数vol_window和ma_window。vol_window决定了你用什么周期的波动率来代表当前状态ma_window决定了你对这个状态的平滑程度。这两个参数的选择直接影响到策略的灵敏度和稳定性。我自己的经验是vol_window一般取10到20个交易日比较合适。太短了噪音大太长了反应迟钝。ma_window一般取vol_window的一半左右比如vol_window20ma_window10。这样既能平滑噪音又不会过度滞后。那这个波动率算出来之后怎么用最常见的用法是作为过滤器当波动率高于某个阈值时说明市场处于高波动状态趋势策略容易被打脸这时候要么降低仓位要么直接不进场当波动率低于阈值时说明市场相对平稳趋势策略的胜率更高可以正常进场。1.2 稳定性策略的核心逻辑再来说稳定性策略。这个名字听起来很抽象什么叫稳定性在量化语境下稳定性通常指的是策略绩效的稳定性也就是策略在不同市场环境下表现的一致性。但作为策略本身稳定性策略的核心思路是通过某种机制来评估当前策略的运行状态当策略表现不稳定时主动降低风险暴露。具体实现上稳定性策略有很多种做法。我见过最多的两种一种是基于滚动窗口的绩效评估比如计算过去N天的夏普比率或最大回撤当这些指标恶化到一定程度时触发减仓或平仓另一种是基于策略信号的一致性比如当均线频繁交叉、信号方向反复变化时说明市场没有明确趋势这时候降低仓位。我个人的做法更偏向第一种因为绩效指标是直接可观测的逻辑也更清晰。比如def stability_filter(returns, window60, sharpe_threshold0.5): returns: 策略的日收益率序列 window: 滚动评估窗口 sharpe_threshold: 夏普比率阈值 rolling_sharpe returns.rolling(windowwindow).mean() / returns.rolling(windowwindow).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(windowvw).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, window60, sharpe_threshold0.5, dd_threshold-0.15): 组合稳定性过滤器 rolling_sharpe returns.rolling(windowwindow).mean() / returns.rolling(windowwindow).std() * np.sqrt(252) cumulative (1 returns).cumprod() rolling_max cumulative.rolling(windowwindow).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_threshold0.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_simulations1000): 蒙特卡洛模拟检验 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_window20, ma_window10, vol_threshold_pct0.5): 生成交易信号 # 计算滚动波动率 df[vol] df[returns].rolling(windowvol_window).std() # 对波动率做移动平均 df[ma_vol] df[vol].rolling(windowma_window).mean() # 计算波动率阈值用历史分位数 df[vol_threshold] df[ma_vol].rolling(window252).quantile(vol_threshold_pct) # 波动率低于阈值时允许交易 df[vol_filter] df[ma_vol] df[vol_threshold] # 基础均线信号 df[ma_fast] df[close].rolling(window10).mean() df[ma_slow] df[close].rolling(window30).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_pct0.5表示用过去252天波动率的中位数作为阈值。低于中位数说明当前波动率处于历史较低水平允许交易高于中位数说明波动率偏高过滤掉信号。为什么用中位数而不是固定值因为不同品种的波动率水平差异很大。比如螺纹钢的波动率和豆粕的波动率完全不在一个量级用固定阈值根本没法通用。用历史分位数就解决了这个问题不管什么品种都是跟自己的历史比。3.3 稳定性过滤与仓位管理信号生成之后还要加上稳定性过滤和仓位管理。def apply_stability_filter(df, window60, sharpe_threshold0.5, dd_threshold-0.15): 应用稳定性过滤 # 计算策略的滚动收益率 df[strategy_returns] df[signal].shift(1) * df[returns] # 滚动夏普比率 rolling_mean df[strategy_returns].rolling(windowwindow).mean() rolling_std df[strategy_returns].rolling(windowwindow).std() df[rolling_sharpe] rolling_mean / rolling_std * np.sqrt(252) # 滚动最大回撤 cumulative (1 df[strategy_returns]).cumprod() rolling_max cumulative.rolling(windowwindow).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, slippage0.001, commission0.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这段代码里slippage0.001表示单边滑点0.1%commission0.0003表示单边手续费0.03%。这两个数值可以根据实际交易品种调整。加上这些之后回测结果会下降不少但更接近实盘。我个人在实际操作中的体会是量化策略的核心不是找到什么神奇的指标而是把简单的逻辑执行到位把风险控制做好。移动平均波动率策略和稳定性策略本质上都是风险控制的工具。它们不会让你的策略收益翻倍但能让你的策略活得更久。在这个市场里活得久比赚得快更重要。