The strategy involves investing in U.S. non-financial stocks based on sales seasonality. Stocks are allocated monthly into deciles, long the lowest decile and short the highest, with monthly rebalancing.

I. STRATEGY IN A NUTSHELL

The strategy trades non-financial U.S. stocks (excluding common equities) based on sales seasonality. Quarterly sales ratios (SEA) are averaged over the past two years (AVGSEA), and stocks are ranked monthly into deciles. The lowest decile is bought and the highest decile shorted, with value-weighted portfolios held for one month and rebalanced monthly.

II. ECONOMIC RATIONALE

Low-sales-season stocks earn a premium because investors underreact to earnings during these periods, creating a predictable anomaly. The effect is robust across Fama-French factors, momentum, and portfolio adjustments, showing that sales seasonality provides a strong, independent predictor of stock returns.

III. SOURCE PAPER

When Low Beats High: Riding the Sales Seasonality Premium [Click to Open PDF]

Gustavo Grullon — Rice University – Jesse H. Jones Graduate School of Business; Yamil Kaba — Rice University – Jesse H. Jones Graduate School of Business; Alexander Nuñez — CUNY Lehman College.

<Abstract>

We demonstrate that sorting stocks on sales seasonality predicts future abnormal returns. A longshort strategy of buying low-sales-season stocks and shorting high-sales-season stocks generates
an annual alpha of 8.4%. Further, this strategy has become stronger over time, generating an
annual alpha of approximately 15% over the last decade. This seasonal effect predicts future stock
returns in cross-sectional regressions, and is independent of previously documented seasonal
anomalies. Moreover, the alphas from this trading strategy cannot be explained by differences in
stock market liquidity, systematic risk, asymmetric information, or financing decisions. Further
tests indicate that this phenomenon may be driven partially by seasonal fluctuations in the level of
investor attention

IV. BACKTEST PERFORMANCE

Annualised Return8.73%
Volatility12.36%
Beta0.331
Sharpe Ratio 0.38
Sortino Ratio0.238
Maximum DrawdownN/A
Win Rate53%

V. FULL PYTHON CODE

import numpy as np
from AlgorithmImports import *
from collections import deque
from typing import List, Dict, Tuple, Deque
from numpy import isnan
class SalesSeasonalityPremium(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100000)
        self.avg_SEA_threshold:int = 100
        self.max_period:int = 1000
        self.period:int = 13
        self.leverage:int = 10
        self.min_share_price:int = 5
        self.consecutive_quarter_count:int = 6
        self.percentiles:List[int] = [10, 90]
        self.exchange_codes:List[str] = ['NYS', 'NAS', 'ASE']	
        self.sea:Dict[Symbol, Deque[Tuple[float]]] = {} # SEA quarterly data
        self.avgsea_alltime:Deque[float] = deque(maxlen=self.max_period)   # All time SEA values.
        market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        
        self.weight:Dict[Symbol, float] = {}
        
        self.fundamental_count:int = 3000
        self.fundamental_sorting_key = lambda x: x.MarketCap
        self.selection_flag:bool = True
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.Schedule.On(self.DateRules.MonthStart(market), self.TimeRules.AfterMarketOpen(market), self.Selection)
        self.settings.daily_precise_end_time = False
    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]:
        if not self.selection_flag:
            return Universe.Unchanged
        selected:List[Fundamental] = [
            x for x in fundamental if x.HasFundamentalData and x.Price > self.min_share_price and x.Market == 'usa' and \
            not isnan(x.FinancialStatements.IncomeStatement.TotalRevenue.ThreeMonths) and x.FinancialStatements.IncomeStatement.TotalRevenue.ThreeMonths != 0 and \
            not isnan(x.FinancialStatements.IncomeStatement.TotalRevenue.TwelveMonths) and x.FinancialStatements.IncomeStatement.TotalRevenue.TwelveMonths != 0 and \
            not isnan(x.EarningReports.BasicAverageShares.ThreeMonths) and x.EarningReports.BasicAverageShares.ThreeMonths > 0 and \
            not isnan(x.EarningReports.BasicEPS.TwelveMonths) and x.EarningReports.BasicEPS.TwelveMonths > 0 and \
            not isnan(x.ValuationRatios.PERatio) and x.ValuationRatios.PERatio > 0 and \
            x.SecurityReference.ExchangeId in self.exchange_codes 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 which had earnings last month.
        last_month_date:datetime = self.Time - timedelta(days = self.Time.day)
        fine_selected = [x for x in selected if x.EarningReports.FileDate.Value.month == last_month_date.month and x.EarningReports.FileDate.Value.year == last_month_date.year]
        
        avg_sea:Dict[Fundamental, float] = {}
        for stock in fine_selected:
            symbol = stock.Symbol
            
            # store sea data.
            if symbol not in self.sea:
                self.sea[symbol] = deque(maxlen = self.period)
            # SEA calc.
            curr_month:float = stock.EarningReports.FileDate.Value.month
            revenue:float = stock.FinancialStatements.IncomeStatement.TotalRevenue.ThreeMonths
            # annual_revenue = stock.FinancialStatements.IncomeStatement.TotalRevenue.TwelveMonths
            annual_revenue:float = (revenue / curr_month) * 12
            
            if annual_revenue != 0:
                self.sea[symbol].append((stock.EarningReports.FileDate.Value, revenue / annual_revenue))
            
            if len(self.sea[symbol]) == self.sea[symbol].maxlen:
                curr_year:int = self.Time.year
                relevant_quarters:List[Tuple[float]] = [x for x in self.sea[symbol] if x[0].year == curr_year - 2 or x[0].year == curr_year - 3]
                # Make sure we have a consecutive seasonal data => 2 years by 4 quarters.
                if len(relevant_quarters) == self.consecutive_quarter_count:
                    avgsea:float = np.mean([x[1] for x in relevant_quarters])
                    avg_sea[stock] = avgsea
                    
                    # All time avg_sea values to calculate deciles.
                    self.avgsea_alltime.append(avgsea)
        
        if len(avg_sea) != 0:
            # Sort by SEA.
            long:List[Fundamental] = []
            short:List[Fundamental] = []
            
            # Wait for at least 100 last AVGSEA values to estimate percentile values.
            if len(self.avgsea_alltime) > self.avg_SEA_threshold: 
                avgsea_values:List[float] = [x for x in self.avgsea_alltime]
                
                top_decile:float = np.percentile(avgsea_values, self.percentiles[1])
                bottom_decile:float = np.percentile(avgsea_values, self.percentiles[0])
                
                for stock, avgsea in avg_sea.items():
                    if avgsea > top_decile:
                        short.append(stock)
                    elif avgsea < bottom_decile:
                        long.append(stock)
            else:
                return Universe.Unchanged
                
            # Market cap weighting.
            for i, portfolio in enumerate([long, short]):
                mc_sum:float = sum(list(map(lambda stock: stock.MarketCap, portfolio)))
                for stock in portfolio:
                    self.weight[stock.Symbol] = ((-1)**i) * stock.MarketCap / mc_sum
        
        return list(self.weight.keys())
        
    def Selection(self) -> None:
        self.selection_flag = True
    
    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()
class CustomFeeModel(FeeModel):
    def GetOrderFee(self, parameters):
        fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
        return OrderFee(CashAmount(fee, "USD"))

VI. Backtest Performance

Leave a Reply

Discover more from Quant Buffet

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

Continue reading