Quant BuffetRelax, Not Over Thinking

High-Frequency Arbitrage with ETF Twins

Log in to collect

Academic paper

The Microstructure of Arbitrage: ETF Evidence

AuthorsBen R. Marshall; Nhut H. Nguyen; Nuttawat Visaltanachoti

Institute
  • NZMassey University
  • ?Massey University - School of Economics and Finance
  • NZAuckland University of Technology
  • ?Massey University - Department of Economics and Finance

Strategy in a nutshell

Exploit SPY–IUSA ETF price divergences >0.2% via cross-exchange arbitrage; buy undervalued, short overvalued ETFs, closing trades when spreads converge.

Economic rationale

ETF prices occasionally deviate from underlying indices due to misweighting, creating temporary arbitrage opportunities that allow investors to capture risk-free profits.

Backtest performance

Annualised return28.91%
Volatility14.69%
Beta-0.002
Sharpe ratio1.7
Win rate40%

Full Python code

from AlgorithmImports import *
from typing import List, Union
# endregion
class HighFrequencyArbitragewithETFTwins(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2000, 1, 1)
self.SetCash(100000)
self.spread_threshold:float = 1.002
self.spy_voo_ratio:Union[None, float] = None
self.voo_spy_ratio:Union[None, float] = None
self.symbols:List[Symbol] = [self.AddEquity(x, Resolution.Minute).Symbol for x in ['SPY', 'VOO']]
self.trade_direction_flag:Union[None, bool] = None
def OnData(self, data: Slice) -> None:
if self.symbols[0] in data and data[self.symbols[0]] and self.symbols[1] in data and data[self.symbols[1]]:
    # get ratio of etfs
    if self.Time.hour == 9 and self.Time.minute == 35:
        self.spy_voo_ratio = self.Securities[self.symbols[0]].BidPrice / self.Securities[self.symbols[1]].AskPrice
        self.voo_spy_ratio = self.Securities[self.symbols[1]].BidPrice / self.Securities[self.symbols[0]].AskPrice
    if self.spy_voo_ratio is not None and self.voo_spy_ratio is not None and not self.Portfolio.Invested:
        # decide on trading direction
        self.trade_direction_flag = True \
            if (self.Securities[self.symbols[0]].BidPrice / self.Securities[self.symbols[1]].AskPrice) >= self.spy_voo_ratio * self.spread_threshold \
            else False \
            if (self.Securities[self.symbols[1]].BidPrice / self.Securities[self.symbols[0]].AskPrice) >= self.voo_spy_ratio * self.spread_threshold \
            else None
    
        # trade execution
        if self.trade_direction_flag is not None:
            self.SetHoldings(self.symbols[0], (-1 if self.trade_direction_flag else 1) * 1)
            self.SetHoldings(self.symbols[1], (-1 if self.trade_direction_flag else 1) * -1)
    # closing trade
    if self.Portfolio.Invested:
        if self.trade_direction_flag:
            if (self.Securities[self.symbols[0]].BidPrice / self.Securities[self.symbols[1]].AskPrice) < self.spy_voo_ratio * self.spread_threshold:
                self.Liquidate()
                self.trade_direction_flag = None
                self.spy_voo_ratio = None
                self.voo_spy_ratio = None
        
        else:
            if (self.Securities[self.symbols[1]].BidPrice / self.Securities[self.symbols[0]].AskPrice) < self.voo_spy_ratio * self.spread_threshold:
                self.Liquidate()
                self.trade_direction_flag = None
                self.spy_voo_ratio = None
                self.voo_spy_ratio = None
# close before market close
if self.Time.hour == 15 and self.Time.minute == 59 and self.Portfolio.Invested:
    self.Liquidate()
    self.trade_direction_flag = None
    self.spy_voo_ratio = None
    self.voo_spy_ratio = None