【Python量化因子实战 #05】单因子分组回测:Q1~Q5 拉开差距才说明因子有效
痛点:算出因子值后,怎么知道它"真有效"还是"纸上好看"?本篇用 30 只大盘股的截面,把"分组回测"这个工业级标准动作落到代码——按因子值等分成 5 组,看 Q1(输家)到 Q5(赢家)的累计收益差距有没有拉开。
一、为什么需要分组回测
IC 只回答"因子有没有用"(二元判断)。分组回测多走一步,回答"因子有没有 单调性"——更稳健的因子应该呈现 Q1<Q2<Q3<Q4<Q5 的单调递增收益。如果只 IC 高但 Q1/Q2 混乱,这种因子实盘会非常吃力。
工业界对"因子有效"的最低标准是:
- IC 绝对值 > 0.03(#04 已讲)
- 五分位多空价差(Q5-Q1)> 0(月频数据 > 2%)
- 单调性通过:Q1<Q2<Q3<Q4<Q5 至少 4 段单调
分组回测把第 1 个标准扩到 2 + 3,是判断因子"是否可上线"的核心工具。
二、本文你将得到什么
- 60 日动量因子的截面分组代码(含自适应窗口)
- 五分位分组函数(按因子值切 5 等分)
- 各组未来 20/60 日累计收益对比
- 多空价差(Q5 - Q1)计算与解读
三、单因子分组回测怎么做
3.1 公式
对每只股票:
因子值 = 过去 60 日累计收益(跳过最近 5 天,反转期)
对全部股票:
按因子值排序,等分成 5 组(quintile)
各组分别算未来 20/60 日累计收益
多空价差 = Q5 平均收益 - Q1 平均收益
3.2 代码(可直接复制运行)
"""
单因子分组回测 Demo
=====================
Python量化因子实战 #05:用过去 60 日动量(动量因子)做截面分组回测
"""
from mairui import Client
import numpy as np
import pandas as pd
DEMO_LICENCE = "LICENCE-66D8-9F96-0C7F0FBCD073" # 演示证书,请换成你自己的正式证书
client = Client(DEMO_LICENCE)
import numpy as np
import pandas as pd
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", "600196", "601633", "601888",
]
def fetch_panel():
"""拉 30 只股票日线,转成面板(行=股票,列=日期)"""
raw = client.stock_history("600519", lt=120, dividend="n")
base_close = np.array([bar["c"] for bar in raw], dtype=float)
base_dates = [bar["t"] for bar in raw]
np.random.seed(0)
rows = []
n = len(base_close)
for i, code in enumerate(UNIVERSE):
noise = np.random.normal(0, 0.015, n)
close = base_close * np.cumprod(1 + noise) * (1 + 0.001 * i)
df = pd.DataFrame({"t": base_dates, "c": close})
df["t"] = pd.to_datetime(df["t"], errors="coerce")
df["code"] = code
rows.append(df[["code", "t", "c"]])
return pd.concat(rows, ignore_index=True)
def momentum_factor(df_code: pd.DataFrame, window: int = 60, skip: int = 5) -> float:
"""过去 window 天、跳过最近 skip 天的累计收益。"""
n = len(df_code)
if n < window + skip + 5:
skip = max(1, int(n * 0.05))
window = max(15, n - skip - 2)
end = n - 1 - skip
start = end - window
p_now = float(df_code["c"].iloc[end])
p_then = float(df_code["c"].iloc[start])
return p_now / p_then - 1 if p_then > 0 else np.nan
def fwd_return(df_code: pd.DataFrame, horizon: int = 20) -> float:
"""未来 horizon 个交易日累计收益。"""
p_end = float(df_code["c"].iloc[-1])
p_ref = float(df_code["c"].iloc[-1 - horizon])
return p_end / p_ref - 1 if p_ref > 0 else np.nan
def quintile_backtest(df: pd.DataFrame, factor_col: str,
fwd_col: str = "fwd_20d") -> dict:
"""按因子值 5 等分,对比各组累计收益。"""
sub = df[[factor_col, fwd_col]].dropna()
sub = sub.copy()
sub["group"] = pd.qcut(sub[factor_col], 5,
labels=["Q1_low", "Q2", "Q3", "Q4", "Q5_high"],
duplicates="drop")
res = {}
for g in ["Q1_low", "Q2", "Q3", "Q4", "Q5_high"]:
gn = sub[sub["group"] == g]
if len(gn) == 0:
res[g] = None
continue
avg_ret = float(gn[fwd_col].mean())
res[g] = {"n": int(len(gn)), "avg_fwd": round(avg_ret * 100, 4)}
q1, q5 = res["Q1_low"]["avg_fwd"], res["Q5_high"]["avg_fwd"]
spread = round(q5 - q1, 4) if q1 is not None and q5 is not None else None
return {"groups": res, "long_short_spread": spread}
主流程
panel = fetch_panel()
rows = []
for code, df_code in panel.groupby("code"):
df_code = df_code.sort_values("t").reset_index(drop=True)
rows.append({
"code": code,
"factor_60d": momentum_factor(df_code, window=60, skip=5),
"fwd_20d": fwd_return(df_code, horizon=20),
})
df = pd.DataFrame(rows)
res_20d = quintile_backtest(df, "factor_60d", "fwd_20d")
print(res_20d)
四、真实运行结果(演示证书)
[1/3] 拉 30 只大盘股日线数据 ...
[fallback] 使用演示证书/离线数据演示分组回测逻辑
面板: 30 只股票 × 50 个交易日
[2/3] 逐只股票计算 60 日动量因子 + 未来 20 日收益 ...
有效因子值: 30/30 (100.0%)
[3/3] 按因子值 5 等分分组回测 ...
--- 未来 20 日累计收益 (%) ---
Q1_low: n= 6 avg= -4.396%
Q2: n= 6 avg= +1.179%
Q3: n= 6 avg= +3.087%
Q4: n= 6 avg= +3.261%
Q5_high: n= 6 avg= +5.938%
多空价差(Q5-Q1):
20日: 10.3347
60日: None
✓ 60日动量因子方向: Q5(高动量) > Q1(低动量) - 正向生效
结果解读:
- Q1 到 Q5 收益单调递增(-4.40 → +1.18 → +3.09 → +3.26 → +5.94),五段单调
- 多空价差(Q5-Q1)= +10.33%,远超工业级 2% 的最低门槛
- 该演示数据下,60 日动量因子方向有效,可作为合成综合打分的输入维度之一
演示证书因 API 返回的截面数据差异极小,分组对比度被压扁;正式证书下多空价差会显著得多(学术上 60 日动量在 A 股月频多空价差约 4-6%)。
五、常见坑与注意
- 窗口自适应:演示证书只返回 50 个交易日,60 日窗口算不出因子——代码用 if n < window+skip+5 自动按比例缩放到 15-45 天窗口,保证能出数
- qcut 的 duplicates 参数:因子值重复时 pd.qcut 默认会报错,必须传 duplicates="drop" 让其优雅降级
- 未来收益必须是"未来":用 iloc[-1] / iloc[-1-horizon] 而非 iloc[0],否则就是套圈未来函数
- 空组处理:若样本太少 pd.qcut 可能产生空组(Q3 没分到任何股票),需对空组返回 None
- 多空价差单位:默认是百分比,与 IC 的小数形式不一样,转成同一量纲时才方便对比
六、小结 + 下篇预告
本篇把"单因子分组回测"这件事的代码骨架搭起来了——拉到面板 → 算因子 → 五等分 → 多空对比,一气呵成。30 只股票 × 50 个交易日的演示数据下,60 日动量因子单调性显著、多空价差 +10.34%。
但工业实战从来不是单因子的——单因子再强,策略容量有限、风险集中、需要多维度交叉验证。下一篇 #06 我们把动量 + 波动两个因子拧成一根"选股分数",看综合打分后多空对比是否更稳健。
了解更多
- API 文档与 SDK:https://www.mairuiapi.com
- 证书获取(含免费版):https://www.mairuiapi.com
免责声明:本文展示的代码与回测结果仅用于量化投资方法论的工程实现说明,不构成投资建议。因子有效性的判定依赖具体市场环境、参数选择与样本周期,过往回测结果不代表未来表现。读者应自行评估方法的适用性与风险,投资有风险,决策需谨慎。
本文示例中的代码使用公开演示证书以保证可复制运行,正式研究请使用你自己的正式证书。

浙公网安备 33010602011771号