Spatial arbitrage

Spatial arbitrage is the act of buying low at one exchange and selling high at another. That is pretty much all there is to it. However, as always, the devil is in the details. There are some important variables, such as transaction fees, that need to be taken into account before attempting to take advantage of an arbitrage opportunity.

Important variables

The following information needs to be known to determine if there is an exploitable and profitable arbitrage opportunity between two exchanges:

Arbitrage detection using aggregated order books

It is possible to aggregate order books from multiple exchanges into one big limit order book. Often this will result in a crossed order book (where bid prices are higher than ask prices). This means that there are arbitrage opportunities present, seeing as it is possible to buy low on one exchange and sell higher on another exchange. The aggregated limit order book then forms an easy, albeit rather slow method for finding all spatial arbitrage opportunities between several exchanges. Transaction fees can be taken into account by multiplying all bids and asks in the order book by respectively (1-fee) and (1+fee).

For example, in the following limit order book aggregated from exchanges A and B (no fees for simplicity), there is an arbitrage opportunity of $268.22 or 163 shares present.

                      BIDS   ASKS

     [ EXCH | QTY | PRICE  - PRICE  | QTY | EXCH ]
     [-------------------------------------------]
     [    B | 131 | 103.23 - 100.54 | 48  | A    ]
     [    A |  32 | 102.98 - 101.87 | 84  | A    ]
     [    A | 293 | 101.48 - 102.17 | 38  | B    ]
     [    A |  65 | 101.10 - 104.75 | 27  | B    ]

The first two asks can be bought and sold completely into to two topmost bids, resulting in a profit of about $243:

$$(103.23 - 100.54) * 48 + (103.23 - 101.87) * 83 + (102.98 - 101.87) * 1 = 243.11$$

Thirty-one shares of the third ask can then be sold into the second bid for , after which no further profits can be made:

$$(102.98 - 102.17)*31 = 25.11$$

Exploitation methods

Arbitrage can be exploited by either moving assets between exchanges or by keeping an equal amount of money and assets on all exchanges.

The first method is rather impractical and risky depending on the time it takes to move funds between exchanges. Arbitrage opportunities dissapear rather quickly. By the time you have moved any bought assets the prices on other exchanges will have moved and you will most likely be forced to sell at a loss.

The second method incurs no such risk. This is because an equal amount of money and assets is kept on all exchanges. When an arbitrage opportunity appears you can then instantly and simultaneously sell on the expensive exchange and buy on the cheap exchange. The prices won't be able to change much and the expected arbitrage profits will be mostly realized. Funds will still need to be moved between exchanges (to avoid asset imbalances) over time, but not during trades. The downside to this method is the large amount of funds needed depending on the amount of exchanges you intend to arbitrage.

Python implementation

(In progress)

This strategy implementation uses an aggregated order book to find arbitrage opportunities between exchanges. It takes both fees and available volume into account.

from api.unifiedapi import UnifiedAPI
from exchange.orders import ExternalOrder, AggregatedOrderbook
from strategies.strategy import Strategy
from system.event import Signal

import time

class Arbitrage:

    def __init__(self, path, delta, buy_price, sell_price, volume, fees, profit):
        self.buy_price = buy_price
        self.sell_price = sell_price
        self.path = path
        self.delta = delta
        self.volume = volume
        self.fees = fees
        self.profit = profit

class SpatialArbitrage(Strategy):

    def __init__(self, pair, exchanges):
        Strategy.__init__(self,  "SpatArb", [pair], exchanges)
        self.api = UnifiedAPI()
        self.pair = pair
        self.book = AggregatedOrderbook()
        self.exchanges = [exchange for exchange in exchanges if exchange in self.api.exchanges() and self.pair in self.api.pairs(exchange)]
        self.fees = {exchange: self.api.fees(exchange)['taker'] for exchange in self.exchanges}
        self.paths = []

    def update(self):
        orderbooks = self.api.order_books(self.exchanges, self.pair)
        self.book = AggregatedOrderbook()
        for orderbook in orderbooks:
            exchange = orderbook['exchange']
            bids = [ExternalOrder(exchange, "limit", "buy", bid['price'], bid['quantity']) for bid in orderbook['bids']]
            asks = [ExternalOrder(exchange, "limit", "sell", ask['price'], ask['quantity']) for ask in orderbook['asks']]
            self.book.merge(bids, asks)

    def find_opportunities(self):
        opportunities = []

        possible_paths = [list(path) for path in itertools.permutations(self.exchanges, r=3 - 1)]
        for path in possible_paths:
            arbitrage = self.analyze_path(path)
            if arbitrage:
                opportunities.append(arbitrage)
        return opportunities

    def analyze_path(self, path):
        buy_exch = path[0]
        sell_exch = path[1]

        asks = copy.deepcopy([ask for ask in self.book.asks if ask.exchange == buy_exch])
        bids = copy.deepcopy([bid for bid in self.book.bids if bid.exchange == sell_exch])

        ask = asks[0].price
        bid = bids[0].price

        delta = bid*(1-self.fees[sell_exch])-ask*(1+self.fees[buy_exch])
        if delta > 0:

            volume = 0
            profit = 0
            fees = 0
            while bids[0].price * (1 - self.fees[buy_exch]) > asks[0].price * (1 + self.fees[buy_exch]): #while there is arbitrage profit possible
                buy_order = asks.pop(0)
                while buy_order.quantity > 0:# while the arbitrage order is not filled
                    sell_order = bids[0]
                    delta = sell_order.price * (1 - self.fees[buy_exch]) > buy_order.price * (1 + self.fees[buy_exch])
                    if not delta:
                        break
                    elif sell_order.quantity > buy_order.quantity:
                        profit += (buy_order.quantity * sell_order.price) - (buy_order.quantity * buy_order.price)
                        fees += (buy_order.quantity * sell_order.price * self.fees[sell_exch]) + (buy_order.quantity * buy_order.price * self.fees[buy_exch])
                        volume += buy_order.quantity
                        sell_order.quantity -= buy_order.quantity
                        buy_order.quantity = 0
                    elif sell_order.quantity == buy_order.quantity:
                        profit += (buy_order.quantity * sell_order.price) - (buy_order.quantity * buy_order.price)
                        fees += (buy_order.quantity * sell_order.price * self.fees[sell_exch]) + (
                        buy_order.quantity * buy_order.price * self.fees[buy_exch])
                        volume += buy_order.quantity
                        buy_order.quantity = 0
                        del bids[0]
                    elif sell_order.quantity < buy_order.quantity:
                        profit += (sell_order.quantity * sell_order.price) - (sell_order.quantity * buy_order.price)
                        buy_order.quantity -= sell_order.quantity
                        fees += (sell_order.quantity * sell_order.price * self.fees[sell_exch]) + (
                        sell_order.quantity * buy_order.price * self.fees[buy_exch])
                        volume += sell_order.quantity
                        buy_order.quantity -= sell_order.quantity
                        del bids[0]

            return Arbitrage(path, delta, ask*(1+self.fees[buy_exch]), bid*(1-self.fees[sell_exch]), volume, fees, profit)
        else:
            pass

    def generate_signal(self, ticker):
        signals = []
        self.update()
        opportunities = self.find_opportunities()
        opportunities.sort(key = lambda a: -a.profit)
        for arbitrage in opportunities:
            signals.append(Signal(arbitrage.path[0], self.pair, "BUY", arbitrage.buy_price, arbitrage.volume/arbitrage.buy_price, time.time()))
            signals.append(Signal(arbitrage.path[1], self.pair, "SELL", arbitrage.sell_price, arbitrage.volume/arbitrage.sell_price, time.time()))
        return signals