有一些量化策略看似违反直觉,却在长期数据中表现出惊人的稳定性。低波动率策略就是其中之一。

今天再次拆解一篇经典论文《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 重现了论文中的核心逻辑,并扩展到更广泛的美股样本。

整体流程如下:

  1. 数据频率:日度收盘价(近似周收益计算)
  2. 波动率窗口:过去 3 年(约 756 个交易日)
  3. 排序规则:按波动率升序取前 10%(即最低波动组)
  4. 持仓方式:每月初等权重买入,月度再平衡
  5. 不加杠杆、不预测、不调参

这种实现方式遵循论文原意:只用历史波动率、定期再平衡、不做预测。

四、回测结果

回测区间1990 – 2025
夏普比率0.98
年化收益率12.62%
年化波动率12.97%
最大回撤37.75%

五、完整回测代码

下面是完整的回测代码,如果对你有帮助,请转发给你朋友(或者收藏我们网站,感谢)

Python
# 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()