Notebook

Behavioral Arbitrage - Design Strategies That Time Market Mistakes

By Cheng Peng

Video Here

Behavioral Arbitrage refers to arbitrage opportunities that are based on how human biases affect the capital markets, especailly prices - these opportunities are typically known as the Behavioral Gap. There are multiple ways human behavior can influence market prices, and so, awareness of these behaviors can help in our analysis and trading. Generally, there are two types of biases: cognitive and emotional. Cognitive biases are generally hard to notice because they appear when interpreting and processing information. Emotional biases are easier to recognize - but difficult to manage since our biological responses can upset even the most rational plans.

Proposed Hypothesis Framework

  1. What is the market thinking?
    • What is the current sentiment of the market towards this asset? Is it bullish, bearish or going unnoticed?
  2. Why is the market wrong?
    • Based on current information, is the market sentiment correct? Too extreme or just wrong?
  3. How wrong is the market?
    • What conditions can further explain the difference between sentiment and reality?

Event Studies

This framework is generally enough to be applied to any model - however, I find event studies to be particularly fitting for behavioral explanations. Event studies bring homogeneity to the data by filtering and isolating most of the noisy information irrelevant to our samples. This notebook will focus on Earnings Announcements in stocks, and especially how new information directly impacts future market prices.

First, we create a few convenient methods for running event studies with a factor.

In [1]:
import alphalens
from scipy import stats

from quantopian.pipeline import Pipeline
from quantopian.research import run_pipeline
from quantopian.pipeline.data.builtin import USEquityPricing
from quantopian.pipeline.factors import Returns
from quantopian.pipeline.experimental import QTradableStocksUS
from quantopian.pipeline.data import Fundamentals
from quantopian.pipeline.factors.eventvestor import BusinessDaysUntilNextEarnings, BusinessDaysSincePreviousEarnings

# Earnings Imports Zacks
from quantopian.pipeline.data.zacks import EarningsSurprises
from quantopian.pipeline.factors.zacks import BusinessDaysSinceEarningsSurprisesAnnouncement
from quantopian.pipeline.factors.eventvestor import BusinessDaysUntilNextEarnings, BusinessDaysSincePreviousEarnings
In [2]:
start = '%s-01-01' % 2011
end =  '%s-12-31' % 2015

def make_pipeline(factor, universe):
    combined_alpha = factor.zscore(mask=universe)
    
    pipe = Pipeline(
        columns={
            'combined_alpha': combined_alpha
        },
        screen=universe & combined_alpha.notnan()
    )
    return pipe

def run_new_event_study(
    universe=QTradableStocksUS(), 
    factor=Returns(window_length=5), 
    days_before=5, days_after=5, 
    long_short=False,
    quantiles=None,
    bins=[0],
    periods=(1,2,3,4,5),
    price_type='open_price'):

    results = run_pipeline(make_pipeline(factor, universe), start_date=start, end_date=end)
    security_universe = results.index.levels[1].unique().tolist()

    prices = get_pricing(security_universe, start_date=start, end_date=end, fields=price_type)
    factor_data = alphalens.utils.get_clean_factor_and_forward_returns(
        factor=results['combined_alpha'], 
        prices=prices,
        periods=periods,
        quantiles=quantiles,
        bins=bins
    )

    alphalens.tears.create_event_study_tear_sheet(
        factor_data,
        prices,
        avgretplot=(days_before, days_after),
    )

Recency Biases Pre Earnings

  1. What is the market thinking?
    • Weekly returns + last earnings surprise
  2. Why is the market wrong?
    • Exaggeration based on last earnings surprise
  3. How wrong is the market?
    • Unstable earnings estimates lead to larger discrepancies

Event Studies To Run

  • Earnings Surprise Before Earnings
  • Weekly Returns Before Earnings
  • (Earnings Surprise + Weekly Returns) Before Earnings
  • (Earnings Surprise + Weekly Returns) Before Earnings Filtering For Unstable Estimates

Earnings Surprise Before Earnings

In [5]:
factor = EarningsSurprises.eps_pct_diff_surp.latest
point_in_time = BusinessDaysUntilNextEarnings().eq(1)
universe = QTradableStocksUS() & factor.notnan() & point_in_time
run_new_event_study(
    universe=universe, 
    factor=factor, 
    days_before=0, days_after=5, 
    long_short=True, 
    bins=None, quantiles=4)
Dropped 0.3% entries from factor data: 0.0% in forward returns computation and 0.3% in binning phase (set max_loss=0 to see potentially suppressed Exceptions).
max_loss is 35.0%, not exceeded: OK!
Quantiles Statistics
min max mean std count count %
factor_quantile
1.0 -12.455037 0.469154 -0.682293 1.039889 8475 28.259420
2.0 -0.974619 0.702058 -0.108819 0.175358 6833 22.784261
3.0 -0.564710 1.171512 0.024015 0.171587 7053 23.517839
4.0 -0.446910 12.364636 0.833095 1.205553 7629 25.438479
<matplotlib.figure.Figure at 0x7f009c084bd0>
<matplotlib.figure.Figure at 0x7f0096838390>

Weekly Returns Before Earnings

In [6]:
factor = Returns(window_length=5)
point_in_time = BusinessDaysUntilNextEarnings().eq(1)
universe = QTradableStocksUS() & factor.notnan() & point_in_time
run_new_event_study(
    universe=universe, 
    factor=factor, 
    days_before=0, days_after=5, 
    long_short=True, 
    bins=None, quantiles=4)
Dropped 0.0% entries from factor data: 0.0% in forward returns computation and 0.0% in binning phase (set max_loss=0 to see potentially suppressed Exceptions).
max_loss is 35.0%, not exceeded: OK!
Quantiles Statistics
min max mean std count count %
factor_quantile
1 -8.869759 0.432751 -1.160334 0.691155 8313 26.195878
2 -0.994887 0.683128 -0.231266 0.214357 7806 24.598223
3 -0.627731 1.196967 0.263298 0.208894 7526 23.715888
4 -0.196040 8.645558 1.171104 0.688884 8089 25.490011
<matplotlib.figure.Figure at 0x7f0096757590>
<matplotlib.figure.Figure at 0x7f0096757a90>

(Earnings Surprise + Weekly Returns) Before Earnings

In [7]:
factor = (EarningsSurprises.eps_pct_diff_surp.latest.rank() + Returns(window_length=5).rank())
point_in_time = BusinessDaysUntilNextEarnings().eq(1)
universe = QTradableStocksUS() & factor.notnan() & point_in_time
run_new_event_study(
    universe=universe, 
    factor=factor, 
    days_before=0, days_after=5, 
    long_short=True, 
    bins=None, quantiles=4)
Dropped 0.0% entries from factor data: 0.0% in forward returns computation and 0.0% in binning phase (set max_loss=0 to see potentially suppressed Exceptions).
max_loss is 35.0%, not exceeded: OK!
Quantiles Statistics
min max mean std count count %
factor_quantile
1 -3.383404 0.153830 -1.264886 0.421868 7893 26.243516
2 -1.080606 0.694442 -0.328566 0.280272 7389 24.567762
3 -0.602228 1.240970 0.398136 0.254633 7120 23.673361
4 -0.137434 3.296036 1.247782 0.383622 7674 25.515361
<matplotlib.figure.Figure at 0x7f009672bd90>
<matplotlib.figure.Figure at 0x7f007e2d9250>

(Earnings Surprise + Weekly Returns) Before Earnings With Unstable Estimates

In [11]:
factor = (EarningsSurprises.eps_pct_diff_surp.latest.rank() + Returns(window_length=5).rank())
filter_factor = EarningsSurprises.eps_std_dev_est.latest
point_in_time = BusinessDaysUntilNextEarnings().eq(1)

universe = QTradableStocksUS() & factor.notnan() & filter_factor.notnan()
universe = filter_factor.rank(mask=universe).percentile_between(70, 100) & point_in_time

run_new_event_study(
    universe=universe, 
    factor=factor, 
    days_before=0, days_after=5, 
    long_short=True, 
    bins=None, quantiles=4)
Dropped 0.0% entries from factor data: 0.0% in forward returns computation and 0.0% in binning phase (set max_loss=0 to see potentially suppressed Exceptions).
max_loss is 35.0%, not exceeded: OK!
Quantiles Statistics
min max mean std count count %
factor_quantile
1 -3.031697 0.060151 -1.228915 0.435295 2334 28.073130
2 -1.080379 0.690530 -0.289151 0.303036 1963 23.610777
3 -0.495789 1.184307 0.410273 0.267594 1800 21.650229
4 0.065264 2.684728 1.216340 0.363391 2217 26.665865
<matplotlib.figure.Figure at 0x7f007dfdbed0>
<matplotlib.figure.Figure at 0x7f0096354450>

Price Inefficiency Post Earnings

  1. What is the market thinking?
    • Adjusting prices from new earnings announcement
  2. Why is the market wrong?
    • Information overload from multiple earnings
  3. How wrong is the market?
    • Consistent earnings surprises go unnoticed because of limited attention

Event Studies To Run

  • PE Ratio Post Earnings
  • PE Ratio Post Earnings Filtering For Low Absolute Earnings Surprise

PE Ratio Post Earnings

In [9]:
factor = Fundamentals.pe_ratio.latest
point_in_time = BusinessDaysSincePreviousEarnings().eq(1)
universe = QTradableStocksUS() & factor.notnan() & point_in_time

run_new_event_study(
    universe=universe, 
    factor=factor, 
    days_before=0, days_after=5,
    long_short=True, 
    bins=None, quantiles=4)
Dropped 0.0% entries from factor data: 0.0% in forward returns computation and 0.0% in binning phase (set max_loss=0 to see potentially suppressed Exceptions).
max_loss is 35.0%, not exceeded: OK!
Quantiles Statistics
min max mean std count count %
factor_quantile
1 -2.413576 0.025693 -0.425386 0.259665 8438 26.208225
2 -1.003266 0.657991 -0.294832 0.119536 7927 24.621071
3 -0.648377 1.270079 -0.178480 0.151318 7641 23.732762
4 -0.416306 14.319737 0.890086 1.653688 8190 25.437943
<matplotlib.figure.Figure at 0x7f00b3581150>
<matplotlib.figure.Figure at 0x7f00740a74d0>

PE Ratio Post Earnings With Low Absolute Earnings Surprise

In [8]:
factor = Fundamentals.pe_ratio.latest
filter_factor = (EarningsSurprises.eps_pct_diff_surp.latest**2)**0.5
point_in_time = BusinessDaysSincePreviousEarnings().eq(1)

universe = QTradableStocksUS() & factor.notnan() & filter_factor.notnan()
universe = filter_factor.rank(mask=universe).percentile_between(0, 30) & point_in_time

run_new_event_study(
    universe=universe, 
    factor=factor, 
    days_before=0, days_after=5,
    long_short=True, 
    bins=None, quantiles=4)
Dropped 0.0% entries from factor data: 0.0% in forward returns computation and 0.0% in binning phase (set max_loss=0 to see potentially suppressed Exceptions).
max_loss is 35.0%, not exceeded: OK!
Quantiles Statistics
min max mean std count count %
factor_quantile
1 -2.263972 -0.112666 -0.641142 0.335718 2508 28.059969
2 -0.908051 0.687249 -0.349475 0.175858 2127 23.797270
3 -0.570112 1.098804 -0.140710 0.233284 1936 21.660327
4 -0.426321 8.479850 1.107964 1.340137 2367 26.482435
<matplotlib.figure.Figure at 0x7f7f2e70f250>
<matplotlib.figure.Figure at 0x7f7f33381350>

This notebook is for informational purposes only and does not constitute as investment advice in any security or any form. There are no guarantees for accuracy or completeness, and all information is subject to the reader's interpretation.