3. 单因子构建:从零到一搭建你的第一个有效因子
聊到因子构建,我得先跟你掏个底——这活儿看着简单,做起来全是坑。我刚开始做量化那会儿,以为找个指标算一算就完事了,结果回测曲线漂亮得不像话,实盘一跑直接翻车。后来才明白,因子构建不是算数,是门手艺活。
说白了,因子就是能预测股票未来收益的某个特征。比如市盈率、动量、换手率,这些都是因子。但怎么把这些原始数据变成真正能用的因子?这里头有四个关键步骤:构建原则、构建方法、暴露度计算、中性化处理。咱们一个一个来拆。
3.1 因子构建原则:别踩这些坑
我总结了几条铁律,你写因子前最好先过一遍:
- 逻辑要硬——因子背后得有经济学或行为金融学的解释。比如低波动异象,为什么低波动股票反而收益更高?因为散户喜欢追高,把高波动股票价格推上去了,预期收益自然就低了。这个逻辑说得通。
- 数据要稳——别用未来数据,别用幸存者偏差。我见过有人用"过去一年涨幅"做因子,结果把退市股票全删了,回测效果当然好,实盘直接傻眼。
- 分布要正常——极端值要处理,不然一个异常值就能把整个组合带偏。我习惯用MAD(中位数绝对偏差)去极值,比3σ稳健得多。
- 相关性要低——你辛辛苦苦造了10个因子,结果它们之间相关系数0.9,那跟用一个因子没区别。
3.2 常见因子构建方法:截面回归 vs 时间序列排序
构建方法主要有两种,我分别说说我的使用心得。
3.2.1 截面回归法
截面回归,说白了就是在每个时间截面上,用股票的特征去回归它的收益。比如你想知道"市净率"这个因子有没有用,就把所有股票的当期收益对市净率做回归,看系数是否显著。
这种方法的好处是能同时控制多个变量,比如你可以在回归中加入市值、行业作为控制变量,这样得到的因子暴露就更干净。但缺点也很明显——对异常值敏感,而且假设线性关系,现实往往没那么简单。
# 截面回归示例(Python伪代码)
import statsmodels.api as sm
# 假设 data 包含:return, pb, market_cap, industry_dummy
X = data[['pb', 'market_cap'] + industry_dummy_cols]
y = data['return']
# 加截距项
X = sm.add_constant(X)
# 做截面回归
model = sm.OLS(y, X).fit()
factor_exposure = model.params['pb'] # 这就是pb因子的暴露度
3.2.2 时间序列排序法
这个方法更直观——把股票按某个特征排序,然后分组看收益差异。比如把股票按市盈率从低到高分成10组,看低市盈率组是不是比高市盈率组收益更高。
我个人更喜欢排序法,原因有三:
- 不依赖线性假设,能捕捉非线性关系
- 对异常值不那么敏感,排序只看相对位置
- 结果直观,好跟老板解释
但排序法也有局限——它只能告诉你"高 vs 低"有没有差异,没法精确量化每个股票的因子暴露度。
# 时间序列排序示例
import pandas as pd
# 每月对股票按pe排序
data['pe_rank'] = data.groupby('month')['pe'].rank(pct=True)
# 构建多空组合
long = data[data['pe_rank'] <= 0.2] # 低市盈率组
short = data[data['pe_rank'] >= 0.8] # 高市盈率组
# 计算多空收益差
long_ret = long.groupby('month')['return'].mean()
short_ret = short.groupby('month')['return'].mean()
spread = long_ret - short_ret
3.3 因子暴露度计算:你到底暴露了多少?
因子暴露度,就是每只股票在某个因子上的"得分"。比如你构建了一个"动量因子",那每只股票都有一个动量得分,这个得分就是暴露度。
计算暴露度有两种常见做法:
- 直接法——标准化后的原始值就是暴露度。比如把市盈率做Z-score标准化,得到的值就是暴露度。简单粗暴,但容易受极端值影响。
- 回归法——用时间序列回归得到因子载荷。比如用股票收益对因子收益做回归,回归系数就是暴露度。这种方法更精确,但需要较长的历史数据。
我一般这样处理:先用直接法算一个初步暴露度,然后用回归法做验证。如果两者差异很大,说明数据可能有问题,需要回头检查。
# 直接法计算因子暴露度
from scipy import stats
# 对因子值做横截面标准化
def calc_exposure(factor_values):
# 去极值(MAD方法)
median = factor_values.median()
mad = (factor_values - median).abs().median()
factor_values = factor_values.clip(median - 5*mad, median + 5*mad)
# Z-score标准化
exposure = stats.zscore(factor_values)
return exposure
3.4 因子中性化处理:把干扰项剔除干净
这是最容易踩坑的一步。你想想看,如果某个因子跟市值高度相关,那它到底是因子本身有效,还是因为买小盘股赚了钱?中性化就是为了回答这个问题。
3.4.1 市值中性化
市值是A股最强的风格因子,没有之一。我做过统计,A股中超过60%的因子都跟市值有显著相关性。所以市值中性化几乎是必做的一步。
做法很简单:把因子暴露度对市值做回归,取残差作为中性化后的暴露度。
# 市值中性化
def market_neutralize(exposure, market_cap):
# 对市值取对数,更符合正态分布
log_mkt = np.log(market_cap)
# 回归取残差
X = sm.add_constant(log_mkt)
model = sm.OLS(exposure, X).fit()
neutral_exposure = model.resid
return neutral_exposure
3.4.2 行业中性化
行业中性化道理一样——不同行业的股票天然有差异。比如银行股PE普遍低,消费股PE普遍高,如果不做行业中性化,你可能会误以为"低PE因子"有效,其实只是因为你重仓了银行股。
行业中性化通常用虚拟变量回归:
# 行业中性化
def industry_neutralize(exposure, industry_dummies):
# industry_dummies 是one-hot编码的行业哑变量
X = sm.add_constant(industry_dummies)
model = sm.OLS(exposure, X).fit()
neutral_exposure = model.resid
return neutral_exposure
实际操作中,我一般会把市值和行业一起中性化,一步到位:
# 市值+行业联合中性化
def joint_neutralize(exposure, market_cap, industry_dummies):
log_mkt = np.log(market_cap)
X = pd.concat([pd.Series(log_mkt, name='log_mkt'), industry_dummies], axis=1)
X = sm.add_constant(X)
model = sm.OLS(exposure, X).fit()
neutral_exposure = model.resid
return neutral_exposure
3.5 本章知识体系总览
说了这么多,我画了张图帮你梳理一下整个因子构建的流程。你一看就明白了:
嗯,以上就是单因子构建的全部核心内容。从原则到方法,从暴露度到中性化,每一步都有讲究。我刚开始做的时候也踩过不少坑,但只要你按照这个流程走下来,至少能保证你的因子是"干净"的。
记住一句话:因子构建不是终点,而是起点。一个干净的因子,才能为后续的组合优化打下坚实基础。