单因子评估 — IC / IR / 分组回测 / 衰减分析
IC / IC IR / 分组回测 / IC 衰减 / alphalens 工作流
日期: 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}) $$
注意三个细节:
- 截面相关,不是时间序列相关。你不是在算「一只股票的 factor 历史 vs 它的 return 历史」,而是在算「t 时刻所有股票的 factor 排序 vs 它们 t+1 期的 return 排序」。
- 必须是 t+1 期 return,不是 t 期。如果用 t 期,就是「现在的 factor 解释现在的 return」=完美 in-sample,毫无预测价值。
- IC 是一个时间序列。每期一个值,最后求均值和标准差。
2.2 Pearson IC vs Spearman rank IC
| 类型 | 公式 | 假设 | 抗异常值 | 何时用 |
|---|---|---|---|---|
| Pearson IC | 标准 Pearson 相关 | factor 和 return 线性相关 | 差 | 因子已经标准化、无 outlier |
| Spearman rank IC | rank 后 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 / ...
输出大致长这样:
| Horizon | ic_mean | ic_ir | t_stat |
|---|---|---|---|
| 1 | 0.045 | 0.62 | 6.8 |
| 2 | 0.038 | 0.55 | 6.0 |
| 3 | 0.029 | 0.42 | 4.6 |
| 6 | 0.014 | 0.21 | 2.3 |
| 12 | 0.005 | 0.07 | 0.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-test | IC IR × √N |
| 多 variant 测试 | 多因子合成 |
| Novelty effect 衰减 | IC decay |
| Sample selection bias | Survivorship bias |
| Multiple comparison problem | Factor 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_ic和ic_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 字 今日完成度:理论 ✓ / 实操(你自己执行)/ 笔记 ✓