
Trade CRSP stocks by momentum and short interest, going long on low short-interest, high-momentum stocks and shorting high short-interest, high-momentum stocks, using value-weighted, monthly rebalanced portfolios.
ASSET CLASS: stocks | REGION: United States | FREQUENCY:
Monthly | MARKET: equities | KEYWORD: Short Selling, Momentum
I. STRATEGY IN A NUTSHELL
Trades CRSP stocks (no ADRs/ETFs) by combining momentum and short interest. Within each momentum decile, stocks are split by short interest. In the top momentum decile, go long on low short-interest stocks and short on high short-interest stocks. Portfolios are value-weighted and rebalanced monthly.
II. ECONOMIC RATIONALE
Relies on short sellers’ expertise: high short-interest stocks tend to be overvalued and underperform. Momentum highlights potential mispricing, and by following the “smart money,” the strategy captures returns from stocks mispriced by retail or less-informed investors.
III. SOURCE PAPER
Short Selling Activity and Future Returns: Evidence from FinTech Data [Click to Open PDF]
Gargano, Antonio, C.T. Bauer College of Business
<Abstract>
We use a novel dataset from a leading FinTech company (S3 Partners) to study the ability of short interest to predict the cross-section of U.S. stock returns. We find that short interest (i.e. the quantity of shares shorted expressed as the fraction of shares outstanding) is a bearish indicator, consistent with theoretical predictions and with the intuition that short sellers are informed traders. The hedged portfolio long (short) in the top (bottom) short-interest decile generates an annual 4-Factor Fama-French alfa of -7.6% when weighting stocks equally and of -6.24% when weighting stocks based on market capitalization. Conditioning on past returns improves the predictive accuracy of short interest: the hedged short-interest portfolio that only uses stocks that appreciated the most in the past six months generates an alfa of -17.88%. Multivariate regressions that control for other known drivers of stock returns (e.g. size, value and liquidity) confirm the validity of these findings. In both Fama-MacBeth and Panel regressions we find that a one standard deviation increase in short interest predicts a drop in future adjusted returns of between 4.3% and 9.3%.


IV. BACKTEST PERFORMANCE
| Annualised Return | 15.66% |
| Volatility | 17.8% |
| Beta | -0.054 |
| Sharpe Ratio | 0.88 |
| Sortino Ratio | 0.436 |
| Maximum Drawdown | N/A |
| Win Rate | 50% |
V. FULL PYTHON CODE
from AlgorithmImports import *
from io import StringIO
from typing import List, Dict
from pandas.core.frame import DataFrame
from numpy import isnan
class ShortSellingActivityAndMomentum(QCAlgorithm):
def Initialize(self) -> None:
self.SetStartDate(2017, 1, 1) # short interest data starts at 12-2017
self.SetCash(100_000)
self.tickers_to_ignore: List[str] = ['NE']
self.data: Dict[Symbol, SymbolData] = {}
self.weight: Dict[Symbol, float] = {} # storing symbols, with their weights for trading
self.quantile: int = 4
self.leverage: int = 5
self.period: int = 6 * 21 # need 6 months of daily prices
market: Symbol = self.AddEquity("SPY", Resolution.Daily).Symbol
# source: https://www.finra.org/finra-data/browse-catalog/equity-short-interest/data
text: str = self.Download('data.quantpedia.com/backtesting_data/economic/short_volume.csv')
self.short_volume_df: dataframe = pd.read_csv(StringIO(text), delimiter=';')
self.short_volume_df['date'] = pd.to_datetime(self.short_volume_df['date']).dt.date
self.short_volume_df.set_index('date', inplace=True)
# self.fundamental_count: int = 1000
# self.fundamental_sorting_key = lambda x: x.MarketCap
self.selection_flag: bool = False
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.settings.daily_precise_end_time = False
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 = stock.Symbol
# store daily price
if symbol in self.data:
self.data[symbol].update(stock.AdjustedPrice)
# monthly rebalance
if not self.selection_flag:
return Universe.Unchanged
# check last date on custom data
if self.Time.date() > self.short_volume_df.index[-1] or self.Time.date() < self.short_volume_df.index[0]:
self.Liquidate()
return Universe.Unchanged
# select top n stocks by dollar volume
selected: List[Fundamental] = [
x for x in fundamental
if x.HasFundamentalData
and x.MarketCap != 0
and x.Symbol.Value not in self.tickers_to_ignore
]
# if len(selected) > self.fundamental_count:
# selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
momentums: Dict[Symbol, float] = {} # storing stocks momentum
market_cap: Dict[Symbol, float] = {} # storing stocks market capitalization
# warmup price rolling windows
for stock in selected:
symbol: Symbol = stock.Symbol
ticker: str = symbol.Value
if symbol not in self.data:
# create SymbolData object for specific stock symbol
self.data[symbol] = SymbolData(self.period)
# get history daily prices
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
# store history daily prices into RollingWindow
for _, close in closes.items():
self.data[symbol].update(close)
if ticker in self.short_volume_df.columns:
if isnan(self.short_volume_df[self.short_volume_df.index <= self.Time.date()][ticker][-1]):
continue
self.data[symbol].update_short_interest(self.short_volume_df[self.short_volume_df.index <= self.Time.date()][ticker][-1] / stock.CompanyProfile.SharesOutstanding)
if not self.data[symbol].is_ready():
continue
# store stock market capitalization
market_cap[symbol] = stock.MarketCap
# calculate stock momentum
momentum = self.data[symbol].performance()
# store stock momentum
momentums[symbol] = momentum
# not enough stocks for quartile selection
if len(momentums) < self.quantile:
return Universe.Unchanged
# perform quartile selection
quantile: int = int(len(momentums) / 4)
sorted_by_momentum: List[Symbol] = [x[0] for x in sorted(momentums.items(), key=lambda item: item[1])]
# get top momentum stocks
top_by_momentum: List[Symbol] = sorted_by_momentum[-quantile:]
# check if there are enough data for next quartile selection on top stocks by momentum
if len(top_by_momentum) < self.quantile:
return Universe.Unchanged
# perform quartile selection on top stocks by momentum
quantile = int(len(top_by_momentum) / self.quantile)
sorted_by_short_interest: List[Symbol] = [x for x in sorted(top_by_momentum, key=lambda symbol: self.data[symbol].short_interest)]
# in the top momentum quartile, short the highest short interest quartile and long the quartile with the lowest short interest
short: List[Symbol] = sorted_by_short_interest[-quantile:]
long: List[Symbol] = sorted_by_short_interest[:quantile]
# calculate total long capitalization and total short capitalization
for i, portfolio in enumerate([long, short]):
mc_sum: float = sum(list(map(lambda symbol: market_cap[symbol], portfolio)))
for symbol in portfolio:
self.weight[symbol] = ((-1)**i) * market_cap[symbol] / mc_sum
return list(self.weight.keys())
def OnData(self, data: Slice) -> None:
# rebalance montly
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)
self.short_interest: Union[None, float] = None
def update(self, close: float) -> None:
self.closes.Add(close)
def update_short_interest(self, short_interest_value: float) -> None:
self.short_interest = short_interest_value
def is_ready(self) -> bool:
return self.closes.IsReady and self.short_interest
def performance(self) -> float:
closes: List[float] = [x for x in self.closes]
return (closes[0] - closes[-1]) / closes[-1]
# 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"))