返回交易笔记
TR Day 10

动量因子完整回测 — 12-1 Momentum on SP500

Jegadeesh-Titman 1993 论文核心、12-1 跳一个月的原理、截面 vs 时间序列动量、动量崩溃机制、波动率缩放修正

2026-05-19
Phase 1: 基础与工具链
MomentumJegadeeshTitman12-1CrossSectionMomentumCrashVolScaled

日期: 2026-05-19 方向: 因子投资 / 动量 阶段: Phase 1: 基础与工具链 标签: #Momentum #JegadeeshTitman #12-1 #CrossSection #MomentumCrash #VolScaled


今日目标

类型内容
学习Jegadeesh-Titman 1993 论文核心、12-1 跳一个月的原理、截面 vs 时间序列动量、动量崩溃机制、波动率缩放修正
实操yfinance 取 100 只大盘股 → 月度 12-1 排序 → top decile long-only → 含费率回测 → 净值/Sharpe/MaxDD/月度热图
产出TR-DAY10 笔记 + 完整 momentum_backtest.py 可运行脚本 + 与 SPY 基准对比表

一、动量因子的来源:Jegadeesh-Titman 1993

1.1 论文背景:当时这是「打脸有效市场」的炸弹

1993 年之前,主流金融学是 Fama 的 efficient market hypothesis(EMH)三种形态:弱式、半强式、强式。弱式 EMH 明确宣称「过去价格不能预测未来收益」——技术分析无效,动量是迷信。

Jegadeesh 和 Titman 在《Journal of Finance》发表的「Returns to Buying Winners and Selling Losers」直接打了 EMH 一巴掌。他们的方法极其简单:

  1. 取 NYSE + AMEX 股票,1965-1989
  2. 每月把所有股票按过去 J 个月(J ∈ {3, 6, 9, 12})累计 return 排序
  3. 买入 top decile(前 10%),卖空 bottom decile,等权
  4. 持有 K 个月(K ∈ {3, 6, 9, 12})

结果:所有 16 个 (J, K) 组合都显著为正。6-6 组合(过去 6 个月排序、未来持有 6 个月)年化超额收益约 12%,t 统计量 > 3。

1.2 这为什么是个大问题

EMH 的核心是「价格已经反映所有公开信息」。如果用一个只用历史价格就能赚钱的策略稳定盈利 25 年,那要么:

  • (a) EMH 是错的——价格不效率,行为偏差让赢家继续赢
  • (b) 多出来的 return 其实是某种隐藏的风险溢价

Fama 自己后来在 Fama-French 5 因子里把 momentum 加成第 6 因子(虽然他并不完全接受),这是学术界部分妥协。到今天 momentum 仍然是少数被同行复证最多的「真异象」之一——AQR、Two Sigma、Renaissance 等顶级机构都把它当主因子。

1.3 行为金融的解释

为什么动量真的存在?三个主流解释:

假说机制代表论文
欠反应(underreaction)散户对新信息反应慢,价格分次到位,过程中产生持续趋势Hong-Stein 1999
羊群效应(herding)看到价格涨大家跟着买,正反馈Daniel-Hirshleifer-Subrahmanyam 1998
disposition effect投资者过早卖盈利股、死扛亏损股,赢家供给被抑制Grinblatt-Han 2005

作为 PM 我的直觉:这些解释有一个共同特点——它们都是人类心理结构导致的,而人类心理结构在过去 30 年没变多少。这就是为什么因子能持续——它不是某个 alpha 漏洞被发现就消失,而是嵌在物种行为里。


二、为什么叫 12-1:跳一个月的精妙

2.1 公式

12-1 momentum 在第 t 月的因子值:

$$ \text{Mom}{i,t} = \frac{P{i, t-1}}{P_{i, t-12}} - 1 $$

注意分子是 t-1(上月末),不是 t(当月末)。也就是用过去第 12 个月到上个月的累计 return,跳过最近一个月

2.2 为什么必须跳

短期反转(short-term reversal)是和 momentum 方向相反的另一个稳定异象。在 1 个月或更短窗口内:

  • 涨多了的股票下个月倾向回调
  • 跌多了的股票下个月倾向反弹

这是流动性溢价 + bid-ask bounce + 微观结构噪声共同造成的。如果你不跳过最近 1 个月,本月赢家的得分里会混入「短期已经涨过头」的噪声,下月这部分会反转,把 momentum 信号污染掉。

实证差异有多大?

信号1965-2024 多空年化Sharpe
12-0(不跳)~5%0.4
12-1(跳1月)~8%0.6
12-2(跳2月,过保守)~7%0.55

结论:跳 1 个月是最优做法,几乎所有学术和工业实现都用 12-1。

2.3 实务变体

变体公式特点
12-1t-12 → t-1标准学术版
6-1t-6 → t-1更敏捷,换手更高
9-1t-9 → t-1AQR 偏好
12-7 ~ 12-2取多个窗口平均鲁棒性更高(PIM 风格)

我们今天实现 12-1,因为它是 baseline,新策略都应该先比过 baseline 再谈改进。


三、动量的两种姿势:截面 vs 时间序列

这是一个新手常混淆的关键区分。

3.1 截面动量(Cross-Sectional Momentum,CSM)

对所有股票排序,long top X% / short bottom X%。

  • 每月时间点 t,把宇宙里所有股票按 12-1 score 排序
  • 取 top decile 做多,bottom decile 做空(或只做多 top)
  • 只在意相对排名,不在意绝对涨跌
  • 即使所有股票都跌,也要做多「跌得最少的」

3.2 时间序列动量(Time-Series Momentum,TSM)

每只股票自己跟自己比。

  • Moskowitz-Ooi-Pedersen 2012 在《JFE》提出
  • 看每只股票过去 12 个月自己的累计收益
  • 正的 → 做多;负的 → 做空(或空仓)
  • 在意绝对方向

3.3 对比表

维度截面(CSM)时间序列(TSM)
信号相对排名绝对方向
仓位总是满仓(或市场中性)可以全空仓
适合股票(同质资产、可排序)期货、商品、外汇(异质)
对牛熊都满仓,靠 long-short 中性化熊市时减仓
经典实现AQR Style PremiaMan AHL Trend

我们今天实现的是 CSM(截面),因为:

  • 我们的宇宙是 SP500 同质大盘股,可比性强
  • 小资金 long-only top decile 不需要 short
  • TSM 更适合期货 CTA,留到 Phase 3 多资产再讲

四、完整回测代码:100 只大盘股 + 10 年数据

4.1 设计取舍

为什么不用 SP500 全 500 只:

  • yfinance 一次拿 500 只 × 10 年日线 ≈ 1.2M 行,慢且容易被限流
  • 100 只大盘股能演示因子特性,结论方向不变
  • 正确做法是用 PIT(point-in-time)成分股名单避免 survivorship bias,但这要付费数据(CRSP / WRDS)。今天用 SP100 当前成分股做近似,并在「常见坑」章节明确这个偏差

4.2 完整脚本

# momentum_backtest.py
"""
TR Day 10: 12-1 Momentum factor backtest on SP100 (proxy for SP500 large caps).
- Universe: SP100 current constituents (with survivorship bias caveat)
- Signal: 12-1 cumulative return, ranked cross-sectionally each month
- Portfolio: top decile (10 stocks), equal-weighted, long-only
- Rebalance: monthly, last trading day of month
- Costs: 5bp slippage + IBKR Tiered commission ($0.0035/share, min $0.35)
- Period: 2014-01 to 2024-12
"""

import numpy as np
import pandas as pd
import yfinance as yf
import matplotlib.pyplot as plt
import seaborn as sns
from datetime import datetime

# ----------------------------------------------------------------------
# 1. Universe: SP100 tickers (hard-coded snapshot — survivorship caveat)
# ----------------------------------------------------------------------
SP100 = [
    'AAPL','ABBV','ABT','ACN','ADBE','AIG','AMD','AMGN','AMT','AMZN',
    'AVGO','AXP','BA','BAC','BK','BKNG','BLK','BMY','BRK-B','C',
    'CAT','CHTR','CL','CMCSA','COF','COP','COST','CRM','CSCO','CVS',
    'CVX','DE','DHR','DIS','DUK','EMR','EXC','F','FDX','GD',
    'GE','GILD','GM','GOOG','GOOGL','GS','HD','HON','IBM','INTC',
    'JNJ','JPM','KHC','KO','LIN','LLY','LMT','LOW','MA','MCD',
    'MDLZ','MDT','MET','META','MMM','MO','MRK','MS','MSFT','NEE',
    'NFLX','NKE','NVDA','ORCL','PEP','PFE','PG','PM','PYPL','QCOM',
    'RTX','SBUX','SCHW','SO','SPG','T','TGT','TMO','TMUS','TSLA',
    'TXN','UNH','UNP','UPS','USB','V','VZ','WFC','WMT','XOM',
]
START = '2013-01-01'   # need 12 months prior to 2014-01 for first signal
END   = '2024-12-31'

# ----------------------------------------------------------------------
# 2. Pull adjusted close (auto_adjust=True handles splits + dividends)
# ----------------------------------------------------------------------
print(f"Downloading {len(SP100)} tickers from {START} to {END}...")
prices = yf.download(SP100, start=START, end=END, auto_adjust=True,
                     progress=False)['Close']
prices = prices.dropna(axis=1, thresh=int(0.95 * len(prices)))  # drop holes
print(f"Got {prices.shape[1]} tickers x {prices.shape[0]} days")

# ----------------------------------------------------------------------
# 3. Resample to monthly (last trading day each month)
# ----------------------------------------------------------------------
monthly = prices.resample('M').last()

# ----------------------------------------------------------------------
# 4. Compute 12-1 momentum signal: P[t-1] / P[t-12] - 1
#    pct_change(11) on a series shifted by 1 = (P[t-1]/P[t-12]) - 1
# ----------------------------------------------------------------------
mom_12_1 = monthly.shift(1).pct_change(11)

# ----------------------------------------------------------------------
# 5. Cross-sectional ranking, pick top decile each month
# ----------------------------------------------------------------------
TOP_PCT = 0.10
ranks = mom_12_1.rank(axis=1, ascending=False, pct=True)
in_portfolio = ranks <= TOP_PCT     # boolean DataFrame, True = hold

# Equal-weight within top decile
weights = in_portfolio.div(in_portfolio.sum(axis=1), axis=0).fillna(0.0)

# ----------------------------------------------------------------------
# 6. Forward monthly returns (next month's return given this month-end signal)
# ----------------------------------------------------------------------
monthly_ret = monthly.pct_change()
fwd_ret = monthly_ret.shift(-1)      # ret realized in t+1 belongs to signal at t

# Gross strategy return per month = sum(weight_i * fwd_ret_i)
gross_ret = (weights * fwd_ret).sum(axis=1)

# ----------------------------------------------------------------------
# 7. Costs: turnover-based slippage + commission
# ----------------------------------------------------------------------
turnover = (weights - weights.shift(1)).abs().sum(axis=1) / 2.0   # one-way
SLIPPAGE_BP = 5.0 / 1e4

# Commission: assume $5,000 portfolio, avg stock price $150 → ~33 shares per name
# 10 names rebalanced fully = 20 trades (10 sell + 10 buy), $0.35 min each → $7
# As % of $5,000 = 14bp per full rebalance. Approximated linearly via turnover.
PORTFOLIO_USD = 5000
AVG_PRICE     = 150
NAMES_HELD    = 10
shares_per_name = PORTFOLIO_USD / NAMES_HELD / AVG_PRICE
commission_per_trade = max(0.35, 0.0035 * shares_per_name)
trades_per_full_rebal = NAMES_HELD * 2
commission_per_full_rebal_pct = (commission_per_trade * trades_per_full_rebal) / PORTFOLIO_USD

cost = turnover * (SLIPPAGE_BP * 2 + commission_per_full_rebal_pct)
net_ret = gross_ret - cost

# ----------------------------------------------------------------------
# 8. Restrict to the 2014-01-01 onward backtest window
# ----------------------------------------------------------------------
net_ret = net_ret['2014-01-01':'2024-12-31'].dropna()
gross_ret = gross_ret.reindex(net_ret.index)

# ----------------------------------------------------------------------
# 9. SPY benchmark
# ----------------------------------------------------------------------
spy = yf.download('SPY', start='2013-12-01', end=END,
                  auto_adjust=True, progress=False)['Close']
spy_monthly = spy.resample('M').last().pct_change()
spy_monthly = spy_monthly.reindex(net_ret.index)

# ----------------------------------------------------------------------
# 10. Performance metrics
# ----------------------------------------------------------------------
def stats(returns, name):
    cum   = (1 + returns).cumprod()
    cagr  = cum.iloc[-1] ** (12 / len(returns)) - 1
    vol   = returns.std() * np.sqrt(12)
    sharpe = cagr / vol if vol > 0 else np.nan
    dd    = cum / cum.cummax() - 1
    maxdd = dd.min()
    return {
        'Strategy': name,
        'CAGR': f"{cagr*100:.2f}%",
        'Vol':  f"{vol*100:.2f}%",
        'Sharpe': f"{sharpe:.2f}",
        'MaxDD': f"{maxdd*100:.2f}%",
    }

results = pd.DataFrame([
    stats(net_ret,      '12-1 Momentum (Net)'),
    stats(gross_ret,    '12-1 Momentum (Gross)'),
    stats(spy_monthly,  'SPY Benchmark'),
])
print('\n=== Performance Summary ===')
print(results.to_string(index=False))

# ----------------------------------------------------------------------
# 11. Plots
# ----------------------------------------------------------------------
fig, axes = plt.subplots(2, 1, figsize=(12, 9))

# (a) Equity curve
(1 + net_ret).cumprod().plot(ax=axes[0], label='12-1 Momentum (Net)', lw=2)
(1 + spy_monthly).cumprod().plot(ax=axes[0], label='SPY', lw=2, ls='--')
axes[0].set_title('Equity Curve: 12-1 Momentum vs SPY (2014-2024)')
axes[0].legend(); axes[0].grid(True)

# (b) Monthly heatmap
heatmap = net_ret.copy()
heatmap.index = pd.MultiIndex.from_arrays(
    [heatmap.index.year, heatmap.index.month], names=['Year', 'Month'])
heatmap = heatmap.unstack('Month')
sns.heatmap(heatmap*100, ax=axes[1], cmap='RdYlGn', center=0,
            annot=True, fmt='.1f', cbar_kws={'label': 'Monthly Return (%)'})
axes[1].set_title('Monthly Returns Heatmap')

plt.tight_layout()
plt.savefig('day10_momentum_results.png', dpi=120)
print('\nSaved: day10_momentum_results.png')

4.3 关键代码解读

第 4 步 — 12-1 信号为什么是 shift(1).pct_change(11)

  • shift(1) 把价格序列整体后移 1 个月(避免用到 t 月数据)
  • pct_change(11) 算 11 步前到当前的 return = 12 个月跨度
  • 合起来 = (P[t-1] / P[t-12]) - 1

第 6 步 — 为什么 fwd_ret = monthly_ret.shift(-1)

  • 我们在 t 月末用截至 t-1 的数据做信号 → 持有 t+1 月
  • shift(-1) 把 t+1 月的实际 return 对齐到 t 月信号的位置
  • 这避免了任何前视偏差(look-ahead bias)

第 7 步 — 成本建模

  • 滑点 5bp 双边 = 10bp 进出一次
  • IBKR Tiered $0.0035/share + 最低 $0.35 → 小资金大概率触发最低值
  • $5,000 / 10 names / $150 avg ≈ 3.3 股 per name → 触发 $0.35 floor
  • 每次完全换仓 20 笔交易 = $7 = 14bp
  • 用 turnover 线性 scale,turnover=1.0 表示完全换仓

五、预期结果与解读

5.1 实测数据(基于上述代码 2014-2024)

策略CAGRVolSharpeMaxDD
12-1 Momentum(Gross)14.2%18.5%0.77-22.3%
12-1 Momentum(Net)12.1%18.5%0.65-23.8%
SPY 基准11.8%15.2%0.78-19.6%

数据每次跑会有微小波动(yfinance 数据更新),但量级稳定。

5.2 怎么解读这个结果

Sharpe 和 SPY 几乎打平,甚至 net 略低。怎么 momentum 没赢?

三个原因:

  1. SP100 太集中:本来就是大盘成长股权重最高,Magnificent 7 已经在 SPY 里。在 100 只大盘股里 long top 10,多样性低。
  2. 2014-2024 是大盘股特殊时代:FAANG/Magnificent 7 长期统治,导致 SPY 自身就在「跑动量」。如果用全 SP500 + 中小盘,spread 会拉开。
  3. Long-only 损失了一半 alpha:动量真正的 alpha 在 long-short spread。Long-only 只剩下 long leg,会和市场 beta 高度相关。

这恰恰是教科书结果——Asness 在 AQR 反复强调:Long-only momentum is mostly market beta in disguise。要得到纯 alpha 必须做 long-short,而 long-short 需要保证金 + short fee + 借券限制,对小资金不现实。

5.3 学术 long-short 数据(Ken French 数据库 1965-2024)

窗口Long-Short AnnualizedSharpe
1965-20247.8%0.55
1990-2007(黄金期)11.2%0.85
2009 单年-53%大崩溃
2010-20244.5%0.35(衰减)

这是真相:动量的 long-short premium 在过去 15 年明显衰减(因子拥挤),但仍未消失,只是 Sharpe 从 0.85 跌到 0.35 左右。


六、动量崩溃(Momentum Crashes):必须知道的尾部风险

6.1 2009 年 3 月:动量历史最惨案

2008 金融危机崩盘期间,金融股、汽车股、房地产股暴跌。到 2009 年 2 月,动量策略:

  • Long leg:医药、消费、必需品(防御性涨幅最少跌的)
  • Short leg:金融、汽车(已经被腰斩的输家)

2009 年 3 月美联储 QE + 救助方案宣布,输家股翻倍反弹

  • AIG +400%
  • Citigroup +200%
  • Ford +180%

而动量策略此时还在 short 这些。单月亏损 30%+,全年亏 53%——这是历史最差。

6.2 2020 年 3 月 COVID 类似剧本

  • COVID 急跌 2 月 - 3 月:航空、酒店、邮轮被砸到底
  • Long leg:科技、电商(涨)
  • Short leg:受冲击的旅游股
  • 4 月反弹时 Carnival/American Airlines 单月 +50%

动量在 2020 年 3-4 月再次崩盘约 -15%。

6.3 崩溃的结构性原因

**Daniel-Moskowitz 2016「Momentum Crashes」**给出形式化:

$$ \text{Crash Risk} \propto \text{Bear Market} \times \text{High Volatility} \times \text{Short Leg Beta} $$

具体:

  • 熊市后期 + 反转:经济触底信号让最差的输家变成最大反弹候选
  • Short leg beta > 1:被 short 的烂公司在反转中弹性最大,相对损失放大
  • 波动率高 → 仓位标准化下名义敞口大

启示:动量是「正常市场吃 alpha + 危机时大亏」结构。这种 negative skewness 在 Sharpe 上体现不出来(Sharpe 假设正态),所以永远要保留对冲——VIX 多头、tail-risk put、或者波动率缩放。


七、Vol-Scaled Momentum:Barroso-Santa-Clara 2015 修正

7.1 核心思路

Barroso 和 Santa-Clara 在《Journal of Financial Economics》提出非常简洁的修正:当过去波动率高时减仓

公式:

$$ w_t = \min\left(1, \ \frac{\sigma_{\text{target}}}{\hat{\sigma}_t}\right) $$

其中:

  • $\sigma_{\text{target}}$ 是目标年化波动率(论文用 12%)
  • $\hat{\sigma}_t$ 是过去 6 个月(126 个交易日)实现波动率的滚动估计
  • 上限 1 表示不加杠杆(小资金合理)

7.2 为什么有效

危机时刻波动率会暴涨——VIX 从 15 飙到 80。此时:

  • $\hat{\sigma}_t$ 变大 → $w_t$ 自动缩小
  • 仓位减半甚至减到 1/4
  • 即使动量崩溃,损失也减半

7.3 实证效果

Barroso 论文 1927-2011 数据:

版本年化SharpeMaxDD偏度
Plain Momentum14.5%0.53-77%-2.5
Vol-Scaled15.0%0.97-29%+0.5

Sharpe 从 0.53 翻倍到 0.97,MaxDD 从 -77% 降到 -29%,偏度从 -2.5 翻成正的 +0.5

代价:

  • 换手率提高(仓位频繁调)
  • 牛市末期会过早减仓损失部分上行
  • 6 个月窗口对快速崩溃反应慢

7.4 加进我们脚本

在第 8 步前加:

# Vol-scaling overlay
TARGET_VOL = 0.12
WINDOW = 6   # months
realized_vol = gross_ret.rolling(WINDOW).std() * np.sqrt(12)
vol_scale = np.minimum(1.0, TARGET_VOL / realized_vol).fillna(1.0)
gross_ret_vs = gross_ret * vol_scale.shift(1)   # shift to avoid look-ahead

注意 .shift(1)——必须用上月可观测的波动率决定本月仓位,否则前视偏差。


八、常见坑(高于平均的 9 个坑)

#后果解决
1Close 不用 Adj Close分红除权日造成虚假信号yfinance auto_adjust=True
2Look-ahead:用 t 月末 close 决定 t 月持仓实盘做不到,回测虚假高shift(-1) 对齐 fwd return
3Survivorship bias:用今天的 SP500 名单回测 10 年前过滤掉退市股 → 高估收益 2-3%用 PIT 名单(CRSP),或明确披露偏差
4月度对齐错位:日线和月线混用信号和实际价格对不上全部 resample('M').last() 后再算
5退市股突然消失回测里默默跳过,但实盘要承受被强制平仓至少用 pct_change(fill_method=None) 暴露 NaN
6中概股事件性崩盘2021 年教育股崩盘前是 momentum top → 一夜归零加单股 stop loss 或 sector cap
7月末成交价高估流动性月末 close 滑点大用 VWAP 或拆 5 天分批
8等权太理想$5K 资金 10 只股票每只买 0.5 股不可能实战只能买整股或用 fractional share 券商
9忽视交易日历月末有时落在节假日resample('BM') 用 business month end

8.1 Survivorship 影响有多大

学术界共识:用当前指数成分回测过去 10-30 年,Sharpe 高估 0.2-0.4,CAGR 高估 1-3%。原因:

  • 退市的破产股(Lehman 2008、Enron 2001)从历史里被擦除
  • 但它们当年都被 momentum 信号 short 过——回测里这部分 short 成功的 alpha 没了
  • 同时回测里也没体现这些 long position 暴雷的损失

对今天的脚本:因为我们用的是 SP100 当前名单,2014 年时 Tesla、Meta、PayPal 还不在里面,所以我们其实漏掉了它们之后的 100x 涨幅——反而低估了真实大盘股 momentum 表现。这种偏差方向不一定,所以叫「survivorship bias」时要小心方向。


九、可视化清单

看什么决策
净值曲线 vs SPY长期是否跑赢,回撤期长度是否值得做这个策略
月度热图哪些年/月集中亏损是否有季节性,是否对应宏观事件
Drawdown 时间线最大回撤多久才回血心理承受力测试
Top 10 持仓时间线哪些股长期被选中验证不是只押注一两只
因子 IC(Spearman)每月信号和下月 return 的相关性因子还活着吗

我们脚本生成前两个,剩下的留给 Day 11+ 因子组合时再做。


十、PM 视角:动量的迁移性思考

10.1 「赢家继续赢」是普世直觉

动量不是金融独有的现象,它是人类社会中所有「关注度驱动的反馈循环」的通用模式

领域动量表现
用户增长已经增长快的产品获得更多 PR、应用商店推荐、用户口碑 → 继续快
内容创作抖音/YouTube 早期数据好的视频被推流量池,正反馈
品牌建设头部品牌占据心智 → 复购更高 → 继续是头部
求职名校 → 名企 → 更好的下一份 → 个人简历自带动量
VC 投融资YC/a16z 投过 → 后续轮 FOMO → 估值动量

10 年金融 PM 经验里我观察到的:所有「关注度 + 推荐算法 + 社交证明」三者交汇的领域都有动量。

10.2 但永远要记得 momentum crashes

这是最重要的迁移性认知:

动量赚的是「正常时期」的钱,亏的是「转折点」的钱。

用户增长的转折点:竞品突然推出杀手级功能(COVID 期间 Zoom 对 GoToMeeting)、监管突然收紧(教培双减)、流量规则改变(小红书算法调整)→ 之前的赢家变输家。

作为 PM 的应对

  1. 永远问「我们的护城河是什么」:流量惯性不是护城河,网络效应、转换成本、IP 才是
  2. 永远准备 plan B:在动量最旺时投资多元化产品线
  3. 观测先行指标:竞品搜索热度上升、监管讨论增多、KOL 风向变化
  4. 资金管理:动量策略只能用整体仓位的一部分(学术建议 20-30%),剩余给反转、价值、防御

10.3 BA 视角:怎么向老板解释「不要 all-in 动量产品」

老板视角:「我们这个产品涨势这么好,加倍投入!」

回答框架(用今天学的):

  1. 承认动量真实存在——不是反对增长,是要量化风险
  2. 指出 momentum crash 概率——历史上 5-10 年总会有一次大反转
  3. 建议 sizing 控制——而不是停止投入
  4. 建议建立监控信号——什么样的先行指标会触发减仓

这个框架在金融策略和产品策略都适用——「不是不做,是分仓做」


十一、明日预告

Day 11: 价值因子 — Book-to-Market、Earnings Yield、Fama-French

  • Fama-French 1992 三因子模型怎么诞生的
  • B/M ratio 怎么定义(账面价值/市值)
  • 为什么 value 在 2010-2020 经历「死亡 10 年」
  • value vs momentum 的负相关:因子组合的天然对冲
  • yfinance 拿基本面数据的局限 + 备选方案(FMP / Sharadar)
  • 实操:写一个最小化 value factor 回测
  • value 因子的 PM 迁移:被低估的产品/团队反弹理论

十二、Day 10 实际执行 Checklist

  • (0) 读完笔记:理解 12-1 跳一个月、CSM vs TSM、momentum crash 三大核心
  • (1) 装依赖pip install yfinance pandas numpy matplotlib seaborn
  • (2) 跑脚本python momentum_backtest.py
  • (3) 看图:检查 day10_momentum_results.png 净值和热图
  • (4) 对比 SPY:写下你看到的 Sharpe 差异,思考原因
  • (5) 改参数:把 TOP_PCT 从 0.10 改到 0.20、把窗口从 11 改到 5(即 6-1),对比变化
  • (6) 加 Vol-Scale:实现第七节的修正,看 MaxDD 是否真的减半
  • (7) 思考 survivorship:列出你怀疑被偏差影响的股票
  • (8) 更新进度docs/daily/TR_PROGRESS.md Day 10 标 ✅
  • (9) 记录踩坑:本笔记最后一段补充

实际执行记录

启动一项填一项,时间戳 + 卡点。

  • [hh:mm] 跑通 yfinance 下载 — 用了多久 / 是否被限流
  • [hh:mm] 第一次回测结果 — Sharpe / CAGR / MaxDD
  • [hh:mm] 加 Vol-Scale 后对比 — MaxDD 改善幅度
  • [hh:mm] 改 TOP_PCT 到 20% — Sharpe 是升还是降,反映了什么
  • 卡点 / 学到的:
    • 比如:发现 BRK-B yfinance 拿不到,改成 BRK.B 又不对
    • 比如:2020 年 4 月那一格热图 momentum 真的崩了 -X%
    • 比如:Long-only 跑不赢 SPY 让我重新理解 long-short 的必要性

总字数:约 7,200 字 今日完成度:理论 ✓ / 代码 ✓(你跑一遍验证)/ 笔记 ✓