【Python量化多因子实战 04】因子暴露不是 alpha:市值与行业中性化实战

上篇我们把市值、动量两个因子用 IC 加权合成了组合信号,IC 加权的表现优于等权。但先别急着高兴------你合成出来的"超额收益",可能有一大块根本不是 alpha,而是市值暴露带来的 beta。

举个例子:如果你的动量因子恰好偏好小市值股票,而样本期恰好小市值占优,那因子 IC 会很漂亮。可一旦市场风格切换,这份"alpha"会连本带利还回去。处理这个问题的标准动作叫中性化(Neutralization)。

今天用真实数据实测:市值中性化、行业中性化到底会怎样改变一个因子的 IC。


本文你将得到什么

  1. 市值中性化的 Python 实现:逐日截面回归取残差。
  2. 行业中性化的 Python 实现:行业内去均值。
  3. 一组真实对比数据:中性化前后动量因子 IC 与分层价差的变化。
  4. 一个关键认知:先剥离暴露,再谈因子有效性。

核心思路:中性化在做什么

市值中性化(回归法)

把因子值对 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 只样本股测算;演示证书返回就近样本,正式证书可拉取全市场。)


怎么读这组数字

  1. 暴露确实被剥干净了:动量与 log(市值) 的相关性从 0.0781 降到 -0.0000,市值中性化的"手术"是成功的。
  2. IC 从 -0.1211 缩到 -0.0660 :本样本期内动量呈反转特征(IC 为负),而中性化后 IC 绝对值几乎砍半------说明原始动量 IC 里有相当一部分,是市值与行业暴露贡献的,不是动量本身的 alpha。
  3. 中性化不会"变废为宝":它只负责把混在因子里的暴露剥掉。如果剥完之后 IC 趋近于 0,说明这个因子本来就是风格暴露的影子,应当放弃。

三个容易踩的坑

  1. 先定向,再中性化:因子方向(大好还是小好)应先用全样本 IC 确认,否则回归残差会把方向信息也洗掉。
  2. 小股票池里行业去均值要小心:某行业只有 1 只股票时,去均值后恒为 0,等于该股被剔除了。本文 30 只池里"房地产"只有 1 只,读结果时要知道这一点;实盘建议 100 只以上。
  3. 中性化是逐日截面操作:不要在时间序列维度上回归,风格暴露是每天的截面现象。

小结

中性化是多因子研究里性价比最高的一步:十行代码,就能让你看清一个因子到底是真 alpha,还是市值/行业风格的影子。本文实证显示,动量因子的原始 IC 中有近一半可以被暴露解释。

下一篇我们研究因子维度的时间现象:因子也会轮动------去年的赢家因子今年还灵吗?因子动量与因子反转的择时实测。

下篇预告:【Python量化多因子实战 05】因子也会轮动:因子动量与因子反转的择时应用。


免责声明

本文仅为技术方法演示,不构成任何投资建议。文中示例数据仅用于教学,历史表现不代表未来收益。投资有风险,决策需谨慎。

代码与文档可参考 GitHub:github.com/MaiRuiApi

相关推荐
挖坑的张师傅9 分钟前
Agent 基建系列:用 MCP Tunnel 让云端 Agent 访问企业内网工具
后端·agent
挖坑的张师傅10 分钟前
开源 yourbatis:把 MyBatis 的动态 SQL 体验带到 Go
后端·agent
挖坑的张师傅13 分钟前
Agent 基建系列:E2B Go SDK
后端·agent
挖坑的张师傅19 分钟前
Agent 基建系列(二):基于 Proxy 的凭证不入沙箱和网络管控方案
后端·agent
挖坑的张师傅22 分钟前
用 FUSE 为云端 Agent 构建文件系统
后端·agent
不可能片场42 分钟前
Electron 发布 正文写入的三条通道与读回口径
前端·electron
张彦峰ZYF1 小时前
放行、排队与拒绝:限流设计的算法选型、数据结构与并发代价
后端·架构·滑动窗口·漏桶·令牌桶·高并发+限流
jerrywus1 小时前
让 AI 帮你部署 Vercel,报错了也不用手动搬日志
前端·agent·mcp
补码缩写1 小时前
11 行 Python 拆 PNG 块,解压后的字节数能手算
前端