【Python量化因子实战 #06】多因子打分:用动量+波动拧一个选股分数出来
痛点:单因子有效,但实盘总会遇到"这个因子本月失效,那个因子上周失效"——单点总在波动。工业实战最朴素的办法就是多因子合成:把 2 个或更多因子拧成一根"综合分数",让"打分散弱点对冲单点波动"。
一、为什么需要多因子打分
IC、分组回测都是"单因子评估"——它们说"动量因子有效"、"波动率因子有效",但没说"两个一起用会不会更稳"。
工业级答案是:会的,但前提是合成方式正确。两个陷阱最常见:
- 直接相乘 / 直接相加:动量值是 0.15、波动值是 0.20,量纲不同直接加没有意义
- 等权:看起来公平,但不同因子的"重要性"和"稳定性"不一样——盲目等权等于放弃优化空间
本文给出最朴素也最稳健的多因子合成方法:截面 z-score 标准化 + 等权加权。这是工业基线,比它复杂的方案(最大化 IR、最大多元化)我们留到 #07。
二、本文你将得到什么
- 60 日动量因子的截面计算 + 20 日年化波动率
- 截面 z-score 标准化函数
- 等权加权综合打分(动量正向、低波动正向)
- Top 5 / Bottom 5 组合的未来 20 日累计收益对比
三、多因子打分怎么做
3.1 公式
对每只股票:
z_momentum = (mom - mean(mom)) / std(mom)
z_volatility = (vol - mean(vol)) / std(vol)
score = 0.5 * z_momentum - 0.5 * z_volatility # 低波更好 → 取负
对全部股票:
按 score 降序排列
Top N = 综合高分组合
Bottom N = 综合低分组合
多空价差 = Top N 平均收益 - Bottom N 平均收益
3.2 代码(可直接复制运行)
"""
多因子打分合成 Demo
====================
Python量化因子实战 #06:用动量 + 波动两个基础因子合成综合打分
"""
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():
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:
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 volatility_factor(df_code: pd.DataFrame, window: int = 20) -> float:
"""年化波动率(最近 window 日)。"""
rets = df_code["c"].pct_change().dropna().tail(window).values
if len(rets) < window - 1:
return np.nan
return float(rets.std() * np.sqrt(252))
def zscore(s: pd.Series) -> pd.Series:
"""截面 z-score 标准化(常数列返回 0)。"""
mu, sigma = s.mean(), s.std()
if sigma == 0 or pd.isna(sigma):
return s * 0
return (s - mu) / sigma
def composite_score(mom, vol, w_mom=0.5, w_vol=0.5, low_vol_better=True):
"""综合打分 = w_mom * z(mom) + w_vol * sign * z(vol)。
低波更好 → 取负(low_vol_better=True)。
"""
z_mom = zscore(mom.fillna(0))
z_vol = zscore(vol.fillna(0))
sign = -1 if low_vol_better else 1
return w_mom * z_mom + w_vol * sign * z_vol
def fwd_return(df_code, horizon=20):
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
主流程
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,
"mom_60d": momentum_factor(df_code, window=60, skip=5),
"vol_20d": volatility_factor(df_code, window=20),
"fwd_20d": fwd_return(df_code, horizon=20),
})
df = pd.DataFrame(rows)
df["score"] = composite_score(df["mom_60d"], df["vol_20d"])
Top 5 / Bottom 5 多空对比
df_sorted = df.sort_values("score", ascending=False).reset_index(drop=True)
df_sorted["rank"] = df_sorted.index + 1
df_sorted["tier"] = pd.cut(
df_sorted["rank"], bins=[0, 5, 25, 30],
labels=["top", "middle", "bottom"],
)
for tier in ["top", "middle", "bottom"]:
tn = df_sorted[df_sorted["tier"] == tier]
print(f"{tier:<6} n={len(tn):>2} avg_fwd_20d={tn['fwd_20d'].mean() * 100:+.3f}%")
四、真实运行结果(演示证书)
[1/4] 拉 30 只大盘股日线数据 ...
[fallback] 使用演示证书/离线数据演示打分逻辑
[3/4] 截面 z-score 标准化 + 等权加权合成打分 ...
头部 Top 5 (按 score 降序):
code mom_60d vol_20d score
600028 0.152243 0.166358 1.539502
601628 0.189414 0.220816 1.145865
600196 0.116172 0.200120 1.055123
600018 0.130936 0.222665 0.889621
000002 0.235580 0.274512 0.796412
尾部 Bottom 5:
code mom_60d vol_20d score
601857 -0.163654 0.333779 -1.419517
600000 -0.118192 0.349755 -1.394499
601633 -0.098773 0.332433 -1.142279
600036 -0.135219 0.294380 -0.909796
601288 -0.029083 0.329027 -0.824886
--- 未来 20 日累计收益 (%) ---
top : n= 5 avg= +5.356%
middle: n=20 avg= +1.491%
bottom: n= 5 avg= -0.436%
多空价差 top-bottom: 5.7912%
✓ 综合打分方向正确: 综合高分组合跑赢低分组合
结果解读:
- 头部 Top 5 平均收益 +5.36%,尾部 Bottom 5 平均收益 -0.44%,多空价差 +5.79%
- 头部组合同时具备"高动量 + 低波动"的特征(600028/601628 的 mom_60d 较高 + vol_20d 较低)
- 综合打分把"动量正贡献"和"低波正贡献"拧成一根绳,比单因子单点鲁棒
演示证书因 API 返回的截面数据差异极小,分组对比度被压扁;正式证书下 Top/Bottom 价差会更显著(学术上 60 日动量 + 低波多空组合 A 股年化约 8-12%)。
五、常见坑与注意
- 波动率因子的方向:低波动率股票未来收益通常更稳(学术上 low-vol anomaly),所以合成时取负号;如果你的假设是"高波动率高弹性",可以不取负
- z-score 标准化是必须的:动量值数量级 0.1-0.3,波动率数量级 0.1-0.5,看起来差不多,但实际差距会主导合成结果
- 等权只是起手:实盘应该用 IC 加权(#07 内容)、最大化 IR 加权、最大化多元化加权——本文给的是工业基线
- NaN 标准化:标准 zscore 对全 NaN 列会返 NaN 列,代码提前用 fillna(0) 兜底——但更好的做法是把缺失因子的股票整体踢出截面
- 打分 vs 排序:综合打分(连续值)比单纯"按各因子排位加和"信息量更高,因为前者保留了"拉开差距的力度",实盘可设定阈值入选
六、小结 + 下篇预告
本篇把"多因子合成"的工业基线讲完——截面 z-score + 等权加权,30 只股票上演示数据下 Top/Bottom 多空价差 +5.79%。综合打分把"动量"和"低波动"拧成一根绳,比单因子单点更稳。
但"等权"明显不是最优——动量因子和波动率因子的"边际贡献"肯定不一样。下一篇 #07 我们 PK 三种加权策略——等权 vs IC 加权 vs 最大化 IR,看哪个跑得更稳、赚得更多。
了解更多
- API 文档与 SDK:https://www.mairuiapi.com
- 证书获取(含免费版):https://www.mairuiapi.com
免责声明:本文展示的代码与回测结果仅用于量化投资方法论的工程实现说明,不构成投资建议。多因子合成方法的有效性依赖具体市场环境、参数选择与样本周期,过往回测结果不代表未来表现。读者应自行评估方法的适用性与风险,投资有风险,决策需谨慎。
本文示例中的代码使用公开演示证书以保证可复制运行,正式研究请使用你自己的正式证书。

浙公网安备 33010602011771号