上一篇我们用波动率目标法把仓位变成了机械规则。这篇做个反向验证:业界流传很广的"低波动异象"------波动率低的股票长期反而跑赢高波动的------在数据里到底长什么样?
多数人只听过结论没见过算过程。今天我们手搓一个分层实验:把一组股票按波动率分成低波组和高波组,各拿一组钱跟踪一段时间,看谁赢。
本文你将得到什么
- 低波动异象的完整分层实验设计:形成期分组 + 持有期比较(无前视)。
- 十只股票的股票池分组实证:波动率排名、组内收益、组内波动与回撤。
- 一个必须诚实面对的结果:本样本里异象不成立,以及为什么这不奇怪。
- 判断"异象是否在你样本里成立"的可复制代码框架。
一、实验设计:两段式分层
text
1. 形成期:用前 20 个日收益算每只股票的年化波动率
2. 分组:波动率最低的 3 只 = 低波组,最高的 3 只 = 高波组
3. 持有期:之后的交易日里,两组各自等权持有,比较收益 / 波动 / 回撤
关键在信息集纪律:分组只能用形成期的数据。如果你"偷看"了持有期的表现再分组,实验就作废了------这是所有因子分层回测的第一铁律。
二、异象到底在说什么
低波动异象(Low Volatility Anomaly)的经典解释:多数资金有"博高波"倾向,把高波动股买到溢价、低波动股卖到折价,长期看低波动股反而占便宜。注意两个限定词:长期 、大样本。它是一条统计规律,不是每一期的保证------短样本里一只股票的极端行情就能把整组带偏。下面你会亲眼看到这一点。
可运行代码
bash
pip install mairui pandas numpy
python
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 个交易日。
运行结果
text
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 只股票测算,样本较短,结果为教学演示而非普遍规律;演示证书返回就近样例数据,申请正式证书可拉取全市场全量历史做更长样本验证。)
怎么读这组数字:异象这次没赢,但这恰恰是最有价值的部分
- 收益维度:低波组输了 3.95 个百分点 (-2.20% vs +1.75%)。原因一眼可见------高波组里的 600519 持有期涨了 +30.11%,一只股票的极端行情把整组拉了起来。这正是"异象是统计规律、不是每期保证"的现场演示:小样本 + 小池子里,个股极端值可以吞掉分组效应。
- 风险维度:低波组完胜 。年化波动 11.45% vs 25.79%,最大回撤 -3.98% vs -12.41%------波动和回撤都只有高波组的约三分之一。低波动策略的本质是用更少的颠簸走完同样的路,收益端的"异象"只是长期统计下的副产物。
- 正确的姿势:把这次结果记成"异象不保证每期赢",而不是"异象是假的"。要严肃验证异象,需要全市场几千只股票、按月滚动分组、跑十年以上的样本------那是正式证书 + 数据管道才能干的事,本篇给你的是可复用的实验框架。
三个容易踩的坑
- 分组用了未来数据 :形成期与持有期必须严格分开,
form_end之后的数据在分组时一个字节都不能碰。前视偏差在分层实验里尤其隐蔽,因为"结果看起来很合理"。 - 用日收益的简单标准差当波动率没问题,但别忘了年化:日波动 2% 听着很小,年化就是 2%×√244≈31%。分组阈值、展示口径都统一用年化,不然低波组和高波组的界限会失真。
- 等权组合同样要防前视:组内收益直接取成员日收益的均值(每日再平衡口径),不要用"今天知道的权重"去乘"今天的收益"。
小结
本样本给出了一个诚实的负结果:低波组收益跑输,但波动与回撤减半------异象的"风险减半"面在,"收益反超"面没显形,被一只极端强势股吞掉了。这比一个"异象成立"的漂亮结果更有教学价值:任何因子异象都要靠大样本、长周期说话,一次分组实验只配回答"框架能不能跑通"。
下一篇回到组合本身:五只股票拿到手,你真的分散了吗?用滚动相关系数和分散化比率,量一量"分散"这个最常被高估的词。
下篇预告:【Python量化波动率实战 05】你的组合真的分散了吗:滚动相关与分散化收益。
免责声明
本文仅为技术方法演示,不构成任何投资建议。文中示例数据仅用于教学,历史表现不代表未来收益。投资有风险,决策需谨慎。
代码与文档可参考 GitHub:github.com/MaiRuiApi