
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.
ASSET CLASS: stocks | REGION: United States | FREQUENCY:
Monthly | MARKET: equities | KEYWORD: Seasonality
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 Return | 12.95% |
| Volatility | 14.74% |
| Beta | -0.563 |
| Sharpe Ratio | 0.88 |
| Sortino Ratio | -0.697 |
| Maximum Drawdown | N/A |
| Win Rate | 42% |
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"))