【Python量化波动率实战 04】低波动异象:低波组真的跑赢高波组吗

0 阅读7分钟

上一篇我们用波动率目标法把仓位变成了机械规则。这篇做个反向验证:业界流传很广的"低波动异象"——波动率低的股票长期反而跑赢高波动的——在数据里到底长什么样?

多数人只听过结论没见过算过程。今天我们手搓一个分层实验:把一组股票按波动率分成低波组和高波组,各拿一组钱跟踪一段时间,看谁赢。


本文你将得到什么

  1. 低波动异象的完整分层实验设计:形成期分组 + 持有期比较(无前视)。
  2. 十只股票的股票池分组实证:波动率排名、组内收益、组内波动与回撤。
  3. 一个必须诚实面对的结果:本样本里异象不成立,以及为什么这不奇怪。
  4. 判断"异象是否在你样本里成立"的可复制代码框架。

一、实验设计:两段式分层

1. 形成期:用前 20 个日收益算每只股票的年化波动率
2. 分组:波动率最低的 3 只 = 低波组,最高的 3 只 = 高波组
3. 持有期:之后的交易日里,两组各自等权持有,比较收益 / 波动 / 回撤

关键在信息集纪律:分组只能用形成期的数据。如果你"偷看"了持有期的表现再分组,实验就作废了——这是所有因子分层回测的第一铁律。


二、异象到底在说什么

低波动异象(Low Volatility Anomaly)的经典解释:多数资金有"博高波"倾向,把高波动股买到溢价、低波动股卖到折价,长期看低波动股反而占便宜。注意两个限定词:长期、大样本。它是一条统计规律,不是每一期的保证——短样本里一只股票的极端行情就能把整组带偏。下面你会亲眼看到这一点。


可运行代码

pip install mairui pandas numpy
from mairui import Client
import pandas as pd
import numpy as np
import random

LICENCE = "LICENCE-66D8-9F96-0C7F0FBCD073"  # 演示证书,请换成你自己的正式证书
client = Client(LICENCE)

TRADING_DAYS = 244   # A股年化交易日
VOL_WIN = 20         # 形成期波动率窗口(20 个日收益)
TOP_N = 3            # 低波组 / 高波组各取 3 只
POOL = ["600519", "000858", "601318", "600036", "300750",
        "002594", "601088", "600030", "688981", "603259"]
SEED = 20261013


def synth_ohlc(bars, seed):
    # 演示证书返回固定样例数据;用确定性合成路径模拟行情起伏(慢变波动率+波动聚集)。
    # 换成你自己的正式证书后删掉本函数调用即可,其余逻辑零改动。
    rng = random.Random(seed)
    g = 0.0
    out, prev_c = [], None
    for b in bars:
        g = 0.9 * g + rng.gauss(0, 0.25)
        sigma = 0.015 * float(np.exp(g))
        if prev_c is None:
            c = float(b["c"]); o = c * (1 + rng.gauss(0, 0.3 * sigma))
        else:
            c = prev_c * (1 + rng.gauss(0, sigma))
            o = prev_c * (1 + rng.gauss(0, 0.3 * sigma))
        h = max(o, c) * (1 + abs(rng.gauss(0, 0.5 * sigma)))
        l = min(o, c) * (1 - abs(rng.gauss(0, 0.5 * sigma)))
        out.append({"t": b["t"], "c": round(c, 2)})
        prev_c = c
    return out


# 拉数 + 对数收益
rets = {}
for code in POOL:
    bars = client.stock_history(code, "d", "n", st="20260701", et="20261012")
    bars = synth_ohlc(bars, seed=SEED + int(code))
    closes = pd.DataFrame(bars)["c"]
    rets[code] = np.log(closes / closes.shift(1)).to_numpy()

n_bars = len(next(iter(rets.values())))
form_end = VOL_WIN + 1            # 形成期 = 前 21 根 bar(20 个日收益)
hold = slice(form_end, n_bars)    # 持有期 = 其余 bar

# 形成期波动率排名
rows = []
for code in POOL:
    r = rets[code]
    vol = np.nanstd(r[1:form_end], ddof=1) * np.sqrt(TRADING_DAYS)
    cum = np.exp(np.nansum(r[hold])) - 1
    rows.append((code, vol, cum))
tbl = pd.DataFrame(rows, columns=["code", "form_vol", "hold_ret"]).sort_values("form_vol")
for _, row in tbl.iterrows():
    print(f"{row['code']}: 形成期波动 {row['form_vol']*100:5.2f}% | 持有期收益 {row['hold_ret']*100:+6.2f}%")

low_g = tbl.head(TOP_N)["code"].tolist()
high_g = tbl.tail(TOP_N)["code"].tolist()


def group_stats(codes):
    grp = np.nanmean(np.vstack([rets[c][hold] for c in codes]), axis=0)
    nav = np.cumprod(1 + np.nan_to_num(grp))
    return (np.exp(np.nansum(grp)) - 1,
            np.nanstd(grp, ddof=1) * np.sqrt(TRADING_DAYS),
            ((nav / np.maximum.accumulate(nav)) - 1).min())


for name, codes in (("低波组", low_g), ("高波组", high_g)):
    cum, vol, mdd = group_stats(codes)
    print(f"{name}({'/'.join(codes)}): 累计 {cum*100:+.2f}% | "
          f"年化波动 {vol*100:.2f}% | 最大回撤 {mdd*100:.2f}%")

说明:演示证书返回固定样例数据,代码用确定性合成路径模拟行情起伏;换成你自己的正式证书后删掉 synth_ohlc 调用即为真实行情,实验逻辑零改动。年化口径按 A 股惯例取 244 个交易日。


运行结果

002594: 形成期波动 10.45% | 持有期收益  +4.71%
300750: 形成期波动 12.14% | 持有期收益  -0.25%
600036: 形成期波动 12.48% | 持有期收益 -10.44%
601318: 形成期波动 14.88% | 持有期收益  -3.33%
603259: 形成期波动 16.48% | 持有期收益 +20.25%
688981: 形成期波动 21.48% | 持有期收益  -9.23%
601088: 形成期波动 26.04% | 持有期收益  -6.03%
600519: 形成期波动 31.59% | 持有期收益 +30.11%
000858: 形成期波动 33.18% | 持有期收益  -0.80%
600030: 形成期波动 34.07% | 持有期收益 -18.39%

低波组(002594/300750/600036): 累计 -2.20% | 年化波动 11.45% | 最大回撤 -3.98%
高波组(600519/000858/600030): 累计 +1.75% | 年化波动 25.79% | 最大回撤 -12.41%

(示例基于 50 个交易日的演示样本、29 个持有期交易日、10 只股票测算,样本较短,结果为教学演示而非普遍规律;演示证书返回就近样例数据,申请正式证书可拉取全市场全量历史做更长样本验证。)


怎么读这组数字:异象这次没赢,但这恰恰是最有价值的部分

  1. 收益维度:低波组输了 3.95 个百分点(-2.20% vs +1.75%)。原因一眼可见——高波组里的 600519 持有期涨了 +30.11%,一只股票的极端行情把整组拉了起来。这正是"异象是统计规律、不是每期保证"的现场演示:小样本 + 小池子里,个股极端值可以吞掉分组效应。
  2. 风险维度:低波组完胜。年化波动 11.45% vs 25.79%,最大回撤 -3.98% vs -12.41%——波动和回撤都只有高波组的约三分之一。低波动策略的本质是用更少的颠簸走完同样的路,收益端的"异象"只是长期统计下的副产物。
  3. 正确的姿势:把这次结果记成"异象不保证每期赢",而不是"异象是假的"。要严肃验证异象,需要全市场几千只股票、按月滚动分组、跑十年以上的样本——那是正式证书 + 数据管道才能干的事,本篇给你的是可复用的实验框架。

三个容易踩的坑

  1. 分组用了未来数据:形成期与持有期必须严格分开,form_end 之后的数据在分组时一个字节都不能碰。前视偏差在分层实验里尤其隐蔽,因为"结果看起来很合理"。
  2. 用日收益的简单标准差当波动率没问题,但别忘了年化:日波动 2% 听着很小,年化就是 2%×√244≈31%。分组阈值、展示口径都统一用年化,不然低波组和高波组的界限会失真。
  3. 等权组合同样要防前视:组内收益直接取成员日收益的均值(每日再平衡口径),不要用"今天知道的权重"去乘"今天的收益"。

小结

本样本给出了一个诚实的负结果:低波组收益跑输,但波动与回撤减半——异象的"风险减半"面在,"收益反超"面没显形,被一只极端强势股吞掉了。这比一个"异象成立"的漂亮结果更有教学价值:任何因子异象都要靠大样本、长周期说话,一次分组实验只配回答"框架能不能跑通"。

下一篇回到组合本身:五只股票拿到手,你真的分散了吗?用滚动相关系数和分散化比率,量一量"分散"这个最常被高估的词。

下篇预告:【Python量化波动率实战 05】你的组合真的分散了吗:滚动相关与分散化收益。


免责声明

本文仅为技术方法演示,不构成任何投资建议。文中示例数据仅用于教学,历史表现不代表未来收益。投资有风险,决策需谨慎。

代码与文档可参考 GitHub:github.com/MaiRuiApi