上一篇我们知道了要先做中性化再评价因子。但还有一个时间维度的问题没解决:因子本身也会"失效"和"回归"。
小市值因子曾经是 A 股的常青树,动量因子在美股百年有效------但任何因子都有顺风期和逆风期。能不能像跟踪股票动量一样,跟踪"因子的动量",在因子顺风时多配、逆风时空仓?这就是因子择时(Factor Timing)。
今天用真实数据把三种配置方式放在同一起跑线:静态等权、因子动量择时、因子反转择时,看谁活到最后。结果有点反直觉,但非常有价值。
本文你将得到什么
- 因子动量的定义与 Python 实现:过去 20 日因子多空价差能否预测未来 20 日。
- 三种因子配置策略的逐日对比代码:静态等权 / 因子动量 / 因子反转。
- 一组诚实的实测结论:因子择时什么时候有用、什么时候救不了你。
- 为下一篇工程化流水线埋好伏笔:IC 加权 + 空仓机制。
核心思路
因子多空价差
每个交易日,按因子值把股票分 5 组,计算:
text
因子当日价差 = 最高组未来5日平均收益 - 最低组未来5日平均收益
这个价差序列就是因子自己的"净值曲线"。
因子动量 / 反转
- 因子动量:假设因子近期强,未来还会强------把权重加给最近 20 日价差为正的因子。
- 因子反转:假设因子近期强,未来会均值回归------把权重加给近期价差为负的因子。
检验与对比
先算「过去 20 日价差 vs 未来 20 日价差」的相关性(因子动量的 IC),再把三种规则放进同一套逐日回测框架,用相同口径对比日均价差、胜率与 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",
]
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))
运行后你会看到类似结果:
text
因子动量相关性[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