Lottery Stocks and the 52-Week High
Log in to collectAcademic paper
The Role of Psychological Barriers in Lottery-Related Anomalies
Suk‐Joon Byun; Jihoon Goh
- KRKorea Advanced Institute of Science and Technology
- ?Korea Advanced Institute of Science and Technology (KAIST) - Financial Engineering
- ?College of Business, Korea Advanced Institute of Science and Technology (KAIST)
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.
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.
Backtest performance
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"))