第 25 章/ 45
动量(Momentum)
主线:把“过去赢家继续赢”写成端点精确的窗口、方向明确的持仓和成本可计的收益,并区分横截面动量(cross-sectional momentum, XSMOM)与时间序列动量(time-series momentum, TSMOM,又称趋势 trend)是两件不同的事。 先修:第11章(收益与复利)、第23章(截面研究)。完成后你能:写出 12–1 动量在信号月的确切端点,不差一个月;说明
与求和的区别;构造横截面赢家减输家与时间序列趋势两种组合;用重叠持有降低换手并推出换手公式;识别价格拆股、期货展期与连续合约(continuous contract)、幸存样本三类数据口径问题;用 10 个行业的真实月收益报告结果,并把负结果、成本与崩溃状态一起报告。
1. 本章目标
读者应能做到:
- 对信号月
(月末),写出 12–1 的形成区间(months ,共 11 个月)、被跳过的月份( )、持有区间( ),并说明时间序列动量(Moskowitz、Ooi 与 Pedersen 2012,下称 MOP)的 12–0 窗口含最近一个月。 - 手算并区分
、 、 ,给出一个求和排序与复利排序相反的例子。 - 构造横截面动量组合(形成收益最高桶做多、最低桶做空)与时间序列动量组合(各资产按自己过去收益的符号做多或做空,按事前波动率缩放),并分别说明净暴露(net exposure,有符号权重之和,亦即多头权重和减空头权重绝对值之和)、总暴露(gross exposure,权重绝对值之和)与基准。
- 构造
个月重叠持有(overlapping holdings)的组合,推出相邻月份权重变化为 ,并用真实行业数据比较 的换手(turnover,每月权重绝对变化之和)与成本。 - 识别并修复三类口径错误:拆股与分红造成的假输家、期货展期拼接造成的假收益、以现存股票池回测造成的幸存者偏差。
- 报告 10 个行业组合上的 12–1、窗口变体、
网格(含 Bonferroni 门槛)、时间序列趋势与“始终做多”基准、崩溃状态,并明确哪些不能说。
完成标准:任何动量信号都附一张时间图,标明形成月份、跳过月份、signal_time、trade_time 与持有月份,并能说明窗口比标准定义多一个月或少一个月会改变什么。
2. 为什么 Quant 需要它
动量是 Part IV 里最容易被“实现错”的因子:定义只有一句话(过去涨得多的继续涨),但窗口端点差一个月、收益用求和代替复利、价格没有调整拆股、期货价格直接拼接、股票池只含现存公司,都会改变结果,而且每一种都不会报错。动量也是有明确崩溃风险的因子:长期均值为正的策略会在极少数月份出现非常大的损失,这些月份集中在市场暴跌之后的反弹里,所以只报告均值与
本章的逻辑链:先把窗口端点与复利写精确(6.1–6.2),再用重叠持有与波动率缩放写出两种组合(6.3–6.4),再说明为什么两者的风险与基准不同(6.5),再看崩溃状态(6.6),再处理数据口径(6.7–6.8),最后在 10 个行业组合上做完整报告(11 节)。第23章的逐期 IC、分位组合与按依赖结构选择的时间推断方法在这里使用;第22章的试验账本约束窗口搜索;第27章会把展期问题扩展到 carry。
3. 直觉
过去一年涨得最多的资产,下个月比过去一年跌得最多的资产平均多涨一点,这是横截面动量。一条价格自己过去一年向上,下个月继续向上的可能性更大,这是时间序列动量,也叫趋势。前者比较资产之间的相对位置,永远一多一空、净头寸约为零;后者只看每个资产自己,若大多数资产都在上涨,组合就会净做多,收益里混着市场整体的涨跌。
直觉里还有两个细节。其一,为什么要跳过最近一个月:个股的月收益有短期反转,最近一个月的赢家下个月往往回吐一点,这与流动性和微观结构有关(AMP 2013,印刷页 936);所以 12–1 的“1”就是把最近一个月从形成期里拿掉。其二,为什么要看“持有多久”:信号每个月更新,如果每个月都把整个组合换掉,换手很高;让每个月的新持仓批次(vintage) 只占目标账本的
简化的边界:本章的真实案例只有 10 个行业组合(已修订的价值加权月收益),它们没有个股的微观结构反转,截面小,且不是可交易的成交序列;个股层面的结论本章没有数据支持。
3.1 让实际月份把 12–1 的时钟固定下来
4. 数学基础
记号。 资产
复利收益。 对月份集合
时间序列信号。 资产
重叠持有。 每月形成一个“vintage”(一批目标权重)
熊市状态。
频率约定:月频;signal_time 是月末;成交在下一个可交易时点;收益区间是
5. 正式定义
形成区间(formation interval)、跳月(skip)与持有区间(holding interval)。 形成区间是用来计算信号的月份,12–1 是
横截面动量(cross-sectional momentum)。 同一日期把资产按形成收益排序,买入最高桶、卖出最低桶(Jegadeesh 与 Titman 1993 用十分位)。它按相对排名定方向,而非按每个资产绝对收益的正负;组合市场风险仍取决于两腿暴露。
时间序列动量(time-series momentum, TSMOM)。 每个资产按自己过去收益的符号决定多空,位置大小反比于事前波动率(Moskowitz、Ooi 与 Pedersen 2012,式 (5):
短期反转(short-term reversal)。 最近一个月收益的负向预测,在个股中与流动性和微观结构有关(AMP 2013);本章在行业组合里检验它是否存在。
趋势(trend)。 与时间序列动量同义使用,指资产价格自身的持续方向。
动量崩溃(momentum crash)。 动量策略出现的持续或极端的负收益,集中在市场下跌之后的反弹月份(Daniel 与 Moskowitz 2014)。
再平衡(rebalancing)与重叠持有。 每月更新信号并调整头寸;重叠持有每月替换一个占
总回报、价格回报与调整价格。 价格回报只含价格变动;总回报含股息与拆股调整。拆股后未调整的价格会把拆股当作暴跌。
展期(roll)与连续合约。 期货合约到期前换到下一个合约。把不同合约的价格首尾相连得到的“连续价格”在展期日有一个跳跃,这个跳跃不是持有任何合约的收益。
幸存者偏差(survivorship bias)。 回测的股票池只包含后来仍然存在的公司,而在每个历史 signal_time 实际可投资的公司(含后来退市者)没有被包含。
6. 推导
6.1 12–1 的精确端点
信号月
所用月份是 formation_return(returns, window=11, skip=1) 的第
6.2 为什么是 ,不是
收益在时间上复利:本金 1 经过 formation_return 对简单收益取 log1p 求和后 expm1,收益单元缺失或历史不足使窗口为 NaN;缺日历月行违反连续索引要求并报错)。
6.3 重叠持有组合的换手
每月形成一批新权重
因为
每个 vintage 的总暴露为 2(各腿 1),所以
6.4 时间序列动量的暴露与基准
权重
6.5 横截面与时间序列的差别
横截面动量是相对的:多空等额,净暴露为零,名义资金配平;市场 beta 是
6.6 崩溃状态:条件均值
把月份按信号月的状态分成熊市与非熊市(过去 24 个月市场累计收益是否为负),横截面动量的下月多空收益的条件均值为
6.7 价格、拆股、分红与总回报
拆股比例
6.8 展期拼接:连续价格不是收益
本节及8.5、25-E06将同一合约的价格比串联为收益指数:忽略费用和抵押品利息,每段以起始合约价全额抵押,并随财富调整名义金额,才可将各段财富因子相乘。固定一手合约须逐段计算货币P&L,再按明确资本分母换算,不能直接称相同资本回报。
合约 A 在
7. 金融解释
动量的收益来自哪里,本章不判决。 Jegadeesh 与 Titman(1993)报告了美国个股 1965–1989 年的赢家减输家收益;Asness、Moskowitz 与 Pedersen(2013)在多个资产类别里使用同一个简单度量;Moskowitz 等(2012)报告时间序列动量在 58 个期货上持续 1 到 12 个月并部分反转。这些文献里的解释(对信息的反应不足、风险补偿等)本章没有读原文细节,正文不转述;对 Quant 的要求是:无论解释如何,信号必须在 signal_time 可构造,成本必须计入,崩溃必须单独报告。
行业动量与个股动量不同。 12–1 的“跳月”在个股里是为了避开短期反转(AMP 2013,印刷页 936),AMP 同时说明对于股指期货和货币,不跳过最近一个月的动量更强(印刷页 937)。行业组合是大量股票的价值加权平均,买卖价差与流动性的微观结构影响被平均掉。本章的行业数据与这一点一致:窗口 1–0(上个月收益)的多空月均 window=1, skip=1,只用
换手是动量的主要成本来源。 横截面 12–1 每月目标权重换手 0.904(毛暴露 2);按每单位换手 10 bp 的假设,月成本 9 bp,使 0.519% 变成 0.427%。
崩溃风险要和均值一起报告。 10 个行业横截面 12–1 的复利累计在最差时期的回撤约 64%,十个最差月份损失 13.6% 至 34.8%,7 个发生在熊市状态之后。均值为正的策略仍可能让投资者无法持有它。
时间序列趋势的收益里有市场。 行业样本的多数月份趋势信号为正(67.4% 的资产多头),组合平均净暴露 1.05,所以它的月均 1.391% 里包含了股票风险溢价;相对始终做多的差均值为-0.389%每月,t=-1.05,未见统计明确的正增量;这不是回归alpha估计。
7.1 三个问题与三个待检验机制
横截面动量问:“同一天过去赢家相对输家,下月是否更好?”它需要一个共同股票池,做赢家减输家;即使所有股票过去都跌,只要相对排名可用,仍会同时做多和做空。时间序列动量问:“这一个资产自己的过去方向,下月是否延续?”它以零收益或明确的超额收益零点作阈值,所有资产同向时可以净多或净空。短期反转问:“最近一个月的赢家是否回吐?”若信号定为最近月收益的相反数,越大代表最近跌得更多;它与12–1排除了最近月是两种规格,不能用一个名字替换另一个。
反应不足是一种假说:信息进入价格需要时间,使过去趋势继续。它可用事前界定的信息事件和后续漂移检验。风险补偿是另一种:赢家与输家的状态暴露不同,收益伴随某些反弹时的损失;第11节的崩溃状态描述只能提示这种检验,不能证明原因。测量代理还可能来自行业趋势、滞后成交或流动性,须用相应股票池与可得报价控制来检查。三者都能与正历史收益共存,历史利润不能裁决解释。此处提出研究问题,不扩展原文经验结论。
跳月是一项事前规格,代价是放弃最近信息;它是否有益取决于资产与数据口径。行业组合中1–0为正的旧结果和时间序列相对始终做多的负差结果都保留。样本换成个股、期货或货币后,必须重新定义输入,不能按本章19个变体里表现最好者反过来选择主规格。
8. 数值例题:手算每一步
所有数字由 hand_examples() 产生,并由 qa/a5/claude/recompute/ch25_recompute.py 用不同实现复核。例子为教学构造。
8.1 12–1 的窗口:十四个月的收益
某资产 14 个月的月简单收益(月份 1 到 14):
- 复利:
;求和 ;差 0.716 个百分点。 - 向前挪一个月(月份 1 到 11):
;向后挪一个月(月份 3 到 13,包含被跳过的月份): ;包含月份 13 的 12 个月(月份 2 到 13,即 12–0 窗口): 。
读法:同一个资产在三种错误窗口下的信号相差 2 至 7 个百分点;12–0 比 12–1 低了 7 个百分点,因为被跳过的月份是
8.2 横截面:六个资产
6 个资产的 12–1 形成收益
8.3 时间序列:波动率缩放
三个资产信号
8.4 拆股与分红
第3章构造例:拆股前 100,1 股拆为 2 股(美股称 2-for-1 split),拆股后 51,每新股分红 1。总回报
8.5 期货展期
合约 A:
8.6 重叠持有与换手
两个资产,六个月的 vintage 权重:前两个月
8.7 熊市状态
市场月收益全为
9. Python 实现
窗口在 factor_signals.py 的 formation_return(第22章引入),组合与成本在新模块 momentum.py;截面分桶、IC、Newey–West 复用第23章的 factor_research.py。
| 函数 | 输入 | 输出与边界 |
|---|---|---|
formation_return |
月频连续 PeriodIndex 的收益面板、window、skip |
复利形成收益,信号月 |
cross_sectional_momentum_weights |
收益面板、窗口、跳月、桶数、持有月数 |
每月 vintage(最高桶 |
blend_vintages |
权重面板、 |
滚动平均,不足 |
time_series_sign |
超额收益面板、窗口、跳月 | |
ex_ante_volatility |
收益面板、窗口 36、最少 12 | 年化滚动标准差;教学替代,不是原文的日度指数加权估计 |
tsmom_weights |
符号、波动率、目标波动率 | |
strategy_returns |
权重(信号月)、收益 | 信号月 |
net_of_costs |
毛收益、权重、单位成本 | 毛收益减去 $c\times\sum |
trailing_bear_state |
市场总收益序列、窗口 | 过去 24 个月(含当月)累计为负记 1,窗口不足为 NaN |
默认离线入口(从 I3 阅读包根目录运行):
.venv/bin/python run_notebook.py notebooks/ch25_momentum.ipynb
默认 RUN_FRENCH=False,展示窗口、复利、CS/TS权重及24×6、种子2的vintage恒等式(K=1/3/6)。五个市场单元均跳过,默认不导入ch25模块、不计算拆股/展期或历史网格。 runner另写 runs/execution_<stem>_<uuid>/ 的执行副本与状态。Notebook英语语言层及整包路径在BIL23装配;下列为历史完整CLI与测试参考,并非默认离线命令:
.venv/bin/python code/ch25_momentum.py
.venv/bin/python -m pytest tests/test_ch25_momentum.py tests/test_ch25_workflow.py -q
频率月;signal_time 是月末;成交在下一可交易时点;收益区间是
代码段一:12–1 窗口与三种错误窗口(8.1 节)。
import numpy as np
import pandas as pd
from quant_course.factor_signals import formation_return
r = [0.03, -0.02, 0.05, 0.01, -0.04, 0.06, 0.02, -0.01, 0.04, 0.03, -0.02, 0.05, -0.06, 0.02]
frame = pd.DataFrame({"a": r}, index=pd.period_range("2020-01", periods=14, freq="M"))
s = pd.Period("2021-01", "M") # month 13: signal at the end of this month
f121 = formation_return(frame, window=11, skip=1).loc[s, "a"] # months 2..12
f120 = formation_return(frame, window=12, skip=0).loc[s, "a"] # months 2..13, nothing skipped (time-series style)
early = np.prod(1 + np.array(r[0:11])) - 1 # months 1..11
late = np.prod(1 + np.array(r[2:13])) - 1 # months 3..13
assert abs(f121 - 0.177159) < 1e-6 and abs(f120 - 0.106530) < 1e-6
assert abs(early - 0.154737) < 1e-6 and abs(late - 0.129112) < 1e-6
assert abs(sum(r[1:12]) - 0.17) < 1e-12 # the sum, 0.716 percentage points lower
print(round(f121, 4), round(f120, 4), round(early, 4), round(late, 4))
代码段二:求和与复利给出相反的排序(6.2 节)。
import numpy as np
import pandas as pd
from quant_course.factor_research import rank_cross_section
from quant_course.factor_signals import formation_return
idx = pd.period_range("2020-01", periods=2, freq="M")
returns = pd.DataFrame({"X": [0.50, -0.50], "Y": [0.0, 0.0]}, index=idx)
compound = formation_return(returns, window=2, skip=0).iloc[-1]
total = returns.sum()
assert np.isclose(total["X"], 0.0) and np.isclose(total["Y"], 0.0) # sums tie
assert np.isclose(compound["X"], -0.25) and np.isclose(compound["Y"], 0.0) # compounding separates them
assert np.isclose(np.log1p(returns["X"]).sum(), np.log(0.75)) # log returns add up to the compound return
assert list(rank_cross_section(compound.to_numpy())) == [1.0, 2.0] # X is the loser
print(compound.round(4).to_dict())
代码段三:横截面六资产与时间序列权重(8.2、8.3 节)。
import numpy as np
import pandas as pd
from quant_course.factor_research import quantile_long_short_weights
from quant_course.momentum import tsmom_weights
idx = pd.period_range("2026-06", periods=1, freq="M")
names = list("ABCDEF")
formation = pd.DataFrame([[0.30, 0.12, 0.05, -0.02, -0.10, -0.25]], index=idx, columns=names)
w = quantile_long_short_weights(formation, 3).iloc[0] # terciles of 6 names: top 2 long, bottom 2 short
nxt = np.array([0.02, 0.03, 0.01, 0.00, 0.01, -0.02])
np.testing.assert_allclose(w.to_numpy(), [0.5, 0.5, 0, 0, -0.5, -0.5])
assert abs(w.to_numpy() @ nxt - 0.03) < 1e-12 and abs(w.sum()) < 1e-12 and abs(w.abs().sum() - 2.0) < 1e-12
sig = pd.DataFrame([[1.0, -1.0, 1.0]], index=idx, columns=list("xyz"))
vol = pd.DataFrame([[0.10, 0.20, 0.40]], index=idx, columns=list("xyz"))
tw = tsmom_weights(sig, vol, target_vol=0.40).iloc[0]
np.testing.assert_allclose(tw.to_numpy(), [4 / 3, -2 / 3, 1 / 3])
assert abs(tw.abs().sum() - 7 / 3) < 1e-12 and abs(tw.sum() - 1.0) < 1e-12 # gross 2.33, net 1.0: not market neutral
assert abs(tw.to_numpy() @ np.array([0.01, -0.02, 0.03]) - 0.036667) < 1e-6
print(tw.round(3).to_dict())
代码段四:重叠持有的换手公式
import numpy as np
import pandas as pd
from quant_course.momentum import blend_vintages
rng = np.random.default_rng(2)
idx = pd.period_range("2020-01", periods=24, freq="M")
vint = pd.DataFrame(rng.choice([-0.5, 0.0, 0.5], size=(24, 6)), index=idx) # arbitrary vintage weights
for K in (1, 3, 6):
blended = blend_vintages(vint, K)
turnover = blended.diff().abs().sum(axis=1, min_count=1)
formula = pd.Series([np.abs(vint.iloc[i] - vint.iloc[i - K]).sum() / K for i in range(len(vint))], index=idx)
np.testing.assert_allclose(turnover.iloc[K:].to_numpy(), formula.iloc[K:].to_numpy(), atol=1e-12)
assert turnover.iloc[:K].isna().all() # no turnover before K vintages exist
print("turnover = ||w_s - w_(s-K)||_1 / K holds for K = 1, 3, 6")
代码段五:拆股、分红与期货展期(8.4、8.5 节)。
pre, ratio, dividend, post = 100.0, 2.0, 1.0, 51.0
assert abs((ratio * post + ratio * dividend) / pre - 1 - 0.04) < 1e-12 and abs(post / pre - 1 + 0.49) < 1e-12
a_prices, b_prices = [100.0, 102.0], [106.0, 107.0]
spliced = a_prices + b_prices
returns_spliced = [spliced[i + 1] / spliced[i] - 1 for i in range(3)]
tradable = [a_prices[1] / a_prices[0] - 1, b_prices[1] / b_prices[0] - 1]
assert abs(returns_spliced[1] - (106 / 102 - 1)) < 1e-15 and abs(returns_spliced[1] - 0.039216) < 1e-6 # fake return at the roll
assert abs(tradable[0] - 0.02) < 1e-15 and abs(tradable[1] - (107 / 106 - 1)) < 1e-15
print([round(x, 4) for x in returns_spliced], [round(x, 4) for x in tradable])
代码段六:熊市状态的窗口边界(8.7 节)与行业窗口的“没有未来信息”。
import numpy as np
import pandas as pd
from quant_course.momentum import cross_sectional_momentum_weights, trailing_bear_state
idx = pd.period_range("2000-01", periods=30, freq="M")
m = pd.Series(0.01, index=idx); m.iloc[2] = -0.35 # month 3 is the crash month
state = trailing_bear_state(m, window=24)
assert np.isnan(state.iloc[22]) and state.iloc[23] == 1.0 and state.iloc[25] == 1.0 and state.iloc[26] == 0.0
rng = np.random.default_rng(4)
returns = pd.DataFrame(rng.normal(0.01, 0.05, (48, 10)), index=pd.period_range("2010-01", periods=48, freq="M"))
base = cross_sectional_momentum_weights(returns, window=11, skip=1, n_buckets=5, hold_months=3)
shocked = returns.copy(); shocked.iloc[30:] = 0.3
changed = cross_sectional_momentum_weights(shocked, window=11, skip=1, n_buckets=5, hold_months=3)
pd.testing.assert_frame_equal(base.iloc[:31], changed.iloc[:31]) # months >= 30 cannot change weights decided up to month 30
print(state.iloc[[22, 23, 25, 26]].tolist())
10. 图形解读
图 25-1:窗口时间轴。 横向是日历月 1 到 15,信号在月份 13 的月末。第一行:横截面 12–1 用月份 2 到 12(蓝色,11 个月),月份 13(斜线)被跳过,持有月份 14(橙色)。第二行:时间序列 12–0 用月份 2 到 13(紫色,12 个月),不跳月。第三行:窗口向前挪一个月(灰色,月份 1 到 11)是一种常见的错误。读法:三行的信号不是同一个数。

图 25-2:10 个行业的组合累计与最差月份。 左图纵轴是复利财富(对数刻度,起点为 1),样本 1927-07 起:横截面 12–1 的多空(

图 25-3:

图 25-4:崩溃状态。 左图:把信号月按“过去 24 个月市场累计总收益是否为负”分成两类,横截面 12–1 的下月平均多空:非熊市 987 个月

11. 历史市场案例
数据。 与第22–24章相同的冻结 French 10 行业价值加权月收益(处理后 CSV SHA-256 3b097d810c295d64eedfa1ece7067ff2a0707f5ca90e33935df9949251f70950,1926-07 至 2026-08 共 1,202 个月)以及 FF3 的 Mkt-RF 与 RF(处理后 SHA-256 c19770a19a0ac47ad1c29074f57234570aeaa9725212cb4fa8146a2846c52814)。行业组合是已修订的发布收益,10 个资产,不是个股,不是成交序列。本章没有新增下载。
横截面 12–1(五分位,每桶 2 个行业,等权,月度形成)。 信号月 1927-06 至 2026-07。12–1 形成收益在全部 1,191 个有形成收益的月份都没有并列,所以每桶恰为 2 个行业;一个月窗口的变体(1–0、1–1)各有 60 个月出现并列,并列者按平均名次处理,极端桶可能含 1 或 3 个行业,多空两腿各自的权重和仍为 1。
| 毛均值(% 每月) | 毛 |
净均值(每单位 10 bp) | 净 |
换手均值 | |
|---|---|---|---|---|---|
| 1 | 0.519 | 3.64 | 0.427 | 2.99 | 0.904 |
| 3 | 0.474 | 3.43 | 0.423 | 3.06 | 0.518 |
| 6 | 0.347 | 2.71 | 0.308 | 2.40 | 0.364 |
均值口径。 表中K=1/3/6的毛收益分别有1190/1188/1185个月,净收益为1189/1187/1184个月。首次有效目标权重没有上一期可比权重,换手及净收益因此缺失;换手均值按权重差自身的有效行计算,还可能含没有下一月收益标签的末行。因此毛均值减净均值不必等于10bp乘此列换手均值。比较成本时须将毛收益和换手先对齐到同一有效月份;此表保留各历史序列自己的口径,不能从显示三位数反推新成本或修改历史结果。
月波动 4.9%(
窗口变体(
| 窗口 | 月数 | 均值(% 每月) | |
|---|---|---|---|
| 12–1(月份 |
1190 | 0.519 | 3.64 |
| 12–0(月份 |
1190 | 0.621 | 4.41 |
| 1–0(月份 |
1201 | 0.740 | 5.61 |
| 1–1(月份 |
1200 | ||
| 6–1(月份 |
1196 | 0.209 | 1.50 |
| 3–1(月份 |
1199 |
这个表有六行,加上
| 1 | 3 | 6 | 12 | |
|---|---|---|---|---|
| 3 | 0.60(0.06) | 1.64(0.13) | 2.84(0.17) | |
| 6 | 1.50(0.21) | 1.70(0.22) | 2.62(0.28) | 2.23(0.19) |
| 9 | 2.13(0.30) | 2.94(0.39) | 3.18(0.39) | 1.62(0.15) |
| 12 | 3.64(0.52) | 3.43(0.47) | 2.71(0.35) | 0.78(0.08) |
时间序列动量(超额收益;每个资产目标年化波动 40%;波动率为过去 36 个月标准差,至少 12 个月)。
| 组合 | 均值(% 每月) | 月标准差(%) | 年化夏普 | 总暴露均值 | 净暴露均值 | |
|---|---|---|---|---|---|---|
| TS 12–0(含最近月) | 1.391 | 5.46 | 8.79 | 0.55 | 2.50 | 1.05 |
| TS 12–1(跳月) | 1.197 | 4.66 | 8.86 | 0.47 | 2.50 | 1.01 |
| 始终做多(同样缩放) | 1.779 | 5.79 | 10.61 | 0.58 | 2.50 | 2.50 |
TS 12–0 比始终做多低
崩溃状态。 过去 24 个月市场总收益(Mkt-RF 加 RF)为负的信号月 191 个(占全部有状态月份的 16.2%):下月多空均值
| 信号月 → 收益月 | 多空(%) | 熊市状态 | 收益月市场总收益(%) |
|---|---|---|---|
| 1933-03 → 1933-04 | 是 | ||
| 1932-07 → 1932-08 | 是 | ||
| 2009-03 → 2009-04 | 是 | ||
| 2000-12 → 2001-01 | 否 | ||
| 1932-06 → 1932-07 | 是 | ||
| 1938-05 → 1938-06 | 是 | ||
| 2022-12 → 2023-01 | 是 | ||
| 1939-08 → 1939-09 | 是 | ||
| 2026-06 → 2026-07 | 否 | ||
| 2025-01 → 2025-02 | 否 |
7 个出现在熊市状态之后,8 个收益月的市场总收益为正。横截面 12–1(
能说什么,不能说什么。 可以说:10 个行业组合上,12–1 的横截面多空在 1927–2026 为正且显著(
12. 常见错误
错误 1:窗口差一个月。 8.1 节:同一资产的 12–1 信号是 17.72%,向前挪一个月 15.47%,向后挪一个月 12.91%,12–0 为 10.65%。修复:用 formation_return(window=11, skip=1) 并让第23章代码段五的“改未来不改过去”测试通过。
错误 2:求和代替复利。 6.2 节:
错误 3:横截面与时间序列的跳月不一致。 把 MOP 的 12–0 与 JT 的 12–1 混在一个对比里,会把窗口差异当作策略差异:行业数据上 12–0 的均值 0.621% 与 12–1 的 0.519% 相差 0.10 个百分点。修复:并列报告,明确写出每一列的窗口。
错误 4:未调整价格的拆股。 8.4 节:拆股使价格下降 49%,总回报 +4%。下面的例子里一个拆股后的“假输家”被排在最后,一个真正的输家被排在前面。
import numpy as np
import pandas as pd
from quant_course.factor_research import rank_cross_section
idx = pd.period_range("2020-01", periods=3, freq="M")
prices = pd.DataFrame({"split_stock": [100.0, 51.0, 52.0], "true_loser": [100.0, 90.0, 85.0]}, index=idx) # 2-for-1 split in month 2 (+$1 dividend per new share)
unadjusted = prices.iloc[-1] / prices.iloc[0] - 1
adjusted_split = (2 * 51.0 + 2 * 1.0) / 100.0 * (52.0 / 51.0) - 1 # total-return chain: split month, then 52/51
adjusted = pd.Series({"split_stock": adjusted_split, "true_loser": 85.0 / 100.0 - 1})
rank_u, rank_a = rank_cross_section(unadjusted.to_numpy()), rank_cross_section(adjusted.to_numpy())
assert rank_u[0] == 1.0 and rank_a[0] == 2.0 # unadjusted: split stock is the worst; adjusted: it is the best
print(unadjusted.round(3).to_dict(), adjusted.round(3).to_dict())
错误 5:期货价格直接拼接。 6.8 节:3.92% 的假收益。时间序列信号会把展期跳跃当成趋势。
import numpy as np
a_prices, b_prices = [100.0, 102.0, 104.0], [108.0, 109.0, 111.0] # contract B trades at a premium: contango
spliced = np.array([100.0, 102.0, 108.0, 109.0, 111.0]) # roll between the second and third prices
naive_return = spliced[-1] / spliced[0] - 1 # "12-month" return from the spliced series
true_a = a_prices[1] / a_prices[0] - 1
true_b = b_prices[2] / b_prices[0] - 1
true_total = (1 + true_a) * (1 + true_b) - 1 # hold A to the roll, then hold B
assert naive_return > true_total + 0.04 # the splice overstates the move by the roll gap
assert np.sign(naive_return) == np.sign(true_total) == 1
print(round(naive_return, 4), round(true_total, 4))
错误 6:现存股票池回测。 用今天仍上市的名单回到历史,等于用“活到现在”这一未来信息筛选股票:在下面固定赢家腿的例子里,剔除退市输家使输家腿未来收益被高估,WML被低估;一般动态股票池还会改变赢家、排名与权重,偏差方向不固定。数值例:10 只形成期的输家,9 只后来存活并平均上涨 signal_time 当时的成员;后续标签再计入实现的退市收益,不能用今天的名单筛选历史或把未来退市收益当作信号输入。本章没有个股数据,Project 4 准备层把它列为数据缺口。
错误 7:重叠持有收益用朴素
错误 8:忽略换手与成本。 横截面 12–1 的月换手 0.904(毛暴露 2):10 bp 的单位成本占去 0.092 个百分点,约 18% 的毛均值;
错误 9:窗口搜索不入账本。 本章评价了窗口变体与
13. 量化研究应用
Project 4 的动量字段与真实研究所需清单。 当前机器信号调用 formation_return(window=11, skip=1),价格契约列名为 total_return,定义为构造的小数月度总回报,signal_time 为月末;构造价格层没有记录真实 available_at。下表是未来真实研究需要的描述性字段清单(例如 total_return_m 不是当前机器列名),不能将其中供应商或可得时间字段称为本层已经取得:
| 字段 | 来源 | 时间字段 | 单位 | 缺失规则 |
|---|---|---|---|---|
total_return_m |
总回报价格或供应商总收益 | 月末(event_time),available_at |
小数 | 缺收益单元使窗口为 NaN;缺日历月行应先拒绝,不填补 |
mom_12_1 |
计算 | signal_time |
小数 | 历史不足 12 个月为 NaN |
rank_pct |
同日百分位 | signal_time |
0–1 | 同日少于 2 个有效资产为 NaN |
universe_member |
历史成分 | signal_time 时的版本,含退市 |
布尔 | 无历史成分则不得声称回测 |
label_1m |
下月收益(含退市收益) | 标签区间起止 | 小数 | 退市月使用退市收益,不用 NaN 丢弃 |
研究流程。 先写冻结计划(第22章):假设“同日 12–1 收益更高的资产下月收益更高”,信号窗口 12–1,
本章完成的内容与没有完成的内容。 完成:窗口与组合的核心函数、10 个行业的完整报告。没有完成:个股层面的动量(缺历史成分、退市收益、总回报价格),Project 4 的成本后策略(第 35–36 章),动态动量。第28章把动量与价值、质量合成。
13.1 收益口径和 Project 4 接口逐项对照
| 要计算的对象 | 必须取得的输入 | 不能用什么替代 |
|---|---|---|
| 股票月度总回报 | 持股数变化、拆股、现金分配与退市处理,或明确这些调整的总回报序列 | 未调整收盘价差;已含分红的总回报再加一次分红 |
| 股票价格收益 | 拆股调整价格,另标不含现金分配 | 将它称作总回报动量 |
| 期货收益 | 同一合约持有期间的P&L、展期规则、名义敞口/保证金口径及抵押品利息处理 | 跨合约的拼接价格差;它不是展期成交利润 |
| 当前P4构造总回报 | 生成器定义的小数月简单总回报,连续月索引,按当时成员取样 | 真实交易所价格或实盘成交证据 |
股票例里若其他形成月的财富乘积为
Project 4 mom_12_1 实际调用 formation_return(..., 11, 1),收益输入是契约中的构造月度总回报,信号月
14. 本章小结
动量由三件事决定:窗口端点(12–1 是月份 signal_time 当时的成员。10 个行业组合上,12–1 为正且在成本后仍为正,但 19 个不同的横截面变体里只有少数越过多重检验门槛;时间序列趋势没有超过始终做多;最差月份集中在熊市状态之后的反弹。适用边界:行业组合不是个股;成本是假设;崩溃的描述不是复现;本章没有个股数据。
15. 练习
25-E01(手算) 某资产 14 个月收益(月份 1 到 14):
25-E02(手算) 8 个资产的 12–1 形成收益
25-E03(手算) 四个资产符号
25-E04(手算) 两个资产,七个月 vintage:前三个月
25-E05(手算) 某股票价格 90,1 股拆为 3 股(3-for-1 split),拆股后价格 31,每新股现金分红 0.5。求总回报与未调整价格变动;若它在 12–1 形成窗口里,信号被低估多少个百分点?
25-E06(手算) 合约 A 的价格 50、52;展期时合约 B 的价格 55、56.1。求拼接价格的“收益”序列、可得收益序列与展期点的假收益。
25-E07(手算) 市场月收益全为
25-E08(概念与手算) 资产 X 的两个月收益
25-E09(解释) 表 11 里 TS 12–0 的月均 1.391%,始终做多 1.779%。解释为什么不能把 1.391% 当作趋势的 alpha,应怎样检验趋势是否增加了价值。
25-N01(日历与数据诊断) 从第3.1节实际月份表计算12–1、12–0与求和,标出形成/跳月/持有时点。若有人把2026-01的收益乘入信号,指出泄漏。再令第6.7节拆股例中其他形成月财富乘积
16. 编程练习
25-P01 写测试:给定 14 个月收益,断言 formation_return(window=11, skip=1) 在月份 13 等于 8.1 节的值,并断言改变月份 13 及之后的收益不改变该行。验收:两条断言通过。
25-P02 构造 24 个月、10 个资产的随机权重 vintage,计算
25-P03 在构造的带正漂移的随机游走面板(10 个资产,480 个月,月漂移 0.5%,月波动 4%)上计算时间序列 12–0 与同样缩放的始终做多,输出均值、
17. 挑战题
25-C01(证明) 证明重叠持有组合
25-C02(开放) 为 Project 4 的动量信号写完整的冻结计划与数据缺口表:窗口、总回报口径、历史成分与退市收益、成本与借券假设、崩溃状态的报告方式、多重检验计数。指出哪些字段在本课程现有数据里没有。评分:计划完整性 25,时间字段正确性 25,数据缺口 25,崩溃与成本的处理 25。
18. 完整答案与参考实现
25-E01 12–1:月份 2 到 12 的复利
25-E02 按资产顺序,形成收益
25-E03 单个仓位
25-E04
25-E05 总回报
25-E06 拼接价格
25-E07 月份按 1 到 30 编号,窗口月份
25-E08 X:求和 0,复利
25-E09 趋势组合平均净暴露 1.05、67% 的资产被判为多头,所以 1.391% 里含有(缩放后的)股票风险溢价:始终做多(同样缩放)就有 1.779%。检验趋势的价值要用差序列(TS − 始终做多)或把 TS 对始终做多回归取截距:差序列均值
25-P01 参考实现
import numpy as np
import pandas as pd
from quant_course.factor_signals import formation_return
r = [0.03, -0.02, 0.05, 0.01, -0.04, 0.06, 0.02, -0.01, 0.04, 0.03, -0.02, 0.05, -0.06, 0.02]
idx = pd.period_range("2020-01", periods=14, freq="M")
frame = pd.DataFrame({"a": r}, index=idx)
s = idx[12]
base = formation_return(frame, window=11, skip=1)
assert abs(base.loc[s, "a"] - (np.prod(1 + np.array(r[1:12])) - 1)) < 1e-12
shocked = frame.copy(); shocked.iloc[12:] = 0.9 # month 13 and later change
pd.testing.assert_frame_equal(base.iloc[:13], formation_return(shocked, window=11, skip=1).iloc[:13])
25-P02 参考实现:见第 9 节代码段四,保持原有K=1/3/6循环,将输入替换为 vint = pd.DataFrame(rng.choice([-0.5, 0.0, 0.5], size=(24, 10)), index=idx, columns=[f"A{i}" for i in range(10)]),其余差分、公式与断言不变;这给出题干要求的10列。
25-P03 参考实现
import numpy as np
import pandas as pd
from quant_course.momentum import ex_ante_volatility, strategy_returns, time_series_sign, tsmom_weights
rng = np.random.default_rng(3)
idx = pd.period_range("1980-01", periods=480, freq="M")
returns = pd.DataFrame(rng.normal(0.005, 0.04, (480, 10)), index=idx, columns=[f"A{i}" for i in range(10)])
vol = ex_ante_volatility(returns)
sig = time_series_sign(returns, window=12, skip=0)
ts = strategy_returns(tsmom_weights(sig, vol, target_vol=0.40), returns).dropna()
always = strategy_returns(tsmom_weights(pd.DataFrame(1.0, index=idx, columns=returns.columns).where(vol.notna()), vol, target_vol=0.40), returns).dropna()
diff = (ts - always).dropna()
t = diff.mean() / (diff.std(ddof=1) / np.sqrt(len(diff)))
assert always.mean() > ts.mean() and t < -2 # independent returns: no trend to exploit; the 12-month sign filter shorts after a negative cumulative return and gives up the drift
print(round(100 * ts.mean(), 3), round(100 * always.mean(), 3), round(t, 2))
输出约为 0.500、1.565、
25-C01
25-C02(评分依据与参考) 计划要点:假设与符号(赢家下月收益更高,符号 +);信号 12–1 总回报;股票池按 signal_time 的历史成分,含退市收益;窗口 11 个月跳月 1 个月;组合最高与最低十分位等权,
25-N01完整答案。 2025-01至11共11项,累计财富1.1547369892,12–1为15.4737%;求和15%;含12月的12–0为21.2474%。12月是跳月;1月2026是下月持有标签,信号形成时未知,加入是前视。拆股例正确单月4%、未调整单月-49%,差53个百分点,乘
19. 延伸阅读
- Must Know:Jegadeesh 与 Titman(1993)§I 与 §II 开头,读 J/K 策略、重叠持有与对 32 次检验的 Bonferroni 说明。
- Must Know:Asness、Moskowitz 与 Pedersen(2013)印刷页 936–937,读 MOM2–12 的定义与跳月理由。
- Should Know:Moskowitz、Ooi 与 Pedersen(2012)§2.4 与 §4.1,读波动率缩放与 TSMOM 的式 (5)。
- Should Know:Daniel 与 Moskowitz(2014 工作稿)摘要与表 3 说明,读崩溃状态的定义;期刊终稿未读。
- Advanced:Moskowitz–Grinblatt(1999)行业动量原文;本章未读。
- Optional:French 数据库 Mom 因子说明页,核对 prior (2-12) 的定义。
20. 参考资料
引用键与读取范围见 source_notes.md。
- CH25_JT1993:Jegadeesh, N., Titman, S. (1993). Journal of Finance 48(1),印刷页 68–69。
- CH25_AMP2013:Asness, C. S., Moskowitz, T. J., Pedersen, L. H. (2013). Journal of Finance 68(3),印刷页 936–937。
- CH25_MOP2012:Moskowitz, T. J., Ooi, Y. H., Pedersen, L. H. (2012). Journal of Financial Economics 104(2),§2.4、§4.1。
- CH25_DM2014:Daniel, K., Moskowitz, T. J. (2014). NBER Working Paper 20439,摘要、印刷页 8、表 3 说明;期刊终稿未读。
- CH25_FRENCH_MOM_PAGE:Ken French 数据库 Mom 因子说明页。
- FRENCH:French 行业与 FF3 冻结快照(
data/manifests/)。