Quant BuffetRelax, Not Over Thinking

Arbitraging Levered ETFs

Log in to collect

Academic paper

Leveraged ETPs Across Asset Classes

AuthorsAdriano Tosi

Institute
  • Morgan Stanley (United States)
  • ?Morgan Stanley

Strategy in a nutshell

Each month, pair LETFs with ETFs showing the highest absolute correlation (≥50%). Match ETF leverage to LETFs, then sell LETFs and buy/sell paired ETFs based on correlation. Portfolio is equally weighted across pairs.

Economic rationale

The strategy exploits LETF slippage: leveraged ETFs diverge from underlying ETF returns over multiple days due to compounding, creating predictable profit opportunities by trading correlated ETF-LETF pairs.

Backtest performance

Annualised return4.92%
Volatility2.56%
Beta0.025
Sharpe ratio1.92
Sortino ratio-1.173
Win rate48%

Full Python code

from AlgorithmImports import *
from typing import Dict, List, Set
from data_tools import CustomFeeModel, TradePair, SymbolData
# endregion

class ArbitragingLeveredETFs(QCAlgorithm):

def Initialize(self):
self.SetStartDate(2010, 1, 1)
self.SetCash(100000)

self.leverage:int = 5
self.corr_threshold:float = 0.5

self.period:int = 12 * 21

self.data:Dict[Symbol, SymbolData] = {}
self.trades:List[List[Symbol, float]] = []

self.l_etfs:Dict[str, float] = {}

l_etf_tickers_csv:str = self.Download('data.quantpedia.com/backtesting_data/equity/leveraged_etf_tickers.csv')
lines:List[str] = l_etf_tickers_csv.split('\r\n')

for line in lines[1:]:
    if line == '':
        continue

    line_split:List[str] = line.split(';')
    ticker:str = line_split[0]
    leverage:float = float(line_split[-1])

    self.l_etfs[ticker] = leverage

etf_tickers_csv:str = self.Download('data.quantpedia.com/backtesting_data/equity/not_leveraged_etf_tickers.csv')
lines:List[str] = etf_tickers_csv.split('\r\n')
self.etfs:List[str] = { ticker: 1 for ticker in lines[1:] if ticker != '' }

self.market_symbol:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol

self.selection_flag:bool = False
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)

self.Schedule.On(self.DateRules.MonthStart(self.market_symbol), self.TimeRules.BeforeMarketClose(self.market_symbol, 0), 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]:
for stock in fundamental:
    symbol:Symbol = stock.Symbol

    if symbol in self.data:
        self.data[symbol].update_prices(stock.AdjustedPrice)

if not self.selection_flag:
    return Universe.Unchanged

selected_leveraged_symbols:List[Symbol] = []
selected_symbols:List[Symbol] = []

for stock in fundamental:
    symbol:Symbol = stock.Symbol
    ticker:str = symbol.Value

    if ticker in self.etfs:
        selected_symbols.append(symbol)

    elif ticker in self.l_etfs:
        selected_leveraged_symbols.append(symbol)

    if symbol not in self.data:
        self.data[symbol] = SymbolData(self.period)
        history:pd.DataFrame = self.History(symbol, self.period, Resolution.Daily)

        if history.empty:
            continue

        closes:pd.Series = history.loc[symbol].close

        for _, close in closes.items():
            self.data[symbol].update_prices(close)

trade_pairs:List[TradePair] = []
symbols_to_trade:Set[Symbol] = set()
abs_corr_by_symbol:Dict[Symbol, Tuple] = {}

# pair each LETF to the ETF and calculate their correlation based on daily returns in one year
for l_symbol in selected_leveraged_symbols:
    if not self.data[l_symbol].is_ready():
        continue

    leveraged_daily_returns:np.array = self.data[l_symbol].get_daily_returns()

    for symbol in selected_symbols:
        if not self.data[symbol].is_ready():
            continue

        daily_returns:np.array = self.data[symbol].get_daily_returns()

        correlation:float = np.corrcoef(leveraged_daily_returns, daily_returns)[0][-1]

        # make sure correlation is greater than threshold
        abs_corr:float = abs(correlation)
        if abs_corr >= self.corr_threshold:
            if (l_symbol not in abs_corr_by_symbol) or \
                (l_symbol in abs_corr_by_symbol and abs_corr > abs_corr_by_symbol[l_symbol][1]):
                # go short (long) on ETF, when correlation is negative (positive)
                short_signal:bool = True if correlation < 0 else False
                abs_corr_by_symbol[l_symbol] = (symbol, abs_corr, short_signal)

    # create trade pairs
    for l_symbol, pair_tuple in abs_corr_by_symbol.items():
        symbol:Symbol = pair_tuple[0]
        short_signal:bool = pair_tuple[2]

        symbols_to_trade.add(symbol)
        symbols_to_trade.add(l_symbol)
        
        trade_pairs.append(TradePair(symbol, l_symbol, short_signal))

if len(trade_pairs) == 0:
    return Universe.Unchanged

total_trades:int = len(trade_pairs) * 2
portfolio_partition:float = self.Portfolio.TotalPortfolioValue / total_trades

# calculate quantity for each ETF and LETF in trade pairs
for trade_pair in trade_pairs:
    l_etf_price:float = self.data[trade_pair.l_etf_symbol].get_last_price()
    l_etf_leverage:float = self.l_etfs[trade_pair.l_etf_symbol.Value]
    etf_price:float = self.data[trade_pair.etf_symbol].get_last_price()

    l_etf_quantity:float = np.floor(portfolio_partition / l_etf_price / l_etf_leverage)
    etf_quantity:float = np.floor(portfolio_partition / etf_price)

    self.trades.append([trade_pair.l_etf_symbol, -l_etf_quantity])
    self.trades.append([trade_pair.etf_symbol, -etf_quantity if trade_pair.short_signal else etf_quantity])

return list(symbols_to_trade)

def OnData(self, data: Slice) -> None:
if not self.selection_flag:
    return
self.selection_flag = False

self.Liquidate()

for symbol, quantity in self.trades:
    self.MarketOrder(symbol, quantity)

self.trades.clear()

def Selection(self) -> None:
self.selection_flag = True