
The strategy longs high-negative-return stocks (hot potato) and shorts high-positive-return stocks (lottery), based on monthly MAX/MIN deciles, rebalancing value-weighted portfolios monthly for U.S. stocks.
ASSET CLASS: stocks | REGION: United States | FREQUENCY:
Monthly | MARKET: equities | KEYWORD: Lottery, Hot Potato
I. STRATEGY IN A NUTSHELL
Rank U.S. stocks by daily gains (MAX) and losses (MIN), excluding top MAX “lottery” stocks. Go long on the top MIN decile (largest negative daily returns) and short the top MAX decile, value-weighted and rebalanced monthly.
II. ECONOMIC RATIONALE
The strategy exploits the lottery effect and investor mispricing: by removing overlap with high-gain lottery stocks, it isolates undervalued “hot potato” stocks that tend to reverse, capturing abnormal returns.
III. SOURCE PAPER
Hot Potatoes: Overreaction to extreme negative returns [Click to Open PDF]
Mustafa O. Caglayan, Florida International University – Department of Finance; Robinson Reyes-Peña, Florida International University – Department of Finance
<Abstract>
Although investors accept a negative premium for lottery-like stocks, it is puzzling that the opposite effect is not observed among stocks experiencing large daily losses. We find that many stocks that experience large negative daily returns (MIN) also display large positive daily returns (MAX); therefore the MIN effect is subdued. Once stocks ranked as high-MAX within MIN deciles are removed, we find that the MIN effect produces significantly higher next-month returns. The subsequent-month returns following MIN are particularly higher when stocks experience negative cumulative monthly returns, when firm-specific investor sentiment is low, and when stocks are near their 52-week lows.

IV. BACKTEST PERFORMANCE
| Annualised Return | 17.89% |
| Volatility | 17.04% |
| Beta | -0.263 |
| Sharpe Ratio | 1.05 |
| Sortino Ratio | -0.044 |
| Maximum Drawdown | N/A |
| Win Rate | 51% |
V. FULL PYTHON CODE
from AlgorithmImports import *
import numpy as np
from typing import List, Dict, Tuple
from pandas.core.frame import dataframe
from pandas.core.series import Series
from dataclasses import dataclass
#endregion
class LotteryAndHotPotatoStocks(QCAlgorithm):
def Initialize(self) -> None:
self.SetStartDate(2000, 1, 1)
self.SetCash(100_000)
self.exchange_codes: List[str] = ['NYS', 'NAS', 'ASE']
market: Symbol = self.AddEquity("SPY", Resolution.Daily).Symbol
self.data: Dict[Symbol, SymbolData] = {}
self.weight: Dict[Symbol, float] = {}
self.period: int = 21
self.quantile: int = 10
self.section: int = 3
self.leverage: int = 5
self.min_share_price: int = 5
self.fundamental_count = 3_000
self.fundamental_sorting_key = lambda x: x.MarketCap
self.selection_flag = False
self.UniverseSettings.Leverage = self.leverage
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.Schedule.On(self.DateRules.MonthEnd(market), self.TimeRules.AfterMarketOpen(market), self.Selection)
self.settings.daily_precise_end_time = False
def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
security.SetFeeModel(CustomFeeModel())
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:
# Store daily price.
self.data[symbol].update(stock.AdjustedPrice)
# Selection once a month.
if not self.selection_flag:
return Universe.Unchanged
MIN_MAX: List[Tuple[Symbol, float, float]] = []
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]]
# 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: Series = history.loc[symbol].close
for time, close in closes.items():
self.data[symbol].update(close)
if not self.data[symbol].is_ready():
continue
highest_loss, highest_gain = self.data[symbol].highest_loss_and_gain()
MIN_MAX.append(MinMax(stock, highest_loss, highest_gain))
if len(MIN_MAX) < self.section * self.quantile:
return Universe.Unchanged
# Firstly, sort stocks to the deciles based on their MAX.
quantile: int = int(len(MIN_MAX) / self.quantile)
sorted_by_MAX: List[Fundamental] = sorted(MIN_MAX, key=lambda x: x.MAX, reverse=True)
# Short position in MAX stocks’ original top decile.
short: List[Fundamental] = [x for x in sorted_by_MAX[:quantile]]
# Secondly, exclude stocks that are ranked in the top three MAX deciles.
MIN_MAX_altered: List[Fundamental] = sorted_by_MAX[(self.section * quantile):]
# Nextly, sort remaining stocks into decile portfolios based on the values of MIN in ascending order.
# The MIN values are multiplied by minus one to ensure that the top decile includes the stocks with the largest negative daily returns over the past month,
# and the bottom decile includes the stocks with the lowest negative daily loss over the past month.
# In my opinion, the description before can be implemented by reverse sorting, oder?
quantile: int = int(len(MIN_MAX_altered) / self.quantile)
sorted_by_MIN: List[Fundamental] = sorted(MIN_MAX_altered, key=lambda x: x.MIN, reverse=True)
# Long position in the top MIN decile from the reduced sample
long: List[Fundamental] = [x for x in sorted_by_MIN[-quantile:]]
for i, portfolio in enumerate([long, short]):
mc_sum: float = sum(list(map(lambda stock: stock.symbol.MarketCap, portfolio)))
for stock in portfolio:
self.weight[stock.symbol.Symbol] = ((-1)**i) * stock.symbol.MarketCap / mc_sum
return list(self.weight.keys())
def OnData(self, data: 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.weight.items() if symbol in data and data[symbol]]
self.SetHoldings(portfolio, True)
self.weight.clear()
def Selection(self) -> None:
self.selection_flag = True
class SymbolData():
def __init__(self, period: int) -> None:
self.Closes: RollingWindow = RollingWindow[float](period)
def update(self, close: float) -> None:
self.Closes.Add(close)
def is_ready(self) -> bool:
return self.Closes.IsReady
def highest_loss_and_gain(self) -> float:
closes = np.array([x for x in self.Closes])
returns = (closes[:-1] - closes[1:]) / closes[1:]
return np.min(returns), np.max(returns)
@dataclass
class MinMax():
symbol: Symbol
MIN: float
MAX: float
# 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"))
VI. Backtest Performance