The strategy involves sorting large-cap stocks based on proximity to their 52-week high and maximum monthly return, then going long on stocks with low NH and low MAX, and shorting high MAX stocks.

I. STRATEGY IN A NUTSHELL

The strategy trades large-cap stocks on NYSE, AMEX, or NASDAQ (excluding stocks under $5), sorted by proximity to their 52-week highs (NH) and monthly maximum daily returns (MAX). Within the lowest NH quintile, stocks in the top MAX quintile are shorted and those in the bottom MAX quintile are longed. Portfolios are equally weighted and rebalanced monthly.

II. ECONOMIC RATIONALE

Investors overweight lottery-like stocks with potential for extreme gains, but avoid stocks near 52-week highs due to psychological barriers. Stocks far from their highs display stronger lottery-driven anomalies, especially with low institutional ownership. This behavior allows the strategy to profit from predictable mispricings across liquidity, size, and price, making it broadly robust.

III. SOURCE PAPER

The Role of Psychological Barriers in Lottery-Related Anomalies [Click to Open PDF]

Suk-Joon Byun — Korea Advanced Institute of Science and Technology (KAIST) – Financial Engineering, College of Business; Jihoon Goh — Korea Advanced Institute of Science and Technology (KAIST) – Financial Engineering, College of Business.

<Abstract>

Previous studies find that stocks with lottery features are overpriced. We show that anomalies
induced by investors’ lottery preferences exist primarily among stocks that are far from their 52-week
high prices. The results suggest that if stocks are near their 52-week highs, investors no longer prefer
lottery stocks since they consider the 52-week high a psychological barrier or an upper bound for prices.
We find that the dependency between lottery-related anomalies and nearness to the 52-week high is
pronounced among stocks with low institutional ownership. Alternative explanations, such as limits to
arbitrage and capital gains, do not explain our results.

IV. BACKTEST PERFORMANCE

Annualised Return18.58%
Volatility19.25%
Beta0.438
Sharpe Ratio 0.76
Sortino Ratio-0.278
Maximum DrawdownN/A
Win Rate 47%

V. FULL PYTHON CODE

import numpy as np
from AlgorithmImports import *
from typing import List, Dict
class LotteryStocks52WeekHigh(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2010, 1, 1)
        self.SetCash(100000)
        self.exchange_codes: List[str] = ['NYS', 'NAS', 'ASE']    
        self.period: int = 52 * 5
        self.month_period: int = 21
        self.leverage: int = 10
        self.min_share_price: int = 5
        self.quantile: int = 10
        
        self.data: Dict[Symbol, RollingWindow] = {}
        self.long: List[Symbol] = []
        self.short: List[Symbol] = []
        
        market: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        self.fundamental_count: int = 1_000
        self.fundamental_sorting_key = lambda x: x.DollarVolume
        self.selection_flag: bool = True
        self.settings.daily_precise_end_time = 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)
    
    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
            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.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]]
        MAX: Dict[Symbol, float] = {}
        NH: Dict[Symbol, float] = {}
        for stock in selected:
            symbol: Symbol = stock.Symbol
            # warmup price rolling windows
            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:Series = history.loc[symbol].close
                for time, close in closes.items():
                    self.data[symbol].Add(close)
            if not self.data[symbol].IsReady:
                continue
            
            closes: List[float] = list(self.data[symbol])
            last_month_closes: np.ndarray = np.array(closes[:self.month_period])
            last_close: float = closes[0]
            
            daily_returns: np.ndarray = (last_month_closes[:-1] - last_month_closes[1:]) / last_month_closes[1:]
            MAX[symbol] = max(daily_returns)
            
            # NH calc    
            if last_close != 0:
                local_highest_close: float = max(closes)
                NH[symbol] = last_close / local_highest_close
        if len(MAX) < self.quantile or len(NH) < self.quantile:
            return Universe.Unchanged
        # NH sorting
        sorted_by_NH: List[Tuple[Symbol, float]] = sorted(NH.items(), key = lambda x: x[1], reverse = True)
        quintile:int = int(len(sorted_by_NH) / self.quantile)
        low_NH: List[Symbol] = [x[0] for x in sorted_by_NH[-quintile:]]
        
        # MAX sorting
        sorted_by_MAX: List[Tuple[Symbol, float]] = sorted(low_NH, key = lambda x: MAX[x], reverse = True)
        quintile: int = int(len(sorted_by_MAX) / self.quantile)
        
        self.long = [x for x in sorted_by_MAX[:quintile]]
        self.short = [x for x in sorted_by_MAX[-quintile:]]
        return self.long + self.short
    
    def OnData(self, slice: Slice) -> None:
        if not self.selection_flag:
            return
        self.selection_flag = False
        
        # Trade execution
        targets:List[PortfolioTarget] = []
        for i, portfolio in enumerate([self.long, self.short]):
            for symbol in portfolio:
                if symbol in slice and slice[symbol]:
                    targets.append(PortfolioTarget(symbol, ((-1) ** i) / len(portfolio)))
        
        self.SetHoldings(targets, True)
        self.long.clear()
        self.short.clear()
        
    def Selection(self) -> None:
        self.selection_flag = True
        
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