【Python量化因子实战 #01】动量因子 6M vs 12M:截面 IC 该信哪一个
系列:Python 量化因子实战 · 第 01 篇(共 10 篇)
难度:⭐⭐(需有 pandas / numpy 基础)
阅读时长:约 8 分钟
实测代码:Demo/2026-09-01/Python量化因子实战01-动量因子6月12月对比/01_momentum_6m_12m/
一句话结论
在 A 股做截面选股,6 个月动量因子的 Rank IC 显著性强于 12 个月动量——不是窗口越长越好。把动量从 12 个月缩短到 6 个月,跳过最近 1 个月,短期反转噪声能被压掉一大半,剩下的才是真正的"赢家惯性"。
一、为什么先做动量因子
动量(momentum)是量化里最古老的一类。1993 年 Jegadeesh & Titman 在美股 1965-1989 数据上发现:过去 6~12 个月涨得最好的股票,未来 1 个月继续跑赢大盘的概率显著高于随机——这就是著名的"动量效应"。
但 A 股有三个特殊性:
- 散户主导(交易量占比约 60-70%):对新闻、题材的反应更快,惯性窗口更短。
- 涨停板制度:连续涨停无法买入,相当于"被动缩短"了可持仓窗口。
- 做空限制:只能做多,动量因子只能"赢家组合"那一半能落地,输家组合没法做空对冲。
这三个差异共同指向一个结论:A 股的动量窗口应该比美股更短。6 个月、跳过最近 1 个月(UMD 经典定义)是 A 股研究里最常出现的版本;12 个月版本因回看太长容易被"老黄历"拖累。
二、6M vs 12M 的差异在哪
维度 6M 动量 12M 动量
回看窗口 t-120 ~ t-1(最近 1 月跳过) t-240 ~ t-21(最近 1 月跳过)
对短期反转的敏感度 低(已主动跳过) 低
对"远古暴涨"的敏感度 低(仅回看半年) 高(纳入 1 年前的大涨)
A 股实证 IC(沪深 300) 约 0.04~0.06 约 0.01~0.02
适合作为独立因子? ✅ 单独可用 ⚠️ 需与其他短期因子组合
经验结论:12 个月动量在 A 股单独使用几乎无效。常见做法是把它和反转、换手等"短期反转类"因子做正交化,提取纯粹的"中期动量"信息。
三、截面 Rank IC 的直觉
Rank IC 不是"涨跌幅",而是横截面排序一致性:
把所有股票按"过去 6 个月涨幅"从小到大排个名,再按"未来 1 个月涨幅"排个名,两个排名的 Spearman 相关系数就是 6M 动量的 IC。
- IC > 0:涨得多的未来继续涨(动量有效)
- IC < 0:涨得多的未来反而跌(反转有效)
- |IC| > 0.02 算有弱预测力,> 0.05 算显著
- p < 0.05 才可信;样本量小时别下结论
外推到 N 只股票,IC 的标准误大约是 1/sqrt(N)。30 只股票 → 标准误 ≈ 0.18,要 IC > 0.36 才在 5% 水平显著——这显然太严,所以做 IC 必须用 500+ 只股票的全 A 截面,而不是 30 只。
四、可运行代码(30 只大盘股示例)
下面的脚本完整复现了 6M / 12M 动量因子的计算与截面 Rank IC 评估。直接复制运行即可。
"""
动量因子 6M vs 12M 验证 Demo
============================
30 只大盘股 × 6M/12M 动量 × 截面 Spearman IC。
"""
import sys
import warnings
from datetime import datetime
from pathlib import Path
import numpy as np
import pandas as pd
from mairui import Client
from scipy.stats import spearmanr
warnings.filterwarnings("ignore")
真实证书通过环境变量或 .secrets 内部文件读取(不打印原文)
import os
LICENCE = os.environ.get("MAIRUI_LICENCE") or "<你的麦蕊证书>"
client = Client(LICENCE)
UNIVERSE = [
"600519", "000001", "000002", "600036", "000858",
"601318", "601398", "601988", "600276", "000333",
"600030", "601166", "600887", "600000", "601288",
"600585", "600028", "601857", "600050", "601628",
"601088", "601668", "600018", "601800", "601012",
"600900", "600104", "601628", "600196", "601633",
]
def fetch_history(code: str, lt: int = 300) -> pd.DataFrame:
"""拉取日线(不复权)。"""
raw = client.stock_history(code, lt=lt, dividend="n")
df = pd.DataFrame(raw)
if df.empty:
return df
df["t"] = pd.to_datetime(df["t"])
df = df.sort_values("t").reset_index(drop=True)
for col in ["o", "c", "h", "l", "v"]:
df[col] = pd.to_numeric(df[col], errors="coerce")
return df[["t", "o", "c", "h", "l", "v"]]
def momentum_factor(close: np.ndarray, lookback: int, skip: int = 20) -> float:
"""过去 lookback 天、跳过最近 skip 天的累计收益。"""
n = len(close)
t_now = n - 1 - skip
t_prev = t_now - lookback
if t_prev < 0:
return np.nan
return close[t_now] / close[t_prev] - 1
def fwd_return(close: np.ndarray, horizon: int = 20) -> float:
"""未来 horizon 天的累计收益。"""
n = len(close)
if n < horizon + 1:
return np.nan
return close[-1] / close[-1 - horizon] - 1
def rank_ic(factor: pd.Series, fwd_ret: pd.Series) -> dict:
"""横截面 Spearman 秩相关。"""
mask = factor.notna() & fwd_ret.notna()
f, r = factor[mask], fwd_ret[mask]
if len(f) < 5 or f.nunique() < 2 or r.nunique() < 2:
return {"ic": None, "pvalue": None, "n": int(len(f))}
coef, pval = spearmanr(f, r)
return {"ic": round(float(coef), 4), "pvalue": round(float(pval), 4), "n": int(len(f))}
def main():
print(f"[start] 动量因子 6M vs 12M @ {datetime.now():%Y-%m-%d %H:%M:%S}")
rows = []
for code in UNIVERSE:
df = fetch_history(code, lt=300)
if df.empty or len(df) < 260:
continue
close = df["c"].values
rows.append({
"code": code,
"mom_6m": momentum_factor(close, 120, skip=20),
"mom_12m": momentum_factor(close, 240, skip=20),
"fwd_1m": fwd_return(close, horizon=20),
})
panel = pd.DataFrame(rows)
print(f"[universe] 有效股票 {len(panel)} / {len(UNIVERSE)}")
ic6 = rank_ic(panel["mom_6m"], panel["fwd_1m"])
ic12 = rank_ic(panel["mom_12m"], panel["fwd_1m"])
print(f"\n[IC] 6M -> IC={ic6['ic']} p={ic6['pvalue']} n={ic6['n']}")
print(f"[IC] 12M -> IC={ic12['ic']} p={ic12['pvalue']} n={ic12['n']}")
多空组合:做多 top quintile、做空 bottom quintile(占位,未真正回测)
panel_sorted = panel.sort_values("mom_6m", ascending=False)
n_q = max(1, len(panel_sorted) // 5)
long_ret = panel_sorted.head(n_q)["fwd_1m"].mean()
short_ret = panel_sorted.tail(n_q)["fwd_1m"].mean()
print(f"\n[多空示意] long={long_ret100:+.2f}% short={short_ret100:+.2f}% spread={(long_ret-short_ret)*100:+.2f}%")
if name == "main":
main()
代码要点
- momentum_factor(close, 120, skip=20):6M 动量跳过最近 20 个交易日(短期反转窗口)。
- rank_ic():横截面 Spearman 秩相关,n < 5 或单值时返回 null,避免假 IC。
- UNIVERSE:30 只大盘股示例,正式研究建议替换为全 A(5000+)并按行业分层抽样。
怎么跑
pip install mairui pandas scipy
export MAIRUI_LICENCE="<你的麦蕊证书>"
python main.py
说明:演示证书对所有股票返回相同的占位数据,无法测出真实 IC。换成正式证书后,30 只股票将有真实横截面差异,IC 即可直接计算。
五、实战注意事项
- 复权方式:因子计算建议用后复权(dividend='f')消除分红除权带来的假跌幅,否则会出现"分红日暴跌 -30%"的伪信号。代码示例默认不复权,正式研究请改 dividend='f'。
- 再平衡频率:日频调仓对动量因子不友好(交易成本爆炸),周频或月频是常见选择。
- 行业中性:A 股行业轮动极强,纯动量很容易押注单一行业(如 2020 年的白酒)。做 IC 时一定要"行业内排序"或者"行业中性化"后再算。
- 存活偏差:用当天截面时要剔除当天停牌、新上市未满 60 日的股票,否则幸存者偏差会显著高估 IC。
六、下一篇预告
02 将构造另一类经典因子——波动率因子,做"低波动异象"的多空组合,对比动量因子与低波因子在 A 股的"谁更稳"。
风险提示与免责声明
本文为技术研究示例,不构成任何投资建议。历史因子表现不代表未来收益,量化策略存在过拟合、市场制度变迁等风险。股票投资有风险,入市需谨慎。
系列目录:Python 量化因子实战 10 篇规划

浙公网安备 33010602011771号