← 返回文章列表 因子研究

动量因子的三种构造方式,哪一种在 A 股还有效

同一个想法用收益率排序、残差动量和路径调整动量分别实现,回测差异比想象中大得多。

先把数据对齐

多数回测的偏差不来自模型,而来自时间戳。分钟线的收盘价、日频的复权因子、财报的发布时点,三者如果错位一天,一个看起来年化 30% 的因子就会变成噪声。先用统一的交易日历重采样,再做任何计算。

import pandas as pd

px = pd.read_parquet('daily.parquet')
px = px.pivot(index='date', columns='code', values='close')

ret   = px.pct_change()
mom   = px.pct_change(20).shift(1)   # 滞后一天,避免未来函数
score = mom.rank(axis=1, pct=True)
w     = (score > 0.8).astype(float)
w     = w.div(w.sum(axis=1), axis=0).fillna(0)

pnl = (w * ret).sum(axis=1) - 0.0008 * w.diff().abs().sum(axis=1)
print(pnl.mean() / pnl.std() * 252**0.5)

留意

上面的 8bp 单边成本只是占位。A 股的实际冲击成本随换手率非线性上升,换手翻倍时不要假设成本也只是翻倍。

三种构造方式

同样是「过去涨得多的继续涨」,落到代码里至少有三条路,它们对噪声、对行业轮动、对个股跳空的敏感度完全不同。

# 路径调整动量:夏普式的动量
win  = 20
cum  = px.pct_change(win)
vol  = ret.rolling(win).std() * (252 ** 0.5)
sharpe_mom = (cum / vol.replace(0, pd.NA)).shift(1)

# 残差动量:先剔除市场与行业
mkt = ret.mean(axis=1)
beta = ret.rolling(120).cov(mkt).div(mkt.rolling(120).var(), axis=0)
resid = ret.sub(beta.mul(mkt, axis=0))
resid_mom = resid.rolling(win).sum().shift(1)

夏普比率之外

一条净值曲线至少要看四个数:年化收益、最大回撤、回撤修复天数、以及不同参数下结果的方差。前三个描述历史,最后一个才描述稳健性。

18.4%
年化收益
1.32
夏普比率
-11.6%
最大回撤
64 天
回撤修复

把回看窗口从 20 天扫到 60 天,如果夏普在 1.1 到 1.4 之间平滑变化,这个信号大概率是真的;如果只有 20 天那一档突出,剩下的都在 0.3 附近,那就是过拟合。

下一节我们把这个动量信号拆成行业中性和市值中性两层,看看超额收益还剩多少。

动量因子回测pandas
下一篇

复权、停牌与退市:把行情数据洗干净的清单