The strategy sorts U.S. stocks based on past performance and a lottery proxy (MAX), then goes long on the lowest and short on the highest lottery deciles in the loser portfolio, rebalanced monthly.

I. STRATEGY IN A NUTSHELL

The strategy trades U.S. stocks from NYSE, AMEX, and NASDAQ (excluding stocks under $1, closed-end funds, and REITs). Stocks are first sorted into winners, losers, and middle portfolios based on past 3-month performance. Within the loser portfolio, stocks are further ranked into quintiles using a lottery proxy (MAX = previous month’s highest daily return). The strategy goes long on the lowest lottery decile and short on the highest, with a value-weighted, monthly rebalanced portfolio.

II. ECONOMIC RATIONALE

Behavioral biases drive lottery-like stock preferences, where investors overpay for poorly performing stocks with potential for extreme gains. This mispricing persists because investors undervalue non-lottery stocks and overvalue lottery-like losers. Exploiting these biases allows the strategy to capture abnormal returns by shorting overpriced lottery-like stocks and going long on underpriced low-lottery stocks.

III. SOURCE PAPER

Gambling Preferences for Loser Stocks [Click to Open PDF]

Peixuan Yuan, Rutgers Business School, Rutgers University

<Abstract>

I discover that investors’ preferences for gambling mainly involve stocks that have performed poorly in the past three months, as lottery-like stocks with poor performance are much more likely to generate large payoffs than those with good performance (61.53% vs. 40.17%). Furthermore, lotto investors tend to believe that lottery-like stocks with poor performance may have a vigorous rebound shortly, while those with good performance may be less likely to produce a highly positive return given their high prices. Therefore, lottery-like stocks with poor performance have a highly effective lottery-like look, and thus they attract lotto investors. On the other hand, loser stocks without lottery-like features may continue to perform poorly. Overly optimistic (pessimistic) beliefs about stocks with (without) lottery-like features result in a pronounced lottery premium among loser stocks.

IV. BACKTEST PERFORMANCE

Annualised Return28.78%
Volatility23.38%
Beta-0.814
Sharpe Ratio1.23
Sortino Ratio-0.183
Maximum DrawdownN/A
Win Rate55%

V. FULL PYTHON CODE

import numpy as np
from AlgorithmImports import *
from pandas.core.frame import dataframe
class LotteryStocksandPastPerformance(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2000, 1, 1)  
        self.SetCash(100000) 
        market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        
        self.period:int = 3 * 21
        self.performance_quantile:int = 3
        self.lottery_quantile:int = 10
        self.leverage:int = 5
        self.fundamental_count:int = 1000
        self.fundamental_sorting_key = lambda x: x.DollarVolume
        
        self.weight:Dict[Symbol, float] = {}
        
        # Daily price data.
        self.data:Dict[Symbol, RollingWindow] = {}
        
        self.selection_flag:bool = False
        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())
            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
            # Store monthly price.
            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.Market == 'usa' and x.MarketCap != 0]
        if len(selected) > self.fundamental_count:
            selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
        
        performance:Dict[Fundamental, float] = {}
        # Warmup price rolling windows.
        for stock in selected:
            symbol:Symbol = stock.Symbol
            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:pd.Series = history.loc[symbol].close
                for time, close in closes.items():
                    self.data[symbol].Add(close)
            
            if self.data[symbol].IsReady:
                performance[stock] = self.data[symbol][0] / self.data[symbol][self.period-1] - 1
        if len(performance) >= self.performance_quantile * self.lottery_quantile:
            # Performance sorting.
            sorted_by_performance:List[Fundamental] = sorted(performance, key = performance.get, reverse = True)
            quantile:int = int(len(sorted_by_performance) / self.performance_quantile)
            losers:List[Fundamental] = sorted_by_performance[-quantile:]
            
            # MAX calc.
            lottery:Dict[Fundamental, float] = {}
            for stock in losers:
                daily_closes:np.ndarray = np.array([x for x in self.data[stock.Symbol]][:21])
                daily_returns:np.ndarray = (daily_closes[:-1] - daily_closes[1:]) / daily_closes[1:]
                lottery[stock] = max(daily_returns)
            
            # Lottery sorting.
            sorted_by_lottery = sorted(lottery, key = lottery.get, reverse = True)
            quantile:int = int(len(lottery) / self.lottery_quantile)
            long:List[Fundamental] = sorted_by_lottery[-quantile:]
            short:List[Fundamental] = sorted_by_lottery[:quantile]
        
            # Market cap weighting.
            for i, portfolio in enumerate([long, short]):
                mc_sum:float = sum(map(lambda x: x.MarketCap, portfolio))
                for stock in portfolio:
                    self.weight[stock.Symbol] = ((-1) ** i) * stock.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
# Custom fee model
class CustomFeeModel(FeeModel):
    def GetOrderFee(self, parameters):
        fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
        return OrderFee(CashAmount(fee, "USD"))

Leave a Reply

Discover more from Quant Buffet

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

Continue reading