03-因子研究方法论
预计学习时间:3-4 小时
难度:⭐⭐⭐⭐
核心问题:怎么用统计方法验证一个因子是否真的有效?如何量化因子的预测力?
从一个直觉出发
你构造了一个新因子——“过去 20 天的平均换手率变化”。你觉得它应该能预测收益。
你回测了一下,发现”换手率下降最多的股票,未来收益最高”。看起来很棒!
但问题是:这个结果是真实的,还是随机巧合?
如果你试了 100 个类似的因子,总会有几个碰巧”显著”。你只是碰巧选中了那个看起来最好的。
因子研究方法论就是回答这个问题的工具箱——它告诉你如何严格地、可信地验证一个因子是否真的有效。
一、Fama-MacBeth 横截面回归
1.1 为什么不能直接做一次横截面回归?
假设你有 300 只股票,你想验证”价值因子是否能预测收益”。
最直觉的做法:某个月的截面回归——收益 = alpha + beta * B/P + 误差。
但这样做有一个大问题:某个月的回归结果可能是噪音。也许那个月价值股碰巧涨了,但下个月就跌回来。
Fama-MacBeth(1973)提出的方法解决的是:如何在”考虑时间序列维度”的情况下,评估因子的横截面预测力?
1.2 两步法详解
第一步:每个月做一次横截面回归,得到因子收益的估计值。
其中 是股票 在时间 对因子 的暴露值。每个月得到一组 。
第二步:对因子收益的时间序列取平均,做 t 检验。
如果 ,说明因子 的收益在统计上显著不为零。
1.3 为什么 Fama-MacBeth 比普通回归好?
| 方法 | 问题 |
|---|---|
| 混合截面回归(Pooled OLS) | 忽略了截面残差的相关性,t 值虚高 |
| 单次截面回归 | 结果不稳定,受某个月的噪音影响 |
| Fama-MacBeth | 正确处理了截面相关性,t 值更可信 |
1.4 Python 完整代码:模拟 100 只股票 x 120 个月
import numpy as np
import pandas as pd
import statsmodels.api as sm
np.random.seed(42)
# ============================================================
# 参数设置
# ============================================================
n_stocks = 100
n_months = 120 # 10 年
# ============================================================
# 模拟数据
# ============================================================
# 3 个因子:价值、规模、动量
# 其中价值因子有效(有真实溢价),规模和动量无效
# 每只股票的因子暴露(假设随时间缓慢变化)
factor_names = ['价值', '规模', '动量']
true_factor_premiums = [0.005, 0.000, 0.000] # 只有价值有溢价
# 初始化因子暴露
value_exp = np.random.normal(0, 1, n_stocks)
size_exp = np.random.normal(0, 1, n_stocks)
momentum_exp = np.random.normal(0, 1, n_stocks)
all_monthly_betas = []
all_stock_returns = []
for t in range(n_months):
# 因子暴露缓慢变化
value_exp = value_exp * 0.95 + np.random.normal(0, 0.2, n_stocks)
size_exp = size_exp * 0.95 + np.random.normal(0, 0.2, n_stocks)
momentum_exp = momentum_exp * 0.95 + np.random.normal(0, 0.2, n_stocks)
# 截面回归:r = alpha + beta1 * value + beta2 * size + beta3 * mom + epsilon
# 真实关系:只有价值有溢价
expected_return = true_factor_premiums[0] * value_exp
# 加上特质噪音
stock_returns = expected_return + np.random.normal(0, 0.08, n_stocks)
# Fama-MacBeth 第一步:截面回归
X = np.column_stack([value_exp, size_exp, momentum_exp])
X = sm.add_constant(X)
model = sm.OLS(stock_returns, X).fit()
all_monthly_betas.append(model.params[1:]) # 跳过截距
all_stock_returns.append(stock_returns)
betas = np.array(all_monthly_betas) # shape: (n_months, n_factors)
# ============================================================
# Fama-MacBeth 第二步:时间序列平均和 t 检验
# ============================================================
print("=" * 65)
print("Fama-MacBeth 横截面回归结果")
print("=" * 65)
print(f"{'因子':>8} {'真实溢价':>10} {'估计溢价':>10} {'t 统计量':>10} {'显著?':>8}")
print("-" * 50)
for k, name in enumerate(factor_names):
mean_beta = np.mean(betas[:, k])
std_beta = np.std(betas[:, k], ddof=1)
se_beta = std_beta / np.sqrt(n_months)
t_stat = mean_beta / se_beta
is_sig = abs(t_stat) > 2
sig_mark = "是" if is_sig else "否"
true_prem = true_factor_premiums[k]
print(f"{name:>8} {true_prem:10.4f} {mean_beta:10.4f} {t_stat:10.2f} {sig_mark:>8}")
print("\n说明:只有价值因子有效(t > 2),规模和动量不显著(t < 2)")
print("Fama-MacBeth 正确识别了真实有效的因子")二、分组分析(Quintile / Decile)
2.1 基本思想
把所有股票按因子值从低到高分成 5 组(Quintile)或 10 组(Decile),然后比较各组的平均收益。
如果因子有效,你应该看到:高因子值组的收益显著高于低因子值组的收益。
2.2 方法步骤
- 每个月,按因子值排序,分成 N 组
- 计算每组的等权平均收益
- 多空收益 = 最高组收益 - 最低组收益
- 对多空收益做 t 检验
2.3 Python 代码
import numpy as np
import pandas as pd
np.random.seed(42)
# ============================================================
# 模拟数据:200 只股票,60 个月
# ============================================================
n_stocks = 200
n_months = 60
# 因子值(每月更新)
factor_values = np.zeros((n_months, n_stocks))
for t in range(n_months):
# 因子值有一定持续性
if t == 0:
factor_values[t] = np.random.normal(0, 1, n_stocks)
else:
factor_values[t] = (0.9 * factor_values[t-1]
+ np.random.normal(0, 0.3, n_stocks))
# 股票收益 = 0.005 * 因子值 + 噪音
# (因子值越高,收益越高)
stock_returns = np.zeros((n_months, n_stocks))
for t in range(n_months):
stock_returns[t] = (0.005 * factor_values[t]
+ np.random.normal(0, 0.06, n_stocks))
# ============================================================
# 分组分析
# ============================================================
n_groups = 5
group_returns = np.zeros((n_months, n_groups))
for t in range(n_months):
# 按因子值分组
quantiles = pd.qcut(factor_values[t], n_groups, labels=False, duplicates='drop')
for g in range(n_groups):
mask = quantiles == g
group_returns[t, g] = np.mean(stock_returns[t, mask])
# 多空收益(最高组 - 最低组)
long_short = group_returns[:, -1] - group_returns[:, 0]
# 统计检验
mean_ls = np.mean(long_short)
std_ls = np.std(long_short, ddof=1)
t_stat = mean_ls / (std_ls / np.sqrt(n_months))
sharpe = mean_ls / std_ls * np.sqrt(12)
# ============================================================
# 输出结果
# ============================================================
print("=" * 55)
print("分组分析结果(5 组)")
print("=" * 55)
print(f"{'组别':>8} {'年化收益':>10} {'年化波动':>10} {'Sharpe':>8}")
print("-" * 40)
group_labels = ['Q1(最低)', 'Q2', 'Q3', 'Q4', 'Q5(最高)']
for g in range(n_groups):
ann_ret = np.mean(group_returns[:, g]) * 12
ann_vol = np.std(group_returns[:, g], ddof=1) * np.sqrt(12)
shr = ann_ret / ann_vol if ann_vol > 0 else 0
print(f"{group_labels[g]:>8} {ann_ret:10.2%} {ann_vol:10.2%} {shr:8.2f}")
print("-" * 40)
ann_ls = mean_ls * 12
ann_vol_ls = std_ls * np.sqrt(12)
print(f"{'多空(Q5-Q1)':>8} {ann_ls:10.2%} {ann_vol_ls:10.2%} {sharpe:8.2f}")
print(f"\n多空收益 t 统计量: {t_stat:.2f}")
print(f"{'显著' if abs(t_stat) > 2 else '不显著'}")2.4 分组分析的注意事项
- 分组数量:5 组是最常用的,10 组能提供更多细节但每组股票数少
- 加权方式:等权 vs 市值加权,两者结论可能不同
- 因子值极端值:极端因子值可能由异常值驱动,需 Winsorize
- 单调性:有效的因子通常表现出”单调递增/递减”的分组收益
三、IC / ICIR 评价
3.1 什么是 IC?
IC(Information Coefficient) 是因子值和未来收益之间的相关系数。
其中 是股票 在时间 的因子值, 是未来收益。
白话版本:IC 衡量的是”因子值的高低和未来收益的高低是否一致”。IC = 0.05 意味着因子值和收益之间有微弱但正向的相关性。
3.2 Rank IC:量化更常用的指标
直接用 Pearson 相关系数计算 IC 的问题:极端值影响太大。
Rank IC 先把因子值和收益分别排序(变成排名),再计算 Spearman 秩相关系数。
为什么量化更喜欢 Rank IC?
- 对异常值不敏感
- 不要求线性关系(只要单调关系即可)
- 更稳定,截面之间更可比
3.3 ICIR:IC 的稳定性
单个月的 IC 不稳定。你需要看 IC 的时间序列统计。
ICIR = IC 的均值 / IC 的标准差。它衡量的是 IC 的”信噪比”——信号(均值)相对噪音(波动)有多大。
| ICIR | 含义 |
|---|---|
| > 0.5 | 非常好的因子 |
| 0.3 - 0.5 | 好的因子 |
| 0.1 - 0.3 | 可接受的因子 |
| < 0.1 | 弱因子 |
3.4 t 统计量检验 IC 是否显著
其中 是月数。如果 ,IC 在统计上显著不为零。
3.5 Python 完整代码
import numpy as np
import pandas as pd
from scipy import stats
np.random.seed(42)
# ============================================================
# 模拟数据:300 只股票,120 个月
# ============================================================
n_stocks = 300
n_months = 120
# 因子值
factor_values = np.zeros((n_months, n_stocks))
for t in range(n_months):
if t == 0:
factor_values[t] = np.random.normal(0, 1, n_stocks)
else:
factor_values[t] = (0.9 * factor_values[t-1]
+ np.random.normal(0, 0.3, n_stocks))
# 未来收益(因子值越高,收益越高,但效果微弱)
future_returns = np.zeros((n_months, n_stocks))
for t in range(n_months):
future_returns[t] = (0.01 * factor_values[t]
+ np.random.normal(0, 0.08, n_stocks))
# ============================================================
# 计算 IC 和 Rank IC
# ============================================================
ic_series = np.zeros(n_months)
rank_ic_series = np.zeros(n_months)
for t in range(n_months):
# Pearson IC
ic, _ = stats.pearsonr(factor_values[t], future_returns[t])
ic_series[t] = ic
# Spearman Rank IC
rank_ic, _ = stats.spearmanr(factor_values[t], future_returns[t])
rank_ic_series[t] = rank_ic
# ============================================================
# 统计汇总
# ============================================================
mean_ic = np.mean(ic_series)
std_ic = np.std(ic_series, ddof=1)
icir = mean_ic / std_ic
t_stat_ic = mean_ic / (std_ic / np.sqrt(n_months))
mean_rank_ic = np.mean(rank_ic_series)
std_rank_ic = np.std(rank_ic_series, ddof=1)
rank_icir = mean_rank_ic / std_rank_ic
t_stat_rank_ic = mean_rank_ic / (std_rank_ic / np.sqrt(n_months))
# IC > 0 的比例
ic_positive_ratio = np.sum(ic_series > 0) / n_months
print("=" * 60)
print("IC / ICIR 分析结果")
print("=" * 60)
print(f"\n--- Pearson IC ---")
print(f" IC 均值: {mean_ic:.4f}")
print(f" IC 标准差: {std_ic:.4f}")
print(f" ICIR: {icir:.4f}")
print(f" t 统计量: {t_stat_ic:.2f} ({'显著' if abs(t_stat_ic) > 2 else '不显著'})")
print(f" IC > 0 比例: {ic_positive_ratio:.1%}")
print(f"\n--- Spearman Rank IC ---")
print(f" Rank IC 均值: {mean_rank_ic:.4f}")
print(f" Rank IC 标准差: {std_rank_ic:.4f}")
print(f" Rank ICIR: {rank_icir:.4f}")
print(f" t 统计量: {t_stat_rank_ic:.2f} "
f"({'显著' if abs(t_stat_rank_ic) > 2 else '不显著'})")
# ============================================================
# IC 衰减分析
# ============================================================
print(f"\n--- IC 衰减分析 ---")
# 看 IC 随时间是否衰减
first_half = ic_series[:n_months//2]
second_half = ic_series[n_months//2:]
print(f" 前 5 年 IC 均值: {np.mean(first_half):.4f}")
print(f" 后 5 年 IC 均值: {np.mean(second_half):.4f}")
print(f" 衰减比例: {1 - np.mean(second_half) / max(np.mean(first_half), 0.0001):.1%}")
# IC 稳定性(滚动 12 个月 ICIR)
rolling_icir = pd.Series(ic_series).rolling(12).apply(
lambda x: np.mean(x) / np.std(x) if np.std(x) > 0 else 0, raw=True
)
print(f"\n 滚动 12 月 ICIR 范围: [{rolling_icir.min():.2f}, {rolling_icir.max():.2f}]")
print(f" 滚动 12 月 ICIR 均值: {rolling_icir.mean():.2f}")3.6 IC 分析的实战建议
- 看 Rank IC 比 Pearson IC 更重要:对异常值更稳健
- ICIR > 0.5 是好因子,> 1.0 是极好因子
- IC > 0 的比例 > 55% 就不错了(纯随机应该是 50%)
- IC 衰减是因子研究最重要的监控指标之一:如果后 5 年的 IC 显著低于前 5 年,因子可能在失效
四、因子 t 统计量
4.1 从分组分析到统计检验
分组分析告诉你”最高组和最低组的收益差是多少”,但没告诉你”这个差异是否显著”。
Newey-West 调整 t 统计量是最常用的方法,它同时考虑了异方差和自相关。
4.2 Python 代码
import numpy as np
import statsmodels.api as sm
np.random.seed(42)
# ============================================================
# 多空收益序列(来自分组分析)
# ============================================================
n_months = 120
# 模拟多空收益(有正溢价 + 自相关噪音)
true_premium = 0.005
noise = np.zeros(n_months)
for t in range(1, n_months):
noise[t] = 0.3 * noise[t-1] + np.random.normal(0, 0.02)
long_short = true_premium + noise
# ============================================================
# t 检验(普通 vs Newey-West)
# ============================================================
# 普通 t 检验
mean_ls = np.mean(long_short)
std_ls = np.std(long_short, ddof=1)
t_ols = mean_ls / (std_ls / np.sqrt(n_months))
# Newey-West t 检验(使用 statsmodels)
ls_series = sm.add_constant(np.arange(n_months))
model = sm.OLS(long_short, ls_series).fit(
cov_type='HAC', cov_kwds={'maxlags': int(n_months ** (1/4))}
)
t_nw = model.tvalues[0]
print("=" * 50)
print("因子 t 统计量")
print("=" * 50)
print(f" 多空收益均值: {mean_ls:.4f} ({mean_ls*12:.2%} 年化)")
print(f" 普通 t 统计量: {t_ols:.2f}")
print(f" Newey-West t: {t_nw:.2f}")
print(f"\n 注意: Newey-West t 通常 <= 普通 t")
print(f" 因为 NW 调整考虑了自相关,标准误更大")五、因子正交化
5.1 为什么需要正交化?
因子之间往往有相关性(比如价值和动量负相关)。如果你同时用两个相关因子,它们的效果会”互相干扰”。
正交化就是消除因子之间的相关性,使得每个因子只包含”独有的信息”。
5.2 方法一:施密特正交化(Gram-Schmidt)
逐步处理:第一个因子不变,第二个因子去掉和第一个因子重叠的部分,以此类推。
5.3 方法二:回归残差正交化
更常用的方法:用目标因子对所有”优先因子”做回归,取残差。
5.4 Python 代码
import numpy as np
import pandas as pd
import statsmodels.api as sm
np.random.seed(42)
# ============================================================
# 模拟 3 个有相关性的因子
# ============================================================
n_stocks = 300
# 基础因子
f1_base = np.random.normal(0, 1, n_stocks) # 价值
f2_base = np.random.normal(0, 1, n_stocks) # 规模
f3_base = np.random.normal(0, 1, n_stocks) # 质量
# 加入相关性
f1 = f1_base
f2 = -0.3 * f1 + f2_base # 规模和价值负相关
f3 = 0.25 * f1 + 0.1 * f2 + f3_base # 质量和其他两个因子正相关
factors = pd.DataFrame({'价值': f1, '规模': f2, '质量': f3})
print("正交化前因子相关性:")
print(factors.corr().round(3))
# ============================================================
# 方法一:施密特正交化
# ============================================================
def gram_schmidt(factors_df):
"""
施密特正交化
按列的顺序处理:第一列不变,后续列去掉与之前列的重叠部分
"""
n_factors = factors_df.shape[1]
orthogonal = np.zeros_like(factors_df.values, dtype=float)
for k in range(n_factors):
vec = factors_df.iloc[:, k].values.copy().astype(float)
for j in range(k):
# 减去在之前正交化向量上的投影
proj = (np.dot(vec, orthogonal[:, j])
/ np.dot(orthogonal[:, j], orthogonal[:, j])
* orthogonal[:, j])
vec -= proj
orthogonal[:, k] = vec
return pd.DataFrame(orthogonal, columns=factors_df.columns)
orthogonal_gs = gram_schmidt(factors)
# ============================================================
# 方法二:回归残差正交化
# ============================================================
def regression_residual_orthogonalize(factors_df, priority_order):
"""
回归残差正交化
按优先级顺序处理:优先级高的因子不变,
优先级低的因子对优先级高的做回归,取残差
"""
result = factors_df.copy()
for i, current in enumerate(priority_order):
if i == 0:
continue # 第一个因子保持不变
# 当前因子对所有优先级更高的因子做回归
prior_factors = priority_order[:i]
X = factors_df[prior_factors].values
X = sm.add_constant(X)
y = factors_df[current].values
model = sm.OLS(y, X).fit()
result[current] = model.resid # 取残差
return result
# 按优先级正交化:价值 > 规模 > 质量
orthogonal_reg = regression_residual_orthogonalize(
factors, ['价值', '规模', '质量']
)
print("\n施密特正交化后因子相关性:")
print(orthogonal_gs.corr().round(3))
print("\n回归残差正交化后因子相关性:")
print(orthogonal_reg.corr().round(3))
print("\n结论: 正交化后,因子间的相关性接近于零")5.5 正交化的注意事项
- 正交化顺序很重要:先正交化的因子”保留更多原始信息”
- 正交化后因子含义变了:残差因子不再是”原始因子”,而是”剔除其他因子影响后的纯因子”
- 不要过度正交化:如果两个因子天然相关(比如价值和盈利),强行正交化可能破坏经济含义
六、因子衰减曲线分析
6.1 核心问题
你的因子值能预测多远的未来收益?1 个月?3 个月?1 年?
因子衰减曲线回答的就是这个问题。它展示了”持仓期越长,因子的预测力如何变化”。
6.2 为什么重要?
- 最优换仓频率:如果因子只能预测 1 周的收益,你需要每周换仓;如果能预测 3 个月,可以降低换仓频率
- 交易成本考虑:换仓越频繁,成本越高。衰减曲线帮你找到”收益 vs 成本”的平衡点
- 因子理解:快速衰减的因子可能是短期反转效应,缓慢衰减的因子可能是结构性错误定价
6.3 Python 代码
import numpy as np
import pandas as pd
from scipy import stats
np.random.seed(42)
# ============================================================
# 模拟数据:300 只股票,250 天
# ============================================================
n_stocks = 300
n_days = 250
# 因子值(每天更新)
factor_values = np.zeros((n_days, n_stocks))
for t in range(n_days):
if t == 0:
factor_values[t] = np.random.normal(0, 1, n_stocks)
else:
factor_values[t] = (0.98 * factor_values[t-1]
+ np.random.normal(0, 0.1, n_stocks))
# 未来收益(因子值越高,近期收益越高,但效果逐渐衰减)
future_returns = np.zeros((n_days, n_stocks))
for t in range(n_days):
# 近期效果强,远期效果弱
effect = 0.01 * factor_values[t]
future_returns[t] = effect + np.random.normal(0, 0.04, n_stocks)
# ============================================================
# IC 衰减曲线
# ============================================================
holding_periods = [1, 5, 10, 20, 40, 60] # 持仓天数
decay_results = []
for holding in holding_periods:
rank_ics = []
for t in range(n_days - holding):
# 因子值在 t 时刻
f_t = factor_values[t]
# 未来 holding 天的累计收益
ret = np.sum(future_returns[t:t+holding], axis=0)
# 计算 Ranked IC
ic, _ = stats.spearmanr(f_t, ret)
rank_ics.append(ic)
mean_ic = np.mean(rank_ics)
std_ic = np.std(rank_ics, ddof=1)
icir = mean_ic / std_ic if std_ic > 0 else 0
decay_results.append({
'holding_days': holding,
'mean_rank_ic': mean_ic,
'icir': icir,
't_stat': mean_ic / (std_ic / np.sqrt(len(rank_ics)))
})
# ============================================================
# 输出结果
# ============================================================
print("=" * 60)
print("因子衰减曲线")
print("=" * 60)
print(f"{'持仓天数':>10} {'平均 Rank IC':>15} {'ICIR':>8} {'t 统计量':>10}")
print("-" * 50)
for r in decay_results:
print(f"{r['holding_days']:>10} {r['mean_rank_ic']:>15.4f} "
f"{r['icir']:>8.3f} {r['t_stat']:>10.2f}")
# 找到最优换仓频率
# 考虑交易成本后的净 IC
cost_per_trade = 0.001 # 每次换仓成本 0.1%
annual_turnover = 252 / np.array([r['holding_days'] for r in decay_results])
cost_impact = cost_per_trade * annual_turnover / 252 # 每日成本
print(f"\n考虑交易成本后的分析(换仓成本: {cost_per_trade:.2%}):")
print(f"{'持仓天数':>10} {'年化换仓次数':>12} {'年化成本':>10} {'IC/成本比':>10}")
print("-" * 50)
for i, r in enumerate(decay_results):
ann_turn = 252 / r['holding_days']
ann_cost = cost_per_trade * ann_turn
ic_cost_ratio = r['mean_rank_ic'] / (ann_cost / 252) if ann_cost > 0 else 0
print(f"{r['holding_days']:>10} {ann_turn:>12.1f} {ann_cost:>10.2%} {ic_cost_ratio:>10.2f}")
print("\n建议:选择 IC/成本比最高的持仓期作为换仓频率")七、样本外检验的严格方法
7.1 为什么要样本外检验?
样本内表现好的因子,在样本外可能完全失效。原因包括:
- 过拟合:因子参数是在样本内优化的,自然在样本内表现好
- 结构变化:市场结构变了,原来的关系不再成立
- 数据挖掘:你试了很多因子,选了样本内最好的那个
7.2 Walk-Forward 检验
最严格的样本外检验方法之一:
训练期 1 → 测试期 1 → 训练期 2 → 测试期 2 → ...
[60 个月] [12 个月] [60 个月] [12 个月]
每次用过去 60 个月的数据估计因子参数,然后用接下来 12 个月的数据检验。
7.3 Python 代码
import numpy as np
import pandas as pd
from scipy import stats
np.random.seed(42)
# ============================================================
# 模拟数据:300 只股票,180 个月
# ============================================================
n_stocks = 300
n_months = 180
# 因子值
factor_values = np.zeros((n_months, n_stocks))
for t in range(n_months):
if t == 0:
factor_values[t] = np.random.normal(0, 1, n_stocks)
else:
factor_values[t] = (0.9 * factor_values[t-1]
+ np.random.normal(0, 0.3, n_stocks))
# 股票收益
stock_returns = np.zeros((n_months, n_stocks))
for t in range(n_months):
# 前 90 个月:因子有效
# 后 90 个月:因子效果减弱 50%
if t < 90:
premium = 0.008
else:
premium = 0.004
stock_returns[t] = premium * factor_values[t] + np.random.normal(0, 0.06, n_stocks)
# ============================================================
# Walk-Forward 检验
# ============================================================
train_window = 60 # 5 年训练
test_window = 12 # 1 年测试
step = 12 # 每次前进 1 年
# 因子阈值:在训练期估计的"最优分组阈值"
# 简化版:用训练期的 IC 均值作为预期 IC
is_results = []
os_results = []
t = 0
while t + train_window + test_window <= n_months:
train_end = t + train_window
test_end = train_end + test_window
# 训练期 IC
train_ics = []
for m in range(t, train_end):
ic, _ = stats.spearmanr(factor_values[m], stock_returns[m])
train_ics.append(ic)
# 测试期 IC
test_ics = []
for m in range(train_end, test_end):
ic, _ = stats.spearmanr(factor_values[m], stock_returns[m])
test_ics.append(ic)
is_results.append({
'period': f'{t}-{train_end}',
'mean_ic': np.mean(train_ics),
'icir': np.mean(train_ics) / np.std(train_ics) if np.std(train_ics) > 0 else 0
})
os_results.append({
'period': f'{train_end}-{test_end}',
'mean_ic': np.mean(test_ics),
'icir': np.mean(test_ics) / np.std(test_ics) if np.std(test_ics) > 0 else 0
})
t += step
# ============================================================
# 输出结果
# ============================================================
print("=" * 60)
print("Walk-Forward 样本外检验")
print("=" * 60)
print(f"\n{'期间':>12} {'样本内 IC':>12} {'样本内 ICIR':>14}")
print("-" * 40)
for r in is_results:
print(f"{r['period']:>12} {r['mean_ic']:>12.4f} {r['icir']:>14.3f}")
print(f"\n{'期间':>12} {'样本外 IC':>12} {'样本外 ICIR':>14}")
print("-" * 40)
for r in os_results:
print(f"{r['period']:>12} {r['mean_ic']:>12.4f} {r['icir']:>14.3f}")
# 汇总
is_mean_ic = np.mean([r['mean_ic'] for r in is_results])
os_mean_ic = np.mean([r['mean_ic'] for r in os_results])
decay = 1 - os_mean_ic / is_mean_ic if is_mean_ic != 0 else 0
print(f"\n{'='*40}")
print(f"样本内平均 IC: {is_mean_ic:.4f}")
print(f"样本外平均 IC: {os_mean_ic:.4f}")
print(f"衰减比例: {decay:.1%}")
print(f"\n结论: 如果样本外 IC 仍然显著 > 0,因子有效")
print(f" 如果衰减严重(> 50%),因子可能在失效")7.4 样本外检验的最佳实践
- 不要只分一次训练/测试:Walk-Forward 的多次滚动检验更可靠
- 测试期不宜太短:至少 12 个月,否则结果噪音太大
- 考虑 Regime 变化:测试期的市场环境是否和训练期类似
- 记录所有结果:即使某些测试期的结果不好,也不要只报告好的
小结
| 方法 | 回答的问题 | 核心指标 |
|---|---|---|
| Fama-MacBeth | 因子的横截面预测力是否显著? | t 统计量 |
| 分组分析 | 因子值高的股票是否真的赚更多? | 多空收益、单调性 |
| IC / ICIR | 因子的预测力和稳定性? | ICIR > 0.5、t > 2 |
| 因子正交化 | 消除因子间的重叠信息 | 正交化后的 IC |
| 衰减曲线 | 因子能预测多远? | 最优换仓频率 |
| 样本外检验 | 因子在”没见过”的数据上还成立吗? | 样本外 IC |
记住:因子研究不是跑一个 IC 就完事。你需要从多个角度验证,用多种方法交叉检验,才能对因子的有效性做出可信的判断。
→ 下一章:04-Barra风险模型 —— 机构怎么用结构化风险模型管理组合风险