Data·Data Analysis·Beginner

Arbitrage Detection

Detect cross-exchange arbitrage opportunities by simultaneously monitoring real-time bid and ask price quotes across multiple trading venues and calculating the net profit potential after fully accounting for trading fees, withdrawal costs, and execution latency constraints.

data-engineeringpattern-recognition

Arbitrage Detection

This notebook provides a framework for detecting statistically significant spot arbitrage opportunities across cryptocurrency exchanges. It identifies price divergences that exceed a predefined profitability threshold after accounting for taker fees.

Resources

This section outlines the dependencies and environment setup required for the arbitrage detection module.

1. Environment Setup

1.1. Install Dependencies

The ccxt library is used for interacting with various cryptocurrency exchanges. pandas and numpy are essential for data manipulation, while matplotlib and seaborn are utilized for data visualization.

[ ]
pip install ccxt pandas numpy matplotlib seaborn --quiet
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 153.3/153.3 kB 3.6 MB/s eta 0:00:00
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 6.6/6.6 MB 59.4 MB/s eta 0:00:00
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.6/1.6 MB 40.4 MB/s eta 0:00:00
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 223.8/223.8 kB 10.2 MB/s eta 0:00:00
[?25h

1.2. Import Libraries

Standard and third-party libraries are imported to facilitate exchange interaction, data processing, and visualization.

[ ]
import time
import warnings
import ccxt
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.ticker as mtick
import seaborn as sns

1.3. Configure Display and Warnings

This section sets pandas display options for clearer data presentation and filters out unnecessary warnings to improve readability of the output.

[ ]
warnings.filterwarnings('ignore')
pd.set_option('display.float_format', '{:.6f}'.format)
pd.set_option('display.max_columns', 20)
pd.set_option('display.width', 120)
sns.set_theme(style='darkgrid', palette='muted')

print(f"ccxt version  : {ccxt.__version__}")
print(f"pandas version: {pd.__version__}")
print("Environment ready.")
ccxt version  : 4.5.56
pandas version: 2.2.2
Environment ready.

2. Configuration Parameters

Key parameters for exchange interaction, symbols to monitor, request timeouts, and arbitrage thresholds are defined. Estimated taker fees for various exchanges are also provided.

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

SYMBOLS = [
    'BTC/USDT',
    'ETH/USDT',
    'SOL/USDT',
    'BNB/USDT',
    'XRP/USDT',
]

REQUEST_TIMEOUT_MS      = 10_000   # 10 seconds per request
ARBITRAGE_THRESHOLD_BPS = 10       # Minimum net profit in basis points (0.10%)

# Per-exchange taker fee estimates in basis points
EXCHANGE_FEES_BPS = {
    'binance': 7.5,
    'bybit'  : 7.5,
    'okx'    : 8.0,
    'kraken' : 16.0,
    'kucoin' : 10.0,
}
DEFAULT_FEE_BPS = 10.0

print("Configuration loaded.")
print(f"  Exchanges         : {EXCHANGES}")
print(f"  Symbols           : {SYMBOLS}")
print(f"  Arb threshold     : {ARBITRAGE_THRESHOLD_BPS} bps")
Configuration loaded.
  Exchanges         : ['binance', 'bybit', 'okx', 'kraken', 'kucoin']
  Symbols           : ['BTC/USDT', 'ETH/USDT', 'SOL/USDT', 'BNB/USDT', 'XRP/USDT']
  Arb threshold     : 10 bps

3. Exchange Initialization

This section defines a utility function to instantiate and verify ccxt exchange objects. It attempts to load markets for each configured exchange and reports success or failure.

[ ]
def initialize_exchanges(exchange_ids: list) -> dict:
    """Instantiate and verify ccxt exchange objects."""
    active_exchanges = {}
    for ex_id in exchange_ids:
        try:
            exchange_class = getattr(ccxt, ex_id)
            exchange = exchange_class({
                'timeout': REQUEST_TIMEOUT_MS,
                'enableRateLimit': True,
            })
            exchange.load_markets()
            active_exchanges[ex_id] = exchange
            print(f"  [OK]   {ex_id:<12}{len(exchange.markets):>5} markets loaded")
        except Exception as e:
            print(f"  [FAIL] {ex_id:<12}{type(e).__name__}: {e}")
    return active_exchanges


print("\nInitializing exchanges...")
exchanges = initialize_exchanges(EXCHANGES)
print(f"\nActive exchanges: {list(exchanges.keys())}")

Initializing exchanges...
  [FAIL] binance      — ExchangeNotAvailable: binance GET https://api.binance.com/api/v3/exchangeInfo 451  {
  "code": 0,
  "msg": "Service unavailable from a restricted location according to 'b. Eligibility' in https://www.binance.com/en/terms. Please contact customer service if you believe you received this message in error."
}
  [FAIL] bybit        — RateLimitExceeded: bybit GET https://api.bybit.com/v5/market/instruments-info?category=spot 403 Forbidden {
    error:The Amazon CloudFront distribution is configured to block access from your country
}
  [OK]   okx          —  4123 markets loaded
  [OK]   kraken       —  1551 markets loaded
  [OK]   kucoin       —  1712 markets loaded

Active exchanges: ['okx', 'kraken', 'kucoin']

4. Price Data Collection

Functions are defined to fetch real-time ticker data (bid, ask, last price, volume) for specified symbols across all initialized exchanges. This data forms the basis for arbitrage detection.

[ ]
def fetch_ticker_data(exchange_obj, symbol: str) -> dict | None:
    """Fetch ticker data for a single symbol from a single exchange."""
    try:
        if symbol not in exchange_obj.markets:
            return None
        ticker = exchange_obj.fetch_ticker(symbol)
        bid  = ticker.get('bid')  or 0.0
        ask  = ticker.get('ask')  or 0.0
        last = ticker.get('last') or 0.0
        mid_price = (bid + ask) / 2 if (bid and ask) else last
        return {
            'symbol'    : symbol,
            'bid'       : bid,
            'ask'       : ask,
            'last'      : last,
            'volume_24h': ticker.get('baseVolume') or 0.0,
            'mid_price' : mid_price,
        }
    except Exception:
        return None


def fetch_all_prices(exchange_dict: dict, symbols: list) -> pd.DataFrame:
    """Aggregate prices across all exchanges and symbols."""
    records = []
    for ex_id, ex_obj in exchange_dict.items():
        for symbol in symbols:
            data = fetch_ticker_data(ex_obj, symbol)
            if data:
                data['exchange'] = ex_id
                records.append(data)
            time.sleep(0.1) # Introduce a small delay to respect rate limits
    if not records:
        raise RuntimeError("No ticker data retrieved. Verify exchange connectivity.")
    df = pd.DataFrame(records)
    col_order = ['exchange', 'symbol', 'bid', 'ask', 'mid_price', 'last', 'volume_24h']
    df = df[[c for c in col_order if c in df.columns]]
    return df.reset_index(drop=True)


print("\nFetching price data from all active exchanges...")
price_df = fetch_all_prices(exchanges, SYMBOLS)
print(f"Records retrieved: {len(price_df)}")

Fetching price data from all active exchanges...
Records retrieved: 15

5. Arbitrage Detection

This section details the core logic for identifying arbitrage opportunities. The arbitrage_detection function evaluates price spreads between exchange pairs, accounts for taker fees, and determines potential profitability against a specified threshold. It considers both buy-on-ask and sell-on-bid scenarios for each pair.

[ ]
def arbitrage_detection(
    price_dataframe: pd.DataFrame,
    threshold_bps: float = ARBITRAGE_THRESHOLD_BPS,
    fee_map: dict = None,
) -> pd.DataFrame:
    """
    Identifies actionable arbitrage opportunities from cross-exchange price data.

    Parameters
    ----------
    price_dataframe : pd.DataFrame
        Required columns: 'exchange', 'symbol', 'bid', 'ask', 'mid_price'.
    threshold_bps : float, optional
        Minimum net profit in basis points (bps) after fees to flag as actionable.
        Defaults to ARBITRAGE_THRESHOLD_BPS from configuration.
    fee_map : dict, optional
        Dictionary mapping exchange IDs to their respective taker fees in basis points.
        Defaults to EXCHANGE_FEES_BPS from configuration if None.

    Returns
    -------
    pd.DataFrame
        A DataFrame containing flagged arbitrage opportunities, sorted by profitability
        and then by net spread in descending order. Returns an empty DataFrame
        if no opportunities are found or if input data is insufficient.
    """
    if fee_map is None:
        fee_map = EXCHANGE_FEES_BPS

    opportunities = []
    symbols_list  = price_dataframe['symbol'].unique()

    for symbol in symbols_list:
        symbol_data = price_dataframe[price_dataframe['symbol'] == symbol].copy()
        if len(symbol_data) < 2:
            continue # Requires at least two exchanges for a symbol

        exchanges_for_symbol = symbol_data['exchange'].tolist()

        # Iterate through all unique pairs of exchanges for the current symbol
        for i, ex_buy in enumerate(exchanges_for_symbol):
            for ex_sell in exchanges_for_symbol[i + 1:]:

                row_buy  = symbol_data[symbol_data['exchange'] == ex_buy].iloc[0]
                row_sell = symbol_data[symbol_data['exchange'] == ex_sell].iloc[0]

                # Evaluate both directions for each exchange pair:
                # 1. Buy on ex_buy (ask), Sell on ex_sell (bid)
                # 2. Buy on ex_sell (ask), Sell on ex_buy (bid)
                directions = [
                    (
                        row_buy['ask']  if row_buy['ask']  > 0 else row_buy['mid_price'],
                        row_sell['bid'] if row_sell['bid'] > 0 else row_sell['mid_price'],
                        ex_buy, ex_sell,
                    ),
                    (
                        row_sell['ask'] if row_sell['ask'] > 0 else row_sell['mid_price'],
                        row_buy['bid']  if row_buy['bid']  > 0 else row_buy['mid_price'],
                        ex_sell, ex_buy,
                    ),
                ]

                for buy_px, sell_px, b_ex, s_ex in directions:
                    if buy_px <= 0 or sell_px <= 0: # Ensure valid prices
                        continue

                    gross_spread  = sell_px - buy_px
                    # Calculate mean price to normalize spread for basis point calculation
                    mean_px       = (buy_px + sell_px) / 2
                    gross_bps     = (gross_spread / mean_px) * 10_000

                    # Sum taker fees for both the buy and sell legs of the trade
                    total_fee_bps = (
                        fee_map.get(b_ex, DEFAULT_FEE_BPS) + # Fee for buying exchange
                        fee_map.get(s_ex, DEFAULT_FEE_BPS)   # Fee for selling exchange
                    )
                    net_spread_bps = gross_bps - total_fee_bps
                    is_profitable  = net_spread_bps > threshold_bps

                    opportunities.append({
                        'symbol'          : symbol,
                        'buy_exchange'    : b_ex,
                        'sell_exchange'   : s_ex,
                        'buy_price'       : buy_px,
                        'sell_price'      : sell_px,
                        'gross_spread_bps': round(gross_bps, 4),
                        'total_fee_bps'   : round(total_fee_bps, 2),
                        'net_spread_bps'  : round(net_spread_bps, 4),
                        'profitable'      : is_profitable,
                    })

    result = pd.DataFrame(opportunities)
    if result.empty:
        print("No arbitrage records generated. Verify price data availability or exchange pairs.")
        return result

    # Sort results by profitability (profitable first) and then by net spread
    result = result.sort_values(
        ['profitable', 'net_spread_bps'], ascending=[False, False]
    ).reset_index(drop=True)

    return result


print(f"\nRunning arbitrage detection (threshold = {ARBITRAGE_THRESHOLD_BPS} bps)...")
arb_df = arbitrage_detection(price_df)

profitable_count = int(arb_df['profitable'].sum()) if not arb_df.empty else 0
print(f"Total pairs evaluated : {len(arb_df)}")
print(f"Profitable signals    : {profitable_count}")
print()
print(arb_df.head(20).to_string(index=False))

Running arbitrage detection (threshold = 10 bps)...
Total pairs evaluated : 30
Profitable signals    : 0

  symbol buy_exchange sell_exchange    buy_price   sell_price  gross_spread_bps  total_fee_bps  net_spread_bps  profitable
ETH/USDT          okx        kucoin  1757.540000  1757.980000          2.503200      18.000000      -15.496800       False
BTC/USDT          okx        kucoin 62547.000000 62553.800000          1.087100      18.000000      -16.912900       False
XRP/USDT       kucoin           okx     1.155540     1.155500         -0.346200      18.000000      -18.346200       False
BNB/USDT          okx        kucoin   593.700000   593.675000         -0.421100      18.000000      -18.421100       False
XRP/USDT          okx        kucoin     1.155600     1.155530         -0.605800      18.000000      -18.605800       False
BTC/USDT       kucoin           okx 62553.900000 62546.900000         -1.119100      18.000000      -19.119100       False
SOL/USDT          okx        kucoin    68.520000    68.510000         -1.459500      18.000000      -19.459500       False
SOL/USDT       kucoin           okx    68.520000    68.510000         -1.459500      18.000000      -19.459500       False
BNB/USDT       kucoin           okx   593.743000   593.600000         -2.408700      18.000000      -20.408700       False
ETH/USDT       kucoin           okx  1757.990000  1757.530000         -2.617000      18.000000      -20.617000       False
ETH/USDT          okx        kraken  1757.540000  1757.770000          1.308600      24.000000      -22.691400       False
BTC/USDT          okx        kraken 62547.000000 62552.200000          0.831300      24.000000      -23.168700       False
XRP/USDT       kraken           okx     1.155510     1.155500         -0.086500      24.000000      -24.086500       False
XRP/USDT       kraken        kucoin     1.155510     1.155530          0.173100      26.000000      -25.826900       False
BTC/USDT       kucoin        kraken 62553.900000 62552.200000         -0.271800      26.000000      -26.271800       False
SOL/USDT       kraken           okx    68.530000    68.510000         -2.918900      24.000000      -26.918900       False
ETH/USDT       kucoin        kraken  1757.990000  1757.770000         -1.251500      26.000000      -27.251500       False
ETH/USDT       kraken        kucoin  1758.260000  1757.980000         -1.592600      26.000000      -27.592600       False
ETH/USDT       kraken           okx  1758.260000  1757.530000         -4.152700      24.000000      -28.152700       False
XRP/USDT          okx        kraken     1.155600     1.155110         -4.241100      24.000000      -28.241100       False

6. Analysis and Visualization of Opportunities

This section presents the results of the arbitrage detection, including a table of profitable signals and visualizations of spread distributions.

6.1. Profitable Signals Table

Displays a filtered table of arbitrage opportunities that exceed the defined ARBITRAGE_THRESHOLD_BPS after accounting for all fees. This table highlights actionable trading opportunities.

[ ]
if not arb_df.empty:
    profitable_signals = arb_df[arb_df['profitable'] == True].copy()
    if profitable_signals.empty:
        print(f"\nNo profitable arbitrage opportunities detected above {ARBITRAGE_THRESHOLD_BPS} bps threshold.")
        print("Current market conditions may reflect efficient pricing across exchanges.")
    else:
        print(f"\nProfitable Arbitrage Signals (net spread > {ARBITRAGE_THRESHOLD_BPS} bps):")
        print("=" * 80)
        display_cols = ['symbol', 'buy_exchange', 'sell_exchange',
                        'buy_price', 'sell_price', 'gross_spread_bps',
                        'total_fee_bps', 'net_spread_bps']
        print(profitable_signals[display_cols].to_string(index=False))
else:
    print("Arbitrage DataFrame is empty — no data to display.")

No profitable arbitrage opportunities detected above 10 bps threshold.
Current market conditions may reflect efficient pricing across exchanges.

6.2. Spread Distribution Visualization

Visualizes the distribution of net spreads across all evaluated pairs and the maximum net spread for each symbol. This provides insights into the overall market efficiency and potential for arbitrage.

[ ]
if not arb_df.empty:
    fig, axes = plt.subplots(1, 2, figsize=(16, 6))
    fig.suptitle('Arbitrage Opportunity Analysis', fontsize=14, fontweight='bold')

    ax1 = axes[0]
    ax1.hist(
        arb_df['net_spread_bps'], bins=30,
        color='steelblue', edgecolor='white', linewidth=0.5, alpha=0.85,
    )
    ax1.axvline(
        ARBITRAGE_THRESHOLD_BPS, color='crimson', linewidth=2, linestyle='--',
        label=f'Threshold ({ARBITRAGE_THRESHOLD_BPS} bps)',
    )
    ax1.set_title('Net Spread Distribution (all pairs)')
    ax1.set_xlabel('Net Spread (basis points)')
    ax1.set_ylabel('Count')
    ax1.legend()
    ax1.grid(axis='y', alpha=0.4)

    ax2 = axes[1]
    top_by_symbol = (
        arb_df
        .groupby('symbol')['net_spread_bps']
        .max()
        .sort_values(ascending=True)
    )
    colors = ['crimson' if v > ARBITRAGE_THRESHOLD_BPS else 'steelblue'
              for v in top_by_symbol.values]
    ax2.barh(top_by_symbol.index, top_by_symbol.values, color=colors, edgecolor='white')
    ax2.axvline(
        ARBITRAGE_THRESHOLD_BPS, color='orange', linewidth=1.5, linestyle='--',
        label=f'Threshold ({ARBITRAGE_THRESHOLD_BPS} bps)',
    )
    ax2.set_title('Max Net Spread per Symbol')
    ax2.set_xlabel('Net Spread (basis points)')
    ax2.set_ylabel('Symbol')
    ax2.legend()
    ax2.grid(axis='x', alpha=0.4)

    plt.tight_layout()
    plt.savefig('93_arbitrage_signals.png', dpi=150, bbox_inches='tight')
    plt.show()
    print("Chart saved: 93_arbitrage_signals.png")
else:
    print("No arbitrage data available for visualization.")
cell output
Chart saved: 93_arbitrage_signals.png

Conclusion

This notebook provides a framework for detecting potential arbitrage opportunities across cryptocurrency exchanges. While the current market conditions, as evidenced by the analysis, did not reveal any profitable opportunities exceeding the defined threshold after accounting for taker fees, the framework remains robust. The visualizations illustrate the distribution of net spreads, highlighting that all observed spreads were negative, indicating market efficiency or the need for a lower profitability threshold or different exchange selection. Continuous monitoring with this framework can help identify transient inefficiencies in market pricing should they arise.