halo 的技术博客

返回

你手里有带噪声的观测:成交价里混着买卖价差跳动、协整价差的对冲比 β\beta 一直在漂、隐含波动率藏在带噪的期权报价背后。你想追踪的「真值」看不见,只能看到被噪声污染的信号。怎么办?

卡尔曼滤波(Kalman Filter, 1960) 就是为这个量身定做的:它是线性高斯状态下最小均方误差的递推估计器,也是「状态空间模型」的工程引擎。它的妙处不在公式复杂,而在于每步只做两件极简的事——预测(用状态演化往前推一步)和更新(用新观测修正),中间一个叫卡尔曼增益 KK 的数自动权衡「该信状态还是该信观测」。

本文用纯 numpy 从零实现通用卡尔曼滤波(支持标量状态与时变参数回归),在三个量化场景真实跑通:① 噪声价格里还原平滑真值 ② 时变对冲比 βt\beta_t 在线跟踪 ③ 过程噪声 QQ 的 U 形敏感性。并诚实指出「因果 KF 的 RMSE 会输给偷看未来的移动平均」这条最被忽视的边界。所有图表均由下文 Python 真实计算,非占位图。

卡尔曼滤波从噪声观测里还原隐藏真值:真值(蓝)、噪声观测(灰)、KF 估计(橙)在前 300 点几乎重合

一、状态空间模型:把问题写成两个方程#

卡尔曼滤波假设你面对的是一个状态空间模型

  • 状态方程(隐藏的真值怎么演化)  xt=Fxt1+wt,wtN(0,Q)\;x_t = F\,x_{t-1} + w_t,\quad w_t\sim\mathcal N(0,Q)
  • 观测方程(你能看到什么)  yt=Hxt+vt,vtN(0,R)\;y_t = H\,x_t + v_t,\quad v_t\sim\mathcal N(0,R)

xtx_t 是你真正想追踪的(真价格 / 真对冲比 / 真波动率),yty_t 是观测(噪声价格 / 资产2 报价 / 期权 IV),FF 是状态怎么转移(随机游走就取单位阵),HH 是状态怎么映射到观测,QQ 是状态演化的噪声(过程噪声),RR 是观测噪声。

滤波每个时刻只做两步:

  1. 预测x^tt1=Fx^t1\hat x_{t|t-1} = F\,\hat x_{t-1},不确定性 Ptt1=FPt1F+QP_{t|t-1} = F P_{t-1}F^\top + Q
  2. 更新:新息 et=ytHx^tt1e_t = y_t - H\hat x_{t|t-1},卡尔曼增益 Kt=Ptt1H(HPtt1H+R)1K_t = P_{t|t-1}H^\top(HP_{t|t-1}H^\top + R)^{-1},修正 x^t=x^tt1+Ktet\hat x_t = \hat x_{t|t-1} + K_t e_t

KtK_t 就是核心:它永远在 [0,1][0,1] 之间——K0K\to 0 表示「观测太噪,我信状态」, K1K\to 1 表示「状态在乱漂,我信观测」。最优折中由 QQRR 的比值自动决定,你不用手调。纯 numpy 实现(Joseph 形式保证协方差半正定):

二、场景1:噪声价格里还原真值#

构造一条平滑随机游走「真值」,再叠加 6 倍于信号的噪声观测(SNR=0.167),用随机游走状态( F=[1],  QF=[1],\;Q 很小)跑滤波:

F = np.array([[1.0]]); H = np.array([[1.0]])
Q = np.array([[1e-3]]); R = np.array([[0.09]])   # 观测噪声 ≈0.30²
kf = kalman_filter(obs, F, H, Q, R, np.array([obs[0]]), np.array([[1.0]]))
python

对真值的 RMSE 对比:

估计器RMSE
原始噪声观测0.299
移动平均 MA(20)0.094
卡尔曼滤波0.127

估计误差对比:KF 把原始噪声砍 58%;MA(20) 更低但它是非因果的(偷看未来)

注意一个反直觉但必须诚实说的事实:KF 的 RMSE(0.127)比 MA(20)(0.094)略高。原因是 MA 是非因果的——它用了未来 10 个点来平滑当前点,等于偷看了未来,所以任何平滑指标只要能看未来就必然更准。卡尔曼滤波是因果的实时估计器,只能用「到目前为止」的信息,自然在「平滑一条已知曲线」这种事后任务上略逊于 MA。它的真正优势不在场景1,而在下一节的时变状态追踪——状态一旦会变,MA 的平均窗就被糊掉了。

三、场景2:时变对冲比 β_t 在线跟踪#

这是卡尔曼滤波在量化里最经典的用武之地——协整配对里 β\beta 会漂。经典协整配对用全样本 OLS 锁一个固定 β\beta,可真实 β\beta 一直在摆。把对冲比写成二维状态 [αt,  βt][\alpha_t,\;\beta_t],观测方程变成回归 yt=αt+βtxt+vty_t = \alpha_t + \beta_t x_t + v_t

F2 = np.eye(2)                                  # 状态随机游走演化
H2 = np.stack([np.ones(M), x], axis=1)[:, None, :]   # 逐时刻 H_t = [1, x_t]
Q2 = np.array([[1e-4, 0], [0, 1e-4]])           # 过程噪声(β 缓慢漂移)
R2 = np.array([[0.25]])                         # 观测噪声 ≈0.5²
kf2 = kalman_filter(y, F2, H2, Q2, R2, np.array([0.0, 1.0]), np.eye(2))
beta_kf = kf2[:, 1]
python

合成一条真 βt=1.0+0.35sin()\beta_t = 1.0 + 0.35\sin(\cdot)[0.65,1.35][0.65, 1.35] 间缓慢摆动,KF 跟踪 vs 静态 OLS:

真 β 范围            : [0.650, 1.350]
静态 OLS β           : 0.960   (被平均糊成一条线)
β 追踪 RMSE : KF=0.0815   OLS=0.2541   (KF 精度 3.1×)
价差重建 RMSE: KF=0.249    OLS=2.080    (KF 精度 8.3×)
plaintext

时变对冲比 β_t 跟踪:KF(橙) 紧贴真 β(蓝) 的摆动,静态 OLS(绿虚线) 被平均成一条线

KF 把 β\beta 的追踪误差压到 OLS 的 1/3、把「用估计 β 重建的动态价差」误差压到 OLS 的 1/8——这正是配对交易里动态对冲比能多赚、少踩伪突破的原因。如果 β\beta 在漂而你用固定 OLS 锁死,价差的「偏离」有一半是 β\beta 漂移假造的,会频繁误触发交易。

四、场景3:过程噪声 Q 的 U 形敏感性#

QQ 是卡尔曼滤波最该调、也最易调错的旋钮。它控制「我觉得状态每步能变多大」:

  • Q 太小(太刚):滤波过于信任模型、几乎不跟新观测,状态被「焊死」,滞后严重;
  • Q 太大(太软):滤波几乎只信观测、每步都被噪声带着抖,等于没滤波。

在场景1 上扫 Q(对数网格 10510110^{-5}\sim10^{-1}),KF 对真值的 RMSE 呈漂亮 U 形

Q 的 U 形敏感性(左):太小太刚、太大太软;最优 Q 下的卡尔曼增益随时间自适应(右)

网格搜索最优 Q ≈ 0.0030
本文设定 Q     = 0.0010   (已接近最优,增益自适应良好)
plaintext

U 形谷底才是好 Q:它恰好平衡了「状态能漂」和「观测太噪」。调参经验法——先从观测噪声 RR 出发,把 QQ 设成比 RR 小 1~2 个数量级,再在验证集上扫出来。增益 KtK_t 随时间自适应变化(右图),说明滤波在「状态平稳时少信观测、噪声跳变时多信观测」,这正是 KK 在干活的证据。

五、实战落地:用 KF 跟踪的 β_t 直接生成配对信号#

追踪到动态 βt\beta_t 不是终点,把它接进配对交易的进出场才是价值。配对交易的核心假设是「价差偏离后回归」,价差定义在状态空间里就是残差 et=yt(αt+βtxt)e_t = y_t - (\alpha_t + \beta_t x_t)——也就是滤波的新息。KF 已经白送你这个信号:

alpha_kf, beta_kf = kf2[:, 0], kf2[:, 1]
spread_dynamic = y - (alpha_kf + beta_kf * x)     # = 滤波新息 e_t
# 用动态价差的标准差做 z-score,±2 开仓、±0.5 平仓
zs = (spread_dynamic - spread_dynamic.mean()) / spread_dynamic.std()
long_entry  = zs < -2.0   # 价差被压太低 → 做多价差(买 y 卖 βx)
short_entry = zs >  2.0   # 价差被顶太高 → 做空价差
exit_signal = np.abs(zs) < 0.5
python

为什么用 KF 的 βt\beta_t 而不是 OLS 的固定 β\beta?因为固定 β\beta 造出的静态价差里,有一大块偏离是 β\beta 漂移假造的(本文里静态价差 RMSE 是动态的 8.3 倍)。这部分「伪偏离」会频繁穿过 ±2σ 触发线,制造一堆不回归的假信号;KF 的动态价差把真偏离和 β\beta 漂移剥开,信号的回归率自然更高。实务上还有三点要注意:

  • 价差要标准化再开仓:动态价差的方差随时间变,直接拍硬阈值会在波动大的段过度交易;
  • 状态要预热:前 30~50 步 KF 还在收敛,这期间信号别用;
  • 成本必须单笔核算:KF 让信号更干净,但配对交易决胜在手续费+冲击,别只看信号频率。

把这节和第三节的数字合起来看:KF 把价差的重建误差压到 OLS 的 1/8,不是论文里的漂亮话,而是直接决定「你触发的偏离是不是真的会回归」。

六、五类真实陷阱(必看)#

  1. 把因果 KF 和 MA 比 RMSE 是不公平的:MA 偷看未来,KF 是实时因果估计器,平滑已知曲线任务上 KF 必然略输。比 KF 该比的是「实时、只用到当前及之前信息」的估计器(如前向 EMA、单次指数平滑),或在时变状态场景比 OLS。
  2. Q 定错=滤波失效:Q 太小拖成滞后、Q 太大抖成噪声,U 形谷底才是好 Q,必须调。很多人直接套 Q=1e-5 然后抱怨 KF「没用」,其实是 Q 焊死了。
  3. 线性高斯假设:KF 最优性只在「状态转移与观测都线性、噪声都高斯」时成立。价格里的厚尾、跳变、 regime 切换会让 KF 系统性滞后——这时该上 EKF/UKF 或粒子滤波(本专栏另有粒子滤波专文)。
  4. 协方差 P 不准会雪崩:KF 的增益来自 PP(对不确定性的自我认知)。若初始化 P0P_0 太小或 Q/RQ/R 比例失真,前几十步会剧烈震荡。实务上 P0P_0 宁可设大一点,让滤波先「谦虚」地多信观测。
  5. 观测矩阵 H 时变要显式建模:回归场景的 Ht=[1,xt]H_t=[1, x_t]xtx_t 变,必须逐时刻传进滤波(本文 H.ndim==3 分支)。若把 xtx_t 当成常数、用全样本平均的 HH,时变 β\beta 的追踪立刻退化。

结语#

卡尔曼滤波的精髓不是公式长,而是一个极简的递推闭环:预测→更新→用卡尔曼增益自动折中信状态还是信观测。它在量化里最该用的地方不是「平滑一条已知曲线」(那会输给偷看未来的 MA),而是实时追踪会变的隐藏状态——时变对冲比 βt\beta_t 让配对价差重建误差降到 OLS 的 1/8,噪声价格里的真值也能砍掉 58% 的噪声。落地三件事:Q 必须调(U 形谷底)P0P_0 宁可设大、H 时变要显式建模。把它和本专栏的粒子滤波、TVP-VAR 放一起看——三者都是状态空间模型的引擎,区别只在噪声是不是高斯、状态是不是线性。

完整可复跑代码与全部图表脚本已随本文生成(gen_kalman_filter_tracking.py),噪声还原、时变 β 跟踪、Q 敏感性三组实验均由该脚本真实计算。

卡尔曼滤波动态跟踪:用状态空间模型把噪声里的真值估出来
https://blog.halo26812.eu.org/blog/kalman-filter-tracking
Author halo
Published at 2026年7月23日
版权声明 CC BY-NC-SA 4.0
Comment seems to stuck. Try to refresh?✨