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 coarse_selection(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.next_rebalance:
return Universe.UNCHANGED
# Sort the equities (prices > 5) by Dollar Volume descendingly
selected_by_dollar_volume = sorted([x for x in coarse if x.price > 5 and x.has_fundamental_data],
key = lambda x: x.dollar_volume, reverse = True)
# Pick the top 100 liquid equities as the coarse-selected universe
return [x.symbol for x in selected_by_dollar_volume[:self.num_of_coarse]]
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 get_quarterly_return(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 RollingWindow
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 get_earning_growth(self, fine):
'''
Get the rank of securities based on their EPS growth
Return: dictionary
'''
# Earning Growth by symbol
eg_by_symbol = {}
for stock in fine:
# Select the securities with EPS (> 0)
if stock.earning_reports.basic_e_p_s.three_months == 0:
continue
# Add the symbol in the dict if not exist
if not stock.symbol in self.eps_by_symbol:
self.eps_by_symbol[stock.symbol] = RollingWindow[float](2)
# Update the rolling window for each stock
self.eps_by_symbol[stock.symbol].add(stock.earning_reports.basic_e_p_s.three_months)
# If the rolling window is ready
if self.eps_by_symbol[stock.symbol].is_ready:
rw = self.eps_by_symbol[stock.symbol]
# Caculate the Earning Growth
eg_by_symbol[stock.symbol] = (rw[0] - rw[1]) / rw[1]
# Get the rank of the Earning Growth
ranked = sorted(eg_by_symbol, key = eg_by_symbol.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 fine_selection(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.rebalance_days, Resolution.DAILY)
history = history.drop_duplicates().close.unstack(level = 0)
rank_by_quarter_return = self.get_quarterly_return(history)
# Get the earning growth for each symbol
rank_by_earning_growth = self.get_earning_growth(fine)
# Get the sum of rank for each symbol and pick the top ones to long and the bottom ones to short
rank_sum_by_symbol = {key: rank_by_quarter_return.get(key, 0) + rank_by_earning_growth.get(key, 0)
for key in set(rank_by_quarter_return) | set(rank_by_earning_growth)}
# Get 10 symbols to long and short respectively
sorted_dict = sorted(rank_sum_by_symbol.items(), key = lambda x: x[1], reverse = True)
self.long_symbols = [x[0] for x in sorted_dict[:10]]
self.short_symbols = [x[0] for x in sorted_dict[-10:]]
return [x for x in symbols if str(x) in self.long_symbols + self.short_symbols]
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 on_data(self, data):
'''
Rebalance quarterly
'''
# Do nothing until next rebalance
if self.time < self.next_rebalance:
return
# Liquidate the holdings if necessary
for holding in self.portfolio.values:
symbol = holding.symbol
if holding.invested and symbol.value not in self.long_symbols + self.short_symbols:
self.liquidate(symbol, "Not Selected")
# Open positions for the symbols with equal weights
count = len(self.long_symbols + self.short_symbols)
if count == 0:
return
# Enter long positions
for symbol in self.long_symbols:
self.set_holdings(symbol, 1 / count)
# Enter short positions
for symbol in self.short_symbols:
self.set_holdings(symbol, -1 / count)
# Set next rebalance time
self.next_rebalance += timedelta(self.rebalance_days)
Results
Our backtest results in a Sharpe ratio of -0.268 while the SP500 Sharpe ratio is 0.758 during the same decade. This performance may be due to several factors:
- The number of stocks in our portfolio, 100, could be too low.
- Equal weighting for all stocks may not fully capture the strength of higher ranking stocks.
- 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.
Derek Melchin
See the attached backtest for an updated version of the algorithm in PEP8 style.
The material on this website is provided for informational purposes only and does not constitute an offer to sell, a solicitation to buy, or a recommendation or endorsement for any security or strategy, nor does it constitute an offer to provide investment advisory services by QuantConnect. In addition, the material offers no opinion with respect to the suitability of any security or specific investment. QuantConnect makes no guarantees as to the accuracy or completeness of the views expressed in the website. The views are subject to change, and may have become unreliable for various reasons, including changes in market conditions or economic circumstances. All investments involve risk, including loss of principal. You should consult with an investment professional before making any investment decisions.
Daniel Chen
The material on this website is provided for informational purposes only and does not constitute an offer to sell, a solicitation to buy, or a recommendation or endorsement for any security or strategy, nor does it constitute an offer to provide investment advisory services by QuantConnect. In addition, the material offers no opinion with respect to the suitability of any security or specific investment. QuantConnect makes no guarantees as to the accuracy or completeness of the views expressed in the website. The views are subject to change, and may have become unreliable for various reasons, including changes in market conditions or economic circumstances. All investments involve risk, including loss of principal. You should consult with an investment professional before making any investment decisions.
To unlock posting to the community forums please complete at least 30% of Boot Camp.
You can continue your Boot Camp training progress from the terminal. We hope to see you in the community soon!