halo 的技术博客

返回

先说结论:你的策略最大的风险不是参数选错,而是市场在你不知情的时候换了一套规则,而你还在用旧规则下注。均值漂移、波动跳变、相关性结构断裂——这些「结构突变」(structural break / regime shift)一旦发生,基于历史估计的所有参数瞬间过期。CUSUM(Cumulative Sum,累积和检验)是检测这类突变最经典、最轻量的工具:它不需要你预设变点位置,只用一条累积和曲线的「折点」就把 regime 拐点标出来。本文用纯 numpy 从原理到实战完整实现一遍,并且诚实地告诉你它的边界在哪。

一、问题:策略为什么会「突然」失效#

想象你在 2021 年用过去三年数据估了一个动量策略的参数:持有期、止损带宽、仓位系数,全都调到那段上行行情的最优。然后 2022 年来了——均值漂移从正转负,波动率翻倍。你的参数一个都没变,但市场底层的数据生成过程已经换了一套。

这不是过拟合,是结构突变。它和过拟合的区别很关键:

  • 过拟合:模型在样本内学到了噪声,样本外失效——问题在模型
  • 结构突变:模型本来是对的,但数据生成过程本身变了——问题在世界

对付过拟合靠正则化、交叉验证;对付结构突变,你需要一个在线报警器,在市场换挡时告诉你「旧参数该重估了」。CUSUM 就是这个报警器最朴素的实现。

我们先造一段有明确 regime 的合成数据,好让检测结果有「标准答案」可对照:

import numpy as np

rng = np.random.default_rng(7)
T = 900
true_bp = [300, 600]           # 两个真实变点(上帝视角)
mu = np.zeros(T)
mu[:300]   =  0.0010           # 上行:日均 +10bp
mu[300:600] = -0.0025          # 下行:日均 -25bp
mu[600:]   =  0.0012           # 回升:日均 +12bp
sigma = 0.010
rets = mu + sigma * rng.standard_normal(T)
price = 100 * np.cumprod(1 + rets)
python

合成价格序列与真实变点

三段均值截然不同,但注意:在收益序列层面,这三段肉眼几乎分不出来——因为单日噪声 σ=1% 远大于均值差异(几十个 bp)。这正是难点:漂移被噪声淹没了。CUSUM 的本事,就是把被噪声掩盖的微小漂移,通过累积放大出来。

二、CUSUM 的核心直觉:累积和把漂移折成斜率#

CUSUM 的数学极其简单。对去掉全局均值 μ^\hat\mu 的收益序列做累积和:

St=i=1t(riμ^)S_t = \sum_{i=1}^{t} (r_i - \hat\mu)

关键洞察在这里:

  • 如果某一段的真实均值 等于 全局均值,这段的 (riμ^)(r_i - \hat\mu) 期望为零,累积和是无漂移的随机游走——水平方向震荡
  • 如果某一段的真实均值 高于 全局均值,(riμ^)(r_i - \hat\mu) 期望为正,累积和持续向上爬
  • 如果 低于 全局均值,累积和持续向下走

于是,均值漂移在累积和曲线上表现为斜率的改变。变点,就是斜率折转的地方。

mu_hat = rets.mean()
S = np.cumsum(rets - mu_hat)
python

CUSUM 累积和曲线

看这条橙线:前 300 天缓慢上行(该段均值高于全局),300~600 天陡峭下坠(该段均值远低于全局),600 天后再度回升。斜率的两次折转,精确对应两个真实变点。噪声被累积操作平滑掉了,趋势浮出水面——这就是 CUSUM 以简驭繁的地方。

三、二分分割:从一条曲线到自动定位多个变点#

肉眼看折点不算算法。要自动定位,我们需要一个统计量来量化「这个点作为变点有多显著」。标准做法是归一化的 CUSUM 统计量:

Dk=Sksn,Sk=i=1k(rirˉ)D_k = \frac{|S_k|}{s \cdot \sqrt{n}}, \quad S_k = \sum_{i=1}^{k}(r_i - \bar r)

其中 ss 是段内标准差,nn 是段长。DkD_k 取最大值的位置 kk^* 就是最可能的单变点;如果 DkD_{k^*} 超过阈值,就认定这里有一个突变。

单变点检测只能找一个。真实市场往往有多个 regime,解法是二分分割(Binary Segmentation):找到最强变点后,把序列从该点切成两半,在每一半里递归地继续找,直到没有子段的统计量超过阈值。

二分分割检测结果

结果:真实变点 [300, 600],检测出 [363, 609]。误差 60 天和 9 天。

这个误差是真实的,不是 bug。为什么第一个变点偏了 60 天?因为从「+10bp 上行」切换到「-25bp 下行」时,切换初期的负收益还没累积到足以压过前面 300 天正漂移的程度——CUSUM 需要「攒够证据」才敢标点。而第二个变点(-25bp → +12bp)偏差只有 9 天,因为这次跳变幅度更大(37bp),证据攒得快。变点定位精度正比于漂移幅度、反比于噪声——这是信息论级别的约束,不是算法能突破的。

四、阈值:过度分割与漏检之间的走钢丝#

binseg 里那个 threshold=1.10 是整个方法最要命的旋钮。它直接决定你检出几个变点:

thr_grid = np.linspace(0.6, 2.2, 25)
n_detected = []
for thr in thr_grid:
    f = []
    binseg(rets, 0, thr, f)
    n_detected.append(len(f))
python

阈值敏感性

这张图是全文最该盯住的一张:

  • 阈值 < 1.0:检出 5 个、7 个甚至更多变点——过度分割。CUSUM 把纯噪声段里偶然的累积漂移也当成了 regime 切换。这些「变点」样本外根本不复现,你会据此频繁重估参数,交易成本吃光收益。
  • 阈值 > 1.4:只检出 1 个甚至 0 个——漏检。真实的下行 regime 被当成噪声忽略,你的策略在熊市里继续用牛市参数。
  • 稳健区间 1.0~1.3:稳定检出 2 个,与真相一致。

问题在于:真实数据里你没有「上帝视角」的 2 个作参照。怎么定阈值?三条实战路径:

  1. 理论临界值:在原假设(无变点)下,归一化 CUSUM 统计量服从 Brownian Bridge 的极值分布,5% 显著性水平对应约 1.36。这给你一个有统计依据的起点。
  2. 信息准则惩罚:把变点数当模型复杂度,用 BIC/MDL 对「多切一刀」施加惩罚,让数据自己权衡。这是 PELT 等现代变点检测算法的做法。
  3. 样本外稳定性:在滚动窗口上检测,只保留在多个窗口里反复出现的变点——真 regime 切换会稳定复现,噪声不会。

五、离线 vs 在线:检测延迟是逃不掉的税#

上面的二分分割是离线(retrospective)检测:拿到完整序列后回头找变点。做研究、划分历史 regime 时够用。但实盘里你要的是在线(sequential)检测:每来一根新 K 线,立刻判断「市场是不是刚刚换挡了」。

在线检测用的是 Page 的序贯 CUSUM。以检测负向漂移(比如从上行切到下行)为例,维护一个单侧累积统计量:

gt=max(0, gt1(rtμ^)k)g_t = \max\left(0,\ g_{t-1} - (r_t - \hat\mu) - k\right)

其中 kk 是「松弛量」(reference value,通常取待检测漂移幅度的一半),起过滤小波动的作用。gtg_t 平时被压在零附近,一旦真的出现持续负漂移,它会稳定累积上升,突破决策界 hh 时报警:

k_ref = 0.5 * abs(-0.0025)   # 松弛量:待检测漂移的一半
g = np.zeros(T)
h = 0.03                      # 决策界
alarm = None
for i in range(1, T):
    g[i] = max(0, g[i-1] - (rets[i] - mu_hat) - k_ref)
    if g[i] > h and alarm is None and i > 300:
        alarm = i             # -> 316
python

在线 CUSUM 检测延迟

真实下行从 t=300 开始,报警在 t=316——检测延迟 16 天

这 16 天不是算法不够好,是序贯检测的物理下限。在线检测面对一个无法同时满足的三角约束:

  • 降低误报率(少被噪声骗)→ 提高决策界 hh → 但攒够证据更慢 → 延迟变长
  • 缩短检测延迟(快速反应)→ 降低 hh → 但更容易被噪声触发 → 误报变多

这就是 Page/Lorden 序贯检测理论的核心结论:平均检测延迟(ADD)和平均误报间隔(ARL)此消彼长,不存在两全。你能做的只是根据策略特性选一个平衡点:高频策略容忍不了误报(每次误报都是一次昂贵的调仓),就把 hh 抬高吃延迟;风控报警宁可错杀,就把 hh 压低吃误报。

六、结果解读与实战边界#

这个方法能给你什么。 一个不需要预设变点、计算量极小(O(n)O(n) 累积和 + O(nlogn)O(n\log n) 递归分割)、可离线可在线的 regime 拐点检测器。用它给历史数据分段,你能回答「我的策略在哪几段 regime 下有效、哪几段失效」;用在线版本挂在实盘上,你能在市场换挡时收到一个「参数该重估了」的信号。

四个必须知道的陷阱:

  1. CUSUM 标准形式只检均值漂移,对波动突变失灵。 本文所有实验检测的都是均值变化。如果 regime 切换主要体现在波动率跳变(均值没怎么动,但 σ 从 1% 蹦到 2.5%),去均值累积和对此几乎无感——因为它累加的是 (riμ^)(r_i - \hat\mu),波动放大后正负相消,累积和斜率不变。检测波动突变要改用平方收益 (riμ^)2(r_i-\hat\mu)^2 的 CUSUM,或直接上 GARCH regime-switching / ICSS 算法。用错工具检错量,是这里最常见的翻车方式。

  2. 检测延迟不可能为零,别指望抄在拐点。 上面 16 天的延迟是内生的。任何声称「实时精确捕捉市场顶底」的变点检测都在骗人——它要么事后诸葛(离线),要么误报连篇(阈值压太低)。把 CUSUM 当「换挡确认器」而非「拐点预测器」,心态才对。

  3. 多变点检测对分割顺序敏感。 二分分割是贪心算法:先切最强的点,再递归。当两个变点强度接近或存在交互时,贪心可能切错位置。对精度要求高时,用 PELT(Pruned Exact Linear Time)这类能保证全局最优分割的算法替代二分分割。

  4. 全局均值 μ^\hat\mu 本身被变点污染。 我们用整段的 μ^\hat\mu 去中心化,但这个均值恰恰是混合了多个 regime 的「四不像」。变点越多、越极端,μ^\hat\mu 越不代表任何一段。严格的做法是迭代:初次检测 → 分段重估各段均值 → 用局部均值重新检测。单次检测在变点不多时够用,变点密集时要迭代精修。

一句话总结:CUSUM 是 regime 检测的「听诊器」——便宜、可靠、原理透明,能告诉你「市场心跳变了」,但它不会预测下一次心跳,也听不出波动率这种「血压」问题。知道它能检什么、不能检什么,比调参数重要得多。

CUSUM 结构突变检测:用累积和在收益序列里揪出 regime 拐点
https://blog.halo26812.eu.org/blog/cusum-structural-break
Author halo
Published at 2026年7月25日
版权声明 CC BY-NC-SA 4.0
Comment seems to stuck. Try to refresh?✨