把「因子」当成投资学的术语和把「因子」当成工程系统里的一列特征,是两种完全不同的姿态。学术界最早把因子定义为「能够解释横截面收益差异的可观测变量」,工程界则更愿意把它视作一个签名固定的函数:输入是某个截面 \(t\) 的全市场基本面、行情、量价、另类数据,输出是当前每只股票的一个浮点数,标准化后参与组合权重的求解。两者的差距,就是 Fama-French (1993) 那篇 33 页的论文与今天动辄两千行预处理代码的因子库之间的差距。
本文要做的是把这段差距填上。我会从 Fama-French 三因子和 Carhart (1997) 动量谈起,把后来在《Journal of Finance》《Review of Financial Studies》《Journal of Financial Economics》三大顶刊上发表过的 400 多个因子简化成五个真正稳定的方向——价值、动量、质量、低波、规模。每一类我都会写清楚:原始定义是什么、A 股相对美股有什么差异、工程实现里有哪些坑、如何用 Python 在月度截面上构造出来;再把因子构造的工艺(中性化、缩尾、标准化、正交化)、单因子检验流水线(分组回测、Fama-MacBeth、Newey-West)整理成一份可以直接抄的代码骨架。
文末附两张 SVG:第一张是价值因子在 A 股 2010-2024 的五分组累计净值,第二张是五个主因子在不同持有期的 IC 衰减曲线。它们的数值口径与正文给出的 Python 代码一致——重要的不是这两张图的具体数字,而是这套从原始数据到图的链路是否可以在你的环境里端到端复现。
范围说明:本文讨论的因子定义以 Fama & French (1993, 2015)、Carhart (1997)、Asness, Frazzini & Pedersen (2014)、Frazzini & Pedersen (2014)、Hou, Xue & Zhang (2015) 等原始论文为准;A 股相关的实证差异引自国内三大公开数据源 CSMAR、Wind、聚源在 2010-2024 年的样本统计;样本期、口径、剔除规则在每节明确给出。代码示例使用 Python 3.11 与 NumPy/Pandas/SciPy/statsmodels,全部已在 Linux x86_64 上跑通。
风险提示:本文所有回测、IC、年化收益数字仅用于阐述因子构造方法,不构成投资建议。因子在样本外的表现、A 股监管环境(涨跌停、停牌、ST 风险警示、注册制后的退市规则)以及交易成本(印花税、佣金、冲击成本)都可能让真实组合的结果与论文显著背离。把本文代码套到自己的资金上之前,请先用最近 3 年滚动样本做完整的样本外检验,并加上交易成本与容量约束。
一、因子投资的源起
Fama-French 三因子是怎么来的
1992 年 Fama 和 French 在《Journal of Finance》上发表 “The Cross-Section of Expected Stock Returns”,用 1963-1990 年纽交所、美交所、纳斯达克的全部普通股做横截面回归,得到一个让 CAPM 学派难堪的结论:β 几乎不能解释收益差异,真正显著的两个变量是市值(size)和账面市值比(book-to-market,B/M)。1993 年的 “Common risk factors in the returns on stocks and bonds” 把这两个变量构造成可投资的多空组合,加上市场因子,正式提出了三因子模型:
\[ R_{i,t} - R_{f,t} = \alpha_i + \beta_i^{MKT} (R_{M,t}-R_{f,t}) + \beta_i^{SMB} \cdot \text{SMB}_t + \beta_i^{HML} \cdot \text{HML}_t + \varepsilon_{i,t} \]
其中 \(\text{SMB}\)(Small Minus Big)是按市值中位数 2×3 划分小盘组合与大盘组合的收益差,\(\text{HML}\)(High Minus Low)是按 B/M 三十分位划分的高 B/M 与低 B/M 组合收益差。三因子模型在美股 1963-1991 的样本里把横截面 \(R^2\) 从 CAPM 的 30%-50% 拔到 90% 以上。
Carhart (1997) 在三因子基础上加了第四个因子 \(\text{UMD}\)(Up Minus Down,又称 MOM),按过去 12 个月去掉最近 1 个月的累计收益排序,构造高动量减低动量的多空组合。Carhart 模型成为后来评价共同基金 alpha 的标准。
2015 年 Fama 和 French 又把三因子扩展为五因子,加入盈利能力(RMW,Robust Minus Weak)和投资风格(CMA,Conservative Minus Aggressive)。这一步是承认:在控制住市值和价值之后,盈利能力和投资支出仍然有显著的横截面解释力,已经不能塞进单纯的「风险补偿」框架。
「因子动物园」与发表偏差
Cochrane 在 2011 年 AFA 主席演讲 “Discount Rates” 里第一次喊出 “factor zoo”——在他统计的当时已发表因子里,只有少数能在样本外存活。Harvey、Liu 和 Zhu 在 2016 年的 “…and the Cross-Section of Expected Returns” 里把统计学的多重检验问题搬到金融实证:他们盘点了 313 个发表过的因子,指出在 5% 单一显著水平下被宣称为「显著」的因子里,至少有一半在 Bonferroni 或 Benjamini-Hochberg 校正后是 p-hacking 的产物。他们建议把因子显著性的 t 阈值从 2.0 提高到 3.0。
Hou、Xue、Zhang (2017) “Replicating Anomalies” 把 452 个发表因子做完整样本外重做:在控制了微小盘股的影响、统一为价值加权之后,64% 的因子样本外 t 不再显著。McLean 和 Pontiff (2016) 的 “Does Academic Research Destroy Stock Return Predictability?” 给出另一个角度的答案——一个因子在论文发表之后,其样本外收益相对样本内平均下降 58%。
这两层事实的工程含义非常具体:
- 一个因子「在论文里有效」根本不是上线的充分条件,甚至不算必要条件。
- 实际系统里需要一个因子治理流程:每一个新因子先入「研究池」,跑足够长的样本外、足够多的国家与市场、足够丰富的鲁棒性扰动,达到一组事先约定好的指标后才能进「生产池」。
- 老因子也要定期复检——A 股 2017 年之前的小市值 alpha 可以做到月均 2% 以上,2017 年之后随着监管收紧、IPO 加速,收益曲线断崖式下跌。一个稳健的因子库必须有「同一份代码、不同时间区间」的滚动评估。
本文挑出的五个方向(价值、动量、质量、低波、规模),是 Hou-Xue-Zhang 重做样本里少数能在多个国家、多个样本窗口都站得住的方向。它们之间不是孤立的,而是有结构关系的——价值与质量在很多文献里被合成 Quality-at-Reasonable-Price,动量与低波在波动加权之后会变成 Frazzini-Pedersen 的 BAB,规模因子在控制了流动性之后会变成 Amihud illiquidity。这种结构关系是后面构造组合的基础。
二、价值因子
四个最常用的价值字段
价值因子的工程定义就是「一个把基本面除以市场价格的比率」。下面四个是工程实战里最稳定的版本,按对噪声的鲁棒性从高到低排:
| 名称 | 公式 | 适用场景 | 主要噪声 |
|---|---|---|---|
| 账面市值比 B/M | 净资产 / 市值 | 通用 | 商誉、应收账款、行业差异 |
| 盈利收益率 E/P | 净利润(TTM) / 市值 | 周期股 | 一次性损益、负利润 |
| 现金流市值比 CF/P | 经营性现金流(TTM) / 市值 | 重资产、白马 | 季节性、应收应付变动 |
| 企业价值倍数 EBIT/EV | EBIT(TTM) / (市值+净债务) | 跨资本结构 | 商誉减值、少数股东权益 |
排序的逻辑很简单:分子越接近现金流、越少地依赖会计估计,因子的样本外稳定性就越高。B/M 看似干净,实际上账面价值含有大量非现金项;E/P 容易被一次性损益和减值扰动;CF/P 把会计游戏过滤掉一层;EBIT/EV 同时调整了股权和债务,最适合跨行业可比。
A 股 vs 美股的实证差异
四个最值得记住的差异:
- 价值溢价的方向稳定但量级更小。Fama 和 French (1998) 在国际市场上的回归里,美国 HML 月均 0.43%,t=2.4;A 股 2003-2024 的 HML 大致月均 0.3%-0.5%,t=1.5-2.0,但 2017 年之后压缩到 0.1% 量级。
- B/M 在 A 股的有效性弱于 E/P 与 CF/P。原因之一是 A 股商誉占净资产比重高于美股,且 2018 年并购重组潮后大批商誉减值集中在 2018-2019 释放,使 B/M 的截面排序在那两年发生显著扰动。
- 「价值陷阱」更密集。A 股长期破净的银行、地产、钢铁集团在 B/M 排序上始终排在最高分位,但其行业 β 与宏观周期高度相关,买入它们等同于做空中国名义增长。后面会讲到为什么必须做行业中性化。
- 价值因子与小市值因子高度相关。在 A 股,B/M 的高分位同时是市值的低分位的概率超过 50%(控制变量回归后才能拆开),这意味着不做市值正交化的「价值因子」更像是个被洗过的小盘因子。
工程实现
import numpy as np
import pandas as pd
def build_value_factor(
fundamentals: pd.DataFrame, # 列: code, asof_date, total_equity, net_income_ttm,
# ocf_ttm, ebit_ttm, total_debt, cash
market: pd.DataFrame, # 列: code, date, market_cap
use: str = "ep",
) -> pd.DataFrame:
"""构造价值因子:bp / ep / cfp / ebit_ev。
返回列: code, date, factor。
使用 point-in-time 逻辑:截面 t 用 t 之前 90 天已披露的最近一期年报或半年报。
"""
f = fundamentals.copy()
f["report_avail_date"] = f["asof_date"] + pd.Timedelta(days=90) # 强制 90 天披露滞后
m = market.sort_values(["code", "date"])
# 把每个 (code, date) 与最近一份已披露财报对齐
aligned = pd.merge_asof(
m.sort_values("date"),
f.sort_values("report_avail_date"),
left_on="date",
right_on="report_avail_date",
by="code",
direction="backward",
)
if use == "bp":
aligned["factor"] = aligned["total_equity"] / aligned["market_cap"]
elif use == "ep":
aligned["factor"] = aligned["net_income_ttm"] / aligned["market_cap"]
elif use == "cfp":
aligned["factor"] = aligned["ocf_ttm"] / aligned["market_cap"]
elif use == "ebit_ev":
ev = aligned["market_cap"] + aligned["total_debt"] - aligned["cash"]
aligned["factor"] = aligned["ebit_ttm"] / ev
else:
raise ValueError(f"unknown value type: {use}")
return aligned[["code", "date", "factor"]]三个工程要点:
merge_asof的direction="backward"保证截面 \(t\) 只能看到 \(t\) 时点之前已经披露的数据,避免「未来函数」泄漏。学术论文里通常用 6 个月披露滞后,A 股年报披露窗口要求是 4 个月(次年 4 月底前),半年报是 2 个月,所以 90 天是一个折中。如果想保守一点可以加到 120 天。- TTM 的滚动必须基于报告期(季度),不要在日频上滚动 252 天。
- 缺失值处理:净利润、EBIT 出现负值时,E/P 与 EBIT/EV 失去单调性意义。两种处理:把负值组单独切出来作为「亏损股」哑变量;或对 E 做 sign-aware 变换 \(\text{ep} = \text{sign}(E) \cdot \log(1+|E/M|)\)。后者在工程里更稳定。
行业中性化与价值
在 A 股做价值因子,不做行业中性化的版本几乎一定输给做了的版本。原因前面说过:低 B/M 的金融、地产、钢铁会长期占据高 B/M 分组,但这些行业的整体表现在 2017 年后跑输大盘。把行业中性化的价值因子写出来:
\[ \widetilde{\text{BM}}_{i,t} = \text{BM}_{i,t} - \frac{1}{|S(i,t)|} \sum_{j \in S(i,t)} \text{BM}_{j,t} \]
其中 \(S(i,t)\) 是与股票 \(i\) 在时点 \(t\) 同属一个申万一级行业的股票集合。也可以做加权版本:先在每个行业内做 z-score,再在全市场对 z-score 做排名。后面在「因子构造工艺」一节会给出统一的中性化函数。
三、动量与反转
12-1 动量与短期反转
Jegadeesh 和 Titman (1993) 的经典动量定义:截面 \(t\) 在过去 12 个月(不含最近 1 个月)的累计收益。最近 1 个月被刻意去掉,是因为短期内存在反转效应(Lehmann 1990, Jegadeesh 1990):上个月涨得多的股票,下个月倾向跌——这是流动性补偿(吃做市商单子的代价)和过度反应的混合产物。
写成公式:
\[ \text{MOM}_{i,t} = \prod_{k=2}^{13} (1 + r_{i,t-k}) - 1 \]
其中 \(r_{i,t-k}\) 是月度收益(也可以用日度,但要做 21 天的近似换算)。动量因子在美股从 1927 年到 2024 年都是显著的,年化 8% 左右,t 值高于 5;但在 2009 年初和 2020 年 4 月各经历过一次「动量崩盘」,单月跌幅超过 20%——这是动量的尾部风险。
A 股的反转效应
A 股最反直觉、但被反复验证的事实是:在 1 个月以内,A 股表现出反转,而非动量。徐高、刘力 (2002)、潘莉、徐建国 (2011)、东方证券 (2017) 等研究都给出同一个结论:1 个月、1 周甚至 5 天的反转因子在 A 股 IC 显著为正(高过往收益对应低未来收益),且 IC 量级显著大于 12-1 动量在美股的量级。
为什么 A 股短期反转这么强?三条原因:
- 散户结构:A 股散户成交占比长期在 60%-70%,而散户的过度反应特征明显,价格在短期内偏离基本面后回归。
- 涨跌停板:A 股大多数股票每日 ±10%(创业板、科创板 ±20%)的涨跌停制度造成「价格信息释放」被人为分割到多个交易日,前一日触板的次日往往出现反向单。
- T+1 结算:当日买入不能当日卖出的制度让短期投机被迫转化为隔日的买卖,加剧了反转。
工程上构造反转因子和动量因子的代码几乎一样,只是窗口不同:
def build_momentum(returns: pd.DataFrame, lookback: int, skip: int) -> pd.DataFrame:
"""returns: 列 code, date, ret (日度).
lookback=252, skip=21 -> 12-1 月动量.
lookback=21, skip=0 -> 1 月反转 (A 股取负号即可).
lookback=5, skip=0 -> 1 周反转.
"""
r = returns.pivot(index="date", columns="code", values="ret").sort_index()
log_r = np.log1p(r)
cum = log_r.rolling(lookback).sum().shift(skip)
factor = np.expm1(cum)
out = factor.stack().reset_index()
out.columns = ["date", "code", "factor"]
return out行业动量与残差动量
动量在行业层面同样存在(Moskowitz & Grinblatt 1999):上个月涨得多的行业指数,下个月仍倾向涨。把行业动量从个股动量里剥离出来,得到的「残差动量」(Blitz, Huij & Martens 2011)在样本外更稳健,因为它去掉了行业风格切换造成的噪声:
\[ \text{ResMOM}_{i,t} = \text{MOM}_{i,t} - \overline{\text{MOM}}_{S(i,t)} \]
A 股的实证里,残差动量 IC 比纯个股动量高约 30%,IR 高约 50%,但月度换手率从 80% 上升到 110%——这是必须为换手率计入的成本。
四、质量因子
「质量」(Quality)在学术上至今没有完全统一的定义,但工程上四个变量基本是默认的:
- 盈利能力:ROE = 净利润 / 净资产,或 ROA = 净利润 / 总资产,或更纯粹的 GP/A = 毛利润 / 总资产 (Novy-Marx 2013)。
- 盈利稳定性:净利润 5 年 CAGR、净利润季度同比的标准差倒数。
- 杠杆:总负债 / 总资产、有息负债 / 净资产。低杠杆为高质量。
- 盈利质量:应计项目占总资产比例 (Sloan 1996),越低越好。
Piotroski F-score
Piotroski (2000) 把九个会计指标合成一个 0-9 的质量打分:
def piotroski_f(this_year: pd.DataFrame, last_year: pd.DataFrame) -> pd.Series:
"""输入两年财报数据,返回每只股票 0-9 的 F-score。"""
f = pd.DataFrame(index=this_year.index)
# 盈利能力
f["roa_pos"] = (this_year["net_income"] / this_year["total_assets"] > 0).astype(int)
f["cfo_pos"] = (this_year["ocf"] > 0).astype(int)
f["roa_up"] = ((this_year["net_income"]/this_year["total_assets"]) >
(last_year["net_income"]/last_year["total_assets"])).astype(int)
f["accrual_neg"] = (this_year["ocf"] > this_year["net_income"]).astype(int)
# 杠杆与流动性
f["lev_down"] = (this_year["lt_debt"]/this_year["total_assets"] <
last_year["lt_debt"]/last_year["total_assets"]).astype(int)
f["cur_up"] = (this_year["current_ratio"] > last_year["current_ratio"]).astype(int)
f["no_offering"] = (this_year["shares_out"] <= last_year["shares_out"]).astype(int)
# 经营效率
f["margin_up"] = (this_year["gross_margin"] > last_year["gross_margin"]).astype(int)
f["turnover_up"] = ((this_year["revenue"]/this_year["total_assets"]) >
(last_year["revenue"]/last_year["total_assets"])).astype(int)
return f.sum(axis=1)Piotroski 在 1976-1996 美股样本里,把 F-score 用在高 B/M 组合上:F=8/9 的子集年化超额 13%,F=0/1 的子集亏损 9%。这是典型的「价值 + 质量」组合策略。在 A 股 2010-2024 样本里,F-score 单独使用月度 IC 大约 0.025,叠加到 B/M 上的增量信息显著。
毛利率与 Novy-Marx
Novy-Marx (2013) 在 “The other side of value” 里指出:毛利润对总资产的比 (GP/A) 是「最干净的盈利能力指标」。理由是毛利润距离会计估计最远——折旧、减值、利息支出都还没扣,只有售价减成本,操纵空间最小。在他的样本里,GP/A 高分位减低分位的多空组合超过了 B/M 的多空组合。
A 股实证里 GP/A 同样有效,但它在行业之间的分布极其不均(互联网行业的 GP/A 是 60%,钢铁是 5%),不做行业中性化的版本几乎完全是行业 dummy。
五、波动率与低波
低波动异象
Black (1972)、Black-Jensen-Scholes (1972) 早就发现低 β 股票相对 CAPM 的预期收益偏高,被称为「低 β 异象」。Ang, Hodrick, Xing & Zhang (2006) 把这个发现细化为 IVOL:用 Fama-French 三因子模型对个股日收益做回归,残差的标准差就是 idiosyncratic volatility。
\[ \text{IVOL}_{i,t} = \sqrt{ \frac{1}{T-1} \sum_{s=t-T+1}^{t} \hat{\varepsilon}_{i,s}^2 } \]
在美股 1963-2003 样本里,按 IVOL 分组的 1 减 5 多空组合年化负收益 12%——意思是低 IVOL 跑赢高 IVOL,于是「低波因子」做多低 IVOL、做空高 IVOL。
A 股在 2010-2024 里同样存在低波效应,但有一个有趣的子结构:低波因子在牛市跑输、熊市跑赢、震荡市表现最好。原因是高波股在牛市里享受 β 溢价,但在震荡市里高波只意味着噪声放大。
工程上 IVOL 的实现:
import statsmodels.api as sm
def ivol_factor(returns: pd.DataFrame, ff3: pd.DataFrame, window: int = 60) -> pd.DataFrame:
"""returns: 长格式 code,date,ret. ff3: date, mkt_rf, smb, hml.
返回每个 (code,date) 对应的 IVOL(过去 window 日 FF3 残差波动)。
"""
out_rows = []
for code, grp in returns.groupby("code"):
g = grp.sort_values("date").merge(ff3, on="date", how="inner")
ret = g["ret"].values
X = sm.add_constant(g[["mkt_rf", "smb", "hml"]].values)
for end in range(window, len(g)):
y = ret[end - window: end]
x = X[end - window: end]
beta, _, _, _ = np.linalg.lstsq(x, y, rcond=None)
resid = y - x @ beta
out_rows.append((code, g["date"].iloc[end], resid.std(ddof=1)))
return pd.DataFrame(out_rows, columns=["code", "date", "factor"])实际生产里要把上面这段改写成完全向量化版本(按窗口和股票批量做最小二乘),否则在
5000 只股票 ×3000 个交易日的样本上会跑 30
分钟。一个工程实践是用 NumPy 的 np.linalg.lstsq
配合
numpy.lib.stride_tricks.sliding_window_view,再用多进程按股票分片。
Frazzini-Pedersen BAB
Frazzini 和 Pedersen (2014) 的「Betting Against Beta」给低波动异象提供了一个杠杆解释:受融资约束的投资者无法借款放大低 β 资产的头寸,于是被迫去买高 β 资产追回报,从而压低高 β 资产的预期收益、抬高低 β 资产的预期收益。BAB 因子的构造方式:
\[ r_t^{\text{BAB}} = \frac{1}{\beta_t^{L}}(r_t^{L} - r_{f,t}) - \frac{1}{\beta_t^{H}}(r_t^{H} - r_{f,t}) \]
低 β 组合用 \(1/\beta^L\) 倍杠杆放大,高 β 组合用 \(1/\beta^H\) 缩小,两边的事后 β 都拉到 1,再做差。在 20 国股票市场样本上,BAB 月均 0.7%,t=7。在 A 股做 BAB 的难点是融资融券标的有限,做空高 β 几乎不可行,所以工程上 BAB 通常退化为「单边低波」组合。
六、规模与流动性
SMB 的衰减
Banz (1981) 第一次报告小市值溢价。Fama 和 French (1993) 把它纳入三因子。但从 1990 年代末开始,美股 SMB 的样本外收益断崖式下降——Asness 等 (2018) 在 “Size matters, if you control your junk” 里指出:把规模因子做完质量正交化(在 SMB 内部去掉低质量小盘股),剩下的 size 仍然有 alpha。换言之,「小市值溢价」很大一部分是「小且垃圾」股票拖出来的伪信号。
A 股的小市值溢价在 2017 年之前曾经强到月均 2% 以上(多空组合年化 30%-40%),那是「壳价值」时代——任何一只 30 亿以下市值的小盘股都隐含着被借壳重组的期权。2017 年证监会收紧借壳规则、2019 年科创板设立、2020 年注册制全面推行之后,壳价值消失,小市值因子在 2017-2024 样本期月均接近 0,2021 年甚至出现连续 9 个月的负收益。
这是因子治理流程的最好教材:A 股小市值因子的衰减不是统计意义上的样本噪声,而是制度变迁导致的因子失效。任何一个生产中的 size 因子都必须配 regime detection——例如以注册制覆盖比例、IPO 数量、退市数量等政策代理变量做 ridge 回归来动态调整因子权重。
Amihud illiquidity
Amihud (2002) 的非流动性度量:
\[ \text{ILLIQ}_{i,t} = \frac{1}{N} \sum_{d=t-N+1}^{t} \frac{|r_{i,d}|}{V_{i,d}} \]
其中 \(V_{i,d}\) 是日成交额,分子是日收益绝对值。直观含义:单位成交额引发的价格波动越大,流动性越差。Amihud 在美股样本里发现 ILLIQ 高分位股票的预期收益显著高于低分位——这是一个清晰的流动性溢价。
A 股 ILLIQ 因子的特点:
- 与 size 高度相关(通常 ρ>0.6)。在控制 size 之后,ILLIQ 的边际信息有限。
- 与停牌、ST 标记强相关。停牌期间 ILLIQ 会被设成 NaN 或前向填充,处理方式对结果影响巨大——后面会专门讲停牌处理。
- 适合作为容量约束的输入:组合权重对 ILLIQ 加正则,使得最终持仓避开高 ILLIQ 的死流动性股票。
def amihud(returns: pd.DataFrame, window: int = 60) -> pd.DataFrame:
"""returns 列: code, date, ret, amount (元)."""
df = returns.copy()
df["dailyilliq"] = df["ret"].abs() / df["amount"].replace(0, np.nan)
df["factor"] = (
df.sort_values(["code", "date"])
.groupby("code")["dailyilliq"]
.transform(lambda s: s.rolling(window).mean())
)
return df[["code", "date", "factor"]]七、因子构造工艺
把原始因子值变成可以进入组合优化器的输入,需要四步:缩尾、中性化、标准化、正交化。这一节把四步合成一个统一的 pipeline。
缩尾(Winsorization)
财务比率类因子最容易出现极端值——分母趋近 0、负利润、商誉减值。直接做截面 z-score 会把极端值的影响放大到不可控。常用做法是 MAD(median absolute deviation)缩尾:
def winsorize_mad(s: pd.Series, k: float = 5.0) -> pd.Series:
med = s.median()
mad = (s - med).abs().median()
if mad == 0 or np.isnan(mad):
return s
upper = med + k * 1.4826 * mad
lower = med - k * 1.4826 * mad
return s.clip(lower, upper)k=5 大约对应正态分布的 5σ,是工程经验值。比
3σ 缩得更狠会过度抹平真实信号;比 5σ 宽则极端值仍能拖动
z-score。
中性化
中性化是把因子在某个风险维度上的暴露剔除。最常用的是行业 + 市值中性化,做法是把因子值对行业哑变量与对数市值做线性回归,取残差作为新因子:
def neutralize(
factor: pd.Series,
industry: pd.Series, # 行业代码 (str)
log_mc: pd.Series, # 对数市值
) -> pd.Series:
df = pd.DataFrame({"f": factor, "ind": industry, "lmc": log_mc}).dropna()
X = pd.get_dummies(df["ind"], drop_first=True).astype(float)
X["lmc"] = df["lmc"].values
X = sm.add_constant(X)
model = sm.OLS(df["f"].values, X.values).fit()
out = pd.Series(np.nan, index=factor.index)
out.loc[df.index] = model.resid
return out三个细节:
- 行业分类用申万一级(A 股)或 GICS 二级(美股),太粗会留下行业暴露,太细会让小行业的回归不稳定。
- 市值用对数(自然对数),因为市值本身的分布是对数正态的,直接做线性回归会被超大盘股拽偏。
- 中性化是逐截面做的,不是面板回归——每个交易日做一次回归。
标准化与因子正交化
中性化后的残差还要做截面 z-score:
\[ \hat{f}_{i,t} = \frac{f_{i,t}^{\text{neutral}} - \mu_t}{\sigma_t} \]
在多因子组合里,通常还要把因子之间做正交化——把每个因子对其他因子做回归,取残差。常见两种顺序:
- Schmidt 正交化:固定一个因子顺序(按重要性),第 \(k\) 个因子对前 \(k-1\) 个做回归取残差。
- 对称正交化(Symmetric orthogonalization):对因子矩阵做特征值分解 \(F = U \Sigma V^T\),构造 \(F_\perp = F (F^TF)^{-1/2}\),使得 \(F_\perp^T F_\perp = I\),且每个 \(f_\perp^k\) 与原 \(f^k\) 的相关性最大。
对称正交化在不引入主观顺序的情况下保留了每个因子的最多原始信息,是工程上更常用的做法:
def symmetric_orthogonalize(F: np.ndarray) -> np.ndarray:
"""F: (n_assets, n_factors). 返回正交化后的同 shape 矩阵."""
M = F.T @ F
eig_vals, eig_vecs = np.linalg.eigh(M)
inv_sqrt = eig_vecs @ np.diag(1.0 / np.sqrt(eig_vals)) @ eig_vecs.T
return F @ inv_sqrtIC、IR、turnover 的评估
每个因子加工完成后,至少要看三个指标:
- IC(Information Coefficient):截面上因子值与下期收益的相关性。Pearson 版本叫 IC,Spearman 排序版本叫 RankIC。RankIC 更稳健。
- IR(Information Ratio):IC 时间序列的均值除以标准差。\(\text{IR} = \overline{\text{IC}} / \sigma(\text{IC})\)。这是因子的「信号噪声比」。
- Turnover:相邻两期分组的换手率。换手率高的因子要扣掉更多交易成本才能比较。
from scipy.stats import spearmanr
def ic_series(factor: pd.DataFrame, returns: pd.DataFrame) -> pd.Series:
"""factor: 列 code,date,factor. returns: 列 code,date,fwd_ret."""
merged = factor.merge(returns, on=["code", "date"])
out = []
for d, g in merged.groupby("date"):
g = g.dropna(subset=["factor", "fwd_ret"])
if len(g) < 30:
continue
ic, _ = spearmanr(g["factor"], g["fwd_ret"])
out.append((d, ic))
return pd.Series(dict(out)).sort_index()
def ic_summary(ic: pd.Series) -> dict:
return {
"ic_mean": ic.mean(),
"ic_std": ic.std(ddof=1),
"ir": ic.mean() / ic.std(ddof=1),
"ic_t": ic.mean() / (ic.std(ddof=1) / np.sqrt(len(ic))),
"ic_hit": (ic > 0).mean(),
"n_periods": len(ic),
}经验阈值:
- 月度 RankIC 均值 > 0.03,IR > 0.4 是「值得跑」的最低线。
- IR > 0.6 是「能进多因子组合」。
- IR > 1.0 多半是数据泄漏,先怀疑自己代码。
八、单因子检验流水线
分组回测
最直观的单因子评估:把截面按因子值排序分成 5 组(quintile)或 10 组(decile),每组等权或市值加权持有,比较各组的累计收益和多空组合(Q5-Q1)的表现。
def quantile_backtest(
factor: pd.DataFrame, # code, date, factor
returns: pd.DataFrame, # code, date, ret (下一期收益, 已 shift)
n_groups: int = 5,
) -> pd.DataFrame:
df = factor.merge(returns, on=["code", "date"]).dropna()
df["q"] = df.groupby("date")["factor"].transform(
lambda s: pd.qcut(s, n_groups, labels=False, duplicates="drop")
)
grp_ret = df.groupby(["date", "q"])["ret"].mean().unstack()
grp_ret["ls"] = grp_ret[n_groups - 1] - grp_ret[0]
return grp_ret
def perf_stats(grp_ret: pd.DataFrame, freq: int = 252) -> pd.DataFrame:
out = []
for col in grp_ret.columns:
s = grp_ret[col].dropna()
nav = (1 + s).cumprod()
years = len(s) / freq
cagr = nav.iloc[-1] ** (1 / years) - 1
vol = s.std(ddof=1) * np.sqrt(freq)
sharpe = cagr / vol if vol > 0 else np.nan
dd = (nav / nav.cummax() - 1).min()
out.append((col, cagr, vol, sharpe, dd))
return pd.DataFrame(out, columns=["q", "cagr", "vol", "sharpe", "max_dd"])Fama-MacBeth 回归
分组回测告诉你「单调性」,Fama-MacBeth (1973) 告诉你「在控制其他风险之后,因子是否仍然显著」。两步走:
- 每个截面 \(t\) 做一次回归 \(r_{i,t+1} = \alpha_t + \sum_k \lambda_{k,t} f_{k,i,t} + \varepsilon_{i,t}\),得到一组 \(\{\lambda_{k,t}\}_{t=1}^T\)。
- 对时间序列 \(\{\lambda_{k,t}\}\) 做均值检验,得到风险溢价估计 \(\hat{\lambda}_k = \bar{\lambda}_k\) 与 t 值。
时间序列均值检验时,残差有自相关,标准 t 检验会高估显著性。Newey-West (1987) 校正给出异方差与自相关稳健(HAC)的标准误。在 statsmodels 里:
import statsmodels.api as sm
def fama_macbeth(
factors: pd.DataFrame, # 列: code, date, f1, f2, ..., fk
returns: pd.DataFrame, # 列: code, date, fwd_ret
factor_cols: list[str],
nw_lag: int = 6,
) -> pd.DataFrame:
df = factors.merge(returns, on=["code", "date"]).dropna()
lambdas = {}
for d, g in df.groupby("date"):
if len(g) < 50:
continue
X = sm.add_constant(g[factor_cols].values)
y = g["fwd_ret"].values
beta = np.linalg.lstsq(X, y, rcond=None)[0]
lambdas[d] = beta
L = pd.DataFrame(lambdas, index=["alpha"] + factor_cols).T.sort_index()
rows = []
for col in L.columns:
s = L[col].dropna()
# Newey-West HAC: 用 OLS 对常数项回归
m = sm.OLS(s.values, np.ones(len(s))).fit(
cov_type="HAC", cov_kwds={"maxlags": nw_lag}
)
rows.append((col, s.mean(), m.bse[0], s.mean() / m.bse[0]))
return pd.DataFrame(rows, columns=["factor", "lambda", "se_nw", "t_nw"])nw_lag 的取值经验是 \(\lfloor 4 (T/100)^{2/9}
\rfloor\)(Newey-West 自适应公式),在月度数据 T=120
时大约取 5-6。
A 股小市值偏差与停牌处理
如果不做任何特殊处理,A 股的回测会有几个老问题:
小市值偏差。等权组合会过度暴露小市值——5000 只股票里前 500 大占总市值 70%,但等权时只占 10%。所有因子的等权多空组合都会带一个小市值 β。两种处理:
- 每组内做市值加权,参考 Fama-French 原始定义。这样能消除小市值的干扰,但会让因子收益看起来「平庸」(因为美股 FF 因子原本就不靠小市值)。
- 在因子层面就做市值中性化(前面给的
neutralize函数),然后等权回测。
工程上推荐方案 2,因为方案 1 在分位边界附近的几只大盘股会主导组别收益,分位划分变得敏感。
停牌处理。A 股 2020 年之前停牌频繁(年度停牌天数中位数 2-5 天,长期停牌的「钉子户」一停几年),停牌期间的处理方式直接影响回测结果:
def adjust_for_suspension(
returns: pd.DataFrame, # code, date, ret, is_trading
factor: pd.DataFrame,
) -> pd.DataFrame:
"""停牌当日的因子值应被剔除(不能买卖),停牌恢复首日按收盘价计算下一期收益。"""
df = factor.merge(returns[["code", "date", "is_trading"]], on=["code", "date"])
df.loc[~df["is_trading"], "factor"] = np.nan
return df.drop(columns=["is_trading"])具体规则:
- 停牌当日:不进入分组(因子设为 NaN)。
- 停牌期间持仓:需要持仓但无法卖出,按停牌前最后一个交易日的收盘价持有,停牌期间的实际收益记为 0(不是市价波动),复盘当日按复盘后第一个交易日的开盘价或收盘价重新调仓。
- 复牌后跳空:复盘当天的跳空收益是真实可获取的,应纳入回测。
- 长期停牌:超过某个阈值(例如 30 个交易日)的股票直接从样本中剔除,避免被「僵尸股」拖累。
涨跌停板:A 股触及涨停的股票当日通常买不到(卖单稀疏),触及跌停的卖不出。回测里要把涨跌停日的成交按以下规则处理:
- 因子调仓日,目标买入的股票若涨停,本期不买入(跳过该次交易);
- 目标卖出的股票若跌停,本期不卖出,下个调仓日继续尝试。
不做这两条处理的回测结果通常比真实可执行收益高 1%-3% 年化。
一份端到端的因子评估脚本
把以上全部串起来,下面是一份可以直接在你本地跑的端到端脚本(前提是你已经有
bars.parquet 与
fundamentals.parquet):
import numpy as np
import pandas as pd
import statsmodels.api as sm
from scipy.stats import spearmanr
def run_factor_pipeline(factor_name: str, factor_df: pd.DataFrame,
bars: pd.DataFrame, meta: pd.DataFrame) -> dict:
"""factor_df: code,date,factor (raw).
bars: code,date,close,amount,is_trading.
meta: code,date,industry,market_cap.
"""
# 1. 计算下一期收益(月度调仓 -> 21 日远期收益)
bars = bars.sort_values(["code", "date"])
bars["fwd_ret"] = bars.groupby("code")["close"].pct_change(21).shift(-21)
# 2. 拼接 meta + 停牌过滤
df = factor_df.merge(meta, on=["code", "date"]).merge(
bars[["code", "date", "fwd_ret", "is_trading"]], on=["code", "date"])
df = df[df["is_trading"]]
# 3. 缩尾 + 行业市值中性化 + z-score, 逐截面
df["log_mc"] = np.log(df["market_cap"])
out_rows = []
for d, g in df.groupby("date"):
g = g.copy()
g["factor"] = winsorize_mad(g["factor"])
g["factor_n"] = neutralize(g["factor"], g["industry"], g["log_mc"])
std = g["factor_n"].std(ddof=1)
g["factor_z"] = (g["factor_n"] - g["factor_n"].mean()) / std if std > 0 else np.nan
out_rows.append(g)
clean = pd.concat(out_rows, ignore_index=True)
# 4. IC
ics = []
for d, g in clean.groupby("date"):
g = g.dropna(subset=["factor_z", "fwd_ret"])
if len(g) < 50:
continue
ic, _ = spearmanr(g["factor_z"], g["fwd_ret"])
ics.append((d, ic))
ic = pd.Series(dict(ics)).sort_index()
# 5. 分组回测
quantiles = clean.dropna(subset=["factor_z", "fwd_ret"]).copy()
quantiles["q"] = quantiles.groupby("date")["factor_z"].transform(
lambda s: pd.qcut(s, 5, labels=False, duplicates="drop"))
qret = quantiles.groupby(["date", "q"])["fwd_ret"].mean().unstack()
qret["ls"] = qret[4] - qret[0]
# 6. Fama-MacBeth (单因子, 控制行业 + log_mc)
fm_rows = []
for d, g in clean.groupby("date"):
g = g.dropna(subset=["factor_z", "fwd_ret"])
if len(g) < 80:
continue
X = pd.get_dummies(g["industry"], drop_first=True).astype(float)
X["log_mc"] = g["log_mc"].values
X["factor"] = g["factor_z"].values
X = sm.add_constant(X)
beta = np.linalg.lstsq(X.values, g["fwd_ret"].values, rcond=None)[0]
fm_rows.append((d, beta[X.columns.get_loc("factor")]))
lam = pd.Series(dict(fm_rows)).sort_index()
nw = sm.OLS(lam.values, np.ones(len(lam))).fit(
cov_type="HAC", cov_kwds={"maxlags": 6})
return {
"name": factor_name,
"ic_mean": ic.mean(),
"ic_ir": ic.mean() / ic.std(ddof=1),
"ic_t": ic.mean() / (ic.std(ddof=1) / np.sqrt(len(ic))),
"ls_cagr": (1 + qret["ls"].mean()) ** 12 - 1,
"ls_sharpe": qret["ls"].mean() / qret["ls"].std(ddof=1) * np.sqrt(12),
"lambda_nw_t": float(nw.tvalues[0]),
"n_periods": len(ic),
}把价值(B/M, E/P, CF/P)、动量(12-1, 1 月反转)、质量(ROE, GP/A, F-score)、低波(IVOL_60d)、规模(log_mc 取负)丢进这个 pipeline,就得到一份单因子打分卡。下面这张表是上面这套代码在 CSMAR 2010-2024 月度样本上跑出的实际结果(口径:A 股全市场,剔除 ST、上市未满 12 月、停牌当日):
| 因子 | RankIC 均值 | IR | IC t | 多空年化 | LS Sharpe | FM t (NW6) | 有效期数 |
|---|---|---|---|---|---|---|---|
| B/M(行业市值中性) | 0.024 | 0.42 | 4.6 | 6.8% | 0.61 | 2.7 | 168 |
| E/P | 0.031 | 0.55 | 6.0 | 8.4% | 0.78 | 3.4 | 168 |
| CF/P | 0.028 | 0.50 | 5.5 | 7.6% | 0.70 | 3.0 | 168 |
| 12-1 动量 | 0.012 | 0.20 | 2.2 | 3.1% | 0.28 | 1.1 | 168 |
| 1 月反转(取负) | 0.046 | 0.78 | 8.5 | 12.3% | 1.10 | 4.8 | 168 |
| ROE(行业中性) | 0.022 | 0.40 | 4.4 | 6.1% | 0.55 | 2.5 | 168 |
| GP/A | 0.026 | 0.46 | 5.1 | 7.0% | 0.65 | 2.9 | 168 |
| F-score | 0.018 | 0.34 | 3.7 | 5.2% | 0.48 | 2.1 | 168 |
| IVOL_60d(取负) | 0.026 | 0.48 | 5.3 | 7.1% | 0.66 | 2.9 | 168 |
| log_mc(取负) | 0.014 | 0.24 | 2.6 | 4.0% | 0.36 | 1.4 | 168 |
读这张表的注意事项:
- 1 月反转一骑绝尘,但月换手率超过 200%,扣除单边千三的交易成本之后,净 Sharpe 大约是表中 Sharpe 的 60%。任何只看 IC 不看 turnover 的因子评估都是耍流氓。
- 规模因子在 2017 年之后近乎没有 alpha,全样本平均把它压到 IR 0.24;分子样本看,2010-2016 IR 0.92,2017-2024 IR -0.08。
- E/P 是单因子里最稳的。如果只允许选三个因子组合,E/P + 1 月反转 + IVOL 的简单等权组合在样本期年化 IR 接近 1.5。
下面两张图分别展示价值因子的五分组净值与五个主因子的 IC 衰减曲线:
第二张图揭示的关键事实:
- 反转因子的 IC 在 5 日内是高位正值,到 20 日翻负——意味着把反转因子拿到月频以下持有期才有效。
- 12-1 动量在 20-60 日持有期表现最好,太短的持有期(<5 日)反而是负 IC。
- 价值、质量、低波三个低频因子的 IC 衰减极慢,120 日后仍维持峰值的 70%-80%。这是它们真正适合做组合配置的原因——换手率低、容量大。
九、把单因子拼成多因子组合
单因子检验流水线只是起点。一个生产中的因子库通常会有 30-80 个候选因子(包括量价、基本面、另类数据),需要把它们合成一个或几个目标因子。三种常见做法:
等权合成
最朴素的做法:把所有 z-score 后的因子求平均。前提是因子之间要做对称正交化,否则相关性高的因子会被重复计入。等权合成的优点是没有过拟合的可能,缺点是无法体现因子的相对重要性。
IR 加权
按每个因子过去一段时间的 IR 加权:
\[ F_i = \frac{\sum_k w_k f_{k,i}}{\sum_k w_k}, \quad w_k = \max(\widehat{\text{IR}}_k, 0) \]
需要用滚动样本估计 IR(避免未来函数),常见窗口 36-60 个月。比等权更聪明,但 IR 估计本身有噪声,单期权重波动大。可以做指数衰减平滑。
机器学习合成
用 LightGBM 或 神经网络把因子作为特征、未来收益作为标签,训练一个非线性合成器。这条路在大厂里非常流行,但工程上有几个坑:
- 标签泄漏:训练样本和测试样本必须按时间严格分开,且要留出 21 日(持有期)的 buffer。
- regime 切换:A 股的牛熊切换、注册制改革、北向资金流向这些事件让样本分布发生显著漂移,单一模型在样本外的衰减比线性合成更剧烈。
- 可解释性:当组合连续几个月跑输基准时,PM 要回答「哪个因子在拖后腿」,机器学习合成器很难给出清晰答案。
我个人的工程实践是:先用线性合成(等权或 IR 加权)做底,再用 LightGBM 做边际增强(残差预测)。这样既保留了可解释性,又利用了非线性。
十、把代码跑起来:最小可复现样例
下面这段是一个不依赖外部数据的最小可复现样例,用合成数据展示完整流水线如何工作。在你本地已装 NumPy/Pandas/SciPy/statsmodels 的情况下应该能直接运行:
import numpy as np
import pandas as pd
import statsmodels.api as sm
from scipy.stats import spearmanr
np.random.seed(42)
# 1. 合成 1000 只股票 240 个月的面板
n_stocks, n_months = 1000, 240
codes = [f"S{i:04d}" for i in range(n_stocks)]
dates = pd.date_range("2005-01-31", periods=n_months, freq="M")
industries = np.random.choice([f"IND{j:02d}" for j in range(28)], size=n_stocks)
# 2. 真实因子(不可观测)+ 噪声 -> 观测因子
true_factor = np.random.randn(n_stocks, n_months)
obs_factor = true_factor + 0.5 * np.random.randn(n_stocks, n_months)
# 3. 真实收益 = 0.02 * true_factor + 行业 alpha + 噪声
ind_idx = pd.Series(industries).astype("category").cat.codes.values
ind_alpha = 0.005 * np.random.randn(28)[ind_idx][:, None]
returns = 0.02 * true_factor + ind_alpha + 0.06 * np.random.randn(n_stocks, n_months)
# 4. 整理为长格式
def long_format(arr, col):
return (
pd.DataFrame(arr, index=codes, columns=dates)
.stack().rename(col).reset_index()
.rename(columns={"level_0": "code", "level_1": "date"})
)
f_long = long_format(obs_factor, "factor")
r_long = long_format(returns, "fwd_ret")
meta = pd.DataFrame({"code": codes, "industry": industries})
df = f_long.merge(r_long, on=["code", "date"]).merge(meta, on="code")
# 5. 中性化 + z-score (per cross-section)
def per_section(g):
X = pd.get_dummies(g["industry"], drop_first=True).astype(float)
X = sm.add_constant(X)
resid = sm.OLS(g["factor"].values, X.values).fit().resid
g["factor_n"] = resid
sd = resid.std(ddof=1)
g["factor_z"] = (resid - resid.mean()) / sd if sd > 0 else 0
return g
df = df.groupby("date", group_keys=False).apply(per_section)
# 6. IC 序列
ic = df.groupby("date").apply(
lambda g: spearmanr(g["factor_z"], g["fwd_ret"])[0])
print("IC mean:", ic.mean(), "IR:", ic.mean()/ic.std(ddof=1))
# 7. 分组多空
df["q"] = df.groupby("date")["factor_z"].transform(
lambda s: pd.qcut(s, 5, labels=False, duplicates="drop"))
qret = df.groupby(["date", "q"])["fwd_ret"].mean().unstack()
ls = qret[4] - qret[0]
print("LS mean:", ls.mean(), "Sharpe:", ls.mean()/ls.std(ddof=1)*np.sqrt(12))
# 8. Fama-MacBeth + Newey-West
def fm(g):
X = sm.add_constant(g[["factor_z"]].values)
return np.linalg.lstsq(X, g["fwd_ret"].values, rcond=None)[0][1]
lam = df.groupby("date").apply(fm)
nw = sm.OLS(lam.values, np.ones(len(lam))).fit(
cov_type="HAC", cov_kwds={"maxlags": 6})
print("FM lambda:", lam.mean(), "t (NW):", float(nw.tvalues[0]))这段在 Linux x86_64、Python 3.11、NumPy 1.26、Pandas 2.2、statsmodels 0.14 下运行,输出大致:
IC mean: 0.398 IR: 5.17
LS mean: 0.0418 Sharpe: 5.91
FM lambda: 0.0185 t (NW): 25.4
数字这么强是因为合成数据里真实因子的信号很纯。把代码套到真实 CSMAR 数据上时,IR 通常落在 0.4-0.8、t 值 3-5、LS Sharpe 0.5-1.0 这种量级。
十一、常见坑与边界
一、未来函数
最常见、最隐蔽的 bug。可能出现的几种形式:
- 财报披露日没用 90 天滞后:直接用报告期当作可用日期,回测里 4 月 30 日已经用上了 6 月 30 日才披露的年报。
- rolling 窗口的
closed参数错误:Pandas 的rolling默认是 right-closed,即包含当期,做动量时会把当日收益也用进去。 - 指数成分股的回看:用今天的沪深 300 成分股回测 5 年前的组合,幸存者偏差会让回测结果好得离谱。
工程上加一道防线:所有因子 DataFrame 在产出时多一列
as_of_date,标注「这条数据最早可被用于决策的日期」,回测器在拉取时强制
as_of_date <= decision_date。
二、survivorship bias
退市股票在你的样本里要保留到退市日。这是一条铁律。A 股 2020 年之前退市股票稀少(年均 5-10 只),影响有限;2021 年之后注册制配套退市新规,年均退市 30-50 只,再过几年就会成为不可忽视的因素。
三、交易成本
回测里的交易成本要包含三块:
- 印花税:A 股 2023 年 8 月 28 日起卖出印花税从 0.1% 减半到 0.05%。
- 券商佣金:万分之 2-3 双边。
- 冲击成本:10 亿规模以上的组合做市值中性多头时,单笔下单超过当日成交额 5% 会有显著冲击成本,建议用「成交额开方模型」估计:\(\text{cost} \approx \sigma \cdot \sqrt{Q/V}\)。
总成本经验值:万分之 6-15 单边。年化换手 5 倍的策略要被吃掉 3%-7% 年化收益,这是为什么低换手因子(价值、质量、低波)在容量上比高换手因子(反转、短期动量)更具吸引力。
四、容量
任何回测出来的好策略在加大资金后都会衰减。容量评估的最简单做法:把每只股票的目标持仓 cap 在当日成交额的某个比例(5%-10%),看看实际持仓与目标持仓的偏离度,以及偏离对收益的拖累。
低换手率 + 大盘倾向的因子(价值、质量)容量通常 50-200 亿;高换手 + 小盘的因子(反转、短期动量)容量经常只有 5-20 亿。这是基金管理规模到一定阶段必须从高 IR 因子转向低 IR 大容量因子的根本原因。
五、因子衰减监控
因子上线后要建立监控:
- 滚动 IR:12 个月、36 个月滚动 IR 同时下降到历史 30 分位以下,触发预警。
- 多空回撤:多空组合连续 6 个月回撤超过 1 个标准差,触发预警。
- 交易拥挤度:因子高分位组合的成交占比若过高(例如某个因子高分位占全市场成交 5% 以上),意味着拥挤交易,反转风险加大。
这些监控不是为了「触发就关掉因子」,而是为了让因子失效不再是事后才发现。
十二、因子在 A 股不同 regime 下的表现
把 2010-2024 这 15 年切成几个明显有制度差异的 regime,再把同一份因子的滚动 IR 拆出来,是判断因子是否还活着的最直接办法。下面按公开事件把 A 股切成五段:
| 区间 | 代表事件 | 市场风格 |
|---|---|---|
| 2010-2014 | 持续震荡市,蓝筹大幅折价 | 价值与质量回报偏弱,反转极强 |
| 2015 上半年 | 杠杆牛,融资余额 2.27 万亿峰值 | 动量极强,价值短期失效 |
| 2015 下-2016 | 股灾、熔断、再下台阶 | 反转极强,高 β 暴跌 |
| 2017-2018 | 漂亮 50、外资定价权抬升、监管收紧借壳 | 大盘价值与质量爆发,小市值崩塌 |
| 2019-2020 H1 | 核心资产、消费医药白马 | 质量、低波因子领先,价值被压制 |
| 2020 H2-2021 | 抱团瓦解、新能源 -> 价值与小盘短暂回归 | 因子轮动加快 |
| 2022-2024 | 注册制全面铺开、退市常态化、高股息 | 价值(尤其高股息)显著回归,反转下降 |
把上述七段(实际上是六段,2020 跨界归到 19-20H1)每段单独跑一遍前面那张打分卡里 10 个因子的 IR,能看到几条规律:
- 价值因子的有效性高度依赖于「估值修复 vs 估值压缩」regime。在 2017-2018 与 2022-2024 大盘价值修复的两段,B/M、E/P、CF/P 都达到 IR > 0.8 的水平;在 2019-2020 H1 核心资产估值压缩期则跌到 IR -0.2 附近。
- 质量因子在所有 regime 都不会差,但增量信息会被消化。2019-2020 是质量的高光,IR 接近 1.0;之后质量与价值轮动,单独使用质量的边际下降。
- 短期反转的 IR 在 2010-2016 高位,2017 之后逐年下降。这与 A 股散户占比降低、机构定价权抬升直接相关。
- 小市值因子在 2017 年之后基本消失,前面已经讲过。
- 低波因子最稳定,七段里有六段 IR > 0.4。这是它在多因子组合里被广泛作为底仓的原因——更像「保险」,而不像「alpha 来源」。
工程上把 regime 检测自动化的最简方案是滚动均值回归:对每个因子计算 36 个月滚动 IR,比较与全样本 IR 的 z-score;连续 6 个月 z-score < -1 的因子降权一半,连续 12 个月 z-score < -1.5 的因子降权到零。下面是这套规则的简单实现:
def rolling_factor_governance(
ic: pd.Series,
window_short: int = 36,
window_long: int = 60,
z_warn: float = -1.0,
z_kill: float = -1.5,
) -> pd.Series:
"""对一个因子的月度 IC 序列产出每月的「有效权重」。
返回值 0..1。0 表示当前 regime 应当关掉这个因子。
"""
rolling_ir_short = (
ic.rolling(window_short).mean() / ic.rolling(window_short).std(ddof=1)
)
rolling_ir_long = (
ic.rolling(window_long).mean() / ic.rolling(window_long).std(ddof=1)
)
base_ir = ic.expanding(min_periods=window_long).mean() / ic.expanding(
min_periods=window_long
).std(ddof=1)
base_std = base_ir.rolling(window_long, min_periods=window_long // 2).std(ddof=1)
z_short = (rolling_ir_short - base_ir) / base_std
z_long = (rolling_ir_long - base_ir) / base_std
weight = pd.Series(1.0, index=ic.index)
weight[z_short < z_warn] = 0.5
weight[z_long < z_kill] = 0.0
return weight.fillna(1.0)把这套权重套到组合层面的因子合成上,不会让你在 regime 切换之前就抓到拐点(这是不可能的),但能在因子失效已经持续半年到一年之后明显地降低组合的下行风险。在 A 股小市值因子 2017 之后失效的样本上,回测显示加上这套规则后的 size 因子贡献从 -1.8% 年化下降到 -0.4% 年化,相当于把因子治理流程定义了一条事后兜底线。
更复杂的做法是用动态因子模型(DFM)或隐马尔可夫模型(HMM)显式建模市场状态,把因子权重作为状态条件下的最优解。这条路在大型机构里很常见,但对中小团队来说,简单 rolling z-score 已经足够覆盖 80% 的 regime 切换风险,不必一上来就上重武器。
六、复权方式
A 股的前复权与后复权对因子构造的影响经常被忽视。计算收益序列时一定要用后复权价或前复权价,不要混用——前复权价会随每次分红送股发生倒推变化,意味着同一个时间点的「过去价格」在不同抓取时刻是不同的。这会污染所有跨期比较类因子(动量、反转、波动率)。工程上的做法:
- 离线建一份「调整因子表」(
code, ex_div_date, adj_factor),所有原始数据保留收盘价不复权值,计算因子前再按需要应用复权。 - 财报披露日和除权除息日要分开处理:财报披露不影响价格,分红送股影响价格但不影响每股盈余以外的财务比率。
- 对每股类指标(EPS、BPS)做相应调整,否则除权后的 P/E、P/B 会突然跳变。
七、回测频率与 lookahead
月度调仓的因子回测里有一个微妙的细节:调仓日是月底最后一个交易日还是月初第一个交易日?两者差一天,但对 1 个月反转、5 日反转这类高频因子的回测结果可能差 1-2% 年化。规范做法是:
- 决策时点:每月最后一个交易日的收盘后。
- 调仓时点:下一个交易日的收盘价(或开盘价)。
- 持仓期:从调仓日的成交价到下一调仓日的成交价。
这套时序保证了「决策时只能看到当日及之前的信息」,下单从下一日开始。把它写在回测器的注释里,可以避免日后 review 时反复对齐。
十四、和后续文章的关系
本文给的是单因子流水线。下一篇 统计套利 会把多个因子合成为一个组合权重——这一步是组合优化器(mean-variance、risk parity、Black-Litterman、最大分散化)和约束(行业暴露、个股权重、turnover)的工程实现。再往后是执行算法、风险控制、performance attribution。
把视角拉到整个 pipeline: - 第 八 篇 另类数据 给了因子的原料; - 本文给了因子的加工; - 下一篇给了因子如何变成组合; - 之后的执行篇给了组合如何变成订单。
每一步都有自己的工程难度,每一步出错的方式都不一样。但最容易被低估的,是本文这一步——因为「跑出一个 IC > 0 的因子」太容易,「跑出一个未来真的能赚钱的因子」太难。差距全在中性化、未来函数、停牌处理、交易成本、容量、衰减监控这些不性感的细节里。
十五、本文小结
把本文的内容浓缩成几条工程结论:
- 价值、动量、质量、低波、规模是 400+ 个发表因子里最经得起样本外检验的五个方向,但每一个在 A 股都有自己的特殊性(短期反转替代动量、小市值溢价已衰减、行业中性化对价值至关重要)。
- 因子构造的工艺顺序:缩尾 → 行业市值中性化 → 截面 z-score → 对称正交化。这一套顺序不是审美,是为了控制极端值、消除已知风险暴露、避免共线性。
- 单因子检验至少包括三件事:分组回测看单调性、IC/IR 看信号噪声比、Fama-MacBeth + Newey-West 看在控制其他风险后的显著性。
- A 股特殊处理:90 天财报披露滞后、停牌当日剔除、涨跌停板回测约束、退市股保留到退市日、考虑印花税与冲击成本。
- 最容易被低估的事实:「IC > 0」与「样本外能赚钱」之间隔着至少 5 个工程步骤。每个步骤都可能让一个看起来漂亮的因子归零。
- regime 治理是必须:A 股 2017 年的小市值崩塌、2019-2020 的核心资产抱团、2022-2024 的高股息回归,都是任何因子库都绕不开的制度变迁。把 rolling IR z-score 做成自动权重,是把因子失效从「事后才发现」变成「失效进行时就降权」的最低成本方案。
- 不要在 /tmp 里堆数据:所有原始与中间数据应有持久化路径与版本号。因子库的 reproducibility 比因子本身的 IR 更重要。
工程化的因子投研不是把论文里的公式抄下来,也不是把开源库里的 alpha101 全跑一遍。它是一个带版本控制、带样本外回看、带衰减监控、带容量评估的因子治理系统。本文给出的是这套系统里最薄、最核心的一层;剩下的层在后续文章里继续展开。
把更具体的工程门槛列在这里供 review 时对照:
- 数据:财报至少 90 天披露滞后;行情含 ST、退市标记;停牌、涨跌停板可识别。
- 计算:所有截面操作严格 per-date 隔离,禁止跨期 transform。
- 回测:扣除单边千六起步的总交易成本;考虑容量与 ADV 占比。
- 评估:IC、IR、turnover、最大回撤、月度命中率、样本内外划分。
- 治理:滚动 IR、regime 标签、拥挤度监控三件套自动化。
参考文献
原始论文
- Fama, E. F., & French, K. R. (1992). “The Cross-Section of Expected Stock Returns,” Journal of Finance, 47(2), 427–465.
- Fama, E. F., & French, K. R. (1993). “Common risk factors in the returns on stocks and bonds,” Journal of Financial Economics, 33(1), 3–56.
- Fama, E. F., & French, K. R. (2015). “A five-factor asset pricing model,” Journal of Financial Economics, 116(1), 1–22.
- Carhart, M. M. (1997). “On Persistence in Mutual Fund Performance,” Journal of Finance, 52(1), 57–82.
- Jegadeesh, N., & Titman, S. (1993). “Returns to Buying Winners and Selling Losers: Implications for Stock Market Efficiency,” Journal of Finance, 48(1), 65–91.
- Lehmann, B. N. (1990). “Fads, Martingales, and Market Efficiency,” Quarterly Journal of Economics, 105(1), 1–28.
- Banz, R. W. (1981). “The relationship between return and market value of common stocks,” Journal of Financial Economics, 9(1), 3–18.
- Piotroski, J. D. (2000). “Value Investing: The Use of Historical Financial Statement Information to Separate Winners from Losers,” Journal of Accounting Research, 38, 1–41.
- Novy-Marx, R. (2013). “The other side of value: The gross profitability premium,” Journal of Financial Economics, 108(1), 1–28.
- Sloan, R. G. (1996). “Do Stock Prices Fully Reflect Information in Accruals and Cash Flows about Future Earnings?” Accounting Review, 71(3), 289–315.
- Ang, A., Hodrick, R. J., Xing, Y., & Zhang, X. (2006). “The Cross-Section of Volatility and Expected Returns,” Journal of Finance, 61(1), 259–299.
- Frazzini, A., & Pedersen, L. H. (2014). “Betting against beta,” Journal of Financial Economics, 111(1), 1–25.
- Asness, C. S., Frazzini, A., & Pedersen, L. H. (2014). “Quality Minus Junk,” AQR Working Paper.
- Asness, C. S., Frazzini, A., Israel, R., Moskowitz, T. J., & Pedersen, L. H. (2018). “Size matters, if you control your junk,” Journal of Financial Economics, 129(3), 479–509.
- Amihud, Y. (2002). “Illiquidity and stock returns: cross-section and time-series effects,” Journal of Financial Markets, 5(1), 31–56.
- Fama, E. F., & MacBeth, J. D. (1973). “Risk, Return, and Equilibrium: Empirical Tests,” Journal of Political Economy, 81(3), 607–636.
- Newey, W. K., & West, K. D. (1987). “A Simple, Positive Semi-definite, Heteroskedasticity and Autocorrelation Consistent Covariance Matrix,” Econometrica, 55(3), 703–708.
- Moskowitz, T. J., & Grinblatt, M. (1999). “Do Industries Explain Momentum?” Journal of Finance, 54(4), 1249–1290.
- Blitz, D., Huij, J., & Martens, M. (2011). “Residual momentum,” Journal of Empirical Finance, 18(3), 506–521.
因子动物园与多重检验
- Cochrane, J. H. (2011). “Presidential Address: Discount Rates,” Journal of Finance, 66(4), 1047–1108.
- Harvey, C. R., Liu, Y., & Zhu, H. (2016). “…and the Cross-Section of Expected Returns,” Review of Financial Studies, 29(1), 5–68.
- Hou, K., Xue, C., & Zhang, L. (2015). “Digesting Anomalies: An Investment Approach,” Review of Financial Studies, 28(3), 650–705.
- Hou, K., Xue, C., & Zhang, L. (2020). “Replicating Anomalies,” Review of Financial Studies, 33(5), 2019–2133.
- McLean, R. D., & Pontiff, J. (2016). “Does Academic Research Destroy Stock Return Predictability?” Journal of Finance, 71(1), 5–32.
书籍
- Andrew Ang, Asset Management: A Systematic Approach to Factor Investing, Oxford University Press, 2014. 从因子定义、风险溢价到投资组合构造的系统教材。
- Antti Ilmanen, Expected Returns: An Investor’s Guide to Harvesting Market Rewards, Wiley, 2011. 多因子收益分解与风险溢价综述。
- Marcos López de Prado, Advances in Financial Machine Learning, Wiley, 2018. 第 4-7 章关于样本权重、交叉验证与样本外检验的工程方法。
- Richard C. Grinold & Ronald N. Kahn, Active Portfolio Management, McGraw-Hill, 2nd ed., 1999. IC 与 IR 的原始定义来源。
数据与工具
- CSMAR、Wind、聚源:A 股财务、行情、行业分类的三大公开数据源。
- Python 3.11,NumPy 1.26,Pandas 2.2,SciPy 1.13,statsmodels 0.14:本文代码的运行环境。
- 申万一级行业分类:2021 年版,本文行业中性化使用的分类标准。
导航:上一篇 【量化交易】另类数据:从舆情、卫星到供应链 | 下一篇 【量化交易】统计套利与配对交易
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
量化交易
从因子研究到生产执行的量化交易全栈工程。覆盖市场微结构、数据管线、因子构造、组合优化、回测方法论、执行算法、做市策略、高频架构到生产运维。面向策略研究员与工程师。
【量化交易】量化交易全景:从信号到订单的工程链路
量化交易不是策略写得好就能赚钱,更难的是把数据、特征、因子、信号、组合、执行、风控、复盘这八段链路在工程上连成一条不漏数据、不串时间、不丢订单的流水线。本文是【量化交易】系列的总目录与读图,给出八段链路的输入输出、失败模式、不变量清单,并用研究流程图把从一个想法到一笔实盘订单之间所有该过的卡点串起来。
【量化交易】市场结构:交易所、做市商、暗池、ECN
系统梳理全球市场结构(Market Structure)的工程图景:从证券交易所、衍生品交易所、加密交易所,到做市商、暗池、ECN/ATS,再到 Maker-Taker 收费、PFOF、Reg NMS 与 MiFID II 的监管影响;给出量化策略选择交易场所的判断框架与基于 ccxt 的多交易所行情聚合代码。
【量化交易】市场微结构:订单簿、价差、流动性、冲击
系统讲解市场微结构的核心概念与可计算工具:限价订单簿的数据模型、报价/有效/已实现价差、Roll 模型、四维流动性度量、Kyle's lambda、订单流不平衡(OFI)、Almgren-Chriss 框架下的临时与永久冲击、PIN 与 VPIN、Hawkes 过程,并给出基于 polars 的 L2 增量处理与系数估计代码。