Back to Ccxt

We scanned 100 exchanges for arbitrage in 50 lines of code — here's the catch

website/content/blog/scanning-100-exchanges-for-arbitrage-spreads.mdx

4.5.6516.3 KB
Original Source

The classic first "quant" project: the same asset trades at slightly different prices on different exchanges — find the gaps. Because CCXT gives every exchange the same API, the whole scanner fits in about fifty lines. We'll build it, then spend the second half of this post on why most of what it finds is not free money — which is the part that actually makes you better at this.

The scanner

The efficient shape: load markets everywhere, find symbols listed on at least two venues, then make one batched fetchTickers call per exchange instead of hammering per-symbol endpoints.

javascript
import ccxt from 'ccxt';

const ids = ['binance', 'kraken', 'okx', 'bybit', 'kucoin'];

const exchanges = await Promise.all(ids.map(async (id) => {
    const exchange = new ccxt[id]();
    await exchange.loadMarkets();
    return exchange;
}));

// spot USDT symbols listed on at least two venues
const counts = {};
for (const exchange of exchanges) {
    for (const symbol in exchange.markets) {
        const market = exchange.markets[symbol];
        if (market.spot && market.active !== false && symbol.endsWith('/USDT')) {
            counts[symbol] = (counts[symbol] ?? 0) + 1;
        }
    }
}
const shared = Object.keys(counts).filter((symbol) => counts[symbol] >= 2);

// one batched call per exchange
const perExchange = await Promise.all(exchanges.map((exchange) =>
    exchange.fetchTickers(shared.filter((symbol) => symbol in exchange.markets))));

const opportunities = [];
for (const symbol of shared) {
    const quotes = [];
    exchanges.forEach((exchange, i) => {
        const ticker = perExchange[i][symbol];
        if (ticker && ticker.bid && ticker.ask) {
            quotes.push({ id: exchange.id, bid: ticker.bid, ask: ticker.ask });
        }
    });
    if (quotes.length < 2) continue;
    const bestBid = quotes.reduce((a, b) => (b.bid > a.bid ? b : a));
    const bestAsk = quotes.reduce((a, b) => (b.ask < a.ask ? b : a));
    const spread = (bestBid.bid - bestAsk.ask) / bestAsk.ask;
    if (spread > 0) {
        opportunities.push({ symbol, buyOn: bestAsk.id, sellOn: bestBid.id, spread });
    }
}

opportunities.sort((a, b) => b.spread - a.spread);
for (const o of opportunities.slice(0, 10)) {
    console.log(`${o.symbol}: buy ${o.buyOn}, sell ${o.sellOn}, ${(o.spread * 100).toFixed(3)}%`);
}

await Promise.all(exchanges.map((exchange) => exchange.close()));
python
import asyncio
import ccxt.async_support as ccxt

IDS = ['binance', 'kraken', 'okx', 'bybit', 'kucoin']


async def main():
    exchanges = [getattr(ccxt, exchange_id)() for exchange_id in IDS]
    await asyncio.gather(*[exchange.load_markets() for exchange in exchanges])

    # spot USDT symbols listed on at least two venues
    counts = {}
    for exchange in exchanges:
        for symbol, market in exchange.markets.items():
            if market['spot'] and market['active'] is not False and symbol.endswith('/USDT'):
                counts[symbol] = counts.get(symbol, 0) + 1
    shared = [symbol for symbol, count in counts.items() if count >= 2]

    # one batched call per exchange
    per_exchange = await asyncio.gather(*[
        exchange.fetch_tickers([s for s in shared if s in exchange.markets])
        for exchange in exchanges
    ])

    opportunities = []
    for symbol in shared:
        quotes = []
        for exchange, tickers in zip(exchanges, per_exchange):
            ticker = tickers.get(symbol)
            if ticker and ticker['bid'] and ticker['ask']:
                quotes.append((exchange.id, ticker['bid'], ticker['ask']))
        if len(quotes) < 2:
            continue
        sell_on = max(quotes, key=lambda q: q[1])   # highest bid
        buy_on = min(quotes, key=lambda q: q[2])    # lowest ask
        spread = (sell_on[1] - buy_on[2]) / buy_on[2]
        if spread > 0:
            opportunities.append((symbol, buy_on[0], sell_on[0], spread))

    for symbol, buy, sell, spread in sorted(opportunities, key=lambda o: -o[3])[:10]:
        print(f'{symbol}: buy {buy}, sell {sell}, {spread * 100:.3f}%')

    await asyncio.gather(*[exchange.close() for exchange in exchanges])

asyncio.run(main())
php
<?php
$ids = ['binance', 'kraken', 'okx', 'bybit', 'kucoin'];

$exchanges = [];
foreach ($ids as $id) {
    $class = "\\ccxt\\{$id}";
    $exchange = new $class();
    $exchange->load_markets();
    $exchanges[] = $exchange;
}

// spot USDT symbols listed on at least two venues
$counts = [];
foreach ($exchanges as $exchange) {
    foreach ($exchange->markets as $symbol => $market) {
        if ($market['spot'] && $market['active'] !== false && str_ends_with($symbol, '/USDT')) {
            $counts[$symbol] = ($counts[$symbol] ?? 0) + 1;
        }
    }
}
$shared = array_keys(array_filter($counts, fn ($count) => $count >= 2));

// one batched call per exchange (the async flavor can run these concurrently)
$perExchange = [];
foreach ($exchanges as $exchange) {
    $symbols = array_values(array_filter($shared, fn ($s) => isset($exchange->markets[$s])));
    $perExchange[] = $exchange->fetch_tickers($symbols);
}

$opportunities = [];
foreach ($shared as $symbol) {
    $quotes = [];
    foreach ($exchanges as $i => $exchange) {
        $ticker = $perExchange[$i][$symbol] ?? null;
        if ($ticker && $ticker['bid'] && $ticker['ask']) {
            $quotes[] = ['id' => $exchange->id, 'bid' => $ticker['bid'], 'ask' => $ticker['ask']];
        }
    }
    if (count($quotes) < 2) {
        continue;
    }
    $bestBid = $quotes[0];
    $bestAsk = $quotes[0];
    foreach ($quotes as $quote) {
        if ($quote['bid'] > $bestBid['bid']) $bestBid = $quote;
        if ($quote['ask'] < $bestAsk['ask']) $bestAsk = $quote;
    }
    $spread = ($bestBid['bid'] - $bestAsk['ask']) / $bestAsk['ask'];
    if ($spread > 0) {
        $opportunities[] = [$symbol, $bestAsk['id'], $bestBid['id'], $spread];
    }
}

usort($opportunities, fn ($a, $b) => $b[3] <=> $a[3]);
foreach (array_slice($opportunities, 0, 10) as [$symbol, $buy, $sell, $spread]) {
    printf("%s: buy %s, sell %s, %.3f%%\n", $symbol, $buy, $sell, $spread * 100);
}
csharp
using ccxt;

var ids = new List<string> { "binance", "kraken", "okx", "bybit", "kucoin" };

var exchanges = ids.Select(id => Exchange.DynamicallyCreateInstance(id)).ToList();
var marketsPerExchange = await Task.WhenAll(exchanges.Select(exchange => exchange.LoadMarkets()));

// spot USDT symbols listed on at least two venues
var counts = new Dictionary<string, int>();
foreach (var markets in marketsPerExchange)
{
    foreach (var (symbol, market) in markets)
    {
        if (market.spot == true && market.active != false && symbol.EndsWith("/USDT"))
        {
            counts[symbol] = counts.GetValueOrDefault(symbol) + 1;
        }
    }
}
var shared = counts.Where(kv => kv.Value >= 2).Select(kv => kv.Key).ToList();

// one batched call per exchange
var perExchange = await Task.WhenAll(exchanges.Select((exchange, i) =>
    exchange.FetchTickers(shared.Where(s => marketsPerExchange[i].ContainsKey(s)).ToList())));

var opportunities = new List<(string Symbol, string BuyOn, string SellOn, double Spread)>();
foreach (var symbol in shared)
{
    var quotes = new List<(string Id, double Bid, double Ask)>();
    for (var i = 0; i < exchanges.Count; i++)
    {
        if (perExchange[i].tickers.TryGetValue(symbol, out var ticker)
            && ticker.bid != null && ticker.ask != null)
        {
            quotes.Add((exchanges[i].id, (double)ticker.bid, (double)ticker.ask));
        }
    }
    if (quotes.Count < 2) continue;
    var bestBid = quotes.MaxBy(q => q.Bid);
    var bestAsk = quotes.MinBy(q => q.Ask);
    var spread = (bestBid.Bid - bestAsk.Ask) / bestAsk.Ask;
    if (spread > 0) opportunities.Add((symbol, bestAsk.Id, bestBid.Id, spread));
}

foreach (var o in opportunities.OrderByDescending(o => o.Spread).Take(10))
{
    Console.WriteLine($"{o.Symbol}: buy {o.BuyOn}, sell {o.SellOn}, {o.Spread * 100:F3}%");
}
go
package main

import (
    "fmt"
    "sort"
    "strings"

    ccxt "github.com/ccxt/ccxt/go/v4"
)

type quote struct {
    id       string
    bid, ask float64
}

type opportunity struct {
    symbol, buyOn, sellOn string
    spread                float64
}

func main() {
    ids := []string{"binance", "kraken", "okx", "bybit", "kucoin"}

    exchanges := make([]ccxt.IExchange, len(ids))
    marketsPerExchange := make([]map[string]ccxt.MarketInterface, len(ids))
    for i, id := range ids {
        exchanges[i] = ccxt.CreateExchange(id, nil)
        markets, err := exchanges[i].LoadMarkets()
        if err != nil {
            panic(err)
        }
        marketsPerExchange[i] = markets
    }

    // spot USDT symbols listed on at least two venues
    counts := map[string]int{}
    for _, markets := range marketsPerExchange {
        for symbol, market := range markets {
            if market.Spot != nil && *market.Spot &&
                (market.Active == nil || *market.Active) &&
                strings.HasSuffix(symbol, "/USDT") {
                counts[symbol]++
            }
        }
    }
    var shared []string
    for symbol, count := range counts {
        if count >= 2 {
            shared = append(shared, symbol)
        }
    }

    // one batched call per exchange (run these in goroutines for speed)
    perExchange := make([]map[string]ccxt.Ticker, len(exchanges))
    for i, exchange := range exchanges {
        var symbols []string
        for _, symbol := range shared {
            if _, ok := marketsPerExchange[i][symbol]; ok {
                symbols = append(symbols, symbol)
            }
        }
        tickers, err := exchange.FetchTickers(ccxt.WithFetchTickersSymbols(symbols))
        if err != nil {
            panic(err)
        }
        perExchange[i] = tickers.Tickers
    }

    var opportunities []opportunity
    for _, symbol := range shared {
        var quotes []quote
        for i, exchange := range exchanges {
            ticker, ok := perExchange[i][symbol]
            if !ok || ticker.Bid == nil || ticker.Ask == nil {
                continue
            }
            quotes = append(quotes, quote{exchange.GetId(), *ticker.Bid, *ticker.Ask})
        }
        if len(quotes) < 2 {
            continue
        }
        bestBid, bestAsk := quotes[0], quotes[0]
        for _, q := range quotes {
            if q.bid > bestBid.bid {
                bestBid = q
            }
            if q.ask < bestAsk.ask {
                bestAsk = q
            }
        }
        spread := (bestBid.bid - bestAsk.ask) / bestAsk.ask
        if spread > 0 {
            opportunities = append(opportunities, opportunity{symbol, bestAsk.id, bestBid.id, spread})
        }
    }

    sort.Slice(opportunities, func(a, b int) bool {
        return opportunities[a].spread > opportunities[b].spread
    })
    for _, o := range opportunities[:min(10, len(opportunities))] {
        fmt.Printf("%s: buy %s, sell %s, %.3f%%\n", o.symbol, o.buyOn, o.sellOn, o.spread*100)
    }
}
java
import io.github.ccxt.Exchange;

import java.util.*;

public class SpreadScanner {

    record Quote(String id, double bid, double ask) {}
    record Opportunity(String symbol, String buyOn, String sellOn, double spread) {}

    @SuppressWarnings("unchecked")
    public static void main(String[] args) {
        var ids = List.of("binance", "kraken", "okx", "bybit", "kucoin");

        // dynamic instances return untyped maps (see the typed per-class API otherwise)
        var exchanges = new ArrayList<Exchange>();
        var marketsPerExchange = new ArrayList<Map<String, Object>>();
        for (var id : ids) {
            var exchange = Exchange.dynamicallyCreateInstance(id, null);
            exchange.loadMarkets(false).join();
            exchanges.add(exchange);
            marketsPerExchange.add((Map<String, Object>) exchange.markets);
        }

        // spot USDT symbols listed on at least two venues
        var counts = new HashMap<String, Integer>();
        for (var markets : marketsPerExchange) {
            for (var entry : markets.entrySet()) {
                var market = (Map<String, Object>) entry.getValue();
                if (Boolean.TRUE.equals(market.get("spot"))
                        && !Boolean.FALSE.equals(market.get("active"))
                        && entry.getKey().endsWith("/USDT")) {
                    counts.merge(entry.getKey(), 1, Integer::sum);
                }
            }
        }
        var shared = counts.entrySet().stream()
                .filter(e -> e.getValue() >= 2).map(Map.Entry::getKey).toList();

        // one batched call per exchange
        var perExchange = new ArrayList<Map<String, Object>>();
        for (var i = 0; i < exchanges.size(); i++) {
            var markets = marketsPerExchange.get(i);
            var symbols = shared.stream().filter(markets::containsKey).toList();
            perExchange.add((Map<String, Object>) exchanges.get(i)
                    .fetchTickers(symbols, null).join());
        }

        var opportunities = new ArrayList<Opportunity>();
        for (var symbol : shared) {
            var quotes = new ArrayList<Quote>();
            for (var i = 0; i < exchanges.size(); i++) {
                var ticker = (Map<String, Object>) perExchange.get(i).get(symbol);
                if (ticker == null) continue;
                var bid = (Number) ticker.get("bid");
                var ask = (Number) ticker.get("ask");
                if (bid != null && ask != null) {
                    quotes.add(new Quote(exchanges.get(i).id, bid.doubleValue(), ask.doubleValue()));
                }
            }
            if (quotes.size() < 2) continue;
            var bestBid = quotes.stream().max(Comparator.comparingDouble(Quote::bid)).get();
            var bestAsk = quotes.stream().min(Comparator.comparingDouble(Quote::ask)).get();
            var spread = (bestBid.bid() - bestAsk.ask()) / bestAsk.ask();
            if (spread > 0) {
                opportunities.add(new Opportunity(symbol, bestAsk.id(), bestBid.id(), spread));
            }
        }

        opportunities.sort(Comparator.comparingDouble(Opportunity::spread).reversed());
        for (var o : opportunities.subList(0, Math.min(10, opportunities.size()))) {
            System.out.printf("%s: buy %s, sell %s, %.3f%%%n",
                    o.symbol(), o.buyOn(), o.sellOn(), o.spread() * 100);
        }
    }
}

Run it and you'll see a list of positive spreads, usually clustered in small-cap alts. Before you wire createOrder to the top row, read on.

Why most of those spreads aren't real

Fees eat the small ones. You pay taker fees on both legs — commonly around 0.1% per side on majors, more on small venues. A 0.15% gross spread is a loss. Your threshold needs to be fees-in, and per-exchange fees live in the market structure (market['taker']).

Tickers are top-of-book for one tick. The bid you plan to hit has finite size, and the quote may be stale by the time you act. Before trusting a spread, look at depth with fetchOrderBook(symbol) and compute the executable price for your size, not the displayed one. For continuous monitoring, stream books over WebSockets instead of polling.

Moving coins is slow and costly. The naive plan — buy on A, withdraw, deposit on B, sell — takes minutes to hours, during which the spread is gone. Withdrawal fees are often larger than the edge. And sometimes withdrawals are outright suspended, which is why the spread exists: nobody can close it. Check fetchCurrencies() — the unified currency structure carries per-network active, deposit, and withdraw flags plus fees.

The persistent spreads are persistent for a reason. Venue risk, geographic restrictions, or an illiquid book that can't absorb size. The market is not leaving twenties on the sidewalk; it's charging for risks you haven't priced yet.

What the real version looks like

Serious cross-exchange arbitrage pre-funds inventory on both venues and executes both legs simultaneously — no transfers on the critical path — then rebalances occasionally. That, plus funding-rate arbitrage and triangular arbitrage inside one venue, is the subject of the companion post: The most popular crypto arbitrage strategies.

The scanner above is still the right first step: it's how you learn where spreads live, how fast they close, and which venues systematically lag. Just let it run read-only for a week before any order leaves your machine.