Analyze U.S. stocks for seasonal returns and earnings announcements, trading by longing seasonal winners with prior announcements and shorting seasonal losers without them, using value-weighted positions rebalanced monthly.

I. STRATEGY IN A NUTSHELL

Go long on seasonal winners with prior earnings announcements and short seasonal losers without them. Stocks are rebalanced monthly using value-weighted positions based on five-year same-month returns.

II. ECONOMIC RATIONALE

Seasonal returns are stronger when aligned with earnings announcements. Scheduled events reduce firm-level uncertainty, creating predictable patterns: high returns during announcements and low returns during the buildup period.

III. SOURCE PAPER

The information cycle aand return seasonability [Click to Open PDF]

Haoyuan Li, Roger K. Loh, University of International Business and Economics (UIBE); Nanyang Technological University – Nanyang Business School

<Abstract>

Heston and Sadka (2008) find that cross-sectional stock returns depend on their historical same calendar-month returns. We propose an information-cycle explanation for this seasonality anomaly—that firms’ seasonal information releases lead to higher returns in months with such dissolution of information uncertainty, and lower returns in months with no information releases. Using past earnings announcements and decreases in implied volatility as proxies for scheduled information events, we find indeed that seasonal winners in event months and seasonal losers in non-event months drive the seasonality anomaly. Hence, return seasonality can in fact be consistent with investors’ rational response to information uncertainty

IV. BACKTEST PERFORMANCE

Annualised Return12.95%
Volatility14.74%
Beta-0.563
Sharpe Ratio0.88
Sortino Ratio-0.697
Maximum DrawdownN/A
Win Rate42%

V. FULL PYTHON CODE

from AlgorithmImports import *
from dateutil.relativedelta import relativedelta
from pandas.core.frame import dataframe
class ReturnSeasonalityAndInformationCycle(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100000)
        
        self.data:Dict[Symbol, RollingWindow] = {}
        self.weight:Dict[Symbol, float] = {}
        self.seasonal_data:Dict[Symbol, SymbolData] = {}
        
        self.period:int = 21
        self.seasonal_period:int = 5
        self.require_file_dates:int = 3
        self.leverage:int = 5
        
        market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        self.fundamental_count:int = 3000
        self.fundamental_sorting_key = lambda x: x.MarketCap
        self.selection_flag:int = False
        self.UniverseSettings.Resolution = Resolution.Daily
        self.settings.daily_precise_end_time = False
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        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(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
            # Store daily price.
            if symbol in self.data:
                self.data[symbol].Add(stock.AdjustedPrice)
        if not self.selection_flag:
            return Universe.Unchanged
        
        selected:List[Fundamental] = [x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' and x.MarketCap != 0]
        if len(selected) > self.fundamental_count:
            selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
        stock_file_dates:Dict[Fundamental, int] = {}
        avg_performances:Dict[Fundamental, float] = {}
        current_year:int = self.Time.year
        current_month_year:str = str(self.Time.month) + '-' + str(current_year)
        # Warmup price rolling windows.
        for stock in selected:
            symbol:Symbol = stock.Symbol
            stock_file_date:datetime.date = stock.EarningReports.FileDate
            if symbol not in self.data:
                self.data[symbol] = RollingWindow[float](self.period)
                
                history:dataframe = self.History(symbol, self.period, Resolution.Daily)
                if history.empty:
                    self.Log(f"Not enough data for {symbol} yet")
                    continue
                closes:pd.Series = history.loc[symbol].close
                for time, close in closes.items():
                    self.data[symbol].Add(close)
            
            # calculate metrics
            if self.data[symbol].IsReady:
                if symbol not in self.seasonal_data:
                    self.seasonal_data[symbol] = SymbolData(self.seasonal_period)
                
                performance:float = self.data[symbol][0] / self.data[symbol][self.data[symbol].Count - 1] - 1
                
                self.seasonal_data[symbol].months_perfs[current_month_year] = performance
                self.seasonal_data[symbol].file_dates[current_month_year] = stock_file_date # Check value
                
                if self.seasonal_data[symbol].last_year != current_year:
                    self.seasonal_data[symbol].last_year = current_year
                    self.seasonal_data[symbol].update(current_year)
                    
                if self.seasonal_data[symbol].is_ready():
                    seasonal_file_dates:int = 1
                    seasonal_perfs:List[float] = [performance]
                    look_date:datetime.date = self.Time.date()
                    
                    while len(seasonal_perfs) < self.seasonal_period:
                        look_date:datetime.date = look_date - relativedelta(years=1)
                        look_month_year:str = str(look_date.month) + '-' + str(look_date.year)
                        
                        if look_month_year in self.seasonal_data[symbol].months_perfs:
                            month_performance:float = self.seasonal_data[symbol].months_perfs[look_month_year]
                            seasonal_perfs.append(month_performance)
                            
                            month_file_date:datetime.date = self.seasonal_data[symbol].file_dates[look_month_year]
                            if stock_file_date != month_file_date:
                                stock_file_date = month_file_date
                                seasonal_file_dates += 1
                        else:
                            break
                        
                    if len(seasonal_perfs) < self.seasonal_period: # We don't have enough data for this stock
                        continue
                    
                    stock_file_dates[stock] = seasonal_file_dates
                    avg_performances[stock] = mean(seasonal_perfs)
                
        if len(avg_performances) == 0:
            return Universe.Unchanged
        
        long:List[Fundamental] = []
        short:List[Fundamental] = []
        
        for stock, avg in avg_performances.items():
            if avg >= 0 and stock in stock_file_dates and stock_file_dates[stock] >= self.require_file_dates:
                long.append(stock)
            elif avg < 0 and stock in stock_file_dates and stock_file_dates[stock] < self.require_file_dates:
                short.append(stock)
        
        # value weighting
        for i, portfolio in enumerate([long, short]):
            mc_sum:float = sum(map(lambda x: x.MarketCap, portfolio))
            for stock in portfolio:
                self.weight[stock.Symbol] = ((-1) ** i) * stock.MarketCap / mc_sum
        
        return list(self.weight.keys())
    def OnData(self, data: Slice) -> None:
        if not self.selection_flag:
            return
        self.selection_flag = False
        
        # Trade execution.
        portfolio:List[PortfolioTarget] = [PortfolioTarget(symbol, w) for symbol, w in self.weight.items() if symbol in data and data[symbol]]
        self.SetHoldings(portfolio, True)
        self.weight.clear()
    def Selection(self) -> None:
        self.selection_flag = True
class SymbolData():
    def __init__(self, period: int):
        self._years:RollingWindow = RollingWindow[int](period)
        self.months_perfs = {}
        self.file_dates = {}
        self.last_year = -1
        
    def update(self, year: int) -> None:
        self._years.Add(year)
        
    def is_ready(self) -> bool:
        return self._years.IsReady
        
# Custom fee model
class CustomFeeModel(FeeModel):
    def GetOrderFee(self, parameters):
        fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
        return OrderFee(CashAmount(fee, "USD"))

Leave a Reply

Discover more from Quant Buffet

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

Continue reading