上篇我们把市值、动量两个因子用 IC 加权合成了组合信号,IC 加权的表现优于等权。但先别急着高兴------你合成出来的"超额收益",可能有一大块根本不是 alpha,而是市值暴露带来的 beta。
举个例子:如果你的动量因子恰好偏好小市值股票,而样本期恰好小市值占优,那因子 IC 会很漂亮。可一旦市场风格切换,这份"alpha"会连本带利还回去。处理这个问题的标准动作叫中性化(Neutralization)。
今天用真实数据实测:市值中性化、行业中性化到底会怎样改变一个因子的 IC。
本文你将得到什么
- 市值中性化的 Python 实现:逐日截面回归取残差。
- 行业中性化的 Python 实现:行业内去均值。
- 一组真实对比数据:中性化前后动量因子 IC 与分层价差的变化。
- 一个关键认知:先剥离暴露,再谈因子有效性。
核心思路:中性化在做什么
市值中性化(回归法)
把因子值对 log(市值) 做逐日截面回归:
text
momentum_i = a + b * log_mcap_i + e_i
残差 e_i 就是剔除了市值影响后的动量。回归系数 b 捕捉的是"市值能解释的部分",剩下的才是动量自己的信息。
行业中性化(去均值法)
最简单稳健的做法:同一天、同一行业内,把每只股票的因子值减去行业均值。这样因子在行业维度上不再有系统性偏好,选股只在行业内部比较相对强弱。
检验标准
中性化做没做对,看两点:
- 因子与 log(市值) 的相关性应降到接近 0;
- 中性化前后 IC 的变化幅度,就是被剥离掉的"暴露贡献"。
可运行代码
bash
pip install mairui pandas numpy
python
from mairui import Client
import pandas as pd
import numpy as np
LICENCE = "LICENCE-66D8-9F96-0C7F0FBCD073" # 演示证书,请换成你自己的正式证书
client = Client(LICENCE)
POOL = [
"600519", "000001", "000002", "000333", "000568", "000651", "000725", "000858",
"002001", "002007", "002142", "002230", "002304", "002415", "002594", "002714",
"300003", "300014", "300015", "300033", "300059", "300122", "300124", "300274",
"300750", "601318", "601398", "601888", "603288", "603501",
]
# 行业映射(公开常识,人工标注)
INDUSTRY = {
"600519": "白酒", "000568": "白酒", "000858": "白酒", "002304": "白酒", "603288": "食品",
"000001": "银行", "002142": "银行", "601398": "银行",
"000002": "房地产",
"000333": "家电", "000651": "家电",
"000725": "电子", "002415": "电子", "603501": "电子",
"002007": "医药", "300003": "医药", "300015": "医药", "300122": "医药",
"002594": "汽车",
"002001": "农业", "002714": "农业",
"002230": "计算机", "300033": "计算机",
"300059": "非银金融", "601318": "非银金融",
"300014": "电新", "300124": "电新", "300274": "电新", "300750": "电新",
"601888": "商贸",
}
def fetch_history(code):
try:
bars = client.stock_history(code, "d", "n", st="20260601", et="20261009")
if bars:
df = pd.DataFrame(bars).rename(columns={"t": "date", "c": "close"})
df["date"] = pd.to_datetime(df["date"])
return df.sort_values("date").reset_index(drop=True)
except Exception as e:
print(f" {code} 行情拉取受限: {e}")
return None
def fetch_capital(code):
try:
cap = client.stock_capital(code=code)
if cap:
return sorted(cap, key=lambda x: x.get("bdrq", ""), reverse=True)[0]
except Exception:
pass
return None
records = []
for code in POOL:
hist = fetch_history(code)
cap = fetch_capital(code)
if hist is None or len(hist) < 30:
continue
total_shares = cap.get("zgb") if cap else None
if not total_shares:
continue
for i in range(20, len(hist) - 5):
row = hist.iloc[i]
past = hist.iloc[i - 20]
future = hist.iloc[i + 5]
records.append({
"code": code,
"date": row["date"].strftime("%Y-%m-%d"),
"industry": INDUSTRY[code],
"log_mcap": float(np.log(row["close"] * total_shares)),
"momentum": (row["close"] / past["close"] - 1) * 100,
"fwd_return": (future["close"] / row["close"] - 1) * 100,
})
df = pd.DataFrame(records)
def day_ic(g, col):
if len(g) < 5:
return np.nan
return g[col].corr(g["fwd_return"], method="spearman")
def ic_stats(s, name):
s = s.dropna()
return {"version": name, "mean_ic": round(s.mean(), 4),
"ir": round(s.mean() / s.std(), 4),
"ic_positive_ratio": round((s > 0).mean(), 4)}
# 中性化 1:市值中性化------逐日截面回归取残差
def neutralize_size(g):
x = g["log_mcap"].values
y = g["momentum"].values
k, b = np.polyfit(x, y, 1)
g = g.copy()
g["mom_size_neutral"] = y - (k * x + b)
return g
df = df.groupby("date", group_keys=False).apply(neutralize_size)
# 中性化 2:行业中性化------行业内去均值
df["mom_ind_neutral"] = df.groupby(["date", "industry"])["momentum"].transform(lambda s: s - s.mean())
# 中性化 3:市值 + 行业
df["mom_both_neutral"] = df.groupby(["date", "industry"])["mom_size_neutral"].transform(lambda s: s - s.mean())
report = pd.DataFrame([
ic_stats(df.groupby("date").apply(day_ic, "momentum", include_groups=False), "原始动量"),
ic_stats(df.groupby("date").apply(day_ic, "mom_size_neutral", include_groups=False), "市值中性化"),
ic_stats(df.groupby("date").apply(day_ic, "mom_ind_neutral", include_groups=False), "行业中性化"),
ic_stats(df.groupby("date").apply(day_ic, "mom_both_neutral", include_groups=False), "市值+行业"),
])
print(report.to_string(index=False))
corr_raw = df[["momentum", "log_mcap"]].corr().iloc[0, 1]
corr_neu = df[["mom_size_neutral", "log_mcap"]].corr().iloc[0, 1]
print(f"因子与 log(市值) 相关性: 原始 {corr_raw:.4f} -> 市值中性化后 {corr_neu:.4f}")
运行后你会看到类似结果:
text
version mean_ic ir ic_positive_ratio
原始动量 -0.1211 -0.4032 0.3175
市值中性化 -0.1104 -0.3994 0.3175
行业中性化 -0.0908 -0.4476 0.3333
市值+行业 -0.0660 -0.3343 0.3810
因子与 log(市值) 相关性: 原始 0.0781 -> 市值中性化后 -0.0000
(示例基于 2026 年 6 月至 10 月真实行情、30 只样本股测算;演示证书返回就近样本,正式证书可拉取全市场。)
怎么读这组数字
- 暴露确实被剥干净了:动量与 log(市值) 的相关性从 0.0781 降到 -0.0000,市值中性化的"手术"是成功的。
- IC 从 -0.1211 缩到 -0.0660 :本样本期内动量呈反转特征(IC 为负),而中性化后 IC 绝对值几乎砍半------说明原始动量 IC 里有相当一部分,是市值与行业暴露贡献的,不是动量本身的 alpha。
- 中性化不会"变废为宝":它只负责把混在因子里的暴露剥掉。如果剥完之后 IC 趋近于 0,说明这个因子本来就是风格暴露的影子,应当放弃。
三个容易踩的坑
- 先定向,再中性化:因子方向(大好还是小好)应先用全样本 IC 确认,否则回归残差会把方向信息也洗掉。
- 小股票池里行业去均值要小心:某行业只有 1 只股票时,去均值后恒为 0,等于该股被剔除了。本文 30 只池里"房地产"只有 1 只,读结果时要知道这一点;实盘建议 100 只以上。
- 中性化是逐日截面操作:不要在时间序列维度上回归,风格暴露是每天的截面现象。
小结
中性化是多因子研究里性价比最高的一步:十行代码,就能让你看清一个因子到底是真 alpha,还是市值/行业风格的影子。本文实证显示,动量因子的原始 IC 中有近一半可以被暴露解释。
下一篇我们研究因子维度的时间现象:因子也会轮动------去年的赢家因子今年还灵吗?因子动量与因子反转的择时实测。
下篇预告:【Python量化多因子实战 05】因子也会轮动:因子动量与因子反转的择时应用。
免责声明
本文仅为技术方法演示,不构成任何投资建议。文中示例数据仅用于教学,历史表现不代表未来收益。投资有风险,决策需谨慎。
代码与文档可参考 GitHub:github.com/MaiRuiApi