The strategy trades Russell 3000 stocks with low institutional ownership and high volume, going long before earnings announcements, short after, using equal weighting and daily rebalancing.

I. STRATEGY IN A NUTSHELL: Daily U.S. Equity Institutional Ownership Earnings Timing Strategy

This daily U.S. equity strategy targets Russell 3000 stocks with low institutional ownership and high trading volume around earnings announcements. The investor goes long two days before the announcement and shorts two days after. Positions are equally weighted, with daily rebalancing to maintain alignment with upcoming earnings events.

II. ECONOMIC RATIONALE

Behavioral research suggests that optimistic investors temporarily inflate stock prices before earnings announcements, especially in low-institutional-ownership stocks with short-sale constraints. Post-announcement, speculative positions unwind and earnings reveal overoptimism, producing predictable price reversals. This strategy exploits these pre- and post-announcement behavioral patterns.

III. SOURCE PAPER

Overpricing: Evidence from Earnings Announcements [Click to Open PDF]

Berkman, Koch, University of Auckland Business School, Iowa State University – Finance Department; Iowa State University – Finance Department

<Abstract>

In the days before earnings announcements we find an average price increase of almost 1 percent for stocks that are likely to be overpriced already – stocks with low institutional ownership combined with high market-to-book ratios, turnover, volatility, or analyst forecast dispersion. However, in the days after earnings announcements these same stocks generate negative abnormal returns of more than 3 percent. Together, these results indicate a significant net correction following earnings announcements for stocks that are prone to be overpriced. These results are consistent with the optimism bias hypothesized in Miller (1977), and with recent evidence that cross-sectional return predictability is concentrated among stocks with low institutional ownership.

IV. BACKTEST PERFORMANCE

Annualised Return87%
VolatilityN/A
Beta0.017
Sharpe RatioN/A
Sortino Ratio-0.112
Maximum DrawdownN/A
Win Rate50%

V. FULL PYTHON CODE

import data_tools
from AlgorithmImports import *
import numpy as np
from pandas.tseries.offsets import BDay
class InstititutionalOwnershipEffectDuringEarningsAnnouncements(QCAlgorithm):
    
    def Initialize(self) -> None:
        self.SetStartDate(2009, 1, 1) # earnings dates starts at 2010
        self.SetCash(100_000)
        self.period: int = 21
        self.lookup_period: int = 2
        self.holding_period: int = 2
        self.min_share_price: int = 5
        self.leverage: int = 5
        self.quantile: int = 5
        self.total_long_num: int = 15
        self.total_short_num: int = 15
        
        self.long: Set(Symbol) = set()
        self.short: Set(Symbol) = set()
        self.data: Dict[Symbol, data_tools.SymbolData] = {}
        self.earnings_data: Dict[datetime.date, list[str]] = {}
        
        self.first_date: Union[None, datetime.date] = None
        earnings_set: Set(str) = set()
        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()
            if not self.first_date: self.first_date = date
            self.earnings_data[date] = []
            
            for stock_data in obj['stocks']:
                ticker: str = stock_data['ticker']
                self.earnings_data[date].append(ticker)
                earnings_set.add(ticker)
        
        self.tickers: List[str] = list(earnings_set)
        market: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        # equally weighted brackets for traded symbols. - n symbols long, m symbols short, 2 days of holding
        self.trade_manager: data_tools.TradeManager = data_tools.TradeManager(
            self, self.total_long_num, self.total_short_num, self.holding_period)
        
        self.fundamental_count: int = 3000
        self.fundamental_sorting_key = lambda x: x.MarketCap
        self.selection_flag: bool = False
        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(market), self.TimeRules.AfterMarketOpen(market), 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]:
        # update the rolling window every day
        for stock in fundamental:
            symbol: Symbol = stock.Symbol
            if symbol in self.data:
                self.data[symbol].update(stock.Volume)
        if not self.selection_flag:
            return Universe.Unchanged
        self.selection_flag = False
        selected: List[Fundamental] = [
            x for x in fundamental if x.Symbol.Value in self.tickers \
            and x.HasFundamentalData and x.MarketCap != 0 and x.Market == 'usa' and x.Price > self.min_share_price
        ]
        if len(selected) > self.fundamental_count:
            selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
        warmed_up_symbols:List[Symbol] = []
        for stock in selected:
            symbol: Symbol = stock.Symbol
            if symbol not in self.data:
                self.data[symbol] = data_tools.SymbolData(symbol, self.period)
                history: dataframe = self.History(symbol, self.period, Resolution.Daily)
                if history.empty:
                    continue
                if not hasattr(history.loc[symbol], 'volume'):
                    continue
                volumes: Series = history.loc[symbol].volume
                for _, volume in volumes.items():
                    self.data[symbol].update(volume)
            if self.data[symbol].is_ready():
                warmed_up_symbols.append(symbol)
        
        if len(warmed_up_symbols) < self.quantile:
            return Universe.Unchanged
        quantile: int = int(len(warmed_up_symbols) / self.quantile)
        lowest_market_caps: List[Symbol] = [x for x in warmed_up_symbols[-quantile:]]
        
        volumes: Dict[Symbol, float] = { x : self.data[x].sum_volumes() for x in warmed_up_symbols}
        
        quantile: int = int(len(volumes) / self.quantile)
        highest_volumes: List[Symbol] = [x[0] for x in sorted(volumes.items(), key=lambda item: item[1])][-quantile:]
        
        self.long = set(symbol for symbol in lowest_market_caps if symbol in highest_volumes)
        return list(self.long)
    def OnData(self, data: Slice) -> None:
        # liquidate opened symbols after self.holding_period days.
        self.trade_manager.TryLiquidate()
        
        # long two days before earnings annoucement
        date_to_lookup_long: datetime.date = (self.Time + BDay(self.lookup_period)).date()
        # short two days after earnings annoucement
        date_to_lookup_short: datetime.date = (self.Time - BDay(self.lookup_period)).date()
        
        if date_to_lookup_long < self.first_date:
            self.long.clear()
        # open new trades
        symbols_to_delete: List[Symbol] = []
        if date_to_lookup_long in self.earnings_data:
            for symbol in self.long:
                if symbol.Value in self.earnings_data[date_to_lookup_long] and symbol in data and data[symbol]:
                    self.trade_manager.Add(symbol, True)
                    symbols_to_delete.append(symbol)
        
        # delete already traded symbols and add them to short portfolio
        for symbol in symbols_to_delete:
            self.long.remove(symbol)
            self.short.add(symbol)
            
        symbols_to_delete.clear()
        if date_to_lookup_short in self.earnings_data:
            for symbol in self.short:
                if symbol.Value in self.earnings_data[date_to_lookup_short] and symbol in data and data[symbol]:
                    self.trade_manager.Add(symbol, False)
                    symbols_to_delete.append(symbol)
                    
        for symbol in symbols_to_delete:
            self.short.remove(symbol)
        
    def Selection(self) -> None:
        self.selection_flag = True

Leave a Reply

Discover more from Quant Buffet

Subscribe now to keep reading and get access to the full archive.

Continue reading