CUSUM 结构突变检测:用累积和在收益序列里揪出 regime 拐点
你的策略参数是在过去三年数据上估的,可市场早在半年前就换了一套玩法——均值漂了、波动变了,而你的模型还在用旧假设下注。结构突变检测就是给策略装一个『市场换挡』的报警器。本文用纯 numpy 把 CUSUM(累积和检验)从原理到实战完整走一遍:合成一段三状态收益序列(+10bp 上行 → -25bp 下行 → +12bp 回升,两个真实变点在 t=300/600),用去均值累积和把漂移折成不同斜率的段,再用二分分割递归定位——阈值 1.10 下精确检出 [363, 609],与真实 [300, 600] 只差几十天。阈值敏感性扫描揭示核心权衡:阈值太低(<1.0)把噪声也当变点过度分割,太高(>1.4)漏检真拐点,稳健区间 1.0~1.3。在线 Page-CUSUM 单侧检验模拟实时监控:真实下行从 t=300 起,报警在 t=316,检测延迟 16 天——这就是所有序贯检测无法绕开的『延迟 vs 误报』铁律。全文打穿离线 vs 在线检测的根本区别、CUSUM 只检均值漂移对波动突变失灵、检测延迟不可能为零、多变点需递归分割四类核心陷阱(中阶)。
先说结论:你的策略最大的风险不是参数选错,而是市场在你不知情的时候换了一套规则,而你还在用旧规则下注。均值漂移、波动跳变、相关性结构断裂——这些「结构突变」(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 的数学极其简单。对去掉全局均值 的收益序列做累积和:
关键洞察在这里:
- 如果某一段的真实均值 等于 全局均值,这段的 期望为零,累积和是无漂移的随机游走——水平方向震荡
- 如果某一段的真实均值 高于 全局均值, 期望为正,累积和持续向上爬
- 如果 低于 全局均值,累积和持续向下走
于是,均值漂移在累积和曲线上表现为斜率的改变。变点,就是斜率折转的地方。
mu_hat = rets.mean()
S = np.cumsum(rets - mu_hat)python
看这条橙线:前 300 天缓慢上行(该段均值高于全局),300~600 天陡峭下坠(该段均值远低于全局),600 天后再度回升。斜率的两次折转,精确对应两个真实变点。噪声被累积操作平滑掉了,趋势浮出水面——这就是 CUSUM 以简驭繁的地方。
三、二分分割:从一条曲线到自动定位多个变点#
肉眼看折点不算算法。要自动定位,我们需要一个统计量来量化「这个点作为变点有多显著」。标准做法是归一化的 CUSUM 统计量:
其中 是段内标准差, 是段长。 取最大值的位置 就是最可能的单变点;如果 超过阈值,就认定这里有一个突变。
单变点检测只能找一个。真实市场往往有多个 regime,解法是二分分割(Binary Segmentation):找到最强变点后,把序列从该点切成两半,在每一半里递归地继续找,直到没有子段的统计量超过阈值。
def cusum_stat(x):
n = len(x)
if n < 30: # 段太短不可靠
return None, 0.0
S = np.cumsum(x - x.mean())
s = x.std(ddof=1)
if s == 0:
return None, 0.0
stat = np.abs(S) / (s * np.sqrt(n))
k = int(np.argmax(stat))
return k, stat[k]
def binseg(x, offset, threshold, found):
k, stat = cusum_stat(x)
if k is None or stat < threshold:
return # 没有显著变点,递归终止
bp = offset + k
found.append((bp, stat))
binseg(x[:k], offset, threshold, found) # 左半段递归
binseg(x[k:], bp, threshold, found) # 右半段递归
found = []
binseg(rets, 0, 1.10, found)
found_bp = sorted(bp for bp, _ in found)
# -> [363, 609]python
结果:真实变点 [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 个作参照。怎么定阈值?三条实战路径:
- 理论临界值:在原假设(无变点)下,归一化 CUSUM 统计量服从 Brownian Bridge 的极值分布,5% 显著性水平对应约 1.36。这给你一个有统计依据的起点。
- 信息准则惩罚:把变点数当模型复杂度,用 BIC/MDL 对「多切一刀」施加惩罚,让数据自己权衡。这是 PELT 等现代变点检测算法的做法。
- 样本外稳定性:在滚动窗口上检测,只保留在多个窗口里反复出现的变点——真 regime 切换会稳定复现,噪声不会。
五、离线 vs 在线:检测延迟是逃不掉的税#
上面的二分分割是离线(retrospective)检测:拿到完整序列后回头找变点。做研究、划分历史 regime 时够用。但实盘里你要的是在线(sequential)检测:每来一根新 K 线,立刻判断「市场是不是刚刚换挡了」。
在线检测用的是 Page 的序贯 CUSUM。以检测负向漂移(比如从上行切到下行)为例,维护一个单侧累积统计量:
其中 是「松弛量」(reference value,通常取待检测漂移幅度的一半),起过滤小波动的作用。 平时被压在零附近,一旦真的出现持续负漂移,它会稳定累积上升,突破决策界 时报警:
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 # -> 316python
真实下行从 t=300 开始,报警在 t=316——检测延迟 16 天。
这 16 天不是算法不够好,是序贯检测的物理下限。在线检测面对一个无法同时满足的三角约束:
- 想降低误报率(少被噪声骗)→ 提高决策界 → 但攒够证据更慢 → 延迟变长
- 想缩短检测延迟(快速反应)→ 降低 → 但更容易被噪声触发 → 误报变多
这就是 Page/Lorden 序贯检测理论的核心结论:平均检测延迟(ADD)和平均误报间隔(ARL)此消彼长,不存在两全。你能做的只是根据策略特性选一个平衡点:高频策略容忍不了误报(每次误报都是一次昂贵的调仓),就把 抬高吃延迟;风控报警宁可错杀,就把 压低吃误报。
六、结果解读与实战边界#
这个方法能给你什么。 一个不需要预设变点、计算量极小( 累积和 + 递归分割)、可离线可在线的 regime 拐点检测器。用它给历史数据分段,你能回答「我的策略在哪几段 regime 下有效、哪几段失效」;用在线版本挂在实盘上,你能在市场换挡时收到一个「参数该重估了」的信号。
四个必须知道的陷阱:
-
CUSUM 标准形式只检均值漂移,对波动突变失灵。 本文所有实验检测的都是均值变化。如果 regime 切换主要体现在波动率跳变(均值没怎么动,但 σ 从 1% 蹦到 2.5%),去均值累积和对此几乎无感——因为它累加的是 ,波动放大后正负相消,累积和斜率不变。检测波动突变要改用平方收益 的 CUSUM,或直接上 GARCH regime-switching / ICSS 算法。用错工具检错量,是这里最常见的翻车方式。
-
检测延迟不可能为零,别指望抄在拐点。 上面 16 天的延迟是内生的。任何声称「实时精确捕捉市场顶底」的变点检测都在骗人——它要么事后诸葛(离线),要么误报连篇(阈值压太低)。把 CUSUM 当「换挡确认器」而非「拐点预测器」,心态才对。
-
多变点检测对分割顺序敏感。 二分分割是贪心算法:先切最强的点,再递归。当两个变点强度接近或存在交互时,贪心可能切错位置。对精度要求高时,用 PELT(Pruned Exact Linear Time)这类能保证全局最优分割的算法替代二分分割。
-
全局均值 本身被变点污染。 我们用整段的 去中心化,但这个均值恰恰是混合了多个 regime 的「四不像」。变点越多、越极端, 越不代表任何一段。严格的做法是迭代:初次检测 → 分段重估各段均值 → 用局部均值重新检测。单次检测在变点不多时够用,变点密集时要迭代精修。
一句话总结:CUSUM 是 regime 检测的「听诊器」——便宜、可靠、原理透明,能告诉你「市场心跳变了」,但它不会预测下一次心跳,也听不出波动率这种「血压」问题。知道它能检什么、不能检什么,比调参数重要得多。