第 5 章 · 01 动量类:12 月截面、52 周新高、时序动量 本节摘要:本节精读 quant-wiki docs/repo/static/strategies 下三个代表性动量脚本: (12 个月截面动量,以「一年前同一个月」的收益排序)、 (52 周新高效应,基于行业层面的 PRILAG 比率)、 (时序动量 TSMOM,跨 58 个期货品种的多空绝对趋势)。三者看似都叫「动量」,实则信号逻辑完全不同:截面动量赌「相对强弱」,新高效应赌「锚定偏差」,时序动量赌「绝对趋势」。本节逐脚本讲清宇宙构建、信号计算、持仓与再平衡规则,并指出每个脚本的工程陷阱(滚动窗口、数据预热、波动率目标),让你能把信号迁移到自己的回测框架。
本节摘要:本节精读 quant-wiki docs/repo/static/strategies 下三个代表性动量脚本:
12-month-cycle-in-cross-section-of-stocks-returns.py(12 个月截面动量,以「一年前同一个月」的收益排序)、52-weeks-high-effect-in-stocks.py(52 周新高效应,基于行业层面的 PRILAG 比率)、time-series-momentum-effect.py(时序动量 TSMOM,跨 58 个期货品种的多空绝对趋势)。三者看似都叫「动量」,实则信号逻辑完全不同:截面动量赌「相对强弱」,新高效应赌「锚定偏差」,时序动量赌「绝对趋势」。本节逐脚本讲清宇宙构建、信号计算、持仓与再平衡规则,并指出每个脚本的工程陷阱(滚动窗口、数据预热、波动率目标),让你能把信号迁移到自己的回测框架。
内容来源:quant-wiki docs/repo/static/strategies,真实 Python 策略脚本,知识结构化整理。
⚠️ 学习提示:动量是经典的「有经济逻辑的异象」,但在 2009 年 3 月、2020 年 3 月等剧烈反转期会发生「动量崩盘」。读懂脚本的信号定义,远比记回测数字重要——同样的「12 月动量」,用 t-12 到 t-2 还是 t-13 到 t-2,结果可能差别很大。
阅读完本节,你应当能够:
「动量」一词在量化里被反复使用,但不同脚本的「赌法」差别很大。先把三者摆到同一张表上:
| 脚本 | 信号定义 | 信号类型 | 多空逻辑 | 持仓加权 |
|---|---|---|---|---|
| 12 月截面动量 | 一年前同一个月的 1 个月收益排序 | 横截面相对强弱 | 多顶部分位、空底部分位 | 市值加权 |
| 52 周新高 | 价格 / 52 周最高,行业层面市值加权均值 | 锚定偏差 | 多 6 个高比率行业、空 6 个低比率行业 | 等权、分 3 批 |
| 时序动量 TSMOM | 过去 12 个月超额收益的正负号 | 绝对趋势 | 单标的本身正即多、负即空 | 反向波动加权 |
💡 关键观察:截面动量要求宇宙里有「可比的同伴」(同行业或全市场)做排序,而时序动量不需要同伴——它直接看一只品种自己的趋势。这是 TSMOM 能在商品、外汇、债券等跨资产上独立运行的根本原因。
原始论文的洞察很反直觉:不看「最近 12 个月」的收益,而是看「一年前同一个月」那一个月的收益。直觉上,这捕捉的是季节性/年度周期叠加动量——某些股票在每年同期的相对强弱具有黏性。
脚本里用 13 期的月度滚动窗口存储月末收盘价,然后取「倒数第二个月相对倒数第十三个月」的收益,即一年前那一个月的月度表现:
def performance(self): values = [x for x in self.Window] return (values[-2] / values[-1] - 1)
注意 RollingWindow 的索引约定:values[0] 是最新值,values[-1] 是最旧值。所以 values[-2] / values[-1] - 1 计算的是「最旧那期到次新那期」的收益,恰好对应「一年前那个月」的月度表现。这一行代码是整个策略的命门——读懂索引方向,就读懂了信号。
| 要素 | 实现 |
|---|---|
| 宇宙 | NYSE/AMEX/NASDAQ 前 500 流动性最好的股票(原论文是市值前 30%) |
| 信号频率 | 月末选股 |
| 分组 | 按「一年前的月度收益」排序,分 10 组(decile) |
| 多空 | 多最高 decile、空最低 decile |
| 加权 | 多空两腿分别按市值加权 |
| 再平衡 | 每月一次,在 Schedule.On(DateRules.MonthEnd, BeforeMarketClose) 触发 |
⚠️ 陷阱:脚本在
CoarseSelectionFunction里手工从日线历史「拼」出月末收盘价(用「当月与下月月份号不同」判断月末)。这是一种工程妥协——QC 粗选函数返回的是日线,而信号需要月线。自己复现时,务必确认月末判断逻辑没有踩到节假日或停牌。
同目录下还有一个 momentum-factor-effect-in-stocks.py,它的信号更主流:过去 12 个月收益跳过最近 1 个月(即 t-12 到 t-2),再分 5 分位多空。两者对比:
| 维度 | 12 月截面周期 | 经典 12-1 动量 |
|---|---|---|
| 回看区间 | 一年前同一个月 | t-12 到 t-2(共 11 个月) |
| 经济解释 | 年度周期 + 黏性 | 信息扩散 + 趋势延续 |
| 分组数 | 10(decile) | 5(quintile) |
| 加权 | 市值加权 | 等权 |
💡 迁移提示:这两个脚本告诉你,「动量」不是一个公式,而是一族信号。把回看窗口、跳过月数、分组数当作超参数,在你的框架里做敏感性分析,你会发现「跳过最近一个月」是为了规避短期反转与微结构噪声。
52 周新高效应来自行为金融的锚定效应(anchoring):投资者以「52 周最高价」为锚,当现价接近这个锚时,认为「涨得差不多了」,反而低估了进一步上涨的概率。脚本里定义:
PRILAG_i = 现价_i / 过去 52 周最高价_i
PRILAG 越接近 1(现价越靠近 52 周高点),代表股票越「强势」;越接近 0,代表越「弱势」。脚本用 period = 12 * 21(约 252 个交易日)作为 52 周的工程近似。
这是该脚本最巧妙的设计。它不直接对单只股票的 PRILAG 排序,而是:
MorningstarIndustryGroupCode)把股票分组;为什么先到行业?因为单只股票的 PRILAG 噪声太大,而行业层面的聚合能过滤掉个股特异性,突出「整个行业是否接近年度高点」这一系统性信号。脚本里的关键片段:
industry_prilag_weighted_avg[industry_code] = sum([ stock_prilag_data[1] * (stock_prilag_data[0].MarketCap / total_market_cap) for stock_prilag_data in group[industry_code] ])
脚本另一个工程亮点是 holding_period = 3 的分批逻辑。它维护一个 managed_queue,每月新建一批仓位,持有 3 个月,逐月平仓。这意味着任意时刻组合里有 3 批重叠的仓位,降低了择时风险:
self.managed_queue.append(RebalanceQueueItem(long_symbol_q + short_symbol_q))
💡 设计哲学:分批建仓本质是把「单次择时」变成「多次平均」,类似定投。它牺牲了部分信号锐度,换来更平滑的净值曲线。许多动量/反转类脚本都采用这种「N 个月重叠」的持仓结构。
时序动量(Moskowitz、Ooi、Pedersen 2012)的信号极其简洁:对每个品种,看它过去 12 个月的超额收益,正就做多、负就做空。它不需要与其他品种比较,每个品种独立决定方向。脚本里的核心两行:
performance = back_adjusted_prices[0] / back_adjusted_prices[-1] - 1 long = [x for x in performance_volatility.items() if x[1][0] > 0] short = [x for x in performance_volatility.items() if x[1][0] < 0]
这是三个脚本里宇宙最广的:58 个连续合约,覆盖商品(大豆、原油、黄金)、外汇(澳元、欧元、日元)、股指(纳斯达克、DAX、标普)、债券(美 10 年、德债、日债)。数据用自定义的 QuantpediaFutures 类从远程 CSV 读「后调整」与「拼接」两个价格序列。跨资产的好处是不同品种的趋势相关性低,组合层面分散度高。
TSMOM 的工程精髓在仓位管理。原始论文用 GARCH 估波动,这里用历史波动率简化:
volatility_3M = np.std(daily_rets) * sqrt(252) # 反向波动加权 weights = np.array([(1 / vol) / total_volatility for x in symbols]) # 波动率目标,让组合年化波动收敛到 10% leverage = self.targeted_volatility / portfolio_vol leverage = min(self.leverage_cap, leverage) # 杠杆上限 4 倍
三层处理:(1) 每个品种的权重与其波动率成反比(波动大的品种仓位小);(2) 多空两腿各自归一化;(3) 整体按目标波动 10% 调杠杆,且杠杆上限 4 倍,防止极端行情爆仓。
⚠️ 陷阱:
targeted_volatility = 0.10与leverage_cap = 4是两个最敏感的参数。目标波动设太低会过度压缩收益,设太高则杠杆爆炸;杠杆上限在低波动时期会被频繁触发,造成仓位跳跃。复现时务必做参数敏感性分析。
尽管信号各异,这三个脚本共享一套 QuantConnect 的工程套路,值得归纳:
CoarseSelectionFunction(粗选,按流动性与基本面)+ FineSelectionFunction(精选,按信号排序)。粗选每天跑、精选只在 selection_flag 触发时跑,降低计算量。self.History(symbol, period, Resolution.Daily) 回填滚动窗口,避免窗口没填满就出信号。CustomFeeModel 统一把费率设为 5 个基点(0.00005),比默认更贴近实际。SetLeverage(10) 或 20,允许策略多空两端各加杠杆,这是多空策略能在小资金上跑起来的前提。Schedule.On(DateRules.MonthStart/MonthEnd, TimeRules.AfterMarketOpen/BeforeMarketClose, Selection),把「选股」与「交易」解耦。💡 迁移提示:这套套路不依赖 QuantConnect,你可以等价地搬到 backtrader、vectorbt、自研框架:粗选≈universe filter,精选≈signal ranking,调度≈rebalance schedule,自定义费率≈cost model。关键是把「信号计算」与「交易执行」解耦成两个阶段。
values[-2] / values[-1] - 1)。下一节,我们看动量的「反面」——反转与价值:BAB(看空 beta)、账面市值比、F-score 加短期反转,如何从另一个方向捕捉收益。