
The investment universe comprises U.S. NYSE and Amex stocks with share codes 10 and 11; NASDAQ stocks are excluded due to the different methodologies used in counting turnover.
ASSET CLASS: stocks | REGION: United States | FREQUENCY:
Yearly | MARKET: equities | KEYWORD: Liquidity, Institutional Ownership
I. STRATEGY IN A NUTSHELL
This strategy targets U.S. NYSE and AMEX stocks (CRSP share codes 10 and 11), excluding NASDAQ due to turnover measurement differences. Stocks are first filtered for liquidity—those with annual average daily turnover above the median—and low institutional ownership (bottom tercile). A lottery-like measure, MAX (the maximum daily return in a year), is calculated for each stock. Each December, stocks are sorted into high- and low-MAX portfolios relative to the median. The strategy goes long on low-MAX stocks and short on high-MAX stocks, forming a DIFF portfolio. Portfolios are value-weighted and rebalanced annually.
II. ECONOMIC RATIONALE
The strategy is grounded in behavioral finance. Liquidity traders act as noise traders, making decisions unrelated to fundamentals, hedging, or liquidity needs. Their trading amplifies arbitrage risk and limits efficient price discovery, particularly in liquid markets. High-MAX stocks are likely influenced by these noise traders, creating predictable reversals that can be exploited by systematically shorting “lottery-like” high-MAX stocks and buying low-MAX stocks, complementing the existing literature on market inefficiencies caused by noise trading.
III. SOURCE PAPER
Liquidity, Favorite-Longshot Bias, and the Return of Lottery-Like Stocks [Click to Open PDF]
Chuan-Yang Hwang, Long Yi, Nanyang Business School, Nanyang Technological University, Hong Kong Baptist University (HKBU) – Department of Finance and Decision Sciences
<Abstract>
We document novel results that the lottery effect (lottery-like stocks earn lower returns) is much stronger in liquid stocks. We posit that noise traders dominate the trading of liquid lottery-like stocks, as described in the limits to arbitrage literature, who are attracted by the high jackpot probability of the lottery-like stocks and commit the favorite-longshot bias in which investors overweight the probability of a longshot. The overweight, rather than being driven by preference as suggested in the prospect theory, is more likely caused by overestimation error.


IV. BACKTEST PERFORMANCE
| Annualised Return | 10.56% |
| Volatility | 9.85% |
| Beta | -0.259 |
| Sharpe Ratio | 1.07 |
| Sortino Ratio | N/A |
| Maximum Drawdown | N/A |
| Win Rate | 50% |
V. FULL PYTHON CODE
from AlgorithmImports import *
from pandas.core.frame import dataframe
from dateutil.relativedelta import relativedelta
# endregion
class InfluenceofLiquidityInstitutionalOwnershipLotteryEffectonStocks(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2004, 1, 1)
self.SetCash(100000)
data_delay_months:int = 3
file_contents:str = self.Download('data.quantpedia.com/backtesting_data/economic/institutional_ownership/institutional_ownership_in_percents.csv')
lines:List[str] = file_contents.split('\r\n')
self.tickers:List[str] = lines[0].split(',')[1:]
dict_list:List[Dict[str, float]] = []
for line in lines[1:]:
line_split:List[str] = line.split(',')
date = (datetime.strptime(line_split[0], "%Y-%m-%d") + relativedelta(months=data_delay_months)).date()
temp_dict:Dict[str, float] = { 'date' : date }
for i in range(1, len(line_split)):
ticker:str = self.tickers[i-1]
temp_dict[ticker] = float(line_split[i]) if line_split[i] != '' else 0.
dict_list.append(temp_dict)
io_df:dataframe = pd.dataframe(dict_list, columns=['date'] + self.tickers)
self.io_df:dataframe = io_df.set_index('date')
self.leverage:int = 3
self.selection_month:int = 12
self.period:int = 12 * 21
self.quantile:int = 3
self.price_data:Dict[Symbol, RollingWindow] = {}
self.weight:Dict[Symbol, float] = {}
self.market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
self.selection_flag:bool = False
self.Settings.MinimumOrderMarginPortfolioPercentage = 0
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.CoarseSelectionFunction, self.FineSelectionFunction)
self.Schedule.On(self.DateRules.MonthStart(self.market), self.TimeRules.AfterMarketOpen(self.market), self.Selection)
def OnSecuritiesChanged(self, changes:SecurityChanges) -> None:
for security in changes.AddedSecurities:
security.SetFeeModel(CustomFeeModel())
security.SetLeverage(self.leverage)
def CoarseSelectionFunction(self, coarse:List[CoarseFundamental]) -> List[Symbol]:
# store daily stock prices
for stock in coarse:
symbol:Symbol = stock.Symbol.Value
if symbol in self.price_data:
self.price_data[symbol].Add(stock.AdjustedPrice)
# monthly selection
if not self.selection_flag:
return Universe.Unchanged
selection:List[Symbol] = [x.Symbol for x in coarse if x.HasFundamentalData and x.Symbol.Value in list(self.io_df.columns)]
# warmup price rolling windows
for symbol in selection:
if symbol in self.price_data:
continue
self.price_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.iteritems():
self.price_data[symbol].Add(close)
return [x for x in selection if self.price_data[x].IsReady]
def FineSelectionFunction(self, fine:List[FineFundamental]) -> List[Symbol]:
stock_by_ticker:Dict[str, FineFundamental] = { x.Symbol.Value : x for x in fine }
# filter stocks
if not self.io_df.empty and len(stock_by_ticker) != 0:
last_io_values_sorted:dataframe = self.io_df[self.io_df.index <= self.Time.date()]
if len(last_io_values_sorted) != 0:
last_io_values_sorted:pd.Series = last_io_values_sorted.iloc[-1].sort_values(ascending=False)
last_io_values_sorted = last_io_values_sorted[last_io_values_sorted != 0]
quantile:int = len(last_io_values_sorted) // self.quantile
bottom_io_tickers:List[str] = list(last_io_values_sorted[-quantile:].index)
bottom_io_MAX:Dict[FineFundamental, float] = { stock_by_ticker[x] : max(np.array(list(self.price_data[stock_by_ticker[x].Symbol]))[:-1] / np.array(list(self.price_data[stock_by_ticker[x].Symbol]))[1:] - 1) for x in bottom_io_tickers if x in stock_by_ticker }
MAX_median:float = np.median(list(bottom_io_MAX.values()))
long:List[FineFundamental] = [stock for stock, MAX in bottom_io_MAX.items() if MAX <= MAX_median]
short:List[FineFundamental] = [stock for stock, MAX in bottom_io_MAX.items() if MAX > MAX_median]
# value weighting
market_cap_long:float = sum([x.MarketCap for x in long])
market_cap_short:float = sum([x.MarketCap for x in short])
for stock in long:
self.weight[stock.Symbol] = stock.MarketCap / market_cap_long
for stock in short:
self.weight[stock.Symbol] = -stock.MarketCap / market_cap_short
return list(self.weight.keys())
def OnData(self, data: Slice) -> None:
if not self.selection_flag:
return
self.selection_flag = False
# trade execution
invested:List[Symbol] = [x.Key for x in self.Portfolio if x.Value.Invested]
for symbol in invested:
if symbol not in self.weight:
self.Liquidate(symbol)
for symbol, w in self.weight.items():
self.SetHoldings(symbol, w)
self.weight.clear()
def Selection(self) -> None:
if self.Time.month == self.selection_month:
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"))