
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.
ASSET CLASS: stocks | REGION: United States | FREQUENCY:
Monthly | MARKET: equities | KEYWORD: Lottery
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 Return | 18.58% |
| Volatility | 19.25% |
| Beta | 0.438 |
| Sharpe Ratio | 0.76 |
| Sortino Ratio | -0.278 |
| Maximum Drawdown | N/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