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

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

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

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


本文你将得到什么

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


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

  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

相关推荐
陈随易1 小时前
Rolldown 把插件改动回传提速 26 倍
前端·后端·程序员
金銀銅鐵9 小时前
为什么枚举类中可以定义抽象方法
java·后端·ai编程
陈随易10 小时前
Vue 编译器换掉 source map,快了三四成
前端·后端·程序员
txxzjmzlh11 小时前
SpringBoot 统⼀功能处理
java·spring boot·后端
nice_lcj52013 小时前
Java Lambda 表达式入门:怎么写、怎么用、常用方法有哪些
java·开发语言·后端·面试
IT_陈寒14 小时前
Redis大key删除为什么会导致服务卡顿?我的线上事故复盘
前端·人工智能·后端
系统集成-鹰翔14 小时前
ERP图片改名后仍命中识别缓存:缓存键、十分钟到期与调用计数
javascript·经验分享·后端
用户83562907805114 小时前
使用 Python 在 Excel 中添加和自定义迷你图
后端·python
码事漫谈14 小时前
给AI看一颗❤️,它就开始写递归了
后端