3. 行业动量因子横截面排序与分层回测
任务拆解:从日行情到分层净值
本例要完成一条完整的行业动量分层回测链:以申万一级行业指数日收盘价为输入,计算每个行业过去 N 个交易日的累计收益率作为动量分数,在每个调仓日按横截面排序把 31 个行业分成 5 组,做多第 1 组(最强动量)、做空第 5 组(最弱动量),月度调仓,最后得到多空组合净值曲线,并检查各组年化收益是否随动量排名单调递减。
关键约定先固定下来,后续代码、表格与图示保持一致:
- 动量方向:过去 N 日累计收益率越高,动量越强,排名越靠前(第 1 组为赢家组)。
- 排序时点:调仓日 T 使用截至 T-1 收盘的数据计算动量,避免使用未来信息。
- 持有区间:T 日收盘建仓,持有至下一个调仓日收盘,收益按行业指数日收益率累乘。
- 分组方式:横截面等数量分组,31 个行业分 5 组时前 4 组各 6 个、末组 7 个,组内等权。
动量因子与分层回测的数据流
下图把整条链路的关键对象和方向画清楚:日行情宽表 → 滚动动量 → 横截面排序 → 分组权重 → 持有期收益 → 分层净值与单调性判读。注意排序发生在每个调仓日的横截面上,而净值累乘发生在时间序列方向上,这两个方向是这条链路最容易混淆的地方。
核心实现:动量计算、横截面分组与净值累乘
下面给出可直接运行的 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 |
| G2 | 6 | 观察 | 与 G1 的收益差反映动量强度衰减速度 |
| G3 | 6 | 中性 | 接近市场平均,可作为分层中枢参照 |
| G4 | 6 | 观察 | 与 G5 的差距反映输家组的弱势程度 |
| G5(输家) | 7 | 做空 | 年化收益应最低;若反而偏高,需检查排序方向 |
| 多空组合 | — | G1 − G5 | 净值斜率持续为正且分层有序,才认为动量有效 |
可调参数与敏感性检查
动量分层结果对回看窗口 N 和分组数比较敏感。建议至少做一次参数扫描:固定分组数为 5,令 N 取 20、60、120、250,分别记录分层年化收益与单调性是否成立;再固定 N=60,令分组数取 3、5、10,观察多空收益是否随分组数增加而单调增强。若某个参数下分层顺序明显错乱,应优先怀疑数据对齐问题或排序方向写反,而不是直接归因于市场风格切换。
完成上述步骤后,本案例的交付物是一张包含调仓日分组明细、各分层净值序列和多空净值序列的结果表,以及一份分层年化收益与单调性判断的简要结论,可直接作为后续交易成本修正与 ETF 可交易性验证的输入。