Quant BuffetRelax, Not Over Thinking

Post Earnings Announcement Drift in China

Log in to collect

Academic paper

Strategy in a nutshell

Monitor China A-share stocks for a ≥3% opening price jump after earnings preannouncements. Buy on the third day, hold for 60 days, sell, while shorting the overall market. Portfolio is value-weighted and dynamically rebalanced.

Economic rationale

The strategy leverages the post-earnings announcement drift (PEAD) phenomenon. Behavioral biases, rather than fundamental or Fama-French factors, help explain predictable price patterns, making earnings preannouncement-based trades profitable.

Backtest performance

Annualised return4.69%
Volatility7.66%
Beta0.051
Sharpe ratio0.61
Maximum drawdown-11.63%
Win rate57%

Full Python code

from AlgorithmImports import *
from typing import Dict, List
from data_tools import ChineseStocks, ChineseIncomeStatement, QuantpediaCSI500, SymbolData, \
CustomFeeModel, WaitingStock, ActiveStock
# endregion

class PostEarningsAnnouncementDriftInChina(QCAlgorithm):

def Initialize(self):
self.SetStartDate(2015, 1, 1)   # Chinese data starts in 2015
self.SetCash(100000)

self.short_flag:bool = True

self.leverage:int = 5

self.threshold:float = 0.03

self.wait_period:int = 3
self.holding_period:int = 60

self.active_universe:Dict[Symbol, ActiveStock] = {}     # symbols of stocks, which are actively traded
self.waiting_universe:Dict[Symbol, WaitingStock] = {}   # symbols of stocks, which overnight perf was greater than 3%
self.potential_trades:Dict[Symbol, datetime.date] = {}  # symbols of stocks with value of their annoucement date(income statement date)

self.data:dict[Symbol, SymbolData] = {}

self.top_size_symbol_count:int = 300
ticker_file_str:str = self.Download('data.quantpedia.com/backtesting_data/equity/chinese_stocks/large_cap_500.csv')
tickers:List[str] = ticker_file_str.split('\r\n')[:self.top_size_symbol_count]

for t in tickers:
    data = self.AddData(ChineseStocks, t, Resolution.Daily)
    data.SetFeeModel(CustomFeeModel())
    data.SetLeverage(self.leverage)

    stock_symbol:Symbol = data.Symbol
    data = self.AddData(ChineseIncomeStatement, t, Resolution.Daily)

    self.data[stock_symbol] = SymbolData(data.Symbol)

if self.short_flag:
    data = self.AddData(QuantpediaCSI500, 'CSI_500', Resolution.Daily)
    data.SetFeeModel(CustomFeeModel())
    data.SetLeverage(self.leverage)

    self.benchmark = data.Symbol

def OnData(self, data: Slice):
curr_date:datetime.date = self.Time.date()

# store daily data
for symbol, symbol_data in self.data.items():
    income_statement_symbol:Symbol = symbol_data.get_income_statement_symbol()

    if data.ContainsKey(symbol):
        price_data:dict[str, str] = data[symbol].GetProperty('price_data')
        # valid price data
        if data[symbol].Value != 0. and price_data:
            close_price:float = data[symbol].Value
            open_price:float = price_data['openPrice']
            symbol_data.update_prices(curr_date, close_price, open_price)

            mc:float = float(price_data['marketValue'])
            symbol_data.update_market_cap(mc)

    # check if stock has income statement day(announcement day)
    if data.ContainsKey(income_statement_symbol):
        self.potential_trades[symbol] = curr_date

potential_trades_to_remove:List[Symbol] = []
for symbol, announcement_day in self.potential_trades.items():
    # make sure overnight perf is calculated day after annoucement day
    if curr_date == announcement_day:
        continue
    
    symbol_data:SymbolData = self.data[symbol]
    # make sure prices for overnight perf calculations are ready
    if symbol_data.prices_ready():
        prev_close_price:float = symbol_data.get_prev_close_price()
        open_price:float = symbol_data.get_open_price()
        perf:float = (open_price - prev_close_price) / prev_close_price # overnight perf
        
        if perf >= self.threshold:
            # add stock's symbol to universe of stocks, which will be traded after self.wait_period days since curr_date
            self.waiting_universe[symbol] = WaitingStock(curr_date, symbol, self.wait_period)

    # stock's symbols has to be removed from universe of potential trades each time
    potential_trades_to_remove.append(symbol)

for symbol in potential_trades_to_remove:
    del self.potential_trades[symbol]

trade_flag:bool = False
waiting_stocks_to_remove:List[Symbol] = []

for symbol, waiting_stock_obj in self.waiting_universe.items():
    # stocks in waiting universe have to wait self.waiting_period before they will be trade
    # this condition makes sure these days will be fulfilled
    if curr_date != waiting_stock_obj.get_date():
        waiting_stock_obj.decrease_waiting_period(curr_date)

    if waiting_stock_obj.waiting_period_expired():
        # when self.waiting_period days pass, stock's symbol will be traded
        # and added to universe of actively trading stocks
        trade_flag = True
        waiting_stocks_to_remove.append(symbol)
        self.active_universe[symbol] = ActiveStock(curr_date, symbol, self.holding_period)

for symbol in waiting_stocks_to_remove:
    del self.waiting_universe[symbol]

new_active_universe:Dict[Symbol, ActiveStock] = {}
for symbol, active_stock_obj in self.active_universe.items():
    # stocks in active universe are hold for self.holding_period days
    # this condition makes sure these holding days will be fulfilled
    if curr_date != active_stock_obj.get_date():
        active_stock_obj.decrease_holding_period(curr_date)

    if active_stock_obj.holding_period_expired():
        # liquidate stock, when it was holded for self.holding_period days
        self.Liquidate(symbol)
        # change trade_flag to True, because liquidated stock was removed from active universe
        trade_flag = True
    else:
        new_active_universe[symbol] = active_stock_obj

self.active_universe = new_active_universe
# trade only, when there has been change in active universe
# when trade_flag is true, the signal for trade is met
if trade_flag:
    total_cap:float = sum(list(map(lambda symbol: self.data[symbol].get_market_cap(), self.active_universe)))
    for symbol in self.active_universe:
        self.SetHoldings(symbol, self.data[symbol].get_market_cap() / total_cap)

    if self.short_flag:
        if not self.Securities[self.benchmark].Invested and len(self.active_universe) != 0:
            self.SetHoldings(self.benchmark, -1)
        elif self.Securities[self.benchmark].Invested:
            self.Liquidate(self.benchmark)