你一定听过「强者恒强」:班里成绩好的同学,下学期大概率还坐在前排;超市里卖得动的爆款,下个月还是爆款。股市里也有一批人专门买「最近涨得最好的」,这不是玄学,它是量化研究中被验证得最多的一种市场现象——动量。
上一篇我们把回测报告自动化了,评估一个策略的效率大大提高;从本篇起正式进入策略篇,第一站就是动量策略(Momentum Strategy)。有了第 19 篇的评价指标和第 26 篇的自动报告,我们可以像专业人士一样工作:提出假设 → 写代码回测 → 看报告下结论。本篇讲清动量效应的直觉与解释,实现一个「过去 N 日收益排序、持有第一名」的最小轮动策略,直面它最凶险的软肋——动量崩溃,以及对应的三道纪律:持有期、冷却期、绝对止损;最后对 2–3 只 ETF 做一个教学性回测。
本篇目标
- 能说出动量效应(Momentum Effect)的直觉解释,以及它在什么环境下容易失效。
- 能用 Pandas 实现过去 N 日收益率排序选股 / 选 ETF 的最小动量策略,并用
shift(1)杜绝未来函数。 - 能解释动量崩溃(Momentum Crash)现象,并给策略加上持有期、冷却期与绝对止损三道纪律。
- 能对 2–3 只 ETF 跑通一个教学性动量回测,并与等权买入持有基准对比。
- 能识别「样本极小」的教学回测局限,不把 3 只 ETF 五年的结果当成普遍规律。
核心概念
动量效应:强者恒强的直觉与解释
动量效应(Momentum Effect)指:过去一段时间涨幅靠前的资产,未来较短一段时间内继续跑赢的概率,统计上高于过去涨幅靠后的资产。学术上最著名的证据是 Jegadeesh 与 Titman 在 1993 年对美国股票的研究:过去 3–12 个月表现最好的股票组合,在接下来的 3–12 个月里平均继续跑赢。注意措辞——这是对「一篮子资产的平均效应」,不是「每只股票都会继续涨」。
为什么会有动量?主流解释有三类,都属于观点而非定论:一是信息反应不足(Underreaction),利好刚出来时市场没消化完,价格沿着基本面方向慢慢走;二是资金与注意力的自我强化,涨得好的标的进入更多人的视野,买入推高价格;三是风险补偿,持有「拥挤且波动大」的动量组合本身承担了阶段性崩溃风险,超额收益是对这份风险的报酬。
还有一个对 A 股读者重要的事实(属于经验观察,请自己回测验证):动量效应在 A 股个股上的表现历来有争议,不少研究认为 A 股个股动量偏弱、短期反转更强;而在指数、ETF 这类「一篮子」层面的轮动相对更常被使用。本篇选择用 ETF 做演示,正是出于这个考虑。
划重点:动量赚的不是「追涨杀跌」的情绪钱,而是「价格向新信息充分调整」的过程钱。所以动量策略最怕的不是横盘,而是 V 型反转——涨得好好的突然崩,这就是后面要讲的动量崩溃。
过去 N 日收益率排序:最小可行的动量规则
动量分数(Momentum Score)最常用的定义朴素得惊人:过去 N 个交易日的累计收益率,实现上就是一行 prices.pct_change(N)。完整规则由三件套构成:
- 观察期(Lookback Period)N:用多长的过去算动量,常见 20 / 60 / 120 / 250 日。学术惯例还有个细节叫「12-1 动量」:用过去 12 个月但跳过最近 1 个月,因为最近 1 个月常有短期反转。
- 持有期(Holding Period):多久调一次仓,比如每 20 个交易日。调仓日重新排名,只有第一名变了才换仓。
- 持仓数 K:排序后取前 K 名。本篇教学取 K=1,机制最清楚,但集中度风险也最大;正式做法通常持 5–20 只分散。
排序对象可以是股票池,也可以是 ETF 池。对新手,ETF 池更友好:不退市、没有个股黑天鹅、流动性好。
动量崩溃与三道纪律
动量崩溃(Momentum Crash):动量策略的收益不是均匀分布的。市场深跌后急速反弹的阶段(比如熊市底部),之前跌得最惨的板块反而反弹最猛,而动量策略手里拿着的恰恰是「跌得少、涨得慢」的旧赢家,同时错失反弹——于是策略在市场回暖时巨亏。应对靠纪律,不靠预测:
- 持有期纪律:不要每天盯着动量排名调仓。频繁换标的等于手续费吃光收益,还被短期噪音牵着走。固定每 20 个交易日(约一个月)调一次,其他时间纹丝不动。
- 冷却期纪律(Cooldown Period):刚因止损离场的标的,强制空仓到下一个调仓日才允许重新建仓,防止在崩塌途中「刚割肉又追回来」。
- 绝对止损纪律(Hard Stop-loss):单笔持仓自入场起亏损超过阈值(本篇示例 10%),无条件离场,不看理由。动量崩溃来临时,最大的风险是把「回调」硬扛成「腰斩」。
经验提醒:三道纪律的共同点是「事先定好、机械执行」。动量策略的性格是「小赚多次、偶尔大亏」,没有止损纪律的动量策略,等于把手术刀递给不用麻醉药的医生。
教学回测的边界:样本极小,结论勿放大
本篇用 3 只 ETF、5 年数据演示机制,必须诚实说清局限:标的只有三只,动量排序的「选择」作用几乎无从发挥;5 年里市场状态循环不完整;未考虑停牌、涨跌停无法成交等约束。所以本篇回测的定位是验证代码机制正确,不是证明策略赚钱。严谨的动量研究需要几十上百个标的、跨越牛熊的多轮周期,那是第 24 篇组合回测与第 31 篇多因子的领域。
完整代码
依赖安装:uv pip install akshare pandas numpy。数据用 akshare 拉三只 ETF(沪深300、中证500、黄金)的前复权收盘价,拉不到就用本地造数据 fallback——三条性格不同的随机游走,保证断网也能跑通机制。
# 块 1:获取三只 ETF 的前复权收盘价 + 动量分数工具函数
# uv pip install akshare pandas numpy
import pandas as pd
ETF_NAMES = {"510300": "沪深300ETF", "510500": "中证500ETF", "518880": "黄金ETF"}
def load_prices() -> pd.DataFrame:
"""拉三只 ETF 的前复权收盘价;akshare 不可用时本地造数据。"""
try:
import akshare as ak
data = {}
for code in ETF_NAMES:
df = ak.fund_etf_hist_em(symbol=code, period="daily",
start_date="20200101", end_date="20241231",
adjust="qfq") # 前复权
data[ETF_NAMES[code]] = pd.Series(df["收盘"].values,
index=pd.to_datetime(df["日期"]))
return pd.DataFrame(data).dropna().astype(float)
except Exception:
# fallback:三条漂移率不同的几何随机游走,固定种子可复现(非真实行情)
import numpy as np
rng = np.random.default_rng(7)
idx = pd.bdate_range("2020-01-01", "2024-12-31")
drifts = [0.0003, 0.0001, -0.0001] # 三条曲线性格不同
data = {}
for (code, name), drift in zip(ETF_NAMES.items(), drifts):
ret = rng.normal(drift, 0.011, len(idx))
data[name] = pd.Series(1.0 * np.exp(np.cumsum(ret)), index=idx)
return pd.DataFrame(data)
def momentum_rank(prices: pd.DataFrame, date: pd.Timestamp,
n: int = 60) -> pd.Series:
"""辅助函数:给定日期,返回各标的过去 n 日动量分数(降序)。"""
past = prices.loc[:date].tail(n + 1)
score = past.iloc[-1] / past.iloc[0] - 1 # 过去 n 日累计收益
return score.sort_values(ascending=False)
prices = load_prices()
print(f"数据起止:{prices.index[0].date()} ~ {prices.index[-1].date()},共 {len(prices)} 天")
数据就绪,下面是本篇的核心:一个支持绝对止损的动量轮动回测。
# 块 2:动量轮动回测(支持绝对止损)
import pandas as pd
N_LOOKBACK = 60 # 观察期:过去 60 个交易日的动量
N_HOLD = 20 # 持有期:每 20 个交易日调一次仓
FEE = 0.0005 # 单边手续费 0.05%,双边合计 0.1%(万 10)
def momentum_backtest(prices: pd.DataFrame, stop: float | None = None) -> pd.Series:
"""过去 N 日收益排序、持有第 1 名。约定:信号用截至昨日收盘的数据,
按昨日收盘价成交(等价于今日开盘),因此当日损益归属昨日形成的仓位。
stop 不为 None 时启用绝对止损:自入场起亏超 stop 离场,冷却到下个调仓日。"""
ret = prices.pct_change().fillna(0)
momo = prices.pct_change(N_LOOKBACK) # 过去 N 日动量分数
nav, holding, pnl = {}, None, 1.0 # pnl:本笔持仓截至昨收的累计盈亏
for i in range(N_LOOKBACK + 1, len(prices)):
date, fee_today, stopped_today = prices.index[i], 0.0, False
# 第一步:用截至昨收的信息决定今天的仓位(止损优先,其次调仓)
if holding is not None and stop is not None and pnl - 1 <= -stop:
holding, pnl, fee_today, stopped_today = None, 1.0, FEE, True
if (i - N_LOOKBACK - 1) % N_HOLD == 0 and not stopped_today:
pick = momo.iloc[i - 1].idxmax() # 昨日收盘可知的动量第一
if pick != holding: # 第一名变了才换仓,省手续费
if holding is not None:
fee_today += FEE # 卖旧:单边 0.05%
holding, pnl = pick, 1.0
fee_today += FEE # 买新:单边 0.05%
# 第二步:今日损益 = 仓位 × 今日收益 − 手续费
nav[date] = (ret[holding].iloc[i] if holding is not None else 0.0) - fee_today
# 第三步:收盘后更新本笔盈亏,供明日的止损判断(绝不提前使用)
if holding is not None:
pnl *= 1 + ret[holding].iloc[i]
return pd.Series(nav, name="动量轮动").add(1).cumprod()
最后跑回测、加基准、出对比:
# 块 3:运行回测,与「等权买入持有」基准对比
import pandas as pd
prices = load_prices()
strat = momentum_backtest(prices) # 纯动量
strat_stop = momentum_backtest(prices, stop=0.10) # 加 10% 绝对止损
bench = (prices / prices.iloc[0]).mean(axis=1) * (1 - FEE) # 等权买入持有
comp = pd.concat([strat, strat_stop, bench], axis=1).dropna()
comp = comp / comp.iloc[0] # 统一从首个共同交易日归一
comp.columns = ["动量轮动", "动量+止损", "等权持有"]
print("最新动量排名(过去 60 日):")
print(momentum_rank(prices, comp.index[-1]).round(4))
print(comp.tail(3).round(4)) # 数值仅为示意
for name in comp.columns:
s = comp[name]
mdd = (s / s.cummax() - 1).min()
ann = s.iloc[-1] ** (252 / len(s)) - 1
print(f"{name}: 累计 {s.iloc[-1] - 1:.1%} | 年化 {ann:.1%} | 最大回撤 {mdd:.1%}")
代码逐段解释
1. 数据与动量分数(块 1)。load_prices() 优先用 akshare 的 fund_etf_hist_em 拉三只 ETF 的前复权(qfq)收盘价——前复权保证分红除权不会在价格上留下假缺口,动量计算才干净;失败则退回本地造数据。momentum_rank() 是个诊断工具:给定日期,输出各标的过去 60 日收益的降序排名,动量第一就是表头第一行。输出形如:
数据起止:2020-01-02 ~ 2024-12-31,共 1216 天
最新动量排名(过去 60 日):
黄金ETF 0.0821
沪深300ETF 0.0154
中证500ETF -0.0312 # 数值仅为示意
2. 回测主循环(块 2)。整个循环严格遵守一个约定:决策只用截至昨日收盘的数据,按昨日收盘价成交,因此今天的损益归属于昨天形成的仓位——pick 取自 momo.iloc[i - 1],止损判断用的是昨收前累计的 pnl,第三步才把今日收益计入盈亏,供明日使用。循环内三步:先查止损(触发则清仓并标记当日禁止重建仓,实现冷却);再看是否调仓日(只有第一名变了才付卖、买两笔单边手续费);最后记账。三条纪律在代码里的位置:持有期 = (i - N_LOOKBACK - 1) % N_HOLD == 0;冷却期 = stopped_today 屏蔽当日 + 空仓等到下个调仓日;绝对止损 = pnl - 1 <= -stop。
3. 基准与对比(块 3)。基准是期初等权买入三只、之后不再平衡的组合((prices / prices.iloc[0]).mean(axis=1)),扣一次建仓单边费;期末不清仓、不扣卖出费,属于教学简化。三条净值统一从首个共同交易日归一后对比。输出形如:
动量轮动 动量+止损 等权持有
2024-12-27 1.0432 1.0287 0.9811
2024-12-30 1.0497 1.0361 0.9856
2024-12-31 1.0511 1.0402 0.9823 # 数值仅为示意
动量轮动: 累计 5.1% | 年化 1.0% | 最大回撤 -14.2%
动量+止损: 累计 4.0% | 年化 0.8% | 最大回撤 -9.8%
等权持有: 累计 -1.8% | 年化 -0.4% | 最大回撤 -18.7%
示意里能看到止损的典型代价与收益:累计略降(止损会卖飞一些后来涨回的仓位),但最大回撤明显收窄——这正是纪律买的东西。再次强调:三只 ETF、五年的样本什么都证明不了,换个数据窗口结论就可能反转。
常见坑与小练习
常见坑:
- 用今天的收益算动量、今天买入(未来函数)。现象:回测美到不真实,实盘完全对不上。原因:
pct_change(60)在 t 日收盘才算得出,若 t 日信号 t 日成交,等于偷看了当天收盘价。避免:信号一律shift(1)或像本篇一样循环里只用iloc[i - 1];把「信号日」和「成交日」写成两个明确的变量。 - 把「每 20 日调仓」写成「每 20 日无条件换标的」。现象:换手率高得离谱,手续费把收益磨没。原因:调仓日的正确动作是重新排名、只有第一名变了才换。避免:换仓前比较新旧第一名(本篇的
pick != holding)。 - 过度解读教学回测。现象:跑出好看的曲线就想实盘。原因:3 只标的、5 年样本在统计上什么都证明不了,fallback 造的数据更是纯演示。避免:把本篇回测当「代码机制验证」,正式研究要扩标的、扩周期(参考第 24、31 篇)。
- 止损阈值拍脑袋。现象:止损设 3%,刚买就被洗出去;设 30%,形同虚设。原因:阈值应与标的波动匹配,比如参考 ATR(第 14 篇)或若干倍日波动。避免:用波动率给止损定标尺,并在多个阈值下做敏感性测试。
- 忽略 ETF 的跟踪误差与费用差异。现象:把 ETF 价格涨跌直接当策略收益。原因:ETF 有管理费、跟踪偏离,复权方式也会影响动量计算。避免:用前复权价格算动量(本篇
adjust="qfq"),成本假设宁保守勿乐观。
小练习:
- 把观察期改成 20 日与 120 日各跑一遍,对比三档参数的年化与最大回撤。
思路提示:只需改
N_LOOKBACK;重点观察短观察期是否带来更高的换手与更多的手续费损耗。 - 把持仓数 K 从 1 放宽到 2(两只各占一半资金),实现并对比。
思路提示:用
nlargest(2)取前两名,日收益改为两只的加权平均;注意换仓时两份手续费都要扣。 - 给等权持有基准也加上同样的 10% 绝对止损,再与「动量+止损」对比。
思路提示:这是检验「收益来自动量的选择,还是来自止损本身」的好实验,能把两种效果拆开。
本文策略仅用于教学,不构成投资建议,实盘前请充分测试。