动量因子完整回测 — 12-1 Momentum on SP500
Jegadeesh-Titman 1993 论文核心、12-1 跳一个月的原理、截面 vs 时间序列动量、动量崩溃机制、波动率缩放修正
日期: 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 一巴掌。他们的方法极其简单:
- 取 NYSE + AMEX 股票,1965-1989
- 每月把所有股票按过去 J 个月(J ∈ {3, 6, 9, 12})累计 return 排序
- 买入 top decile(前 10%),卖空 bottom decile,等权
- 持有 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-1 | t-12 → t-1 | 标准学术版 |
| 6-1 | t-6 → t-1 | 更敏捷,换手更高 |
| 9-1 | t-9 → t-1 | AQR 偏好 |
| 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 Premia | Man 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)
| 策略 | CAGR | Vol | Sharpe | MaxDD |
|---|---|---|---|---|
| 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 没赢?
三个原因:
- SP100 太集中:本来就是大盘成长股权重最高,Magnificent 7 已经在 SPY 里。在 100 只大盘股里 long top 10,多样性低。
- 2014-2024 是大盘股特殊时代:FAANG/Magnificent 7 长期统治,导致 SPY 自身就在「跑动量」。如果用全 SP500 + 中小盘,spread 会拉开。
- 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 Annualized | Sharpe |
|---|---|---|
| 1965-2024 | 7.8% | 0.55 |
| 1990-2007(黄金期) | 11.2% | 0.85 |
| 2009 单年 | -53% | 大崩溃 |
| 2010-2024 | 4.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 数据:
| 版本 | 年化 | Sharpe | MaxDD | 偏度 |
|---|---|---|---|---|
| Plain Momentum | 14.5% | 0.53 | -77% | -2.5 |
| Vol-Scaled | 15.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 个坑)
| # | 坑 | 后果 | 解决 |
|---|---|---|---|
| 1 | 用 Close 不用 Adj Close | 分红除权日造成虚假信号 | yfinance auto_adjust=True |
| 2 | Look-ahead:用 t 月末 close 决定 t 月持仓 | 实盘做不到,回测虚假高 | shift(-1) 对齐 fwd return |
| 3 | Survivorship 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 的应对:
- 永远问「我们的护城河是什么」:流量惯性不是护城河,网络效应、转换成本、IP 才是
- 永远准备 plan B:在动量最旺时投资多元化产品线
- 观测先行指标:竞品搜索热度上升、监管讨论增多、KOL 风向变化
- 资金管理:动量策略只能用整体仓位的一部分(学术建议 20-30%),剩余给反转、价值、防御
10.3 BA 视角:怎么向老板解释「不要 all-in 动量产品」
老板视角:「我们这个产品涨势这么好,加倍投入!」
回答框架(用今天学的):
- 承认动量真实存在——不是反对增长,是要量化风险
- 指出 momentum crash 概率——历史上 5-10 年总会有一次大反转
- 建议 sizing 控制——而不是停止投入
- 建议建立监控信号——什么样的先行指标会触发减仓
这个框架在金融策略和产品策略都适用——「不是不做,是分仓做」。
十一、明日预告
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.mdDay 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 字 今日完成度:理论 ✓ / 代码 ✓(你跑一遍验证)/ 笔记 ✓