有一些量化策略看似违反直觉,却在长期数据中表现出惊人的稳定性。低波动率策略就是其中之一。
今天再次拆解一篇经典论文《The Volatility Effect: Lower Risk without Lower Return》,并结合 Python 回测,看看当年的结论在 1990–2025 这个更长周期中是否依然成立。
老规矩,论文原文放这里。
一、这篇论文研究了什么问题
我们先简单说说这篇论文都讲了些啥。
这篇论文发表于2007 年的Journal of Portfolio Management,作者是荷兰 Robeco 量化团队的 David Blitz 与 Pim van Vliet。
他们的问题其实非常简单:高风险真的意味着高回报吗?
传统 CAPM 模型告诉我们,预期收益应随系统性风险(β)上升而上升。但 Blitz 和 Van Vliet 在大量实证中发现情况并非如此。他们提出并验证了一个非常简单的命题:
低波动股票不仅风险更低,反而在风险调整后收益更高。
这就是所谓的Volatility Effect。
在实验中,他们构造了自己的模型,然后回测1986-2006年这个区间,今天我们将100%还原论文中的方法,然后回测1990到2025年,看看结果如何。
二、如何构造低波动组合
我们先来看看他们是如何构造低波动率组合的。他们的实验设计非常清晰,思路完全数据驱动,没有任何宏观假设或预测性模型。
1. 样本选择
- 研究区间:1986–2006
- 股票池:FTSE Developed World 指数(全球约 2000 只大型股票)
- 数据频率:周度收盘价
- 所有回报都以超额收益(即剔除当地无风险利率)形式计算。
2. 因子构造
在每个月末,他们计算每只股票过去三年(约 156 周)周收益的标准差,即历史波动率。
这一数值代表该股票的稳定程度,而非未来预测。
3. 排序与分组
- 按照波动率从低到高,将所有股票分成 10 组(Deciles)。
- 第 1 组(D1)是最低波动的股票,第 10 组(D10)是最高波动的。
- 每月初等权重买入各组股票,持有一个月后重新排名再平衡。
这一过程构成了最基础的横截面因子回测:按特征排序、分组、计算分组表现。
4. 绩效评估
作者使用了多种风险调整指标:
- 夏普比率(Sharpe Ratio):衡量单位风险下的收益。
- CAPM α 与 β:通过与市场回报做回归,判断是否存在系统性超额收益。
- 最大回撤(MDD) 与上下行市场表现。
结论非常明确:
低波动股票(D1)的平均波动率仅为市场的 2/3,却实现了更高的夏普比率(约 0.72 vs 市场 0.40),CAPM α 年化 +4%。
也就是说风险更低,回报更好,非常的反直觉。
5. 稳健性检验
论文还进行了几类重要的验证:
- 在美、欧、日三个地区独立回测,结果一致
- 与其他因子(价值、规模、动量)做双重排序,确认波动率效应独立存在
- 改变波动率计算窗口(1年 vs 3年)结果依然稳健
三、1990–2025年的低波动策略回测
我们用 Python 重现了论文中的核心逻辑,并扩展到更广泛的美股样本。
整体流程如下:
- 数据频率:日度收盘价(近似周收益计算)
- 波动率窗口:过去 3 年(约 756 个交易日)
- 排序规则:按波动率升序取前 10%(即最低波动组)
- 持仓方式:每月初等权重买入,月度再平衡
- 不加杠杆、不预测、不调参
这种实现方式遵循论文原意:只用历史波动率、定期再平衡、不做预测。
四、回测结果
| 回测区间 | 1990 – 2025 |
| 夏普比率 | 0.98 |
| 年化收益率 | 12.62% |
| 年化波动率 | 12.97% |
| 最大回撤 | 37.75% |
五、完整回测代码
下面是完整的回测代码,如果对你有帮助,请转发给你朋友(或者收藏我们网站,感谢)
# Infoway实时行情接口:www.infoway.io
import math
import backtrader as bt
import pandas as pd
import numpy as np
import pwb_toolbox.backtesting as pwb_bt
import pwb_toolbox.datasets as pwb_ds
class LowVolatilityStrategy(pwb_bt.BaseStrategy):
params = (
("lookback_period", 3 * 252),
("leverage", 1.0),
("indicator_cls", None),
("indicator_kwargs", {}),
)
def __init__(self):
super().__init__()
self.selected_stocks = set()
# 每月在当月第一个交易日开盘时进行再平衡
self.add_timer(when=bt.Timer.SESSION_START, monthdays=[1], monthcarry=True)
def notify_timer(self, timer, when, *args, **kwargs):
self.rebalance()
def next(self):
super().next()
def _weekly_vol(self, closes: np.ndarray) -> float:
"""计算重叠的近似周度(5个交易日)对数收益波动率。"""
# 需要至少3年的历史数据
if len(closes) < self.params.lookback_period:
return np.nan
window = closes[-self.params.lookback_period :]
if np.isnan(window).any():
return np.nan
# 使用重叠的5日对数收益计算波动率(比非重叠样本拥有更多观测值)
weekrets = np.log(window[5:] / window[:-5])
if len(weekrets) < 20:
return np.nan
s = float(np.nanstd(weekrets, ddof=1))
return np.nan if s == 0.0 else s
def rebalance(self):
vols = {}
for d in self.datas:
# 从Backtrader缓存中提取最近的回看期收盘价
closes = np.asarray(
d.close.get(size=self.params.lookback_period), dtype=float
)
v = self._weekly_vol(closes)
if not np.isnan(v):
vols[d._name] = v
if not vols:
return
# 选取波动率最低的十分位股票(至少保留1只)
ranked = sorted(vols.items(), key=lambda kv: kv[1])
k = max(1, math.ceil(0.10 * len(ranked)))
self.selected_stocks = {name for name, _ in ranked[:k]}
tgt_w = self.params.leverage / k
for d in self.datas:
self.order_target_percent(
d, target=(tgt_w if d._name in self.selected_stocks else 0.0)
)
def run_strategy():
# --- 股票池:使用成分股数据 ---
# 如果你的pwb-toolbox已配置分组,“sp500” 会自动展开为所有成分股
# 否则请自行传入股票代码列表(可包含当前及历史成分)
symbols = pwb_ds.get_pricing(symbol_list=["sp500"]).columns.levels[0].tolist()
strategy = pwb_bt.run_strategy(
indicator_cls=None,
indicator_kwargs=dict(),
strategy_cls=LowVolatilityStrategy,
strategy_kwargs={},
symbols=symbols,
start_date="1990-01-01",
cash=100_000.0,
)
return strategy
if __name__ == "__main__":
strategy = run_strategy()