问题描述
解决方案
在量化交易中,因子行业中性化(Industry Neutralization) 是一种常见的数据预处理步骤。它的目的是剔除因子值中由行业差异带来的影响,使因子在不同行业之间具有可比性,从而更纯粹地反映股票在行业内部的相对优劣。
核心原理
行业中性化的数学本质是线性回归。
假设我们有一个因子 $F$(例如市盈率 PE),我们认为它由两部分组成:
- 行业部分:由该股票所属行业决定的基准水平(例如银行股普遍PE低,科技股普遍PE高)。
- 特异部分:股票自身的特性(Alpha)。
公式表达为:
$$Y = \beta \times X + \epsilon$$
- $Y$:原始因子值向量。
- $X$:行业哑变量矩阵(Dummy Variables)。如果一只股票属于某行业,对应列为1,否则为0。
- $\epsilon$(残差):即为行业中性化后的因子值。
PTrade 中的实现步骤
在 PTrade 中,我们通常使用 pandas 和 statsmodels 库来实现这一过程。
1. 准备数据
你需要两个 Series 数据:
- 因子数据:索引为股票代码,值为因子值。
- 行业数据:索引为股票代码,值为行业名称或代码。
2. 获取行业分类 (PTrade 特异性)
PTrade 没有直接返回所有股票行业 Series 的简单接口,通常需要通过 get_industry_stocks(获取某行业成分股)反向构建映射,或者使用 get_stock_blocks 循环获取(速度较慢,建议在 initialize 或 before_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 进行选股排序...
关键点解析
-
哑变量 (Dummy Variables):
pd.get_dummies(df['industry'])是核心步骤。它将文本型的行业分类(如“银行”、“医药”)转换成了数学模型可以计算的矩阵。 -
数据对齐:
因子数据和行业数据必须针对同一组股票。代码中pd.DataFrame({'factor': ..., 'industry': ...})利用 pandas 的索引自动对齐功能,确保了每一行数据属于同一只股票。 -
市值中性化 (进阶):
通常行业中性化会和市值中性化同时进行。因为大市值股票和小市值股票的表现差异也很大。
如果要同时做,只需在回归的自变量 $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),会严重拉偏回归线,导致算出的残差失真。通常的处理顺序是:去极值 -> 标准化 -> 中性化。