跳转至

M02 交易策略范式

层级:L1 策略与回测 | 优先级:★★★ | 预计学时:10–12 小时 对应原书:陈 第 2、7 章;黑箱 第 3 章 前置知识:M00 的四类收益来源;M01 的平稳、协整和 t 值。回测偏差在 M03。 读书笔记:陈第 2、7 章 · 黑箱第 3 章 状态:reviewed 数据截至:2026-10|最近复核:2026-10-28


学习目标

  1. 把一条规则放进时序动量、截面动量、短期反转或价差回归,并说出它赚的是谁的钱。
  2. 用手算 12-1 动量、离散半衰期和一笔交易的期望,知道胜率低于一半仍然可以有正期望。
  3. 写出「信号在 t 知道、收益从 t + 1 才算」的代码,并分清教学脚本里的 shift(1) 和引擎里的 effective_at。
  4. 用本仓库两套已经跑完的实验说明:单只股票上的趋势过滤会落后于买入持有,做空端会把规则打穿。
  5. 用发表后衰减和指数纳入效应消失,建立筛选网上策略的否决清单。

本章知识拓扑图

flowchart TB
    subgraph clock[同一资产上的时钟]
        REV[约 1 周到 1 个月:短期反转]
        MOM[约 3 到 12 个月:动量]
        LR[约 3 到 5 年:长期反转]
    end
    REV --> MR[均值回归 / 配对]
    MOM --> TS[时序动量:自己的过去]
    MOM --> XS[截面动量:相对别人]
    TS --> LO[个人账户:多头或现金]
    XS --> LO
    MR --> COST[高换手,先过 M13 的成本]
    LO --> ENG["src/quant/strategies/"]
    XS --> LAB[动量实验室 / JPM 实验室]

🟢 入门层:范式从哪来,钱从谁手里来

1. 先写对手,再写规则

M00 的四类收益来源在这里变成持有期。理论驱动是先写对手:谁在被迫卖、谁在慢慢改预期、谁在提供流动性。数据驱动是先看见一组漂亮的历史,再补故事。个人投资者没有机构的搜索预算,默认走第一条。数据里冒出来、又说不出对手的规则,送去 M08,不进模拟盘。

范式 对手 典型持有期 代表论文
时序动量 对趋势反应慢、或必须跟着价格减仓的人 数周到 12 个月 Moskowitz, Ooi & Pedersen (2012), JFE 104(2)
截面动量 同一篮子里调仓更慢的资金 3–12 个月,跳过最近 1 个月 Jegadeesh & Titman (1993), JF 48(1)
短期反转 今天必须成交的人 1 天到 1 个月 Lehmann (1990), QJE 105(1);Nagel (2012), RFS 25(7)
配对 / 价差 把两种相关证券当成不同资产来定价的人 数天到数周 Gatev, Goetzmann & Rouwenhorst (2006), RFS 19(3)
日历与指数规则 考核日、申赎日、指数生效日必须交易的机构 数天 Etula 等 (2020), RFS 33(1);Harris & Gurel (1986), JF 41(4)

2. 动量和反转可以同时为真

它们不在同一根时钟上。

日内到约 1 个月     约 3–12 个月          约 3–5 年
短期反转            动量                  长期反转
流动性冲击回补      信息还没进完价格      过度反应之后的修正

所以截面动量的经典做法要跳过最近一个月。最近一个月里,短期反转还在起作用;把它算进「过去一年的强弱」,会把刚被砸下去的股票排到输家,又把刚被抢上去的股票排到赢家。De Bondt & Thaler (1985, JF 40(3)) 写的是多年尺度上的反转,Hong & Stein (1999, JF 54(6)) 用反应不足解释中期动量。三条可以同时留在文献里,因为持有期不同。

3. 个人账户先砍掉做空端

时序动量的论文多在期货上:过去 12 个月上涨就做多,下跌就做空。期货的空头是合约,不是借券。股票账户里,下跌改成空仓或短债,才是富途和 IB 上做得到的版本。美股大盘股有时能借到券,借券费在轧空时会跳;港股小盘经常借不到。本章把「必须做空才成立」的规则标成后置,不放进第一年的候选。

JPM 实验室把这件事做在同一只股票上:只做空的均线规则年化 −11.3%,期末把 10 万美元做成约 1.1 万美元;六个月动量若跌了就做空,最大回撤 95.9%。数字的口径在进阶层,先记住方向:单票上的空头不是期货论文里的空头。


🟡 进阶层:定义、手算和时点

4. 时序动量与 10 个月均线

只看自己的过去。Moskowitz, Ooi & Pedersen 在多种期货上用过去 12 个月的收益符号预测下个月。Hurst, Ooi & Pedersen (2017, JPM 44(1)) 把趋势跟踪的证据往十九世纪末延伸。那是多资产、可做空、按波动缩放仓位的结果。把它收成「一只 ETF、跌了就持有现金」,已经是另一条规则。

Faber (2007, Journal of Wealth Management 9(4)) 的简化版:月末价格高于过去 10 个月均线就持有,否则持有现金。教学脚本里现金收益设为 0,信号多延迟一个月。

SMAₜ = 1/10 Σₖ₌₀⁹ Pₜ₋ₖ, hₜ = 1{Pₜ > SMAₜ}, Rₜ^strat = hₜ₋₁ Rₜ

符号 含义
Pₜ 月末复权价。均线不足 10 个月时不产生信号
hₜ t 月末才知道的持有指示,取 0 或 1
Rₜ t 月的简单收益,从 t − 1 月末到 t 月末
hₜ₋₁ 用上月末的指示去乘本月收益。当月收盘才算出来的均线,不能拿去乘当月已经发生的收益

手算。 三个月末价格 100、110、90。窗口只有 3 个月,用来核对时点,不是 Faber 的 10 个月。

第 3 个月的均线 (100 + 110 + 90) / 3 = 100。90 > 100 为假,所以 h₃ = 0。第 3 个月的收益是 90/110 − 1 = −18.18%,当时可用的指示是 h₂。第 2 个月均线只有两笔,规则拒绝发信号,不能把「还没算出来」当成空仓。从第 4 个月起才允许 h₃ = 0 去乘第 4 个月的收益。

下图用动量 Plus 实验室的 SPY 复权收盘价,月末取样,2014-11-30 至 2026-09-30,共 143 个月。无风险利率按年化 2% 从月均收益里扣除后再乘 √12。现金收益为 0,不扣佣金。最大回撤是月末净值的峰值到谷值,2020 年 3 月的日内低点不在月末,所以这张图的回撤浅于日频路径。

买入持有 10 个月均线
CAGR 13.74% 7.40%
年化波动 14.82% 11.28%
夏普(r_f = 2%) 0.81 0.51
月末净值最大回撤 −23.93% −22.95%
在市月份 143 119

这一窗里,均线把波动从 14.8% 降到 11.3%,回撤几乎没降,CAGR 少了 6 个多百分点。2023 年之后现金有利息,把空仓收益设为 0 会低估持有现金的月份;即便补上利息,也不能把这张图读成「择时在这段样本里战胜了买入持有」。Faber 原文的市场和窗口不同,原文表格不能挪到这张图上。

接入现金利息的对照

上表和 plot_faber_spy.py 的图仍把空仓月份记成 0,数字不改。下面是并列的一列:持仓月的收益不动,信号仍是 shift(1);空仓月改为现金利息。计息和 src/quant/data/cash_yield.py 一样,把年化利率写成每个交易日的总收益因子 (1 + r)^(1/252),再按该月实际交易日连乘。年化 2% 的日因子就是该文件 2018 年那一行。

这 24 个空仓月合计 504 个交易日,正好是 252 日惯例下的 2 年。空仓按年化 2%(与本节夏普的无风险利率同一假设)计息时,多拿到的复利是

(1.02)^(504/252) − 1 = (1.02)² − 1 = 0.0404

即 4.04%。策略 CAGR 从 7.40% 升到 7.76%,夏普从 0.51 升到 0.54。

分段短债用两段利率。2015–2021 的空仓日直接用 HYBRID_ESTIMATED_DAILY_FACTORS。2022–2025 用美联储 H.15 三个月国库券年度平均(贴现基础)的公开年值:2.02%、5.07%、4.97%、4.07%,再按同一个 (1 + r)^(1/252) 换成日因子。2024 年没有空仓月,写进表里只是为了年序完整。2026 年还没有同口径的全年平均数,4 月这 21 个交易日沿用 2025 年已公布的 4.07%。这段对照的 CAGR 也是 7.76%,夏普也是 0.54,24 个空仓月多拿到的复利是 4.02%。

两位小数上,两种计息相同。这一窗里 2022 年占了 171 个空仓日,那年的年度平均约 2%,和夏普所用的 2% 贴在一起;高息年份的空仓日较少,所以分段表没有在两位小数上再抬高整段 CAGR。7.76% 仍低于买入持有的 13.74%。原图继续画现金收益为 0 的那条净值。

现金收益 0(原表) 空仓年化 2% 分段短债
CAGR 7.40% 7.76% 7.76%
夏普(r_f = 2%) 0.51 0.54 0.54
24 个空仓月多拿到的复利 0 4.04% 4.02%
# goal2-check
"""M02 空仓现金对照。不写图,不改 plot_faber_spy.py 的 0 收益净值。"""

import math
from pathlib import Path

import pandas as pd

from quant.data.cash_yield import HYBRID_ESTIMATED_DAILY_FACTORS

PUBLISHED_TBILL = {
    2022: 0.0202,
    2023: 0.0507,
    2024: 0.0497,
    2025: 0.0407,
    # 2026 年无同口径全年数,沿用 2025 年已公布的年度平均。
    2026: 0.0407,
}
FLAT_DAILY = HYBRID_ESTIMATED_DAILY_FACTORS[2018]


def daily_factor(year: int, flat: bool) -> float:
    if flat:
        return FLAT_DAILY
    if year <= 2021:
        return HYBRID_ESTIMATED_DAILY_FACTORS[year]
    return (1.0 + PUBLISHED_TBILL[year]) ** (1.0 / 252.0)


def summarize(ret: pd.Series) -> tuple[str, str]:
    years = len(ret) / 12.0
    cagr = (1.0 + ret).prod() ** (1.0 / years) - 1.0
    sharpe = (float(ret.mean()) - 0.02 / 12.0) / float(ret.std(ddof=1))
    sharpe *= math.sqrt(12.0)
    return f"{cagr * 100:.2f}%", f"{sharpe:.2f}"


px = pd.read_csv(
    Path("knowledge/Labs/momentum_plus_lab/seed_data/spy.csv"),
    parse_dates=["session"],
).set_index("session")["adj_close"].astype(float).sort_index()
month = px.resample("ME").last().dropna()
held = (month > month.rolling(10).mean()).where(month.rolling(10).mean().notna()).shift(1)
both = pd.concat({"mkt": month.pct_change(), "held": held}, axis=1).dropna()
ends = list(month.index)
zero = both["mkt"] * both["held"].astype(float)


def cash_on_idle(flat: bool) -> pd.Series:
    out: list[float] = []
    for stamp, is_held in both["held"].items():
        if is_held != 0:
            out.append(0.0)
            continue
        # 空仓月的现金按该月每个已完成交易日连乘,和引擎按会话计息同一口径。
        prev = ends[ends.index(stamp) - 1]
        sessions = px.index[(px.index > prev) & (px.index <= stamp)]
        growth = 1.0
        for ts in sessions:
            growth *= daily_factor(ts.year, flat)
        out.append(growth - 1.0)
    return pd.Series(out, index=both.index)


idle = both["held"] == 0
flat = zero + cash_on_idle(True)
segmented = zero + cash_on_idle(False)
extra_flat = float((1.0 + flat[idle] - zero[idle]).prod() - 1.0)
extra_seg = float((1.0 + segmented[idle] - zero[idle]).prod() - 1.0)
session_count = 0
for stamp in both.index[idle]:
    prev = ends[ends.index(stamp) - 1]
    session_count += int(((px.index > prev) & (px.index <= stamp)).sum())
zero_cagr, zero_sharpe = summarize(zero)
flat_cagr, flat_sharpe = summarize(flat)
seg_cagr, seg_sharpe = summarize(segmented)
print("idle_months", int(idle.sum()))
print("idle_sessions", session_count)
print("zero_cagr", zero_cagr)
print("zero_sharpe", zero_sharpe)
print("flat_cagr", flat_cagr)
print("flat_sharpe", flat_sharpe)
print("flat_extra", f"{extra_flat * 100:.2f}%")
print("seg_cagr", seg_cagr)
print("seg_sharpe", seg_sharpe)
print("seg_extra", f"{extra_seg * 100:.2f}%")
assert int(idle.sum()) == 24
assert session_count == 504
assert (zero_cagr, zero_sharpe) == ("7.40%", "0.51")
assert (flat_cagr, flat_sharpe) == ("7.76%", "0.54")
assert (seg_cagr, seg_sharpe) == ("7.76%", "0.54")
assert f"{extra_flat * 100:.2f}%" == "4.04%"
assert f"{extra_seg * 100:.2f}%" == "4.02%"

SPY 月末 10 个月均线与买入持有。现金收益为 0,信号延迟一个月。

复现:uv run python knowledge/books/figures/M02/plot_faber_spy.py。

sma = month_end.rolling(10).mean()
# 均线仍是 NaN 时保持缺失。本月收益只能乘上月末的指示。
held = (month_end > sma).where(sma.notna()).shift(1)
strategy_ret = month_end.pct_change() * held

仓库里的 SmaCrossStrategy 自己不 shift。build_target_schedule 把 as_of 放在这根日线收盘,把 effective_at 放在下一交易日开盘。教学脚本若直接用收益乘权重,必须自己延迟;接进引擎后再延迟一次,就会晚两天。

5. 截面 12-1 动量

袖套是策略自己的预算信封。总账户可以拆成趋势、事件和现金几份,每一份在汇总之前只按自己的比例管钱。 这一节的 12-1 动量先在自己的信封里排序。动量为负的空位留成现金,不把权重摊给其余股票。 信封里的比例加总为 1,乘上分给这条策略的预算之后,才是账户里的钱。 账户层的单名上限仍在信封外面,这一节先不把两道手续并成一次归一。

本仓库 XsMomentumStrategy 的定义,和月末总收益指数 TR 对齐:

MOM₁₂₋₁(m) = TRₘ₋₁/TRₘ₋₁₃ − 1

决策在月末 m。分子是上月末,分母再往前 12 个月,跨度正好 12 个月,当月 m 的涨跌不进排名。Kenneth French 的动量因子常用 prior (2-12),那是去掉最近一个月之后的 11 个月窗口。两个都叫「跳过最近一个月」,长度差一个月。引用实验室或 French 数据库时要写明是哪一个。

手算。 月末总收益指数:第 1 月 100,第 13 月 140,第 14 月 136。在 m = 14 决策:

MOM = 140/100 − 1 = 40%

第 14 月自己的 136/140 − 1 = −2.86% 被排除。若排名用的是 TR₁₄/TR₂,就把这个 −2.86% 混进了信号,同时也把起点挪错了。

只做多头端时,规则还要加一条绝对过滤:动量为负的空位留成现金,不把权重重新分给剩下的股票。xs_momentum.py 故意不归一。2008 和 2022 年这类下跌里,相对排名仍会选出「跌得少的」,绝对过滤才是把袖套留在现金里的开关。

动量实验室的 Iter 1 就是这条 12-1 规则,做多前 8 名。它的 CAGR 27.68% 必须和同池等权基准 21.79% 一起读,股票池是事后的大市值名单。超额的读法在实验室 README 第五节,不在这里重述成无偏的美股动量溢价。

6. 一笔交易的期望

E = p ¯G − (1 − p) ¯L

符号 含义
p 胜率,盈利笔数除以全部平仓笔数
¯G, ¯L 平均盈利、平均亏损的幅度,都写成正数

手算。 胜率 38%,平均赚 3.2%,平均亏 1.5%。

E = 0.38 × 0.032 − 0.62 × 0.015 = 0.01216 − 0.00930 = 0.00286

每笔 +0.286%,还没扣成本。一年 20 笔独立交易才大约 5.7 个百分点,而且连乘不是把 0.286% 乘 20 那么简单。胜率 38% 低于一半,期望仍为正,因为盈亏比 3.2/1.5 = 2.13。

JPM 实验室给了一个更极端的对照。日频 200 日均线只做多,81 笔完整交易,胜率 18.5%,盈亏比 7.49,利润因子 1.75。低胜率可以被少数大赢弥补。同一张表里,只做空的规则利润因子盖不住方向错误。期望公式不区分「趋势」和「回归」,它只检查离场之后的账单。

7. 半衰期

皮筋被拉开以后,若每天只回弹剩余长度的 7%,留下的是 0.93 倍。 缩到一半要解 0.93 的 h 次方等于 0.5。下面的手算给出 9.55 天。 这是离散步数。连续近似 9.90 天只在回弹很慢时接近,不能互换。 半衰期用来选回看大概是几个来回,不是持有期上限的证明。

价差或偏离 yₜ 若服从

yₜ − yₜ₋₁ = λ yₜ₋₁ + εₜ, φ = 1 + λ,

则 E[yₜ₊ₕ ∣ yₜ] = φ^h yₜ。半衰期是期望偏离掉到一半的步数:

φ^h = ½, h = (ln(½)) / (ln φ) = (ln 0.5) / (ln(1 + λ))

连续时间近似 h ≈ −ln 2/λ 只在 λ 接近 0 时好用。λ 必须为负,否则偏离在放大,没有半衰期。

手算。 日频 λ = −0.07,φ = 0.93。

h = (ln 0.5) / (ln 0.93) = 9.55 天

连续近似 −ln 2 / (−0.07) = 9.90 天。差 0.35 天,参数可以取同一档;λ = −0.5 时两者会差很多,不能再互换。陈书里用连续近似设定回看窗口。本章把离散公式当手算标准:回看大约取 2 到 4 个半衰期,灾难止损另设,不把半衰期当成持有期上限的证明。

λ 是样本回归系数。M01 说过,短样本的 R² 可以很高而自由度不够。半衰期要在滚动窗口里重估,协整消失时公式仍然会给出一个数字。

8. 短期反转和配对

Lehmann (1990) 记录周度反转,Lo & MacKinlay (1990, RFS 3(2)) 指出其中一部分来自股票之间的超前滞后,不是每一只股票自己的反转。Nagel (2012) 把短期反转和流动性供给连在一起:别人急着卖时你接盘,平静日赚价差,流动性枯竭时先亏。

日频 RSI(2)、布林带属于业界规则,不是上面那些论文的原假设。JPM 实验室把它们写死在同一只股票上:RSI(2) 只做多,在市时间 18.6%,年化 4.7%,低于买入持有的 14.3%;周频布林带多空年化 5.1%。换手高的规则还要再减价差。港股一轮来回的印花税量级见 M00,日频反转很难留下正期望。

配对的开仓常用价差的 z 分数。Gatev 等人用的是价格距离,不是先做协整再交易;协整是后来的工程过滤。论文报告自融资配对组合的年化超额最高约 11%(1962–2002),并写明成本吃掉很大一块。Do & Faff (2010, FAJ 66(4)) 把样本延后,收益继续变薄,不收敛的配对变多。

z 分数:

zₜ = (sₜ − ¯s) / σₛ

手算。 价差 1, 2, 0, −1, 3。均值 1,总体口径的二阶中心矩是 2,标准差 √2 = 1.414。最新一点 z = (3 − 1) / 1.414 = 1.41。若开仓阈值是 2,这点还不开仓。样本标准差(分母 n − 1)是 √2.5 = 1.581,z = 1.26。阈值对分母敏感,笔记里要写用的是哪一个。

GOOGL 与 GOOG、BRK.A 与 BRK.B 的价差很窄,机会主要在流动性和手数。A/H 溢价受资本管制,个人账户通常不能在 A 股一侧做空,把它写成可交易的配对会假装有一条做不到的腿。GLD 与 GDX 不是同一资产:金矿股带经营杠杆,协整可以在样本里出现、在金价趋势里消失。

9. 离场

方式 用在哪 破坏方式
信号消失就走 趋势、均线。默认选项 在引擎里再延迟一次,进出都晚
固定持有期 事件和短期反转 期限用拟合窗口挑到最好看的那一天
目标价差 配对回到均值附近 永不回归时没有第二条出路
灾难止损 协整可能已经断了 止损设在正常噪声里面,等于否定回归

均值回归的逻辑是越偏离越便宜。止损和这个逻辑冲突,但它防止的是「关系已经断了,偏离不再回到旧均值」。两条都写进规则:正常噪声里不加仓也不止损,超过事先写明的倍数或持有期就认赔。加仓摊平不在默认规则里。

10. 筛选

McLean & Pontiff (2016, JF 71(1)) 研究 97 个截面预测变量、79 篇论文。相对样本内,样本外收益平均低 26%(他们把这当作数据挖掘效应的上界),发表后平均低 58%。两者相减,发表本身大约再解释 32 个百分点。发表后不是归零,是变薄,而且和别的已发表因子更相关。

Greenwood & Sammon 在《Journal of Finance》2024 年 12 月在线发表的结果显示,标普 500 纳入的异常收益从 1990 年代平均 7.4% 降到近十年不到 1%;剔除的平均异常收益在 2010–2020 年约 0.1%。指数基金规模变大之后,价格冲击没有同步变大。制度性规律会被提前交易和流动性安排吃掉。

否决清单:说不出对手;只有一段未写尝试次数的回测;低频夏普高到需要先怀疑前视;没扣印花税和借券;要做空你借不到的股票;数据只在付费库的时点字段里。社交网站上的三年收益截图,五项里通常已经缺了前三项。


🔴 深入层:状态、组合与单票实验的边界

11. 不要先预测状态

趋势在单边市里少挨打,反转在震荡市里少挨打。Hamilton (1989, Econometrica 57(2)) 的马尔可夫区制模型可以估计状态,估计本身滞后,而且状态个数是搜索维度。更稳的做法是同时持有低相关的规则,让震荡月和趋势月在组合里对冲,而不是每个月宣布「现在是趋势市」。M12 和 M17 接这个题目。

12. JPM 实验室:七套规则都没有跑赢买入持有

评估窗 2008-01-02 至 2026-10-02。单边成本 5 bps,空头另按年化 0.5% 扣借券,空仓现金按年化 1.5%。夏普是日均收益乘 252 再除以年化波动,无风险利率按 0,和 metrics.py 的超额夏普不是同一个数。信号用成交价,股息加在除息日的总收益上。t 日收盘得到的权重从 t + 1 的总收益开始算。

方案 年化 波动 夏普(r_f = 0) 最大回撤 在市
买入持有 14.3% 37.4% 0.54 67.7% 100%
200 日均线,只做多 4.5% 20.7% 0.32 63.0% 69.1%
52 周突破,只做多 4.9% 15.7% 0.38 33.3% 49.2%
RSI(2),只做多 4.7% 20.1% 0.33 35.6% 18.6%
12 个月动量,只做多,跳过最近一月 7.5% 21.6% 0.44 56.1% 67.5%
均线向下才做空 −11.3% 24.9% −0.35 90.9% 空头 14.8%
六个月符号,多空 −9.3% 37.4% −0.07 95.9% 空头 32.4%

七套主动规则的年化和夏普都低于这只股票的买入持有。52 周突破把最大回撤降到 33.3%,代价是年化只剩 4.9%,完整交易只有 9 笔。这不是「趋势无效」的证明。Moskowitz 等人的结果来自几十个期货一起做、可以做空、波动目标化。这里是一只银行股、一个参数、事先写死。它证明的是:把多资产论文收成单票规则之后,要重新计量,不能继承论文里的夏普。

JPM 七套规则与买入持有的净值。口径见实验室 README,夏普的无风险利率为 0。

七套方案的收益、回撤和交易次数。52 周突破交易很少,只做空的规则期末净值接近归零。

复现:uv run python -m knowledge.Labs.jpm_lab.run_lab。前七套的参数在看见结果之后没有再改;S8、S9 是后加的混合,引用前七套时不要把混合方案的改进算进「事先写死」。


真实案例

案例 1:2009 年 3–5 月,动量的空头腿向上崩

Daniel & Moskowitz (2016, JFE 122(2)) 的美国股票样本里,2009 年 3 月至 5 月,过去输家十分位上涨 163%,赢家十分位上涨 8%,市场上涨 26%。崩的是空头腿:输家在反弹里涨得更多。论文表 2 里,赢家减输家组合 2009 年 3 月 −42.28%,4 月 −45.52%。1932 年 7–8 月是同一结构,输家两个月上涨 232%,赢家上涨 32%。

个人只做多头端时,没有 −45% 的那条空头账单,但 2009 年春天的多头动量只涨了 8%,落后市场 26%。「不做空就没有动量崩溃」不成立。崩溃的形态变成跑输反弹,而不是账户归零。多空版本才继承论文里的左尾。仓位上,不要在熊市末期用杠杆去空「已经跌最多」的一档。

案例 2:配对收益变薄

Gatev, Goetzmann & Rouwenhorst (2006) 的距离配对在 1962–2002 年能做出最高约 11% 的年化超额,成本之后薄很多,后半段已经不如前半段。Do & Faff (2010) 延到 2009 年,收益继续下降,价差不回归的比例上升。对手从「定价不一致」变成「别的统计套利账户」。个人若仍做配对,止损要针对关系断裂,而不是把阈值调到样本里最好看的 z 分数。

案例 3:标普纳入效应被市场消化

Harris & Gurel (1986) 记录纳入指数带来的价格压力。Greenwood & Sammon 的《Journal of Finance》论文写,1990 年代纳入的平均异常收益还有 7.4%,近十年不到 1%;2010–2020 年剔除的平均异常收益约 0.1%。指数基金更大了,可预测的冲击却更小。这和 McLean & Pontiff 的发表后衰减是同一类适应:规则一旦被写进太多账户,价格在生效日之前就走完了。把它当作 2026 年的主力策略,等于用 1986 年的容量假设。


方法论来源

概念 出处
时序动量 Moskowitz, Ooi & Pedersen (2012), JFE 104(2)
长期趋势证据 Hurst, Ooi & Pedersen (2017), JPM 44(1)
10 个月均线 Faber (2007), Journal of Wealth Management 9(4)
截面动量 Jegadeesh & Titman (1993), JF 48(1);Carhart (1997), JF 52(1)
动量崩溃 Daniel & Moskowitz (2016), JFE 122(2),表 2
反应不足 Hong & Stein (1999), JF 54(6)
长期反转 De Bondt & Thaler (1985), JF 40(3)
短期反转 Lehmann (1990), QJE 105(1);Lo & MacKinlay (1990), RFS 3(2)
流动性与反转 Nagel (2012), RFS 25(7)
配对 Gatev, Goetzmann & Rouwenhorst (2006), RFS 19(3);Do & Faff (2010), FAJ 66(4)
月末资金流 Etula, Rinne, Suominen & Vaittinen (2020), RFS 33(1)
发表后衰减 McLean & Pontiff (2016), JF 71(1)
指数效应变薄 Greenwood & Sammon, JF,2024-12 在线发表
区制 Hamilton (1989), Econometrica 57(2)
本仓库 12-1 src/quant/strategies/xs_momentum.py
单票七套规则 knowledge/Labs/jpm_lab/README.md
## 💡 思考回顾与巩固

核心认知纠偏

误区 反例 用法
时序动量跌了就要做空 JPM 的只做空年化 −11.3%,六个月多空最大回撤 95.9% 股票账户的默认是空仓或短债
12-1 就是 French 的动量因子 仓库用 TRₘ₋₁/TRₘ₋₁₃ − 1,12 个月;French prior (2-12) 是 11 个月 写明起止下标
均线不足时比较为假,等于已经决定空仓 脚本里 NaN > 价格 在 pandas 里是 False where(sma.notna()) 之后再 shift(1)
空仓现金记 0 不影响这条均线的读法 24 个空仓月、504 个交易日按年化 2% 复利是 4.04%,CAGR 从 7.40% 到 7.76% 原表保留;7.76% 仍低于买入持有 13.74%
胜率 38% 的策略不值得做 手算期望仍是每笔 +0.286%;JPM 均线胜率 18.5% 仍有正的利润因子 看盈亏比和成本之后的期望
不做空就没有动量崩溃 2009 年 3–5 月赢家只涨 8%,市场涨 26% 多头端的风险是跑输反弹
相关高就可以配对 M01:协整的原假设是「没有协整」 A/H 若不能做空一侧,就不是配对
发表过的规律可以按论文夏普预期 97 个预测变量发表后平均收益低 58% 预期更薄,并提高成本假设

费曼自测

  1. 所有股票都在跌的月份,时序动量和截面动量的持仓有什么不同?
  2. 为什么 Rₜ^strat = hₜ₋₁Rₜ,而引擎里的均线策略自己不写 shift?
  3. 2009 年 4 月赢家减输家 −45.52%,亏在哪一条腿?
  4. 单只 JPM 上七套规则都跑输买入持有,能否得出「动量论文被推翻」?
自测要点 1. 时序动量只看每只资产自己的过去。全部下跌时,期货版本会做空,股票版本应空仓。截面动量仍会做多相对不那么差的一档、做空最差的一档,除非另加「动量必须为正」的绝对过滤。 2. 教学脚本用当月收益乘权重,所以权重必须来自上月。`SmaCrossStrategy` 给出的是收盘时点的目标权重,`build_target_schedule` 把成交放到下一交易日。两边都延迟就会晚一个节拍。 3. 亏在空头。2009 年 3–5 月输家涨 163%,赢家涨 8%。表 2 的 −45.52% 是多空组合的月收益,不是赢家那一档自己的跌幅。 4. 不能。实验室是一只股票、固定参数、可以做空的那几套还付了借券费。论文是多资产、多空、另一套频率。单票实验否定的是「把论文直接套到这一只股票上还能继承那个夏普」。

四维习题

A. 概念题

  1. 时序动量和截面动量各回答什么问题?全市场下跌时,只做多的实现分别持有什么?
  2. 为什么 12-1 要跳过最近一个月?French 的 prior (2-12) 和仓库公式差在哪?
  3. 均值回归的收益分布为什么常被说成左偏?止损和「越跌越买」冲突时,规则里应保留哪一条?
  4. 策略组合为什么不先做区制预测?

B. 计算题

  1. 月末总收益指数第 1 月为 100,第 13 月为 140,第 14 月为 136。按仓库公式计算 m = 14 的 12-1 动量。若误用 TR₁₄/TR₂ − 1,多算进去的是哪一段?
  2. 日频回归 λ = −0.07。分别用离散公式和连续近似计算半衰期。
  3. 胜率 38%,平均盈利 3.2%,平均亏损 1.5%。每笔期望是多少?若一笔来回成本 0.20%,期望还剩多少?
  4. 价差 1, 2, 0, −1, 3。用除以 n 的标准差和除以 n − 1 的标准差,分别计算最后一点的 z 分数。阈值 2 时开不开仓?

C. 编程实战题

  1. 用 knowledge/Labs/momentum_plus_lab/seed_data/spy.csv 复现 10 个月均线。月末取样,均线无效的月份保持缺失,shift(1) 之后再乘月收益,现金收益为 0。打印窗口、CAGR、√12 波动、r_f = 2% 的夏普、月末最大回撤,以及在市月份。不要把某次运行写回教材。
  2. 在第 9 题里故意去掉 shift(1),看 CAGR 如何变化,并说明这是前视的哪一种。
  3. 读 src/quant/strategies/xs_momentum.py,用注释写出 skip_months=1、lookback_months=12 时分子和分母的月末下标。不要在策略函数内部再加一次 shift。

D. 个人实战思考题

  1. 按第 10 节的否决清单,处理一条「三年收益 300%」的社交网站截图。至少写出三项你会否决或要求补证的理由。
  2. 从时序动量、截面动量、短期反转、配对里选两条作为一年内的研究候选。说明富途或 IB 上做空和印花税怎样影响选择。
  3. JPM 实验室里 52 周突破把回撤降到 33.3%,年化只剩 4.9%。你是否愿意用一半的长期收益换这种回撤?用你的现金流回答,不要用夏普回答。
完整参考答案与解析 **A1.** 时序动量问「这只资产自己的过去是涨还是跌」。只做多时,过去下跌就空仓。截面动量问「谁比篮子里的其他人强」。只做多且没有绝对过滤时,仍持有相对排名靠前的股票,哪怕它们的绝对收益为负。仓库的实现加了绝对过滤:动量不为正的名额留成现金。 **A2.** 最近一个月重叠着短期反转。跳过它,是为了不把流动性冲击当成中期动量。仓库在月末 m 使用 TRₘ₋₁/TRₘ₋₁₃ − 1,从 m − 13 到 m − 1 共 12 个月。French 的 prior (2-12) 是去掉最近一个月之后再取 11 个月。比较因子收益时不能把这两个序列当成同一个组合。 **A3.** 反转靠很多次小的价差回归,亏在关系不变、价格继续偏离的那几次。左尾比右尾长。规则保留灾难止损和最长持有期,用来承认「旧均值可能已经失效」。在噪声范围内不摊平,避免把止损和加仓写成两条互相取消的指令。 **A4.** 区制估计用的是已经发生的价格,切换信号迟到,状态个数又是一个可以搜索的参数。同时持有趋势和反转,让它们在不同月份分别赚钱,比每个月预测状态少一个过拟合维度。前提是两条规则的持仓不要偷偷变成同一个因子。 **B5.** 140/100 − 1 = 40%。第 14 月的 136/140 − 1 = −2.86% 不在信号里。误用 TR₁₄/TR₂ − 1 会把第 14 月算进去,并把起点从第 1 月改到第 2 月。这既是前视(用了决策月的收盘),也是窗口定义错误。 **B6.** φ = 0.93。h = ln 0.5/ln 0.93 = 9.55 天。连续近似 −ln 2 / (−0.07) = 9.90 天。回看可取约 20–40 个交易日,这是数量级,不是优化出来的最优窗。 **B7.** 0.38 × 3.2% − 0.62 × 1.5% = 1.216% − 0.930% = 0.286%。成本 0.20% 之后每笔期望 0.086%。一年 20 笔的算术合计约 1.7%,再考虑亏损笔的复利和成本的双边口径,空间已经很窄。0.20% 对港股印花税一轮来回并不夸张。 **B8.** 均值 1。离差 0, 1, −1, −2, 2,平方和 10。除以 n = 5 得方差 2,标准差 1.414,z = 2/1.414 = 1.41。除以 n − 1 = 4 得方差 2.5,标准差 1.581,z = 1.26。两种算法都低于 2,不开仓。若有人用总体标准差算到 1.41 就四舍五入成「接近 2」,仍然不开。 **C9–C10.** 参考实现与 `plot_faber_spy.py` 同一契约。正确延迟时,窗口 2014-11-30 至 2026-09-30,143 个月;买入持有 CAGR 13.74%、波动 14.82%、夏普 0.81、月末回撤 −23.93%;均线策略 CAGR 7.40%、波动 11.28%、夏普 0.51、月末回撤 −22.95%;在市 119 个月。去掉 `shift(1)` 会用本月收盘决定本月收益,CAGR 会变高,那是前视,不是策略改进。下面只保留正确时点的代码。
import math
from pathlib import Path

import pandas as pd

RF = 0.02
px = pd.read_csv(
    Path("knowledge/Labs/momentum_plus_lab/seed_data/spy.csv"),
    parse_dates=["session"],
).set_index("session")["adj_close"].astype(float).sort_index()
month = px.resample("ME").last().dropna()
sma = month.rolling(10).mean()
held = (month > sma).where(sma.notna()).shift(1)
ret = pd.concat({"mkt": month.pct_change(), "held": held}, axis=1).dropna()
strat = ret["mkt"] * ret["held"].astype(float)


def report(name: str, r: pd.Series) -> None:
    years = len(r) / 12.0
    cagr = (1.0 + r).prod() ** (1.0 / years) - 1.0
    vol = r.std(ddof=1) * math.sqrt(12.0)
    sharpe = (r.mean() - RF / 12.0) / r.std(ddof=1) * math.sqrt(12.0)
    nav = (1.0 + r).cumprod()
    mdd = (nav / nav.cummax() - 1.0).min()
    print(name, round(cagr, 4), round(vol, 4), round(float(sharpe), 2), round(float(mdd), 4))


print(ret.index.min().date(), ret.index.max().date(), "months", len(ret), "in", int(ret["held"].sum()))
report("buyhold", ret["mkt"])
report("ma10", strat)
**C11.** `momentum = levels.shift(1) / levels.shift(13) - 1`。分子是决策月的上一个月,分母是再往前 12 个月的月末。策略函数只负责在收盘时点给出目标权重。成交日由 `build_target_schedule` 的 `effective_at` 推到下一交易日。函数内部再 `shift`,信号会晚一个节拍。 **D12.** 至少问三件:收益是扣成本之后的还是截图;这三年里试过多少参数,有没有样本外;规则属于四类收益来源的哪一类,对手是谁。300% 的三年收益若对应很高的夏普和很短的窗口,先按 M01 的 t = SR √Y 看证据强度。说不出做空是否借得到、用的是不是复权价,直接否决。 **D13.** 与本仓库一致的候选是:月频截面动量,只做多头端,动量为负时留现金;以及周或月频的时序过滤,下跌时空仓而不是做空。日频反转被港股印花税和美股小盘价差吃掉。配对要求两条腿都能交易,A/H 和多数港股做空不满足。借券费没有写进回测时,多空期望是毛的。 **D14.** 这是现金流问题。若生活支出依赖账户,33% 的回撤仍可能迫使你在低点停掉规则,4.9% 的年化还要覆盖这个心理账户。若这笔钱五年不用,回撤从 68% 降到 33% 可能值得,但 JPM 这一只股票的 4.9% 低于它自己的买入持有,换到的是更低的收益而不是免费的保险。单票实验不能外推成「52 周突破在多资产上只值 4.9%」。

📌 章节知识总结

一句话核心洞察

  1. 动量和反转能并存,因为时钟不同。12-1 跳过最近一个月,是为了不把反转算进动量。
  2. 股票账户的时序规则默认空仓,不默认做空。JPM 上的空头规则把 10 万美元打到约 1 万美元。
  3. 信号在 t 知道,收益从下一期算。引擎已经延迟成交时,策略函数里不要再 shift。
  4. 胜率可以低于一半。期望看盈亏比减成本,不看胜率本身。
  5. 发表后平均收益下降约 58%,指数纳入的异常收益近十年不到 1%。公开规则按更薄的净收益做预算。

公式与参数速查

名称 表达式 前提
均线持有 Rₜ^strat = hₜ₋₁Rₜ h 在月末知道;现金收益另行声明
仓库 12-1 TRₘ₋₁/TRₘ₋₁₃ − 1 与 French 的 11 个月窗口不同
每笔期望 p¯G − (1 − p) ¯L ¯L 取正;尚未年化,尚未扣成本
离散半衰期 ln 0.5/ln(1 + λ) λ < 0;连续近似 −ln 2/λ
λ = −0.07 离散 9.55 天,连续 9.90 天 日频回归
本章 SPY 均线 CAGR 7.40%,买入持有 13.74% 2014-11 至 2026-09,现金收益 0,r_f = 2%
均线空仓计息 CAGR 7.76%,夏普 0.54,空仓复利 4.04% 同一窗口;年化 2%。分段短债的复利是 4.02%,两位小数的 CAGR 与夏普相同
JPM 买入持有 年化 14.3%,回撤 67.7% 2008-01-02 至 2026-10-02,夏普的 r_f = 0
动量崩溃 2009-04 的 WML −45.52% Daniel & Moskowitz 表 2,多空十分位
发表后 平均低 58% McLean & Pontiff,97 个预测变量
指数纳入 1990 年代 7.4%,近十年 <1% Greenwood & Sammon,JF

跨模块联结

本章结论 下游 工程落点
时点:下一期才成交 M03 src/quant/core/signals.py 的 effective_at
12-1 与绝对过滤 M07 src/quant/strategies/xs_momentum.py
单票七套规则 不要外推论文夏普 knowledge/Labs/jpm_lab/
成本否决日频反转 M13、M00 的印花税 src/quant/backtest/fees.py
多规则并存 M12、M17 先看持仓相关,再谈分散

学习指引

概念卡片:时序动量与截面动量 · 协整与半衰期

  • 对照原书:陈第 2、7 章笔记 看筛选和半衰期在书里的位置;黑箱第 3 章笔记 看 Alpha 模型怎样分理论型和数据型。
  • 论文:Moskowitz, Ooi & Pedersen (2012);Daniel & Moskowitz (2016) 的表 2;McLean & Pontiff (2016)。
  • 下一步:M03 数据与回测工程。本章的 SPY 图和 JPM 表都还不是你自己的样本外实验。

自检清单

  • 能手算 m = 14 的 12-1 为 40%,并说出 French 窗口差一个月
  • 能算出 λ = −0.07 的离散半衰期 9.55 天
  • 能解释 SPY 均线图为什么跑输买入持有,以及月末回撤漏掉了什么
  • 能说出 2009 年 4 月 −45.52% 亏在空头腿
  • 知道 SmaCrossStrategy 不 shift 的原因是引擎已把成交放到下一交易日
  • 候选策略清单里写了做空和印花税,而不是只写论文名字