Back to Community
Long/short events based trading strategy (share buybacks and earnings reports)

As both buybacks and earnings calendars are now available in pipeline, this example algorithm is the first I’ve written to trade on more than one event. If you’d like to learn more about trading on buyback announcements or earnings surprises, please view the original posts for share buybacks and earnings surprises.

I’m trying something new by providing a much more in-depth descriptions of the data feeds and custom factors used. Let me know if it’s helpful. You'll find dataset descriptions, custom factor descriptions, and strategy description as you read through.

Dataset and Custom factor descriptions

Strategy Details:

  • Universe filter: We look at only the top 1,000 most liquid securities.
  • Event filter: Each event (earnings surprise & buyback) must have an article sentiment greater than .45 for long positions and less than -45 for short positions.
  • Long Positions: Positive earnings surprises and new buyback authorizations are included in long positions.
  • Short Positions: Negative earnings surprises make up all the short positions that we hold in this algorithm.
  • 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.
  • Capital base: $1,000,000
  • Profit and Loss limits are set to 6%
  • Days held: Positions are currently held for 4 days but are easily changeable by modifying 'context.days_to_hold'
  • Surprise Percent threshold: Only earnings surprises between 0% and 6% in absolute magnitude will be considered as a trading signal. These are adjustable using the minimum and maximum threshold variables in context.
  • Trade dates: All trades are made 1 business day AFTER an event regardless of whether it was a Before Market Open or After Market announcement

For all examples using data, visit the data pipeline factor library.

Disclaimer

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 Quantopian. In addition, the material offers no opinion with respect to the suitability of any security or specific investment. No information contained herein should be regarded as a suggestion to engage in or refrain from any investment-related course of action as none of Quantopian nor any of its affiliates is undertaking to provide investment advice, act as an adviser to any plan or entity subject to the Employee Retirement Income Security Act of 1974, as amended, individual retirement account or individual retirement annuity, or give advice in a fiduciary capacity with respect to the materials presented herein. If you are an individual retirement or other investor, contact your financial advisor or other fiduciary unrelated to Quantopian about whether any given investment idea, strategy, product or service described herein may be appropriate for your circumstances. All investments involve risk, including loss of principal. Quantopian 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.

6 responses

OOS. Drawdown is a little high once you run it on bigger timescale.

Clone Algorithm
Loading...
Total Returns
--
Alpha
--
Beta
--
Sharpe
--
Sortino
--
Max Drawdown
--
Benchmark Returns
--
Volatility
--
Returns 1 Month 3 Month 6 Month 12 Month
Alpha 1 Month 3 Month 6 Month 12 Month
Beta 1 Month 3 Month 6 Month 12 Month
Sharpe 1 Month 3 Month 6 Month 12 Month
Sortino 1 Month 3 Month 6 Month 12 Month
Volatility 1 Month 3 Month 6 Month 12 Month
Max Drawdown 1 Month 3 Month 6 Month 12 Month
import numpy as np

from quantopian.algorithm import attach_pipeline, pipeline_output
from quantopian.pipeline import Pipeline
from quantopian.pipeline.data.builtin import USEquityPricing
from quantopian.pipeline.factors import CustomFactor, AverageDollarVolume

# Premium version avilable at
# https://www.quantopian.com/data/eventvestor/earnings_calendar
from quantopian.pipeline.data.estimize import (
    ConsensusEstimizeEPS,
    ConsensusWallstreetEPS,
    ConsensusEstimizeRevenue, 
    ConsensusWallstreetRevenue
)

# 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, BuybackAuthorizations
from quantopian.pipeline.factors.eventvestor import (
    BusinessDaysUntilNextEarnings,
    BusinessDaysSincePreviousEarnings,
    BusinessDaysSinceBuybackAuth
)

# from quantopian.pipeline.data.accern import alphaone_free as alphaone
# Premium version availabe at
# https://www.quantopian.com/data/accern/alphaone
from quantopian.pipeline.data.accern import alphaone as alphaone

class PercentSurprise(CustomFactor):
    window_length = 1
    inputs = [ConsensusEstimizeEPS.previous_actual_value,
              ConsensusEstimizeEPS.previous_mean]

    def compute(self, today, assets, out, actual_eps, estimize_eps):
        out[:] = (actual_eps[-1] - estimize_eps[-1])/(estimize_eps[-1] + 0)
        
class DaysSinceRelease(CustomFactor):
    # Only getting the previous quarter's estimize surprise
    window_length = 1
    inputs = [EarningsCalendar.previous_announcement,
              ConsensusEstimizeEPS.previous_release_date]
    
    def compute(self, today, assets, out,
                earnings_announcement, estimize_release):
        days = estimize_release - earnings_announcement
        out[:] = abs(days.astype('timedelta64[D]').astype(int))

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

    # Instantiating our factors  
    factor = PercentSurprise()
    
    # Get the days since estimize release date and the latest
    # Earnings release date
    days_since_release = DaysSinceRelease()
    previous_estimize_release_date = \
        ConsensusEstimizeEPS.previous_release_date.latest

    # Screen out penny stocks and low liquidity securities.  
    dollar_volume = AverageDollarVolume(window_length=20)  
    is_liquid = dollar_volume > 10**7

    # Filter down to stocks in the top/bottom according to
    # the earnings surprise
    longs = (factor >= context.min_surprise) & (factor <= context.max_surprise)
    shorts = (factor <= -context.min_surprise) & (factor >= -context.max_surprise)

    # Set our pipeline screens  
    # Filter down stocks using sentiment  
    article_sentiment = alphaone.article_sentiment.latest
    top_universe = is_liquid & longs & article_sentiment.notnan() \
        & (article_sentiment > .45)
    bottom_universe = is_liquid & shorts & article_sentiment.notnan() \
        & (article_sentiment < -.45)

    # Filter for certain types of buyback authorizations
    buyback_type = BuybackAuthorizations.previous_type.latest
    only_new_buybacks = buyback_type.eq("New")
    buybacks = BusinessDaysSinceBuybackAuth().eq(1) & \
        (article_sentiment > .45) & only_new_buybacks

    # Add long/shorts to the pipeline  
    pipe.add(top_universe, "longs")
    pipe.add(bottom_universe, "shorts")
    pipe.add(BusinessDaysSincePreviousEarnings(), 'pe')
    pipe.add(buybacks, "is_buyback")
    pipe.set_screen((days_since_release <= 7) & (previous_estimize_release_date.notnull()))
    return pipe  
        
def initialize(context):
    #: Declaring the days to hold, change this to what you want
    context.days_to_hold = 4
    #: 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 = .06

    #: OPTIONAL - Initialize our Hedge
    # See order_positions for hedging logic
    # context.spy = sid(8554)
    
    # Make our pipeline
    attach_pipeline(make_pipeline(context), 'estimize')

    
    # Log our positions at 10:00AM
    schedule_function(func=log_positions,
                      date_rule=date_rules.every_day(),
                      time_rule=time_rules.market_close(minutes=30))
    # Order our positions
    schedule_function(func=order_positions,
                      date_rule=date_rules.every_day(),
                      time_rule=time_rules.market_open())

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]
    assets_in_universe = results.index
    log.info((results.longs | results.is_buyback))
    context.positive_surprise = assets_in_universe[results.longs | results.is_buyback]
    context.negative_surprise = assets_in_universe[results.shorts]

def log_positions(context, data):
    #: Get all positions  
    if len(context.portfolio.positions) > 0:  
        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)  
        log.info(all_positions)  
        
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
    record(leverage=context.account.leverage)

    # Check our positions for loss or profit and exit if necessary
    check_positions_for_loss_or_profit(context, data)
    
    # Check if we've exited our positions and if we haven't, exit the remaining securities
    # that we have left
    for security in port:  
        if data.can_trade(security):  
            if context.stocks_held.get(security) is not None:  
                context.stocks_held[security] += 1  
                if context.stocks_held[security] >= context.days_to_hold:  
                    order_target_percent(security, 0)  
                    del context.stocks_held[security]  
            # If we've deleted it but it still hasn't been exited. Try exiting again  
            else:  
                log.info("Haven't yet exited %s, ordering again" % security.symbol)  
                order_target_percent(security, 0)  

    # Check our current 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.tolist() + current_negative_pos
    positive_stocks = context.positive_surprise.tolist() + current_positive_pos
    
    # Rebalance our negative surprise securities (existing + new)
    for security in negative_stocks:
        can_trade = context.stocks_held.get(security) <= context.days_to_hold or \
                    context.stocks_held.get(security) is None
        if data.can_trade(security) and can_trade and security != sid(32497):
            order_target_percent(security, -1.0 / 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:
        can_trade = context.stocks_held.get(security) <= context.days_to_hold or \
                    context.stocks_held.get(security) is None
        if data.can_trade(security) and can_trade and security != sid(32497):
            order_target_percent(security, 1.0 / 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.current(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 check_positions_for_loss_or_profit(context, data):
    # Sell our positions on longs/shorts for profit or loss
    for security in context.portfolio.positions:
        is_stock_held = context.stocks_held.get(security) >= 0
        if data.can_trade(security) and is_stock_held and not get_open_orders(security):
            current_position = context.portfolio.positions[security].amount  
            cost_basis = context.portfolio.positions[security].cost_basis  
            price = data.current(security, 'price')
            # On Long & Profit
            if price >= cost_basis * 1.06 and current_position > 0:  
                order_target_percent(security, 0)  
                log.info( str(security) + ' Sold Long for Profit')  
                del context.stocks_held[security]  
            # On Short & Profit
            if price <= cost_basis* 0.94 and current_position < 0:
                order_target_percent(security, 0)  
                log.info( str(security) + ' Sold Short for Profit')  
                del context.stocks_held[security]
            # On Long & Loss
            if price <= cost_basis * 0.94 and current_position > 0:  
                order_target_percent(security, 0)  
                log.info( str(security) + ' Sold Long for Loss')  
                del context.stocks_held[security]  
            # On Short & Loss
            if price >= cost_basis * 1.06 and current_position < 0:  
                order_target_percent(security, 0)  
                log.info( str(security) + ' Sold Short for Loss')  
                del context.stocks_held[security]  
There was a runtime error.

Dataset Descriptions:

Earnings Calendar (Sample data date range: 01 Jan 2007 - 10 Feb 2014)

from quantopian.pipeline.factors.eventvestor import (  
BusinessDaysUntilNextEarnings, # The number of business days until the next earnings report (0 == day of)  
BusinessDaysSincePreviousEarnings # The number of business days since the previous earnings report (0 == day of)  
)

from quantopian.pipeline.data.eventvestor import EarningsCalendar  
# Available Fields:  
# previous_announcement - datetime64[ns]  
# next_announcement - datetime64[ns]  

Buyback Authorizations (Sample data date range: 01 Jun 2007 - 30 May 2014)

# Number of business days since a buyback announcement (0 == day of)  
from quantopian.pipeline.factors.eventvestor import BusinessDaysSinceBuybackAuth

from quantopian.pipeline.data.eventvestor import BuybackAuthorizations  
# Fields:  
# previous_date - datetime64[ns]  
# previous_type - object  
# previous_amount - float64  
# previous_unit - object  

Crowdsourced Earnings Estimates from Estimize (Sample data date range: 18 Oct 2010 - 30 May 2015)

from quantopian.pipeline.data.estimize import (  
    ConsensusEstimizeEPS,  
    ConsensusWallstreetEPS,  
    ConsensusEstimizeRevenue,  
    ConsensusWallstreetRevenue  
)

# Fields:  
# previous_mean - float64  
# next_count - float64  
# previous_fiscal_year - float64  
# next_fiscal_quarter - float64  
# previous_fiscal_quarter - float64  
# previous_high - float64  
# next_standard_deviation - float64  
# previous_actual_value - float64  
# next_low - float64  
# next_fiscal_year - float64  
# previous_low - float64  
# previous_release_date - datetime64[ns]  
# previous_standard_deviation - float64  
# next_mean - float64  
# next_high - float64  
# next_release_date - datetime64[ns]  
# previous_count - float64  

Accern's News Sentiment (Sample data date range: 26 Aug 2012 - 30 May 2014)

from quantopian.pipeline.data.accern import alphaone  
# Fields:  
# article_sentiment - float64  
# impact_score - float64  

Custom Factor Descriptions:

# Calculate the percent surprise for each earnings announcement  
class PercentSurprise(CustomFactor):  
    window_length = 1  
    inputs = [ConsensusEstimizeEPS.previous_actual_value,  
              ConsensusEstimizeEPS.previous_mean]

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

# Calculates the number of days between an Estimize's earnings release  
# date and the current earnings announcement. This is to insure that we  
# are only getting the previous quarter's data and not more than 1 quarter  
# behind.  
class DaysSinceRelease(CustomFactor):  
    # Only getting the previous quarter's estimize surprise  
    window_length = 1  
    inputs = [EarningsCalendar.previous_announcement,  
              ConsensusEstimizeEPS.previous_release_date]  
    def compute(self, today, assets, out,  
                earnings_announcement, estimize_release):  
        days = estimize_release - earnings_announcement  
        out[:] = abs(days.astype('timedelta64[D]').astype(int))  

With ConsensusEstimizeEPS, how do I get hold of the current Estimize consensus estimate? I can see fields above marked "previous_" and "next_", which suggest to me the previous and upcoming quarter. Is the "next_" the current live consensus estimate, or is it looking ahead to the next quarterly announcement?

I note the Research dataset has a field called "mean", which is current.

Dan,

We've found an issue with the Estimize dataset and are shutting down access to it starting July 18th, 2016 (full details here). I'm happy to discuss the question with you over email slee @ quantopian.com

Seong

@Seong Lee - is there any way to calculate the percent surprise for each earnings announcement currently?