上篇我们算出了 20 日滚动波动率,也看到它在 19.74% 到 42.06% 之间翻倍移动。但固定窗口有个天生缺陷:每一条旧数据和新数据的话语权一样大。三个月前的一场暴跌,和昨天的一次急拉,在窗口里权重相同------市场早变了,指标还在为旧闻纠结。
金融工程里的标准答案是 EWMA(指数加权移动平均)波动率,也是 RiskMetrics 体系推广的方法。今天实测它和固定窗口的差异。
本文你将得到什么
- 波动率聚集的量化检验:|收益率| 自相关怎么算、怎么读。
- EWMA 波动率的递推公式与 Python 实现(λ=0.94,RiskMetrics 日度推荐值)。
- EWMA 与固定 20 日窗口的三维对比:最新估计值、单日反应、对未来的预测力。
- 一个反直觉的诚实发现:单日跳变时固定窗口反而跳得更多,但预测力才是终极裁判。
一、波动率聚集:大波动跟大波动
Mandelbrot 在 1963 年就观察到:"大波动倾向于跟随大波动(无论涨跌),小波动倾向于跟随小波动。" 这叫波动率聚集,是几乎所有波动率模型的存在理由。
检验方法很直接:算日收益率绝对值的自相关。如果 |r_t| 和 |r_{t-1}| 显著正相关,说明昨天的动荡确实预示今天可能继续动荡------波动率不是常数,是可预测的。
二、EWMA:给近期数据更大话语权
EWMA 波动率的递推公式只有一行:
text
sigma2_t = λ * sigma2_{t-1} + (1 - λ) * r_{t-1}^2
其中 λ 是衰减因子,RiskMetrics 对日度数据的推荐值是 0.94。展开理解:
- λ=0.94 意味着昨日方差(由昨日收益的平方体现)占 94%,新信息占 6%;
- 越久远的历史数据,权重按 0.94 的幂次几何衰减------10 天前的数据权重只剩 0.94^10 ≈ 54%,一个月前只剩 0.94^20 ≈ 29%;
- 对比固定 20 日窗口:一个月前的数据和昨天的数据权重完全相同,而且第 21 天前的数据会"突然出窗",造成估计值跳变。
递推起步需要一粒种子:常见做法是用最初 20 天的样本方差初始化。
可运行代码
bash
pip install mairui pandas numpy
python
from mairui import Client
import pandas as pd
import numpy as np
LICENCE = "LICENCE-66D8-9F96-0C7F0FBCD073" # 演示证书,请换成你自己的正式证书
client = Client(LICENCE)
TRADING_DAYS = 244
LAMBDA = 0.94 # RiskMetrics 日度推荐值
bars = client.stock_history("600519", "d", "n", st="20260701", et="20261012")
df = pd.DataFrame(bars).rename(columns={"t": "date"}).sort_values("date").reset_index(drop=True)
df["date"] = pd.to_datetime(df["date"])
df["log_ret"] = np.log(df["c"] / df["c"].shift(1))
# 1. 波动率聚集检验:|日收益率| 自相关
r = df["log_ret"].dropna().reset_index(drop=True)
print("|日收益率| 自相关:")
for lag in (1, 2, 3, 5):
print(f" lag {lag}: {r.abs().autocorr(lag):.4f}")
# 2. EWMA 波动率(RiskMetrics 递推),前 20 日做种子
var = np.full(len(df), np.nan)
r0 = df["log_ret"].values
var[20] = np.nanvar(r0[1:21], ddof=1)
for t in range(21, len(df)):
var[t] = LAMBDA * var[t - 1] + (1 - LAMBDA) * r0[t - 1] ** 2
df["vol_ewma"] = np.sqrt(var * TRADING_DAYS)
# 3. 对照组:等权 20 日窗口
df["vol_ew20"] = df["log_ret"].rolling(20).std(ddof=1) * np.sqrt(TRADING_DAYS)
last = df.iloc[-1]
print(f"最新一日年化波动率: EWMA {last['vol_ewma']*100:.2f}% / 等权20日 {last['vol_ew20']*100:.2f}%")
# 4. 单日反应:最大单日波动前后
idx = df["log_ret"].abs().idxmax()
worst = df.loc[idx]
print(f"最大单日波动 {worst['log_ret']*100:+.2f}% @ {worst['date'].date()}:")
for name in ("vol_ewma", "vol_ew20"):
before, after = df.loc[idx - 1, name] * 100, df.loc[idx, name] * 100
print(f" {name:9s}: {before:.2f}% -> {after:.2f}%({after-before:+.2f}pct)")
# 5. 预测力:与未来 5 日已实现波动率的相关系数
df["fwd_vol5"] = df["log_ret"].shift(-1).rolling(5).std(ddof=1) * np.sqrt(TRADING_DAYS)
sub = df[["vol_ewma", "vol_ew20", "fwd_vol5"]].dropna()
print(f"与未来5日波动率相关: EWMA {sub['vol_ewma'].corr(sub['fwd_vol5']):.4f}"
f" / 等权20日 {sub['vol_ew20'].corr(sub['fwd_vol5']):.4f}")
运行后你会看到类似结果:
text
|日收益率| 自相关:
lag 1: 0.2348
lag 2: -0.0283
lag 3: 0.0048
lag 5: -0.0786
最新一日年化波动率: EWMA 40.30% / 等权20日 41.85%
最大单日波动 +6.64% @ 2025-10-28:
vol_ewma : 28.12% -> 32.04%(+3.92pct)
vol_ew20 : 25.91% -> 35.61%(+9.70pct)
与未来5日波动率相关: EWMA 0.3752 / 等权20日 0.2885
(示例基于 50 个交易日的演示样本测算;演示证书返回就近样例数据,申请正式证书可拉取全市场全量历史。)
怎么读这组数字
- 聚集存在的证据有限但方向正确:50 日小样本下,|收益率| 自相关只有 lag1 显著为正(0.2348),更长的滞后归零。真实市场的聚集通常更强------样本越长信号越清晰,这正是"换正式证书拉长样本"的实用场景之一。
- 单日反应的意外结果 :最大单日波动 +6.64% 当天,等权窗口的波动率跳升(+9.70pct)反而比 EWMA(+3.92pct)更大。原因是等权窗口同时发生了"旧数据出窗":窗口边缘滑出了一条平静期的旧数据,一进一出叠加放大了跳变。EWMA 的优势不在单日跳得猛,而在追踪平滑、没有出窗悬崖。
- 预测力才是终极裁判:EWMA 估计值与"未来 5 日已实现波动率"的相关系数 0.3752,高于等权窗口的 0.2885。加权方式更合理,对未来的信息含量就更高------这才是我们把波动率算得花哨的根本目的。
三个容易踩的坑
- λ 不是越小越灵敏越好:λ=0.90 对单日冲击反应剧烈但估计噪声大;0.94 是日度数据的经验平衡点,周度数据 RiskMetrics 推荐 0.97。
- 递推方向别搞反:公式里用的是 r_{t-1}(昨日收益)更新今日方差,天然无前视;如果你写成用 r_t 更新 sigma2_t,回测里就偷看了未来。
- 种子方差影响初期读数:递推前 20 天用样本方差初始化,这段时间的 EWMA 值受种子影响,分析时跳过热身期更稳妥。
小结
EWMA 用一行递推公式解决了固定窗口"新旧数据同权 + 出窗跳变"两大缺陷,实测预测力也更优。它是从"算波动率"到"用波动率"的桥梁。
下一篇就把波动率用起来:既然波动大的时候风险高,那就波动大的时候少拿点仓位------波动率目标仓位法,回测见真章。
下篇预告:【Python量化波动率实战 03】波动大就少拿点:波动率目标仓位的回测实证。
免责声明
本文仅为技术方法演示,不构成任何投资建议。文中示例数据仅用于教学,历史表现不代表未来收益。投资有风险,决策需谨慎。
代码与文档可参考 GitHub:github.com/MaiRuiApi