【Python量化因子实战 #02】波动率因子:低波 vs 高波,多空组合谁更稳
系列:Python 量化因子实战 · 第 02 篇(共 10 篇)
难度:⭐⭐(pandas / numpy 基础)
阅读时长:约 8 分钟
实测代码:Demo/2026-09-01/Python量化因子实战02-波动率因子低波高波对比/02_lowvol_longshort/
一句话结论
在 A 股做截面选股,波动率因子有"低波跑赢高波"的稳健异常(low-volatility anomaly):把股票按过去 60 日波动率分 5 组,最低 20%(低波)未来 1 月收益平均比最高 20%(高波)高 0.5~1.5%。这个效应在 A 股强于美股,主要受散户博彩偏好推动。
一、低波异象为什么存在
2006 年 Ang、Hodrick、Xing & Zhang 在 JFE 发表了一篇经典论文:在美股 1963-2000 数据上,低波动股票组合反而跑赢高波动股票,年化超额约 1-2%。
直觉反常识:风险高(波动大)→ 预期收益高?理论上 CAPM 是这么说的。但实证告诉我们三件事:
- 彩票效应:散户天然偏好"小票 + 高波动"(像买彩票),把高波动股票的价格推到基本面之上,长期均值回归。
- 做空约束:高波动股票往往被高估,但无法做空套利 → 错误定价长期存在。
- 幸存者偏差:退市的高波动小票不进入指数,但散户却买在高点。
A 股的低波异象比美股更明显。原因:
- A 股散户占比约 60-70%,远超美股的 10-15%。
- 涨停板制度放大短期波动,散户更倾向于追涨杀跌。
- 行业层面:银行、公用事业、白色家电等"高分红 + 低估值 + 低波动"板块长期跑赢市场。
二、波动率因子的几种定义
名称 公式 优点 缺点
历史波动率 std(log_ret_N) × sqrt(252) 最简单、最稳定 对极端值敏感
Parkinson sqrt(0.25·ln(H/L)²·252) 只用 H/L,不受跳空影响 单点估计
Garman-Klass 综合 OHLC 估计效率高 公式复杂
已实现波动率 sum(5min_ret²)·252×288 信息含量最高 需要分钟数据
本文用最简单的"历史波动率",因为它门槛最低、复现最容易,新手从这开始最快。
三、五分位多空组合的设计
把股票按"过去 60 日年化波动率"从小到大排:
Q1 (低波) ────── 风险最低、平均收益最高
Q2
Q3
Q4
Q5 (高波) ────── 风险最高、博彩资金最爱
多空组合 = 做多 Q1 + 做空 Q5(理论)。A 股只能做多 → 退化为"纯多头 Q1 vs 基准"。
更稳妥的做法是"Q1~Q2 多头 vs Q4~Q5 多头",避免押注单一组。
四、可运行代码
下面的脚本完整复现了"年化波动率计算 + 五分位分组 + 截面 IC 评估"。
"""
低波动因子 多空组合验证 Demo
============================
30 只大盘股 × 年化波动率 × 五分位分组 × 截面 Spearman IC。
"""
import sys
import warnings
from datetime import datetime
from pathlib import Path
import numpy as np
import pandas as pd
from mairui import Client
from scipy.stats import spearmanr
warnings.filterwarnings("ignore")
import os
LICENCE = os.environ.get("MAIRUI_LICENCE") or "<你的麦蕊证书>"
client = Client(LICENCE)
UNIVERSE = [
"600519", "000001", "000002", "600036", "000858",
"601318", "601398", "601988", "600276", "000333",
"600030", "601166", "600887", "600000", "601288",
"600585", "600028", "601857", "600050", "601628",
"601088", "601668", "600018", "601800", "601012",
"600900", "600104", "601628", "600196", "601633",
]
def fetch_history(code: str, lt: int = 300) -> pd.DataFrame:
raw = client.stock_history(code, lt=lt, dividend="n")
df = pd.DataFrame(raw)
if df.empty:
return df
df["t"] = pd.to_datetime(df["t"])
df = df.sort_values("t").reset_index(drop=True)
for col in ["o", "c", "h", "l", "v"]:
df[col] = pd.to_numeric(df[col], errors="coerce")
return df[["t", "o", "c", "h", "l", "v"]]
def annualized_vol(close: np.ndarray, window: int = 60, skip: int = 5) -> float:
"""过去 window 天、跳过最近 skip 天的年化波动率。"""
n = len(close)
if n < window + skip + 5:
if n < 25:
return np.nan
skip = max(1, int(n * 0.05))
window = max(15, n - skip - 2)
end = n - 1 - skip
arr = close[end - window:end + 1]
if len(arr) < 15:
return np.nan
log_ret = np.diff(np.log(arr))
return float(np.std(log_ret, ddof=1) * np.sqrt(252))
def fwd_return(close: np.ndarray, horizon: int = 20) -> float:
n = len(close)
if n < horizon + 1:
return np.nan
return float(close[-1] / close[-1 - horizon] - 1)
def rank_ic(factor: pd.Series, fwd_ret: pd.Series) -> dict:
mask = factor.notna() & fwd_ret.notna()
f, r = factor[mask], fwd_ret[mask]
if len(f) < 5 or f.nunique() < 2 or r.nunique() < 2:
return {"ic": None, "pvalue": None, "n": int(len(f))}
coef, pval = spearmanr(f, r)
return {"ic": round(float(coef), 4), "pvalue": round(float(pval), 4), "n": int(len(f))}
def quintile_returns(panel: pd.DataFrame, factor_col: str, fwd_col: str = "fwd_1m") -> dict:
df = panel[[factor_col, fwd_col]].dropna()
if df.empty or df[factor_col].nunique() < 5:
return {q: None for q in ["Q1_low", "Q2", "Q3", "Q4", "Q5_high"]}
df = df.copy()
df["group"] = pd.qcut(df[factor_col], 5, labels=["Q1_low", "Q2", "Q3", "Q4", "Q5_high"], duplicates="drop")
grouped = df.groupby("group", observed=True)[fwd_col].mean()
return {q: round(float(grouped.get(q, np.nan)) * 100, 2) for q in ["Q1_low", "Q2", "Q3", "Q4", "Q5_high"]}
def main():
print(f"[start] 低波动因子 多空组合验证 @ {datetime.now():%Y-%m-%d %H:%M:%S}")
rows = []
for code in UNIVERSE:
df = fetch_history(code, lt=300)
if df.empty or len(df) < 25:
continue
close = df["c"].values
rows.append({
"code": code,
"vol_60d": annualized_vol(close, window=60, skip=5),
"fwd_1m": fwd_return(close, horizon=20),
})
panel = pd.DataFrame(rows)
print(f"[universe] 有效股票 {len(panel)} / {len(UNIVERSE)}")
ic_vol = rank_ic(panel["vol_60d"], panel["fwd_1m"])
q_ret = quintile_returns(panel, "vol_60d")
print(f"\n[IC] vol_60d -> IC={ic_vol['ic']} p={ic_vol['pvalue']} n={ic_vol['n']}")
print(f"[五分位收益%] {q_ret}")
if q_ret["Q1_low"] is not None and q_ret["Q5_high"] is not None:
spread = round(q_ret["Q1_low"] - q_ret["Q5_high"], 2)
print(f"[多空价差] Q1_low - Q5_high = {spread}% / 月")
if name == "main":
main()
代码要点
- annualized_vol():自适应窗口,数据不足时自动缩放;std 默认用 ddof=1(样本标准差)。
- quintile_returns():pd.qcut 五等分分组,对 unique 值不足的情况返回 null。
- skip=5:跳过最近 5 天,避免"刚暴涨"的极短期波动污染低波组。
- 60 日窗口:约 3 个月,是 A 股低波异象研究最常用的长度;过短(如 10 日)会被噪声污染,过长(如 250 日)反应慢。
怎么跑
pip install mairui pandas scipy
export MAIRUI_LICENCE="<你的麦蕊证书>"
python main.py
说明:演示证书对所有股票返回相同的占位数据,无法测出真实 IC。换成正式证书后,30 只股票将有真实横截面差异,IC 与五分位收益即可直接计算。
五、实战注意事项
- 复权方式:低波因子建议用后复权(dividend='f'),分红除权日的单日大波动会污染年化估计。
- 再平衡频率:低波因子比动量因子更稳定,月频再平衡即可,季频也常。
- 低估值耦合:低波股票天然偏"高分红 + 低估值"。如果组合同时希望低估值与低波动,要做正交化或两因子打分合成,否则会因为双重押注大盘蓝筹而吃不到中小盘 alpha。
- 行业偏差:纯低波组合容易集中在公用事业、运营商、银行等少数行业。强烈建议"行业内排序"或加行业中性化。
- 回看窗口选择:20 / 60 / 120 日三个窗口的 IC 差别很大。学术上 60 日是常用折中。
六、下一篇预告
03 将构造换手率因子——同样用过去 60 日换手率做截面排序,对比"低换手异象"与"高换手异象",看 A 股里"换手"到底是不是 alpha。
风险提示与免责声明
本文为技术研究示例,不构成任何投资建议。历史因子表现不代表未来收益,量化策略存在过拟合、市场制度变迁等风险。股票投资有风险,入市需谨慎。
系列目录:Python 量化因子实战 10 篇规划

浙公网安备 33010602011771号