Abstract

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).

Background

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.

Method

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) )


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

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)

for i in range(2):
numerator_history = self.history[self.symbols[i]]['bids']


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]
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


To emit Insight objects, 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):
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)]
else:
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)]
else:
return []
return []


Portfolio Construction & Trade Execution

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 Name Start Date End Date Strategy Sharpe ASD
Backtest 8/11/2015 8/11/2020 Strategy -0.447 0.053
Benchmark 0.732 0.192
Fall 2015 8/10/2015 10/10/2015 Strategy 2.837 0.225
Benchmark -0.724 0.251
2020 Crash 2/19/2020 3/23/2020 Strategy -4.196 0.209
Benchmark -1.243 0.793
2020 Recovery 3/23/2020 6/8/2020 Strategy -3.443 0.013
Benchmark 13.761 0.386