Abstract
This tutorial implements a strategy that standardizes the unexpected earnings of stocks and trades the top 5% of those standardized stocks. It is written based on a paper published in The Accounting Review by Foster, Olsen, and Shevlin (1984). Our implementation narrows down our universe to 1000 liquid assets based on daily trading volume and price, and the availability of fundamental data on the stocks in our data library. We calculate the unexpected earnings at the beginning of each month, standardize the unexpected earnings, go long on the top 5%, and rebalance the portfolio monthly. We observed a Sharpe ratio of 0.602 relative to SPY Sharpe of 0.43 using this implementation during the period of December 1, 2009 to September 1, 2019 in backtesting.
Theory
In market efficiency literature, one frequently discussed topic is the anomalous behavior of stock returns following earnings announcements. The market does not adjust to news from earning announcements instantaneously. Instead, many studies report evidence that the direction and magnitude of returns in the post-earnings announcement period are positively correlated with the direction and magnitude of the unexpected component in the earnings releases. This observed phenomenon is consistent with suggestions that the capital market is inefficient.
Method
Unexpected earnings, or earnings surprise, is the difference between reported earnings and the expected earnings of a firm. Expected earnings is calculated using a combination of analyst forecasts and mathematical models based on earnings of previous periods. In this tutorial, we use standardized unexpected earnings (SUE) to measure earnings surprise. SUE's numerator is the change in quarterly earnings per share (EPS) from EPS four quarters ago. Its denominator is the standard deviation of a series of deltas each calculated by subtracting EPS at quarter \(q-4\) from EPS at quarter \(q\). It can be formulated as
\[ SUE_q = \frac{ EPS_q - EPS_{q-4} }{ \sigma( EPS_q - EPS_{q-4} ) } \]
where \(\sigma(X)\) is the standard deviation of \(X\), \(EPS\) a firm's quarterly earnings per share, \(q\) the current quarter, and \(q-4\) four quarters ago. Keep in mind that although we use quarterly EPS data, the portfolio rebalances monthly. Additionally, note that SUE's stock ranking changes month to month because each company's earnings announcement release date for the quarter differs (i.e., firm A's Q3 announcement may come out in August while firm B's Q3 announcement comes out in September).
Step 1: Narrow down the universe with a coarse selection filter function
We use a coarse selection filter to narrow down the universe to 1,000 stocks at the beginning of each month according to dollar volume, price and whether the stock has fundamental data in our Dataset Market.
def coarse_selection_function(self, coarse):
'''Get dynamic coarse universe to be further selected in fine selection
'''
# Before next rebalance time, keep the current universe unchanged
if self.time < self.next_rebalance:
return Universe.UNCHANGED
### Run the coarse selection to narrow down the universe
# Filter stocks by price and whether they have fundamental data
# Then, sort descendingly by daily dollar volume
sorted_by_volume = sorted([ x for x in coarse if x.has_fundamental_data and x.price > 5 ],
key = lambda x: x.dollar_volume, reverse = True)
self.new_fine = [ x.symbol for x in sorted_by_volume[:self.num_coarse] ]
# Return all symbols that have appeared in Coarse Selection
return list( set(self.new_fine).union( set(self.eps_by_symbol.keys()) ) )
Step 2: Sort the universe by SUE and select the top 5%
Next we use a fine universe selection filter to extract quarterly EPS data and save it in a RollingWindow for each stock. We don't trade during the first 36-month warm-up period because the window is not ready yet. After the warm-up period, we can calculate quarterly EPS change from four quarters ago and the standard deviation of the change over the prior eight quarters using historical EPS data saved in the rolling windows. Then we sort the universe and assign the top 5% of Symbol objects to self.long
.
def fine_selection_and_sue_sorting(self, fine):
'''Select symbols to trade based on sorting of SUE'''
sue_by_symbol = dict()
for stock in fine:
### Save (symbol, rolling window of EPS) pair in dictionary
if not stock.symbol in self.eps_by_symbol:
self.eps_by_symbol[stock.symbol] = RollingWindow[float](self.months_count)
# update rolling window for each stock
self.eps_by_symbol[stock.symbol].add(stock.earning_reports.basic_e_p_s.three_months)
### Calculate SUE
if stock.symbol in self.new_fine and self.eps_by_symbol[stock.symbol].is_ready:
# Calculate the EPS change from four quarters ago
rw = self.eps_by_symbol[stock.symbol]
eps_change = rw[0] - rw[self.months_eps_change]
# Calculate the st dev of EPS change for the prior eight quarters
new_eps_list = list(rw)[:self.months_count - self.months_eps_change:3]
old_eps_list = list(rw)[self.months_eps_change::3]
eps_std = np.std( [ new_eps - old_eps for new_eps, old_eps in
zip( new_eps_list, old_eps_list )
] )
# Get Standardized Unexpected Earnings (SUE)
sue_by_symbol[stock.symbol] = eps_change / eps_std
# Sort and return the top quantile
sorted_dict = sorted(sue_by_symbol.items(), key = lambda x: x[1], reverse = True)
self.long = [ x[0] for x in sorted_dict[:math.ceil( self.top_percent * len(sorted_dict) )] ]
# If universe is empty, OnData will not be triggered, then update next rebalance time here
if not self.long:
self.next_rebalance = Expiry.end_of_month(self.time)
return self.long
Step 3: Form an equal-weighted portfolio and place orders
Once the symbols are selected, we form an equal-weighted portfolio and place orders. Finally, we update the next rebalance time to the beginning of the next calendar month. The portfolio will be held until liquidated at next rebalance time.
def on_securities_changed(self, changes):
'''Liquidate symbols that are removed from the dynamic universe
'''
for security in changes.removed_securities:
if security.invested:
self.liquidate(security.symbol, 'Removed from universe')
def on_data(self, data):
'''Monthly rebalance at the beginning of each month. Form portfolio with equal weights.
'''
# Before next rebalance, do nothing
if self.time < self.next_rebalance or not self.long:
return
# Placing orders (with equal weights)
equal_weight = 1 / len(self.long)
for stock in self.long:
self.set_holdings(stock, equal_weight)
# Rebalance at the beginning of every month
self.next_rebalance = Expiry.end_of_month(self.time)
Conclusion and Future Work
This tutorial shows that SUE is a valid indicator for earnings surprise, which can be used as a trading signal to follow post-earning announcement drifts. Our implementation generates a Sharpe ratio of 0.83 relative to SPY Sharpe ratio of 0.88. Interested users can build from this implementation by trying the following extensions:
- Using a more complicated measure for expected earnings to replace the historical EPS from four quarters ago.
- Using different investment horizons such as 3 months, 6 months, 1 year. In a longer investment horizon of \(n\) months, each month’s decile will have \(n\) subdeciles, each of which is initiated in a different month in the prior \(n\)-month period. An example is a horizon of 6 months with each month having 6 subdeciles, each initiated in a different month in the prior 6-month period.
- Using the Estimize dataset, which includes EPS estimates, to replace the expected earnings based on historical EPS.
- Selecting small-size companies and then trade based on SUE ranking, since studies suggest that post-earnings announcement is more significant for small-size companies than larger ones.
Reference
- Foster G, Olsen C, Shevlin T. Earnings releases, anomalies, and the behavior of security returns. Accounting Review. 1984 Oct 1:574-603 Online Copy
- Hou K, Xue C, Zhang L. Replicating Anomalies. The Review of Financial Studies Online Copy
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.
Xin Wei
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!