Quant BuffetRelax, Not Over Thinking

Post-Split Drift Combined with PEAD Anomaly

Log in to collect

Academic paper

Post-Split Drift and Post-Earnings Announcement Drift: One Anomaly or Two?

AuthorsKonan Chan; Fengfei Li; Tse‐Chun Lin

Institute
  • TWNational Chengchi University
  • ?National Chengchi Unversity (NCCU) - Finance
  • Deakin University
  • ?Deakin University - Deakin Business School
  • HKUniversity of Hong Kong
  • ?The University of Hong Kong - Faculty of Business and Economics

Strategy in a nutshell

The strategy trades U.S. stocks around earnings announcements, focusing on top and bottom quintile SUEs. It goes long on high SUE stocks with recent splits and shorts low SUE stocks without splits, holding positions for three months.

Economic rationale

Stock splits signal future earnings, but investors and analysts underreact, creating post-split and post-earnings drift. Exploiting this delayed response allows systematic strategies to capture predictable excess returns.

Backtest performance

Annualised return23.32%
Volatility29.89%
Beta-0.001
Sharpe ratio0.78
Sortino ratio-1.861
Win rate55%

Full Python code

from AlgorithmImports import *
import data_tools
import numpy as np
from collections import deque
import dateutil.relativedelta
from dateutil.relativedelta import relativedelta
from typing import List, Dict, Set, Deque
from numpy import isnan
#endregion
class PostSplitDriftCombinedWithPEADAnomaly(QCAlgorithm):
def Initialize(self) -> None:
self.SetStartDate(2011, 1, 1)
self.SetCash(100_000)

self.symbol: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol

# Set of unique symbols to trade.
self.long: Set = set()
self.short: Set = set()

self.leverage: int = 5
self.period: int = 13
self.long_symbols: int = 20
self.short_symbols: int = 20
self.holding_period: int = 90
self.percentiles: List[int] = [20, 80]
self.eps_data: Dict[Symbol, Deque[datetime, float]] = {}
# Import earnigns data.
self.earnings_data: Dict[datetime, str] = {}
# Import splits data
self.splits_data: Dict[str, datetime.date] = {}

# Surprise data count needed to count standard deviation.
self.surprise_period: int = 4
self.earnings_surprise: Dict[Deque[float]] = {}

# SUE history for previous quarter used for statistics.
self.sue_history_previous: Deque[float] = deque()
self.sue_history_actual: Deque[float] = deque()

self.first_date: Union[None, datetime.date] = None
earnings_data: str = self.Download('data.quantpedia.com/backtesting_data/economic/earnings_dates_eps.json')
earnings_data_json: List[dict] = json.loads(earnings_data)

for obj in earnings_data_json:
    date: datetime.date = datetime.strptime(obj['date'], "%Y-%m-%d").date()
    self.earnings_data[date] = []
    if not self.first_date: self.first_date = date
    for stock_data in obj['stocks']:
        ticker: str = stock_data['ticker']
        self.earnings_data[date].append(ticker)
        
csv_string_file: str = self.Download('data.quantpedia.com/backtesting_data/economic/splits.csv')
lines: str = csv_string_file.split('\r\n')
for line in lines:
    if line == '':
        continue
    line_split: str = line.split(';')
    symbol: str = line_split[0]
    
    self.splits_data[symbol] = []
    
    for i in range(1, len(line_split)):
        if line_split[i] is not '':
            date: datetime = datetime.strptime(line_split[i], '%m/%d/%Y').date()
            self.splits_data[symbol].append(date)

# 70 equally weighted brackets for traded symbols. - 20 symbols long, 20 symbols short, 90 days of holding.
self.trade_manager: data_tools.TradeManager = data_tools.TradeManager(self, self.long_symbols, self.short_symbols, self.holding_period)

self.month: int = 12
self.selection_flag: bool = True
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.settings.daily_precise_end_time = False
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.Schedule.On(self.DateRules.MonthStart(self.symbol), self.TimeRules.AfterMarketOpen(self.symbol), self.Selection)
def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
    security.SetFeeModel(data_tools.CustomFeeModel())
    security.SetLeverage(self.leverage)

def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
if not self.selection_flag:
    return Universe.Unchanged
self.selection_flag = False

# S&P 500 universe from self.splits_data tickers.
selected: List[Fundamental] = [x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' \
                        and not isnan(x.EarningReports.BasicEPS.ThreeMonths) and x.EarningReports.BasicEPS.ThreeMonths != 0 \
                        and not isnan(x.EarningReports.FileDate.Value.year) and x.EarningReports.FileDate.Value.year != 1 \
                        and x.Symbol.Value in self.splits_data]
# SUE data.
sue_data: Dict[Symbol, float] = {}
for stock in selected:
    symbol: Symbol = stock.Symbol
    # Store eps data.
    if symbol not in self.eps_data:
        self.eps_data[symbol] = deque(maxlen = self.period)
    data: Tuple[datetime.date, float] = (stock.EarningReports.FileDate.Value.date(), stock.EarningReports.BasicEPS.ThreeMonths)
    # NOTE: Handles duplicate values. QC fine contains duplicated stocks in some cases.
    if data not in self.eps_data[symbol]:
        self.eps_data[symbol].append(data)
        
    if len(self.eps_data[symbol]) == self.eps_data[symbol].maxlen:
        recent_eps_data: datetime = self.eps_data[symbol][-1]
        
        year_range: List[int] = range(self.Time.year - 3, self.Time.year)
        
        last_month_date: datetime.date = recent_eps_data[0] + relativedelta(months = -1)
        next_month_date: datetime.date = recent_eps_data[0] + relativedelta(months = 1)
        month_range: List[int] = [last_month_date.month, recent_eps_data[0].month, next_month_date.month]
        # Earnings with todays month number 4 years back.
        seasonal_eps_data: List[Tuple[datetime.date, float]] = [x for x in self.eps_data[symbol] if x[0].month in month_range and x[0].year in year_range]
        if len(seasonal_eps_data) != 3:
            continue
        
        # Make sure we have a consecutive seasonal data. Same months with one year difference.
        year_diff: np.ndarray = np.diff([x[0].year for x in seasonal_eps_data])
        if all(x == 1 for x in year_diff):
            seasonal_eps: List[float] = [x[1] for x in seasonal_eps_data]
            diff_values: List[float] = np.diff(seasonal_eps)
            drift: float = np.average(diff_values)
            
            # SUE calculation.
            last_earnings: float = seasonal_eps[-1]
            expected_earnings: float = last_earnings + drift
            actual_earnings: float = recent_eps_data[1]
            # Store sue value with earnigns date.
            earnings_surprise: float = actual_earnings - expected_earnings
            if symbol not in self.earnings_surprise:
                self.earnings_surprise[symbol] = deque()
            else:
                # Surprise data is ready.
                if len(self.earnings_surprise[symbol]) >= self.surprise_period:
                    earnings_surprise_std:float = np.std(self.earnings_surprise[symbol])
                    sue: float = earnings_surprise / earnings_surprise_std

                    sue_data[symbol] = sue
                        
                    # Store pair in this month's history.
                    self.sue_history_actual.append(sue)
            self.earnings_surprise[symbol].append(earnings_surprise)
        
if len(sue_data) != 0: 
    # Wait until we have history data for previous three months.
    if len(self.sue_history_previous) != 0:
        # Sort by SUE
        top_sue_quintile: float = np.percentile(self.sue_history_previous, self.percentiles[1])
        bottom_sue_quintile: float = np.percentile(self.sue_history_previous, self.percentiles[0])
        self.long = [x[0] for x in sue_data.items() if x[1] >= top_sue_quintile]
        self.short = [x[0] for x in sue_data.items() if x[1] <= bottom_sue_quintile]
 
return list(self.long) + list(self.short)

def OnData(self, data: Slice) -> None:
# Liquidate opened symbols after 90 days.
self.trade_manager.TryLiquidate()
current_date: datetime.date = (self.Time).date()
three_months_ago: datetime.date = (current_date - dateutil.relativedelta.relativedelta(months=3))
date_to_lookup: datetime.date = (self.Time - timedelta(days=3)).date()

# If there is no earnings data yet.
if date_to_lookup < self.first_date:
    # Clear long and short set.
    self.long.clear()
    self.short.clear()
# Open new trades.
symbols_to_delete: List[Symbol] = []
if date_to_lookup in self.earnings_data:
    for symbol in self.long:
        if symbol.Value in self.earnings_data[date_to_lookup]: # Check if stock had earnings date three days before this one
            if symbol.Value in self.splits_data:
                if self.CheckSplitDate(symbol.Value, three_months_ago, current_date):
                    self.trade_manager.Add(symbol, True)
                    symbols_to_delete.append(symbol)
                
    for symbol in self.short:
        if symbol.Value in self.earnings_data[date_to_lookup]: # Check if stock had earnings date three days before this one
            if symbol.Value in self.splits_data:
                if self.CheckSplitDate(symbol.Value, three_months_ago, current_date):
                    self.trade_manager.Add(symbol, False)
                    symbols_to_delete.append(symbol)
                    
# Delete already traded symbols from long or short set
for symbol in symbols_to_delete:
    if symbol in self.long:
        self.long.remove(symbol)
    if symbol in self.short:
        self.short.remove(symbol)

def CheckSplitDate(self, symbol: Symbol, date_from: datetime.date, date_to: datetime.date) -> bool:
for split_date in self.splits_data[symbol]:
    if date_from <= split_date <= date_to:
        return True
    
return False

def Selection(self) -> None:
self.selection_flag = True

# Every three months.    
if self.month % 3 == 0:
    # Save previous month history.
    self.sue_history_previous = [x for x in self.sue_history_actual]
    self.sue_history_actual.clear()
self.month += 1
if self.month > 12:
    self.month = 1
    
# Custom fee model
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters: OrderFeeParameters) -> OrderFee:
fee: float = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))