4. 单因子构建与实现:以市盈率倒数(EP)为例

市盈率倒数(EP),说白了就是每股收益除以股价。它是基本面因子中最经典的一个,也是我入行时第一个手工搭建的因子。今天我们就拿它开刀,走一遍完整的构建流程。

核心逻辑:EP 越高,说明每单位股价对应的盈利能力越强,股票越可能被低估。价值投资的核心思想,就浓缩在这个比值里。

4.1 因子定义与数据准备

先明确公式:

EP = 每股收益(EPS) / 股价(Price)

这里有个细节——EPS 用哪个口径?我个人习惯用 TTM(Trailing Twelve Months),也就是最近四个季度的滚动每股收益。为什么?因为单季度 EPS 波动太大,年报又太滞后,TTM 刚好平衡了时效性和稳定性。

数据源方面,我通常从 Wind 或聚宽取数。你需要准备两张表:

数据表 字段 频率
日行情表 股票代码、日期、收盘价 日频
财务指标表 股票代码、报告期、EPS_TTM 季频(但 TTM 可每日更新)

避坑指南:我曾经在取 EPS 时直接用了年报数据,结果每年 4 月底才更新,导致 1-4 月的因子全是旧值。后来改成 TTM 口径,每月滚动更新,因子质量明显提升。

4.2 代码实现:从原始数据到因子值

下面这段代码,我建议你直接跑一遍。它包含了数据合并、去极值、标准化三个核心步骤。

import pandas as pd
import numpy as np
from scipy import stats

# 模拟数据:假设有 1000 只股票,500 个交易日
np.random.seed(42)
n_stocks = 1000
n_days = 500

# 生成日期序列
dates = pd.date_range('2020-01-01', periods=n_days, freq='B')

# 生成股价和 EPS_TTM
data = []
for date in dates:
    for stock_id in range(n_stocks):
        price = np.random.uniform(5, 200)
        eps_ttm = np.random.uniform(-2, 5)
        data.append([date, stock_id, price, eps_ttm])

df = pd.DataFrame(data, columns=['date', 'stock_id', 'price', 'eps_ttm'])

# 计算 EP 因子
df['EP'] = df['eps_ttm'] / df['price']

# 去极值:MAD 方法(中位数绝对偏差)
def winsorize_mad(series, n=5):
    median = series.median()
    mad = (series - median).abs().median()
    upper = median + n * mad
    lower = median - n * mad
    return series.clip(lower, upper)

df['EP_winsorized'] = df.groupby('date')['EP'].transform(winsorize_mad)

# 标准化:Z-score
df['EP_zscore'] = df.groupby('date')['EP_winsorized'].transform(
    lambda x: (x - x.mean()) / x.std()
)

print(df[['date', 'stock_id', 'EP', 'EP_winsorized', 'EP_zscore']].head(10))

嗯,这里要注意去极值的参数 n=5。我试过 3、5、7 三个值,5 倍 MAD 在 A 股市场效果最稳。太严苛会把正常值切掉,太宽松又去不掉极端值。

4.3 数据验证:因子值分布与行业覆盖

因子算出来不等于能用。我一般会做三件事来验证:

  1. 分布检查:看 EP 值是否集中在合理区间,有没有大量负值或异常大值。
  2. 行业覆盖:确保每个行业都有足够的样本,避免因子只在某几个行业有效。
  3. 时间序列稳定性:因子均值随时间是否平稳,有没有突然跳变。

下面这段代码帮你快速检查:

# 假设 df 已有行业字段 'industry'
# 按日期和行业统计 EP 均值
industry_ep = df.groupby(['date', 'industry'])['EP_zscore'].mean().unstack()

# 检查行业覆盖
print("行业数量:", industry_ep.shape[1])
print("各行业样本量:")
print(df.groupby('industry').size())

# 检查时间序列稳定性
daily_mean = df.groupby('date')['EP_zscore'].mean()
print("日均值标准差:", daily_mean.std())
print("日均值范围:", daily_mean.min(), "-", daily_mean.max())

常见问题:如果某个行业(比如银行)的 EP 值长期偏高,不要急着剔除。这是行业特性,不是数据错误。你可以在后续分析中按行业中性化处理。

4.4 因子构建的完整流程图

下面这张图,是我做因子构建时脑子里始终挂着的流程。每一步都有坑,每一步也都有优化空间。

单因子构建与验证流程(以 EP 为例) 1. 数据获取 2. 因子计算 3. 去极值(MAD) 4. 标准化(Z-score) 5. 数据验证 说明:每一步的输出都是下一步的输入。去极值和标准化顺序不能颠倒,否则极值会影响标准化参数。

4.5 因子存储与后续使用

因子算完、验证通过后,我建议按以下格式存储:

字段 类型 说明
date datetime 交易日
stock_id int 股票代码(建议用整数编码)
EP_raw float 原始因子值
EP_winsorized float 去极值后
EP_zscore float 标准化后(用于后续分析)

个人习惯:我会把原始值和处理后的值都保留。为什么?因为后续做因子衰减分析或回测时,可能需要不同口径的数据。删了再重新算,浪费时间。