4、时序归因分析:滚动窗口归因、事件驱动归因、状态切换归因

时序归因,说白了就是回答一个问题:“策略的收益,到底是哪个时间段、哪个事件、哪种市场状态贡献的?”

我刚开始做归因时,犯过一个低级错误——直接用全样本跑一次归因,结果发现某个因子贡献了80%的收益。后来复盘才发现,那80%全集中在某三个月里,其他时间这个因子根本就是负贡献。嗯,从那以后,我再也不敢忽略时序维度了。

这一章,我们聊三种实战中最常用的时序归因方法:滚动窗口、事件驱动、状态切换。它们解决的是不同场景下的归因问题,但核心逻辑是一致的——把时间切开,看局部

4.1 滚动窗口归因:看因子贡献的稳定性

滚动窗口归因,是我个人最常用的诊断手段。它的思路很简单:固定一个窗口长度(比如60个交易日),每次滑动一步,重新做一次归因。这样你就能看到每个因子的贡献率随时间的变化曲线。

为什么要这么做?因为全样本归因会掩盖一个事实——因子的表现可能是不稳定的。上半年是Alpha,下半年可能就是Beta了。

核心逻辑: 滚动窗口归因 = 时间切片 + 截面归因。每个窗口独立计算,最后把结果拼成时间序列。

我在项目中遇到过这样一个案例:一个动量因子策略,全样本夏普比1.8,看起来很不错。但滚动窗口归因一跑,发现2023年Q3之后,动量因子的贡献率从60%直接掉到了10%以下。后来发现是市场风格切换了。如果不做滚动归因,你根本不知道这个因子已经“失效”了。

代码实现上,我习惯这样写:

import pandas as pd
import numpy as np
from statsmodels.api import OLS, add_constant

def rolling_factor_attribution(returns, factors, window=60):
    """
    滚动窗口因子归因
    returns: 策略日收益率序列
    factors: 因子收益率DataFrame
    window: 滚动窗口长度
    """
    n = len(returns)
    result = []
    
    for i in range(window, n):
        # 取窗口数据
        y = returns.iloc[i-window:i]
        X = add_constant(factors.iloc[i-window:i])
        
        # OLS回归
        model = OLS(y, X).fit()
        
        # 记录因子贡献
        contrib = model.params[1:] * factors.iloc[i-1:i].values[0]
        result.append({
            'date': returns.index[i],
            **{f'contrib_{col}': contrib[j] for j, col in enumerate(factors.columns)}
        })
    
    return pd.DataFrame(result).set_index('date')
我的习惯: 窗口长度不要拍脑袋。我一般先用40、60、120三个窗口跑一遍,看结果是否稳健。如果三个窗口的趋势一致,说明归因结论是可靠的。

你想想看,滚动窗口归因最大的价值是什么?是帮你识别因子的“保质期”。一个因子能稳定贡献多久?什么时候开始衰减?这些信息对调仓决策至关重要。

4.2 事件驱动归因:抓关键节点的归因

事件驱动归因,解决的是另一个问题:“某个重大事件发生后,策略的收益来源发生了什么变化?”

举个例子。美联储加息、财报发布、政策出台——这些事件前后,市场结构可能发生剧变。全样本归因会把事件前后的表现混在一起,你看不出事件的影响。

我建议的做法是:以事件日为锚点,取前后各一段窗口(比如前后各30天),分别做归因,然后对比。这叫“事件前后对比归因”。

核心逻辑: 事件驱动归因 = 事件窗口 + 对比分析。重点看因子贡献率的变化量。

我曾经用这个方法诊断过一个CTA策略。策略在2022年俄乌冲突后突然回撤了15%。事件驱动归因一分析,发现冲突前策略主要靠趋势因子赚钱,冲突后趋势因子贡献率从70%掉到了-20%,而波动率因子贡献率从10%飙到了50%。说白了,市场从趋势市变成了震荡市,策略没跟上节奏。

代码实现上,我习惯这样写:

def event_driven_attribution(returns, factors, event_dates, window=30):
    """
    事件驱动归因
    returns: 策略日收益率序列
    factors: 因子收益率DataFrame
    event_dates: 事件日期列表
    window: 事件前后窗口长度
    """
    results = []
    
    for event_date in event_dates:
        # 取事件前后窗口
        pre_start = event_date - pd.Timedelta(days=window)
        post_end = event_date + pd.Timedelta(days=window)
        
        pre_returns = returns.loc[pre_start:event_date]
        pre_factors = factors.loc[pre_start:event_date]
        post_returns = returns.loc[event_date:post_end]
        post_factors = factors.loc[event_date:post_end]
        
        # 分别做归因
        pre_model = OLS(pre_returns, add_constant(pre_factors)).fit()
        post_model = OLS(post_returns, add_constant(post_factors)).fit()
        
        # 计算贡献率变化
        contrib_change = post_model.params[1:] - pre_model.params[1:]
        
        results.append({
            'event_date': event_date,
            **{f'change_{col}': contrib_change[i] for i, col in enumerate(factors.columns)}
        })
    
    return pd.DataFrame(results).set_index('event_date')
注意: 事件窗口的长度选择很关键。窗口太短,样本量不够,回归结果不稳定;窗口太长,又会混入其他事件的影响。我一般取30-60天,具体看策略的持仓周期。

事件驱动归因还有一个变体——事件累积归因。就是把多个同类事件(比如所有加息日)的归因结果做平均,看这类事件的“平均影响”。这个方法在宏观策略中特别有用。

4.3 状态切换归因:按市场状态切分

状态切换归因,是我认为最接近“因果推断”的方法。它的思路是:先识别市场状态(牛市/熊市、高波动/低波动、趋势市/震荡市),然后按状态分别做归因。

为什么要这么做?因为同一个因子在不同市场状态下的表现可能截然不同。比如低波动因子在熊市里是避风港,在牛市里可能就是拖油瓶。

核心逻辑: 状态切换归因 = 状态识别 + 分状态归因 + 状态转移分析。

我个人习惯用HMM(隐马尔可夫模型)来做状态识别。当然,你也可以用更简单的方法——比如用波动率分位数、趋势强度指标来划分状态。

举个例子。我用HMM把市场分成三个状态:低波动趋势市、高波动震荡市、危机模式。然后分别跑归因。结果发现:

市场状态 动量因子贡献 价值因子贡献 波动率因子贡献
低波动趋势市 65% 15% 20%
高波动震荡市 -10% 40% 70%
危机模式 -30% 60% 70%

你看,动量因子在趋势市里是王者,到了震荡市和危机模式就变成了负贡献。这个信息对策略的“状态自适应”设计非常关键。

代码实现上,我习惯用HMM:

from hmmlearn import hmm

def state_switching_attribution(returns, factors, n_states=3):
    """
    状态切换归因
    returns: 策略日收益率序列
    factors: 因子收益率DataFrame
    n_states: 状态数量
    """
    # 用HMM识别市场状态
    model = hmm.GaussianHMM(n_components=n_states, covariance_type="full")
    model.fit(factors.values)
    states = model.predict(factors.values)
    
    # 分状态归因
    results = {}
    for state in range(n_states):
        mask = (states == state)
        state_returns = returns[mask]
        state_factors = factors[mask]
        
        if len(state_returns) < 20:  # 样本太少就跳过
            continue
            
        reg = OLS(state_returns, add_constant(state_factors)).fit()
        results[f'state_{state}'] = {
            'contributions': reg.params[1:],
            'n_samples': len(state_returns),
            'r_squared': reg.rsquared
        }
    
    return results, states
避坑指南: 我曾经犯过一个错误——直接用全样本训练HMM,然后做状态归因。结果发现状态标签一直在变,归因结果不稳定。后来我改成滚动训练:每次用过去半年的数据训练HMM,预测未来一个月的状态。这样状态标签就稳定多了。

状态切换归因还有一个进阶用法——状态转移归因。就是看策略在状态切换时的表现。比如从趋势市切换到震荡市时,策略的收益来源如何变化?这个分析能帮你设计“状态自适应”的调仓逻辑。

4.4 三种方法的对比与选择

说了这么多,你可能会问:这三种方法到底什么时候用?我整理了一个对比表:

方法 适用场景 核心问题 输出形式
滚动窗口归因 因子稳定性诊断 因子贡献随时间如何变化? 时间序列图
事件驱动归因 重大事件影响分析 事件前后因子贡献有何变化? 对比表格
状态切换归因 市场状态适应性分析 不同状态下因子贡献有何差异? 状态-贡献矩阵

我个人习惯的组合是:先用滚动窗口归因做“体检”,发现异常时间段;然后用事件驱动归因定位具体事件;最后用状态切换归因解释根本原因。三步走,基本能把策略的时序行为说清楚。

嗯,这一章的内容就到这里。时序归因的核心就一句话:不要只看整体,要切开看局部。 你想想看,一个策略的收益来源如果随时间变化很大,那全样本归因的结果就是“平均的谎言”。只有把时间维度加进来,归因才有真正的诊断价值。


公众号:蓝海资料掘金营,微信deep3321