在量化投资领域,波动率与市场收益之间的关系是最常被利用的现象之一。当波动率飙升时,市场往往下跌;当市场平静时,上涨趋势更容易持续。但你知道吗?负收益对未来波动率的放大效应,远大于同等幅度的正收益。这种不对称性被称为“杠杆效应”。
今天这篇文章将带你了解如何使用 GJR-GARCH 模型来捕捉这种不对称性,并构建一个实用的“低波动率满仓、高波动率空仓”的择时策略。即使你不打算实盘交易,这套在云栈社区技术论坛中也常被讨论的框架,也能帮你深入理解Python波动率建模和回测的核心要点。
什么是 GJR-GARCH 模型?
标准的 GARCH 模型对正向和负向冲击的处理是对称的,而 GJR-GARCH(Glosten-Jagannathan-Runkle GARCH)模型增加了一个不对称项,公式如下:
σ²ₜ = ω + αε²ₜ₋₁ + γε²ₜ₋₁Iₜ₋₁ + βσ²ₜ₋₁
其中 Iₜ₋₁ 是一个指示变量:当 εₜ₋₁ < 0(即出现负收益)时,Iₜ₋₁ = 1,否则为 0。当 γ > 0 时,负向冲击对未来波动率的影响比正向冲击更大。这在股票市场中是被广泛验证的经验事实,因此 GJR-GARCH 特别适合用于风险管理。
策略核心思路
这个策略的思路非常直接:
- 使用滚动窗口拟合 GJR-GARCH 模型,估计条件波动率
- 根据波动率的历史分位数判断当前是“高波动”还是“低波动”市场状态
- 低波动时 100% 持仓,高波动时 0% 持仓(即空仓)
核心假设是:在市场波动剧烈时退出,可以有效降低回撤,同时在平静期捕捉大部分上涨收益。
代码实现详解
下面是策略的核心实现。我们使用 Python 的 arch 库来拟合 GJR-GARCH 模型。
1. 策略配置类
from dataclasses import dataclass
@dataclass
class StrategyConfig:
"""策略配置参数"""
ticker: str = “SPY” # 交易标的
benchmark_ticker: str = “^GSPC” # 基准指数
start_date: str = “2010-01-01” # 回测起始日期
end_date: str = “2024-12-31” # 回测结束日期
garch_window: int = 252 # GARCH 滚动窗口(交易日)
vol_percentile_threshold: float = 70.0 # 波动率分位数阈值
low_vol_weight: float = 1.0 # 低波动时的仓位
high_vol_weight: float = 0.0 # 高波动时的仓位
commission_per_share: float = 0.005 # 每股佣金
min_commission: float = 1.0 # 最低佣金
2. 滚动波动率估计
import numpy as np
import pandas as pd
from arch import arch_model
class GJRGARCHEngine:
"""GJR-GARCH 波动率引擎"""
def __init__(self, config: StrategyConfig):
self.config = config
def fit_gjr_garch(self, returns: pd.Series) -> dict:
"""拟合 GJR-GARCH 模型"""
# 创建 GJR-GARCH(1,1,1) 模型,使用 t 分布捕捉肥尾特性
model = arch_model(
returns * 100, # 转换为百分比收益率
vol=‘Garch’,
p=1, # GARCH 项阶数
o=1, # 不对称项阶数(GJR 的核心)
q=1, # ARCH 项阶数
dist=‘t’ # Student-t 分布
)
result = model.fit(disp=‘off’, show_warning=False)
return {
‘model’: result,
‘conditional_vol’: result.conditional_volatility / 100,
‘params’: result.params
}
def compute_rolling_volatility(self, returns: pd.Series) -> pd.Series:
"""计算滚动条件波动率"""
n = len(returns)
window = self.config.garch_window
conditional_vol = pd.Series(index=returns.index, dtype=float)
for i in range(window, n):
window_returns = returns.iloc[i-window:i]
try:
result = self.fit_gjr_garch(window_returns)
# 预测未来 1 天的波动率
forecast = result[‘model’].forecast(horizon=1)
conditional_vol.iloc[i] = np.sqrt(forecast.variance.values[-1, 0])
except:
# 拟合失败时使用简单标准差作为后备
conditional_vol.iloc[i] = window_returns.std()
return conditional_vol
3. 市场状态分类(避免前视偏差)
这是很多回测容易犯错的地方。如果使用整个数据集计算分位数,就会引入“前视偏差”——用未来的信息做过去的决策。正确的做法是使用扩展窗口分位数:
class RegimeClassifier:
"""市场状态分类器"""
def __init__(self, config: StrategyConfig):
self.config = config
def classify_regime(self, conditional_vol: pd.Series) -> pd.Series:
"""基于扩展窗口分位数进行状态分类"""
# 计算每个时间点相对于历史的分位数排名
expanding_percentile = conditional_vol.expanding(min_periods=20).apply(
lambda x: pd.Series(x).rank(pct=True).iloc[-1] * 100,
raw=False
)
regime = pd.Series(index=conditional_vol.index, dtype=int)
# 0 表示低波动状态,1 表示高波动状态
regime[expanding_percentile <= self.config.vol_percentile_threshold] = 0
regime[expanding_percentile > self.config.vol_percentile_threshold] = 1
return regime
4. 信号生成(注意滞后处理)
信号必须滞后一期,确保我们基于昨天的信息做今天的决策:
class StrategyEngine:
"""策略引擎"""
def __init__(self, config: StrategyConfig):
self.config = config
def generate_signals(self, regime: pd.Series) -> pd.Series:
"""生成交易信号"""
target_weight = pd.Series(index=regime.index, dtype=float)
target_weight[regime == 0] = self.config.low_vol_weight # 低波动满仓
target_weight[regime == 1] = self.config.high_vol_weight# 高波动空仓
# 关键:信号滞后一期,避免使用当天信息
signal = target_weight.shift(1)
return signal
回测结果分析
使用 2010-2024 年的 SPY 数据进行回测,结果如下:
| 指标 |
策略 |
基准(买入持有) |
| 总收益 |
163.83% |
352.98% |
| 年化收益 |
7.24% |
11.49% |
| 年化波动率 |
9.99% |
17.21% |
| 夏普比率 |
0.724 |
0.668 |
| 最大回撤 |
-18.95% |
-33.93% |
从绝对收益来看,策略跑输了基准。这不奇怪,因为策略有约 30% 的时间处于空仓状态,错过了一些在高波动期出现的反弹。
但从风险调整后的角度看,结果就不一样了:波动率降低了 42%,最大回撤减少了 44%,夏普比率略高于基准。对于那些无法承受 30% 以上回撤的投资者来说,这种取舍是值得的。
在 15 年间共发生了约 78 次状态切换,平均每年约 5 次,并没有频繁交易的问题。
策略的局限性
- 机会成本真实存在:空仓期间会错过市场反弹,而一些涨幅最大的交易日往往出现在高波动时期
- 阈值选择具有主观性:70% 分位数是经验选择,不同阈值会产生不同结果
- 二元配置过于粗糙:更成熟的做法是根据波动率水平渐进式调整仓位
- 计算量较大:滚动拟合 GARCH 模型计算成本较高,实盘需要优化
- 未考虑滑点:假设能以收盘价成交,对大资金不现实
可能的改进方向
- 采用渐进式仓位调整,而非简单的 0/100% 切换
- 结合 VIX 指数进行信号确认
- 扩展到多资产组合,使用层次风险平价进行分散
- 引入马尔可夫状态切换模型,对状态转换进行概率建模
- 增加趋势过滤器,减少震荡市中的假信号
总结
GJR-GARCH 状态切换策略展示了一种基于原理的波动率择时方法。通过尊重波动率冲击的不对称性,并严格避免前视偏差,我们构建了一个能够显著降低风险的策略框架。
它能跑赢买入持有吗?从绝对收益来看,不能。但从回撤调整后的指标来看,它具有竞争力。对于那些在 30% 以上回撤时难以安睡的投资者来说,这种取舍可能正是他们需要的。
这套代码和分析仅供学习参考。过去的表现不代表未来的结果,在实施任何交易策略之前,请进行充分的研究并考虑咨询专业的财务顾问。
参考文章
- Building a GJR-GARCH Regime-Switching Strategy in Python: https://medium.com/@NFS303/building-a-gjr-garch-regime-switching-strategy-in-python-f633693b56b5