Skip to content

因子构建与筛选:如何构建和验证一个有效因子

你可能有一个很好的投资想法——"高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 metrics

IC评估标准

评估指标优秀良好一般较弱
IC均值绝对值>0.080.05-0.080.03-0.05<0.03
ICIR>1.00.5-1.00.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)

评估分组回测结果

一个有效因子的分组回测应该呈现以下特征:

  1. 单调性:从第一组到第N组,收益呈单调递增或递减
  2. 多空收益显著:最高组与最低组的收益差(多空收益)在统计上显著
  3. 各组间差异稳定:不同时间段内都能观察到组间差异

有效性判断

综合以上分析,判断一个因子是否有效需要同时满足以下条件:

必要条件

  1. IC统计显著:IC均值的t统计量大于2(即p值小于0.05)
  2. IC稳定性:ICIR大于0.3,IC胜率大于52%
  3. 分组单调:5分组回测中,至少4组呈现单调排列
  4. 多空收益:多空组合年化收益在统计上显著大于零
  5. 样本外有效:在未参与因子设计的时间段内仍然有效

充分条件(更好的因子)

  1. 经济逻辑清晰:因子背后有合理的经济学解释
  2. 与已有因子低相关:与已知因子的相关性低于0.3
  3. 衰减缓慢:因子信号的预测能力随时间衰减较慢
  4. 容量足够:能够承载足够的资金量而不显著影响收益
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分析和分组回测是验证。只有通过这套完整流程检验的因子,才值得纳入多因子模型。

本文仅为教学目的,不构成任何投资建议。因子的有效性会随市场环境变化而变化,不存在永远有效的因子。持续的研究和监控是因子投资的必要功课。

仅供学习交流,不构成任何投资建议