This post 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.83 relative to SPY Sharpe of 0.88 using this implementation during the period of December 1, 2009 to September 1, 2019 in backtesting.


Unexpected earnings, or earnings surprise, is the difference between reported earnings and the expected earnings of a firm. Expected earnings is calculated using either analyst forecasts or mathematical models based on earnings of previous periods. In this post, 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.  

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 1000 stocks at the beginning of each month according to dollar volume, price and whether the stock has fundamental data in our data library.  

def CoarseSelectionFunction(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.HasFundamentalData and x.Price > 5 ],
key = lambda x: x.DollarVolume, 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 rolling window 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 symbols to self.long.  

def FineSelectionAndSueSorting(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

### Calculate SUE

if stock.Symbol in self.new_fine and self.eps_by_symbol[stock.Symbol].IsReady:

# 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.EndOfMonth(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 OnSecuritiesChanged(self, changes):
'''Liquidate symbols that are removed from the dynamic universe
for security in changes.RemovedSecurities:
if security.Invested:
self.Liquidate(security.Symbol, 'Removed from universe')

def OnData(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:

# Placing orders (with equal weights)
equal_weight = 1 / len(self.long)
for stock in self.long:
self.SetHoldings(stock, equal_weight)

# Rebalance at the beginning of every month
self.next_rebalance = Expiry.EndOfMonth(self.Time)

Conclusion and Future Work  

This post 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:  

  1. Using a more complicated measure for expected earnings to replace the historical EPS from four quarters ago.  
  2. 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.
  3. Importing custom data of analysts’ forecasts of firms’ earnings to replace the expected earnings based on historical EPS.
  4. 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.
Note: For additional information, please check out this tutorial page. Feel free to leave any questions or suggestion here about our implementation. Also, try out the extensions! We'd be happy to hear that you improve the strategy Sharpe!