Strategy Library

Price and Earning Momentum

Introduction

In this tutorial, we will develop a strategy based on the price and earnings momentum effect of stocks. This strategy is derived from the paper "Momentum" by N.Jegadeesh and S.Titman.

N.Jegadeesh et al. describe price/return momentum as a tendency for stocks that perform well over a three to twelve month period to continue to perform well over a subsequent three to twelve month period. Similarly, stocks that perform poorly over a three to twelve month period have a tendency to continue to perform poorly. They describe earnings momentum as the tendency for stocks with high earnings per share (EPS) to continue to outperform stocks with low EPS.

Below, we will implement a quarterly-rebalanced stock strategy based on the price and earnings momentum.

Method

Step 1: Select the coarse universe

We will use both a coarse selection filter and in a later step, a fine universe filter, to narrow down our universe of assets. Our coarse universe filter creates a set of stocks based on volume, price, and whether fundamental data on the stock exists. In this step we filter for the top 100 liquid equities with prices greater than $5. We also exclude the equities missing fundamental data because EPS is needed in the fine selection step.

def CoarseSelection(self, coarse):
  '''
  Pick the top 100 liquid equities as the coarse-selected universe
  '''
  # Before next rebalance time, just remain the current universe
  if self.Time < self.nextRebalance:
      return Universe.Unchanged

  # Sort the equities (prices > 5) by Dollar Volume descendingly
  selectedByDollarVolume = sorted([x for x in coarse if x.Price > 5 and x.HasFundamentalData], 
                                  key = lambda x: x.DollarVolume, reverse = True)

  # Pick the top 100 liquid equities as the coarse-selected universe
  return [x.Symbol for x in selectedByDollarVolume[:self.numOfCoarse]]  

Step 2: Calculate quarterly return and earnings growth

N.Jegadeesh et al. state price momentum and earnings momentum can be used as two indicators for trading. We will calculate for price momentum with the "GetQuarterlyReturn" method and for earnings momentum with the "GetEarningGrowth" method.

"GetQuarterlyReturn" calculates price momentum for each symbol in our coarse universe and ranks each stock based on its quarterly return. First we request last quarter’s close price for all stocks. Then we calculate quarterly return by taking the delta of the first day’s close price and the last day’s close price. Finally, we store the symbols and their corresponding rankings by quarterly return in a dictionary in preparation for fine selection.

    def GetQuarterlyReturn(self, history):
        '''
        Get the rank of securities based on their quarterly return from historical close prices
        Return: dictionary
        '''
        # Get quarterly returns for all symbols
        # (The first row divided by the last row)
        returns = history.iloc[0] / history.iloc[-1]

        # Transform them to dictionary structure
        returns = returns.to_dict()

        # Get the rank of the returns (key: symbol; value: rank)
        # (The symbol with the 1st quarterly return ranks the 1st, etc.)
        ranked = sorted(returns, key = returns.get, reverse = True)
        return {symbol: rank for rank, symbol in enumerate(ranked, 1)}
  

"GetEarningGrowth" calculates earnings momentum for each symbol in our coarse universe and ranks each stock based on its earnings growth. First we use a RollingWindow to store and update the BasicEPS to reflect quarterly earnings reports. A RollingWindow holds a set of the most recent entries of data. As we move from time t=0 forward, our rolling window will shuffle data further along to a different index until it leaves the window completely. The object in the window with index[0] refers to the most recent item. The length-1 in the window is the oldest object.

Our rolling window has a length of 2 so index[0] is the current EPS and index[1] is last quarter's EPS. We calculate earnings growth for each stock by taking the delta of this quarter’s EPS and last quarter’s EPS divided by last quarter’s EPS. Finally we rank each asset based on earnings growth.

    def GetEarningGrowth(self, fine):
        '''
        Get the rank of securities based on their EPS growth
        Return: dictionary
        '''

        # Earning Growth by symbol
        egBySymbol = {}
        for stock in fine:

            # Select the securities with EPS (> 0)
            if stock.EarningReports.BasicEPS.ThreeMonths == 0:
                continue

            # Add the symbol in the dict if not exist
            if not stock.Symbol in self.epsBySymbol:
                self.epsBySymbol[stock.Symbol] = RollingWindow[float](2)

            # Update the rolling window for each stock
            self.epsBySymbol[stock.Symbol].Add(stock.EarningReports.BasicEPS.ThreeMonths)

            # If the rolling window is ready
            if self.epsBySymbol[stock.Symbol].IsReady:
                rw = self.epsBySymbol[stock.Symbol]
                # Caculate the Earning Growth
                egBySymbol[stock.Symbol] = (rw[0] - rw[1]) / rw[1]

        # Get the rank of the Earning Growth
        ranked = sorted(egBySymbol, key = egBySymbol.get, reverse = True)
        return {symbol: rank for rank, symbol in enumerate(ranked, 1)}
  

Step 3: Select the fine universe

We use a fine selection filter in addition to a coarse selection filter to refine our asset selection based on corporate fundamental data. We can use both quarterly return and earnings growth from our two indicators to generate an average rank for each stock. Then we can go long on the top 10 and short the bottom 10.

    def FineSelection(self, fine):
        '''
        Select securities based on their quarterly return and their earnings growth 
        '''
        symbols = [x.Symbol for x in fine]

        # Get the quarterly returns for each symbol
        history = self.History(symbols, self.rebalanceDays, Resolution.Daily)
        history = history.drop_duplicates().close.unstack(level = 0)
        rankByQuarterReturn = self.GetQuarterlyReturn(history)

        # Get the earning growth for each symbol
        rankByEarningGrowth = self.GetEarningGrowth(fine) 

        # Get the sum of rank for each symbol and pick the top ones to long and the bottom ones to short
        rankSumBySymbol = {key: rankByQuarterReturn.get(key, 0) + rankByEarningGrowth.get(key, 0) 
                                for key in set(rankByQuarterReturn) | set(rankByEarningGrowth)}

        # Get 10 symbols to long and short respectively
        sortedDict = sorted(rankSumBySymbol.items(), key = lambda x: x[1], reverse = True)
        self.longSymbols = [x[0] for x in sortedDict[:10]]
        self.shortSymbols = [x[0] for x in sortedDict[-10:]]

        return [x for x in symbols if str(x) in self.longSymbols + self.shortSymbols]    
  

Step 4: Rebalance quarterly

We choose to rebalance every quarter and use equal weights for the long and short positions of securities in our portfolio.

    def OnData(self, data):
        '''
        Rebalance quarterly
        '''
        # Do nothing until next rebalance
        if self.Time < self.nextRebalance:
            return

        # Liquidate the holdings if necessary
        for holding in self.Portfolio.Values:
            symbol = holding.Symbol
            if holding.Invested and symbol.Value not in self.longSymbols + self.shortSymbols:
                self.Liquidate(symbol, "Not Selected")

        # Open positions for the symbols with equal weights
        count = len(self.longSymbols + self.shortSymbols)
        if count == 0:
            return

        # Enter long positions
        for symbol in self.longSymbols:
            self.SetHoldings(symbol, 1 / count)

        # Enter short positions
        for symbol in self.shortSymbols:
            self.SetHoldings(symbol, -1 / count)

        # Set next rebalance time
        self.nextRebalance += timedelta(self.rebalanceDays)   
  

Results

Our backtest results in a Sharpe ratio of 0.59 while the SP500 Sharpe ratio is 0.8 during the same decade. This performance may be due to several factors:

  1. The number of stocks in our portfolio, 100, could be too low.
  2. Equal weighting for all stocks may not fully capture the strength of higher ranking stocks.
  3. Rebalancing quarterly may be too frequent for a momentum strategy in equities.
This tutorial shows us how to take advantage of the techniques of requesting historical data and using a RollingWindow. We hope the community can further develop strategies based on these techniques.

Algorithm

You can also see our Documentation and Videos. You can also get in touch with us via Chat.

Did you find this page helpful?