🚀 AI 一键生成 ptrade 策略代码

PTrade 因子处理:如何实现因子的行业中性化?

ptrade | 发布时间: 2025/12/23
以下内容由 EasyQuant 生成。

问题描述

怎么对因子进行行业中性化处理?

解决方案

在量化交易中,因子行业中性化(Industry Neutralization) 是一种常见的数据预处理步骤。它的目的是剔除因子值中由行业差异带来的影响,使因子在不同行业之间具有可比性,从而更纯粹地反映股票在行业内部的相对优劣。

核心原理

行业中性化的数学本质是线性回归

假设我们有一个因子 $F$(例如市盈率 PE),我们认为它由两部分组成:

  1. 行业部分:由该股票所属行业决定的基准水平(例如银行股普遍PE低,科技股普遍PE高)。
  2. 特异部分:股票自身的特性(Alpha)。

公式表达为:
$$Y = \beta \times X + \epsilon$$

  • $Y$:原始因子值向量。
  • $X$:行业哑变量矩阵(Dummy Variables)。如果一只股票属于某行业,对应列为1,否则为0。
  • $\epsilon$(残差):即为行业中性化后的因子值

PTrade 中的实现步骤

在 PTrade 中,我们通常使用 pandasstatsmodels 库来实现这一过程。

1. 准备数据

你需要两个 Series 数据:

  • 因子数据:索引为股票代码,值为因子值。
  • 行业数据:索引为股票代码,值为行业名称或代码。

2. 获取行业分类 (PTrade 特异性)

PTrade 没有直接返回所有股票行业 Series 的简单接口,通常需要通过 get_industry_stocks(获取某行业成分股)反向构建映射,或者使用 get_stock_blocks 循环获取(速度较慢,建议在 initializebefore_trading_start 中构建缓存)。

3. 代码实现

以下是一个完整的 Python 函数实现,可以直接在 PTrade 策略中使用。

import pandas as pd
import numpy as np
import statsmodels.api as sm

def neutralize_factor(factor_series, industry_series):
    """
    对因子进行行业中性化处理 (使用 OLS 回归取残差)
    
    参数:
    factor_series: pd.Series, 索引为股票代码, 值为原始因子值
    industry_series: pd.Series, 索引为股票代码, 值为行业名称/代码
    
    返回:
    pd.Series: 中性化后的因子值 (残差)
    """
    # 1. 数据对齐与合并
    # 将因子和行业数据合并为一个 DataFrame,自动对齐索引
    df = pd.DataFrame({'factor': factor_series, 'industry': industry_series})
    
    # 2. 去除缺失值
    # 如果因子或行业数据有缺失,回归会报错,必须剔除
    df = df.dropna()
    
    if df.empty:
        return pd.Series()

    # 3. 生成行业哑变量 (One-Hot Encoding)
    # 将行业分类转换为 0/1 矩阵
    # 例如:行业列变为 [行业_银行, 行业_科技, ...]
    industry_dummies = pd.get_dummies(df['industry'])
    
    # 4. 线性回归 (OLS)
    # Y = 原始因子, X = 行业哑变量
    x = industry_dummies
    y = df['factor']
    
    # 使用 statsmodels 进行拟合
    # 注意:这里不需要添加常数项(add_constant),因为行业哑变量已经覆盖了全集
    model = sm.OLS(y, x)
    results = model.fit()
    
    # 5. 获取残差
    # 残差即为剔除了行业影响后的因子部分
    neutralized_factor = results.resid
    
    return neutralized_factor

# ---------------------------------------------------------
# 辅助函数:在 PTrade 中获取股票池的行业映射
# ---------------------------------------------------------
def get_stock_industry_map(stock_list):
    """
    获取指定股票列表的行业映射
    注意:这里以申万一级行业为例,实际可根据需求调整为证监会行业等
    """
    industry_map = {}
    
    # PTrade 获取行业信息的逻辑比较特殊,这里演示一种通过板块查询的方法
    # 实际使用中,建议根据 get_stock_blocks 逐个查询或使用自定义数据
    # 为了演示方便,这里假设我们使用 get_stock_blocks 逐个查询 (注意控制频率)
    # 在实盘或回测中,建议将此步骤放在 before_trading_start 并缓存结果
    
    for stock in stock_list:
        # 获取该股票所属板块
        blocks = get_stock_blocks(stock)
        # 假设我们需要申万行业,通常在 blocks 中寻找 'SW' 开头的或特定名称
        # 这里简化处理:尝试获取其所属的证监会行业(ZJHHY)作为分类依据
        # 实际返回结构如: {'ZJHHY': [['C39000.XBHS', '计算机...']]}
        
        industry_name = 'Unknown'
        if blocks and 'ZJHHY' in blocks:
            # 取第一个证监会行业代码作为分类
            industry_name = blocks['ZJHHY'][0][0] 
        
        industry_map[stock] = industry_name
        
    return pd.Series(industry_map)

# ---------------------------------------------------------
# 策略示例调用
# ---------------------------------------------------------
def initialize(context):
    g.security = get_index_stocks('000300.SS') # 沪深300
    set_universe(g.security)

def handle_data(context, data):
    # 1. 获取原始因子数据 (例如:市盈率 PE)
    # 注意:get_fundamentals 返回的是 DataFrame,我们需要 Series
    q = get_fundamentals(g.security, 'valuation', ['pe_ttm'], date=context.blotter.current_dt.strftime("%Y%m%d"))
    
    if q is None or q.empty:
        return

    # 提取 Series,索引为股票代码
    raw_pe = q['pe_ttm'] # 此时索引是股票代码
    
    # 2. 获取行业分类数据
    # 在实际策略中,不要在 handle_data 里循环调用 get_stock_blocks,太慢。
    # 这里仅为演示逻辑。建议在 before_trading_start 准备好 g.industry_series
    industry_series = get_stock_industry_map(g.security)
    
    # 3. 执行行业中性化
    neutral_pe = neutralize_factor(raw_pe, industry_series)
    
    # 4. 打印结果对比
    # log.info("原始因子前5:\n" + str(raw_pe.head()))
    # log.info("中性化因子前5:\n" + str(neutral_pe.head()))
    
    # 接下来可以使用 neutral_pe 进行选股排序...

关键点解析

  1. 哑变量 (Dummy Variables)
    pd.get_dummies(df['industry']) 是核心步骤。它将文本型的行业分类(如“银行”、“医药”)转换成了数学模型可以计算的矩阵。

  2. 数据对齐
    因子数据和行业数据必须针对同一组股票。代码中 pd.DataFrame({'factor': ..., 'industry': ...}) 利用 pandas 的索引自动对齐功能,确保了每一行数据属于同一只股票。

  3. 市值中性化 (进阶)
    通常行业中性化会和市值中性化同时进行。因为大市值股票和小市值股票的表现差异也很大。
    如果要同时做,只需在回归的自变量 $X$ 中加入市值因子即可:
    $$Y_{\text{因子}} = \beta_1 \times \text{行业Dummies} + \beta_2 \times \ln(\text{市值}) + \epsilon$$

    代码修改如下:

    # 假设 market_cap_series 是对数市值数据
    df = pd.DataFrame({
        'factor': factor_series, 
        'industry': industry_series,
        'ln_mkt_cap': market_cap_series
    })
    industry_dummies = pd.get_dummies(df['industry'])
    # 将市值因子和行业哑变量合并作为 X
    x = pd.concat([industry_dummies, df['ln_mkt_cap']], axis=1)
    y = df['factor']
    # ...后续回归逻辑相同
    

常见问题 (Q&A)

Q: 为什么要取残差(Resid)作为结果?
A: 回归方程的预测值 $\hat{Y}$ 代表了“基于行业属性应该具有的因子值”。原始值 $Y$ 减去预测值 $\hat{Y}$ 得到的残差 $\epsilon$,代表了股票剔除行业共性后,真正属于其自身的特异性表现。

Q: PTrade 中 get_stock_blocks 速度太慢怎么办?
A: get_stock_blocks 涉及网络或数据库IO,不适合在 handle_data 中对几千只股票循环调用。

  • 方案一:在 before_trading_start 中运行,并保存到全局变量 g 中。
  • 方案二:使用 get_industry_stocks(industry_code)。先获取所有行业代码列表,然后遍历行业代码获取成分股,构建 stock -> industry 的字典。这种方式循环次数少(只有几十个行业),速度快得多。

Q: 中性化之前需要做去极值(Winsorization)吗?
A: 强烈建议。如果原始因子中有极端的异常值(Outliers),会严重拉偏回归线,导致算出的残差失真。通常的处理顺序是:去极值 -> 标准化 -> 中性化