Appearance
因子构建与筛选:如何构建和验证一个有效因子
你可能有一个很好的投资想法——"高ROE的公司应该涨得更好"。但这个直觉对不对?效果有多强?在什么条件下有效、什么条件下失效?这些问题不能靠拍脑袋回答,必须通过系统化的因子研究流程来验证。
因子构建与筛选是量化投资研究中最核心的环节。一个好的因子研究流程,能够将投资直觉转化为可验证、可重复的量化信号,并通过严格的统计检验来判断其有效性。本文将详细介绍从数据准备到因子有效性判断的完整流程。
整体流程概览
一个完整的因子研究流程包含以下关键步骤:
投资想法 → 数据准备 → 因子值计算 → 去极值 → 标准化 → 中性化 → 有效性检验 → 因子筛选每一步都有其特定的目的和方法,跳过任何一步都可能导致最终结论的偏差。下面逐一展开。
第一步:数据准备
数据是因子研究的基石。再好的因子逻辑,如果数据质量有问题,结论就是不可靠的。
数据来源
A股市场常用的数据来源包括:
- Tushare:提供A股日线行情、财务数据、指数数据等,社区版免费额度有限
- AKShare:开源免费,数据覆盖面广,但稳定性参差不齐
- Wind:机构级数据服务,数据质量和覆盖度最好,但费用较高
- 聚宽/米筐:量化平台自带的数据接口,适合在平台上直接使用
- Choice:东方财富的数据终端,面向专业投资者
数据质量检查
获取数据后,必须进行质量检查:
python
import pandas as pd
import numpy as np
def check_data_quality(df: pd.DataFrame) -> dict:
"""数据质量检查报告"""
report = {
'总记录数': len(df),
'缺失率': df.isnull().mean().to_dict(),
'重复记录数': df.duplicated().sum(),
'日期范围': f"{df['date'].min()} ~ {df['date'].max()}",
'股票数量': df['code'].nunique(),
}
# 检查价格异常值
if 'close' in df.columns:
neg_prices = (df['close'] <= 0).sum()
report['非正价格记录数'] = neg_prices
# 检查收益率异常
if 'close' in df.columns:
df_sorted = df.sort_values(['code', 'date'])
daily_ret = df_sorted.groupby('code')['close'].pct_change()
extreme_ret = (daily_ret.abs() > 0.3).sum() # 超过30%的日收益率
report['极端日收益记录数'] = extreme_ret
return report常见数据问题
- 停牌数据:停牌期间的价格不变,但成交量为零。需要标记或排除停牌日。
- 复权处理:必须使用前复权或后复权价格来计算收益率,否则分红除权会导致虚假的下跌。
- ST标记:ST股票涨跌停限制为5%,交易行为与正常股票不同,通常需要单独处理或排除。
- 上市时间:新股上市初期波动剧烈,建议排除上市不满60个交易日的股票。
- 退市股票:如果数据中不包含已退市股票,就会产生存活者偏差。
python
def filter_universe(df: pd.DataFrame) -> pd.DataFrame:
"""基础股票池筛选"""
# 排除ST
df = df[~df['name'].str.contains('ST', na=False)]
# 排除新股(上市不满60个交易日)
list_date_count = df.groupby('code')['date'].transform('count')
df = df[list_date_count >= 60]
# 排除停牌(成交量为0)
df = df[df['volume'] > 0]
# 排除涨跌停(无法正常交易)
df = df[df['pct_change'].abs() < 9.9]
return df第二步:因子值计算
数据准备完毕后,根据投资逻辑计算因子值。这里需要注意几个关键问题。
因子定义的精确性
一个好的因子定义必须足够精确,使得不同人在不同时间点计算的结果完全一致。模糊的定义会导致不可复现。
示例——定义"盈利能力"因子:
python
def calc_profitability_factor(df: pd.DataFrame) -> pd.DataFrame:
"""计算盈利能力因子"""
# ROE = 净利润 / 净资产
df['roe'] = df['net_income'] / df['total_equity']
# 注意处理分母为零或为负的情况
df.loc[df['total_equity'] <= 0, 'roe'] = np.nan
return df时点对齐
A股财务数据按季度披露,但披露时间有延迟。一季报最迟在4月30日披露,半年报最迟在8月31日。在计算因子时,必须确保只使用当时已知的信息,不能使用尚未披露的数据:
python
def align_fundamental_data(price_df: pd.DataFrame, fin_df: pd.DataFrame) -> pd.DataFrame:
"""将财务数据按披露时点对齐到价格数据"""
# 为每条财务数据标记其可用起始日期
fin_df['available_date'] = pd.to_datetime(fin_df['report_date'])
# 一季报:4月30日前披露
# 半年报:8月31日前披露
# 三季报:10月31日前披露
# 年报:4月30日前披露
for _, row in fin_df.iterrows():
report_month = row['report_date'].month
if report_month == 3:
available = pd.Timestamp(year=row['report_date'].year, month=4, day=30)
elif report_month == 6:
available = pd.Timestamp(year=row['report_date'].year, month=8, day=31)
elif report_month == 9:
available = pd.Timestamp(year=row['report_date'].year, month=10, day=31)
elif report_month == 12:
available = pd.Timestamp(year=row['report_date'].year + 1, month=4, day=30)
else:
continue
fin_df.loc[fin_df.index == _, 'available_date'] = available
# 使用 merge_asof 按时点对齐
merged = pd.merge_asof(
price_df.sort_values('date'),
fin_df.sort_values('available_date'),
by='code',
left_on='date',
right_on='available_date',
direction='backward' # 只回溯使用已公布的数据
)
return merged多频率数据处理
价格数据是日频的,财务数据是季频的。在计算因子时需要正确处理这种频率差异。通常的做法是将季频财务数据向前填充(ffill)到日频,但必须确保只填充"已披露"的数据。
第三步:去极值
计算完因子值后,数据中可能存在极端异常值。这些异常值可能来自数据错误(如报表录入错误)或真实的极端情况。无论哪种原因,极端值都会严重影响后续的统计分析和组合构建。
MAD法(Median Absolute Deviation)
MAD法是因子去极值中最常用的方法,相比3倍标准差法更稳健(不受极端值本身的影响):
python
def winsorize_mad(series: pd.Series, n: float = 3.0) -> pd.Series:
"""MAD法去极值
Args:
series: 因子值序列
n: MAD倍数,通常取3或5
Returns:
去极值后的序列
"""
median = series.median()
mad = (series - median).abs().median()
# MAD到标准差的转换系数(假设正态分布)
# 1 mad ≈ 1.4826 std
mad_to_std = 1.4826
upper = median + n * mad_to_std * mad
lower = median - n * mad_to_std * mad
return series.clip(lower, upper)3-Sigma法
适用于近似正态分布的因子:
python
def winsorize_3sigma(series: pd.Series, n: float = 3.0) -> pd.Series:
"""3倍标准差法去极值"""
mean = series.mean()
std = series.std()
upper = mean + n * std
lower = mean - n * std
return series.clip(lower, upper)百分位法
最简单直接的方法,直接截断首尾一定百分比的极端值:
python
def winsorize_percentile(series: pd.Series, lower_pct: float = 0.025,
upper_pct: float = 0.975) -> pd.Series:
"""百分位法去极值"""
lower = series.quantile(lower_pct)
upper = series.quantile(upper_pct)
return series.clip(lower, upper)去极值方法选择
| 方法 | 优势 | 劣势 | 推荐场景 |
|---|---|---|---|
| MAD | 对异常值鲁棒 | 参数选择影响结果 | 通用,首选 |
| 3-Sigma | 简单易懂 | 受极端值影响大 | 因子分布近似正态 |
| 百分位 | 不依赖分布假设 | 可能截掉有效的极端因子值 | 快速处理 |
第四步:标准化
不同因子的量纲和数值范围差异巨大(PE可能在0-1000,ROE在-0.5到0.5之间),必须进行标准化才能进行横向比较和组合。
Z-Score标准化
最常用的标准化方法,将因子值转化为均值为0、标准差为1的分布:
python
def zscore_standardize(series: pd.Series) -> pd.Series:
"""Z-Score标准化"""
return (series - series.mean()) / series.std()排名标准化
将因子值转化为百分位排名(0到1),对异常值和非正态分布更加鲁棒:
python
def rank_standardize(series: pd.Series) -> pd.Series:
"""排名标准化"""
return series.rank(pct=True)行业标准化
在同行业内进行Z-Score标准化,消除行业固有结构差异:
python
def industry_standardize(df: pd.DataFrame, factor_col: str,
industry_col: str = 'industry') -> pd.Series:
"""行业内标准化"""
return df.groupby(industry_col)[factor_col].transform(
lambda x: (x - x.mean()) / x.std()
)在实践中,排名标准化是最推荐的方法。它不需要假设因子服从正态分布,对异常值天然免疫,且因子间可比性好。
第五步:中性化
中性化的目的是消除因子中包含的某些已知风险暴露,使得因子值真正反映因子本身的信息,而不是其他维度的暴露。
行业中性化
消除因子在不同行业间的系统性差异。比如银行业天然PE较低,如果不做行业中性化,低PE因子可能会过度集中于银行股:
python
def industry_neutralize(df: pd.DataFrame, factor_col: str,
industry_col: str = 'industry') -> pd.Series:
"""行业中性化:线性回归取残差"""
from sklearn.linear_model import LinearRegression
# 行业虚拟变量
industry_dummies = pd.get_dummies(df[industry_col], drop_first=True)
X = industry_dummies.values
y = df[factor_col].values
# 排除缺失值
valid = ~(np.isnan(y) | np.isnan(X).any(axis=1))
if valid.sum() < len(industry_dummies.columns) + 10:
return pd.Series(np.nan, index=df.index)
model = LinearRegression()
model.fit(X[valid], y[valid])
residuals = y - model.predict(X)
residuals[~valid] = np.nan
return pd.Series(residuals, index=df.index)市值中性化
消除因子与市值的相关性。很多因子天然与市值相关(比如流动性因子与小市值正相关),不做中性化可能导致因子的有效性主要来自市值效应:
python
def size_neutralize(df: pd.DataFrame, factor_col: str) -> pd.Series:
"""市值中性化"""
from sklearn.linear_model import LinearRegression
X = np.log(df['market_cap']).values.reshape(-1, 1)
y = df[factor_col].values
valid = ~(np.isnan(y) | np.isnan(X.flatten()))
model = LinearRegression()
model.fit(X[valid], y[valid])
residuals = y - model.predict(X)
residuals[~valid] = np.nan
return pd.Series(residuals, index=df.index)行业+市值中性化
最完整的做法是同时对行业和市值进行中性化:
python
def full_neutralize(df: pd.DataFrame, factor_col: str,
industry_col: str = 'industry') -> pd.Series:
"""行业+市值中性化"""
from sklearn.linear_model import LinearRegression
industry_dummies = pd.get_dummies(df[industry_col], drop_first=True)
size_col = np.log(df['market_cap']).values.reshape(-1, 1)
X = np.hstack([industry_dummies.values, size_col])
y = df[factor_col].values
valid = ~(np.isnan(y) | np.isnan(X).any(axis=1))
if valid.sum() < X.shape[1] + 10:
return pd.Series(np.nan, index=df.index)
model = LinearRegression()
model.fit(X[valid], y[valid])
residuals = y - model.predict(X)
residuals[~valid] = np.nan
return pd.Series(residuals, index=df.index)IC信息系数
完成因子预处理后,需要量化评估因子的预测能力。IC(Information Coefficient) 是最核心的指标。
IC的计算
IC是因子值与下期收益率的横截面相关系数。在每个时间截面上,计算所有股票的因子值与它们未来收益率的秩相关系数:
python
def calc_ic_series(df: pd.DataFrame, factor_col: str,
forward_period: int = 20) -> pd.DataFrame:
"""计算IC时间序列"""
# 计算未来收益率
df = df.sort_values(['code', 'date'])
df['forward_ret'] = df.groupby('code')['close'].transform(
lambda x: x.shift(-forward_period) / x - 1
)
# 逐截面计算IC
dates = sorted(df['date'].unique())
ic_list = []
for date in dates:
daily = df[df['date'] == date].dropna(subset=[factor_col, 'forward_ret'])
if len(daily) < 50: # 样本量太少则跳过
continue
# Spearman秩相关系数
ic = daily[factor_col].corr(daily['forward_ret'], method='spearman')
ic_list.append({'date': date, 'ic': ic, 'n_stocks': len(daily)})
return pd.DataFrame(ic_list)IC的评估维度
单看IC均值是不够的,需要从多个维度综合评估:
python
def evaluate_ic(ic_df: pd.DataFrame) -> dict:
"""全面评估IC表现"""
ic = ic_df['ic']
metrics = {
'IC均值': ic.mean(),
'IC标准差': ic.std(),
'ICIR(IC均值/IC标准差)': ic.mean() / ic.std() if ic.std() != 0 else 0,
'IC胜率(IC>0的比例)': (ic > 0).mean(),
'IC绝对值均值': ic.abs().mean(),
'IC的t统计量': ic.mean() / (ic.std() / np.sqrt(len(ic))),
'样本期数': len(ic),
}
return metricsIC评估标准
| 评估指标 | 优秀 | 良好 | 一般 | 较弱 |
|---|---|---|---|---|
| IC均值绝对值 | >0.08 | 0.05-0.08 | 0.03-0.05 | <0.03 |
| ICIR | >1.0 | 0.5-1.0 | 0.3-0.5 | <0.3 |
| IC胜率 | >60% | 55%-60% | 52%-55% | <52% |
关键原则:IC的稳定性(ICIR)比IC的大小更重要。一个IC均值为0.03但ICIR为1.0的因子,比一个IC均值为0.08但ICIR为0.3的因子更有价值。前者虽然单次预测能力弱,但胜在稳定可重复;后者可能只是某些特定时期的偶然现象。
分组回测
IC是统计指标,分组回测则直接展示因子的经济含义——因子能否实际赚钱。
分组回测方法
python
def group_backtest(df: pd.DataFrame, factor_col: str,
n_groups: int = 5, holding_days: int = 20) -> pd.DataFrame:
"""因子分组回测"""
dates = sorted(df['date'].unique())
rebalance_dates = dates[::holding_days]
group_navs = {g: [1.0] for g in range(1, n_groups + 1)}
for i in range(len(rebalance_dates) - 1):
rb_date = rebalance_dates[i]
next_rb = rebalance_dates[i + 1]
daily = df[df['date'] == rb_date].dropna(subset=[factor_col])
if len(daily) < n_groups * 20:
continue
daily['group'] = pd.qcut(daily[factor_col], n_groups,
labels=False, duplicates='drop') + 1
for g in range(1, n_groups + 1):
stocks = daily[daily['group'] == g]['code'].tolist()
if not stocks:
group_navs[g].append(group_navs[g][-1])
continue
# 计算持有期收益
hold = df[(df['date'] > rb_date) & (df['date'] <= next_rb)]
hold = hold[hold['code'].isin(stocks)]
period_ret = hold.groupby('date')['close'].pct_change().mean()
# 简化处理:使用持有期累计收益
if len(period_ret.dropna()) > 0:
cum_ret = (1 + period_ret.dropna()).prod() - 1
else:
cum_ret = 0
group_navs[g].append(group_navs[g][-1] * (1 + cum_ret))
return pd.DataFrame(group_navs)评估分组回测结果
一个有效因子的分组回测应该呈现以下特征:
- 单调性:从第一组到第N组,收益呈单调递增或递减
- 多空收益显著:最高组与最低组的收益差(多空收益)在统计上显著
- 各组间差异稳定:不同时间段内都能观察到组间差异
有效性判断
综合以上分析,判断一个因子是否有效需要同时满足以下条件:
必要条件
- IC统计显著:IC均值的t统计量大于2(即p值小于0.05)
- IC稳定性:ICIR大于0.3,IC胜率大于52%
- 分组单调:5分组回测中,至少4组呈现单调排列
- 多空收益:多空组合年化收益在统计上显著大于零
- 样本外有效:在未参与因子设计的时间段内仍然有效
充分条件(更好的因子)
- 经济逻辑清晰:因子背后有合理的经济学解释
- 与已有因子低相关:与已知因子的相关性低于0.3
- 衰减缓慢:因子信号的预测能力随时间衰减较慢
- 容量足够:能够承载足够的资金量而不显著影响收益
python
def judge_factor_effectiveness(ic_metrics: dict, group_result: pd.DataFrame) -> str:
"""综合判断因子有效性"""
score = 0
# IC评估
if abs(ic_metrics['IC均值']) > 0.05:
score += 2
elif abs(ic_metrics['IC均值']) > 0.03:
score += 1
if ic_metrics['ICIR'] > 0.5:
score += 2
elif ic_metrics['ICIR'] > 0.3:
score += 1
if ic_metrics['IC胜率'] > 0.55:
score += 1
# 分组评估
group_returns = group_result.iloc[-1] / group_result.iloc[0] - 1
if group_returns.max() - group_returns.min() > 0.1:
score += 2
elif group_returns.max() - group_returns.min() > 0.05:
score += 1
if score >= 6:
return '强有效因子'
elif score >= 4:
return '有效因子'
elif score >= 2:
return '弱有效因子,需进一步研究'
else:
return '无效因子'总结
因子构建与筛选是一个严谨的系统工程。从数据准备到最终的有效性判断,每一个环节都需要细心处理。数据质量是基础,去极值和标准化是保障,中性化是深入,IC分析和分组回测是验证。只有通过这套完整流程检验的因子,才值得纳入多因子模型。
本文仅为教学目的,不构成任何投资建议。因子的有效性会随市场环境变化而变化,不存在永远有效的因子。持续的研究和监控是因子投资的必要功课。