量化交易中文教材

第 03 章 因子研究

因子研究回答一个问题:某个可观测的股票特征,能否预测下一期的截面收益,而且这种预测力不是已知风险的变形、不是运气、扣除成本后还有剩余。本章按研究顺序组织:构造与预处理 → 中性化 → IC 分析 → 分组回测 → Fama–MacBeth 回归与标准误 → 衰减与换手 → 多重检验。结论先说:中性化能识别"伪因子",Fama–MacBeth 的标准误本质上是按时间聚类,重叠样本必须用 Newey–West,换手由信号自相关决定,候选因子多时 t 值门槛应提高到 3 左右。

学习目标

  1. 能把原始特征加工成因子(截尾、标准化、中性化),并用 FWL 定理解释中性化。
  2. 会计算 IC、RankIC、ICIR 及其 t 值,知道重叠持有期为什么要用 Newey–West。
  3. 会做分组回测,检查单调性、换手与成本。
  4. 会做 Fama–MacBeth 回归,能根据误差相关结构选择标准误。
  5. 会测量因子衰减与信号自相关,并据此估计换手和调仓频率。
  6. 会对大批候选因子做多重检验校正。

读前导读

这一章在解决什么问题。 因子研究就是把卖方/买方研究员的"选股逻辑"变成可检验的统计命题。研究员说"高 ROE、低负债的公司长期跑赢",这是一个选股逻辑;因子研究要回答:这条逻辑在过去 15 年、几千只股票上,是否系统性地有效?是不是只是"偏好小盘股"或"偏好某个行业"的变形?扣掉交易成本还剩多少?是不是试了几百条逻辑后挑出来的幸运儿?

把各节对应到你熟悉的工作:

  • 3.2–3.3 预处理与中性化:相当于做行业比较时的"同口径调整"。你比较两家公司的 PE,会先看它们是否属于同一行业;中性化就是在每个月把因子值里"行业差异"和"大小盘差异"剥掉,只留下同行业、同规模内的相对高低。
  • 3.4 IC:相当于检验分析师评级的"命中率"。每月把研究员的打分与下月实际涨跌做相关,看平均有多准、有多稳定。
  • 3.5 分组回测:相当于基金公司的"模拟组合":买评分最高的 10%、卖最低的 10%,看历史表现。
  • 3.6 Fama–MacBeth:CFA 二级回归的面板版本,同时放多个因子,看谁的贡献是独立的。重点是"标准误怎么算",这直接决定了你会不会被假显著骗。
  • 3.7 衰减与换手:相当于"研究结论的保质期"。消息类逻辑一个月就过期,基本面逻辑能持续半年。保质期短的逻辑必须频繁调仓,交易成本就高。
  • 3.8 多重检验:相当于合规里的"选择性披露"问题:试了 200 个因子只报告最好的几个,必须对试验次数做校正。

需要先想起来的数学。

  1. 矩阵形式的 OLS:\(\hat{\boldsymbol\theta}=(\boldsymbol B'\boldsymbol B)^{-1}\boldsymbol B'\boldsymbol x\),残差 \(\boldsymbol e=\boldsymbol x-\boldsymbol B\hat{\boldsymbol\theta}\)。这就是 CFA 的多元回归,只是把所有观测排成一列向量、所有自变量排成矩阵 \(\boldsymbol B\)。\(\boldsymbol B'\) 是转置(行列互换),\((\cdot)^{-1}\) 是逆矩阵(矩阵版的"除法")。见 第 00 册第 06 章 线性代数速成。
  2. 哑变量:属于第 \(k\) 个行业则该列为 1,否则为 0。\(K\) 个行业哑变量加起来每行恰好等于 1,所以它们已经"包含截距",再加常数列会完全共线(CFA 里的"哑变量陷阱")。
  3. 相关系数与 t 检验:IC 序列的均值 t 检验就是 \(t=\bar x/(s/\sqrt T)\),你在 CFA 一级就见过。本章新增的只是"序列自相关时 \(s/\sqrt T\) 会低估真实标准误"。
  4. 自协方差 \(\hat\gamma_l\):序列与自身滞后 \(l\) 期的协方差。\(\hat\gamma_0\) 就是方差。正的自协方差意味着"今天高、下个月也偏高",信息有重复,有效样本变少。见 第 00 册第 07 章 概率中的分析工具。
  5. 期望 \(\mathbb E[\cdot]\) 与半衰期:\(\mathbb E\) 表示总体平均。若每期保留比例 \(\phi\),经过 \(h\) 期剩 \(\phi^h\);令 \(\phi^h=0.5\) 得半衰期 \(h=\ln0.5/\ln\phi\)。例:\(\phi=0.88\) 时 \(h\approx5.4\)。

怎么读这一章。 必读:3.3(中性化与 FWL)、3.4(IC 与 NW)、3.6(标准误的选择)、3.8(多重检验)。3.1 的合成数据代码第一次可以只看讲解框和"体检"输出;3.5 和 3.7 偏实务,读结论和数字即可,但"换手 ∝ \(\sqrt{1-\rho}\)"这个结论要记住,第 05 章会用到。


3.1 问题与动机

记股票 \(i\) 在 \(t\) 期末的因子值为 \(x_{i,t}\),下一期收益为 \(r_{i,t+1}\)。因子研究的核心模型是截面回归

\[r_{i,t+1}=a_t+b_t\,x_{i,t}+\boldsymbol c_t'\boldsymbol g_{i,t}+e_{i,t+1},\]

其中 \(\boldsymbol g_{i,t}\) 是控制变量(行业、市值等),\(b_t\) 是 \(t+1\) 期的因子收益。我们关心 \(\mathbb E[b_t]\) 是否显著不为零。这个模型就是第 06 册第 09 章 9.3.1 节的 BARRA 截面回归,也是第 05 册第 10 章 10.8 节的 Fama–MacBeth 框架。本章讲工程上的错误。

白话解释:这个回归的读法是"每个月做一次横截面回归"。纵轴是 1000 只股票下个月的收益,横轴是它们这个月末的因子值 \(x\)。斜率 \(b_t\) 的含义是"因子值高一个单位的股票,下个月多赚多少",因此叫"因子收益"——它就像一个多空组合当月的收益。\(\boldsymbol c_t'\boldsymbol g_{i,t}\) 是控制变量的贡献(\(\boldsymbol c_t'\) 中的撇号表示转置,\(\boldsymbol c_t'\boldsymbol g_{i,t}\) 就是"系数 × 变量"逐项相乘再相加)。180 个月就有 180 个 \(b_t\),我们检验的是它们的平均值是否显著不为 0。和 CFA 里的 CAPM 检验相比,这里的"自变量"不是 β,而是你想检验的任何股票特征。

本章使用一个合成月度市场:1000 只股票、180 个月、10 个行业。收益由市场因子(GARCH 波动、\(t\) 新息)、有长期均值差异的行业因子、市值与估值风格因子、两个研究员看不到的 alpha 来源(一个持续、一个快速衰减,溢价本身也随时间波动)和厚尾的特质噪声组成,小盘股的特质波动更大。研究员能看到四个候选因子:

  • quality:持续 alpha 来源加上有持续性的测量噪声;
  • sentiment:快速衰减的 alpha 来源加上噪声;
  • lowsize_ind:由"小市值"与"好行业"拼出来的伪因子,自身没有 alpha;
  • mom_12_1:12–1 月动量,在这个模拟世界中没有真实效应。

本章代码按顺序在同一会话中运行。

import numpy as np
import pandas as pd
from scipy import stats

rng = np.random.default_rng(303)
N, T, K = 1000, 180, 10                          # 1000 只股票,180 个月(15 年),10 个行业
months = pd.period_range('2011-01', periods=T, freq='M')
ind = rng.integers(0, K, N)                      # 行业归属(固定)
IND = np.eye(K)[ind]                             # N×K 行业哑变量

def ar1(phi, shape, scale=1.0):
    """各列独立的平稳 AR(1),按行(时间)递推。"""
    x = np.empty(shape); x[0] = rng.standard_normal(shape[1]) * scale
    for t in range(1, shape[0]):
        x[t] = phi * x[t-1] + np.sqrt(1 - phi**2) * scale * rng.standard_normal(shape[1])
    return x

# ---- 股票特征(t 月末可观测) ----
lsize = ar1(0.99, (T, N)) + 0.8 * (IND @ rng.normal(0, 1, K))       # 对数市值,行业间有差异
value = ar1(0.95, (T, N))                                            # 估值(如 B/P)
q     = ar1(0.90, (T, N))                                            # 真 alpha 来源(如盈利质量),研究员看不到
fastq = ar1(0.30, (T, N))                                            # 快速衰减的 alpha 来源(如短期情绪)

# ---- 因子收益与股票收益 ----
# 市场:GARCH(1,1)、t 新息;行业:各有长期均值;风格:市值(小盘溢价)与估值
h = np.empty(T); mkt = np.empty(T); h[0] = 0.0016
for t in range(T):
    if t: h[t] = 0.0002 + 0.20 * (mkt[t-1] - 0.006)**2 + 0.70 * h[t-1]
    mkt[t] = 0.006 + np.sqrt(h[t]) * rng.standard_t(5) / np.sqrt(5/3)
ind_mu = rng.normal(0, 0.004, K)
f_ind = ind_mu + 0.025 * rng.standard_normal((T, K))
f_size = -0.002 + 0.015 * rng.standard_normal(T)                     # 每 1σ 市值,月均 -0.2%
f_val = 0.002 + 0.012 * rng.standard_normal(T)
beta = rng.normal(1, 0.3, N)
zs = lambda a: (a - a.mean(axis=1, keepdims=True)) / a.std(axis=1, keepdims=True)
idio_vol = 0.06 + 0.03 * np.clip(-zs(lsize), -1.5, 3)                # 小盘股特质波动更大
eps = idio_vol * rng.standard_t(4, (T, N)) / np.sqrt(2)

lam_q = 0.0030 + 0.0060 * rng.standard_normal(T)                     # alpha 的"溢价"也随时间波动
lam_f = 0.0040 + 0.0080 * rng.standard_normal(T)
ret = np.full((T, N), np.nan)                                        # ret[t] = 第 t 月的收益
for t in range(1, T):                                                # 由 t-1 月末的特征决定期望收益
    ret[t] = (beta * mkt[t] + IND @ f_ind[t] + f_size[t] * zs(lsize)[t-1] + f_val[t] * zs(value)[t-1]
              + lam_q[t] * zs(q)[t-1] + lam_f[t] * zs(fastq)[t-1] + eps[t])
ret = np.maximum(ret, -0.95)                                         # 有限责任:最多亏 95%

# ---- 研究员能看到的候选因子(都在 t 月末形成,预测 t+1 月收益) ----
cols = [f'S{i:04d}' for i in range(N)]
P = lambda a: pd.DataFrame(a, index=months, columns=cols)
R = P(ret)
fwd = R.shift(-1)                                                    # 标签:下个月收益
cum = np.log1p(R.fillna(0))
F = {
    'quality': P(q + ar1(0.8, (T, N))),                              # 真信号 + 有持续性的测量噪声
    'sentiment': P(fastq + 1.0 * rng.standard_normal((T, N))),       # 快衰减真信号
    'lowsize_ind': P(-0.7 * zs(lsize) + 0.6 * (IND @ (ind_mu / ind_mu.std())) + rng.standard_normal((T, N))),
    'mom_12_1': cum.shift(1).rolling(11).sum(),                      # 12-1 月动量(剔除最近 1 个月)
}
SIZE = P(lsize); INDS = pd.Series(ind, index=cols)

# ---- 合成数据的"体检" ----
r_all = R.values[1:].ravel()
print(f'个股月收益:均值 {np.nanmean(r_all):.3%},标准差 {np.nanstd(r_all):.3%},'
      f'超额峰度 {stats.kurtosis(r_all, nan_policy="omit"):.1f}')
m2 = (mkt - mkt.mean())**2
print(f'市场收益平方的 1 阶自相关 {np.corrcoef(m2[:-1], m2[1:])[0,1]:.2f}(波动聚集)')
avg_corr = np.nanmean(np.corrcoef(R.values[1:].T)[np.triu_indices(N, 1)])
print(f'个股两两相关均值 {avg_corr:.2f}')
print('候选因子与对数市值的平均截面相关:',
      {k: round(float(v.corrwith(SIZE, axis=1).mean()), 2) for k, v in F.items()})

输出:

个股月收益:均值 0.335%,标准差 9.291%,超额峰度 4.8
市场收益平方的 1 阶自相关 0.12(波动聚集)
个股两两相关均值 0.34
候选因子与对数市值的平均截面相关: {'quality': -0.01, 'sentiment': 0.0, 'lowsize_ind': -0.57, 'mom_12_1': 0.01}

合成数据的个股月收益超额峰度 4.8,市场收益平方正自相关,个股两两相关 0.34(略高于多数真实市场)。与真实数据相比,它缺少因子溢价的结构性变化与拥挤、财报的季度节奏、行业分类变更和上市退市。

白话解释:这段模拟代码为什么这样设计?它故意造了一个"有标准答案的考场": 收益 = β × 市场 + 行业收益 + 市值风格 + 估值风格 + 两个真 alpha + 噪声。关键在 ret[t] 那一行用的是 [t-1] 期的特征,即"上月末的特征决定本月期望收益",这是真正的可预测性。 四个候选因子各代表一种研究中常见的情况:quality 是"好逻辑但数据有误差"(类似用财报 ROE 去近似真实盈利能力);sentiment 是"有效但很快过期"(类似短期资金流、舆情);lowsize_ind 是"看起来有效、其实只是偏好小盘股和好行业"(很多卖方金工报告里的因子都有这个问题);mom_12_1 是"本世界里根本无效"的对照组。后面每一节的工具都要能把这四类区分开。 lam_q、lam_f 带随机项,意思是 alpha 的强度每个月都在变,有时甚至为负。这正是真实因子的特点,也是 3.6 节"按月份聚类"必要性的根源。np.maximum(ret, -0.95) 对应股票的有限责任:股价最多跌到接近 0。

3.2 因子构造与预处理

一个因子从原始数据到可用,要经过固定的流水线:

  1. 原始值:在 \(t\) 期末可得的数据算出的特征(本册第 02 章的时点规则)。
  2. 截尾:MAD 5 倍截尾,处理数据错误和极端值(本册第 02 章 2.6 节)。
  3. 标准化:截面 z-score,使不同因子、不同时期的数值可比。
  4. 中性化:去掉对行业、市值等已知风险的暴露(3.3 节)。
  5. 再标准化:中性化残差重新 z-score。

两个构造细节:动量剔除最近一个月(12–1 月动量),因为最近一个月往往表现为短期反转;比率因子用不会因分母变号而跳跃的方向(B/P 而非 P/B,E/P 而非 P/E)。

3.3 中性化

3.3.1 OLS 残差

每期截面上把因子对行业哑变量和标准化对数市值回归,取残差:

\[\boldsymbol x_t=\boldsymbol B_t\boldsymbol\theta_t+\boldsymbol e_t,\qquad \tilde{\boldsymbol x}_t=\boldsymbol e_t=\big(\boldsymbol I-\boldsymbol B_t(\boldsymbol B_t'\boldsymbol W_t\boldsymbol B_t)^{-1}\boldsymbol B_t'\boldsymbol W_t\big)\boldsymbol x_t,\]

\(\boldsymbol W_t\) 为权重矩阵(等权时为单位阵,常用 \(\sqrt{\text{市值}}\) 加权)。行业哑变量已经包含截距,不能再加常数列,否则设计矩阵奇异。

推导拆解:这个长公式拆开看只有三步(以等权 \(\boldsymbol W=\boldsymbol I\) 为例)。 第一步,把因子值 \(\boldsymbol x\)(1000 只股票的一列数)对 \(\boldsymbol B\)(10 列行业哑变量 + 1 列市值)做普通回归,系数 \(\hat{\boldsymbol\theta}=(\boldsymbol B'\boldsymbol B)^{-1}\boldsymbol B'\boldsymbol x\),这是 CFA 多元回归的矩阵写法。 第二步,拟合值 \(\boldsymbol B\hat{\boldsymbol\theta}\) 就是"因子值中能被行业和市值解释的部分"。 第三步,残差 \(\boldsymbol x-\boldsymbol B\hat{\boldsymbol\theta}\) 是"剩下的部分"。把 \(\hat{\boldsymbol\theta}\) 代进去、提出 \(\boldsymbol x\),就得到公式里的 \((\boldsymbol I-\boldsymbol B(\boldsymbol B'\boldsymbol B)^{-1}\boldsymbol B')\boldsymbol x\)。 白话:如果只有行业哑变量,残差就是"每只股票的因子值减去它所在行业的平均值"(练习 2)。加上市值后,还要再减去"同行业里市值能解释的那部分"。有了 \(\boldsymbol W\),回归变成加权最小二乘:大盘股的权重更大,拟合得更准。 代码 neutralize 里乘 sw = np.sqrt(w) 是加权回归的标准技巧:把每行数据乘以 \(\sqrt{w_i}\) 后做普通 OLS,等价于加权 OLS。

3.3.2 FWL 定理:中性化就是"加控制变量"

Frisch–Waugh–Lovell 定理(第 05 册第 06 章 6.9 节)说:在 \(\boldsymbol y\) 对 \([\boldsymbol x,\boldsymbol B]\) 的回归中,\(\boldsymbol x\) 的系数等于 \(\boldsymbol y\) 对"\(\boldsymbol x\) 对 \(\boldsymbol B\) 回归的残差"的回归系数。所以"先中性化、再做单变量检验"与"多元截面回归中加控制变量"得到同一个因子收益。代码中第 100 期两种算法的斜率完全相同。

def mad_z(df, k=5.0):
    """每期截面:MAD 截尾 + z-score(本册第 02 章 2.6 节)。"""
    med = df.median(axis=1)
    mad = df.sub(med, axis=0).abs().median(axis=1) * 1.4826
    x = df.clip(med - k * mad, med + k * mad, axis=0)
    return x.sub(x.mean(axis=1), axis=0).div(x.std(axis=1), axis=0)

def exposures(t):
    """t 期的控制变量矩阵:行业哑变量 + 标准化对数市值(行业哑变量已含截距)。"""
    s = SIZE.iloc[t].values
    return np.column_stack([IND, (s - s.mean()) / s.std()])

def neutralize(df, weights=None):
    """逐期截面 OLS/WLS:x = Bθ + e,返回残差 e 再标准化。weights 为 None 时等权。"""
    out = np.full(df.shape, np.nan)
    for t in range(len(df)):
        x = df.iloc[t].values; ok = np.isfinite(x)
        if ok.sum() < 50:
            continue
        B = exposures(t)[ok]
        w = np.ones(ok.sum()) if weights is None else weights.iloc[t].values[ok]
        sw = np.sqrt(w)
        theta, *_ = np.linalg.lstsq(B * sw[:, None], x[ok] * sw, rcond=None)
        e = x[ok] - B @ theta
        out[t, ok] = (e - e.mean()) / e.std()
    return pd.DataFrame(out, index=df.index, columns=df.columns)

Z = {k: mad_z(v) for k, v in F.items()}
ZN = {k: neutralize(v) for k, v in Z.items()}

def ic_series(sig, method='spearman'):
    return sig.corrwith(fwd, axis=1, method=method).dropna()

print(f'{"因子":<12s}{"原始 RankIC":>12s}{"t":>8s}{"中性化 RankIC":>15s}{"t":>8s}')
for k in F:
    a, b = ic_series(Z[k]), ic_series(ZN[k])
    ta = a.mean() / a.std() * np.sqrt(len(a)); tb = b.mean() / b.std() * np.sqrt(len(b))
    print(f'{k:<12s}{a.mean():12.4f}{ta:8.2f}{b.mean():15.4f}{tb:8.2f}')

# FWL 定理:在截面回归中"加控制变量"与"先中性化因子再回归"得到相同的斜率
t = 100
x = Z['quality'].iloc[t].values; y = fwd.iloc[t].values; ok = np.isfinite(x) & np.isfinite(y)
B = exposures(t)[ok]
b_full = np.linalg.lstsq(np.column_stack([x[ok], B]), y[ok], rcond=None)[0][0]
e = x[ok] - B @ np.linalg.lstsq(B, x[ok], rcond=None)[0]
b_fwl = (e @ y[ok]) / (e @ e)
print(f'\n第 {t} 期:带控制变量回归的斜率 {b_full:.6f},先中性化再回归的斜率 {b_fwl:.6f}')

# 市值加权(WLS,权重取 √市值)中性化:大盘股的拟合更准,常用于与市值加权组合配合
wN = neutralize(Z['lowsize_ind'], weights=np.sqrt(np.exp(SIZE)))
print(f'lowsize_ind:√市值加权中性化后 RankIC {ic_series(wN).mean():.4f}')

输出:

因子             原始 RankIC       t     中性化 RankIC       t
quality           0.0307    5.48         0.0298    5.52
sentiment         0.0461    6.84         0.0465    6.83
lowsize_ind       0.0285    2.52        -0.0012   -0.57
mom_12_1          0.0145    1.65         0.0111    1.50

第 100 期:带控制变量回归的斜率 0.009558,先中性化再回归的斜率 0.009558
lowsize_ind:√市值加权中性化后 RankIC -0.0001

lowsize_ind 的原始 RankIC 为 0.029、t 值 2.5,可以通过传统的 5% 显著性检验;中性化后 RankIC 变为 −0.001。它的"预测力"完全来自小盘溢价和行业均值差异。中性化前后 quality 和 sentiment 几乎不变,因为它们本来就与市值、行业无关。

金融直觉:FWL 定理用一个你熟悉的例子来理解。你想知道"ROE 高的公司是否估值更高",但大公司 ROE 往往更高、估值也更高。两种做法:(a) 回归 PB 对 ROE 和市值;(b) 先把 ROE 中"市值能解释的部分"剔掉(得到"同等规模下 ROE 的高低"),再用这个残差去解释 PB。FWL 说两种做法得到的 ROE 系数完全一样。所以"中性化"并不神秘,它就是"控制变量"的另一种实现方式:先剔除、再检验。第 100 期两个斜率相同到小数点后 6 位,就是这个定理的数值验证。

lowsize_ind 的例子说明了它的实际价值:一个卖方报告里 t 值 2.5 的"新因子",中性化后 t 值 −0.57。它的全部"预测力"都来自小盘溢价和行业轮动,这两样东西用现成的风格指数和行业 ETF 就能便宜地拿到,不值得为它付 alpha 的价钱。

中性化不是越多越好。控制变量应当是你不想承担、或不想为之付费的风险;对与因子机制相关的变量中性化(例如用"分析师覆盖度"中性化分析师预期修正因子),可能把真正的信息也剔除了。

3.4 IC、RankIC 与 IR

信息系数(IC)是因子与下一期收益的截面相关:\(\mathrm{IC}_t=\mathrm{corr}(\boldsymbol x_t,\boldsymbol r_{t+1})\)。RankIC 用秩相关,对异常值稳健,是业界默认。IC 序列的均值衡量平均预测力,标准差衡量稳定性:

\[\mathrm{ICIR}=\frac{\overline{\mathrm{IC}}}{s_{\mathrm{IC}}},\qquad t=\mathrm{ICIR}\cdot\sqrt T.\]

月度 ICIR 乘以 \(\sqrt{12}\) 近似等于按因子线性加权的多空组合的年化 Sharpe。Grinold–Kahn 的基本定律 \(\mathrm{IR}\approx\mathrm{IC}\cdot\sqrt{\mathrm{BR}}\)(BR 为每年独立押注次数)只是理想上限,押注相关、约束和成本都会使它大幅打折。

IC 的 t 值何时会骗人:当 IC 序列自相关时。最常见的原因是重叠持有期——每月计算一次"未来 3 个月收益"的 IC,相邻两个 IC 共享两个月的收益,序列相关是机械的。这时要用 Newey–West 标准误(推导见第 05 册第 16 章 16.4 节、第 06 册第 02b 章 2.12 节):

\[\widehat{\mathrm{Var}}(\overline{\mathrm{IC}})=\frac1T\Big[\hat\gamma_0+2\sum_{l=1}^{L}\Big(1-\frac{l}{L+1}\Big)\hat\gamma_l\Big],\]

\(\hat\gamma_l\) 为 IC 序列的 \(l\) 阶样本自协方差,\(L\) 至少取持有期减 1。

推导拆解:这个公式从哪来?逐步看。 第一步,均值的方差:\(\mathrm{Var}(\overline{\mathrm{IC}})=\mathrm{Var}\big(\tfrac1T\sum_t\mathrm{IC}_t\big)=\tfrac1{T^2}\sum_s\sum_t\mathrm{Cov}(\mathrm{IC}_s,\mathrm{IC}_t)\)。这一步用了"和的方差 = 所有两两协方差之和",和你算组合方差 \(\sum_i\sum_j w_iw_j\sigma_{ij}\) 是同一个公式,只是权重都是 \(1/T\)。 第二步,如果 IC 互不相关,只剩对角线 \(s=t\) 的 \(T\) 项,结果是 \(\gamma_0/T\),即熟悉的 \(\sigma^2/T\)。 第三步,如果相邻 \(l\) 期的协方差是 \(\gamma_l\),那么非对角线上间隔为 \(l\) 的项大约有 \(2T\) 个(上下各 \(T\) 个),合计约 \(\tfrac1{T^2}\cdot2T\gamma_l=\tfrac{2\gamma_l}{T}\)。加起来就是 \(\tfrac1T[\gamma_0+2\sum_l\gamma_l]\)。 第四步,权重 \((1-\tfrac{l}{L+1})\) 叫 Bartlett 核:远处的自协方差估计不准,打个折,同时保证估计出的方差不会是负数。 数值直觉:3 个月重叠 IC 自相关 0.71,相当于每个月的信息有七成和上个月重复。正相关的 \(\gamma_l\) 让方差变大,t 值变小,这正是表中 8.8 降到 5.5 的原因。就像组合里资产正相关时,分散化效果变差。

白话解释:为什么"月度 ICIR × \(\sqrt{12}\) ≈ 年化 Sharpe"?若每月按因子值线性加权构造多空组合,组合当月收益与当月 IC 大致成正比(再乘上截面收益的离散度)。所以多空收益的"均值/标准差"约等于 IC 的"均值/标准差",即 ICIR;年化时均值乘 12、标准差乘 \(\sqrt{12}\),比值乘 \(\sqrt{12}\)。这里假设截面收益离散度大致稳定,实际中它随市场变化,所以只是近似。

import statsmodels.api as sm

def nw_tstat(x, lags):
    """对序列均值做 Newey–West(HAC)t 检验:回归 x 对常数项。"""
    res = sm.OLS(np.asarray(x), np.ones(len(x))).fit(cov_type='HAC', cov_kwds={'maxlags': lags})
    return res.tvalues[0]

rows, ics = [], {}
def ic_report(sig, label, horizon=1, lags=3):
    y = fwd if horizon == 1 else np.log1p(R).rolling(horizon).sum().shift(-horizon)
    ic = sig.corrwith(y, axis=1, method='spearman').dropna()
    pic = sig.corrwith(y, axis=1).dropna()
    rows.append({'信号': label, 'RankIC': ic.mean(), 'PearsonIC': pic.mean(), 'IC标准差': ic.std(),
                 'ICIR(月)': ic.mean() / ic.std(), '胜率': (ic > 0).mean(),
                 't(朴素)': ic.mean() / ic.std() * np.sqrt(len(ic)), 't(NW)': nw_tstat(ic, lags),
                 'IC自相关': ic.autocorr()})
    ics[label] = ic

ic_report(ZN['quality'], 'quality 1M')
ic_report(ZN['sentiment'], 'sentiment 1M')
ic_report(ZN['quality'], 'quality 3M重叠', horizon=3)   # 每月算一次未来 3 个月的 IC,相邻样本重叠 2 个月
print(pd.DataFrame(rows).set_index('信号').round(3).to_string())

# 稳定性:前后两半样本、市场高/低波动月份
ic_q = ics['quality 1M']; half = len(ic_q) // 2
mkt_s = pd.Series(mkt, index=months)
vol_hi = (mkt_s.rolling(6).std() > mkt_s.rolling(6).std().median()).reindex(ic_q.index)
print(f'\nquality 子样本 RankIC:前半 {ic_q.iloc[:half].mean():.4f},后半 {ic_q.iloc[half:].mean():.4f};'
      f'高波动期 {ic_q[vol_hi].mean():.4f},低波动期 {ic_q[~vol_hi].mean():.4f}')

输出:

              RankIC  PearsonIC  IC标准差  ICIR(月)     胜率  t(朴素)  t(NW)  IC自相关
信号                                                                         
quality 1M     0.030      0.026  0.072    0.413  0.659  5.523  5.183  0.068
sentiment 1M   0.046      0.040  0.091    0.510  0.726  6.828  7.352 -0.089
quality 3M重叠   0.044      0.039  0.067    0.659  0.718  8.770  5.541  0.707

quality 子样本 RankIC:前半 0.0263,后半 0.0332;高波动期 0.0236,低波动期 0.0357

1 个月 IC 的自相关接近 0,朴素 t 与 NW t 差别不大。3 个月重叠 IC 的自相关 0.71,朴素 t 值 8.8,NW 校正后只有 5.5——朴素 t 值高估了约 60%。还要注意:3 个月 IC 的均值(0.044)比 1 个月(0.030)大,ICIR 也更高,但这是因为持有期变长,不能拿不同持有期的 ICIR 直接比较。

子样本稳定性也要检查:quality 前后两半样本的 RankIC 为 0.026 和 0.033,高波动期较弱。

3.5 分组回测

分组回测把股票按因子值排成 \(Q\) 组(常用 5 或 10 组),每组等权或市值加权持有一期。要看三件事:

  • 单调性:只有两端有效、中间杂乱的因子,可能由少数极端股票驱动。
  • 多空收益:顶组减底组,它的 Sharpe 与 t 值。
  • 换手与成本:多空组合每期需要调整多少仓位,扣除成本后还剩多少。
def quantile_backtest(sig, n_q=10, cost_bps=30):
    """每月按信号分 n_q 组,组内等权;返回各组月收益、多空收益与多空组合换手。"""
    grp = sig.rank(axis=1, pct=True).mul(n_q).apply(np.ceil).clip(1, n_q)
    grp = grp.where(fwd.notna())                                       # 只用下月有收益的股票
    qret = pd.DataFrame({g: fwd.where(grp == g).mean(axis=1) for g in range(1, n_q + 1)}).dropna()
    # 多空组合:多头腿 1 元、空头腿 1 元,各自组内等权
    top = (grp == n_q).astype(float); bot = (grp == 1).astype(float)
    w = top.div(top.sum(axis=1), axis=0) - bot.div(bot.sum(axis=1), axis=0)
    traded = w.diff().abs().sum(axis=1).reindex(qret.index)            # 当月买卖成交额合计(元)
    turnover = traded / 4                                              # 单边换手:两条腿全换时为 100%
    ls = qret[n_q] - qret[1]
    net = ls - traded * cost_bps / 1e4                                 # 每成交 1 元付 cost_bps
    return qret, ls, net, turnover

for k in ['quality', 'sentiment']:
    qret, ls, net, to = quantile_backtest(ZN[k])
    mono = stats.spearmanr(np.arange(1, 11), qret.mean()).statistic
    print(f'【{k}】十分组月均收益(%):', (qret.mean() * 100).round(2).tolist())
    print(f'  单调性(组号与均值的秩相关) {mono:.2f};多空月均 {ls.mean():.2%},年化 Sharpe {ls.mean()/ls.std()*np.sqrt(12):.2f},'
          f't {ls.mean()/ls.std()*np.sqrt(len(ls)):.2f}')
    print(f'  月均单边换手 {to.mean():.0%};按单边 30bp 扣成本后年化 Sharpe {net.mean()/net.std()*np.sqrt(12):.2f}')

输出:

【quality】十分组月均收益(%): [-0.07, 0.23, 0.1, 0.22, 0.42, 0.28, 0.54, 0.47, 0.53, 0.64]
  单调性(组号与均值的秩相关) 0.92;多空月均 0.72%,年化 Sharpe 1.33,t 5.13
  月均单边换手 38%;按单边 30bp 扣成本后年化 Sharpe 0.48
【sentiment】十分组月均收益(%): [-0.14, -0.06, 0.04, 0.17, 0.3, 0.39, 0.58, 0.61, 0.58, 0.87]
  单调性(组号与均值的秩相关) 0.96;多空月均 1.01%,年化 Sharpe 1.47,t 5.70
  月均单边换手 84%;按单边 30bp 扣成本后年化 Sharpe -0.01

两个因子的毛 Sharpe 相近(1.33 与 1.47),单调性都很好。但 quality 每月单边换手 38%,按单边 30bp 成本扣除后 Sharpe 0.48;sentiment 换手 84%,扣成本后为零。仅看 IC 或毛收益,会选 sentiment;考虑成本后结论相反。空头腿还有融券成本与可得性限制(A 股尤其受限),其贡献要单独报告。

白话解释:quantile_backtest 在做什么?第一行把每月的因子值转成百分位排名,乘 10 向上取整,得到 1–10 的组号。qret 是每组下月的等权平均收益。权重 w 是"多头 1 元等分给第 10 组、空头 1 元等分给第 1 组"。w.diff().abs().sum() 是本月相对上月要买卖的总金额:如果两条腿的股票全部换掉,多头卖 1 买 1、空头平 1 开 1,合计 4 元,所以除以 4 后,"全部换掉"对应 100% 换手。成本按"每成交 1 元付 30bp"扣。 设计上的关键点是:换手和成本是从实际权重变化算出来的,而不是拍一个固定比例。这样快衰减因子(sentiment)自然会因为每月成分股大换血而付出更高成本,回测结果才和真实交易一致。这就是交易台常说的"纸面 alpha"与"可实现 alpha"之差。

3.6 Fama–MacBeth 回归与标准误

3.6.1 两步法

Fama–MacBeth(1973)两步法:每期做一次截面回归得到 \(\hat b_t\),再对 \(\{\hat b_t\}\) 求时间序列均值和标准误(第 05 册第 10 章 10.8.2 节)。与 IC 分析相比,它的优势是多个因子可以同时进入,系数衡量的是控制了其他因子之后的边际贡献。

3.6.2 选择标准误

第 05 册第 10 章 10.8.3 节总结了 Petersen(2009)的结论:标准误的选法取决于误差的相关结构。在因子研究中,最常见的是时间效应——同一个月内所有股票受共同冲击,而且因子溢价本身随时间波动,使得同月残差与因子值相关。此时:

  • 混合 OLS 的常规标准误、White 稳健标准误、按股票聚类的标准误都严重偏小;
  • 按月份聚类与 FM 标准误正确;
  • 若同时存在持续的个股效应(如使用重叠的长期收益),需要双向聚类或对 FM 系数用 Newey–West。
names = ['quality', 'sentiment', 'lowsize_ind', 'mom_12_1']

# ---- Fama–MacBeth:每期截面回归,再对系数序列求均值与(NW)标准误 ----
coefs = []
for t in range(12, T - 1):                                  # 动量需要 12 个月历史
    X = np.column_stack([Z[k].iloc[t].values for k in names] + [exposures(t)])
    y = fwd.iloc[t].values
    ok = np.isfinite(X).all(axis=1) & np.isfinite(y)
    b = np.linalg.lstsq(X[ok], y[ok], rcond=None)[0]
    coefs.append(b[:len(names)].tolist() + [b[-1]])           # 4 个候选因子 + 市值
G = pd.DataFrame(coefs, columns=names + ['size'], index=months[12:T-1])
fm = pd.DataFrame({'系数(月,%)': G.mean() * 100,
                   't(FM)': G.mean() / G.std() * np.sqrt(len(G)),
                   't(FM-NW,3)': [nw_tstat(G[c], 3) for c in G]})
print('Fama–MacBeth(控制行业与市值,四个候选因子同时进入):'); print(fm.round(3).to_string())

# ---- 混合 OLS(面板堆叠)+ 不同标准误:以 quality 为例 ----
rows = []
for t in range(12, T - 1):
    x = ZN['quality'].iloc[t].values; y = fwd.iloc[t].values
    ok = np.isfinite(x) & np.isfinite(y)
    rows.append(pd.DataFrame({'t': t, 'i': np.where(ok)[0], 'x': x[ok], 'y': y[ok] - y[ok].mean()}))
panel = pd.concat(rows, ignore_index=True)                 # y 已逐期去均值 = 时间固定效应
Xp = sm.add_constant(panel['x'])
fits = {
    'OLS 常规':      sm.OLS(panel['y'], Xp).fit(),
    'White 稳健':    sm.OLS(panel['y'], Xp).fit(cov_type='HC1'),
    '按股票聚类':    sm.OLS(panel['y'], Xp).fit(cov_type='cluster', cov_kwds={'groups': panel['i']}),
    '按月份聚类':    sm.OLS(panel['y'], Xp).fit(cov_type='cluster', cov_kwds={'groups': panel['t']}),
    '双向聚类':      sm.OLS(panel['y'], Xp).fit(cov_type='cluster',
                                                 cov_kwds={'groups': np.column_stack([panel['i'], panel['t']])}),
}
print(f'\n混合 OLS(时间固定效应),quality 系数 {fits["OLS 常规"].params["x"]*100:.4f}%,观测数 {len(panel)}')
for k, f in fits.items():
    print(f'  {k:<10s} 标准误 {f.bse["x"]*100:.4f}%   t = {f.tvalues["x"]:6.2f}')

输出:

Fama–MacBeth(控制行业与市值,四个候选因子同时进入):
             系数(月,%)  t(FM)  t(FM-NW,3)
quality        0.212  5.539       5.148
sentiment      0.302  6.336       6.626
lowsize_ind   -0.022 -0.797      -0.704
mom_12_1       0.042  0.670       0.722
size          -0.176 -1.380      -1.474

混合 OLS(时间固定效应),quality 系数 0.2075%,观测数 167000
  OLS 常规     标准误 0.0188%   t =  11.06
  White 稳健   标准误 0.0187%   t =  11.10
  按股票聚类      标准误 0.0187%   t =  11.11
  按月份聚类      标准误 0.0385%   t =   5.39
  双向聚类       标准误 0.0385%   t =   5.39

几点说明:

  1. quality 和 sentiment 显著,lowsize_ind 和 mom_12_1 不显著,与设定一致。市值的真实溢价为每标准差 −0.2%/月,但 FM t 值只有 −1.4:溢价真实存在、但波动大的风格因子,15 年数据也不一定能检验出来。
  2. 混合 OLS 中,quality 的系数 0.21% 与 FM 结果几乎一致,但常规、White 与按股票聚类的 t 值约为 11,是正确值(按月份聚类,5.4)的两倍。原因是因子溢价每月都在变化:同一个月里所有股票的残差都含有"本月溢价偏离均值 × 因子暴露"这一共同成分,观测数 16.7 万,有效样本却只有 167 个月。
  3. 按月份聚类的 t 值(5.39)与 FM 的 t 值(5.54)非常接近,这正是"FM 标准误本质上是按时间聚类"的数值体现。本例没有持续的个股残差效应,所以双向聚类与按月份聚类一样。

白话解释:为什么 16.7 万个观测"只相当于" 167 个?想象一个民调:你在 167 个城市各采访 1000 人,但每个城市当天都有一条影响所有人的本地新闻。同一城市 1000 个人的答案高度相关,真正独立的信息更接近"167 个城市",而不是"16.7 万人"。在因子研究里,"城市"就是月份,"本地新闻"就是"本月因子溢价偏离均值"。常规 OLS 和 White 标准误假设 16.7 万个残差互相独立,于是标准误被严重低估(本例中只有正确值的一半),t 值翻倍。按月份聚类允许同月残差任意相关,只假设不同月份独立,所以给出正确答案。

推导拆解:为什么 FM 就等价于按月份聚类?FM 第一步每月得到一个 \(\hat b_t\),同月内所有股票的共同冲击都被"打包"进这一个数字里;第二步把 167 个 \(\hat b_t\) 当作 167 个独立观测求均值的 t 值,\(t=\bar b/(s_b/\sqrt{T})\)。它天然只用到"月份之间独立"这一个假设,和按月聚类相同。代码中 t(FM-NW,3) 再进一步,允许相邻几个月的 \(\hat b_t\) 也相关。 对实务的含义:读研究报告时,如果作者用 16 万个观测做混合回归、报告 t 值 11,却没说标准误怎么算,你应当先打个对折。

3.7 因子衰减与换手

因子值在 \(t\) 期末形成,但实际交易可能要晚一些,持有期也可能长于一个月。因子衰减用 \(\mathrm{IC}(h)=\mathrm{corr}(\boldsymbol x_t,\boldsymbol r_{t+h})\) 衡量(注意是第 \(t+h\) 期单月收益,不是累计收益)。若 \(\mathrm{IC}(h)\approx\mathrm{IC}(1)\,\phi^{h-1}\),IC 的半衰期为 \(\ln 0.5/\ln\phi\)。

换手由信号自相关 \(\rho=\mathrm{corr}(\boldsymbol x_t,\boldsymbol x_{t-1})\) 决定。若组合权重与标准化因子值成正比、因子近似正态,则 \(\mathbb E|x_{i,t}-x_{i,t-1}|=\sqrt{2(1-\rho)}\cdot\sqrt{2/\pi}\),换手大致与 \(\sqrt{1-\rho}\) 成正比。

推导拆解:两步得到这个式子。 第一步,求差值的方差。\(x_t\) 和 \(x_{t-1}\) 都已标准化(方差为 1),相关系数 \(\rho\),所以 \(\mathrm{Var}(x_t-x_{t-1})=1+1-2\rho=2(1-\rho)\)。这一步用的是两资产组合方差公式 \(\sigma_1^2+\sigma_2^2-2\rho\sigma_1\sigma_2\),权重为 \(+1\) 和 \(-1\)。 第二步,正态变量绝对值的均值。若 \(D\sim N(0,\sigma^2)\),则 \(\mathbb E|D|=\sigma\sqrt{2/\pi}\approx0.8\sigma\)(对正态密度在正半轴积分可得,见 第 00 册第 03 章 积分)。代入 \(\sigma=\sqrt{2(1-\rho)}\) 即得。 数值例:\(\rho=0.84\) 时 \(\sqrt{2\times0.16}\times0.8\approx0.45\);\(\rho=0.14\) 时 \(\sqrt{2\times0.86}\times0.8\approx1.05\)。权重与 \(x\) 成正比时,每期调仓金额正比于 \(\sum_i|x_{i,t}-x_{i,t-1}|\),所以两个因子的换手之比就是 \(1.05/0.45\approx2.3\)。 金融直觉:\(\rho\) 就是"本月排名和上月排名有多像"。基本面因子(ROE、估值)一个月里变化不大,\(\rho\) 高;价量、情绪因子每月重新洗牌,\(\rho\) 低。\(\rho\) 从 0.84 降到 0.14,换手翻一倍还多。

# ---- 因子衰减:t 月末的信号对 t+h 月收益的 RankIC ----
H = 12
decay = pd.DataFrame({k: [ZN[k].corrwith(R.shift(-h), axis=1, method='spearman').mean() for h in range(1, H + 1)]
                      for k in ['quality', 'sentiment']}, index=range(1, H + 1))
decay.index.name = 'h(月)'
print('RankIC 随预测期 h 的衰减:'); print(decay.T.round(4).to_string())

# ---- 信号自相关、半衰期与换手 ----
for k in ['quality', 'sentiment']:
    rho = ZN[k].corrwith(ZN[k].shift(1), axis=1, method='spearman').mean()
    hl = np.log(0.5) / np.log(rho)
    ic = decay[k]
    # 用前几期 IC 拟合指数衰减 IC_h ≈ IC_1 · φ^(h-1)
    phi = np.exp(np.polyfit(np.arange(4), np.log(ic.iloc[:4].clip(lower=1e-6)), 1)[0])
    _, _, _, to = quantile_backtest(ZN[k])
    print(f'{k:<10s} 信号秩自相关 {rho:.2f}(半衰期 {hl:.1f} 月);IC 衰减率 φ≈{phi:.2f}(IC 半衰期 {np.log(0.5)/np.log(phi):.1f} 月);'
          f'十分组多空月换手 {to.mean():.0%}')

# ---- 执行延迟:信号形成后晚 1 个月才交易 ----
for k in ['quality', 'sentiment']:
    lagged = ZN[k].shift(1).corrwith(fwd, axis=1, method='spearman').mean()
    print(f'{k:<10s} 延迟 1 个月使用:RankIC {decay.loc[1, k]:.4f} -> {lagged:.4f}')

输出:

RankIC 随预测期 h 的衰减:
h(月)           1       2       3       4       5       6       7       8       9       10      11      12
quality    0.0298  0.0256  0.0226  0.0206  0.0190  0.0177  0.0190  0.0176  0.0147  0.0139  0.0113  0.0103
sentiment  0.0465  0.0117  0.0017  0.0009  0.0008  0.0026  0.0002  0.0014 -0.0008 -0.0030 -0.0007 -0.0012
quality    信号秩自相关 0.84(半衰期 3.9 月);IC 衰减率 φ≈0.88(IC 半衰期 5.6 月);十分组多空月换手 38%
sentiment  信号秩自相关 0.14(半衰期 0.4 月);IC 衰减率 φ≈0.25(IC 半衰期 0.5 月);十分组多空月换手 84%
quality    延迟 1 个月使用:RankIC 0.0298 -> 0.0256
sentiment  延迟 1 个月使用:RankIC 0.0465 -> 0.0117

quality 的 IC 缓慢衰减,12 个月后仍有第 1 个月的三分之一;sentiment 的 IC 在第 2 个月就只剩四分之一,第 3 个月基本为零。用上面的近似,两个因子的换手比应约为 \(\sqrt{1-0.14}/\sqrt{1-0.84}\approx2.3\),与分组回测得到的 \(84\%/38\%\approx2.2\) 接近。

这组数字直接决定组合设计:

  • 调仓频率应与 IC 半衰期匹配。sentiment 必须尽快交易(延迟一个月 IC 就从 0.047 掉到 0.012),而 quality 可以按季度调仓,或者每月只调整一部分。
  • 快信号与慢信号组合时,快信号的权重应当在扣除成本后决定。本册第 05 章的换手惩罚优化会把这一权衡变成一个显式的目标函数。

3.8 多重检验

\(M\) 个零效应检验按 5% 单检验阈值,平均会有 \(0.05M\) 个"显著"结果(第 03 册第 10b 章)。常用的校正:

  • Bonferroni:阈值 \(\alpha/M\),控制族错误率(FWER)。
  • Holm:逐步下降版本,同样控制 FWER,功效不低于 Bonferroni。
  • Benjamini–Hochberg(BH):控制错误发现率(FDR),适合初筛。
  • \(|t|>3\):Harvey、Liu 与 Zhu(2016)根据已发表因子的数量建议的经验门槛。

白话解释:FWER 与 FDR 的区别。族错误率(FWER)是"至少出现一个假发现"的概率,Bonferroni 把它控制在 5%:200 个检验,每个阈值 \(0.05/200=0.00025\)。这非常严格,适合"一旦错了代价很大"的场合,比如最终上线的因子。错误发现率(FDR)是"所有发现中假发现所占比例"的期望,BH 把它控制在 10%:允许发现清单里混入少量假的,换来更多真发现,适合初筛。 BH 的操作很简单:把 \(M\) 个 p 值从小到大排成 \(p_{(1)}\le\dots\le p_{(M)}\),找最大的 \(k\) 使 \(p_{(k)}\le\frac{k}{M}q\)(\(q\) 为目标 FDR),然后拒绝前 \(k\) 个。直观上,排第 1 的门槛最严(\(q/M\)),越往后门槛越松。

金融直觉:这和审计抽样的逻辑相通。Bonferroni 像"零容忍"的高风险领域测试;BH 像"可容忍错报率"下的抽样——承认会有少量错报,但把比例控制住。另一个类比是基金经理评价:从 200 只基金里找"真有选股能力"的,如果只用单只基金 5% 显著性,会有大约 10 只纯靠运气的基金被选中。

下面构造 200 个候选因子:190 个是有持续性但与收益无关的噪声,10 个是真信号的弱代理(噪声越大,IC 越小)。

from statsmodels.stats.multitest import multipletests

# 200 个候选因子:190 个纯噪声(有持续性,看起来像真实特征),10 个是真信号的"弱代理"
rng = np.random.default_rng(711)                                  # 本节单独设定种子(ar1 使用全局 rng)
M, M_true = 200, 10
cand = np.empty((T, N, M))
for j in range(M - M_true):
    cand[:, :, j] = ar1(0.9, (T, N))
noise_scale = np.linspace(3.0, 9.0, M_true)                      # 代理越差,IC 越小
for j in range(M_true):
    src = q if j % 2 == 0 else fastq
    cand[:, :, M - M_true + j] = zs(src) + noise_scale[j] * rng.standard_normal((T, N))
is_true = np.r_[np.zeros(M - M_true, bool), np.ones(M_true, bool)]

# 向量化的 RankIC:每月把候选因子和下月收益都转成秩,再算 Pearson 相关
ic_mat = []
for t in range(T - 1):
    y = fwd.iloc[t].values; ok = np.isfinite(y)
    ry = stats.rankdata(y[ok]); ry = (ry - ry.mean()) / ry.std()
    rx = stats.rankdata(cand[t, ok, :], axis=0); rx = (rx - rx.mean(0)) / rx.std(0)
    ic_mat.append(rx.T @ ry / ok.sum())
ic_mat = np.array(ic_mat)                                         # (T-1) × M
tstat = ic_mat.mean(0) / ic_mat.std(0, ddof=1) * np.sqrt(len(ic_mat))
pval = 2 * stats.t.sf(np.abs(tstat), df=len(ic_mat) - 1)

rules = {'单检验 p<0.05': pval < 0.05,
         '|t|>3 (HLZ)':   np.abs(tstat) > 3,
         'Bonferroni 5%': multipletests(pval, 0.05, 'bonferroni')[0],
         'Holm 5%':       multipletests(pval, 0.05, 'holm')[0],
         'BH FDR 10%':    multipletests(pval, 0.10, 'fdr_bh')[0]}
print(f'真信号的 t 值:{np.round(tstat[is_true], 2).tolist()}')
print(f'噪声因子中最大的 |t|:{np.abs(tstat[~is_true]).max():.2f}\n')
print(f'{"规则":<16s}{"发现数":>6s}{"其中真":>6s}{"其中假":>6s}{"假发现比例":>10s}')
for k, rej in rules.items():
    tp, fp = (rej & is_true).sum(), (rej & ~is_true).sum()
    print(f'{k:<16s}{rej.sum():>6d}{tp:>6d}{fp:>6d}{fp / max(rej.sum(), 1):>10.0%}')

输出:

真信号的 t 值:[4.31, 5.06, 3.31, 4.15, 3.46, 5.08, 2.27, 2.91, 1.75, 2.47]
噪声因子中最大的 |t|:3.09

规则                 发现数   其中真   其中假     假发现比例
单检验 p<0.05          15     9     6       40%
|t|>3 (HLZ)          7     6     1       14%
Bonferroni 5%        4     4     0        0%
Holm 5%              4     4     0        0%
BH FDR 10%           7     6     1       14%

单检验 \(p<0.05\) 给出 15 个"发现",其中 6 个是假的,假发现比例 40%。Bonferroni 和 Holm 没有假发现,但只找到了 4 个真信号,把 \(t\) 值在 3.3–3.5 之间的两个也放弃了;\(|t|>3\) 规则与 BH(FDR 10%)都找到 6 个真信号,代价是 1 个假发现。实务建议与第 03 册第 10b 章 10b.5 节一致:初筛用 BH,最终上线的少数几个因子用 Holm 或 Deflated Sharpe(本册第 06 章)确认。

\(M\) 应取试验日志中的全部候选数(本册第 01 章 1.5.2 节);候选高度相关时 Bonferroni 过于保守,可用 Bootstrap 模拟"最大 t 值"的零分布(第 03 册第 10b 章 10b.6.3 节)。


3.9 常见陷阱与检查清单

  • [ ] 因子值只用 \(t\) 期末可得的数据;动量剔除最近一个月;比率因子用不会变号的方向。
  • [ ] 先截尾、再标准化、再中性化、再标准化,每步参数写入配置。
  • [ ] 同时报告原始与中性化后的 IC;中性化变量的选择有经济理由。
  • [ ] IC 的 t 值在重叠持有期下使用 Newey–West,\(L\ge\) 持有期 \(-1\)。
  • [ ] 不同持有期的 ICIR 不直接比较。
  • [ ] 分组回测报告单调性、多空两条腿各自的贡献、换手和扣成本后的收益。
  • [ ] 面板回归的标准误按月份聚类或用 FM;存在持续个股效应时用双向聚类。
  • [ ] 报告 IC 衰减曲线和信号自相关,调仓频率与 IC 半衰期匹配。
  • [ ] 显著性门槛随候选数提高;\(M\) 取自试验日志。

本章小结

由 FWL 定理,中性化等价于在截面回归中加控制变量;本章的伪因子 lowsize_ind 原始 RankIC 的 t 值为 2.5,中性化后消失。IC 的 t 值等于 ICIR 乘以 \(\sqrt T\),但重叠持有期会使 IC 自相关,本例中朴素 t 值被高估约 60%。混合 OLS 在因子溢价随时间波动时,常规与 White 标准误只有正确值的一半左右,按月份聚类与 FM 标准误给出一致的答案。换手由信号自相关决定,大致与 \(\sqrt{1-\rho}\) 成正比;毛 Sharpe 相近的两个因子,扣成本后可能一个可用、一个为零。200 个候选因子中,单检验阈值的假发现比例达 40%,BH 与 \(|t|>3\) 在功效与错误率之间取得较好平衡。

概念 公式 / 要点
中性化 \(\tilde{\boldsymbol x}=(\boldsymbol I-\boldsymbol B(\boldsymbol B'\boldsymbol W\boldsymbol B)^{-1}\boldsymbol B'\boldsymbol W)\boldsymbol x\);FWL:等价于加控制变量
IC / ICIR \(\mathrm{IC}_t=\mathrm{corr}(\boldsymbol x_t,\boldsymbol r_{t+1})\);\(t=\mathrm{ICIR}\sqrt T\)
基本定律 \(\mathrm{IR}\approx\mathrm{IC}\sqrt{\mathrm{BR}}\)(理想上限)
NW 方差 \(\frac1T[\hat\gamma_0+2\sum_{l\le L}(1-\frac{l}{L+1})\hat\gamma_l]\)
Fama–MacBeth 每期截面回归得 \(\hat b_t\);\(\bar b/(s_b/\sqrt T)\);等价于按时间聚类
衰减 \(\mathrm{IC}(h)\approx\mathrm{IC}(1)\phi^{h-1}\),半衰期 \(\ln0.5/\ln\phi\)
换手 \(\propto\sqrt{1-\rho}\),\(\rho\) 为信号一阶自相关
多重检验 Bonferroni/Holm 控制 FWER;BH 控制 FDR;\(\lvert t\rvert>3\)

练习

  1. 在 3.3 节中改用 \(\sqrt{\text{市值}}\) 加权中性化 quality,并用市值加权的十分组回测比较等权中性化与加权中性化的结果差异。
  2. 证明:只有行业哑变量时,OLS 中性化残差等于"减去行业均值";加入市值后不再成立。
  3. 在 3.4 节中计算 quality 在 6 个月重叠持有期下的 IC,比较 \(L=0,2,5,8\) 时的 NW t 值。\(L\) 取多大时结果趋于稳定?
  4. 修改模拟,让特质收益含有持续的个股成分(每只股票一个固定的月均收益偏离,标准差 0.5%),重做 3.6 节的标准误比较。哪种标准误会失效?FM 标准误还正确吗? 提示:参考第 05 册第 10 章 10.8.3 节的表格。
  5. 用 3.7 节的结论设计一个"部分调仓"规则:每月只把权重向目标调整 \(\kappa\) 比例。对 sentiment 和 quality 分别找出使扣成本后 Sharpe 最大的 \(\kappa\)。

延伸阅读

  • 第 05 册第 06 章 6.9 节(FWL 定理)、第 10 章 10.8 节(从面板回归到横截面因子回归)、第 16 章 16.4 节(HAC 标准误)。
  • 第 06 册第 09 章 9.3 节(BARRA 与 Fama–French 方法)。
  • 第 03 册第 10b 章(多重检验与策略过拟合)。
  • Fama, E. F., & MacBeth, J. D. (1973). Risk, Return, and Equilibrium: Empirical Tests. Journal of Political Economy, 81(3), 607–636.
  • Petersen, M. A. (2009). Estimating Standard Errors in Finance Panel Data Sets: Comparing Approaches. Review of Financial Studies, 22(1), 435–480.
  • Harvey, C. R., Liu, Y., & Zhu, H. (2016). … and the Cross-Section of Expected Returns. Review of Financial Studies, 29(1), 5–68.
  • Grinold, R. C., & Kahn, R. N. (2000). Active Portfolio Management (2nd ed.). McGraw-Hill.