Quant BuffetRelax, Not Over Thinking

Top 30% Market Cap Stocks Momentum Long-Short Strategy

Log in to collect

Academic paper

Seasonality in the Cross-Section of Expected Stock Returns

AuthorsSteven L. Heston; Ronnie Sadka

Institute
  • University of Maryland, College Park
  • ?University of Maryland - Department of Finance
  • Boston College
  • ?Boston College - Carroll School of Management

Strategy in a nutshell

The top 30% of firms based on their market cap from NYSE and AMEX are part of the investment universe. Every month, stocks are grouped into ten portfolios (with an equal number of stocks in each portfolio) according to their performance in one month one year ago. Investors go long in stocks from the winner decile and shorts stocks from the loser decile. The portfolio is equally weighted and rebalanced every month.

Economic rationale

Academic research shows that the seasonal pattern of liquidity may help explain part of the expected returns. Other explanations attribute returns to compensation for systematic risk or to behavioral theories of investing.

Backtest performance

Annualised return8.6%
Volatility12.2%
Beta-0.01
Sharpe ratio-0.285
Sortino ratio-0.294
Maximum drawdown68.1%
Win rate49%

Full Python code

from AlgoLib import *
from typing import List, Dict, Tuple
import pandas as pd

class Month12CycleinCrossSectionofStocksReturns(XXX):

def Initialize(self) -> None:
self.SetStartDate(2000, 1, 1)  
self.SetCash(100_000)

self.UniverseSettings.Leverage = 5
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.0

self.exchange_codes: List[str] = ['NYS', 'NAS', 'ASE']
self.fundamental_count: int = 1_000
self.fundamental_sorting_key = lambda x: x.DollarVolume
self.quantile: int = 5
self.year_period: int = 13
self.month_period: int = 30

# Monthly close data.
self.symbol_data: Dict[Symbol, SymbolData] = {}
self.portfolio_weights: Dict[Symbol, float] = {}
self.selection_flag: bool = False

symbol: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
self.Schedule.On(self.DateRules.MonthEnd(symbol), 
                self.TimeRules.BeforeMarketClose(symbol), 
                self.Selection)

def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
if not self.selection_flag:
    return Universe.Unchanged

# Update the rolling window every month.
for f in fundamental:
    if f.Symbol in self.symbol_data:
        self.symbol_data[f.Symbol].update(f.AdjustedPrice)

filtered: List[Fundamental] = [f for f in fundamental if f.HasFundamentalData
                                and f.SecurityReference.ExchangeId in self.exchange_codes
                                and not f.CompanyReference.IsREIT
                                and f.MarketCap != 0]

sorted_filter: List[Fundamental] = sorted(filtered,
                                        key=self.fundamental_sorting_key,
                                        reverse=True)[:self.fundamental_count]

# Warmup price rolling windows.
for f in sorted_filter:
    if f.Symbol in self.symbol_data:
        continue
    
    self.symbol_data[f.Symbol] = SymbolData(self.year_period)
    history: pd.DataFrame = self.History(f.Symbol, self.year_period * self.month_period, Resolution.Daily)
    if history.empty:
        self.Log(f"Not enough data for {f.Symbol} yet.")
        continue
    closes: pd.Series = history.loc[f.Symbol].close
    
    # Find monthly closes.
    for index, time_close in enumerate(closes.iteritems()):
        # index out of bounds check.
        if index + 1 < len(closes.keys()):
            date_month: int = time_close[0].date().month
            next_date_month: int = closes.keys()[index + 1].month
        
            # Find last day of month.
            if date_month != next_date_month:
                self.symbol_data[f.Symbol].update(time_close[1])
    
ready_securities: List[Fundamental] = [x for x in sorted_filter if self.symbol_data[x.Symbol].is_ready()]

# Performance sorting. One month performance, one year ago.
performance: Dict[Fundamental, float] = {x: self.symbol_data[x.Symbol].performance() for x in ready_securities}

longs: List[Fundamental] = []
shorts: List[Fundamental] = []

if len(performance) >= self.quantile:
    sorted_by_perf: List[Tuple[Fundamental, float]] = sorted(performance.items(), key=lambda x: x[1], reverse=True)
    quantile: int = int(len(sorted_by_perf) / self.quantile)
    longs = [x[0] for x in sorted_by_perf[:quantile]]
    shorts = [x[0] for x in sorted_by_perf[-quantile:]]

# Market cap weighting.
for i, portfolio in enumerate([longs, shorts]):
    mc_sum: float = sum(map(lambda x: x.MarketCap, portfolio))
    for security in portfolio:
        self.portfolio_weights[security.Symbol] = ((-1) ** i) * security.MarketCap / mc_sum

return list(self.portfolio_weights.keys())

def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
    security.SetFeeModel(CustomFeeModel())

def OnData(self, slice: 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.portfolio_weights.items() 
                                    if slice.ContainsKey(symbol) and slice[symbol] is not None]
self.SetHoldings(portfolio, True)
self.portfolio_weights.clear()

def Selection(self) -> None:
self.selection_flag = True

class SymbolData():
def __init__(self, period: int) -> None:
self.price: RollingWindow = RollingWindow[float](period)

def update(self, value: float) -> None:
self.price.Add(value)

def is_ready(self) -> bool:
return self.price.IsReady

# One month performance, one year ago.
def performance(self) -> float:
prices: List[float] = list(self.price)
return (prices[-2] / prices[-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"))