Strategy Library

Intraday Arbitrage Between Index ETFs


In this tutorial, we implement an intraday arbitrage strategy that capitalizes on deviations between two closely correlated index ETFs. Even though at times both ETFs may hold different constituents and different weights of securities while tracking the index, they are both highly correlated and extremely liquid. Researchers have shown these two properties are essential to an arbitrage system's success. The algorithm we implement here is inspired by the work of Kakushadze and Serur (2018) and Marshall, Nguyen, and Visaltanachoti (2010).


Marshall et al (2010) define an arbitrage opportunity as when the bid price of ETF A (B) diverts high enough away from the ask price of ETF B (A) such that their quotient reaches a threshold. In their paper, an arbitrage opportunity is only acted upon when the threshold is satisfied for 15 seconds. When these criteria are met, the algorithm enters the arbitrage trade by going long ETF B (A) and short ETF A (B). When the spread reverts back to where the bid of ETF B (A) >= the ask of ETF A (B) for 15 seconds, the positions are liquidated. An overview of the trade process is illustrated in the image below.



Universe Selection

We implement a manual universe selection model that includes our two ETFs, SPY and IVV. The attached research notebook finds the correlation of daily returns to be >0.99.

tickers = ['IVV', 'SPY']
symbols = [ Symbol.Create(t, SecurityType.Equity, Market.USA) for t in tickers ]
self.SetUniverseSelection( ManualUniverseSelectionModel(symbols) )

Spread Adjustments

Plotting the ratio of the security prices shows its trending behavior.


Without adjusting this ratio over time, an arbitrage system would be stuck in a single trade for majority of the backtest. To resolve this, we subtract a trailing mean from each data point.


Both of the above plots can be reproduced in the attached research notebook. During backtesting, this adjustment is done during trading by setting up a QuoteBarConsolidator for each security in our universe. On each new consolidated QuoteBar, we update the trailing window of L1 data, then calculate the latest spread adjustment values.

# In OnSecuritiesChanged
for symbol in self.symbols:
    self.consolidators[symbol] = QuoteBarConsolidator(1)
    self.consolidators[symbol].DataConsolidated += self.CustomDailyHandler
    algorithm.SubscriptionManager.AddConsolidator(symbol, self.consolidators[symbol])

def CustomDailyHandler(self, sender, consolidated):
    # Add new data point to history while removing expired history
    self.history[consolidated.Symbol]['bids'] = np.append(self.history[consolidated.Symbol]['bids'][1:], consolidated.Bid.Close)
    self.history[consolidated.Symbol]['asks'] = np.append(self.history[consolidated.Symbol]['asks'][1:], consolidated.Ask.Close)

def update_spread_adjusters(self):
    for i in range(2):
        numerator_history = self.history[self.symbols[i]]['bids']
        denominator_history = self.history[self.symbols[abs(i-1)]]['asks']
        self.spread_adjusters[i] = (numerator_history / denominator_history).mean()

Alpha Construction

The ArbitrageAlphaModel monitors the intraday bid and ask prices of the securities in the universe. In the constructor, we can specify the model parameters. In this tutorial, we select a shorter window an arbitrage opportunity must be active before we act on it by setting `order_delay` to 3.

class ArbitrageAlphaModel(AlphaModel):
    symbols = [] # IVV, SPY
    entry_timer = [0, 0]
    exit_timer = [0, 0]
    spread_adjusters = [0, 0]
    long_side = -1
    consolidators = {}
    history = {}

    def __init__(self, order_delay = 3, profit_pct_threshold = 0.02, window_size = 400):
        self.order_delay = order_delay
        self.pct_threshold = profit_pct_threshold / 100
        self.window_size = window_size

Trade Signals

To emit insights, we check if either side of the arbitrage strategy warrants an entry. If no new entries are to be made, the algorithm then looks to exit any current positions. With this design, we can flip our long/short bias without first flattening our position. We use a practically-infinite insight durations as we do not know how long the algorithm will be in an arbitrage trade.

# Search for entries
for i in range(2):
    if quotebars[abs(i-1)].Bid.Close / quotebars[i].Ask.Close - self.spread_adjusters[abs(i-1)] >= self.pct_threshold:
        self.entry_timer[i] += 1
        if self.entry_timer[i] == self.order_delay:
            self.exit_timer = [0, 0]
            if self.long_side == i:
                return []
            self.long_side = i
            return [Insight.Price(self.symbols[i], timedelta(days=9999), InsightDirection.Up),
                    Insight.Price(self.symbols[abs(i-1)], timedelta(days=9999), InsightDirection.Down)]
            return []
    self.entry_timer[i] = 0

# Search for an exit
if self.long_side >= 0: # In a position
    if quotebars[self.long_side].Bid.Close / quotebars[abs(self.long_side-1)].Ask.Close - self.spread_adjusters[self.long_side] >= 0: # Exit signal
        self.exit_timer[self.long_side] += 1
        if self.exit_timer[self.long_side] == self.order_delay: # Exit signal lasted long enough
            self.exit_timer[self.long_side] = 0
            i = self.long_side
            self.long_side = -1
            return [Insight.Price(self.symbols[i], timedelta(days=9999), InsightDirection.Flat),
                    Insight.Price(self.symbols[abs(i-1)], timedelta(days=9999), InsightDirection.Flat)]
            return []
return []

Portfolio Construction & Trade Execution

Following the guidelines of Alpha Streams and the Quant League competition, we utilize the EqualWeightingPortfolioConstructionModel and the ImmediateExecutionModel.


Relative Performance

We analyze the performance of this strategy by comparing it to the S&P 500 ETF benchmark, SPY. We notice that the strategy has a lower Sharpe ratio over all of our testing periods than the benchmark, except for the Fall 2015 crisis where it achieved a 2.8 Sharpe ratio. The strategy also has a lower annual standard deviation of returns when compared to the SPY, implying more consistent returns over time. A breakdown of the strategy's performance across all our testing periods is displayed in the table below.

Period NameStart DateEnd DateStrategySharpeASD
Backtest 8/11/2015 8/11/2020Strategy-0.447 0.053
Benchmark 0.7320.192
Fall 2015 8/10/2015 10/10/2015Strategy 2.837 0.225
2020 Crash 2/19/2020 3/23/2020Strategy-4.196 0.209
Benchmark -1.2430.793
2020 Recovery 3/23/2020 6/8/2020Strategy-3.443 0.013
Benchmark 13.7610.386

The lack of performance for this arbitrage strategy is mostly attributed to the fees it incurs while trading. This is common for an intraday arbitrage strategy, but we discuss ways to reduces these fees in the conclusion of this tutorial. After removing the costs of commissions, crossing the spread, and slippage, the strategy outperforms the SPY over the entire backtesting period. Without these costs, the strategy generates a 1.09 Share ratio while the SPY generates a 0.732 Sharpe ratio. See the backtest performance without fees below.

Market & Competition Qualification

Although this strategy passes several of the metrics required for Alpha Streams and the Quant League competition, it requires further work to pass the following requirements:

  • Profitable
  • PSR >= 80%
  • Max drawdown duration <= 6 months
  • Insights contain the following properties: Symbol, Duration, Direction, and Weight
  • Alphas need to place at least 10 trades per month for the majority of the backtest

The algorithm currently places trades during 12 unique months throughout the backtest. Since the backtest spans across 61 months, it places trades through a minority of the backtest.


The intraday arbitrage strategy we built and tested throughout this tutorial underperforms the SPY benchmark in terms of Sharpe ratio when including trading costs. Without these costs, we found the strategy outperforms the SPY in terms of Sharpe ratio. In our implementation, we specified the alpha model to initiate trading when atleast a 0.02% profit threshold is reached for 3 seconds. Both of these parameters are set lower than the strategy examined in Marshall et al (2010) for demonstration purposes. Increasing the profit threshold will lead to more profitable, but fewer, trades that may overcome the cost of trading. We leave this area of study for future research. Additional areas of future research include increasing the resolution of data from second to tick and incorportating an execution model that utilizes limit orders to reduce fees.


  1. Marshall, Ben R. and Nguyen, Nhut (Nick) Hoang and Visaltanachoti, Nuttawat, ETF Arbitrage: Intraday Evidence (November 16, 2010). Online copy
  2. Kakushadze, Zura and Serur, Juan Andrés, 151 Trading Strategies (August 17, 2018). Z. Kakushadze and J.A. Serur. 151 Trading Strategies. Cham, Switzerland: Palgrave Macmillan, an imprint of Springer Nature, 1st Edition (2018), XX, 480 pp; ISBN 978-3-030-02791-9. Online copy

You can also see our Documentation and Videos. You can also get in touch with us via Discord.

Did you find this page helpful?

Contribute to the tutorials: