Sector Momentum: Explained & Backtested

Sector momentum is a sector rotation strategy to boost performance by ranking sectors according to their momentum, buying top performers, and selling laggards.

In this post, I describe sector momentum and why it works and backtest an algorithmic sector rotational strategy in Backtrader. The goal is not to provide an exhaustive, statistically significant analysis of sector momentum rotation strategies but to empower ambitious readers to be able to test their algorithmic trading strategies.

What is Sector Momentum Rotation Strategy?

There are 11 stock sectors that group businesses based on their products or services. Historically, each sector performs differently based upon where we are at in the business cycle. The idea behind a momentum rotation strategy is to rank each sector, using momentum, buy the best performing sectors and optionally short the laggards. This is called a “top N” sector rotation strategy using momentum as its quantitative signal.

The primary variables in a top N momentum rotation strategy are:

  • The momentum calculation. I use a 90-day slope calculation
  • The lookback window. I use the 20-year period 1999-2018
  • The trading strategy. I go long only
  • The ranking percentage. We will optimize
  • The number of assets. We will optimize

The implicit assumption with momentum strategies is that trends will persist, and we can take advantage of the momentum anomaly.

Why Does Momentum Work?

The momentum anomaly is well documented and pervasive. It’s usually explained as behavioral investing mistakes such as under-reaction, investor herding/FOMO, and confirmation bias. It’s especially prevalent in Industry momentum, as described by Tobias J. Moskowitz and Mark Grinblatt.

If you’re unfamiliar with the momentum anomaly, I suggest you watch the talk by Dr. Wes Gray on Momentum Investing.

YouTube video

Sector Momentum Strategy

For the example, I take the following indices’ daily close price data, adjusted for dividends and buybacks, for the years 1999 through 2018. I removed the Real Estate and Communications sectors as they were both recently formed. For a production strategy, you likely would want to analyze the existing XLC and XLRE companies and create a data set replicating what performance would have been for a more extended time period.

The data is provided by Intrinio, which is one of my favorite securities data providers.

TickerName
SPYSSGA SPDR S&P 500
XLCCommunication Services Select Sector SPDR Fund
XLPSSgA Consumer Staples Select Sector SPDR
XLYSSgA Consumer Discretionary Select Sector SPDR
XLESSgA Energy Select Sector SPDR
XLFSSgA Financial Select Sector SPDR
XLVSSgA Health Care Select Sector SPDR
XLISSgA Industrial Select Sector SPDR
XLBSSgA Materials Select Sector SPDR
XLKSSgA Technology Select Sector SPDR
XLUSSgA Utilities Select Sector SPDR

I then rank each sector based on its momentum. Instead of just ranking based upon returns, I use a linear regression based upon the slope of the line over the previous 90 days and annualize it. Rebalancing occurs on the first of every month.

Optimizing The Strategy

There’s plenty of information on the web regarding curve fitting and over-optimization. We’ll analyze the matrix of 50, 100, 150, 200, and 250-day momentum periods with the number of ETFs.

The Optimal Momentum Period

For the twenty years ending in 2018, the 150-day momentum period was the best.

The Best Number of Holdings

While we speak nothing of volatility and drawdowns, nine was the only holding count that showed up more than once.

The Sector Momentum Results

The SPY returned 168%, meaning 10,000 in starting capital would turn into 26,800.

Only about 22%, or 10 of the above 45 tests in the 20 years ending in 2018, did not beat the SPY benchmark.

Again, while many scholarly papers suggest that sector momentum works, getting more granular shows better performance. You can easily modify the following program to use industries instead of sectors.

The Optimization

A starting capital of $10,000.

Momentum PeriodNumber of HoldingsValue
50114025.53
50219483.49
50335866.58
50429206.05
50630055.37
50728154.98
50936940.14
50525608.29
50835946.76
100226911.97
100330009.83
100115601.72
100439984.76
100544146.40
100633990.44
100935862.82
100741110.49
150125366.22
150233968.55
150355104.87
150442536.01
150538116.67
100834032.96
150640125.33
150738413.02
150935787.82
150832763.17
200223756.33
200335165.14
200133424.86
200432359.93
200635351.41
200535648.14
200730516.58
200834453.88
200936586.48
250323196.63
250124591.44
250734139.63
250220700.87
250933503.18
250528150.97
250422896.26
250629603.19
250831928.43

Algorithmic Sector Momentum Backtrader Strategy

I created this strategy in Backtrader using data from my custom PostgreSQL securities database. The goal here isn’t to be an exhaustive resource on sector momentum but to empower you to be able to test research papers and the strategies they recommend.

If you’re new to Backtrader, please read my post, Backtrader: Getting Started. The Backtrader documentation and community are excellent. The inspiration for the post was to add some functionality to the momentum strategy.

If you want to run an optimization, use cerebro.optstrategy with the optimization parameters instead of cerebro.addstrategy. In the example of this post, the following would be run:

cerebro.optstrategy(Strategy, momentum_period=range(50,300,50), num_positions=range(1,len(ETF_TICKERS) + 1))

First, we get all of the imports and data models. You can find the custom modules, including this code, on the Analyzing Alpha Github.

import os, sys
import pandas as pd
import numpy as np
import backtrader as bt
import setup_psql_environment
from models import Security, SecurityPrice
from scipy.stats import linregress
from collections import defaultdict
from tabulate import tabulate
import PyQt5
import matplotlib

matplotlib.use('Qt5Agg')
import matplotlib.pyplot as plt
import backtrader.plot
from matplotlib.pyplot import figure

etf_tickers = ['XLB', 'XLE', 'XLF', 'XLI', 'XLK', 'XLP', 'XLU', 'XLV', 'XLY']

I then define a momentum function that returns an annualized slope of log returns as described in Stocks on the Move by Andreas Clenow. Without getting too in-depth, the slope equation favors less volatile slopes. We create a declarative indicator, which can be beneficial if we have a lot of complex code as we can create it as a module and import it.

def momentum_func(self, price_array):
    r = np.log(price_array)
    slope, _, rvalue, _, _ = linregress(np.arange(len(r)), r)
    annualized = (1 + slope) ** 252
    return (annualized * (rvalue ** 2))


class Momentum(bt.ind.OperationN):
    lines = ('trend',)
    params = dict(period=90)
    func = momentum_func

I then create our Backtrader strategy by inheriting from bt.Strategy and parameterizing where I can. I define the notify_timer and rebalance methods which are both pretty straightforward. I define stop as our optimizations analysis that will run after each strategy iteration.

class Strategy(bt.Strategy):
    params = dict(
        momentum=Momentum,
        momentum_period=180,
        num_positions=2,
        when=bt.timer.SESSION_START,
        timer=True,
        monthdays=[1],
        monthcarry=True,
        printlog=True
    )

    def log(self, txt, dt=None, doprint=False):
        ''' Logging function fot this strategy'''
        if self.params.printlog or doprint:
            dt = dt or self.datas[0].datetime.date(0)
            print('%s, %s' % (dt.isoformat(), txt))

    def __init__(self):
        self.i = 0
        self.securities = self.datas[1:]
        self.inds = {}

        self.add_timer(
            when=self.p.when,
            monthdays=self.p.monthdays,
            monthcarry=self.p.monthcarry
        )

        for security in self.securities:
            self.inds[security] = self.p.momentum(security,
                                                  period=self.p.momentum_period)

    def notify_timer(self, timer, when, *args, **kwargs):
        if self._getminperstatus() < 0:
            self.rebalance()

    def rebalance(self):
        rankings = list(self.securities)
        rankings.sort(key=lambda s: self.inds[s][0], reverse=True)
        pos_size = 1 / self.p.num_positions

        # Sell stocks no longer meeting ranking filter.
        for i, d in enumerate(rankings):
            if self.getposition(d).size:
                if i > self.p.num_positions:
                    self.close(d)

        # Buy and rebalance stocks with remaining cash
        for i, d in enumerate(rankings[:self.p.num_positions]):
            self.order_target_percent(d, target=pos_size)

    def next(self):
        self.notify_timer(self, self.p.timer, self.p.when)

    def stop(self):
        self.log('| %2d | %2d |  %.2f |' %
                 (self.p.momentum_period,
                  self.p.num_positions,
                  self.broker.getvalue()),
                 doprint=True)

if __name__ == '__main__':
    cerebro = bt.Cerebro()

    # Create an SQLAlchemy conneciton to PostgreSQL and get ETF data
    db = setup_psql_environment.get_database()
    session = setup_psql_environment.get_session()

    query = session.query(SecurityPrice, Security.ticker).join(Security). \
        filter(SecurityPrice.date >= START_DATE). \
        filter(SecurityPrice.date <= END_DATE). \
        filter(Security.code == 'ETF').statement

    dataframe = pd.read_sql(query,
                            db,
                            index_col=['ticker', 'date'],
                            parse_dates=['date'])
    dataframe.sort_index(inplace=True)
    dataframe = dataframe[['adj_open',
                           'adj_high',
                           'adj_low',
                           'adj_close',
                           'adj_volume']]
    dataframe.columns = ['open', 'high', 'low', 'close', 'volume']

    # Add Spy as datas0
    spy = dataframe.loc['SPY']
    benchdata = bt.feeds.PandasData(dataname=spy, name='spy', plot=True)
    cerebro.adddata(benchdata)
    dataframe.drop('SPY', level='ticker', inplace=True)

I perform some basic manipulation in pandas on a multi-dimensional index to align the data in the way Backtrader expects, and then I run the optimization.

# Add securities as datas1:
for ticker, data in dataframe.groupby(level=0):
    if ticker in ETF_TICKERS:
        print(f"Adding ticker: {ticker}")
        data = bt.feeds.PandasData(dataname=data.droplevel(level=0),
                                    name=ticker,
                                    plot=False)
        data.plotinfo.plotmaster = benchdata
        cerebro.adddata(data)

print('Starting Portfolio Value: %.2f' % cerebro.broker.getvalue())

# Add Strategy
stop = len(ETF_TICKERS) + 1
cerebro.optstrategy(Strategy,
                    momentum_period=range(50, 300, 50),
                    num_positions=range(1, len(ETF_TICKERS) + 1))

# Run the strategy. Results will be output from stop.
cerebro.run(stdstats=False, tradehistory=False)

Additional Resources

Leave a Comment