Quant BuffetRelax, Not Over Thinking

12 Month Seasonal Reversals

Log in to collect

Academic paper

Seasonal Reversals in Expected Stock Returns

AuthorsMatti Keloharju; Juhani T. Linnainmaa; Peter Nyberg

Institute
  • FIAalto University
  • Centre for Economic Policy Research
  • SEResearch Institute of Industrial Economics
  • ?Aalto University School of Business
  • ?Centre for Economic Policy Research (CEPR)
  • ?Research Institute of Industrial Economics (IFN)
  • National Bureau of Economic Research
  • Dartmouth College
  • ?Dartmouth College - Tuck School of Business
  • ?National Bureau of Economic Research (NBER)

Strategy in a nutshell

Targets U.S. stocks, ranking them by average returns in months other than the current month over the past 20 years. Goes long on the two lowest-return portfolios and short on the two highest, rebalanced monthly to capture seasonal reversal patterns.

Economic rationale

Seasonal reversals correct temporary mispricing caused by short-term over- or underperformance. Returns balance across months, reflecting mispricing rather than risk-based factors, and are distinct from short-term or long-term reversals.

Backtest performance

Annualised return5.54%
Volatility8.07%
Beta0.022
Sharpe ratio0.19
Sortino ratio-0.09
Win rate49%

Full Python code

from AlgorithmImports import *
import numpy as np
from typing import List, Dict, Tuple
#endregion
class TwelveMonthSeasonalReversals(QCAlgorithm):
def Initialize(self) -> None:
self.SetStartDate(2000, 1, 1)
self.SetCash(100_000)
self.exchange_codes: List[str] = ['NYS', 'NAS', 'ASE']
market: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol

# monthly prices
self.data: Dict[Symbol, SymbolData] = {} 
self.period: int = 5 * 12 + 1
self.weight: Dict[Symbol, float] = {}
self.min_share_price: int = 5
self.quantile: int = 3
self.leverage: int = 5
self.fundamental_count: int = 1000
self.fundamental_sorting_key = lambda x: x.DollarVolume
self.selection_flag: bool = False
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
# update the rolling window every month
for stock in fundamental:
    symbol: Symbol = stock.Symbol
    # Store monthly price.
    if symbol in self.data:
        self.data[symbol].update(stock.AdjustedPrice)
selected: List[Fundamental] = [
    x for x in fundamental if x.HasFundamentalData and x.Price > self.min_share_price and x.Market == 'usa' \
    and x.MarketCap > 0 and x.SecurityReference.ExchangeId in self.exchange_codes
]
if len(selected) > self.fundamental_count:
    selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
# warmup price rolling windows
for stock in selected:
    symbol: Symbol = stock.Symbol
    if symbol in self.data:
        continue
    
    self.data[symbol] = SymbolData(self.period)
    history: DataFrame = self.History(symbol, self.period*30, Resolution.Daily)
    if history.empty:
        self.Log(f"Not enough data for {symbol} yet.")
        continue
    closes: Series = history.loc[symbol].close
    
    closes_len: int = len(closes.keys())
    # find monthly closes
    for index, time_close in enumerate(closes.items()):
        # index out of bounds check.
        if index + 1 < closes_len:
            date_month: int = time_close[0].date().month
            next_date_month: int = closes.keys()[index + 1].month
        
            # Found last day of month.
            if date_month != next_date_month:
                self.data[symbol].update(time_close[1])

average_return: Dict[FindFundamental, float] = {}
for stock in selected:
    if not self.data[symbol].is_ready():
        continue
    monthly_returns: np.ndarray = self.data[stock.Symbol].monthly_returns()
        
    # other-calendar-month returns
    relevant_monthly_returns:List[float] = [ret for i, ret in enumerate(monthly_returns) if i % 12 != 0 or i == 0]
    average_return[stock] = np.average(relevant_monthly_returns)

if len(average_return) < self.quantile:
    return Universe.Unchanged
# avg return sorting
sorted_by_return: List[Tuple] = sorted(average_return.items(), key = lambda x: x[1], reverse = False)
quantile: int = int(len(sorted_by_return) / self.quantile)
short: List[Fundamental] = [x[0] for x in sorted_by_return[:quantile]]
long: List[Fundamental] = [x[0] for x in sorted_by_return[-quantile:]]

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 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) -> None:
self._monthly_prices: RollingWindow = RollingWindow[float](period)
    
def update(self, price: float) -> None:
self._monthly_prices.Add(price)
    
def is_ready(self) -> bool:
return self._monthly_prices.IsReady

def monthly_returns(self) -> np.ndarray:
monthly_closes: np.ndarray = np.array([x for x in self._monthly_prices])
return (monthly_closes[:-1] / monthly_closes[1:] - 1)
# Custom fee model.
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters: OrderFeeParameters) -> OrderFee:
fee: float = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))