Quant BuffetRelax, Not Over Thinking

Cash Operating Profitability Predicts Earnings Announcement Returns

Log in to collect

Academic paper

Why Does Operating Profitability Predict Returns? New Evidence on Risk versus Mispricing Explanations

AuthorsAnwer S. Ahmed; Michael Neel; Irfan Safdar

Institute
  • Texas A&M University
  • ?Texas A&M University - Mays Business School
  • University of North Texas
  • ?University of North Texas - Department of Accounting
  • Widener University

Strategy in a nutshell

Long-short strategy on U.S. non-microcap stocks around earnings announcements: go long high-profitability firms and short low-profitability firms based on cash operating profitability (COP). Portfolios are rebalanced daily and value-weighted over four quarterly earnings periods.

Economic rationale

Profitability predicts future returns: high-COP firms exhibit lower crash risk and higher upside potential. Strategy exploits underreaction by analysts and institutional investors, capturing mispricing around earnings announcements.

Backtest performance

Annualised return68.81%
Volatility50.57%
Beta-0.006
Sharpe ratio1.36
Sortino ratio0.046
Win rate52%

Full Python code

from AlgorithmImports import *
import numpy as np
from collections import deque
from typing import Dict, List
import data_tools
from pandas.tseries.offsets import BDay
from dateutil.relativedelta import relativedelta
from numpy import isnan
from functools import reduce
# endregion

class CashOperatingProfitabilityPredictsEarningsAnnouncementReturns(QCAlgorithm):

def Initialize(self):
self.SetStartDate(2008, 1, 1)
self.SetCash(100000)

self.quantile:int = 10
self.leverage:int = 5
self.long_num:int = 5
self.short_num:int = 5
self.holding_period:int = 5
self.DR_period:int = 2

self.financial_statement_names:List[str] = [
    'FinancialStatements.IncomeStatement.TotalRevenue.TwelveMonths',
    'FinancialStatements.IncomeStatement.CostOfRevenue.TwelveMonths',
    'FinancialStatements.IncomeStatement.GeneralAndAdministrativeExpense.TwelveMonths',
    'FinancialStatements.IncomeStatement.ResearchAndDevelopment.TwelveMonths',
    'FinancialStatements.CashFlowStatement.ChangeInReceivables.TwelveMonths',
    'FinancialStatements.CashFlowStatement.ChangeInInventory.TwelveMonths',
    'FinancialStatements.CashFlowStatement.ChangeInPrepaidAssets.TwelveMonths',
    'FinancialStatements.BalanceSheet.CurrentDeferredRevenue.TwelveMonths',
    'FinancialStatements.CashFlowStatement.ChangeInAccountPayable.TwelveMonths',
    'FinancialStatements.CashFlowStatement.ChangeInAccruedExpense.TwelveMonths',
]

self.long:List[Symbol] = []
self.short:List[Symbol] = []
self.DR_data:Dict[Symbol, float] = {}  

self.earnings_data:Dict[datetime.date, List[str]] = {} 
self.tickers:Set(str) = set()

self.first_date:Union[datetime.date, None] = 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.tickers.add(ticker)
        self.earnings_data[date].append(ticker)

# 5 equally weighted brackets for traded symbols. - 5 symbols long, 5 symbols short, 5 days of holding
self.trade_manager:TradeManager = data_tools.TradeManager(self, self.long_num, self.short_num, self.holding_period)

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

self.selection_month:int = 4
self.selection_flag:bool = False
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
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]: 
# yearly selection
if self.selection_flag:
    self.selection_flag = False

    COP_stocks:Dict[Fundamental, float] = {}
    selected:List[Fundamental] = [x for x in fundamental if x.Symbol.Value in self.tickers and \
                all((not isnan(self.rgetattr(x, statement_name)) and self.rgetattr(x, statement_name) != 0) for statement_name in self.financial_statement_names)]

    for stock in selected:
        symbol:Symbol = stock.Symbol

        if symbol not in self.DR_data:
            self.DR_data[symbol] = data_tools.DRChangeManager(self.DR_period)
        self.DR_data[symbol].update_data(stock.FinancialStatements.BalanceSheet.CurrentDeferredRevenue.TwelveMonths)
        
        if not self.DR_data[symbol].is_ready():
            continue
        
        DR_change = self.DR_data[symbol].get_DR_change()
        sales, COGS, SG_A, R_D, REC_change, INV_change, XPP_change, DR_change, AP_change, XACC_change = [self.rgetattr(stock, statement_name) for statement_name in self.financial_statement_names]

        # calculate COP value on stocks
        if stock not in COP_stocks:
            COP_stocks[symbol] = sales - COGS - (SG_A - R_D) - REC_change - INV_change - XPP_change + DR_change + AP_change + XACC_change            

    # sort and divide to quantiles
    if len(COP_stocks) >= self.quantile:
        sorted_COP = sorted(COP_stocks, key=COP_stocks.get, reverse=True)
        quantile:int = int(len(sorted_COP) / self.quantile)
        self.long = sorted_COP[:quantile]
        self.short = sorted_COP[-quantile:]       

return self.long + self.short

def OnData(self, data: Slice) -> None:
# liquidate opened symbols after five days
self.trade_manager.TryLiquidate()

date_to_lookup:datetime.date = (self.Time + BDay(2)).date()

# if there is no earnings data yet
if date_to_lookup < self.first_date:
    # clear long set
    self.long.clear()
    self.short.clear()

# open new trades
symbols_to_delete = []
if date_to_lookup in self.earnings_data:
    for symbol in self.long:
        # Next day is earnings day for the symbol.
        if symbol.Value in self.earnings_data[date_to_lookup] and symbol in data and data[symbol]:
            self.trade_manager.Add(symbol, True)

    for symbol in self.short:
        # Next day is earnings day for the symbol.
        if symbol.Value in self.earnings_data[date_to_lookup] and symbol in data and data[symbol]:
            self.trade_manager.Add(symbol, False)

def Selection(self) -> None:
if self.Time.month == self.selection_month:
    self.selection_flag = True
    
    self.long.clear()
    self.short.clear()

# https://gist.github.com/wonderbeyond/d293e7a2af1de4873f2d757edd580288
def rgetattr(self, obj, attr, *args):
def _getattr(obj, attr):
    return getattr(obj, attr, *args)
return reduce(_getattr, [obj] + attr.split('.'))