Introduction

Momentum effect is an anomaly in nearly every market. However, if a stock in the winner group is in the final stages of overreaction, it is not the best long opportunity because of the high probability of its reversal, and therefore profit reduction. Similarly, in the loser group, a stock which is in the final stages of overreaction is also not the best short opportunity because its reversal would lower the profit of short selling within a short time. Based on this logic, this momentum-reversal strategy seeks to buy winners and sell losers that are less likely to be in the final stages of overreaction.

Method

All stocks on NYSE and NASDAQ are used as the investment universe. We create a class to save all variables for each Symbol.

class SymbolData:
    def __init__(self, symbol):
        self.symbol = symbol
        self.window = RollingWindow[float](13)
        self.g_a_r_r_ratio = None
        self.yearly_return = None

To get the value for the above variables, we save the stock price at the start of each month in the RollingWindow for the last 12 months and calculate the monthly return and the yearly return. Then we can compute the last month's geometric average rate of return (GARR) and the previous 12-month GARR. The formulas is

\[GARR_n = \prod_{i=1}^{n}(1 + r_i)^{\frac{1}{n}}-1\]

where \(GARR_1\) is the GARR of the most recent month, \(GARR_{12}\) is the GARR of the previous 12 months, and \(r_i\) is the monthly return of month \(i\). We assign the value of two returns to each symbol in SymbolData class. All stocks are sorted based on their past 12-month return. Stocks are then divided into 3 portfolios (top 30% - winner group, middle 40% and bottom 30% - loser group). In the winner group, stocks are further classified into two categories: the return-increasing winner and return-decreasing winner using the ratio of last month's geometric average rate of return (GARR) over the last 12 month GARR.

\[GARR\ Ratio= \frac{GARR_{1} }{GARR_{12}}\]

def coarse_selection_function(self, coarse):
    if self.month_start:
        self.coarse = True
        coarse = [i for i in coarse if i.adjusted_price > 10]
        for i in coarse:
            if i.symbol not in self.symbol_price:
                self.symbol_price[i.symbol] = SymbolData(i.symbol)
            self.symbol_price[i.symbol].window.add(float(i.adjusted_price))
            if self.symbol_price[i.symbol].window.is_ready:
                price = np.array([i for i in self.symbol_price[i.symbol].window])
                returns = (price[:-1]-price[1:])/price[1:]
                self.symbol_price[i.symbol].yearly_return = (price[0]-price[-1])/price[-1]
                GARR_12 = np.prod([(1+i)**(1/12) for i in returns])-1
                GARR_1 = (1+returns[0])**(1/12)-1
                self.symbol_price[i.symbol].g_a_r_r_ratio = GARR_1 / GARR_12

The decreasing-return winner group contains 13 stocks in winner group with the lowest GARR Ratio and vice-versa for the increasing-return winner group. The loser group is divided into the increasing-return loser and the decreasing-return loser groups using a similar methodology. The return-increasing loser group contains 15 stocks in loser group with the highest GARR Ratio.

ReadySymbolPrice = {symbol: SymbolData for symbol, SymbolData in self.symbol_price.items() if SymbolData.window.is_ready}
if ReadySymbolPrice and len(ReadySymbolPrice)>50:
    # sort stocks in coarse by last 12-month return
    sorted_by_return = sorted(ReadySymbolPrice, key = lambda x: ReadySymbolPrice[x].yearly_return)
    # top 30% with the highest 12-month return goes into winner group
    winner = sorted_by_return[:int(len(sorted_by_return)*0.3)]
    # bottom 30% with the lowest 12-month return goes into loser group
    loser = sorted_by_return[-int(len(sorted_by_return)*0.3):]
    self.decrease_winner = sorted(winner, key = lambda x: ReadySymbolPrice[x].g_a_r_r_ratio)[:15]
    self.increase_loser = sorted(loser, key = lambda x: ReadySymbolPrice[x].g_a_r_r_ratio)[-15:]
    return self.decrease_winner+self.increase_loser

The algorithm goes long stocks from the decreasing-return winner group and short stocks from the increasing-return loser group. The portfolio is created as equally weighted and rebalanced on a monthly basis.

def on_data(self, data):
    if self.month_start and self.coarse:
        self.month_start = False
        self.coarse = False
        if all([self.decrease_winner, self.increase_loser]):
            stocks_invested = [x.key for x in self.portfolio]
            for i in stocks_invested:
                if i not in self.decrease_winner+self.increase_loser:
                    self.liquidate(i)
            short_weight = 0.5/len(self.increase_loser)
            for j in self.increase_loser:
                self.set_holdings(j, -short_weight)
            long_weight = 0.5/len(self.decrease_winner)
            for i in self.decrease_winner:
                self.set_holdings(i, long_weight)


Reference

  1. Quantpedia Premium - Momentum - Short Term Reversal Strategy