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.

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 Return15.66%
Volatility17.8%
Beta-0.054
Sharpe Ratio0.88
Sortino Ratio0.436
Maximum DrawdownN/A
Win Rate50%

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"))

Leave a Reply

Discover more from Quant Buffet

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

Continue reading