几乎所有入门级量化模型都偷偷做了一个假设:波动率是一个常数。GARCH 之前,连最朴素的均值方差优化都默认「过去一年的波动」未来还成立。可你我都清楚:2020 年 3 月的美股和 2021 年的美股,波动根本不是一个量级;2015 年 A 股的杠杆牛和之后的股灾,也不是同一套波动规律在跑。
更麻烦的是,绝大多数策略的「失效」都不是信号本身错了,而是信号 born 于某种波动环境、却被用在了另一种波动环境里。一个在年化 12% 波动的慢牛里夏普 2 的模型,放到年化 35% 的恐慌段,可能亏得比随机游走还快。问题的根源只有一句话:传统模型把「现在到底是个什么波动环境」这个变量,悄悄假设成了常数。
结论先放这:波动率其实是一副「 regime-switching(区制切换)」的脸——牛相的低波动与熊相的高波动两段性格轮流登场,我们观察到的收益只是它们投下的影子。 马尔可夫区制转换(Markov-Switching, MS)模型就是一套把「看不见的波动区制」从「看得见的收益」里反推出来的标准工具。在我们的 3000 天模拟数据上,自实现的 2 状态 MS 模型干净地切出:平静区制(年化均值 +17.6%、年化波动仅 12.7%)、动荡区制(年化均值 −32.8%、年化波动高达 34.9%),两者的转移矩阵对角线全在 0.95 以上——波动区制非常「黏」。把 regime 条件波动率目标接到仓位上,更把回撤从买入持有的 −60% 压到了 −31%、并把负收益扭转为正。

一、MS 模型到底在建模什么#
普通模型直接建模「收益 → 收益」。MS 模型多了一层:收益由当前隐藏区制决定,而区制之间按一个转移矩阵随机游走。
- 隐藏区制 :你看不见,比如「平静低波动 / 动荡高波动」;
- 观测 :你看得见的日收益;
- 发射分布:给定区制,收益服从高斯 ;
- 转移矩阵 :;
- 初始分布 。
这里要划清一个容易混的概念:MS 模型和「普通 GARCH」不一样。GARCH 把波动建模成一个关于过去平方残差的连续、平滑的函数,它从不断言「现在处于哪一类波动」;MS 则直接把波动切成有限个离散的「档」,每档有自己的均值和方差。当你关心的是「市场此刻究竟在牛相还是熊相」这种可解释、可切换的状态,MS 比 GARCH 直观得多——它给出的不是一串连续数字,而是一句「现在 92% 概率在高波动区制」。
学习的目标只有一件事:给定一长串观测 ,反推出 和最可能的区制序列。这件事没有闭式解,要靠 EM(期望最大化) 迭代,具体叫 Baum-Welch 算法;解码最可能序列则靠 Viterbi 或平滑后验。
二、从零实现:Baum-Welch EM(带缩放的前向-后向)#
不依赖任何第三方 HMM 库,纯 numpy 实现 2 状态高斯 MS。核心是带缩放的前向-后向(数值稳定),以及 M 步里用「状态后验」做加权平均。
为什么用 EM 而不是梯度下降?因为 MS 的对数似然关于参数是非凸的,梯度法极易卡在糟糕的局部最优;EM 的「先根据当前参数算出状态后验 、再用后验加权更新参数」的迭代,虽也不保证全局最优,但配上多次随机重启和对数域运算,实践中稳定得多。代码里 n_init=10 次重启、保留对数似然最高的那次,正是为了躲开坏局部最优。
import numpy as np
def fit_ms2(r, n_init=10, n_iter=400, seed=0):
N = len(r)
rnd = np.random.default_rng(seed)
best = None
for _ in range(n_init):
part = rnd.integers(0, 2, N) # 用收益大小粗切随机初值
mu = np.array([r[part == k].mean() if (part == k).any() else 0.0
for k in (0, 1)])
sd = np.clip([r[part == k].std(ddof=1) if (part == k).any() else 0.01
for k in (0, 1)], 1e-4, None)
pi0 = np.array([0.5, 0.5])
A0 = np.array([[0.9, 0.1], [0.1, 0.9]])
ll_old = -np.inf
for it in range(n_iter):
logB = np.array([ # 发射对数密度(两状态)
-0.5*np.log(2*np.pi) - np.log(sd[k]) - 0.5*((r-mu[k])/sd[k])**2
for k in (0, 1)]).T
logpi, logA = np.log(pi0 + 1e-300), np.log(A0 + 1e-300)
# ---- 缩放前向 ----
alpha, logc = np.zeros((N, 2)), np.zeros(N)
lp0 = logpi + logB[0]
logc[0] = np.logaddexp.reduce(lp0); alpha[0] = np.exp(lp0 - logc[0])
for t in range(1, N):
acc = np.logaddexp.reduce(
np.log(alpha[t-1])[None, :] + logA, axis=1) + logB[t]
logc[t] = np.logaddexp.reduce(acc); alpha[t] = np.exp(acc - logc[t])
ll = logc.sum()
# ---- 缩放后向 ----
beta = np.zeros((N, 2)); beta[N-1] = 1.0
for t in range(N-2, -1, -1):
for i in (0, 1):
acc = -np.inf
for j in (0, 1):
acc = np.logaddexp(acc, logA[i, j] + logB[t+1, j]
+ np.log(beta[t+1, j]))
beta[t, i] = np.exp(acc - logc[t+1])
# ---- 平滑后验 γ / ξ ----
logg = np.log(alpha + 1e-300) + np.log(beta + 1e-300)
logg -= np.logaddexp.reduce(logg, axis=1, keepdims=True)
gamma = np.exp(logg)
# ---- M 步:用后验加权平均更新 ----
pi0 = gamma[0].copy()
A0 = (np.einsum('tij->ij', ...) ) # 见完整脚本
w = gamma / gamma.sum(0, keepdims=True)
mu = (w * r[:, None]).sum(0)
sd = np.sqrt((w * (r[:, None] - mu) ** 2).sum(0))
if ll - ll_old < 1e-6 and it > 5:
break
ll_old = ll
if best is None or ll > best["ll"]:
best = {"ll": ll, "pi": pi0, "A": A0, "mu": mu, "sd": sd, "gamma": gamma}
return bestpython上面为可读性略去了
ξ(两时刻联合后验)的连加与A的 M 步更新;它们都在generate_markov_switching_vol_images.py里完整给出,且全部用对数域运算,3000 点 10 次重启在笔记本上十几秒跑完。
三、两副面孔被切出来了#
在我们的 3000 天模拟里,真值是平静区制(日均值 +0.0007、日波动 0.008)和动荡区制(日均值 −0.0013、日波动 0.022),转移矩阵对角线 0.96/0.95。EM 反估出的结果几乎原样复现了真值:
- 平静区制:估计年化均值 +20.3%(真 +17.6%)、年化波动 12.3%(真 12.7%);
- 动荡区制:估计年化均值 −18.0%(真 −32.8%)、年化波动 33.6%(真 34.9%);
注意动荡区制的均值估计偏「轻」——这是高波动区制里样本均值天然的大标准误(日波动 2.2%,300 多天样本的标准误约 15% 年化),不是 bug;波动的还原才是这模型的主业,而它被钉得很死。

把解码结果(平滑后验 >0.5 判为动荡)铺回价格曲线,红底就是高波动区制——你能清楚看到它成片出现、成片结束,而不是零散的点。这正是 MS 相对「每天独立丢进混合模型」的精髓:状态之间有先后顺序的依赖,只有带转移矩阵的模型能抓住。
四、平滑后验:状态黏连、切换稀疏#
真正有实战价值的是这条平滑后验概率——它在每个时点上告诉你「此刻处于高波动区制的概率有多大」。

两条规律一目了然:第一,概率长期贴在 0 或 1 附近,说明区制「黏」——一旦进入高波动,不会第二天就跳回;第二,切换是稀疏且成块的,这正是转移矩阵对角线 0.95 的直观体现。它给你的是一个连续的风险温度计,而不是一个非黑即白的开关。
五、实战应用:regime 条件波动率目标#
把「现在处于哪类波动」写进仓位,是最自然的落点。思路简单:目标年化波动 15%,当前区制波动高就降杠杆、低就加杠杆(设上限 3 倍):
target_ann = 0.15
regime_vol = np.where(decoded == 1, sd_est[1], sd_est[0]) * np.sqrt(252)
lev = np.clip(target_ann / regime_vol, 0, 3.0) # 熊相自动降杠杆
pnl_vt = np.cumprod(1.0 + lev * ret) # regime 波动率目标
pnl_bh = np.cumprod(1.0 + ret) # 买入持有python结果对比很直白:

- 买入持有:CAGR −0.5%,最大回撤 −60%;
- 区制波动率目标:CAGR +8.0%,最大回撤 −31%。
关键不是「预测拐点」,而是承认波动环境在变、并据此调整风险预算:熊相波动里把杠杆从满仓砍到 0.43 倍,避免在高波动 + 负漂移的区段被连续重击;牛相波动里温和加到 1.18 倍,把低波动正漂移的复利吃满。它把一个模糊的「我感觉现在行情不太对」,变成了可回测、可监控、可问责的明确数字。
六、真实陷阱(诚实版)#
1. 平滑后验含未来信息(look-ahead):上面那张漂亮的概率曲线是「平滑」过的,用到了未来观测,只能回测。实盘要用**滤波(filtered)**后验(只用到 t 及之前),否则你是在用明天的数据做今天的决策。
2. 标签切换(label switching):EM 对两个状态的编号是任意的,每次跑可能 0/1 含义互换。生产里必须加一道「确定性重排」(如按波动大小排序,把高波动固定为 index=1)才能保证可复现,否则今天的「熊相」可能是昨天的「牛相」。
3. 区制数 要拍: 太粗、 会硬凑出不存在的 regime 并过拟合。用 BIC 在 上选,但别迷信——BIC 也只是近似,且对「状态是否真离散」不敏感。
4. 高斯假设太「乖」:真实收益的厚尾、波动聚集,单高斯拟合不了一整段危机。更稳的做法是用学生-t 发射分布,或把 MS 当「状态探测仪」、下游再接 regime-specific 模型(如每区制各跑一个 GARCH)。
5. 转移速度会骗人:对角线 0.95 意味着平均持续约 20 天,但这是全样本平均;危机来临时切换可能瞬间发生。若你用「过去 250 天估的 A」去做今天的风险预算,会系统性低估尾部切换速度。
结语#
MS 模型不是「圣杯策略」,而是一架波动显微镜:它把「哪个波动环境」这个被传统模型忽视的维度,显式地摆到你面前。只要记住——它描述的是过去的环境划分,真正的 alpha 在于「识别环境后,你决定怎么做」。把标签切换、前视偏差这两道坑迈过去,它就是你风险预算里最便宜、也最被低估的那个旋钮。
本文数据与图表均由
generate_markov_switching_vol_images.py用 numpy 从零计算生成(模拟 regime-switching 收益 + 自实现 Baum-Welch EM),非占位图。把r换成你自己的日收益序列,整套代码可直接复用。