Quantlandian

Are Earnings Predictable with Buyback Announcements?

Copyright reserved by Seong Lee - edited Sep 23, 2016 in Quantopian

Most financial analysts will agree that managers often have an informational advantage. This advantage can lead to a number of decisions like when an executive decides to dump company shares prior to a poor company update or when a company announces a buyback to boost share prices after a bad quarter. In fact, the latter seems to be a common occurrence.

Shahram Amini and Vijay Singal from Virginia Tech propose that managers use their information edge to time corporate actions close to quarterly announcements in order to maximize shareholder value. Their whitepaper, “Are Earnings Predictable?”, examines the effects of share buybacks and issues surrounding an earnings announcement. The study documents consistent positive abnormal returns for earnings announcements following buyback announcements with similarly negative abnormal returns for share issues regardless of the earnings themselves. This is quite surprising because it indicates that “that the market adjustment to corporate actions is incomplete, and can result in predictability of earnings announcements.”

Our friends at Quantpedia provide a possible explanation for the market’s reaction:

The academic paper states that it is generally accepted that managers
have more information about the firm than investors. Given this
information asymmetry, managers can make informed decisions about
corporate actions such as equity offerings or repurchases. The
announcement of stock repurchase or secondary equity offering is
voluntary and can be easily moved by a few weeks or months. Therefore
the timing of SEO or repurchase announcement before earnings
announcement could be perceived as important information about future
performance of stock during earnings announcement period.

In order to validate the authors’ research, my notebook (view the notebook to see a walkthrough of the whitepaper) attempts an OOS implementation of the methods used in the whitepaper. I examine share buybacks and earnings announcements from 2011 till 2016 finding similar results to the authors with positive returns of 1.115% in a (-10, +15) day window surrounding earnings. Using these results, the folks at Quantpedia and I attempted to craft an example trading strategy that would profit off the positive market reaction that seems to follow earnings reports made after a buyback announcement.

Trading Strategy Details

Looking at a universe of the top 2,000 most liquid securities, the algorithm filters for securities that have announced a buyback [-15, 0] days before an earnings announcements and goes long on that security for either 25 days or 15 days after the earnings (whichever comes first).

FAQ

What is the Quantpedia Trading Strategy Series?

Quantpedia is an online resource for discovering trading strategies and we’ve teamed up with them to bring you interactive and high quality trading strategy examples based off financial research. Our goal is that you’re able to replicate the process we’ve used here for your own research and backtesting.

This is a high beta strategy! Why are you posting a strategy that’s long-only?

This series is meant to bring you high quality research and trading strategy examples. We plan to release long short, low-beta strategies in the future. For this specific strategy, the share issuance dataset would’ve been needed to create the short portfolio but a possible substitute would be to use negative earnings surprises as the short portfolio for this strategy.

Where can I find more trading strategy ideas?

You can find the full Quantpedia Series here along with other curated pieces of research. Other than that, you can browse Quantpedia’s strategies or look through our forums for ideas posted by community members. Want to feature your own? Submit your proposal to SLEE @ quantopian.com


import numpy as np
import pandas as pd
from quantopian.algorithm import attach_pipeline, pipeline_output
from quantopian.pipeline import Pipeline
from quantopian.pipeline.classifiers.morningstar import Sector
from quantopian.pipeline.data.builtin import USEquityPricing
from quantopian.pipeline.data import morningstar as mstar
from quantopian.pipeline.factors import (
    AverageDollarVolume,
    CustomFactor
)
from quantopian.pipeline.filters import Q1500US,Q500US
from quantopian.pipeline.filters.morningstar import IsPrimaryShare

from quantopian.pipeline.factors.eventvestor import (
    BusinessDaysUntilNextEarnings,
    BusinessDaysSincePreviousEarnings,
    BusinessDaysSinceBuybackAuth,
)

from quantopian.pipeline.data.eventvestor import BuybackAuthorizations

import quantopian.experimental.optimize as opt
import quantopian.algorithm as algo

MAX_GROSS_LEVERAGE = 1.0

def make_pipeline():
    """
    Create and return our pipeline.

    We break this piece of logic out into its own function to make it easier to
    test and modify in isolation.

    In particular, this function can be copy/pasted into research and run
    by itself.
    """
    # When current day is 6 days from a buyback announcement
    # and 10 days from an earnings
    till_earnings = BusinessDaysUntilNextEarnings()
    since_buybacks = BusinessDaysSinceBuybackAuth()
    since_earnings = BusinessDaysSincePreviousEarnings()

    # Strategy 1A: When there is a buyback announcement with a
    # known future earnings date at least 1 day ahead, go long
    # on the security starting on the buyback announcement date
    # for 25 days starting on day t-15
    # |-------buybacks & earnings-------| (earnings_announcement)
    longs = ((since_buybacks + till_earnings) <= 15) & \
            since_buybacks.notnan() & till_earnings.notnan()

    return Pipeline(
        columns={
            'till_earnings': till_earnings,
            'since_buybacks': since_buybacks,
            'since_earnings': since_earnings,
            'market_cap': mstar.valuation.market_cap.latest,
            'buyback_unit': BuybackAuthorizations.previous_unit.latest,
            'buyback_amount': BuybackAuthorizations.previous_amount.latest,
            'pricing': USEquityPricing.close.latest,
            'longs': longs
        },
        screen=(longs & Q500US())
    )


def initialize(context):
    # Hold for a period of [-10, +15)
    context.days_to_hold = 25

    # Declares which stocks we currently held
    # and how many days we've held them dict[stock:days_held]
    context.stocks_held = {}

    # Make our pipeline
    algo.attach_pipeline(make_pipeline(), 'buybacks_and_earnings')

    # Order our positions
    algo.schedule_function(func=order_positions,
                      date_rule=date_rules.every_day(),
                      time_rule=time_rules.market_open())

    context.longs = None


def before_trading_start(context, data):
    results = pipeline_output('buybacks_and_earnings')
    log.info("Results: %r" % results)
    if len(results.index) == 0:
        return

    # Only look at buybacks > 5%
    results = results.apply(lambda row: convert_units(row), axis=1)
    results = results[results['Percent of SO'] > .05]
    assets_in_universe = results.index
    context.longs = assets_in_universe[results.longs]

    for stock in context.longs:
        log.info("\n")
        log.info("%s: %s days since buyback and %s left till earnings"
                 % (stock.symbol, results.ix[stock]['since_buybacks'],
                    results.ix[stock]['till_earnings']))


def convert_units(row):
    buyback_unit = row['buyback_unit']
    market_cap = row['market_cap']
    shares_outstanding = market_cap/row['pricing']
    if buyback_unit == '$M':
        total_bought = row['buyback_amount'] * 1000000.0
        percent_bought = (total_bought)/market_cap
    elif buyback_unit == "Mshares":
        percent_bought = row['buyback_amount']/shares_outstanding
    elif buyback_unit == '%':
        percent_bought = row['buyback_amount']/100.0
    else:
        percent_bought = None

    row['Percent of SO'] = percent_bought
    return row

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,
           positions=len(context.portfolio.positions))
    
    weights = {}

    # 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)
                    weights[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)
                weights[security] = 0

    if context.longs is None:
        return

    # Check our current positions
    current_longs = [pos for pos in port if
                     (port[pos].amount > 0 and pos in context.stocks_held)]
    all_longs = context.longs.tolist() + current_longs

    # Rebalance our long securities (existing + new)
    for security in all_longs:
        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:
            # order_target_percent(security, 1.0 / len(all_longs))
            weights[security] = 1.0 / len(all_longs)
            if context.stocks_held.get(security) is None:
                context.stocks_held[security] = 0
    
    universe = set(all_longs)
    
    if len(universe) == 0:
        return
    
    objective = opt.TargetPortfolioWeights(weights)
    
    leverage_constraint = opt.MaxGrossLeverage(MAX_GROSS_LEVERAGE)
    
    algo.order_optimal_portfolio(
        objective=objective,
        constraints=[
            leverage_constraint
        ],
        universe=universe
    )

BUILD OUR QUANTLAND