3. 行业动量因子横截面排序与分层回测

任务拆解:从日行情到分层净值

本例要完成一条完整的行业动量分层回测链:以申万一级行业指数日收盘价为输入,计算每个行业过去 N 个交易日的累计收益率作为动量分数,在每个调仓日按横截面排序把 31 个行业分成 5 组,做多第 1 组(最强动量)、做空第 5 组(最弱动量),月度调仓,最后得到多空组合净值曲线,并检查各组年化收益是否随动量排名单调递减。

关键约定先固定下来,后续代码、表格与图示保持一致:

  • 动量方向:过去 N 日累计收益率越高,动量越强,排名越靠前(第 1 组为赢家组)。
  • 排序时点:调仓日 T 使用截至 T-1 收盘的数据计算动量,避免使用未来信息。
  • 持有区间:T 日收盘建仓,持有至下一个调仓日收盘,收益按行业指数日收益率累乘。
  • 分组方式:横截面等数量分组,31 个行业分 5 组时前 4 组各 6 个、末组 7 个,组内等权。

动量因子与分层回测的数据流

下图把整条链路的关键对象和方向画清楚:日行情宽表 → 滚动动量 → 横截面排序 → 分组权重 → 持有期收益 → 分层净值与单调性判读。注意排序发生在每个调仓日的横截面上,而净值累乘发生在时间序列方向上,这两个方向是这条链路最容易混淆的地方。

行业动量分层回测:横截面排序 + 时间序列累乘 日行情宽表 31 行业 × 交易日 收盘价 / 日收益率 滚动动量 MOM = P(t-1)/P(t-1-N) - 1 N = 60 交易日(示例) 横截面排序 每个调仓日 T 独立排序 rank 1 = 动量最强 等数量分 5 组 G1 赢家 … G5 输家 组内等权,6/6/6/6/7 G1 赢家组 做多 G2 观察 G3 中性 G4 观察 G5 输家组 做空 持有期收益(T 收盘 → 下一调仓日收盘) 组收益 = 组内行业日收益率等权平均后逐日累乘;多空收益 = G1 组收益 − G5 组收益 分层净值曲线 G1…G5 各自累乘净值 + 多空净值 月度调仓,共 5 条曲线 单调性判读 年化收益 G1 > G2 > … > G5 ? 多空净值斜率 > 0 且分层有序 → 动量有效

核心实现:动量计算、横截面分组与净值累乘

下面给出可直接运行的 Python 实现。为便于复现,用固定随机种子生成 31 个行业、约 6 年的模拟日行情作为教学示例数据;真实使用时把 prices 替换为申万一级行业指数收盘价宽表即可,其余逻辑不变。示例中的收益数字均为模拟数据结果,不代表真实市场表现。

import numpy as np
import pandas as pd

rng = np.random.default_rng(20240501)
n_ind, n_day = 31, 1500
dates = pd.bdate_range("2018-01-02", periods=n_day)
industries = [f"IND{i:02d}" for i in range(1, n_ind + 1)]

# 模拟:给不同行业设置不同的漂移,制造可被动量捕捉的持续性
drift = rng.normal(0.0002, 0.0004, n_ind)
ret = rng.normal(drift, 0.012, size=(n_day, n_ind))
prices = pd.DataFrame(100 * np.cumprod(1 + ret, axis=0),
                      index=dates, columns=industries)

N = 60          # 动量回看窗口(交易日)
N_GROUP = 5     # 分层组数
REBAL = "M"     # 月度调仓

# 1) 动量因子:截至 T-1 的 N 日累计收益,shift(1) 防止未来信息
mom = prices.pct_change(N).shift(1)

# 2) 调仓日:每月最后一个交易日
rebal_dates = prices.resample(REBAL).last().index
rebal_dates = [d for d in rebal_dates if d in prices.index]

# 3) 日收益率
daily_ret = prices.pct_change().fillna(0.0)

# 4) 逐调仓日分组,生成持有期权重
group_ret = pd.DataFrame(index=prices.index,
                         columns=[f"G{i}" for i in range(1, N_GROUP + 1)],
                         dtype=float)
weights_log = []

for k, t in enumerate(rebal_dates[:-1]):
    t_next = rebal_dates[k + 1]
    score = mom.loc[t].dropna()
    if len(score) < N_GROUP:
        continue
    # 横截面排序:动量从高到低,rank 1 为赢家
    ranked = score.sort_values(ascending=False)
    labels = pd.qcut(np.arange(len(ranked)), N_GROUP,
                     labels=[f"G{i}" for i in range(1, N_GROUP + 1)])
    mapping = dict(zip(ranked.index, labels))
    weights_log.append({"date": t, **{ind: mapping[ind] for ind in ranked.index}})

    hold = daily_ret.loc[(daily_ret.index > t) & (daily_ret.index <= t_next)]
    for g in group_ret.columns:
        members = [ind for ind, lab in mapping.items() if lab == g]
        if members:
            group_ret.loc[hold.index, g] = hold[members].mean(axis=1)

group_ret = group_ret.fillna(0.0)
nav = (1 + group_ret).cumprod()
long_short = group_ret["G1"] - group_ret[f"G{N_GROUP}"]
ls_nav = (1 + long_short).cumprod()

# 5) 年化收益与单调性检查
ann = (nav.iloc[-1] ** (252 / len(nav)) - 1)
print(ann.round(4))
print("单调递减:", all(ann[f"G{i}"] >= ann[f"G{i+1}"] for i in range(1, N_GROUP)))
print("多空年化:", round(ls_nav.iloc[-1] ** (252 / len(ls_nav)) - 1, 4))

几个实现要点值得单独说明:

  • pct_change(N).shift(1) 是防未来信息的关键。若省略 shift(1),调仓日 T 的动量会包含 T 日当天收益,回测结果会系统性偏乐观。
  • 用 np.arange(len(ranked)) 配合 qcut 做等数量分组,而不是对动量值本身分箱,避免动量分布偏斜导致组内行业数量严重不均。
  • 持有期收益用 daily_ret.loc[(index > t) & (index <= t_next)] 截取,保证区间不重叠、不遗漏,调仓日当天收益归属上一持有期。
  • 组内等权即对成员行业日收益率取均值,再逐日累乘,等价于每日再平衡的等权组合;若要求买入持有,需改为按权重累乘。

结果判读:分层单调性与多空净值

回测完成后,先看分层年化收益表,再看多空净值曲线。判断动量在行业层面是否有效的核心标准不是多空收益绝对值,而是分层是否有序:如果 G1 到 G5 的年化收益没有呈现单调递减,说明动量信号在该样本区间内不稳定,多空收益可能只来自某一端的偶然表现。

分组行业数量组合方向观察重点
G1(赢家)6做多年化收益应最高,且回撤不应显著劣于 G3
G26观察与 G1 的收益差反映动量强度衰减速度
G36中性接近市场平均,可作为分层中枢参照
G46观察与 G5 的差距反映输家组的弱势程度
G5(输家)7做空年化收益应最低;若反而偏高,需检查排序方向
多空组合—G1 − G5净值斜率持续为正且分层有序,才认为动量有效
若分层单调性成立但多空净值波动很大,通常说明动量因子的收益集中在少数几段趋势行情中。此时可进一步查看多空净值的月度胜率和最大回撤,而不是只看最终年化收益。
本例未计入交易成本、涨跌停与流动性约束,多空净值属于毛收益口径。月度调仓下行业分组换手率可能较高,实际评估需在后续环节加入双边佣金、印花税与冲击成本后再判断策略是否可交易。

可调参数与敏感性检查

动量分层结果对回看窗口 N 和分组数比较敏感。建议至少做一次参数扫描:固定分组数为 5,令 N 取 20、60、120、250,分别记录分层年化收益与单调性是否成立;再固定 N=60,令分组数取 3、5、10,观察多空收益是否随分组数增加而单调增强。若某个参数下分层顺序明显错乱,应优先怀疑数据对齐问题或排序方向写反,而不是直接归因于市场风格切换。

完成上述步骤后,本案例的交付物是一张包含调仓日分组明细、各分层净值序列和多空净值序列的结果表,以及一份分层年化收益与单调性判断的简要结论,可直接作为后续交易成本修正与 ETF 可交易性验证的输入。