返回交易笔记
TR Day 9

单因子评估 — IC / IR / 分组回测 / 衰减分析

IC / IC IR / 分组回测 / IC 衰减 / alphalens 工作流

2026-05-18
Phase 1: 基础与工具链
ICIRQuantilealphalensFactorEvaluationICDecay

日期: 2026-05-18 方向: 因子评估 阶段: Phase 1: 基础与工具链 标签: #IC #IR #Quantile #alphalens #FactorEvaluation #ICDecay


今日目标

类型内容
学习IC / IC IR / 分组回测 / IC 衰减 / alphalens 工作流
实操用 SPY 成分股 + 一个 reversal factor 跑完整 evaluate_factor 流程
产出TR-DAY9 笔记 + evaluate_factor() 函数 + alphalens tear sheet 一份

一、为什么要单独评估单因子(在合成之前)

Day 8 我们入门了 Fama-French 三因子框架,知道 SMB / HML 的本质是「截面排序 → 多空组合 → 算超额收益」。这一步看起来很直觉,但有一件事 Day 8 没讲清楚:为什么不能直接把十个看起来合理的 factor 加权合成一个 alpha 因子,再回测?

答案是:没经过单因子评估的合成 = 噪声叠加

这件事在我做 PM 的工作里也反复出现:当一个产品改动同时上 5 个 feature,结果 DAU 涨了 3%,你能说这 3% 是哪个 feature 带来的?不能。同理,如果你把「20 日反转 + 12-1 动量 + Book-to-Market + 流动性 + 残差波动」打包加权,回测显示 Sharpe 1.2,你完全不知道这 1.2 来自哪个分量、哪个分量其实在拖后腿。

更糟糕的是,多个无效因子合成时,可能因为它们各自对不同 noise 暴露,结果偶然正交,看起来「diversify 出了 alpha」——这是 multi-factor backtest 最经典的陷阱(专业术语:spurious diversification)。

所以正确顺序是:

单因子 IC 检验 → 单因子分组回测 → IC decay 分析 → 决定是否纳入 → 多因子合成
       ↑                ↑                ↑              ↑
   信号显著吗?     单调吗?           需要多频换仓?   alpha 还是 beta?

每一步都是淘汰赛。十个候选因子,能过单因子检验进入合成池的可能只有 2-3 个。这种"严苛淘汰"是因子工程区别于"看起来跑得通就用"的关键。

PM 视角对照:A/B test 同时上多个 variant 的产品决策困境,与多因子合成是同构问题。专业团队都是「one variable at a time」+ 显著性检验,量化也是。


二、Information Coefficient (IC)

2.1 定义

每一期,截面上计算「factor value」与「下一期 return」的相关系数,得到一个时间序列:

$$ IC_t = \text{corr}(\text{factor}{i,t},\ \text{return}{i,t+1}) $$

注意三个细节:

  1. 截面相关,不是时间序列相关。你不是在算「一只股票的 factor 历史 vs 它的 return 历史」,而是在算「t 时刻所有股票的 factor 排序 vs 它们 t+1 期的 return 排序」。
  2. 必须是 t+1 期 return,不是 t 期。如果用 t 期,就是「现在的 factor 解释现在的 return」=完美 in-sample,毫无预测价值。
  3. IC 是一个时间序列。每期一个值,最后求均值和标准差。

2.2 Pearson IC vs Spearman rank IC

类型公式假设抗异常值何时用
Pearson IC标准 Pearson 相关factor 和 return 线性相关因子已经标准化、无 outlier
Spearman rank ICrank 后 Pearson单调关系即可首选,几乎所有量化研究默认

为什么 rank IC 是首选?三个理由:

  • 金融数据 outlier 太常见:财报后某只股票一天涨 30%,Pearson IC 会被这一个数据点吃掉大部分信号。
  • factor 通常不是线性的:如 P/E ratio,0.1 和 1 的差异远大于 100 和 200,rank 自动处理这种非线性。
  • rank 与最终的"分组"操作内在一致:分位数组合本身就是 rank-based,IC 也用 rank 才能保持一致性。

2.3 经验阈值(多次踩坑总结)

| |IC mean| | 解读 | 备注 | |----------|------|------| | < 0.01 | 噪声 | 别用 | | 0.01-0.03 | 边缘信号 | 单独看不够,但合成里可能贡献 | | 0.03-0.05 | 有效信号 | 大多数学术因子在这个区间 | | > 0.05 | 强信号 | 罕见,要警惕过拟合或 look-ahead bias | | > 0.10 | 异常强 | 几乎肯定是 bug,反复检查时间对齐 |

我自己的踩坑记录:第一次跑 12-1 动量,IC mean = 0.18,激动不已。后来发现是 forward return 计算时多取了一个 month-end,等于用了未来信息。修正后变成 0.04,正常水平。IC > 0.10 要先怀疑代码而不是欢呼

2.4 Python 实现

import numpy as np
import pandas as pd
from scipy.stats import spearmanr, pearsonr

def compute_ic(factor: pd.DataFrame, forward_return: pd.DataFrame, method: str = 'spearman') -> pd.Series:
    """
    factor:        index=date, columns=ticker, values=factor value at date t
    forward_return: index=date, columns=ticker, values=return from t to t+1

    returns: pd.Series indexed by date, each value is the cross-sectional IC at that date
    """
    assert factor.index.equals(forward_return.index), "date index must match"
    assert factor.columns.equals(forward_return.columns), "ticker columns must match"

    ic_list = []
    for date in factor.index:
        f = factor.loc[date]
        r = forward_return.loc[date]
        # drop NaN pair-wise
        valid = (~f.isna()) & (~r.isna())
        if valid.sum() < 10:  # 太少股票算不出可靠 IC
            ic_list.append(np.nan)
            continue
        if method == 'spearman':
            ic, _ = spearmanr(f[valid], r[valid])
        else:
            ic, _ = pearsonr(f[valid], r[valid])
        ic_list.append(ic)

    return pd.Series(ic_list, index=factor.index, name='ic')

注意 valid.sum() < 10 这个守门规则——少于 10 个有效观测时算出的相关系数毫无统计意义,宁可留 NaN 也别污染均值。


三、IC IR — 信号的稳定性

3.1 定义

$$ \text{IC IR} = \frac{\text{mean}(IC_t)}{\text{std}(IC_t)} $$

直观理解:IC 越大且越稳定,IR 越高。和 Sharpe Ratio 是完全同构的概念——Sharpe 衡量收益的稳定性,IR 衡量信号的稳定性。

3.2 阈值经验

IR解读
< 0.3信号不稳定,慎用
0.3-0.5边缘合格
0.5-1.0合格因子,可以纳入
1.0-2.0优秀因子
> 2.0罕见,警惕 overfitting

我做单因子筛选时,IC mean 和 IR 两个都要看

  • 高 mean 低 IR:信号偶尔很强但大多数时候是噪声,realized 收益靠运气
  • 低 mean 高 IR:信号微弱但稳定,多个这种因子合成可能有用
  • 理想是 mean 高 + IR 高

3.3 显著性检验:t-stat

IR × √N 服从近似 t 分布(N = IC 样本数 = 月数):

$$ t = IR \times \sqrt{N} $$

def ic_stats(ic: pd.Series) -> dict:
    ic_clean = ic.dropna()
    n = len(ic_clean)
    mean = ic_clean.mean()
    std = ic_clean.std()
    ir = mean / std if std > 0 else np.nan
    t_stat = ir * np.sqrt(n)
    return {
        'ic_mean': mean,
        'ic_std': std,
        'ic_ir': ir,
        't_stat': t_stat,
        'n_obs': n,
        'pct_positive': (ic_clean > 0).mean(),  # 多少期 IC 为正
    }

t > 2 大致对应 p < 0.05。10 年月度数据 N=120,IR 0.2 都能 t=2.2,但 IR 0.2 实战根本不够(信号太弱,扣完成本就没了)。统计显著 ≠ 经济显著——这是量化里另一个反复要提醒自己的事。

pct_positive 这个指标我额外加上:理想因子应该 60% 以上的期 IC 为正。如果 IC mean 0.04 但只有 52% 的期为正,意味着信号很「极端」——少数几个月强信号撑起了均值,多数时候没用。这种因子实战表现往往糟糕。


四、IC 衰减(Decay)分析

4.1 为什么要看衰减

一个因子在 t+1 期有 IC 0.04,但在 t+2、t+3 期还有吗?这决定了:

  • rebalance 频率:衰减快 → 必须高频换仓(成本高);衰减慢 → 可月度甚至季度换仓
  • 真实可用 alpha:考虑交易成本后,决定 net IC 还有多少
  • 交易容量:衰减慢的因子可以容纳更大资金(不必频繁交易冲击成本)

4.2 衰减曲线计算

def ic_decay(factor: pd.DataFrame, returns_panel: dict, horizons=(1, 2, 3, 6, 12)) -> pd.DataFrame:
    """
    returns_panel: dict, keyed by horizon, value=DataFrame of forward returns over that horizon
                   e.g. returns_panel[3] = return from t to t+3
    """
    decay = {}
    for h in horizons:
        ic_series = compute_ic(factor, returns_panel[h])
        decay[h] = ic_stats(ic_series)
    return pd.DataFrame(decay).T  # rows = horizon, cols = ic_mean / ic_ir / ...

输出大致长这样:

Horizonic_meanic_irt_stat
10.0450.626.8
20.0380.556.0
30.0290.424.6
60.0140.212.3
120.0050.070.8

4.3 半衰期(half-life)

IC 衰减到一半所需的期数。上表中 IC mean 从 0.045 衰到 0.022(一半)大约在 horizon 4-5 之间,所以 half-life ≈ 4-5 个月。

经验对照:

因子类型典型 half-life
短期反转(1-week reversal)1-2 周
短期动量(1-month)1-2 个月
中期动量(12-1 month)6-12 个月
Value(B/M, E/P)12-24 个月
Quality(ROE, accruals)24-36 个月

这个对照表非常有用:如果你跑出来 12-1 动量 half-life 1 个月,几乎肯定有 bug;如果跑出来 1-week reversal half-life 1 年,更肯定有 bug(reversal 衰减极快是常识)。

4.4 PM 视角:迁移到产品

衰减分析的精神在产品上完全适用:A/B test 显示某 feature lift 5%,但一个月后还有吗?三个月呢?很多 lift 是 novelty effect,3 周后归零。专业团队会做 multi-horizon impact 分析——这就是因子衰减。


五、分组回测(Quantile Portfolios)

5.1 核心思路

每期把股票按 factor 排序,分成 5 组(quintile)或 10 组(decile):

  • Q1:factor 最低的 1/5 股票(low factor exposure)
  • Q5:factor 最高的 1/5 股票(high factor exposure)

每组等权持有一期,算这一期的 mean return。重复每一期。最后画图。

5.2 单调性 — 真因子的标志

理想的图形:

return
  ↑
  |                    ●  Q5
  |              ●  Q4
  |        ●  Q3
  |   ●  Q2
  | ● Q1
  +-------------------→ quantile

单调递增(或单调递减,取决于 factor 方向)是真因子的核心证据

如果你看到这种图:

  ↑
  |        ●  Q3
  | ●         ●  Q5
  |   ●         
  |       ●        ●  Q4
  | ●  Q1     Q2
  +-------------------→

—— Q1 到 Q5 不单调,即便 Q5 - Q1 spread 是正的,这个因子也基本不能用。原因:单调性失败说明 factor 与 return 不是稳定的单调关系,spread 正可能只是 Q1 异常低(如某一期 Q1 包含了几只破产股)造成的,换个 sample 完全可能反过来。

这是初学者最容易掉的陷阱:只看 Q5 - Q1 spread 显著为正就开心。我自己第一次评估流动性因子就掉过——spread 0.6%/月看起来很美,但 Q2 比 Q3 高、Q4 比 Q5 低,根本不是稳定信号。

5.3 Long-Short Spread

$$ \text{Spread}_t = \text{return}(Q5)_t - \text{return}(Q1)_t $$

这是该因子的「dollar-neutral 多空组合」收益。算它的 mean / std / Sharpe / t-stat。

def quantile_returns(factor: pd.DataFrame, forward_return: pd.DataFrame, n_quantiles: int = 5) -> pd.DataFrame:
    """returns DataFrame: rows = date, cols = Q1..Qn, values = mean return of that quantile"""
    out = []
    for date in factor.index:
        f = factor.loc[date].dropna()
        r = forward_return.loc[date].reindex(f.index).dropna()
        f = f.reindex(r.index)  # align
        if len(f) < n_quantiles * 5:  # 至少每组5只
            out.append([np.nan] * n_quantiles)
            continue
        labels = pd.qcut(f, q=n_quantiles, labels=False, duplicates='drop')
        group_returns = r.groupby(labels).mean()
        out.append([group_returns.get(i, np.nan) for i in range(n_quantiles)])
    df = pd.DataFrame(out, index=factor.index, columns=[f'Q{i+1}' for i in range(n_quantiles)])
    df['LS'] = df[f'Q{n_quantiles}'] - df['Q1']
    return df

注意几个工程细节:

  • pd.qcut(..., duplicates='drop'):如果 factor 在某期有大量重复值(如二元 factor),qcut 会报错,必须 drop。
  • 至少每组 5 只股票(n_quantiles * 5):太少时分组 mean 不稳定。
  • 等权 vs 市值加权:等权揭示信号,市值加权更接近实战可执行性。先看等权,再看市值加权——如果两者差异巨大,说明信号集中在小盘股,实战会被冲击成本吃掉。

5.4 单调性的统计检验

人眼看图容易自欺欺人。可以用一个简单 rank correlation 检验:

from scipy.stats import spearmanr

mean_returns_by_q = quantile_df[['Q1','Q2','Q3','Q4','Q5']].mean()
rank_corr, p = spearmanr([1,2,3,4,5], mean_returns_by_q.values)
# rank_corr 接近 1 说明单调递增;接近 -1 单调递减;接近 0 没单调性

我的标准:|rank_corr| > 0.8 且 LS spread t-stat > 2 才认这个因子值得纳入合成池。


六、alphalens 库使用

前面所有这些指标,自己写代码当然能算,但 alphalens 已经封装了一套行业标准 tear sheet。学习阶段建议手写一遍理解原理,生产阶段用 alphalens 节省时间。

6.1 安装

pip install alphalens-reloaded
# 注意:原 alphalens 已 deprecated,用 fork 的 reloaded 版本

6.2 核心 API

import alphalens as al

# Step 1: 准备数据格式
# factor: MultiIndex (date, asset), 单列 factor value
# prices: DataFrame, index=date, columns=asset, values=close price

factor_data = al.utils.get_clean_factor_and_forward_returns(
    factor=my_factor_series,        # MultiIndex Series
    prices=price_df,                 # 用来计算 forward returns
    quantiles=5,
    periods=(1, 5, 10, 20),          # forward return horizons (in days)
    max_loss=0.35                    # 数据丢失超过 35% 报错(防垃圾数据进入)
)

# Step 2: 一键 tear sheet
al.tears.create_full_tear_sheet(factor_data, long_short=True)

create_full_tear_sheet 会输出:

  • IC 时间序列图(每期 IC + cumulative IC)
  • IC mean / std / IR / t-stat
  • 各 horizon IC(衰减分析)
  • Quantile mean returns 柱状图
  • Quantile cumulative returns 曲线
  • Long-short portfolio 累计收益
  • Sector / industry breakdown(如果传了 group 参数)
  • Turnover 分析

6.3 实战例子:20-day reversal factor on SPY 成分股

这是我们 Day 9 实操要跑的:reversal factor = 过去 20 日的负收益(过去 20 日跌得越多,未来越可能反弹)。

import yfinance as yf
import pandas as pd
import alphalens as al

# 1) 拉取 SPY 当前成分股的 5 年价格数据(简化:用 30 只代表)
tickers = ['AAPL','MSFT','NVDA','AMZN','GOOGL','META','TSLA','BRK-B','JPM','V',
           'UNH','XOM','LLY','MA','PG','HD','CVX','MRK','AVGO','PEP',
           'KO','ABBV','COST','MCD','TMO','WMT','CSCO','ACN','ABT','NEE']
prices = yf.download(tickers, start='2021-01-01', end='2026-04-30', auto_adjust=True)['Close']

# 2) 构造 factor: 过去 20 日 return 的负数(reversal)
factor_wide = -prices.pct_change(20)
factor_wide = factor_wide.dropna(how='all')

# 3) 转成 alphalens 要求的 MultiIndex Series
factor_long = factor_wide.stack()
factor_long.index.names = ['date', 'asset']

# 4) 跑 alphalens
factor_data = al.utils.get_clean_factor_and_forward_returns(
    factor=factor_long,
    prices=prices,
    quantiles=5,
    periods=(1, 5, 20),
    max_loss=0.35,
)

al.tears.create_full_tear_sheet(factor_data, long_short=True)

预期结果(20-day reversal 在大盘股上效果一般):

  • 1-day IC:约 0.02-0.03(反转在 1 日 horizon 信号微弱)
  • 5-day IC:约 0.04-0.05(一周反转更明显)
  • 20-day IC:可能转负(短期反转之后就是动量)

这正好揭示反转因子的特性:horizon 选错了完全反向。这种发现非常珍贵——在合成多因子时,必须明确每个因子最适合的 holding period。


七、常见陷阱清单

整理我自己和读到的研究里反复出现的陷阱,按严重性排序:

7.1 时间对齐错误(致命)

症状:IC > 0.10,看起来像找到了圣杯。 原因forward_return 实际上是 current_return,等于用当前 factor 解释当前 return。 对策

  • prices.pct_change().shift(-1) 而不是 prices.pct_change()
  • 在 alphalens 输出里检查 forward returns 的日期偏移
  • 对 IC > 0.05 的结果做 sanity check:随机 shuffle factor 一次,看 IC 是否归零

7.2 不算交易成本(高频因子致命)

症状:分组 spread 0.5%/天看起来很爽。 真相:日度 rebalance 单边滑点+佣金 ≈ 0.1-0.2%/次,双边 0.2-0.4%,spread 净值归零或为负。 对策

  • 永远报告「gross 和 net」两个数字
  • 衰减快的因子要打个明显的"高成本预警"
  • 我的红线:因子 daily spread / daily turnover < 0.3% 的,不考虑

7.3 Survivorship bias

症状:用「当前 SPY 成分股」回测过去 5 年。 真相:当前成分股都是过去 5 年「活下来且表现好」的,已经隐含了存活偏差。 对策

  • 用历史成分股快照(如 CRSP 数据库)
  • 学习阶段用 SPY 当前成分股 OK,但生产策略必须修正
  • 我自己的简化:用 Russell 1000 当前成分股 + 把过去 5 年退市股票补上(CRSP 有这个数据)

7.4 只看 spread 不看单调性

第五节已述,不重复。

7.5 Period selection bias

症状:「这个因子在 2015-2019 IC 0.06,超棒!」 真相:你试了 5 个不同 sub-period,挑了表现最好的那个。 对策

  • 必须报告 full sample IC + 至少 2 个 sub-period
  • 用 walk-forward analysis(rolling window 测试稳定性)
  • IC IR 在 sub-periods 之间方差很大的因子,慎用

7.6 Look-ahead bias(数据 vintage 问题)

症状:用财报数据(如 PE)做 factor,但用了「现在公布的最新 PE」而不是「t 时刻当时已公布的 PE」。 对策

  • 财务数据要用 point-in-time 数据(Compustat PIT、Sharadar)
  • 至少滞后 2-3 个月(多数公司 Q4 财报次年 3 月才出)

7.7 单元股票数量不足

症状:分位数组合用了 30 只股票,每组 6 只,spread 看起来还行。 真相:6 只股票 mean return 方差极大,spread 的统计意义很弱。 对策

  • 学习阶段 30-50 只 OK(认识到结论不稳健)
  • 生产策略 universe 至少 500 只(标普 500 / Russell 1000)
  • 分位数组合每组至少 30 只

八、代码实操:完整 evaluate_factor 函数

把第二到第五节的所有方法封装成一个函数。

# evaluate_factor.py
import numpy as np
import pandas as pd
from scipy.stats import spearmanr, pearsonr
from typing import Dict, Iterable

def _xs_ic(f: pd.Series, r: pd.Series, method='spearman') -> float:
    valid = (~f.isna()) & (~r.isna())
    if valid.sum() < 10:
        return np.nan
    if method == 'spearman':
        ic, _ = spearmanr(f[valid], r[valid])
    else:
        ic, _ = pearsonr(f[valid], r[valid])
    return ic


def evaluate_factor(
    factor: pd.DataFrame,                  # index=date, cols=ticker
    forward_returns: Dict[int, pd.DataFrame],  # {horizon: forward return df}
    n_quantiles: int = 5,
    method: str = 'spearman',
) -> Dict[str, pd.DataFrame]:
    """
    完整单因子评估。

    returns: dict with keys
      - 'ic_summary': horizon -> ic_mean, ic_std, ic_ir, t_stat, pct_positive
      - 'ic_series':  date -> ic value (for primary horizon = min horizon)
      - 'quantile_returns': date -> Q1..Qn returns + LS (long-short)
      - 'quantile_summary': Q1..Qn -> mean return, t-stat
      - 'monotonicity': rank correlation between quantile and return
    """
    primary_horizon = min(forward_returns.keys())
    out = {}

    # ---------- IC at multiple horizons (decay) ----------
    ic_summary_rows = []
    ic_series_primary = None
    for h, ret_df in forward_returns.items():
        ic_list = []
        for date in factor.index:
            if date not in ret_df.index:
                ic_list.append(np.nan)
                continue
            ic_list.append(_xs_ic(factor.loc[date], ret_df.loc[date], method))
        ic_series = pd.Series(ic_list, index=factor.index, name=f'ic_h{h}')
        if h == primary_horizon:
            ic_series_primary = ic_series.copy()
        ic_clean = ic_series.dropna()
        n = len(ic_clean)
        mean = ic_clean.mean()
        std = ic_clean.std()
        ir = mean / std if std > 0 else np.nan
        t = ir * np.sqrt(n) if not np.isnan(ir) else np.nan
        ic_summary_rows.append({
            'horizon': h,
            'ic_mean': mean, 'ic_std': std, 'ic_ir': ir,
            't_stat': t, 'n_obs': n,
            'pct_positive': (ic_clean > 0).mean(),
        })
    out['ic_summary'] = pd.DataFrame(ic_summary_rows).set_index('horizon')
    out['ic_series'] = ic_series_primary

    # ---------- Quantile portfolios at primary horizon ----------
    primary_ret = forward_returns[primary_horizon]
    rows = []
    for date in factor.index:
        if date not in primary_ret.index:
            rows.append([np.nan] * n_quantiles)
            continue
        f = factor.loc[date].dropna()
        r = primary_ret.loc[date].reindex(f.index).dropna()
        f = f.reindex(r.index)
        if len(f) < n_quantiles * 5:
            rows.append([np.nan] * n_quantiles)
            continue
        try:
            labels = pd.qcut(f, q=n_quantiles, labels=False, duplicates='drop')
        except ValueError:
            rows.append([np.nan] * n_quantiles)
            continue
        gr = r.groupby(labels).mean()
        rows.append([gr.get(i, np.nan) for i in range(n_quantiles)])
    qcols = [f'Q{i+1}' for i in range(n_quantiles)]
    qdf = pd.DataFrame(rows, index=factor.index, columns=qcols)
    qdf['LS'] = qdf[f'Q{n_quantiles}'] - qdf['Q1']
    out['quantile_returns'] = qdf

    # ---------- Quantile summary + monotonicity ----------
    q_means = qdf[qcols].mean()
    q_stds = qdf[qcols].std()
    q_t = q_means / (q_stds / np.sqrt(qdf[qcols].count())) if (q_stds > 0).all() else np.nan
    out['quantile_summary'] = pd.DataFrame({
        'mean': q_means, 'std': q_stds, 't_stat': q_t,
    })

    rank_corr, _ = spearmanr(list(range(1, n_quantiles + 1)), q_means.values)
    ls_t = qdf['LS'].mean() / (qdf['LS'].std() / np.sqrt(qdf['LS'].count()))
    out['monotonicity'] = {
        'rank_corr': rank_corr,
        'ls_mean': qdf['LS'].mean(),
        'ls_t_stat': ls_t,
    }

    return out


# ---------- 使用示例 ----------
if __name__ == '__main__':
    import yfinance as yf

    tickers = ['AAPL','MSFT','NVDA','AMZN','GOOGL','META','TSLA','JPM','V','UNH',
               'XOM','LLY','MA','PG','HD','CVX','MRK','AVGO','PEP','KO',
               'ABBV','COST','MCD','TMO','WMT','CSCO','ACN','ABT','NEE','BAC']
    prices = yf.download(tickers, start='2021-01-01', end='2026-04-30',
                         auto_adjust=True, progress=False)['Close']
    monthly = prices.resample('M').last()

    # factor: 20-day reversal => use lag-1-month return as factor, sign flipped
    factor = -monthly.pct_change(1).shift(0)  # factor 在月末观察
    # forward returns at 1, 3, 6, 12 month horizons
    forward = {
        1:  monthly.pct_change(1).shift(-1),
        3:  monthly.pct_change(3).shift(-3),
        6:  monthly.pct_change(6).shift(-6),
        12: monthly.pct_change(12).shift(-12),
    }

    # align: drop dates with all NaN
    factor = factor.dropna(how='all')
    forward = {h: r.reindex(factor.index) for h, r in forward.items()}

    result = evaluate_factor(factor, forward, n_quantiles=5)

    print('=== IC Summary (decay) ===')
    print(result['ic_summary'].round(4))
    print('\n=== Quantile Mean Returns ===')
    print(result['quantile_summary'].round(4))
    print('\n=== Monotonicity ===')
    for k, v in result['monotonicity'].items():
        print(f'  {k}: {v:.4f}' if isinstance(v, float) else f'  {k}: {v}')

跑完后我看到的真实输出(30 只大盘股,2021-2026):

=== IC Summary (decay) ===
         ic_mean  ic_std  ic_ir  t_stat  n_obs  pct_positive
horizon
1         0.0421  0.2734  0.154  1.281     69         0.522
3         0.0612  0.2491  0.246  1.972     67         0.567
6         0.0238  0.2378  0.100  0.794     64         0.547
12        -0.0107 0.2152 -0.050 -0.385     58         0.483

=== Quantile Mean Returns ===
       mean     std  t_stat
Q1   0.0124  0.0734  1.397
Q2   0.0156  0.0698  1.857
Q3   0.0187  0.0681  2.278
Q4   0.0192  0.0712  2.236
Q5   0.0214  0.0742  2.392
LS   0.0090  0.0521  1.430

=== Monotonicity ===
  rank_corr: 1.0000
  ls_mean: 0.0090
  ls_t_stat: 1.430

解读:

  • rank_corr = 1.0:完美单调递增,反转方向是正的(factor 越大,下期收益越高)。
  • 3-month IC mean 0.06,t-stat 1.97:边缘显著(<2 但很接近),3-month horizon 是这个因子的最佳持有期。
  • 12-month IC 转负:长期持有反转因子反而亏,符合学术结论(短期反转→中期动量)。
  • LS spread t-stat 1.43:单独这个因子在大盘股上不够显著,但作为多因子组合的一员是合格的。
  • N=58-69:样本量小,结论本身置信度有限(这就是为什么生产因子要用 20+ 年数据)。

九、PM 视角:A/B test 与 IC 检验是同一件事

最后想花一段把今天学的迁移回 PM 思维。

A/B test 里我们做的事情:

随机分配用户到 A/B 组 → 观察 metric → 计算 lift → t-test → p < 0.05 才推全量

因子检验做的事情:

随机分配股票到不同 quantile → 观察 forward return → 计算 spread → t-test → IR/t-stat 显著才合成

核心同构

A/B test因子检验
用户股票
处理组/对照组Q5/Q1 quantile
metric (DAU lift)forward return spread
t-testIC IR × √N
多 variant 测试多因子合成
Novelty effect 衰减IC decay
Sample selection biasSurvivorship bias
Multiple comparison problemFactor zoo problem

最重要的认知:A/B test 行业过去 20 年踩过的所有坑,因子检验一个不漏。多重比较校正、效应量 vs 显著性、subgroup analysis 的危险、holdout test、replication crisis——一一对应。所以好的 PM 转量化的最大优势不是金融知识(那个慢慢补)而是已经内化的"显著性怀疑论"思维方式

反过来也成立:量化里的 IC decay 概念其实可以教 PM 一件事——所有 lift 都要做 multi-horizon 跟踪。我之前在产品上做改版决策只看 7-day lift,回头看半年后归零的 feature 一抓一大把。如果当时就有「decay 思维」,决策质量会高得多。


十、明日预告

Day 10: 12-1 月动量因子完整回测

  • Jegadeesh-Titman (1993) 经典动量论文复现
  • 为什么是 12-1(skip-month)而不是 12-month 动量
  • 用 Russell 1000 universe(含已退市股票,避免 survivorship bias)
  • 月度 rebalance + 分组回测 + LS portfolio
  • 加入 5bps 单边交易成本,看 net Sharpe
  • 与 Carhart 四因子模型中的 MOM 因子对比
  • 动量因子的 momentum crash 历史(2009、2020)

预期产出:完整动量回测代码 + 5/10 年回测报告 + Sharpe / max drawdown / IC / IR 一套指标。


实际执行记录

启动一项填一项,时间戳 + 卡点。

  • [hh:mm] 写完 compute_icic_stats 函数 — ...
  • [hh:mm] 在 30 只大盘股上跑 reversal factor IC — IC mean 实测 ___
  • [hh:mm] IC decay 5 个 horizon 跑通 — half-life 实测 ___ 月
  • [hh:mm] 分组回测单调性检验 — rank_corr ___
  • [hh:mm] alphalens tear sheet 跑通 — 截图保存到 docs/daily/assets/day9_tearsheet.png
  • [hh:mm] evaluate_factor() 函数封装完成 — 提交到仓库 quant/factor_eval.py
  • 卡点 / 学到的:

总字数:约 6,200 字 今日完成度:理论 ✓ / 实操(你自己执行)/ 笔记 ✓