双动量:一个真正有效的策略

Read in English

多数交易策略都会失败。双动量不一样——它有 100 多年的学术研究支撑,在各类资产和各个国家都成立,并且数十年来以更低的回撤跑赢了标普 500。下面讲它如何运作,以及为什么有效。

📚 预备知识

在深入策略之前,请先读过:

什么是双动量?

双动量结合了两种经过验证的动量策略:

  1. 相对动量(横截面): 买入过去 X 个月表现最好的那个资产
  2. 绝对动量(时间序列): 只有当该资产处于上升趋势时才持有(过去 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 多个国家和所有主要资产类别的检验。它是有效的。

动量为何能持续存在?

如果动量这么有效,为什么没被大家套利抹平?三个原因:

  1. 行为偏差顽固存在:
    • 反应不足: 投资者吸收新信息很慢(锚定在旧观念上)
    • 羊群效应: 更多人涌入使趋势延续(FOMO 强化了动量)
    • 处置效应: 投资者过早卖出盈利、过久持有亏损(还记得心理学那一课吗?)
  2. 机构的职业风险: 职业基金经理承担不起"短期做错"的后果,哪怕长期是对的。动量策略会有跑输的阶段,而那足以让经理丢掉工作。
  3. 心理上的难度: 动量要求你买入已经涨过的(感觉贵)、卖出已经跌过的(感觉像恐慌)。多数人无法持续做到。

双动量策略(分步说明)

简化版(全球股票动量,GEM)

备选池:

  • 美国股票: S&P 500 (SPY)
  • 国际股票: MSCI ACWI ex-US (VEU or ACWX)
  • 债券: 美国综合债券(AGG)
  • 现金: 国库券或货币市场(SHY 或现金)

规则(每月执行一次,在最后一个交易日):

  1. 计算 12 个月回报 针对 SPY 和 VEU(含股息的总回报)
  2. 相对动量: 找出 12 个月回报更高的那个(SPY 还是 VEU)
  3. 绝对动量: 检查胜出者的 12 个月回报是否为正
    • 若是:100% 投入胜出者(SPY 或 VEU)
    • 若否:100% 投入债券(AGG)或现金
  4. 每月再平衡: 在每月最后一个交易日重复上述步骤

示例 (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)

实际执行的建议

执行清单

  1. 设置日历提醒: 每月最后一个交易日(或下月第一个交易日)
  2. 计算动量: 使用 12 个月总回报(含股息)
    • 许多券商会显示"1 年回报"——用那个即可
    • 或者:(当前价格 / 12 个月前价格)- 1
  3. 比较 SPY 与 VEU: 哪个回报更高?
  4. 检查是否为正: 胜出者过去 12 个月是否高于 0%?
  5. 执行交易:
    • 如果已经持有正确的仓位:什么都别做(省下成本!)
    • 如果需要切换:卖出当前持仓,买入新的
    • 使用限价单(尽量避免市价单)

常见错误

  • 用价格涨跌代替总回报 ——必须把股息算进去!
  • 再平衡 mid-month ——只在月末执行(这就是纪律!)
  • 对信号事后怀疑 ——"VEU 看起来太贵了,这个月我跳过"=系统化交易的死亡
  • 忘记交易成本 ——用限价单,别追价
  • 在应税账户里运行 ——短期资本利得会摧毁回报。只用 IRA 或 401k。

增强与变体

多资产双动量

把范围扩展到股票和债券之外:

  • 资产: SPY(美股)、VEU(国际股票)、VNQ(REITs)、GLD(黄金)、TLT(长期债券)、AGG(综合债券)
  • 规则: 买入动量为正的前两名,各占 50%
  • 结果: 回报更高(年化 15% 至 16%),但复杂度也更高

带趋势过滤的双动量

加入 200 日均线过滤:

  • 附加规则: 只有当 SPY 位于 200 日均线之上时才投资股票
  • 结果: 回报略低,但曲线更平滑(来回打脸更少)

最终要点

  1. 动量是有效的: 100 多年、40 多个国家、8 类资产的证据支撑
  2. 双动量胜过买入持有: 回报更高、回撤更小、夏普比率更好(就历史而言)
  3. 它不是免费午餐: 在平稳牛市中会跑输,且需要纪律
  4. 交易成本很重要: 把成本压低(每笔 0.05% 或更少)
  5. 只用税优账户: 在应税账户里,短期资本利得会吃掉回报
  6. 纪律至关重要: 每月都照信号执行,没有例外,不凭"直觉"
  7. 要预期到跑输的阶段: 约 40% 的年份会落后于 SPY。这是正常的。
  8. 风险管理依然重要: 这只是**一个**策略。别把整个组合都押上去。

⚠️ 现实检验

双动量是散户能用到的、最有证据支撑的策略之一。

但它不是魔法。它会:

  • 在强牛市中落后(2013-2017、2020-2023)
  • 出现回撤(预期某个阶段会有 -15% 到 -25%)
  • 考验你的纪律(连续两年跑输后,你会想放弃)

如果你受不了这些,就老老实实买入并持有指数基金。 这没什么丢人的——95% 的人本来就该这么做。

但如果你能有纪律地执行一套系统化策略,双动量确实能给你一份正当的优势。

接下来: 我们将探讨配对交易——一种无论市场涨、跌还是横盘都能获利的市场中性策略。回报低于双动量,但它在不同环境下有效(这就是分散!)。