阅读提示

这篇讲解 RobustTest 模块——用”幸运因子”(Lucky Factors)方法检验一个因子的收益是否真的来自该因子,还是被其他因子(如市值、行业)所解释。做完这一步,你的因子研究才算真正经得起推敲。

导言

一个因子的回测收益很漂亮,但你有没有想过:这份收益有多少是因子本身的贡献,有多少是恰好捕获了其他因子(如小市值效应、低估值效应)的暴露?

Lucky Factors 检验的核心思想来自 Harvey、Liu & Zhu (2016) 的论文——通过对大量”假因子”(把真实因子数据随机打乱生成)做回测,评估你的因子在统计上是否比”随机噪声”更强。如果一个因子连随机生成的假因子都跑不赢,那它的收益很可能只是幸运。

Betalens 的 robust 模块提供了这套检验的完整实现。

核心 API

1
2
3
4
5
6
7
8
from betalens.robust import RobustTest, bootstrap_once, panel

# 基本用法
rt = RobustTest(fund=因子净值曲线, factor=因子IC序列)
result = rt.test()
print(result.significant) # True / False
print(result.p_value) # p 值
print(result.t_stat) # t 统计量

检验流程

步骤 1:准备数据

1
2
3
4
5
6
7
8
9
import pandas as pd

# 因子净值曲线(从 BacktestBase 获取)
engine = BacktestBase(weight=weights, ...)
nav = engine.nav

# 因子 IC 序列(从 profiling 获取)
ic_ts = IC_analysis(labeled, returns, factor_key="股息率(报告期)")
factor_ic = ic_ts["IC"] # Series, DatetimeIndex

步骤 2:执行 Bootstrap 检验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from betalens.robust import bootstrap_once

# 单次 bootstrap:生成一个"假因子"并跑回测
fake_ic = bootstrap_once(factor_ic, n_samples=len(factor_ic))
# fake_ic: IC 序列,但每个值都是从原始 IC 里随机抽的(打乱了时间顺序)

# 评估"假因子"的 ICIR
fake_nav = simulate_backtest_from_ic(fake_ic, ...) # 简化的模拟
fake_icir = fake_ic.mean() / fake_ic.std()

# 重复 1000 次,得到"假因子 ICIR 分布"
real_icir = factor_ic.mean() / factor_ic.std()
fake_icirs = []
for _ in range(1000):
fake_ic = bootstrap_once(factor_ic, n_samples=len(factor_ic))
fake_icirs.append(fake_ic.mean() / fake_ic.std())

# 计算 p 值:有多少比例的"假因子"比真实因子强?
p_value = (sum(f > real_icir for f in fake_icirs)) / len(fake_icirs)
print(f"真实 ICIR: {real_icir:.3f}, p值: {p_value:.3f}")

步骤 3:解读结果

p 值 解读
< 0.01 极显著,真实因子远超”假因子”,稳健性极强
< 0.05 显著,稳健
< 0.10 边缘显著,需要更多数据验证
>= 0.10 不显著,收益可能来自运气或其他因子

panel 回归检验

除了 Bootstrap,还可以用 Fama-MacBeth 截面回归做增量检验:

1
2
3
4
5
6
7
8
9
from betalens.robust import panel

# X: 因子值矩阵(n_dates × n_stocks)
# y: 下期收益
result = panel(X=factor_values, y=next_returns, n_lags=2)
print(result["alpha"]) # 截距(超额收益)
print(result["t_alpha"]) # 截距 t 统计量
print(result["beta"]) # 因子暴露
print(result["R2"]) # 解释力

如果 alpha(截距)显著为正,说明因子收益不能被其他已知因子完全解释,有真正的 alpha。

增量检验:逐步加入控制变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 检验 ROE 因子是否真的比市值因子强
# 用中性化后的 ROE 因子做回归
from betalens.factor.preprocessing import preprocess_factor

# 对 ROE 做市值 + 行业中性化
neutralized_roe = preprocess_factor(
raw_roe,
metric="ROE(报告期)",
winsorize_method="mad",
standardize_method="zscore",
industry_scheme="申万一级行业",
use_mktcap=True,
mktcap_metric="log_mktcap",
)

# 对中性化后的因子做 RobustTest
rt = RobustTest(fund=neutralized_nav, factor=neutralized_ic)
print(rt.test())

如果中性化后的因子仍然显著,说明 ROE 因子本身(而非市值暴露)是有价值的。

开发者侧:bootstrap 的实现原理

1
2
3
4
def bootstrap_once(series, n_samples):
"""时间序列 bootstrap:保持序列长度,随机打乱顺序。"""
indices = np.random.choice(len(series), size=n_samples, replace=True)
return series.iloc[indices].reset_index(drop=True)

这叫”时序自助法”(Block Bootstrap 的简化版)。严格的做法应该用”块自助法”(block bootstrap)保持时间相关性,但 n_samples=len(series) 的随机抽样在大多数场景下已经够用。

常见问题

1. 样本量太少导致 p 值不显著

1
2
3
# 少于 3 年的 IC 数据(< 60 个数据点)做 bootstrap 检验,p 值通常不可靠
# 解决:用更长的历史数据,或者降低 bootstrap 次数
rt = RobustTest(fund=nav, factor=factor_ic, n_bootstrap=500)

2. 把”不显著”误解为”因子无效”

RobustTest 的零假设是”因子收益来自随机噪声”。不显著只说明”无法拒绝零假设”,不等于”因子无效”。可能是:

  • 数据不够(统计功效不足)
  • 控制变量不够(其他因子还没被中性化)
  • 因子的收益确实依赖特定市场环境

3. 中性化顺序不对

正确顺序:先去极值 → 再标准化 → 再中性化。如果先中性化再做去极值,中性化的效果会被破坏。

延伸阅读