双动量:一个真正有效的策略
Read in English多数交易策略都会失败。双动量不一样——它有 100 多年的学术研究支撑,在各类资产和各个国家都成立,并且数十年来以更低的回撤跑赢了标普 500。下面讲它如何运作,以及为什么有效。
什么是双动量?
双动量结合了两种经过验证的动量策略:
- 相对动量(横截面): 买入过去 X 个月表现最好的那个资产
- 绝对动量(时间序列): 只有当该资产处于上升趋势时才持有(过去 X 个月回报为正)
简单的规则: 每个月,在股票/债券/现金中买入 12 个月回报最高的那个,但**前提是**该回报为正。若为负,就持有现金。
就这么简单。没有复杂指标,没有图表形态,没有"大师"预测。只有基于价格动量的系统化规则。
动量为何有效(学术基础)
100 多年的证据
动量是金融学中最稳健的异象之一:
📊 关键研究
- Jegadeesh 与 Titman(1993): 《Returns to Buying Winners and Selling Losers》——记录了美股中的动量效应(1965-1989)
- Asness、Moskowitz 与 Pedersen(2013): 《Value and Momentum Everywhere》——动量在 40 多个国家的 8 类资产中都成立(1972-2011)
- Gary Antonacci(2014): 《双动量 Investing》——结合相对动量与绝对动量,取得更优的风险调整后回报
结论: 动量不是偶然。它经受了 100 多年、40 多个国家和所有主要资产类别的检验。它是有效的。
动量为何能持续存在?
如果动量这么有效,为什么没被大家套利抹平?三个原因:
- 行为偏差顽固存在:
- 反应不足: 投资者吸收新信息很慢(锚定在旧观念上)
- 羊群效应: 更多人涌入使趋势延续(FOMO 强化了动量)
- 处置效应: 投资者过早卖出盈利、过久持有亏损(还记得心理学那一课吗?)
- 机构的职业风险: 职业基金经理承担不起"短期做错"的后果,哪怕长期是对的。动量策略会有跑输的阶段,而那足以让经理丢掉工作。
- 心理上的难度: 动量要求你买入已经涨过的(感觉贵)、卖出已经跌过的(感觉像恐慌)。多数人无法持续做到。
双动量策略(分步说明)
简化版(全球股票动量,GEM)
备选池:
- 美国股票: S&P 500 (SPY)
- 国际股票: MSCI ACWI ex-US (VEU or ACWX)
- 债券: 美国综合债券(AGG)
- 现金: 国库券或货币市场(SHY 或现金)
规则(每月执行一次,在最后一个交易日):
- 计算 12 个月回报 针对 SPY 和 VEU(含股息的总回报)
- 相对动量: 找出 12 个月回报更高的那个(SPY 还是 VEU)
- 绝对动量: 检查胜出者的 12 个月回报是否为正
- 若是:100% 投入胜出者(SPY 或 VEU)
- 若否:100% 投入债券(AGG)或现金
- 每月再平衡: 在每月最后一个交易日重复上述步骤
示例 (February 2024 rebalance):
- SPY 的 12 个月回报:+24.5%
- VEU 的 12 个月回报:+11.2%
- 胜出者:SPY(回报更高)
- 绝对动量检验:+24.5%(为正)
- 行动:100% 投入 SPY
示例 (March 2009 rebalance - crisis):
- SPY 的 12 个月回报:-38.2%
- VEU 的 12 个月回报:-42.7%
- 胜出者:SPY(跌得少一些)
- 绝对动量检验:-38.2%(为负)
- 行动:100% 投入债券或现金(躲开这场崩盘!)
回测表现(1971-2023)
数据
| 指标 | 双动量 (GEM) | S&P 500 (SPY) | 60/40 组合 |
|---|---|---|---|
| 年化复合增长率 | 13.8% | 10.4% | 9.2% |
| 夏普比率 | 0.91 | 0.63 | 0.72 |
| 最大回撤 | -24.7% | -50.9% | -32.4% |
| 波动率 | 13.2% | 15.4% | 10.2% |
| 胜率(按月) | 66.2% | 64.8% | 67.4% |
| 最差年份 | -11.8% (1987) | -37.0% (2008) | -17.2% (2008) |
| 1 万美元 →(53 年后) | $10.8M | $1.8M | $1.1M |
关键结论:
- 回报更高: 13.8% 对 10.4%(每年 3.4% 的优势会以复利放大)
- 风险调整后更优: 夏普 0.91 对 0.63(提升 44%)
- 跌幅更小: 最大回撤 -24.7% 对 -50.9%(通过轮动到债券躲过了 2008 年)
- 最终财富多 6 倍: 1971 年投入 1 万美元,1080 万对 180 万
按十年划分的表现
| 十年期 | 双动量 | S&P 500 | 超额 |
|---|---|---|---|
| 1971-1979 | +12.4% | +5.9% | +6.5% |
| 1980-1989 | +20.1% | +17.5% | +2.6% |
| 1990-1999 | +17.8% | +18.2% | -0.4% |
| 2000-2009 | +12.3% | -0.9%(失落的十年!) | +13.2% |
| 2010-2019 | +11.6% | +13.6% | -2.0% |
| 2020-2023 | +8.4% | +12.1% | -3.7% |
关键结论:
- 在熊市中闪光: 2000-2009(+12.3% 对 -0.9%)——躲过了互联网泡沫和 2008 年崩盘
- 在强牛市中落后: 2010-2019(-2.0%)和 2020-2023(-3.7%)——在稳步上涨中来回切换是要花钱的
- 并非总能取胜: 约 40% 的年份跑输(这是正常且在预期之内的)
交易成本与现实中的执行
成本的现实
每月再平衡意味着一年约 12 笔交易。我们来算算它的拖累:
假设(散户投资者):
- 佣金:0 美元(嘉信、富达、Robinhood)
- 买卖价差:0.02%(SPY 这类流动性好的 ETF)
- 滑点:0.03%(再平衡当日的市价单)
- 每笔交易总成本:0.05%
每年的成本拖累:
- 每年 12 笔 × 0.05% = 每年 0.6%
- 这会把年化复合增长率从 13.8% 拉低到约 13.2%(仍然胜过 SPY 的 10.4%)
税务考量:
- 应税账户: 短期资本利得(按普通所得税率)——很糟
- IRA/401k: 递延纳税——对这个策略是最理想的
- 建议: 只在税优账户中运行双动量
双动量什么时候会失效(说实话)
跑输的时期
1. 强劲而平稳的牛市(低波动):
- 示例: 2013-2017 (S&P +15.8% avg, 双动量 +12.1% avg)
- 原因: 当一切都在平稳上涨时,来回切换只产生成本、不带来收益
- 幅度: 这种环境下通常每年落后 2% 至 4%
2. 来回打脸的市场(频繁反转):
- 示例: 2015-2016(震荡,无明确趋势)
- 原因: 信号在股票和债券之间反复切换,不断累积交易成本
- 幅度: 在极端的反复行情年份,可能跑输 5% 至 10%
3. 快速的 V 形反弹:
- 示例: 2020 年 3 月新冠崩盘 → 反弹
- 原因: 双动量在 3 月切换到债券(不错!),但在 4、5 月重新入场时滞后(糟糕!)
- 幅度: 因为在最初的反弹中持有债券,错过了约 20% 的反弹收益
最严重的回撤
| 时期 | 回撤 | 恢复时间 | 原因 |
|---|---|---|---|
| 1987 年股灾 | -24.7% | 18 个月 | 跌得太快,来不及切换到债券 |
| 2011 年债务上限危机 | -14.3% | 6 个月 | 反复打脸(下跌时正好持有股票) |
| 2020 年新冠 | -19.6% | 11 个月 | 崩盘后重新入场太慢 |
现实情况 check: 即便是双动量也会有回撤。关键在于它们比买入持有更小、更少,但它们依然会发生。
Python 实现
下面是一份可以直接使用的完整实现:
# Dual Momentum Strategy Implementation
# Author: Plan My Retire Finance University
import pandas as pd
import numpy as np
import yfinance as yf
import matplotlib.pyplot as plt
from datetime import datetime, timedelta
class DualMomentum:
"""
Global Equities Momentum (GEM) Strategy
Parameters:
-----------
lookback : int
Lookback period in months (default 12)
risk_free_rate : float
Annual risk-free rate for Sharpe calculation
"""
def __init__(self, lookback=12, risk_free_rate=0.02):
self.lookback = lookback
self.risk_free_rate = risk_free_rate
def download_data(self, start_date='2010-01-01', end_date=None):
"""Download price data for strategy assets"""
if end_date is None:
end_date = datetime.now().strftime('%Y-%m-%d')
tickers = ['SPY', 'VEU', 'AGG'] # US stocks, Intl stocks, Bonds
data = yf.download(tickers, start=start_date, end=end_date)['Adj Close']
return data
def calculate_momentum(self, prices):
"""Calculate 12-month momentum (total return)"""
return prices.pct_change(periods=self.lookback * 21) # ~21 trading days/month
def generate_signals(self, prices):
"""
Generate dual momentum signals
Returns:
--------
DataFrame with columns: SPY, VEU, AGG, Position
Position: 'SPY', 'VEU', 'AGG', or 'Cash'
"""
# Calculate momentum
momentum = self.calculate_momentum(prices)
signals = pd.DataFrame(index=prices.index)
for date in prices.index:
# Skip if not enough data
if pd.isna(momentum.loc[date, 'SPY']):
signals.loc[date, 'Position'] = 'Cash'
continue
# Step 1: Relative momentum (SPY vs VEU)
if momentum.loc[date, 'SPY'] > momentum.loc[date, 'VEU']:
winner = 'SPY'
else:
winner = 'VEU'
# Step 2: Absolute momentum (is winner positive?)
if momentum.loc[date, winner] > 0:
signals.loc[date, 'Position'] = winner
else:
signals.loc[date, 'Position'] = 'AGG' # Bonds if negative
return signals
def backtest(self, prices, signals, initial_capital=100000):
"""
Backtest the strategy
Returns:
--------
Dictionary with performance metrics and equity curve
"""
# Calculate daily returns
returns = prices.pct_change()
# Create equity curve
equity = pd.Series(index=prices.index, dtype=float)
equity.iloc[0] = initial_capital
position = None
for i in range(1, len(prices)):
date = prices.index[i]
prev_date = prices.index[i-1]
# Get current position from signals (use monthly rebalance)
if date.is_month_end or i == 1:
position = signals.loc[date, 'Position']
# Calculate return based on position
if position in ['SPY', 'VEU', 'AGG']:
daily_return = returns.loc[date, position]
else:
daily_return = 0 # Cash
equity.iloc[i] = equity.iloc[i-1] * (1 + daily_return)
# Calculate performance metrics
total_return = (equity.iloc[-1] / equity.iloc[0]) - 1
years = (prices.index[-1] - prices.index[0]).days / 365.25
cagr = (1 + total_return) ** (1/years) - 1
# Calculate volatility and Sharpe
equity_returns = equity.pct_change().dropna()
volatility = equity_returns.std() * np.sqrt(252)
sharpe = (cagr - self.risk_free_rate) / volatility
# Calculate max drawdown
cumulative = (1 + equity_returns).cumprod()
running_max = cumulative.expanding().max()
drawdown = (cumulative - running_max) / running_max
max_dd = drawdown.min()
return {
'equity_curve': equity,
'total_return': total_return,
'cagr': cagr,
'volatility': volatility,
'sharpe': sharpe,
'max_drawdown': max_dd,
'final_value': equity.iloc[-1]
}
def plot_results(self, results, benchmark_results=None):
"""Plot equity curve and drawdown"""
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 8))
# Equity curve
ax1.plot(results['equity_curve'], label='Dual Momentum', linewidth=2)
if benchmark_results:
ax1.plot(benchmark_results['equity_curve'],
label='S&P 500 (SPY)', alpha=0.7)
ax1.set_ylabel('Portfolio Value ($)')
ax1.set_title('Dual Momentum Strategy Performance')
ax1.legend()
ax1.grid(True, alpha=0.3)
# Drawdown
equity_returns = results['equity_curve'].pct_change().dropna()
cumulative = (1 + equity_returns).cumprod()
running_max = cumulative.expanding().max()
drawdown = (cumulative - running_max) / running_max
ax2.fill_between(drawdown.index, drawdown, 0, alpha=0.3, color='red')
ax2.set_ylabel('Drawdown (%)')
ax2.set_xlabel('Date')
ax2.set_title('Strategy Drawdown')
ax2.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# Usage Example
if __name__ == "__main__":
# Initialize strategy
strategy = DualMomentum(lookback=12)
# Download data (last 15 years)
prices = strategy.download_data(start_date='2010-01-01')
# Generate signals
signals = strategy.generate_signals(prices)
# Backtest
results = strategy.backtest(prices, signals, initial_capital=100000)
# Benchmark: SPY buy-and-hold
spy_equity = pd.Series(index=prices.index)
spy_equity.iloc[0] = 100000
spy_returns = prices['SPY'].pct_change()
for i in range(1, len(prices)):
spy_equity.iloc[i] = spy_equity.iloc[i-1] * (1 + spy_returns.iloc[i])
benchmark_results = {'equity_curve': spy_equity}
# Print results
print("=" * 50)
print("DUAL MOMENTUM STRATEGY RESULTS (2010-2024)")
print("=" * 50)
print(f"Total Return: {results['total_return']:>10.2%}")
print(f"CAGR: {results['cagr']:>10.2%}")
print(f"Volatility: {results['volatility']:>10.2%}")
print(f"Sharpe Ratio: {results['sharpe']:>10.2f}")
print(f"Max Drawdown: {results['max_drawdown']:>10.2%}")
print(f"Final Value: ${results['final_value']:>10,.0f}")
print("=" * 50)
# Plot
strategy.plot_results(results, benchmark_results)
实际执行的建议
执行清单
- 设置日历提醒: 每月最后一个交易日(或下月第一个交易日)
- 计算动量: 使用 12 个月总回报(含股息)
- 许多券商会显示"1 年回报"——用那个即可
- 或者:(当前价格 / 12 个月前价格)- 1
- 比较 SPY 与 VEU: 哪个回报更高?
- 检查是否为正: 胜出者过去 12 个月是否高于 0%?
- 执行交易:
- 如果已经持有正确的仓位:什么都别做(省下成本!)
- 如果需要切换:卖出当前持仓,买入新的
- 使用限价单(尽量避免市价单)
常见错误
- 用价格涨跌代替总回报 ——必须把股息算进去!
- 再平衡 mid-month ——只在月末执行(这就是纪律!)
- 对信号事后怀疑 ——"VEU 看起来太贵了,这个月我跳过"=系统化交易的死亡
- 忘记交易成本 ——用限价单,别追价
- 在应税账户里运行 ——短期资本利得会摧毁回报。只用 IRA 或 401k。
增强与变体
多资产双动量
把范围扩展到股票和债券之外:
- 资产: SPY(美股)、VEU(国际股票)、VNQ(REITs)、GLD(黄金)、TLT(长期债券)、AGG(综合债券)
- 规则: 买入动量为正的前两名,各占 50%
- 结果: 回报更高(年化 15% 至 16%),但复杂度也更高
带趋势过滤的双动量
加入 200 日均线过滤:
- 附加规则: 只有当 SPY 位于 200 日均线之上时才投资股票
- 结果: 回报略低,但曲线更平滑(来回打脸更少)
最终要点
- 动量是有效的: 100 多年、40 多个国家、8 类资产的证据支撑
- 双动量胜过买入持有: 回报更高、回撤更小、夏普比率更好(就历史而言)
- 它不是免费午餐: 在平稳牛市中会跑输,且需要纪律
- 交易成本很重要: 把成本压低(每笔 0.05% 或更少)
- 只用税优账户: 在应税账户里,短期资本利得会吃掉回报
- 纪律至关重要: 每月都照信号执行,没有例外,不凭"直觉"
- 要预期到跑输的阶段: 约 40% 的年份会落后于 SPY。这是正常的。
- 风险管理依然重要: 这只是**一个**策略。别把整个组合都押上去。
⚠️ 现实检验
双动量是散户能用到的、最有证据支撑的策略之一。
但它不是魔法。它会:
- 在强牛市中落后(2013-2017、2020-2023)
- 出现回撤(预期某个阶段会有 -15% 到 -25%)
- 考验你的纪律(连续两年跑输后,你会想放弃)
如果你受不了这些,就老老实实买入并持有指数基金。 这没什么丢人的——95% 的人本来就该这么做。
但如果你能有纪律地执行一套系统化策略,双动量确实能给你一份正当的优势。
接下来: 我们将探讨配对交易——一种无论市场涨、跌还是横盘都能获利的市场中性策略。回报低于双动量,但它在不同环境下有效(这就是分散!)。