上一篇我们知道了要先做中性化再评价因子。但还有一个时间维度的问题没解决:因子本身也会"失效"和"回归"。
小市值因子曾经是 A 股的常青树,动量因子在美股百年有效——但任何因子都有顺风期和逆风期。能不能像跟踪股票动量一样,跟踪"因子的动量",在因子顺风时多配、逆风时空仓?这就是因子择时(Factor Timing)。
今天用真实数据把三种配置方式放在同一起跑线:静态等权、因子动量择时、因子反转择时,看谁活到最后。结果有点反直觉,但非常有价值。
本文你将得到什么
- 因子动量的定义与 Python 实现:过去 20 日因子多空价差能否预测未来 20 日。
- 三种因子配置策略的逐日对比代码:静态等权 / 因子动量 / 因子反转。
- 一组诚实的实测结论:因子择时什么时候有用、什么时候救不了你。
- 为下一篇工程化流水线埋好伏笔:IC 加权 + 空仓机制。
核心思路
因子多空价差
每个交易日,按因子值把股票分 5 组,计算:
因子当日价差 = 最高组未来5日平均收益 - 最低组未来5日平均收益
这个价差序列就是因子自己的"净值曲线"。
因子动量 / 反转
- 因子动量:假设因子近期强,未来还会强——把权重加给最近 20 日价差为正的因子。
- 因子反转:假设因子近期强,未来会均值回归——把权重加给近期价差为负的因子。
检验与对比
先算「过去 20 日价差 vs 未来 20 日价差」的相关性(因子动量的 IC),再把三种规则放进同一套逐日回测框架,用相同口径对比日均价差、胜率与 IC。
可运行代码
pip install mairui pandas numpy
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",
]
BLOCK = 20 # 以 20 个交易日为一个评估块
def fetch_history(code):
try:
bars = client.stock_history(code, "d", "n", st="20260401", 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
shares = cap.get("zgb") if cap else None
if not shares:
continue
for i in range(20, len(hist) - 5):
row, past, future = hist.iloc[i], hist.iloc[i - 20], hist.iloc[i + 5]
records.append({
"code": code,
"date": row["date"].strftime("%Y-%m-%d"),
"log_mcap": float(np.log(row["close"] * shares)),
"momentum": (row["close"] / past["close"] - 1) * 100,
"fwd_return": (future["close"] / row["close"] - 1) * 100,
})
df = pd.DataFrame(records)
df["f_size"] = -df["log_mcap"] # 小市值更好,方向统一为"值大则好"
def zscore(s):
return (s - s.mean()) / s.std() if s.std() else s * 0.0
df["z_size"] = df.groupby("date")["f_size"].transform(zscore)
df["z_mom"] = df.groupby("date")["momentum"].transform(zscore)
def day_spread(g, col):
if len(g) < 10:
return np.nan
q = pd.qcut(g[col].rank(method="first"), 5, labels=False)
return g.loc[q == 4, "fwd_return"].mean() - g.loc[q == 0, "fwd_return"].mean()
spread = pd.DataFrame({
"size": df.groupby("date").apply(day_spread, "z_size", include_groups=False),
"mom": df.groupby("date").apply(day_spread, "z_mom", include_groups=False),
}).dropna()
# 1) 因子动量相关性:过去20日价差 vs 未来20日价差
for name in ["size", "mom"]:
past = spread[name].rolling(BLOCK).mean()
future = spread[name].shift(-BLOCK).rolling(BLOCK).mean()
v = past.notna() & future.notna()
print(f"因子动量相关性[{name}]: {np.corrcoef(past[v], future[v])[0, 1]:.4f}")
# 2) 三种配置方式逐日对比
spread["block"] = np.arange(len(spread)) // BLOCK
def composite_spread(g, w_size, w_mom):
if w_size + w_mom <= 0:
return np.nan # 空仓
g = g.copy()
g["composite"] = (w_size * g["z_size"] + w_mom * g["z_mom"]) / (w_size + w_mom)
return day_spread(g, "composite")
rows = []
for name, rule in [("静态等权", None), ("因子动量择时", "mom_timing"), ("因子反转择时", "rev_timing")]:
daily = []
for b in sorted(spread["block"].unique()):
if b == 0:
w_s, w_m = 1.0, 1.0
else:
prev = spread[spread["block"] == b - 1][["size", "mom"]].mean()
if rule is None:
w_s, w_m = 1.0, 1.0
elif rule == "mom_timing":
w_s, w_m = max(prev["size"], 0), max(prev["mom"], 0)
else:
w_s, w_m = max(-prev["size"], 0), max(-prev["mom"], 0)
for d, g in df[df["date"].isin(spread.index) & (df["date"] == d)].groupby("date"):
s = composite_spread(g, w_s, w_m)
if not np.isnan(s):
daily.append(s)
ds = pd.Series(daily)
rows.append({"strategy": name, "mean_daily_spread_pct": round(ds.mean(), 4),
"win_rate": round((ds > 0).mean(), 4),
"cum_spread_pct": round(ds.sum(), 2), "flat_days": len(spread) - len(ds)})
print(pd.DataFrame(rows).to_string(index=False))
运行后你会看到类似结果:
因子动量相关性[size]: -0.2692
因子动量相关性[mom]: -0.0764
strategy mean_daily_spread_pct win_rate cum_spread_pct flat_days
静态等权 -0.0883 0.4706 -9.0 0
因子动量择时 -0.1911 0.4516 -11.8 40
因子反转择时 -0.6961 0.4100 -69.6 2
(示例基于 2026 年 4 月至 10 月真实行情、30 只样本股测算;演示证书返回就近样本,正式证书可拉取全市场。)
怎么读这组数字(反直觉但重要)
- 因子动量相关性是负的(市值 -0.27、动量 -0.08):本样本期内,近期表现好的因子下一期反而更弱——这是因子反转的证据,不是因子动量。
- 但因子反转择时表现最差(-69.6%):因为样本期内两个因子全期都弱,反转策略等于满仓押注"下期会变好",把亏损失控放大。
- 因子动量择时的价值在"空仓":它有 40 天处于空仓(两个因子近 20 日价差都为负就不开仓),全年虽未跑赢静态等权,但显著减少了在因子失效期的暴露。
- 真正该学的结论:因子择时救不活失效的因子,它是风控信号(该不该开仓、配多少),而不是收益增强器。落地时把它做成 IC 加权里的"降权/空仓开关",比追求"猜对下一个赢家因子"现实得多。
三个容易踩的坑
- 样本期太短别下重结论:本文 102 个交易日、约 5 个评估块,只够说明方法论;实盘决策请用 3 年以上数据复验。
- 块与块之间别有前视:第 t 块的权重只能来自第 t-1 块的表现,代码里严格用
block - 1。 - 空仓也是一种仓位:别把"因子都不行"硬凑成"选个最不差的",现金本身就是仓位。
小结
因子和股票一样会轮动。因子动量/反转择时给出了一个可计算的"该不该相信这个因子"的信号——本样本期内它最大的贡献是让人在因子失效期空仓,而不是多赚钱。
下一篇是本系列收官:把数据接入、因子构造、中性化、IC 加权、持仓输出串成一条每天都能跑的工程化流水线,从因子真正走到持仓。
下篇预告:【Python量化多因子实战 06】从因子到持仓:多因子选股实盘工程化落地。
免责声明
本文仅为技术方法演示,不构成任何投资建议。文中示例数据仅用于教学,历史表现不代表未来收益。投资有风险,决策需谨慎。
代码与文档可参考 GitHub:github.com/MaiRuiApi