The strategy involves sorting stocks from NASDAQ, Amex, and NYSE by past performance, buying the top decile and shorting the bottom decile every quarter, with equally weighted stocks and rebalancing.

I. STRATEGY IN A NUTSHELL

This strategy targets all NASDAQ, AMEX, and NYSE stocks, sorting them monthly by 21–251 day returns. At each quarter-end, the investor goes long on the top decile and short on the bottom decile, equally weighting positions and rebalancing quarterly.

II. ECONOMIC RATIONALE

By exploiting momentum, the strategy captures persistent performance trends. Stocks that recently outperformed tend to continue rising, while underperformers often lag, creating predictable patterns that the long-short portfolio can leverage.

III. SOURCE PAPER

Covering-up when tide goes out? Momentum sesionality and investor preferences [Click to Open PDF]

Nigel J. Barradale.Barradale Asset Management.

<Abstract>

We use the seasonal patterns in momentum returns to provide insight into investor preferences. We find the momentum factor return is much greater prior to the calendar quarter-end, especially after a stock market decline. This pattern holds more strongly for larger stocks, for both winners and losers, for the US and internationally, and especially in recent years. The established year-end seasonality is consistent with the quarterly pattern, rather than tax-loss selling. The time-series momentum of markets follows the same pattern, primarily after a market decline. The patterns imply investors prefer well-performing stocks/markets at the quarter-end, particularly in a declining market.

IV. BACKTEST PERFORMANCE

Annualised Return8%
VolatilityN/A
Beta-0.042
Sharpe RatioN/A
Sortino Ratio0.191
Maximum DrawdownN/A
Win Rate50%

V. FULL PYTHON CODE

from AlgorithmImports import *
from pandas.core.frame import dataframe
class MomentumSeasonalityInvestorPreferences(QCAlgorithm):
    
    def Initialize(self):
        self.SetStartDate(2010, 1, 1)
        self.SetCash(100000)
        market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        
        self.fundamental_count:int = 500
        self.fundamental_sorting_key = lambda x: x.DollarVolume
        
        self.period:int = 12 * 21
        self.quantile:int = 10
        self.leverage:int = 5
        self.exchange_codes:List[str] = ['NYS', 'NAS', 'ASE']
        self.long:List[Symbol] = []
        self.short:List[Symbol] = []
        
        # Daily price data.
        self.data:Dict[Symbol, SymbolData] = {}
        
        self.selection_flag:bool = True
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.settings.daily_precise_end_time = False
        self.settings.minimum_order_margin_portfolio_percentage = 0.
        self.schedule.on(self.date_rules.month_start(market),
                        self.time_rules.after_market_open(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 monthly price.
            if symbol in self.data:
                self.data[symbol].update(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.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]]
            
        performance:Dict[Symbol, float] = {}
        # Warmup price rolling windows.
        for stock in selected:
            symbol:Symbol = stock.Symbol
            if symbol not in self.data:
                self.data[symbol] = SymbolData(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].update(close)
                
            if self.data[symbol].is_ready():
                performance[symbol] = self.data[symbol].performance()
        
        if len(performance) >= self.quantile:
            sorted_by_performance:List = sorted(performance, key = performance.get, reverse = True)
            quantile:int = int(len(sorted_by_performance) / self.quantile)
            self.long = sorted_by_performance[:quantile]
            self.short = sorted_by_performance[-quantile:]
        
        return self.long + self.short
    def OnData(self, data: Slice) -> None:
        if not self.selection_flag:
            return
        self.selection_flag = False
        
        # order execution
        targets:List[PortfolioTarget] = []
        for i, portfolio in enumerate([self.long, self.short]):
            for symbol in portfolio:
                if symbol in data and data[symbol]:
                    targets.append(PortfolioTarget(symbol, ((-1) ** i) / len(portfolio)))
		
        self.SetHoldings(targets, True)
        self.long.clear()
        self.short.clear()
    def selection(self) -> None:
        if self.Time.month % 3 == 0:
            self.Liquidate()
            self.selection_flag = True
class SymbolData():
    def __init__(self, period: int):
        self._price:RollingWindow = RollingWindow[float](period)
    
    def update(self, price: float) -> None:
        self._price.Add(price)
    
    def is_ready(self) -> bool:
        return self._price.IsReady
        
    # Yearly performance, one month skipped.
    def performance(self) -> float:
        closes:List[float] = list(self._price)[21:]
        return (closes[0] / closes[-1] - 1)
    
# 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