The strategy identifies volume spikes, creating a portfolio of the 10% ETF constituents with the lowest beta during negative returns. These stocks are bought and held for 40 days, equally weighted.

I. STRATEGY IN A NUTSHELL

The strategy invests in stocks from 9 sector ETFs, the S&P 500 ETF, and a small-cap ETF, focusing on volume spikes—days when trading volume exceeds three standard deviations above the mean. If a volume spike coincides with a negative return, the investor selects the 10% of ETF constituents with the lowest beta relative to the ETF, forming an equally weighted portfolio held for 40 days. The portfolio is rebalanced with equal weights.

II. ECONOMIC RATIONALE

ETF distortions and reversals arise because individual constituents respond differently to shocks—such as commodity price changes, negative earnings surprises, or political events—creating heterogeneous exposures. These differences generate temporary inefficiencies, allowing investors to exploit mispricings by targeting low-beta constituents during negative volume spikes.

III. SOURCE PAPER

The Revenge of the Stock Pickers [Click to Open PDF]

Hailey Lynch et al.

<Abstract>

When an exchange-traded fund (ETF) trades heavily around a theme, correlations among its constituents increase significantly. Even some securities that have little or negative exposure to the theme itself begin to trade in lockstep with other ETF constituents. In other words, because ETF investors are agnostic to security-level information, they often “throw the baby out with the bathwater.” As the prices of individual stocks get dragged up or down with ETFs, these mispricings can become significant, and the profits realized by taking advantage of them may present an opportunity for stock pickers.

IV. BACKTEST PERFORMANCE

Annualised Return 18%
VolatilityN/A
Beta0.008
Sharpe RatioN/A
Sortino Ratio-4.171
Maximum DrawdownN/A
Win Rate57%

V. FULL PYTHON CODE

from AlgorithmImports import *
from QC100UniverseSelectionModel import QC100UniverseSelectionModel
from collections import deque
from scipy import stats
class StockPickingETFConstituents(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100000)
        self.period:int = 21
        self.SetWarmup(self.period, Resolution.Daily)
        
        # Source: https://github.com/QuantConnect/Lean/blob/master/Algorithm.Framework/Selection/QC500UniverseSelectionModel.py
        self.UniverseSettings.Resolution = Resolution.Daily
        self.SetUniverseSelection(QC100UniverseSelectionModel(n_of_symbols = 100, select_every_n_months = 3))
        self.quantile:int = 10
        # daily price data
        self.data:dict[Symbol, deque] = {}
        self.market:Symbol = self.AddEquity('OEF', Resolution.Daily).Symbol
        
        self.day_holding_period:int = 40
        self.managed_queue:list[RebalanceQueueItem] = []
    def OnSecuritiesChanged(self, changes):
        # newly added proxy S&P stocks
        for security in changes.AddedSecurities:
            symbol:Symbol = security.Symbol
            
            security.SetFeeModel(CustomFeeModel())
            security.SetLeverage(10)
            if symbol not in self.data:
                self.data[symbol] = deque(maxlen = self.period)
        
        # delete removed S&P stock from data storage
        for security in changes.RemovedSecurities:
            symbol:Symbol = security.Symbol
            if symbol in self.data:
                del self.data[symbol]
    def OnData(self, data) -> None:
        # store daily data for universe
        for symbol in self.data:
            if symbol in data and data[symbol]:
                price:float = data[symbol].Value
                volume:float = data[symbol].Volume
                self.data[symbol].append((price, volume))
        market_closes:list[float] = []
        trade_flag:bool = False
        
        # market etf data is ready
        if self.market in self.data and len(self.data[self.market]) == self.data[self.market].maxlen:
            market_closes = [x[0] for x in self.data[self.market]]
            volumes:list[float] = [x[1] for x in self.data[self.market]]
            volume_mean:float = np.mean(volumes)
            volume_std:float = np.std(volumes)
            
            recent_volume:float = volumes[-1]
            
            # volume spike has not occured
            if recent_volume > volume_mean + 3 * volume_std:
                # last day's return was negative
                last_day_return:float = market_closes[-1] / market_closes[-2] - 1
                if last_day_return < 0:
                    trade_flag = True
        
        market_closes:np.ndarray = np.array(market_closes)
        
        if trade_flag:
            stock_beta:dict[Symbol, float] = {}
            for symbol in self.data:
                if symbol == self.market: continue
                # stock data is ready
                if (symbol in self.data and len(self.data[symbol]) == self.data[symbol].maxlen):
                    # beta calculation
                    stock_closes:np.ndarray = np.array([x[0] for x in self.data[symbol]])
                        
                    market_returns:np.ndarray = (market_closes[1:] - market_closes[:-1]) / market_closes[:-1]
                    stock_returns:np.ndarray = (stock_closes[1:] - stock_closes[:-1]) / stock_closes[:-1]
                    
                    # manual beta calc
                    cov = np.cov(market_returns, stock_returns)[0][1]
                    market_variance = np.std(market_returns) ** 2
                    beta = cov / market_variance            
                    
                    # beta, alpha, r_value, p_value, std_err = stats.linregress(market_returns, stock_returns)
                    stock_beta[symbol] = beta
            
            if len(stock_beta) >= self.quantile:
                # beta sorting
                sorted_by_beta:list = sorted(stock_beta.items(), key = lambda x: x[1], reverse = True)
                quantile:int = int(len(sorted_by_beta) / self.quantile)
                long:list[Symbol] = [x[0] for x in sorted_by_beta[-quantile:]]
                
                long_w:float = self.Portfolio.TotalPortfolioValue / self.day_holding_period / len(long)
                long_symbol_q:list[tuple[Symbol, float]] = [(x, np.floor(long_w / self.data[x][-1][0])) for x in long]
                
                # append long portfolio to managed queue
                self.managed_queue.append(RebalanceQueueItem(long_symbol_q))
        
        # rebalance portfolio
        remove_item:RebalanceQueueItem = None
        for item in self.managed_queue:
            if item.holding_period == self.day_holding_period:
                for symbol, quantity in item.symbol_q:
                    self.MarketOrder(symbol, -quantity)
                            
                remove_item = item
                
            elif item.holding_period == 0:
                open_symbol_q:list[tuple[Symbol, float]] = []
                
                for symbol, quantity in item.symbol_q:
                    if symbol in data and data[symbol]:
                        self.MarketOrder(symbol, quantity)
                        open_symbol_q.append((symbol, quantity))
                            
                # only opened orders will be closed
                item.symbol_q = open_symbol_q
                
            item.holding_period += 1
            
        # we need to remove closed part of portfolio after loop. Otherwise it will miss one item in self.managed_queue
        if remove_item:
            self.managed_queue.remove(remove_item)
class RebalanceQueueItem():
    def __init__(self, symbol_q:list) -> None:
        # symbol/quantity collections
        self.symbol_q:list[tuple[Symbol, float]] = symbol_q  
        self.holding_period:int = 0
        
# 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