SEP10
TUE2024

Canto I: Momentum Basics

Comparing baselines to rt(k)=i=1kwiRtir_t^{(k)} = \sum_{i=1}^{k} w_i R_{t-i} trend signals.
algorithmic tradingcantomomentumbacktesting

Preamble

The first post where I explore market inefficiencies in the style of epic poems.

Momentum

In the flux of market tides, where shadows stretch and prices churn, the swift ascend, borne on invisible winds, while the laggards drown in time’s relentless undertow. Momentum, the unseen force, propels the chosen few to peaks uncharted, a silent gale, fierce yet intangible, guiding each ascent with whispered promise of ascent anew. Not in still waters do fortunes burgeon, but in the pulse, the surge, the ceaseless flow, where only the fleet-footed dare to thrive, and in their wake, the gains, like echoes, rise.

Logistics & Benchmark & Data

To make things simple, I'll be using this mixture of large cap tech stocks throughout:

aapl, msft, goog, amzn, tsla, nflx, meta, spy

Our benchmark that we compare our performance against is a buy and hold, long only strategy applied to the S&P 500.

Our backtesting framework will be bt. This package also grabs the data for us.

Here is the function that I'm using to run the strategies:

import bt

def compare_momentum_strategies(data: pd.DataFrame, momentum_days_list: list,
                                strategy: Callable[[pd.DataFrame, int], bt.Backtest],
                                baselines: bt.Backtest | list[bt.Backtest],
                                top_percent: float = 0.1) -> None:
    """
    Compares multiple momentum strategies with different momentum windows.

    Args:
        data (pd.DataFrame): Historical price data for the tickers.
        momentum_days_list (list): List of integers representing different momentum windows (in trading days).
        strategy (Callable): A function that takes the data and momentum_days and returns a bt.Backtest object.
        baselines (bt.Backtest | list[bt.Backtest]): A backtest to choose as a baseline
        top_percent (float): The percentage of top momentum stocks to select. Defaults to 0.1 (top 10%).

    Returns:
        None
    """
    # Create backtests for each momentum window using the provided strategy function
    backtests = [strategy(data=data, momentum_days=days, top_percent=top_percent) for days in momentum_days_list]

    match baselines:
        case bt.Backtest():
            backtests.append(baselines)
        case list():
            for baseline in baselines:
                backtests.append(baseline)
        case _:
            print("You passed the wrong datatype for baselines, ya dingus!")

    # Run all backtests
    res = bt.run(*backtests)

    # Plot the performance
    res.plot(title=f"Comparison of Strategies vs. Baselines")

    # Display performance statistics
    res.display()

    return res

Once we have our strategies, we can run them like so:

import json
    from plotting import backtest_plot

    # Example usage:
    tickers = "aapl,msft,goog,amzn,tsla,nflx,meta,spy"
    start_date = "2020-01-01"
    momentum_days_list = [1, 5, 10, 21, 42]

    # Fetch the data
    data = get_data(tickers, start_date)
    backtest_baseline = baseline_strategy()

    # Compare different momentum strategies
    for strat, title in zip(
        [momentum_strategy, volatility_adjusted_momentum_strategy],
        ["Simple Momentum", "Volatility-Adjusted Momentum"],
    ):
        res = compare_momentum_strategies(
            data, momentum_days_list, strategy=strat, baselines=backtest_baseline
        )
        fig = backtest_plot(res, f"Cumulative Returns: {title}")

Simple Momentum

Strategy

This stratagem rides the crest of price waves, measuring momentum—price shifts over chosen intervals. It selects equities that surge ahead, casting aside the laggards.

  1. Momentum:

    • Gauge the price flux over set days.
  2. Selection:

    • Rank, retain the top echelon.
  3. Rebalance:

    • Equal stakes, realigned monthly.
  4. Benchmark:

    • Compare the temporal arcs — week, fortnight, month — against the S&P’s steady course.

The aim: to harness the force of those whose ascent foretells further climb.

def momentum_strategy(tickers: str, start_date: str, momentum_days: int, top_percent: float = 0.1) -> bt.Backtest:
    """
    Creates a momentum strategy backtest with a specified momentum window.

    Args:
        tickers (str): Comma-separated list of tickers to include in the backtest.
        start_date (str): The start date for downloading stock data (YYYY-MM-DD).
        momentum_days (int): The number of trading days to calculate momentum over.
        top_percent (float): The percentage of top momentum stocks to select. Defaults to 0.1 (top 10%).

    Returns:
        bt.Backtest: The backtest for the momentum strategy.
    """
    # Download historical price data
    data = bt.get(tickers, start=start_date)

    # Calculate momentum (percentage change over the specified window)
    momentum = data.pct_change(momentum_days).shift(1)

    # Select top momentum stocks based on the specified top percentage
    signal = momentum.rank(axis=1, pct=True) > (1 - top_percent)

    # Strategy: select top momentum stocks, weigh them equally, and rebalance monthly
    strategy = bt.Strategy(
        f'{momentum_days}_day_momentum',
        [
            bt.algos.SelectWhere(signal),
            bt.algos.WeighEqually(),
            bt.algos.Rebalance()
        ]
    )

    return bt.Backtest(strategy, data)

Results

20202021202220232024020040060080010001200
Strategy1_day_momentum5_day_momentum10_day_momentum21_day_momentum42_day_momentumS&P 500Cumulative Returns: Simple MomentumDateCumulative Returns
Stat5_day10_day21_day42_dayS&P 500
Start2020-01-012020-01-012020-01-012020-01-012020-01-01
End2024-09-062024-09-062024-09-062024-09-062024-09-06
Risk-free rate0.00%0.00%0.00%0.00%0.00%
Total Return960.80%818.12%566.24%400.63%78.24%
Daily Sharpe1.261.201.070.940.68
Daily Sortino2.262.141.761.571.06
CAGR65.60%60.57%49.94%41.06%13.14%
Max Drawdown-52.41%-56.25%-56.88%-47.45%-33.71%
Calmar Ratio1.251.080.880.870.39
MTD-8.00%-8.94%-5.07%-4.52%-4.14%
3m24.23%22.83%-1.06%-2.77%1.39%
6m44.10%13.51%11.96%12.04%6.68%
YTD51.85%19.98%34.45%20.63%14.40%
1Y49.09%34.66%41.57%12.38%22.79%
3Y (ann.)38.20%27.73%24.23%24.07%7.76%
5Y (ann.)65.60%60.57%49.94%41.06%13.14%
Since Incep. (ann.)65.60%60.57%49.94%41.06%13.14%
Daily Sharpe1.261.201.070.940.68
Daily Sortino2.262.141.761.571.06
Daily Mean (ann62.85%59.64%53.04%46.99%14.68%
Daily Vol (ann.49.77%49.53%49.74%50.03%21.46%
Daily Skew0.450.41-0.240.11-0.53
Daily Kurt5.224.885.017.6911.14
Best Day19.89%19.89%13.48%23.28%9.06%
Worst Day-17.18%-17.18%-21.06%-21.06%-10.94%
Monthly Sharpe1.041.061.110.860.77
Monthly Sortino2.692.782.552.081.40
Monthly Mean (ann.)59.42%55.27%51.26%49.66%14.39%
Monthly Vol (ann.)56.93%52.24%45.99%57.51%18.75%
Monthly Skew1.160.830.261.25-0.36
Monthly Kurt2.590.97-0.205.06-0.22
Best Month60.18%49.13%32.50%74.14%12.69%
Worst Month-29.95%-25.25%-24.70%-38.59%-12.48%
Yearly Sharpe1.040.560.650.890.59
Yearly Sortino4.871.901.834.961.41
Yearly Mean37.84%43.12%34.25%22.15%12.78%
Yearly Vol36.28%76.53%52.89%24.92%21.56%
Yearly Skew-1.770.21-0.93-0.15-1.54
Yearly Kurt3.360.201.521.362.16
Best Year65.53%137.47%88.43%51.93%28.72%
Worst Year-15.54%-45.39%-37.53%-8.93%-18.17%
Avg. Drawdown-8.27%-10.38%-9.24%-10.44%-1.99%
Avg. Drawdown Days53.1351.3941.0049.8418.76
Avg. Up Month14.22%14.22%13.71%14.74%4.55%
Avg. Down Month-7.41%-7.31%-6.62%-8.10%-4.83%
Win Year %75.00%75.00%75.00%75.00%75.00%
Win 12m %82.61%76.09%76.09%82.61%76.09%

Volatility-Weighted Momentum

Strategy

The same as above, but we divide the momentum factor by rolling volatility. In this case we use 21 days for volatility. We could do all combinations as well pretty easily (exercise for the reader perhaps).

def volatility_adjusted_momentum_strategy(data: pd.DataFrame, momentum_days: int, volatility_days: int = 21, top_percent: float = 0.1) -> bt.Backtest:
    """
    Creates a volatility-adjusted momentum strategy backtest with a specified momentum window.

    Args:
        data (pd.DataFrame): Historical price data for the tickers.
        momentum_days (int): The number of trading days to calculate momentum over.
        volatility_days (int): The number of days to calculate volatility over. Defaults to 21 (approximately 1 month).
        top_percent (float): The percentage of top momentum stocks to select. Defaults to 0.1 (top 10%).

    Returns:
        bt.Backtest: The backtest for the volatility-adjusted momentum strategy.
    """
    # Ensure volatility_days is a valid integer
    if not isinstance(volatility_days, int) or volatility_days <= 0:
        raise ValueError("volatility_days must be a positive integer.")

    # Calculate momentum (percentage change over the specified window)
    momentum = data.pct_change(momentum_days).shift(1)

    # Calculate rolling volatility (standard deviation of percentage changes)
    volatility = data.pct_change().rolling(volatility_days).std()

    # Adjust momentum by volatility
    vol_adjusted_momentum = momentum / volatility

    # Select top momentum stocks based on the specified top percentage
    signal = vol_adjusted_momentum.rank(axis=1, pct=True) > (1 - top_percent)

    # Strategy: select top momentum stocks, weigh them equally, and rebalance monthly
    strategy = bt.Strategy(
        f'{momentum_days}_day_vol_adj_momentum',
        [
            bt.algos.SelectWhere(signal),
            bt.algos.WeighEqually(),
            bt.algos.Rebalance()
        ]
    )

    return bt.Backtest(strategy, data)
20202021202220232024100200300400500600
Strategy1_day_vol_adj_momentum5_day_vol_adj_momentum10_day_vol_adj_momentum21_day_vol_adj_momentum42_day_vol_adj_momentumS&P 500Cumulative Returns: Volatility-Adjusted MomentumDateCumulative Returns
Stat5_day10_day21_day42_dayS&P 500
Start2020-01-012020-01-012020-01-012020-01-012020-01-01
End2024-09-062024-09-062024-09-062024-09-062024-09-06
Risk-free rate0.00%0.00%0.00%0.00%0.00%
Total Return381.27%190.81%262.58%163.80%78.24%
Daily Sharpe0.970.730.820.690.68
Daily Sortino1.691.201.361.141.06
CAGR39.88%25.61%31.67%23.02%13.14%
Max Drawdown-49.71%-60.95%-59.13%-52.05%-33.71%
Calmar Ratio0.800.420.540.440.39
MTD-8.00%-8.94%-5.07%-4.52%-4.14%
3m-2.30%24.82%-14.06%9.98%1.39%
6m34.06%50.71%-5.82%24.59%6.68%
YTD33.88%45.54%28.21%59.63%14.40%
1Y32.25%59.57%20.35%72.00%22.79%
3Y (ann.)26.01%11.82%27.23%37.24%7.76%
5Y (ann.)39.88%25.61%31.67%23.02%13.14%
Since Incep. (ann.)39.88%25.61%31.67%23.02%13.14%
Daily Sharpe0.970.730.820.690.68
Daily Sortino1.691.201.361.141.06
Daily Mean (ann.)43.81%33.16%38.63%30.72%14.68%
Daily Vol (ann.)45.24%45.38%47.16%44.77%21.46%
Daily Skew0.350.000.380.49-0.53
Daily Kurt6.365.718.3411.5511.14
Best Day16.85%15.31%23.28%23.28%9.06%
Worst Day-17.18%-17.18%-17.18%-18.58%-10.94%
Monthly Sharpe0.910.740.810.690.77
Monthly Sortino1.981.551.731.221.40
Monthly Mean (ann.)46.21%31.96%39.01%33.27%14.39%
Monthly Vol (ann.)50.53%43.31%48.43%48.48%18.75%
Monthly Skew0.340.470.42-0.32-0.36
Monthly Kurt0.611.351.063.17-0.22
Best Month37.89%40.42%38.95%38.27%12.69%
Worst Month-33.03%-26.98%-35.41%-49.06%-12.48%
Yearly Sharpe0.840.540.670.950.59
Yearly Sortino3.171.152.263.461.41
Yearly Mean34.05%30.89%40.42%40.98%12.78%
Yearly Vol40.60%57.55%60.43%43.32%21.56%
Yearly Skew-1.02-1.78-0.38-1.94-1.54
Yearly Kurt1.473.290.203.822.16
Best Year74.38%72.47%108.07%68.56%28.72%
Worst Year-21.47%-53.85%-35.74%-23.69%-18.17%
Avg. Drawdown-8.34%-9.12%-7.51%-7.49%-1.99%
Avg. Drawdown Days52.6359.1537.5446.9118.76
Avg. Up Month13.44%10.33%12.08%11.06%4.55%
Avg. Down Month-7.21%-7.56%-7.69%-8.28%-4.83%
Win Year %75.00%75.00%75.00%75.00%75.00%
Win 12m %80.43%73.91%78.26%76.09%76.09%

Results

If we look just at total return:

Simple Momentum:

Stat5_day10_day21_day42_dayS&P 500
Total Return960.80%818.12%566.24%400.63%78.24%

Volatility-Adjusted Momentum:

Stat5_day10_day21_day42_dayS&P 500
Total Return381.27%190.81%262.58%163.80%78.24%

It makes sense that Volatility-Adjusted momentum would have lesser returns. My intuition is that momentum thrives off of volatility, and in our case we were dampening the momentum signal by dividing by volatility.

If we go back and change the division to a multiplication, having volatility amplify momentum, we get much different results:

Stat5_day10_day21_day42_dayS&P 500
Total Return743.87%576.43%1221.59%430.94%78.24%
20202021202220232024050010001500
Strategy1_day_vol_adj_momentum5_day_vol_adj_momentum10_day_vol_adj_momentum21_day_vol_adj_momentum42_day_vol_adj_momentumS&P 500Cumulative Returns: Volatility-Adjusted MomentumDateCumulative Returns