量化因子怎么判断有没有用?我用 Bucket Spread 和 Rank IC 跑了一次多周期验证
做选股或打分模型时,最容易做的是继续加因子。
动量、RSI、相对强弱、距离 52 周高点、波动率、成交量……只要回测里看起来有点关系,很快就能再加一列、再配一个权重。真正难的是后面那一步:这个因子是真的在排序未来收益,还是刚好在某个窗口里表现不错?
我最近在整理自己的量化研究链路时,发现以前最容易被“胜率”“单周期收益”这类数字带偏。一个因子 5 日后看起来很好,不代表 1 日、3 日也一致;同一批信号每天都统计,样本数会快速变大,但 forward return 又大量重叠,看起来稳定的结果未必真的增加了那么多独立信息。
所以我没有直接拿内部系统里以前记过的 IC 或 spread 来写文章,而是重新做了一次小型公开复现。数据用的是一个公开仓库里的 5 个 ETF 调整收盘价:sector_prices.csv。我固定了数据文件的 blob SHA 0a442a9260c2ad64e6cd8c01af49328eb7619367,用 SPY、XLK、XLF、XLE、XLV 测了几种很简单的 20 日价格因子,再分别看 1D / 3D / 5D 的 forward return。
结果没有给出一个漂亮的“最佳因子”。这反而更有用。
20 日动量在稀疏锚点样本里,1D 和 5D 的 High-Low median spread 是正的,3D 却略微转负;低波动因子三个周期的方向也不稳定。更有意思的是,我写了一个“20 日相对 SPY 强弱”以后,最后发现它和原始 20 日动量的横截面排名完全一样。
这几处“不整齐”,比一个全绿的因子报告更接近真实研究。
先把信号发生时的因子值冻住
做因子验证,我现在最看重的一条不是公式,而是时间边界。
假设某天系统给一组股票打分,里面有一个 momentum_20d。真正应该保存的是那一天决策发生时看到的值,然后等 1D、3D、5D outcome 结算后再回来看它有没有排序能力。
也就是:
decision / snapshot -> factor value at t -> realized return at t + N
而不是等三天以后,再用今天的数据和今天的代码重新算一次“当时的因子”。
这两个看起来很像,实际差别很大。数据供应商可能修订历史数据,复权方式可能变化,股票池会变,因子定义会改,标准化横截面也可能不是当时那批资产。只要事后重算,验证对象就有机会悄悄变成“今天理解的过去”,而不是系统当时真正看到的过去。
Alphalens 的因子分析本质上也是把 factor value 和之后的 forward returns 对齐,再按 quantile 看收益、算 Information Coefficient。它的文档还专门提醒过一个很典型的 look-ahead 风险:如果你用未来收益本身算 z-score 再过滤异常值,那么清洗这一步已经知道了未来。
我的生产系统如果以后真要自动调整权重,我会把这条边界做得比普通研究 notebook 更死:factor snapshot 一旦进入 outcome tracking,就不允许后面的模型版本回头覆盖它。
公开复现没有生产数据库,所以我用历史价格在同一份固定数据上构造 signal-time factor 和 forward return。它能验证计算流程,但不能拿来证明某个生产模型没有 point-in-time 数据问题。这个边界必须分开。
Bucket Spread 和 Rank IC 看的是两件不同的事
如果因子只是用来做排序,我不太喜欢一上来就问“胜率多少”。
胜率会把很多信息压成一个布尔值。涨 0.01% 和涨 8% 都算赢,排第一和排第五也可能都算赢。它适合描述策略结果,却不够直接地回答“因子值越高,未来结果是不是整体更好”。
我这次用的是两个很基础的量。
第一种是 Bucket Spread。每个 signal date,把横截面的因子值按排名切成 Low / Mid / High 三组,然后比较 High 和 Low 组之后的收益差:
Bucket Spread = median(return_high) - median(return_low)
如果一个“越高越好”的因子真的有排序信息,理想情况下 High 组应该比 Low 组更强。但 spread 为正只说明这批样本里头尾两组拉开了,它不自动等于统计显著,也不等于能覆盖交易成本,更不等于中间组是单调的。
第二种是 Rank IC。这里我用 Spearman rank correlation,把因子排名和未来收益排名直接做相关:
Rank IC = Spearman(factor_rank, forward_return_rank)
Scipy 的 spearmanr 返回的是非参数的单调相关度,范围从 -1 到 1。对很多因子来说,我更关心“排序方向有没有信息”,而不是假设因子值和未来收益之间一定是线性关系,所以 Spearman 很自然。
这次实际执行的核心计算很小,主要逻辑就是:
mom20 = px / px.shift(20) - 1
fwd = px.shift(-horizon) / px - 1
x = mom20.loc[dt].dropna()
y = fwd.loc[dt].dropna()
common = x.index.intersection(y.index)
bucket = pd.qcut(
x.loc[common].rank(method="first"),
3,
labels=["low", "mid", "high"],
)
rho, _ = spearmanr(
x.loc[common].values,
y.loc[common].values,
)
spread = (
y.loc[common][bucket == "high"].median()
- y.loc[common][bucket == "low"].median()
)
实际实验脚本已经固定在 Content Lab 里,执行命令退出码为 0。这里没有做一个新的“量化框架”,只是把最核心的验证步骤压到足够小,避免结果被复杂策略逻辑包住。
查看这次公开复现的实验指纹
- 数据:
VishnuKot14/QM-Final-Project/data/sector_prices.csv - 固定 blob SHA:
0a442a9260c2ad64e6cd8c01af49328eb7619367 - 执行:
python /tmp/run_factor_validation.py - Exit code:
0 - Universe:SPY / XLK / XLF / XLE / XLV
- 主要展示样本:14 个 every-fifth-trading-day anchor dates
这个证据只能证明本文公开复现链路按固定输入成功执行,不代表我的生产因子或真钱组合已经通过同等级验证。
同一个因子,换个 horizon 结论就开始变
为了少一点 forward return 重叠,我把主要展示结果设成“每 5 个交易日取一个 anchor date”。一共 14 个日期,每个日期 5 个 ETF,所以每个 factor × horizon 有 70 条横截面观察。
下面的 spread 用 High bucket median - Low bucket median,单位是百分点;Rank IC 是各 anchor date 横截面 Spearman IC 的平均值。
| 因子 | Horizon | N | Median Spread | Mean Rank IC |
|---|---|---|---|---|
| 20D Momentum | 1D | 70 | +0.583 pp | +0.179 |
| 20D Momentum | 3D | 70 | -0.071 pp | +0.014 |
| 20D Momentum | 5D | 70 | +0.667 pp | +0.214 |
| Distance to 20D High | 1D | 70 | +0.249 pp | +0.089 |
| Distance to 20D High | 3D | 70 | +0.259 pp | +0.151 |
| Distance to 20D High | 5D | 70 | +0.529 pp | +0.252 |
| Low Volatility 20D | 1D | 70 | -0.029 pp | +0.029 |
| Low Volatility 20D | 3D | 70 | +0.296 pp | +0.014 |
| Low Volatility 20D | 5D | 70 | -0.224 pp | -0.207 |
| Relative Strength 20D vs SPY | 1D | 70 | +0.583 pp | +0.179 |
| Relative Strength 20D vs SPY | 3D | 70 | -0.071 pp | +0.014 |
| Relative Strength 20D vs SPY | 5D | 70 | +0.667 pp | +0.214 |
20D Momentum · Median High-Low Spread
中心线是 0。这里故意只画同一个因子的三个 horizon,重点不是“谁最高”,而是让 3D 的方向翻转一眼可见。
这张表里我最不想做的事情,就是圈出一个最大值然后宣布“有效”。
先看 20D Momentum。只看 5D,spread +0.667 pp、mean Rank IC +0.214,确实很容易让人产生加权冲动。可 3D spread 变成了 -0.071 pp,Rank IC 也几乎贴近 0。它没有形成一个很干净的“周期越长越有效”结构。
Low Volatility 更明显。3D median spread 是正的,5D 却转成 -0.224 pp,mean Rank IC 也到了 -0.207。如果我只截 3D 那一列,它会看起来像一个可以继续研究的正向因子;换到 5D,故事就完全不一样。
这就是多周期 outcome tracking 最实际的价值。它不是为了把 1D、3D、5D 加权成一个更漂亮的综合分,而是先暴露:你的结论到底对 horizon 有多敏感。
我还保留了一份每日都取样的结果。那份样本有 66 个 signal dates、330 行 factor/outcome 记录,数量一下大很多。
问题是 3D、5D forward returns 会高度重叠。
结果也真的变了:20D Momentum 的 3D median spread 在每日重叠样本里约为 +0.578 pp,到了每 5 个交易日的锚点样本却变成 -0.071 pp。
我不会拿这个差异去判断哪一边“才是真相”。14 个锚点仍然很小,每 5 日取样也没有彻底消掉所有依赖。它说明的是另一个更重要的问题:如果一个因子的结论连合理的抽样方式一换都会翻,研究报告就不该只给一个漂亮的总样本数字。
因子数量变多之前,先检查是不是同一份排序信息
这次还有一个结果一开始让我以为代码写错了。
我同时做了:
momentum_20d
relative_strength_20d_vs_spy
后者定义为:
asset_20d_return - SPY_20d_return
跑完以后,两者所有 bucket、spread、Rank IC 结果完全一样。
检查代码后发现这不是 bug。
在同一个日期,SPY 的 20 日收益对所有资产都是同一个常数:
RS_i = Momentum_i - C_t
给横截面里的每个资产都减去同一个 C_t,不会改变排序。所以只要 Bucket 和 Rank IC 只依赖横截面 rank,这两个写法在这里就是同一份排序信息。
这个例子对我比“某个因子 IC 是 0.21”更有价值。
多因子模型最容易出现一种假复杂:指标名不同、公式看着不同,最后其实在重复表达同一个方向。两个高度冗余的因子如果都拿到独立权重,不是模型获得了两份证据,而是同一份证据被重复投票。
这里不能推广成“relative strength 等于 momentum”。如果相对强弱是相对行业、动态 benchmark、残差收益、rolling beta 调整,或者比较的是别的窗口,排序都可能变化。这次等价只来自一个很具体的构造:同一天给所有资产减去相同的 SPY 20D return。
但它给了我一个很实用的检查习惯:在调权之前,先比较 factor rank、相关性和 bucket assignment,看看新因子到底增加了新信息,还是只换了名字。
我不会让一次正 IC 直接改线上权重
如果研究系统最后要做自动调优,我现在更愿意把因子从“被发现”到“影响模型”之间拉开一点距离。
一个候选因子可以先进入 observe:记录信号时点值,不参与决策;等 outcome 结算以后,再看不同 horizon 的 bucket spread、Rank IC、覆盖率和样本分布。
结果有点意思,再进 shadow。Shadow 可以生成“如果启用它,权重会怎样变化”的候选结果,但仍然不改 active model。
我至少会看这几件事:
- 样本量和非空覆盖是不是够;
- High / Mid / Low 有没有基本的排序结构,而不只是头尾偶然拉开;
- 1D / 3D / 5D 的方向是否能解释,还是互相打架;
- 换一段时间、换一个 regime,方向是否还存在;
- 是否和已有因子高度重复;
- 抽样方式、去重规则一变,结论会不会翻;
- 研究过程中试过多少个因子、窗口和参数。
最后这一条尤其容易被忽略。因子研究本质上经常是在做大量假设检验。你试 50 个指标、6 个窗口、4 个 horizon,再挑一个最好看的,出现“漂亮结果”的概率当然会越来越高。Harvey、Liu 和 Zhu 在资产定价的多重检验研究里专门讨论过这个问题:… and the Cross-Section of Expected Returns。
所以 Bucket Spread 和 Rank IC 对我来说不是“因子准入许可证”,更像两种基础体检。它们能帮我快速看出排序有没有信息、方向是否稳定,也很容易暴露 horizon 翻转和因子冗余;但离“这个因子可以真钱交易”中间还隔着稳定性、交易成本、样本外验证、regime、组合暴露和风险控制。
这次公开实验本身也只用了 5 个 ETF 和 14 个稀疏锚点日期。我不会拿它证明任何生产 alpha。它真正证明的是这条验证链可以很小地跑起来,而且只要把 horizon、抽样和 rank redundancy 摆到桌面上,很多原本看起来很干净的因子结论会立刻变得没那么确定。
这正是我想保留的状态。
一个因子如果值得进入模型,最好不是因为某次研究报告给了它一个漂亮数字,而是它经过一段时间的 outcome tracking 以后,仍然有足够多不太好看的检查没有把它推翻。