量化交易中文教材

第 02 章 数据

数据环节决定了后面所有环节的上限。本章处理六件事:收益怎么算(简单与对数、复权)、面板数据怎么存(长表与宽表、交易日历)、样本怎么选(幸存者偏差)、信息什么时候可得(时点数据)、缺失与异常值怎么办、因子值怎么标准化。每一节都用合成数据演示"做错了会怎样",并给出可以直接放进项目的写法。结论先说:复权要用总收益口径,股票池要包含退市股,财报要按公告日对齐,截面数据先截尾再标准化;这四条做错任何一条,回测结果都可能面目全非。

学习目标

  1. 区分简单收益与对数收益的适用场合,会由除权除息信息计算复权因子,理解前复权与后复权的差别。
  2. 会在长表与宽表之间转换,按完整交易日历处理停牌、上市和退市,正确构造未来收益标签。
  3. 能量化幸存者偏差对平均收益和因子检验的影响,知道退市收益缺失的后果。
  4. 会用 merge_asof 按公告日构造时点(point-in-time)数据,理解报告期对齐造成的前视偏差有多大。
  5. 掌握 MAD、分位数、迭代 \(3\sigma\) 三种截尾方法和 z-score、秩正态化两种标准化方法,会识别典型的价格数据错误。

读前导读

这一章在解决什么问题。 这一章是量化版的"数据审计"。对应到你的工作场景:

  • 复权相当于做"可比口径调整"。你在做公司历史估值比较时,会把送转股之后的 EPS 追溯调整;复权做的是同一件事,只不过对象是股价。
  • 面板数据与停牌相当于编制合并报表时的"期间对齐":子公司的会计期间不一致、或某段时间没有数据,就不能简单地把相邻两条记录当成相邻两期。
  • 幸存者偏差你在 CFA 的基金业绩评价里见过:只统计还活着的基金,平均业绩被高估。这里是同一现象在股票上的版本,而且会"制造"出根本不存在的因子。
  • 时点数据对应审计中的"资产负债表日后事项"和"报出日"概念:信息在报告期结束时并不公开,要等到披露日。卖方分析师都知道"财报季"的价格跳跃发生在公告日,而不是季度末。
  • 异常值与标准化相当于数据清洗中的"合理性测试":单位错(元/万元)、小数点错,审计抽样时你一眼就能看出来,但在一千只股票的截面里需要规则化地处理。

本章的结论很朴素:四类数据问题(口径、结构、时点、质量)都很少让程序报错,却几乎总是让回测"更好看"。

需要先想起来的数学。

  1. 对数与指数:\(\ln(1+R)\) 把"乘法"变成"加法",\(\ln(ab)=\ln a+\ln b\)。所以多期复利 \(\prod(1+R_t)\) 取对数后变成 \(\sum\ln(1+R_t)\)。例:两天 +10%、−10%,简单收益相加为 0,但实际 \(1.1\times0.9=0.99\),亏 1%;对数收益 \(\ln1.1+\ln0.9=\ln0.99\approx-1.005\%\),正好对上。见 第 00 册第 04 章 级数与收敛 中 e 与指数对数部分。
  2. 泰勒展开的二阶近似:\(\ln(1+R)\approx R-\tfrac12R^2\)(\(R\) 很小时)。对两边取期望就得到"波动拖累" \(\mathbb E[r]\approx\mathbb E[R]-\tfrac12\mathrm{Var}(R)\)(严格说是 \(-\tfrac12\mathbb E[R^2]\),均值很小时近似等于方差)。这和你熟悉的"债券价格 ≈ 久期项 + ½ 凸性项"是同一种展开。见 第 00 册第 02 章 导数与泰勒展开。
  3. 中位数与 MAD:MAD(median absolute deviation)= 每个值与中位数之差的绝对值的中位数。它是"稳健版标准差":一千个数里有几个被乘了 100 倍,中位数和 MAD 几乎不动,但均值和标准差会被严重拉偏。
  4. 标准正态分位数函数 \(\Phi^{-1}\):给一个概率 \(p\),返回标准正态下"左边面积为 \(p\)"的点。例:\(\Phi^{-1}(0.975)=1.96\),就是你熟悉的 95% 双侧临界值。Excel 中为 NORM.S.INV。
  5. \(\mathbb E\) 记号:\(\mathbb E[X]\) 表示 \(X\) 的期望值(总体均值),与 CFA 里的 \(E(X)\) 是一回事。见 第 00 册第 07 章 概率中的分析工具。

怎么读这一章。 2.4 节(幸存者偏差)和 2.5 节(时点数据)最重要,它们演示了"数据错误制造出 t 值 6 到 9 的假因子",必读并建议运行代码。2.2.2 节复权和 2.6 节截尾是日常工具,读懂公式和结论即可。2.3 节的面板代码偏工程,第一次可以只看输出后的解读段落。建议顺序:2.1 → 2.4 → 2.5 → 2.2 → 2.6 → 2.3。


2.1 问题与动机

上一章的最小研究循环假设数据是干净的。真实数据有四类问题:口径(价格是否复权、收益是否含分红)、结构(停牌、上市、退市让面板参差不齐)、时点(数据库里的值是不是当时就能看到的值)、质量(缺失、错价、单位错误)。这四类问题的共同点是:它们很少让程序报错,只会悄悄改变结果,而且改变的方向通常是"让回测更好看"。

2.2 价格与收益

2.2.1 简单收益与对数收益

记 \(P_t\) 为(复权后的)价格,简单收益 \(R_t=P_t/P_{t-1}-1\),对数收益 \(r_t=\ln(1+R_t)\)。定义与性质的推导见第 06 册第 01 章 1.2 节,这里只强调工程上的两条规则:

  • 时间上累加用对数收益:\(\ln(P_T/P_0)=\sum_t r_t\)。多期收益 \(\prod(1+R_t)-1\) 不等于 \(\sum R_t\)。
  • 截面上加权用简单收益:组合收益 \(R_{p,t}=\sum_i w_iR_{i,t}\) 精确成立,而 \(\sum_i w_i r_{i,t}\) 不是组合的对数收益(第 06 册第 01 章 1.2.5 节)。

两者的均值相差约半个方差:\(\mathbb E[r]\approx\mathbb E[R]-\tfrac12\mathrm{Var}(R)\),即"波动拖累"。日频下单只股票的差别不大,但年化后可达一两个百分点;高波动资产的算术平均收益会系统性高估长期持有的复合收益。

2.2.2 复权

交易所公布的是原始价格。分红、送转、配股发生时,除权除息日的原始价格会机械地下跳,这一跳不是投资者的损失。复权就是把这些机械跳动去掉,使价格序列的收益等于持有者的真实收益。

设除权日 \(t\) 的前收盘价为 \(P_{t-1}\),每股现金分红 \(D_t\),送转比例为 \(s_t\)(10 转 10 时 \(s_t=2\)),则除权参考价为

\[P^{\text{ref}}_t=\frac{P_{t-1}-D_t}{s_t},\qquad a_t=\frac{P_{t-1}}{P^{\text{ref}}_t},\]

非除权日 \(a_t=1\)。累计因子 \(A_t=\prod_{u\le t}a_u\)(\(\prod\) 是连乘符号,与求和符号 \(\sum\) 对应)。后复权价 \(P_tA_t\) 以上市首日为基准,前复权价 \(P_tA_t/A_T\) 以最新一天为基准。两者相差一个常数倍,所以收益完全相同;区别在价格水平:

  • 前复权价在每次新除权后所有历史值都会改变。任何依赖价格水平的规则("股价低于 5 元剔除"、价格整数关口、按价格水平设止损)用前复权价回测,都用到了未来的分红信息。
  • 后复权价的历史值不变,但与当前实际成交价差很远,不能直接用来计算成交股数。

推导拆解:以 10 转 10 为例,用数字走一遍。前收盘 \(P_{t-1}=20\) 元,无分红 \(D_t=0\),\(s_t=2\)。除权参考价 \(P^{\text{ref}}_t=20/2=10\) 元,单步因子 \(a_t=20/10=2\)。如果除权日实际收盘 10.2 元,原始价格"收益"是 \(10.2/20-1=-49\%\),看起来腰斩;但乘上累计因子后,复权价为 \(10.2\times2=20.4\),复权收益 \(20.4/20-1=+2\%\),这才是持有者的真实收益(他手里股数翻倍了)。 分红的情形同理:\(P_{t-1}=20\),每股派 0.5 元,参考价 \(19.5\),\(a_t=20/19.5\approx1.0256\)。含义是:除息日价格下跌 0.5 元不是损失,复权把它"加回来",等价于假设这 0.5 元按 19.5 元立即买回股票。 前复权与后复权只差一个常数 \(A_T\):后复权以上市首日为 1,前复权以今天为 1。常数倍不影响收益率(分子分母同乘一个数),但改变价格水平。由于 \(A_T\) 每逢新除权就变,前复权的整段历史价格也跟着变。

工程上的建议是:存原始价格和累计复权因子两列,需要收益时用后复权价,需要价格水平时用原始价格,需要和当前价比较时临时计算前复权价。

上面的复权方法(等比复权)隐含"分红在除息日按参考价再投资"。真实持有者是在派息日收到现金,两者有微小差别,下面的代码会显示出来。

import numpy as np
import pandas as pd

rng = np.random.default_rng(7)
T = 500
dates = pd.bdate_range('2022-01-03', periods=T)
true_tr = rng.standard_t(4, T) * 0.012 / np.sqrt(2) + 0.0003    # 真实的日总收益(含分红再投资)

# 公司行为:第 120、370 天除息(每股派 0.5 元),第 200 天 10 转 10(1 股变 2 股)
div = pd.Series(0.0, index=dates); div.iloc[[120, 370]] = 0.5
split = pd.Series(1.0, index=dates); split.iloc[200] = 2.0

# 由真实总收益反推交易所公布的"原始收盘价":除权日价格按 (P_prev*(1+r) - D) / split 变动
raw = np.empty(T); raw[0] = 20.0
for t in range(1, T):
    raw[t] = (raw[t-1] * (1 + true_tr[t]) - div.iloc[t]) / split.iloc[t]
raw = pd.Series(raw, index=dates)

# 复权因子:除权日的"除权参考价" = (前收盘 - 每股分红) / 送转比例;因子 = 前收盘 / 参考价
prev = raw.shift(1)
ref = (prev - div) / split
adj_step = (prev / ref).fillna(1.0)          # 非除权日为 1
cum = adj_step.cumprod()
back_adj = raw * cum                          # 后复权:以上市首日为基准,历史价格不变、新价格放大
fwd_adj = raw * cum / cum.iloc[-1]            # 前复权:以最新一天为基准,历史价格被"压低"

r_raw = raw.pct_change()
r_back = back_adj.pct_change()
r_fwd = fwd_adj.pct_change()
print('除权日原始价格收益:', r_raw.iloc[[120, 200, 370]].round(4).tolist())
print('除权日复权收益    :', r_back.iloc[[120, 200, 370]].round(4).tolist())
print('真实总收益        :', np.round(true_tr[[120, 200, 370]], 4).tolist())
print('后复权与前复权收益最大差:', f'{(r_back - r_fwd).abs().max():.2e}',
      ';与真实总收益最大差:', f'{(r_back - true_tr).iloc[1:].abs().max():.2e}')

# 前复权的副作用:每次新除权,全部历史价格都会改变
fwd_then = raw.iloc[:370] * cum.iloc[:370] / cum.iloc[369]
print(f'第 100 天的前复权价:第 369 天看到 {fwd_then.iloc[100]:.3f},'
      f'第 499 天看到 {fwd_adj.iloc[100]:.3f}(原始价 {raw.iloc[100]:.3f})')

# 简单收益与对数收益:时间上对数收益可加,截面上简单收益可加
lr = np.log1p(r_back.iloc[1:])
print(f'累计收益:∏(1+r)-1 = {np.prod(1 + r_back.iloc[1:]) - 1:.4f},exp(Σlog)-1 = {np.expm1(lr.sum()):.4f},'
      f'Σr = {r_back.iloc[1:].sum():.4f}(错误)')
rb = pd.Series(rng.standard_t(4, T - 1) * 0.02 / np.sqrt(2), index=lr.index)
w = np.array([0.5, 0.5])
port_simple = w[0] * r_back.iloc[1:] + w[1] * rb
port_logavg = np.expm1(w[0] * lr + w[1] * np.log1p(rb))
print(f'两资产等权组合的日均收益:用简单收益加权 {port_simple.mean():.5f},'
      f'用对数收益加权再换回 {port_logavg.mean():.5f}(低估)')
m, s2 = r_back.iloc[1:].mean(), r_back.iloc[1:].var()
print(f'算术均值 {m*252:.3%}/年,几何均值 {lr.mean()*252:.3%}/年,近似 μ-σ²/2 = {(m - s2/2)*252:.3%}/年')

输出:

除权日原始价格收益: [-0.035, -0.4972, -0.0782]
除权日复权收益    : [-0.0004, 0.0056, -0.0039]
真实总收益        : [-0.0003, 0.0056, -0.0036]
后复权与前复权收益最大差: 2.22e-16 ;与真实总收益最大差: 2.93e-04
第 100 天的前复权价:第 369 天看到 7.010,第 499 天看到 6.487(原始价 14.524)
累计收益:∏(1+r)-1 = -0.3043,exp(Σlog)-1 = -0.3043,Σr = -0.3326(错误)
两资产等权组合的日均收益:用简单收益加权 -0.00071,用对数收益加权再换回 -0.00077(低估)
算术均值 -16.799%/年,几何均值 -18.324%/年,近似 μ-σ²/2 = -18.319%/年

除权日的原始价格"收益"分别是 −3.5%、−49.7%、−7.8%,其中 10 转 10 那天几乎腰斩;复权后都变回了正常的日内波动。后复权与前复权收益的差只有 \(2\times10^{-16}\)(浮点误差)。与真实总收益的最大差 \(2.9\times10^{-4}\) 来自上面说的再投资约定:等比复权假设分红在前收盘价减去分红后的价格再投资,与"拿到现金"不完全相同,日频研究中可以忽略。前复权的副作用很明显:同一个第 100 天,站在第 369 天看是 7.010 元,站在第 499 天看是 6.487 元,而当时实际成交价是 14.524 元。

白话解释:这段代码的设计是"先有真相,再造账本,最后看能不能还原真相"。第一步给定真实总收益 true_tr(持有者真正赚到的);第二步按公司行为倒推交易所会公布的原始价格 raw(这就是"账本",里面有除权造成的机械跳水);第三步只用 raw、分红和送转信息算复权因子,看复权后的收益能否还原 true_tr。结果误差只有万分之三,说明复权公式正确。 最后三行是"波动拖累"的数值验证:算术均值 −16.8%/年,几何均值 −18.3%/年,二者之差约等于 \(\tfrac12\sigma^2\)。这就是为什么基金宣传用算术平均、而投资者实际拿到的是几何平均——高波动产品两者差得更多。

金融直觉:为什么"股价低于 5 元剔除"用前复权价会偷看未来?假设一只股票今天前复权后历史价格是 4 元,但这是因为它后来多次高送转、分红,被"压低"了;当年实际成交价可能是 14 元。站在当年,没人会因为"低于 5 元"把它剔除。这和你在审计中不能用今天的汇率重估历史交易是一个道理:规则必须用当时的口径。

2.3 横截面与面板数据结构

股票数据天然是面板:维度是(日期,资产),第 05 册第 10 章讨论了面板回归的统计性质。工程上有两种存法:

  • 长表:每行一个(日期,资产)观测,MultiIndex 或两列键。适合存储、合并基本面数据、做面板回归。没有记录的组合(停牌、未上市)就是"没有这一行"。
  • 宽表:行为日期、列为资产。适合截面运算(标准化、排序)和矩阵运算(组合收益 = 权重矩阵与收益矩阵逐元素相乘再按行求和)。没有记录的组合显示为 NaN。

两者转换用 unstack/stack。关键一步是:宽表要 reindex 到完整的交易日历,这样停牌才显示为 NaN,"相邻两行"才等于"相邻两个交易日"。

还要区分三种 NaN:未上市、已退市、停牌。前两种表示"不在股票池",第三种表示"在池中但不可交易"。停牌股票在截面标准化时通常剔除,在组合中则不能调仓(持仓冻结);把它们混为一谈会让回测在停牌股上"成交"。

import numpy as np
import pandas as pd

rng = np.random.default_rng(11)
cal = pd.bdate_range('2023-01-02', periods=250)          # 交易日历(这里简化为工作日)
N = 400
ipo = rng.integers(0, 200, N); ipo[:300] = 0             # 300 只一开始就在,100 只陆续上市
life = rng.integers(60, 400, N)
delist = np.minimum(ipo + life, 250); delist[rng.random(N) < 0.85] = 250   # 约 15% 期间内退市

rows = []
for i in range(N):
    days = cal[ipo[i]:delist[i]]
    keep = rng.random(len(days)) > 0.02                   # 2% 的日子随机停牌(没有记录)
    if rng.random() < 0.05:                               # 5% 的股票有一段 10 天的长停牌
        k = rng.integers(0, max(1, len(days) - 10)); keep[k:k+10] = False
    r = rng.standard_t(4, len(days)) * 0.015              # 停牌期间公司价值照样变化,复牌时一次性体现
    close = 10 * np.exp(np.cumsum(r))
    rows.append(pd.DataFrame({'date': days[keep], 'asset': f'S{i:03d}', 'close': close[keep]}))
long = pd.concat(rows).set_index(['date', 'asset']).sort_index()
print('长表行数', len(long), ';示例:'); print(long.head(3))

# 长表 -> 宽表,并 reindex 到完整交易日历:缺失 = 未上市 / 已退市 / 停牌
wide = long['close'].unstack('asset').reindex(cal).rename_axis('date')
listed = pd.DataFrame(False, index=cal, columns=wide.columns)   # 是否处于上市期
for c in wide.columns:
    first, last = wide[c].first_valid_index(), wide[c].last_valid_index()
    listed.loc[first:last, c] = True
suspended = listed & wide.isna()
print(f'\n宽表形状 {wide.shape};上市中的格子 {listed.values.sum()},其中停牌 {suspended.values.sum()}')

# 正确的 1 日收益:只有相邻两个交易日都有价格才有定义
ret1 = wide.pct_change(fill_method=None)
# 常见错误:在长表里按"上一条记录"算收益,停牌前后被当成相邻交易日
ret_rowwise = long.groupby('asset')['close'].pct_change().unstack('asset').reindex(cal).rename_axis('date')
gap = ret_rowwise.notna() & ret1.isna()
print(f'"按行"算出、但实际跨越停牌的收益条数:{gap.values.sum()},'
      f'其绝对值均值 {ret_rowwise[gap].abs().stack().mean():.4f} vs 正常日 {ret1.abs().stack().mean():.4f}')

# 未来 5 日收益标签:在日历上前移,并要求未来 5 天都上市(否则视为缺失或单独处理退市收益)
fwd5 = wide.shift(-5) / wide - 1
print(f'可计算未来 5 日收益的格子 {fwd5.notna().values.sum()},'
      f'因停牌/退市无法计算的上市格子 {(listed & fwd5.isna()).values.sum()}')

# 每日截面股票数:做截面标准化与 IC 时,"有效样本数"随时间变化
n_cs = ret1.notna().sum(axis=1)
print(f'每日有效截面样本数:最少 {n_cs.iloc[1:].min()},最多 {n_cs.max()},'
      f'首日 {listed.iloc[0].sum()},末日 {listed.iloc[-1].sum()}')

# 回到长表:stack 默认丢掉 NaN,正好得到"有定义的观测"
panel = pd.DataFrame({'ret1': ret1.stack(), 'fwd5': fwd5.stack()})
print('\n面板(长表)前 3 行:'); print(panel.dropna().head(3))

输出:

长表行数 85563 ;示例:
                      close
date       asset           
2023-01-02 S000   10.242987
           S001    9.951158
           S002   10.460434

宽表形状 (250, 400);上市中的格子 87508,其中停牌 1945
"按行"算出、但实际跨越停牌的收益条数:1724,其绝对值均值 0.0230 vs 正常日 0.0150
可计算未来 5 日收益的格子 81845,因停牌/退市无法计算的上市格子 5663
每日有效截面样本数:最少 284,最多 373,首日 293,末日 367

面板(长表)前 3 行:
                      ret1      fwd5
date       asset                    
2023-01-03 S000  -0.014975 -0.006181
           S001   0.001471 -0.109409
           S003  -0.007994  0.049861

1724 条"按行"计算的收益实际上跨越了停牌,平均绝对值 2.3%,明显大于正常日的 1.5%,因为停牌期间累积的价值变化在复牌日一次性释放。如果把它们当作 1 日收益,就会高估波动、扭曲动量与反转信号。A 股的长期停牌与复牌后的连续涨跌停是这一问题的极端形式。另一个要注意的数字是每日截面样本数在 284 到 373 之间变化:截面统计量(IC、分组收益)的精度随之变化,本册第 03 章计算 IC 的 t 值时应意识到这一点。

白话解释:代码的关键设计是"停牌期间公司价值照样在变":先对每个交易日都生成收益并累乘出价格,再随机删掉 2% 的记录模拟停牌。这和现实一致——停牌只是市场关门,公司基本面不会暂停。于是复牌日的价格变化包含了好几天的累积变动。长表按"上一条记录"算收益时,groupby('asset').pct_change() 不知道中间缺了几天,就把多日收益当作 1 日收益;宽表先 reindex 到完整日历,停牌日变成 NaN,pct_change(fill_method=None) 遇到 NaN 就不算,从而把这些跨期收益显式暴露出来,留给你决定怎么处理。 代码里的 listed 矩阵用"第一个有效值到最后一个有效值"来判断上市期,suspended = listed & wide.isna() 就是"在上市期内却没有价格",即停牌。这正是正文说的三种 NaN 的区分方法。

2.4 幸存者偏差

如果股票池是"今天还在交易的股票",过去 10 年里退市的公司就从历史中消失了。退市有两类:业绩型退市(亏损、股价过低、财务造假),退市前后损失惨重;并购型退市,常伴随溢价。前者在数量上通常占主导。只看存活股票,相当于在每个历史时点都知道"这家公司以后不会倒闭"。

还有一个较轻的版本:股票池包含退市股,但退市当月的收益缺失。很多数据库在股票摘牌后不再有价格,最后一笔损失就没有记录。Shumway (1997) 在美国数据中发现这会显著高估小盘股收益。

import numpy as np
import pandas as pd

rng = np.random.default_rng(2024)
T, N0, n_ipo = 120, 1500, 10                    # 120 个月;期初 1500 只,每月新上市 10 只
N = N0 + n_ipo * T
start = np.r_[np.zeros(N0, int), np.repeat(np.arange(T), n_ipo)]
vol = rng.uniform(0.05, 0.15, N)                # 月特质波动 5%–15%
beta = rng.normal(1, 0.25, N)
mkt = 0.006 + 0.045 * rng.standard_t(5, T) / np.sqrt(5 / 3)

ret = np.full((T, N), np.nan)                   # 包含退市当月收益的"完整"数据
ret_db = np.full((T, N), np.nan)                # 常见数据库:退市当月收益缺失
alive = np.zeros((T, N), bool)
px = np.full(N, 10.0); dead = np.zeros(N, bool)
for t in range(T):
    act = (start <= t) & ~dead
    r = beta[act] * mkt[t] + vol[act] * rng.standard_t(4, act.sum()) / np.sqrt(2)
    r = np.maximum(r, -0.95)
    idx = np.where(act)[0]
    px[idx] *= 1 + r
    ret[t, idx] = r; ret_db[t, idx] = r; alive[t, idx] = True
    # 退市:价格跌破 1 元(业绩型退市,退市月再损失 30%),或被并购(溢价 25%,每月 0.1% 概率)
    fail = idx[px[idx] < 1.0]
    mna = idx[(rng.random(len(idx)) < 0.001) & (px[idx] >= 1.0)]
    if t + 1 < T:
        ret[t+1, fail] = -0.3; ret[t+1, mna] = 0.25          # 退市月收益
        alive[t+1, np.r_[fail, mna]] = True                    # 退市当月仍在样本里
    dead[np.r_[fail, mna]] = True

R = pd.DataFrame(ret); Rdb = pd.DataFrame(ret_db)
survivor = ~dead                                                # "今天还活着"的股票
print(f'总股票数 {N},期间退市 {dead.sum()}(其中业绩型 {(px < 1).sum()}),期末存活 {survivor.sum()}')

ew_full = R.mean(axis=1)                                         # 含退市收益
ew_db = Rdb.mean(axis=1)                                         # 退市收益缺失
ew_surv = R.loc[:, survivor].mean(axis=1)                        # 只用今天的存活股
for name, x in [('全样本(含退市收益)', ew_full), ('退市收益缺失', ew_db), ('仅存活股', ew_surv)]:
    print(f'{name:<14s} 等权月均收益 {x.mean():.3%},年化约 {x.mean()*12:.2%}')

# 幸存者偏差制造的"伪因子":真实模型中没有反转效应,也没有波动率溢价
past12 = np.log1p(R.fillna(0)).rolling(12).sum().where(R.notna())
def mean_rank_ic(sig, fwd, mask):
    s = sig.where(mask); f = fwd.where(mask)
    ic = s.corrwith(f, axis=1, method='spearman').dropna()
    return ic.mean(), ic.mean() / ic.std() * np.sqrt(len(ic))
fwd = R.shift(-1)
full_mask = R.notna() & fwd.notna()
surv_mask = full_mask & pd.DataFrame(np.tile(survivor, (T, 1)))
vol_sig = pd.DataFrame(np.tile(vol, (T, 1)))
for name, sig in [('反转(-过去12月收益)', -past12), ('高波动', vol_sig)]:
    a = mean_rank_ic(sig, fwd, full_mask); b = mean_rank_ic(sig, fwd, surv_mask)
    print(f'{name:<12s} RankIC 全样本 {a[0]:+.4f} (t={a[1]:+.2f})   仅存活股 {b[0]:+.4f} (t={b[1]:+.2f})')

输出:

总股票数 2700,期间退市 484(其中业绩型 257),期末存活 2216
全样本(含退市收益)     等权月均收益 0.275%,年化约 3.30%
退市收益缺失         等权月均收益 0.284%,年化约 3.40%
仅存活股           等权月均收益 0.484%,年化约 5.81%
反转(-过去12月收益) RankIC 全样本 -0.0059 (t=-2.16)   仅存活股 +0.0050 (t=+1.67)
高波动          RankIC 全样本 -0.0004 (t=-0.20)   仅存活股 +0.0163 (t=+6.25)

三点结论:

  1. 平均收益被高估。 只用期末存活的股票,等权年化收益从 3.30% 变成 5.81%,高估约 2.5 个百分点。漏掉退市收益的影响较小(约 0.1 个百分点),但方向同样是高估。
  2. 制造伪因子。 真实模型中没有任何"高波动溢价":全样本 RankIC 为 −0.0004,t 值 −0.20。但在存活样本中,RankIC 变成 0.0163,t 值 6.25,完全可以写成一篇"论文"。原因是:高波动股票更容易跌破退市线,留下来的高波动股票恰好是那些运气好、涨上去的。
  3. 因子方向可能反转。 "过去 12 个月输家"在全样本中表现为弱动量(输家后来退市,又损失 30%),在存活样本中却呈现弱反转。

白话解释:为什么"高波动"在存活样本里会凭空有效?代码的模拟机制是:每只股票的收益只是 β × 大盘 + 噪声,没有任何波动率溢价。但"价格跌破 1 元就退市"是一道筛子。高波动股票的价格路径更分散:一部分大涨,一部分大跌。大跌的那部分跌破 1 元被淘汰,大涨的那部分留下来。期末只看幸存者,就只看到了高波动股票中"运气好"的那一半,于是高波动和高收益显得正相关。 这与 CFA 里讲的"基金数据库幸存者偏差"完全同构:激进型基金(高波动)更容易清盘,留在数据库里的激进型基金平均业绩就被高估了。代码中 surv_mask 用的是期末存活状态去筛选整个历史,这正是"在历史时点使用了未来信息"——你在 2015 年不可能知道哪些公司能活到 2025 年。

金融直觉:反转因子方向翻转也是同一机制。在全样本中,过去 12 个月的输家后来更容易退市并再亏 30%,所以"买输家"是亏的(表现为动量);去掉那些退市的输家,剩下的输家是挺过来的,自然显得会"反弹"。这提醒你:读到"A 股某某因子有效"的研究报告时,第一个问题应当是"股票池包含退市股吗?"

解决办法只有一个:使用包含历史上全部股票的数据库,并在每个时点用当时的股票池。如果研究的是指数成分股,还要使用历史成分股名单,而不是今天的成分股。

2.5 时点数据

财务数据有三个日期:报告期(如 3 月 31 日)、公告日(如 4 月 25 日)、数据库录入或修订日。研究中必须用"在决策时刻已经公告的最新数据"。两个常见错误:

  • 按报告期对齐:3 月 31 日就"知道"了一季报。A 股一季报要求在 4 月底前披露、年报在次年 4 月底前披露,滞后最长可达四个月。
  • 使用修订后的数据:公司重述财报或数据商修正错误后,数据库通常只保留最新值。用最新值回测,相当于在历史上就看到了更正后的数字。宏观数据(GDP、就业)的修订幅度往往比财报更大。

时点数据(PIT)的标准做法是给每条记录保存"何时可得"的日期,查询时用 merge_asof(按资产分组、取可得日期不晚于决策日的最近一条)。

下面的模拟中,盈利意外(SUE)在公告日引起价格跳跃,公告后还有一段小幅漂移(PEAD)。按报告期对齐的信号会"预测"到公告日的跳跃,按公告日对齐的信号只能捕获漂移。

import numpy as np
import pandas as pd

rng = np.random.default_rng(99)
N, T = 500, 1008                                   # 500 只股票,1008 个交易日(4 年)
cal = pd.bdate_range('2020-01-01', periods=T)
q_end_idx = np.arange(62, T, 63)                   # 每 63 个交易日一个"季度末"

# 基本面表:每只股票每季度一条记录,公告滞后 20–60 个交易日
recs, jump = [], np.zeros((T, N)); drift = np.zeros((T, N))
for qi in q_end_idx:
    sue = rng.standard_normal(N)                    # 标准化盈利意外(SUE)
    lag = rng.integers(20, 61, N)
    for i in range(N):
        a = qi + lag[i]
        if a >= T:
            continue
        recs.append((f'S{i:03d}', cal[qi], cal[a], sue[i]))
        jump[a, i] += 0.02 * sue[i]                 # 公告日价格跳跃:1 个标准差意外 ≈ 2%
        drift[a+1:a+41, i] += 0.004 * sue[i] / 40   # 公告后 40 天的漂移(PEAD),合计 0.4%
fund = pd.DataFrame(recs, columns=['asset', 'period_end', 'ann_date', 'sue'])
ret = 0.02 * rng.standard_t(4, (T, N)) / np.sqrt(2) + jump + drift
R = pd.DataFrame(ret, index=cal, columns=[f'S{i:03d}' for i in range(N)])

# 每 21 天调仓一次;标签 = 调仓日之后 21 个交易日的累计收益
reb = cal[np.arange(100, T - 21, 21)]
logR = np.log1p(R)
fwd = pd.DataFrame({d: logR.iloc[cal.get_loc(d)+1: cal.get_loc(d)+22].sum() for d in reb}).T

grid = pd.MultiIndex.from_product([reb, R.columns], names=['date', 'asset']).to_frame(index=False)
grid = grid.sort_values('date')

def asof_signal(key):
    """对每个(调仓日,股票),取 key 列 <= 调仓日 的最近一条记录。"""
    f = fund.sort_values(key)
    m = pd.merge_asof(grid, f[['asset', key, 'sue']], left_on='date', right_on=key, by='asset')
    return m.pivot(index='date', columns='asset', values='sue')

sig_naive = asof_signal('period_end')               # 错误:报告期一结束就"知道"了
sig_pit = asof_signal('ann_date')                   # 正确:公告日收盘后才知道

for name, sig in [('按报告期对齐', sig_naive), ('按公告日对齐(PIT)', sig_pit)]:
    ic = sig.corrwith(fwd, axis=1, method='spearman').dropna()
    print(f'{name:<14s} 平均 RankIC {ic.mean():.4f},t 值 {ic.mean()/ic.std()*np.sqrt(len(ic)):6.2f},'
          f'IC>0 的比例 {(ic > 0).mean():.0%}')

# 差多少?在报告期对齐下,有多大比例的信号在调仓日其实"尚未公布"
m = pd.merge_asof(grid, fund.sort_values('period_end'), left_on='date', right_on='period_end', by='asset')
print(f'按报告期对齐时,{(m["ann_date"] > m["date"]).mean():.1%} 的信号在调仓日尚未公告')

输出:

按报告期对齐         平均 RankIC 0.0905,t 值   9.59,IC>0 的比例 91%
按公告日对齐(PIT)    平均 RankIC 0.0076,t 值   1.12,IC>0 的比例 58%
按报告期对齐时,52.2% 的信号在调仓日尚未公告

按报告期对齐时,有一半以上的信号在调仓日其实还没有公告,RankIC 0.091、t 值 9.6,几乎每个月都赚钱。按公告日对齐后,RankIC 只有 0.008、t 值 1.1,这才是这个模拟世界中可以实际获得的部分(公告后漂移)。两者相差十倍以上,而按报告期对齐的结果看起来并不离谱——IC 0.09 在真实研究中不算罕见的高值,这正是它危险的地方。

白话解释:代码在模拟一个典型的"盈利意外"事件研究。每个季度末,每只股票有一个 SUE(标准化盈利意外,即"实际 EPS 比一致预期高出几个标准差"),但它要等 20–60 个交易日后才公告。公告当天股价跳 \(2\%\times\)SUE(市场立刻反应),之后 40 天再缓慢漂移合计 \(0.4\%\times\)SUE(市场反应不足,即 PEAD)。真正可交易的只有那 0.4% 的漂移。 asof_signal('period_end') 让信号在季度末就"可得",于是它能提前持仓、吃到公告日 2% 的跳跃——这部分在现实中只有内幕人士能拿到。asof_signal('ann_date') 只在公告后才持有,只能吃到漂移。两者 IC 相差十倍,差额全部来自时点错误。 注意 merge_asof(..., by='asset'):by 参数保证每只股票只和自己的财报匹配,等价于第 01 章强调的"长表时间运算先按资产分组"。

如果没有公告日数据,退而求其次的做法是按监管规定的最晚披露期限对齐(例如一季报统一在 5 月初才可用),代价是损失一部分时效。

2.6 缺失值、异常值与标准化

2.6.1 缺失值

缺失的原因决定处理方式:

  • 结构性缺失(未上市、已退市):不在股票池,不填。
  • 停牌:价格不填(不能假装可以成交);收益在复牌日按跨期收益处理或单独剔除。
  • 因子值缺失(新股没有 12 个月历史、公司未披露某项目):截面标准化之后填 0(即截面均值),或用行业中位数填。不能用其他股票的值,也不能用未来的值(本册第 01 章的 bfill 陷阱)。
  • 财务数据的前向填充要设上限:上一期财报最多沿用到下一期法定披露截止日,之后应视为缺失。

2.6.2 异常值与截尾

截面因子值常有两类极端值:真实的极端(厚尾分布本身产生的)和数据错误(单位错、小数点错、复权错)。二者都会在截面回归和 Pearson 相关中占据过大的权重。常用三种截尾方法:

  • MAD 截尾:\(\tilde x=\mathrm{clip}\big(x,\ \mathrm{med}-k\cdot1.4826\,\mathrm{MAD},\ \mathrm{med}+k\cdot1.4826\,\mathrm{MAD}\big)\),常取 \(k=3\) 到 5。中位数和 MAD 本身不受极端值影响,这是它的最大优点;\(1.4826\) 使正态分布下 \(1.4826\,\mathrm{MAD}\) 等于标准差(MAD 的统计性质见第 03 册第 21 章 21.5 节)。
  • 分位数截尾:把低于 1% 分位、高于 99% 分位的值拉回分位点。简单,但无论有无异常值都会改动 2% 的观测。
  • 迭代 \(3\sigma\) 截尾:用均值和标准差,但一次截尾时异常值已经拉大了标准差,需要迭代几次。

截尾之后再做标准化:

  • z-score:\(z=(x-\bar x)/s_x\),保留了原始数值的相对距离。
  • 秩正态化:先求截面百分位秩,再用标准正态分位数函数变换。它对任何单调变换不变,天然消除了异常值,但丢掉了"距离"信息。

推导拆解:MAD 截尾里的 1.4826 从哪来?对标准正态分布,\(|X|\) 的中位数是满足 \(P(|X|\le m)=0.5\) 的 \(m\),即 \(P(X\le m)=0.75\),\(m=\Phi^{-1}(0.75)\approx0.6745\)。所以正态数据的 MAD \(\approx0.6745\sigma\),反过来 \(\sigma\approx\mathrm{MAD}/0.6745=1.4826\,\mathrm{MAD}\)。乘上这个系数,"\(k\) 倍 MAD"就和"\(k\) 倍标准差"含义一致。 数值例:某天 1000 只股票的 PB,中位数 2.0,MAD 0.5,则稳健标准差 \(\approx0.74\);取 \(k=5\),截尾区间为 \([2.0-3.7,\ 2.0+3.7]=[-1.7,\ 5.7]\)。某只股票因单位错误 PB 显示为 200,就被拉回 5.7。若用普通均值和标准差,这个 200 会把标准差拉大到 6 以上,截尾区间宽到形同虚设——这就是正文说的 \(3\sigma\) 截尾需要迭代的原因。

白话解释:秩正态化两步走。第一步把因子值换成百分位排名(最小的接近 0,最大的接近 1),代码里减去 \(0.5/n\) 是为了避免出现正好等于 1 的排名(\(\Phi^{-1}(1)=\infty\))。第二步把排名送进 \(\Phi^{-1}\),变成"如果这是正态分布,这个排名对应几个标准差"。结果无论原始分布多偏、多厚尾,输出总是整齐的钟形。代价是:原来"高出一倍"和"高出十倍"的区别被抹掉了,只保留先后顺序。

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

rng = np.random.default_rng(5)
T, N = 120, 1000

# 真实暴露 x 厚尾(t(3));收益只对 x 的"温和部分"有反应(极端值并不带来成比例的收益)
x_true = rng.standard_t(3, (T, N))
ret = 0.004 * np.clip(x_true, -2.5, 2.5) + 0.08 * rng.standard_t(4, (T, N)) / np.sqrt(2)
# 数据错误:0.3% 的观测单位错 100 倍(如"元"与"百元"),另有 1% 缺失
x_obs = x_true.copy()
bad = rng.random((T, N)) < 0.003
x_obs[bad] *= 100
x_obs[rng.random((T, N)) < 0.01] = np.nan
X = pd.DataFrame(x_obs); Y = pd.DataFrame(ret)

def mad_clip(row, k=5.0):
    med = row.median(); mad = (row - med).abs().median() * 1.4826     # 1.4826:正态下 MAD→σ
    return row.clip(med - k * mad, med + k * mad)

def sigma_clip(row, k=3.0, iters=3):
    for _ in range(iters):                                             # 迭代:极端值先拉大 σ
        row = row.clip(row.mean() - k * row.std(), row.mean() + k * row.std())
    return row

def pct_clip(row, q=0.01):
    return row.clip(row.quantile(q), row.quantile(1 - q))

def zscore(row):
    return (row - row.mean()) / row.std()

def rank_gauss(row):                                                   # 秩 -> 正态分位数
    r = row.rank(pct=True) - 0.5 / row.notna().sum()
    return pd.Series(norm.ppf(r), index=row.index)

methods = {
    '不处理 z-score':    lambda r: zscore(r),
    '3σ 迭代截尾':       lambda r: zscore(sigma_clip(r)),
    '1%/99% 分位截尾':   lambda r: zscore(pct_clip(r)),
    'MAD 5 倍截尾':      lambda r: zscore(mad_clip(r)),
    '秩→正态分位数':     lambda r: rank_gauss(r),
}
rows = []
for name, f in methods.items():
    Z = X.apply(f, axis=1)
    ic = Z.corrwith(Y, axis=1)                                         # Pearson IC
    slope = (Z * Y).sum(axis=1) / (Z**2).sum(axis=1)                   # 每期截面回归斜率(无截距,Z 已去均值)
    rows.append([name, ic.mean(), ic.mean() / ic.std() * np.sqrt(T), slope.mean() * 1e4, slope.std() * 1e4,
                 Z.abs().max().max()])
out = pd.DataFrame(rows, columns=['方法', 'IC 均值', 'IC t值', '斜率均值(bp)', '斜率标准差(bp)', '最大|z|'])
print(out.round(4).to_string(index=False))

# 截面填补缺失:用当期截面中位数(标准化后即 0),而不是用其他股票或未来的数据
Zf = X.apply(lambda r: zscore(mad_clip(r)), axis=1).fillna(0.0)
print(f'\n缺失比例 {X.isna().values.mean():.2%};填 0 后每期 z 的均值范围 '
      f'[{Zf.mean(axis=1).min():.3f}, {Zf.mean(axis=1).max():.3f}]')

# 价格数据错误的识别:单日暴涨暴跌且次日几乎完全反转
p = pd.Series(10 * np.exp(np.cumsum(rng.normal(0, 0.015, 300))))
p.iloc[150] *= 10                                                      # 小数点错位一天
r = p.pct_change()
flag = (r.abs() > 0.3) & (r.shift(-1).abs() > 0.3) & ((1 + r) * (1 + r.shift(-1)) - 1).abs().lt(0.10)
print('疑似错价的日期索引:', list(r.index[flag]))

输出:

         方法  IC 均值   IC t值  斜率均值(bp)  斜率标准差(bp)   最大|z|
不处理 z-score 0.0221  6.4725   17.4970    29.3861 31.1476
    3σ 迭代截尾 0.0589 20.2062   46.8163    25.6275  3.9821
1%/99% 分位截尾 0.0590 20.1907   46.9015    25.6787  4.4214
  MAD 5 倍截尾 0.0578 19.6710   45.9431    25.8121  4.0845
    秩→正态分位数 0.0601 21.7309   47.7418    24.2325  3.2900

缺失比例 0.97%;填 0 后每期 z 的均值范围 [-0.000, 0.000]
疑似错价的日期索引: [150]

只有 0.3% 的观测有单位错误,但不处理时 Pearson IC 从约 0.059 掉到 0.022,回归斜率只剩约三分之一,截面上最大 \(|z|\) 达到 31。四种稳健处理的结果彼此接近,秩正态化略好,因为本例中收益本来就只对 \(x\) 的"温和部分"有反应。

白话解释:为什么 0.3% 的坏数据能让 IC 掉一大半?Pearson 相关和回归斜率都是"平方加权"的:一个值被放大 100 倍,它对分母 \(\sum z_i^2\) 的贡献放大约一万倍。截面里只要有三个这样的点,它们就几乎决定了整个标准差,其余 997 只股票的 z 值都被压缩到接近 0,有效信息被淹没。代码中"斜率 = \(\sum ZY/\sum Z^2\)"就是无截距的截面 OLS 斜率,你在 CFA 里见过的 \(\hat\beta=\mathrm{Cov}(X,Y)/\mathrm{Var}(X)\) 在去均值后正是这个式子。

实践建议:

  • 截面回归、Pearson IC、组合优化的输入,先 MAD 截尾再 z-score;
  • 只关心排序时(RankIC、分组回测),秩正态化最省事;
  • 截尾的阈值属于研究参数,应记入试验日志,不要在看到结果后反复调整。

最后一段代码演示了价格错误的识别规则:单日涨跌幅超过 30%、次日几乎完全反转、两日合计收益很小。被标记的观测应当回到数据源核实,而不是简单删除——真实的暴涨暴跌(如并购传闻后澄清)也可能满足这个模式。


2.7 常见陷阱与检查清单

  • [ ] 收益计算使用含分红的复权价;价格水平类规则使用原始价格,而不是前复权价。
  • [ ] 跨期累加用对数收益,组合加权用简单收益。
  • [ ] 宽表已 reindex 到完整交易日历;区分未上市、退市与停牌三种缺失。
  • [ ] 停牌股在回测中不能成交,持仓冻结;复牌日的跨期收益单独处理。
  • [ ] 股票池在每个历史时点按当时的状态构造,包含后来退市的股票;退市月收益已补齐或合理估计。
  • [ ] 财务数据按公告日(或法定最晚披露日)对齐;使用初次公布值而非修订值。
  • [ ] 截面因子先截尾再标准化,截尾参数事先确定并记录。
  • [ ] 缺失因子值只用当期截面信息填补;前向填充设有上限。
  • [ ] 对价格序列做错价扫描,可疑点回到数据源核实。

本章小结

数据环节的错误不会让程序报错,只会让回测"更好看"。复权要去掉除权除息造成的机械跳动,前复权价的历史值会随新分红改变,因此不能用于价格水平类规则;工程上应同时保存原始价格和累计复权因子。面板数据要按完整交易日历组织,区分未上市、退市与停牌三种缺失;在长表上按行计算收益会把跨停牌的多日收益当作 1 日收益。幸存者偏差在本章的模拟中把等权年化收益高估了约 2.5 个百分点,并凭空制造出 t 值 6 以上的"高波动因子"。按报告期而不是公告日对齐财报,让一个只有微弱漂移的盈利意外信号的 IC 放大了十倍以上。截面因子中极少量的单位错误就能让 IC 减半,MAD 截尾和秩正态化是稳健的默认选择。

概念 要点
收益口径 时间上加对数收益,截面上加简单收益;\(\mathbb E[r]\approx\mathbb E[R]-\tfrac12\mathrm{Var}(R)\)
复权因子 \(a_t=P_{t-1}/[(P_{t-1}-D_t)/s_t]\),\(A_t=\prod a_u\);后复权 \(P_tA_t\),前复权 \(P_tA_t/A_T\)
面板结构 长表存储与合并;宽表截面运算;宽表必须对齐交易日历
幸存者偏差 只用存活股高估收益、制造伪因子;退市收益缺失同向偏差
时点数据 按公告日 merge_asof;使用初次公布值
截尾 MAD:\(\mathrm{med}\pm k\cdot1.4826\,\mathrm{MAD}\);分位数;迭代 \(3\sigma\)
标准化 z-score 保留距离;秩正态化 \(\Phi^{-1}(\text{百分位秩})\) 对单调变换不变

练习

  1. 在 2.2 节的代码中加入一次配股(每 10 股配 3 股,配股价 12 元)。推导配股的除权参考价公式,并验证复权后收益与真实总收益一致。 提示:\(P^{\text{ref}}=(P_{t-1}+0.3\times12)/1.3\),这里假设投资者参与配股。
  2. 用 2.3 节的宽表,写一个函数计算"跨停牌的复牌日收益",并把复牌后第一天的收益单独标记。比较剔除与保留这些收益时,60 日滚动波动率的差异。
  3. 修改 2.4 节的模拟,让退市概率与市值(用价格代替)而不是累计跌幅相关,观察存活样本中"小市值因子"的 IC 如何变化。
  4. 在 2.5 节的模拟中加入"财报修订":20% 的记录在公告 1 年后被修订为更接近真实值的数字,且公告后漂移由真实值驱动。比较使用初次公布值与使用修订值的信号 IC。
  5. 设因子与收益服从相关系数为 \(\rho\) 的双变量正态分布。说明大样本下秩正态化后的 Pearson IC 趋于 \(\rho\),而 Spearman 秩相关趋于 \(\frac6\pi\arcsin(\rho/2)\);当 \(\rho\) 很小时两者之比约为 \(\pi/3\)。用模拟验证。 提示:\(\arcsin u\approx u\)。
  6. 把 2.6 节的单位错误比例从 0.3% 提高到 3%,重新比较五种方法。分位数截尾在什么情况下会失效?

延伸阅读

  • 第 06 册第 01 章(金融收益率及其统计特征),特别是 1.2 节收益率定义与 1.6 节收益率的实证性质。
  • 第 05 册第 01 章 1.4 节(数据的来源与类型:截面、时间序列、面板)与第 10 章(面板数据回归)。
  • 第 03 册第 07 章(经验分布函数与统计泛函)与第 21 章 21.5 节(MAD 与稳健尺度估计)。
  • 第 05 册第 09 章 9.2 节(内部有效性的威胁:样本选择偏差)。
  • Shumway, T. (1997). The Delisting Bias in CRSP Data. Journal of Finance, 52(1), 327–340.
  • López de Prado, M. (2018). Advances in Financial Machine Learning. Wiley. 第 2 章(金融数据结构)。