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.

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 Return17.89%
Volatility17.04%
Beta-0.263
Sharpe Ratio1.05
Sortino Ratio-0.044
Maximum DrawdownN/A
Win Rate51%

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

Leave a Reply

Discover more from Quant Buffet

Subscribe now to keep reading and get access to the full archive.

Continue reading