
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.
ASSET CLASS: stocks | REGION: United States | FREQUENCY:
Daily | MARKET: equities | KEYWORD: ETF
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% |
| Volatility | N/A |
| Beta | 0.008 |
| Sharpe Ratio | N/A |
| Sortino Ratio | -4.171 |
| Maximum Drawdown | N/A |
| Win Rate | 57% |
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"))