第四章:因子构建与计算

因子构建,说白了就是把你脑子里的投资逻辑,变成一串可计算的数字。我做了这么多年量化,见过太多人拿着漂亮的理论模型,结果一算因子全是噪音。今天咱们就聊聊,怎么把因子从想法落地到代码。

4.1 因子计算公式:从逻辑到数学

每个因子背后都有一个故事。比如动量因子,逻辑是“过去涨的股票未来还会涨”。那怎么算?最简单的就是过去N天的收益率。

# 动量因子:过去20天累计收益率
def momentum_factor(close_prices, window=20):
    return close_prices / close_prices.shift(window) - 1

我个人习惯,写因子公式前先问自己三个问题:

  • 这个因子是截面比较,还是时间序列比较? 截面比较看相对强弱,时间序列看自身变化。
  • 数据频率是什么? 日频、周频还是月频?不同频率噪音水平差很多。
  • 有没有行业或市值偏见? 很多因子天然偏向小盘股,后面我们会处理。

举个例子,我做过一个“毛利率变化因子”。公式很简单:

# 毛利率变化 = 本期毛利率 - 上期毛利率
gross_margin_change = (revenue - cost) / revenue - prev_gross_margin

但注意,这里有个坑。毛利率变化在行业间不可比——科技公司毛利率80%,零售公司才20%。直接拿原始值做截面比较,结果全是行业效应。嗯,这就是为什么我们需要标准化和中性化。

4.2 标准化处理:让因子站在同一起跑线

标准化,就是把不同量纲的因子拉到同一个尺度。我最常用的两种方法:Z-score和分位数。

4.2.1 Z-score标准化

Z-score的公式很简单:

Z = (X - μ) / σ

其中μ是均值,σ是标准差。处理后的因子均值为0,标准差为1。

我在项目中遇到过一个问题:因子值有极端异常点。比如某只股票因为数据错误,因子值跑到100个标准差之外。这时候Z-score会被严重扭曲。怎么办?

避坑指南: 我曾经因为没做极端值处理,直接跑Z-score,结果整个因子失效了。建议先做MAD(中位数绝对偏差)截断,或者用分位数截断(比如上下1%)。
# 带极端值处理的Z-score
def robust_zscore(factor, cap=5):
    median = factor.median()
    mad = (factor - median).abs().median()
    # 用MAD替代标准差,更稳健
    z = (factor - median) / (mad * 1.4826)
    # 截断极端值
    return z.clip(-cap, cap)

4.2.2 分位数标准化

分位数标准化,说白了就是把因子值映射到0到1之间。我比较喜欢用rank分位数:

# 分位数标准化
def rank_quantile(factor, n_quantiles=5):
    rank = factor.rank()
    return (rank - 1) / (len(factor) - 1)

你想想看,分位数标准化的好处是什么?它对异常值不敏感。不管你的因子值多大,排名第1就是1,排名最后就是0。但代价是丢失了因子值之间的间距信息。

方法 优点 缺点 适用场景
Z-score 保留间距信息 对异常值敏感 因子值分布较正态
分位数 稳健,不受异常值影响 丢失间距信息 因子有极端值

4.3 中性化处理:剥离干扰因素

中性化,是因子构建中最容易被忽视的一步。我见过太多人,因子跑出来IC很高,结果一分析,全是行业和市值在起作用。

4.3.1 市值中性化

市值中性化的逻辑很简单:把因子中对市值的暴露剥离掉。方法是用因子值对市值做回归,取残差。

# 市值中性化:回归取残差
import statsmodels.api as sm

def market_cap_neutralize(factor, market_cap):
    X = sm.add_constant(np.log(market_cap))
    model = sm.OLS(factor, X).fit()
    residual = model.resid
    return residual

为什么要取对数市值?因为市值分布是右偏的,取对数后更接近正态分布。这是我踩过坑才学到的——直接用原始市值做回归,小市值股票会被过度调整。

4.3.2 行业中性化

行业中性化更直接:在每个行业内,把因子值减去行业均值。

# 行业中性化:组内去均值
def industry_neutralize(factor, industry):
    return factor - factor.groupby(industry).transform('mean')

我个人习惯,做行业中性化时用申万一级行业。但要注意,有些行业股票太少(比如综合行业),中性化后因子值可能不稳定。我曾经遇到过,某个行业只有3只股票,中性化后因子值完全失去了区分度。

小技巧: 如果某个行业股票数少于10只,建议合并到相近行业,或者直接剔除。别让少数股票影响整体因子质量。

4.4 综合处理流程:一个完整的例子

好了,咱们把上面这些串起来。假设我们要构建一个“市盈率倒数因子”(EP因子):

# 完整因子处理流程
def factor_pipeline(data):
    # 1. 原始因子计算
    data['EP'] = data['earnings'] / data['market_cap']
    
    # 2. 极端值处理(MAD截断)
    median = data['EP'].median()
    mad = (data['EP'] - median).abs().median()
    data['EP_clipped'] = data['EP'].clip(
        lower=median - 5 * mad * 1.4826,
        upper=median + 5 * mad * 1.4826
    )
    
    # 3. 市值中性化
    X = sm.add_constant(np.log(data['market_cap']))
    model = sm.OLS(data['EP_clipped'], X).fit()
    data['EP_neutral'] = model.resid
    
    # 4. 行业中性化
    data['EP_final'] = data['EP_neutral'] - \
        data.groupby('industry')['EP_neutral'].transform('mean')
    
    # 5. Z-score标准化
    data['EP_zscore'] = (data['EP_final'] - data['EP_final'].mean()) / \
        data['EP_final'].std()
    
    return data

这个流程,我建议你每次构建因子都走一遍。顺序很重要:先处理极端值,再做中性化,最后标准化。为什么?因为极端值会影响回归系数的估计,所以要先处理掉。

4.5 本章知识体系

下面这张图,是我自己总结的因子构建流程。每次做新因子,我都会对着这张图检查一遍:

因子构建与处理流程图 原始因子计算 极端值处理(MAD截断) 中性化处理 市值中性化(回归取残差) 行业中性化(组内去均值) 标准化(Z-score/分位数) 最终因子值 注意事项 1. 顺序不能乱:先极端值 再中性化,最后标准化 2. 市值中性化用对数市值 3. 行业股票少于10只需合并 4. Z-score前必须处理异常值 5. 分位数标准化丢失间距信息 6. 每次处理完检查分布 7. 保留原始因子值备份

这张图我贴在公司工位上好几年了。每次构建新因子,我都会对着它走一遍流程。你可能会觉得麻烦,但相信我,这一步省了,后面回测出问题更麻烦。

核心要点: 因子构建不是简单的公式计算。极端值处理、中性化、标准化,每一步都在消除噪音。我见过太多人因子IC很高,但实盘一塌糊涂——原因就是没做中性化,因子暴露在行业和市值上。

好了,这一章的内容就到这里。因子构建是量化投资的基石,花时间把这一步做扎实,后面的组合优化才能站得住脚。


公众号:蓝海数据掘金营,微信deep3321