3. 因子构建与计算:构建情绪因子、标准化与中性化处理

好,咱们进入实战环节了。前面聊了数据清洗,现在要真正动手造因子了。情绪因子这东西,说白了就是捕捉市场里那些非理性的行为。我做了这么多年,发现最有效的情绪因子,往往就藏在最基础的交易数据里。

3.1 换手率因子:市场情绪的体温计

换手率,我习惯叫它「市场情绪的体温计」。你想想看,一只股票平时换手率就1%,突然某天飙到10%,说明什么?说明大家情绪上来了,要么恐慌抛售,要么狂热追涨。

构建方式其实很简单:

# 日换手率 = 当日成交量 / 流通股本
df['turnover'] = df['volume'] / df['outstanding_shares']

# 我一般还会做个20日均值,用来观察异常
df['turnover_ma20'] = df['turnover'].rolling(20).mean()
df['turnover_ratio'] = df['turnover'] / df['turnover_ma20'] - 1

这里有个坑,我曾经踩过——新股和次新股的流通股本会变化。刚上市那几天,换手率动不动就50%以上,这其实是正常现象,不是情绪异常。所以我在做回测时,会把上市不满60天的股票先过滤掉。

核心逻辑:换手率因子本质上捕捉的是「交易活跃度的异常变化」。高换手率往往预示着短期价格反转,尤其是那些连续几天换手率异常放大的股票。

3.2 波动率因子:恐惧与贪婪的放大器

波动率因子,说白了就是看市场有多「慌」。我个人的经验是,波动率突然飙升的时候,往往是短期见底的信号——因为恐慌盘都出来了。

计算方式我推荐用两种:

# 方法1:历史波动率(20日标准差)
df['volatility'] = df['returns'].rolling(20).std()

# 方法2:日内波动率(最高最低价差/收盘价)
df['intraday_vol'] = (df['high'] - df['low']) / df['close']

# 我习惯把两者结合
df['vol_factor'] = df['volatility'] * df['intraday_vol']

小技巧:波动率因子最好做对数变换。因为波动率的分布是右偏的,直接使用会导致极端值影响过大。我一般用 np.log1p(df['vol_factor']) 来处理。

为什么会这样?因为市场在极端行情下,波动率可以放大到平时的10倍以上。不做对数处理,一个极端值就能把整个因子的IC值带偏。

3.3 因子标准化:让不同股票站在同一起跑线

嗯,这里要注意。不同股票的换手率、波动率天然就不一样。茅台换手率0.5%算高了,但小盘股换手率5%都算正常。所以我们必须做标准化。

我常用的标准化方法有两种:

方法 公式 适用场景
Z-score标准化 (x - μ) / σ 因子分布接近正态时
排序标准化 rank(x) / N 因子有极端值时
# Z-score标准化(我常用这个)
def zscore_standardize(series):
    return (series - series.mean()) / series.std()

# 排序标准化(抗极端值)
def rank_standardize(series):
    return series.rank(pct=True)

注意:标准化一定要在截面(同一时间点)上做,而不是在时间序列上做。我曾经犯过这个错,把时间序列上的标准化结果直接拿来用,结果因子完全失效了。

3.4 中性化处理:剔除不该有的干扰

因子中性化,说白了就是「把因子中跟市值、行业相关的部分剥离掉」。你想想看,小盘股天然换手率高,大盘股天然波动率低。如果不做中性化,你选出来的股票可能只是小盘股,而不是情绪真的异常。

我一般用回归法做中性化:

import statsmodels.api as sm

def neutralize_factor(factor, market_cap, industry_dummies):
    """
    factor: 待中性化的因子
    market_cap: 市值(取对数)
    industry_dummies: 行业哑变量
    """
    X = pd.concat([np.log(market_cap), industry_dummies], axis=1)
    X = sm.add_constant(X)
    
    model = sm.OLS(factor, X).fit()
    residual = factor - model.predict(X)
    
    return residual

这里有个细节——市值一定要取对数。因为市值分布是幂律的,不取对数的话,茅台一个股票就能把回归结果带偏。我在项目中遇到过,没取对数时,中性化后的因子跟市值还有0.3的相关系数,取完对数后降到0.05以下。

3.5 完整流程:从原始数据到可用因子

来,我把整个流程串起来。这是我个人习惯的pipeline:

def build_sentiment_factor(df):
    # 1. 计算原始因子
    df['turnover'] = df['volume'] / df['outstanding_shares']
    df['volatility'] = df['returns'].rolling(20).std()
    
    # 2. 合成情绪因子
    df['raw_factor'] = df['turnover'] * df['volatility']
    
    # 3. 去极值(MAD方法)
    median = df['raw_factor'].median()
    mad = (df['raw_factor'] - median).abs().median()
    df['raw_factor'] = df['raw_factor'].clip(median - 5*mad, median + 5*mad)
    
    # 4. 标准化
    df['std_factor'] = zscore_standardize(df['raw_factor'])
    
    # 5. 中性化(按日期分组做截面回归)
    df['neutral_factor'] = df.groupby('date').apply(
        lambda g: neutralize_factor(g['std_factor'], g['market_cap'], g['industry'])
    ).reset_index(level=0, drop=True)
    
    return df

记住:去极值 → 标准化 → 中性化,这个顺序不能乱。先去极值再标准化,否则极端值会把均值和标准差拉偏。中性化一定要放在最后,因为回归本身对异常值敏感。

3.6 实战中的避坑指南

最后分享几个我踩过的坑:

  • 我曾经在回测时忘了做行业中性化,结果选出来的股票全是某个行业的,回测曲线跟行业指数几乎重合。后来加了行业中性化,IC值从0.02提升到了0.06。
  • 还有一次,我用的换手率因子没做新股过滤,结果次新股的高换手率把整个因子都污染了。回测看起来很好,实盘一跑就崩。
  • 另外,标准化时一定要用截面数据。我见过有人用全历史数据做标准化,结果因子值一直在漂移,完全没法用。

嗯,因子构建这块就聊到这儿。记住一句话:好的情绪因子,一定是能捕捉到市场非理性行为的,而不是简单地重复市值或行业信息

情绪因子构建与处理流程 原始交易数据 计算换手率因子 + 波动率因子 MAD去极值处理 Z-score / 排序标准化 市值 + 行业中性化 关键要点 • 顺序不能乱 • 截面标准化 • 市值取对数 • 过滤次新股 • 行业哑变量 常见错误: ✗ 时间序列标准化 ✗ 忘记行业中性化 ✗ 未处理极端值 ✗ 新股未过滤

公众号:蓝海资料掘金营,微信deep3321