Skip to content
All library documents

Dual Momentum Sector Rotation with Monthly Rebalancing

Article Strategy library · Author: Farrell Aultman (pinkfish)

Summary

The document describes a sector rotation approach combining relative momentum with a market regime filter. Its stated rules hold the strongest sectors by three-month gains when the S&P 500 is above its ten-month simple moving average, exit when the index closes below that average, and rebalance monthly. The code ranks symbols by momentum and assigns equal weights to the selected top tier; an optional absolute-momentum filter removes assets with negative returns.

There are notable differences between the written rules and implementation. The code defaults the regime filter off and selects a lookback randomly from six to twelve months when no lookback is supplied, rather than consistently applying the stated three-month ranking. The code also calculates a 200-day S&P regime signal, but uses it only when that optional filter is enabled. No backtest results or performance evidence are included, so the rules and code should be reconciled and independently evaluated before drawing conclusions.

Key ideas

  • Relative momentum ranks assets against one another, while absolute momentum can exclude assets with negative returns.
  • The stated approach selects leading sectors when the S&P 500 is above its ten-month moving average and exits when it falls below on a monthly close.
  • The code rebalances monthly and assigns equal weights to the selected top-ranked assets.
  • The implementation defaults its regime filter off and may use a random six-to-twelve-month lookback, differing from the stated three-month rule.
  • No performance results are supplied, and the discrepancies require review before evaluation.

Tags

Full text
# momentum-dmsr-portfolio


# momentum-dmsr-portfolio









Dual Momentum Sector Rotation (DMSR)

'Relative momentum looks at price strength with respect to other assets.
Absolute momentum uses an asset’s own past performance to infer future
performance. Absolute momentum can reduce downside exposure as well
enhance returns. The best approach is to use both types of momentum
together. That is what dual momentum is all about.'
https://www.optimalmomentum.com/momentum/

Buy Signal: When the S&P 500 is above its 10-month simple moving
average, buy the sectors with the biggest gains over a three-month
timeframe.  10-months is about 200 trading days.

Sell Signal: Exit all positions when the S&P 500 moves below its
10-month simple moving average on a monthly closing basis.

Rebalance: Once per month, sell sectors that fall out of the
top tier (three) and buy the sectors that move into the top tier
(two or three).

## Source (MIT)

```python
"""
Dual Momentum Sector Rotation (DMSR)

'Relative momentum looks at price strength with respect to other assets.
Absolute momentum uses an asset’s own past performance to infer future
performance. Absolute momentum can reduce downside exposure as well
enhance returns. The best approach is to use both types of momentum
together. That is what dual momentum is all about.'
https://www.optimalmomentum.com/momentum/

Buy Signal: When the S&P 500 is above its 10-month simple moving
average, buy the sectors with the biggest gains over a three-month
timeframe.  10-months is about 200 trading days.

Sell Signal: Exit all positions when the S&P 500 moves below its
10-month simple moving average on a monthly closing basis.

Rebalance: Once per month, sell sectors that fall out of the
top tier (three) and buy the sectors that move into the top tier
(two or three).
"""

import random

import pinkfish as pf


default_options = {
    'use_adj' : False,
    'use_cache' : True,
    'lookback': None,
    'margin': 1,
    'use_absolute_mom': False,
    'use_regime_filter': False,
    'top_tier': 3
}

class Strategy:

    def __init__(self, symbols, capital, start, end, options=default_options):

        self.symbols = symbols
        self.capital = capital
        self.start = start
        self.end = end
        self.options = options.copy()

        self.ts = None
        self.rlog = None
        self.tlog = None
        self.dbal = None
        self.stats = None

    def _algo(self):

        pf.TradeLog.cash = self.capital
        pf.TradeLog.margin = self.options['margin']

        # These dicts are used to track mom, and weights for
        # each symbol in portfolio
        mom = {}; weights = {}

        # A counter to countdown the number of months a lookback has
        # been in place
        month_count = 0

        for i, row in enumerate(self.ts.itertuples()):

            end_flag = pf.is_last_row(self.ts, i)

            if month_count == 0:
                # if period is None, then select a random trading period of
                # 6,7,8,...,or 12 months
                if self.options['lookback'] is None: 
                    lookback = random.choice(range(6, 12+1))
                else:
                    lookback = self.options['lookback']
                month_count = lookback

            # If first day of the month or last row
            if row.first_dotm or end_flag:

                month_count -= 1

                # Get mom values for current row
                mom_field = 'mom' + str(lookback)
                p = self.portfolio.get_column_values(row, fields=[mom_field])

                # Copy data from `p` into mom dicts for
                # convenience, also zero out weights dict. 
                for symbol in self.portfolio.symbols:
                    mom[symbol] = p[symbol][mom_field]
                    weights[symbol] = 0

                # Rebalance logic:
                #   Assign weights using relative momentum
                #   Then assign using absolute momentum if enabled
                #   Finally rebalance

                # Set weights to zero if last row or using regime 
                # filter and regime has turned bearish.
                if end_flag or (self.options['use_regime_filter'] and row.regime < 0):
                    # Since weights dict is already zeroed out, all positions
                    # will be closed out below with adjust_percent()
                    pass
                else:
                    # Relative momentum: Sort by highest momentum.
                    mom = pf.sort_dict(mom, reverse=True)

                    # Get the top tier momentum symbols; assign
                    # equal weight to top tier symbols
                    for j in range(self.options['top_tier']):
                        symbol = list(mom.keys())[j]
                        weights[symbol] = 1 / self.options['top_tier']

                # Absolute momentum: Set weight to zero if percent
                # change is negative.
                if self.options['use_absolute_mom']:
                    for symbol, pct_change in mom.items():
                        if pct_change < 0: weights[symbol] = 0

                # Rebalance portfolio
                self.portfolio.adjust_percents(row, weights)

            # record daily balance
            self.portfolio.record_daily_balance(row)

    def run(self):
        self.portfolio = pf.Portfolio()
        self.ts = self.portfolio.fetch_timeseries(self.symbols, self.start, self.end,
                    fields=['close'],
                    use_cache=self.options['use_cache'],
                    use_adj=self.options['use_adj'])

        # Add S&P500 200 sma regime filter
        ts = pf.fetch_timeseries('^GSPC')
        ts = pf.select_tradeperiod(ts, self.start, self.end, use_adj=False) 
        self.ts['regime'] = \
            pf.CROSSOVER(ts, timeperiod_fast=1, timeperiod_slow=200, band=3.5)

        # Add calendar columns
        self.ts = self.portfolio.calendar(self.ts)

        # Add technical indicator Momenteum for all symbols in portfolio.
        lookbacks = range(3, 18+1)
        for lookback in lookbacks:
            @pf.technical_indicator(self.symbols, 'mom'+str(lookback), 'close')
            def _momentum(ts, input_column=None):
                return pf.MOMENTUM(ts, lookback=lookback, time_frame='monthly',
                                   price=input_column, prevday=False)
            self.ts = _momentum(self.ts)

        self.ts, self.start = self.portfolio.finalize_timeseries(self.ts, self.start)
        self.portfolio.init_trade_logs(self.ts)

        self._algo()
        self._get_logs()
        self._get_stats()

    def _get_logs(self):
        self.rlog, self.tlog, self.dbal = self.portfolio.get_logs()

    def _get_stats(self):
        self.stats = pf.stats(self.ts, self.tlog, self.dbal, self.capital)

```

Shown in full with attribution under the source's licence. Licence: MIT

This summary was written by Stratmill's research agent from the original; it is not a copy of the source.