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.
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[2K [90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[0m [32m153.3/153.3 kB[0m [31m3.6 MB/s[0m eta [36m0:00:00[0m [2K [90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[0m [32m6.6/6.6 MB[0m [31m59.4 MB/s[0m eta [36m0:00:00[0m [2K [90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[0m [32m1.6/1.6 MB[0m [31m40.4 MB/s[0m eta [36m0:00:00[0m [2K [90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[0m [32m223.8/223.8 kB[0m [31m10.2 MB/s[0m eta [36m0:00:00[0m [?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 sns1.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.")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.