【Python量化多因子实战 05】因子也会轮动:因子动量与因子反转的择时应用

0 阅读7分钟

上一篇我们知道了要先做中性化再评价因子。但还有一个时间维度的问题没解决:因子本身也会"失效"和"回归"。

小市值因子曾经是 A 股的常青树,动量因子在美股百年有效——但任何因子都有顺风期和逆风期。能不能像跟踪股票动量一样,跟踪"因子的动量",在因子顺风时多配、逆风时空仓?这就是因子择时(Factor Timing)。

今天用真实数据把三种配置方式放在同一起跑线:静态等权、因子动量择时、因子反转择时,看谁活到最后。结果有点反直觉,但非常有价值。


本文你将得到什么

  1. 因子动量的定义与 Python 实现:过去 20 日因子多空价差能否预测未来 20 日。
  2. 三种因子配置策略的逐日对比代码:静态等权 / 因子动量 / 因子反转。
  3. 一组诚实的实测结论:因子择时什么时候有用、什么时候救不了你。
  4. 为下一篇工程化流水线埋好伏笔: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 只样本股测算;演示证书返回就近样本,正式证书可拉取全市场。)


怎么读这组数字(反直觉但重要)

  1. 因子动量相关性是负的(市值 -0.27、动量 -0.08):本样本期内,近期表现好的因子下一期反而更弱——这是因子反转的证据,不是因子动量。
  2. 但因子反转择时表现最差(-69.6%):因为样本期内两个因子全期都弱,反转策略等于满仓押注"下期会变好",把亏损失控放大。
  3. 因子动量择时的价值在"空仓":它有 40 天处于空仓(两个因子近 20 日价差都为负就不开仓),全年虽未跑赢静态等权,但显著减少了在因子失效期的暴露。
  4. 真正该学的结论:因子择时救不活失效的因子,它是风控信号(该不该开仓、配多少),而不是收益增强器。落地时把它做成 IC 加权里的"降权/空仓开关",比追求"猜对下一个赢家因子"现实得多。

三个容易踩的坑

  1. 样本期太短别下重结论:本文 102 个交易日、约 5 个评估块,只够说明方法论;实盘决策请用 3 年以上数据复验。
  2. 块与块之间别有前视:第 t 块的权重只能来自第 t-1 块的表现,代码里严格用 block - 1。
  3. 空仓也是一种仓位:别把"因子都不行"硬凑成"选个最不差的",现金本身就是仓位。

小结

因子和股票一样会轮动。因子动量/反转择时给出了一个可计算的"该不该相信这个因子"的信号——本样本期内它最大的贡献是让人在因子失效期空仓,而不是多赚钱。

下一篇是本系列收官:把数据接入、因子构造、中性化、IC 加权、持仓输出串成一条每天都能跑的工程化流水线,从因子真正走到持仓。

下篇预告:【Python量化多因子实战 06】从因子到持仓:多因子选股实盘工程化落地。


免责声明

本文仅为技术方法演示,不构成任何投资建议。文中示例数据仅用于教学,历史表现不代表未来收益。投资有风险,决策需谨慎。

代码与文档可参考 GitHub:github.com/MaiRuiApi