前几篇我们都在「择时」的世界里打转:均线、网格、轮动,研究的都是什么时候买什么资产。从本篇起视野切换到「选股」:横截面上几百上千只股票,我们用什么规则挑出未来更可能跑赢的那一小撮?答案就是多因子模型(Multi-Factor Model)——用一组可量化的「因子」给每只股票打分,分高者买入。本篇聚焦两个核心工具:IC(信息系数,衡量因子预测力)与分层回测(Layered Backtest,检验因子收益是否单调)。
本篇目标
- 能解释什么是因子(Factor)、因子暴露与因子收益
- 能用 Python 计算 Rank IC 序列,并解读其均值、标准差与 ICIR
- 能实现完整的分层回测:按因子值分 5 组,检验组间收益是否单调
- 能理解「IC 显著 + 分层单调」双验证的逻辑,缺一不可
- 能指出教学示例(模拟股票池)相对真实市场的局限
核心概念
因子是什么
因子(Factor)就是一个「选股理由的量化表达」。比如「过去 20 日涨幅」(动量因子)、「过去 60 日收益波动率」(低波动因子)、「市值」(小市值因子)。每只股票在每个交易日都有一个因子值,称为该股的因子暴露(Exposure)。多因子模型相信:因子暴露高的股票,未来收益有系统性差异——这个差异就是因子收益。
IC:因子预测力的单一数字
IC(Information Coefficient,信息系数)= 当期因子值与下期收益的相关系数。计算时先在某个截面日(比如月末),取所有股票的因子值,再取这些股票下个月的收益,算两者的 Spearman 秩相关(Rank IC 用秩相关更抗极端值),得到一个 IC 值。滚动每个月都算一次,得到一条 IC 序列。
解读经验(教学惯例):
| |IC| 均值 | 预测力 | |---|---|---| | < 0.02 | 很弱,基本没用 | | 0.02 ~ 0.05 | 有一定预测力 | | 0.05 ~ 0.10 | 相当不错的因子 | | > 0.10 | 罕见,需警惕数据挖掘 |
光看均值还不够:IC 的稳定性同样重要。ICIR = IC均值 / IC标准差,衡量「预测力持续稳定的能力」。一个 IC 均值 0.04 但 ICIR 高的因子,往往比 IC 均值 0.06 但时有时无的因子更可用。
划重点:IC 衡量的是「平均而言,因子值高一点点,收益是不是也高一点点」,它是横截面关系,不是时间序列预测。别拿个股自身的历史算 IC,那不是 IC 的算法。
分层回测:单调性检验
IC 只给一个数字,容易被极端值或个别月份扭曲。分层回测更直观:每个截面日按因子值从低到高把股票均分成 5 组(Q1 最低、Q5 最高),每组等权持有到下个截面日,滚动下来得到 5 条组内净值曲线。如果 Q1 到 Q5 的收益严格递增(或递减),说明因子信息是「有序的」,这样的因子才好用于选股——因为我们只买最高分那组,而单调性保证「分数越高越好」全程成立。
为什么两个都要做
IC 显著但分层不单调:因子预测力集中在少数极端股票上,或者被极端值污染,做多头组合(只买 Top 组)未必赚钱。分层单调但 IC 不显著:组间差异可能由一两组贡献,秩相关被「中间大部队」稀释。双验证通过,才值得进入下一篇的打分法选股。
完整代码
本篇依赖:uv pip install pandas numpy。教学用模拟股票池:200 只股票 × 60 个月,真实数据可按第 7 篇方式拉取后替换。模拟数据里我们故意埋入一个真实存在的效应(低波动效应),看看工具能不能把它挖出来。
import numpy as np
import pandas as pd
# ---------- 1. 造模拟股票池:月频面板数据 ----------
rng = np.random.default_rng(42)
n_stock, n_month = 200, 60
months = pd.date_range("2020-01-31", periods=n_month, freq="ME")
# 每只股票有自己的真实波动率(截面差异,且未来收益与波动负相关 → 低波因子有效)
true_vol = rng.uniform(0.03, 0.12, n_stock)
# 市场共同收益 + 个股噪声;收益里埋入 -0.3 × 波动 的低波效应
common = rng.normal(0.005, 0.03, n_month)
ret_panel = pd.DataFrame(
{s: common + rng.normal(0, tv, n_month) - 0.3 * tv
for s, tv in zip(range(n_stock), true_vol)},
index=months)
# 每月每只股票的「因子值」:用过去 6 个月已实现波动率(有噪声的估计)
hist_vol = ret_panel.rolling(6).std().shift(1) # shift(1):只用过去数据
factor = hist_vol # 低波动因子:值越低越好(反转处理见下)
# ---------- 2. Rank IC 序列与 ICIR ----------
def rank_ic_series(factor, fwd_ret):
"""factor: 面板(月×股票) 因子值; fwd_ret: 面板 下月收益"""
ics = {}
for d in factor.index:
f = factor.loc[d].dropna()
r = fwd_ret.loc[d].reindex(f.index).dropna()
if len(f) < 30:
continue
ics[d] = f.corr(r, method="spearman")
return pd.Series(ics)
fwd = ret_panel.shift(-1) # 下月收益(t 月因子 → t+1 月收益)
ic = rank_ic_series(factor.dropna(how="all"), fwd)
print(f"IC 均值(示意): {ic.mean():.4f}, IC 标准差: {ic.std():.4f}, "
f"ICIR: {ic.mean() / ic.std():.3f}, IC>0 占比: {(ic > 0).mean():.0%}")
# ---------- 3. 分层回测:按因子值分 5 组 ----------
def layered_backtest(factor, fwd_ret, n_group=5, ascending=True):
"""返回各组的月收益序列 DataFrame"""
valid = factor.dropna(how="all").index
group_ret = {g: [] for g in range(1, n_group + 1)}
for d in valid:
f = factor.loc[d].dropna()
r = fwd_ret.loc[d].reindex(f.index).dropna()
common = f.index.intersection(r.index)
if len(common) < n_group * 10:
continue
f, r = f[common], r[common]
# ascending=True: 第1组因子值最小; 低波因子取 False 使第5组=低波动
q = pd.qcut(f.rank(ascending=ascending), n_group, labels=False) + 1
for g in range(1, n_group + 1):
group_ret[g].append(r[q == g].mean()) # 组内等权
return pd.DataFrame(group_ret)
layers = layered_backtest(factor, fwd, ascending=False) # 第5组=波动最低
nav = (1 + layers).cumprod()
summary = pd.DataFrame({
"累计净值(示意)": nav.iloc[-1].round(3),
"月均收益(示意)": layers.mean().round(4),
})
print(summary)
# ---------- 4. 多头组合与多空组合 ----------
top = layers[5] # 只买最低波动组
ls = layers[5] - layers[1] # 多低波、空高波(教学示意,A股做空难)
print(f"Top组 年化(示意): {(1 + top.mean()) ** 12 - 1:.1%}, "
f"夏普: {top.mean() / top.std() * np.sqrt(12):.2f}")
print(f"多空组合 年化(示意): {(1 + ls.mean()) ** 12 - 1:.1%}, "
f"夏普: {ls.mean() / ls.std() * np.sqrt(12):.2f}")
代码逐段解释
- 模拟面板:
ret_panel是 200 股 × 60 月的收益面板,每只股票有固定真实波动率,收益中埋了-0.3 × 波动的低波效应。因子值用rolling(6).std().shift(1)——只用过去 6 个月估计波动,shift(1) 保证 t 月看到的因子只含 t 月之前的信息,这是横截面世界的「无未来函数」。 - Rank IC:
fwd = ret_panel.shift(-1)把「下月收益」对齐到当前月,逐月算 Spearman 秩相关。len(f) < 30的截面直接跳过(前 6 个月因子值为 NaN 会自动被 dropna 排除)。输出 IC 均值、标准差、ICIR、IC>0 占比四个数字,IC>0 占比超过 55%~60% 说明方向稳定。 - 分层回测:每月对因子值
qcut均分 5 组。注意ascending=False的处理——低波因子里第 5 组是波动最低的股票。组内等权、逐月滚动,得到 5 条月收益序列。n_group * 10的下限保证每组至少 10 只股票,统计上才有意义。 - 多头与多空:真实 A 股做空困难,多空组合仅作因子强度的教学度量;实操关心的是 Top 组(只买第 5 组)的多头表现。
示例输出(示意,模拟数据,已埋入低波效应):
IC 均值(示意): -0.3521, IC 标准差: 0.0716, ICIR: -4.918, IC>0 占比: 2%
累计净值(示意) 月均收益(示意)
1 0.842 -0.0030
2 1.013 0.0002
3 1.146 0.0023
4 1.318 0.0046
5 1.524 0.0071
Top组 年化(示意): 8.8%, 夏普: 1.03
多空组合 年化(示意): 19.4%, 夏普: 1.87
读这份示意输出:IC 均值是负的 −0.35——因为因子值是波动率,波动越高未来收益越低,负相关完全正确(埋的效应被挖出来了)。分层净值从 Q1 的 0.842 单调爬到 Q5 的 1.524,单调性完美。两者互相印证:这个因子既有方向(IC)、又有可操作的排序(分层)。
常见坑与小练习
常见坑
- IC 用了未来数据:t 月的因子值配 t 月的收益(而非 t+1 月),IC 会虚高到离谱。务必
shift(-1)对齐下期收益,因子本身也不能含未来窗口。 - 分层前不去极值:一只因子值 10000 的离群股会把 qcut 的分组边界拉歪。标准流程是先缩尾(如按 1%/99% 分位数截断),教学里秩相关已部分缓解,但 qcut 前做 rank 更稳。
- 把多空当能赚的钱:多空年化 19% 看着诱人,但 A 股融券难且贵。评价因子对「你」的价值,看 Top 组多头表现和相对基准的超额。
- 只看均值不看稳定:IC 均值 0.05 但一半月份是 −0.1、一半是 +0.2 的因子没法用,ICIR 和 IC>0 占比必须一起看。
- 模拟数据成绩外推:本例效应是我们自己埋的,真实市场因子衰减小得多。真实研究中 0.03~0.05 的稳定 IC 已属优秀。
小练习
- 把埋入效应的系数从 −0.3 改成 0,重跑 IC 与分层,看看「无效应世界」里工具会不会误报(这练习的是对噪声的直觉)。
思路提示:改
ret_panel生成那一行的- 0.3 * tv为+ 0.0 * tv,观察 IC 均值是否落在 ±0.02 的噪声带内。 - 新增一个动量因子(过去 6 个月累计收益),与低波因子分别算 IC,比较预测力。
思路提示:
factor2 = (1 + ret_panel).rolling(6).prod().shift(1) - 1,注意动量在模拟数据里没有埋效应,预期 IC 接近 0。 - 把分组数从 5 改成 10,观察 Top 组与 Bottom 组的差距是否拉大,并思考分组过细带来的样本量问题。
思路提示:每组股票数 = 200/10 = 20 只,思考月度截面 20 只等权的运气成分有多大。
本文策略仅用于教学,不构成投资建议,实盘前请充分测试。