Post-Earnings Drift Trading Strategy with Estimize (PEAD)

Copyright reserved by Seong Lee - edited Oct 13, 2016 in Quantopian

7/15/2016 Update:

Access to the Estimize dataset will temporarily be shut down starting July 18th, 2016.

We've identified an issue with the manner in which we were processing  the Estimize dataset that prevented updates to the data starting June, 2016. All subscribers have been notified and we are taking steps to implement a solution. 

For an alternative version using Wall Street Consensus Estimates, please view this thread: https://www.quantopian.com/posts/updated-long-slash-short-earnings-sentiment-trading-strategy-with-the-streets-consesus

The backtest here has been replaced with a version using the Wall Street consensus until an appropriate solution has been implemented.  

This is a simple post-earnings announcement drift (PEAD) trading strategy that attempts to profit off the difference between reported earnings and earnings estimates. Earnings estimates (earnings per share or EPS) are heavily used in both quant and fundamental stock analysis as forward-looking indicators of stock performance, and when a discrepancy occurs between estimates and actually reported earnings, also known as an earnings surprise, stocks tend to drift in either a positive or negative direction (post-earnings announcement drift).

In this strategy, I simply follow the direction of that surprise and hold long/short positions for the following three business days. However, unlike in traditional PEAD strategies I use Crowdsourced earnings estimates rather than the Wall Street analyst average. This is because Crowdsourced earnings estimates can be more accurate than the Street’s average 65% of the time as discussed in this whitepaper.

While we aren’t yet able to test the Street’s version, check out the strategy below using Estimize’s Crowdsourced Earnings Estimates and let me know what you think!

Strategy Notes

  • Data set: The full dataset used is Estimize’s Consensus Estimates and EventVestor’s Earnings Calendar dataset.

  • Weights: The weight for each security is determined by the total number of longs and shorts we have in that current day. So if we have 2 longs and 2 shorts, the weight for each long will be 50% (1.0/number of securities) and the weight for each short will be -50%. This is a rolling rebalance at the beginning of each day according to the number of securities currently held and to order.

  • Hedging: [OPTIONAL] You have the ability to turn on net dollar exposure hedging with the SPY

  • Days held: Positions are currently held for 3 days but are easily changeable by modifying ‘context.days_to_hold’

  • Percent threshold: Only surprises between 0% and 4% in absolute magnitude will be considered as a trading signal. These are adjustable using the minimum and maximum threshold variables in context.

  • Earnings dates: All trades are made 1 business day AFTER an earnings announcement regardless of whether it was a Before Market Open or After Market announcement

Webinar: Learn the advantages of Crowdsourced estimates and how it can help your trading strategies with Vinesh Jha, CEO of Extract Alpha and former executive director at PDT Partners, through this recording.

This is a PEAD strategy based off Estimize's earnings estimates. Estimize
is a service that aggregate financial estimates from independent, buy-side,
sell-side analysts as well as students and professors. You can run this
algorithm yourself by geting the free sample version of Estimize's consensus
dataset and EventVestor's Earnings Calendar Dataset at:

- https://www.quantopian.com/data/eventvestor/earnings_calendar
- https://www.quantopian.com/data/estimize/revisions

Much of the variables are meant for you to be able to play around with them:
1. context.days_to_hold: defines the number of days you want to hold before exiting a position
2. context.min/max_surprise: defines the min/max % surprise you want before trading on a signal

import numpy as np

from quantopian.algorithm import attach_pipeline, pipeline_output
from quantopian.pipeline import Pipeline
from quantopian.pipeline import CustomFactor

# The sample version available from 18 Oct 2010 - 10 Feb 2015
from quantopian.pipeline.data.estimize import consensus_estimize_eps_free as estimize
# from quantopian.pipeline.data.estimize import consensus_estimize_revenue_free
# from quantopian.pipeline.data.estimize import consensus_wallstreet_eps_free
# from quantopian.pipeline.data.estimize import consensus_wallstreet_revenue_free

# The full version is available at https://www.quantopian.com/data/estimize/revisions
# from quantopian.pipeline.data.estimize import consensus_estimize_eps as estimize
# from quantopian.pipeline.data.estimize import consensus_estimize_revenue
# from quantopian.pipeline.data.estimize import consensus_wallstreet_eps
# from quantopian.pipeline.data.estimize import consensus_wallstreet_revenue

# The sample and full version is found through the same namespace
# https://www.quantopian.com/data/eventvestor/earnings_calendar
# Sample date ranges: 01 Jan 2007 - 10 Feb 2014
from quantopian.pipeline.data.eventvestor import EarningsCalendar
from quantopian.pipeline.factors.eventvestor import (

# Create custom factor subclass to calculate a market cap based on yesterday's
# close
class PercentSurprise(CustomFactor):
    window_length = 1
    inputs = [estimize.eps, estimize.estimize_eps_final]

    # Compute market cap value
    def compute(self, today, assets, out, actual_eps, estimize_eps):
        out[:] = (actual_eps[-1] - estimize_eps[-1])/(estimize_eps[-1] + 0)

def make_pipeline(context):
    # Create our pipeline
    pipe = Pipeline()

    # Get our Estimize Factors
    estimize_eps = estimize.estimize_eps_final
    actual_eps = estimize.eps
    num_estimates = estimize.count
    percent_surprise = PercentSurprise()

    # Add Factors to Pipeline
    pipe.add(estimize_eps.latest, 'estimize_eps')
    pipe.add(actual_eps.latest, 'actual_eps')
    pipe.add(num_estimates.latest, 'num_estimates')
    pipe.add(percent_surprise, 'percent_surprise')
    pipe.add(BusinessDaysUntilNextEarnings(), 'ne')
    pipe.add(BusinessDaysSincePreviousEarnings(), 'pe')
    pipe.add(EarningsCalendar.next_announcement.latest, 'next')
    # Set our screen
        actual_eps.latest.notnan() & (
        # Negative Surprise
        ((percent_surprise < -context.min_surprise) &
         (percent_surprise > -context.max_surprise))
        # Positive Surprise
        ((percent_surprise >= context.min_surprise) &
         (percent_surprise <= context.max_surprise))

    return pipe
def initialize(context):
    #: Set commissions and slippage to 0 to determine pure alpha
    #set_commission(commission.PerShare(cost=0, min_trade_cost=0))

    #: Declaring the days to hold, change this to what you want)))
    context.days_to_hold = 3
    #: Declares which stocks we currently held and how many days we've held them dict[stock:days_held]
    context.stocks_held = {}
    #: Declares the minimum magnitude of percent surprise
    context.min_surprise = .00
    context.max_surprise = .04
    #: OPTIONAL - Initialize our Hedge
    # See order_positions for hedging logic
    context.spy = sid(8554)

    context.leverageamount = 1.0
    # Make our pipeline
    attach_pipeline(make_pipeline(context), 'estimize')

    # Log our positions at 10:00AM
    # Order our positions
    # Exit our positions

def before_trading_start(context, data):
    # Screen for securities that only have an earnings release
    # 1 business day previous and separate out the earnings surprises into
    # positive and negative 
    results = pipeline_output('estimize')
    results = results[results['pe'] == 1]
    context.positive_surprise = results[results['percent_surprise'] > 0]
    context.negative_surprise = results[results['percent_surprise'] < 0]
    log.info("There are %s positive surprises today and %s negative surprises" % 
             (len(context.positive_surprise.index), len(context.negative_surprise.index)))
    update_universe(context.positive_surprise.index | context.negative_surprise.index)

def log_positions(context, data):
    #: Get all positions
    all_positions = "Current positions for %s : " % (str(get_datetime()))
    for pos in context.portfolio.positions:
        if context.portfolio.positions[pos].amount != 0:
            all_positions += "%s at %s shares, " % (pos.symbol, context.portfolio.positions[pos].amount)
def order_positions(context, data):
    Main ordering conditions to always order an equal percentage in each position
    so it does a rolling rebalance by looking at the stocks to order today and the stocks
    we currently hold in our portfolio.
    port = context.portfolio.positions
    current_positive_pos = [pos for pos in port if (port[pos].amount > 0 and pos in context.stocks_held)]
    current_negative_pos = [pos for pos in port if (port[pos].amount < 0 and pos in context.stocks_held)]
    negative_stocks = context.negative_surprise.index.tolist() + current_negative_pos
    positive_stocks = context.positive_surprise.index.tolist() + current_positive_pos
    # Rebalance our negative surprise securities (existing + new)
    for security in negative_stocks:
        if get_open_orders(security):
        if security in data:
            order_target_percent(security, -(context.leverageamount / 2) / len(negative_stocks))
            if context.stocks_held.get(security) is None:
                context.stocks_held[security] = 0

    # Rebalance our positive surprise securities (existing + new)                
    for security in positive_stocks:
        if get_open_orders(security):
        if security in data:
            order_target_percent(security, (context.leverageamount / 2) / len(positive_stocks))
            if context.stocks_held.get(security) is None:
                context.stocks_held[security] = 0

    #: Get the total amount ordered for the day
    amount_ordered = 0 
    for order in get_open_orders():
        for oo in get_open_orders()[order]:
            amount_ordered += oo.amount * data[oo.sid].price

    #: Order our hedge
    # order_target_value(context.spy, -amount_ordered)
    # context.stocks_held[context.spy] = 0
    # log.info("We currently have a net order of $%0.2f and will hedge with SPY by ordering $%0.2f" % (amount_ordered, -amount_ordered))
def exit_positions(context, data):        
    Exit position/days held update logic
    #: Go through each held stock and update the number of days held and close out any positions
    #: that have been held past context.days_to_hold        
    for security in context.portfolio.positions:
        #: If we don't currently hold the stock or there are open orders, don't try and exit
        if context.stocks_held.get(security) is None:
            log.info("Cannot exit %s" % security.symbol)

        # Make sure that our days held are incremented
        context.stocks_held[security] += 1     

        # Exit our position and delete it from our list of securities currently held
        if context.stocks_held[security] >= context.days_to_hold:
            order_target_percent(security, 0)
            del context.stocks_held[security]
def handle_data(context, data):