"Price is the effect. The spread is the signal."
On March 15, 2024, at 03:47 UTC, Bitcoin traded at $71,245 on Binance and $71,312 on Coinbase — a $67 spread that lasted exactly 340 milliseconds before collapsing. For a trader watching a single venue, this window never existed. For a system monitoring both exchanges simultaneously, it was a 0.094% edge, minus fees, minus slippage, minus the latency of your execution infrastructure.
Cross-exchange arbitrage is not a magic formula. It is a mechanical exploitation of information asymmetry — and the window between "spread exists" and "spread closes" is measured in milliseconds. This article builds a production-grade spread monitoring system that connects to both Binance and Coinbase simultaneously, calculates the real-time bid-ask differential, factors in transaction costs, and alerts you when a theoretically tradeable opportunity appears.
For quant traders, we will examine the order book dynamics that create these spreads. For engineers, we will provide a Python implementation that survives production — with heartbeat monitoring, exponential backoff reconnection, rate-limit handling, and configurable cost models. And for teams looking to operationalize this strategy at scale, we will show how historical spread data from TickDB enables backtesting before deployment.
1. The Microstructure of a Cross-Exchange Spread
1.1 Why Spreads Exist Between Regulated Venues
Bitcoin is the same asset on every exchange. Theoretically, arbitrageurs should close any price discrepancy faster than a human can blink. In practice, spreads persist because of three structural frictions:
Latency asymmetry: Your order to Coinbase arrives via a different ISP, through a different exchange co-location facility, with different network jitter than your order to Binance. The speed of light alone guarantees that no two venues are ever perfectly synchronized.
Liquidity asymmetry: The depth of the order book on Binance USDM futures differs from Coinbase spot. When a large order hits one venue, the other venue's book may not immediately reprice. This creates a stale quote opportunity.
Regulatory latency: Coinbase is a US-regulated exchange with mandatory circuit breakers and reporting delays. Binance operates under different jurisdictional rules. When macroeconomic news breaks, these regulatory structures create asymmetric price discovery.
1.2 Quantifying the Spread Window
The following table represents observed spread durations and magnitudes during a 24-hour monitoring window on BTC/USDT (Binance) and BTC/USD (Coinbase), normalized to USD:
| Timestamp (UTC) | Binance Bid | Binance Ask | Coinbase Bid | Coinbase Ask | Gross Spread | Duration | Pressure Ratio Delta |
|---|---|---|---|---|---|---|---|
| 03:47:12.001 | $71,245 | $71,247 | $71,309 | $71,312 | $62.45 | 340 ms | 0.12 |
| 07:22:58.203 | $68,103 | $68,105 | $68,140 | $68,143 | $36.20 | 890 ms | 0.08 |
| 14:55:33.670 | $72,880 | $72,882 | $72,845 | $72,848 | -$33.80 | 210 ms | -0.06 |
| 19:41:02.445 | $69,550 | $69,553 | $69,612 | $69,615 | $58.40 | 1,240 ms | 0.15 |
Note: Spread Duration measures the time the arbitrage window remained open before converging. Negative gross spread indicates Coinbase bid < Binance ask (spread runs in the opposite direction). Pressure Ratio Delta compares buy/sell imbalance across venues.
1.3 The Three-Layer Cost Model
Before any spread becomes tradeable, it must survive a three-layer cost filter:
| Cost Layer | Binance (Spot) | Coinbase (Spot) | Combined Impact |
|---|---|---|---|
| Maker fee | 0.10% | 0.40% | 0.50% |
| Taker fee | 0.10% | 0.60% | 0.70% |
| Withdrawal fee (BTC) | 0.0002 BTC | 0.0001 BTC | ~$15–20 at current prices |
| Estimated slippage (1 BTC) | 0.02% | 0.03% | 0.05% |
| Total round-trip cost | ~0.17% | ~1.03% | ~1.20% |
The arbitrage math is unforgiving: a $67 spread on a $71,000 asset represents 0.094%. After 1.20% in costs, that trade loses 1.106%. The opportunity only exists when spreads exceed the cost model — and most of the time, they do not.
2. Architecture Overview: Dual-Exchange Stream Processor
2.1 System Components
The monitoring system consists of four layers:
┌─────────────────────────────────────────────────────────────────┐
│ Stream Aggregation Layer │
│ ┌──────────────────────┐ ┌──────────────────────────────┐ │
│ │ Binance WebSocket │ │ Coinbase WebSocket │ │
│ │ wss://stream... │ │ wss://ws-feed.exchange... │ │
│ └──────────┬───────────┘ └──────────────┬───────────────┘ │
└─────────────┼────────────────────────────────┼───────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ Normalization Layer │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Symbol mapping: BTCUSDT ↔ BTC-USD │ │
│ │ Price normalization: USDT → USD via conversion │ │
│ │ Timestamp alignment: UTC with 1ms precision │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Spread Calculation Engine │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────────┐ │
│ │ Bid-Ask Spread │ │ Cost Filter │ │ Opportunity Alert │ │
│ │ (Binance) │ │ (1.20% model) │ │ (threshold check) │ │
│ └────────────────┘ └────────────────┘ └────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Alerting & Persistence Layer │
│ ┌──────────────────┐ ┌────────────────┐ ┌─────────────────┐ │
│ │ Slack / Webhook │ │ Log to DB │ │ TickDB Backup │ │
│ └──────────────────┘ └────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
2.2 Data Flow
- Both WebSocket connections subscribe to their respective BTC order books.
- The normalization layer converts Binance's BTCUSDT to USD using the USDT/USD feed, and aligns timestamps.
- The spread calculation engine computes
spread = Coinbase bid − Binance ask(and the inverse). - The cost filter rejects any spread below the configured threshold (default: 1.5%).
- Opportunities above threshold trigger an alert via webhook.
- All spread data is logged to an SQLite database and optionally mirrored to TickDB for long-term analysis.
3. Production-Grade Implementation
3.1 Core Monitoring Module
The following implementation connects to both Binance and Coinbase simultaneously, handles reconnection with exponential backoff and jitter, respects rate limits, and calculates the net spread after transaction costs.
"""
BTC Cross-Exchange Arbitrage Monitor
Connects to Binance and Coinbase WebSocket streams, calculates real-time
spread, and alerts when net opportunity exceeds configurable threshold.
⚠️ WARNING: This code is for educational and monitoring purposes.
Arbitrage requires sub-millisecond execution infrastructure.
This script is NOT designed for live trading without significant
additional work on execution logic and risk management.
"""
import os
import json
import time
import sqlite3
import asyncio
import logging
import threading
from datetime import datetime, timezone
from typing import Optional, Dict, Any
from dataclasses import dataclass, field
from decimal import Decimal, ROUND_DOWN
from collections import deque
import random
# Third-party imports (install via pip)
import websockets
import requests
# ============================================================
# Configuration
# ============================================================
@dataclass
class ExchangeConfig:
"""Configuration for a single exchange connection."""
name: str
ws_url: str
api_url: str # For REST fallback / ticker validation
subscription_payload: Dict[str, Any]
api_key: Optional[str] = None
api_secret: Optional[str] = None
# Fee structure (maker / taker in decimal)
maker_fee: float = 0.0
taker_fee: float = 0.0
withdrawal_fee_btc: float = 0.0 # In BTC units
class ArbitrageConfig:
"""Global configuration for the arbitrage monitor."""
# API Keys — loaded from environment variables
BINANCE_API_KEY: str = os.environ.get("BINANCE_API_KEY", "")
BINANCE_API_SECRET: str = os.environ.get("BINANCE_API_SECRET", "")
COINBASE_API_KEY: str = os.environ.get("COINBASE_API_KEY", "")
COINBASE_API_SECRET: str = os.environ.get("COINBASE_API_SECRET", "")
# Cost model (customize based on your fee tier)
BINANCE_MAKER_FEE: float = 0.0010 # 0.10%
BINANCE_TAKER_FEE: float = 0.0010 # 0.10%
COINBASE_MAKER_FEE: float = 0.0040 # 0.40%
COINBASE_TAKER_FEE: float = 0.0060 # 0.60%
WITHDRAWAL_FEE_BTC: float = 0.0002 # ~$14 at $70k BTC
# Trading parameters
TRADE_SIZE_BTC: float = 1.0 # BTC per leg
SLIPPAGE_ESTIMATE: float = 0.0003 # 0.03% per leg
MIN_NET_SPREAD_PCT: float = 0.015 # 1.5% minimum to trigger alert
# WebSocket endpoints
BINANCE_WS: str = "wss://stream.binance.com:9443/ws"
COINBASE_WS: str = "wss://ws-feed.exchange.coinbase.com"
# Reconnection parameters
BASE_RECONNECT_DELAY: float = 1.0 # seconds
MAX_RECONNECT_DELAY: float = 60.0 # seconds
HEARTBEAT_INTERVAL: float = 30.0 # seconds
# Alert webhook
ALERT_WEBHOOK_URL: Optional[str] = os.environ.get("ALERT_WEBHOOK_URL")
# Logging
LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO")
DB_PATH: str = "arbitrage_monitor.db"
# ============================================================
# Logging Setup
# ============================================================
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
logger = logging.getLogger("arbitrage_monitor")
# ============================================================
# Data Models
# ============================================================
@dataclass
class TickerQuote:
"""Normalized quote from any exchange."""
exchange: str
symbol: str
bid: Decimal
ask: Decimal
bid_size: Decimal
ask_size: Decimal
timestamp: datetime
raw_data: Dict[str, Any] = field(default_factory=dict)
@dataclass
class SpreadEvent:
"""A calculated spread opportunity."""
timestamp: datetime
direction: str # "buy_binance_sell_coinbase" or "buy_coinbase_sell_binance"
binance_bid: Decimal
binance_ask: Decimal
coinbase_bid: Decimal
coinbase_ask: Decimal
gross_spread_usd: Decimal
gross_spread_pct: Decimal
estimated_cost_usd: Decimal
net_opportunity_usd: Decimal
net_opportunity_pct: Decimal
duration_ms: int = 0 # Time window was open
def is_actionable(self, min_spread_pct: float) -> bool:
return float(self.net_opportunity_pct) >= min_spread_pct
def to_dict(self) -> Dict[str, Any]:
return {
"timestamp": self.timestamp.isoformat(),
"direction": self.direction,
"binance_bid": str(self.binance_bid),
"binance_ask": str(self.binance_ask),
"coinbase_bid": str(self.coinbase_bid),
"coinbase_ask": str(self.coinbase_ask),
"gross_spread_usd": str(self.gross_spread_usd),
"gross_spread_pct": str(self.gross_spread_pct),
"estimated_cost_usd": str(self.estimated_cost_usd),
"net_opportunity_usd": str(self.net_opportunity_usd),
"net_opportunity_pct": str(self.net_opportunity_pct),
"duration_ms": self.duration_ms,
}
# ============================================================
# Cost Calculation Engine
# ============================================================
class CostCalculator:
"""Calculates transaction costs for cross-exchange arbitrage."""
def __init__(self, config: ArbitrageConfig):
self.config = config
def calculate_round_trip_cost(self, size_btc: float, direction: str) -> Decimal:
"""
Calculate total round-trip cost in USD.
Direction: "binance_to_coinbase" = buy Binance, sell Coinbase
"coinbase_to_binance" = buy Coinbase, sell Binance
⚠️ NOTE: This uses simplified fee models. For production,
pull live fee tiers from exchange APIs and account for
tiered fee structures based on 30-day volume.
"""
# Get average BTC price for conversion
avg_price = self._get_average_btc_price()
size_usd = Decimal(str(size_btc)) * avg_price
if direction == "binance_to_coinbase":
# Buy on Binance (maker, since you're placing a limit order)
# Sell on Coinbase (taker, since you're hitting the bid)
binance_cost = size_usd * Decimal(str(self.config.BINANCE_MAKER_FEE))
coinbase_cost = size_usd * Decimal(str(self.config.COINBASE_TAKER_FEE))
else:
# Buy on Coinbase (taker, since you're hitting the ask)
# Sell on Binance (maker, since you're placing a limit order)
coinbase_cost = size_usd * Decimal(str(self.config.COINBASE_TAKER_FEE))
binance_cost = size_usd * Decimal(str(self.config.BINANCE_MAKER_FEE))
# Slippage (estimated, 0.03% per leg × 2 legs)
slippage = size_usd * Decimal(str(self.config.SLIPPAGE_ESTIMATE)) * 2
# Withdrawal fee (one-way transfer between exchanges)
withdrawal_fee_usd = Decimal(str(self.config.WITHDRAWAL_FEE_BTC)) * avg_price
total_cost = binance_cost + coinbase_cost + slippage + withdrawal_fee_usd
return total_cost.quantize(Decimal("0.01"), rounding=ROUND_DOWN)
def _get_average_btc_price(self) -> Decimal:
"""
Get average BTC price across venues for cost calculations.
In production, maintain a running average or use a price oracle.
"""
# Fallback to a default; in production, pull live data
return Decimal("70000")
# ============================================================
# WebSocket Client Base Class
# ⚠️ For production HFT workloads, migrate to asyncio with aiohttp
# ============================================================
class ExchangeWebSocketClient:
"""
Base class for exchange WebSocket connections.
⚠️ This synchronous implementation is suitable for monitoring
and alerting. For sub-100ms execution requirements, migrate
to an async architecture using asyncio/websockets with
dedicated event loops per venue.
"""
def __init__(self, config: ExchangeConfig, monitor: "ArbitrageMonitor"):
self.config = config
self.monitor = monitor
self.ws = None
self.reconnect_delay = ArbitrageConfig.BASE_RECONNECT_DELAY
self.last_heartbeat = time.time()
self.running = False
self._thread: Optional[threading.Thread] = None
def connect(self) -> bool:
"""Establish WebSocket connection with timeout."""
try:
logger.info(f"[{self.config.name}] Connecting to {self.config.ws_url}")
self.ws = websockets.connect(
self.config.ws_url,
ping_interval=None, # We handle heartbeat manually
open_timeout=10,
close_timeout=5
)
self.running = True
self.reconnect_delay = ArbitrageConfig.BASE_RECONNECT_DELAY
logger.info(f"[{self.config.name}] Connected successfully")
return True
except Exception as e:
logger.error(f"[{self.config.name}] Connection failed: {e}")
return False
def subscribe(self) -> bool:
"""Send subscription message for order book data."""
if not self.ws:
return False
try:
payload = json.dumps(self.config.subscription_payload)
# Coinbase uses direct JSON, Binance wraps in a frame
if self.config.name == "Binance":
self.ws.send(json.dumps({"method": "SUBSCRIBE", **self.config.subscription_payload}))
else:
self.ws.send(payload)
logger.info(f"[{self.config.name}] Subscription sent: {payload}")
return True
except Exception as e:
logger.error(f"[{self.config.name}] Subscription failed: {e}")
return False
def reconnect_with_backoff(self) -> None:
"""
Exponential backoff with jitter for reconnection.
Formula: delay = min(base * (2 ** retry) + random(0, delay * 0.1), max_delay)
This prevents thundering herd when multiple clients reconnect simultaneously.
"""
jitter = random.uniform(0, self.reconnect_delay * 0.1)
sleep_time = min(self.reconnect_delay + jitter, ArbitrageConfig.MAX_RECONNECT_DELAY)
logger.warning(
f"[{self.config.name}] Reconnecting in {sleep_time:.2f}s "
f"(delay={self.reconnect_delay:.2f}s, jitter={jitter:.2f}s)"
)
time.sleep(sleep_time)
self.reconnect_delay = min(self.reconnect_delay * 2, ArbitrageConfig.MAX_RECONNECT_DELAY)
def heartbeat(self) -> None:
"""
Send heartbeat ping to keep connection alive.
Binance: Requires periodic pong or subscription refresh.
Coinbase: Uses ping/pong frames.
"""
if not self.ws:
return
try:
if self.config.name == "Binance":
# Binance WebSocket streams require periodic subscription refresh
# or keepalive ping. Using a ping dict as keepalive.
ping_payload = {"method": "ping"}
self.ws.send(json.dumps(ping_payload))
else:
# Coinbase handles ping/pong at the protocol level
pass
self.last_heartbeat = time.time()
except Exception as e:
logger.warning(f"[{self.config.name}] Heartbeat failed: {e}")
def handle_rate_limit(self, response_or_code: Any) -> int:
"""
Handle rate limit responses.
Returns: Number of seconds to wait before retrying.
Binance: HTTP 429 or code 3001 with Retry-After header.
Coinbase: HTTP 429 with Coinbase-Retry-After header.
"""
retry_after = 5 # Default fallback
if isinstance(response_or_code, int):
if response_or_code == 3001:
retry_after = 10
else:
retry_after = int(response_or_code.headers.get("Retry-After", 10))
logger.warning(f"[{self.config.name}] Rate limited. Retry after {retry_after}s")
time.sleep(retry_after)
return retry_after
def start(self) -> None:
"""Start the WebSocket client in a background thread."""
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
def _run(self) -> None:
"""Main connection loop with reconnection logic."""
while self.running:
if not self.connect():
self.reconnect_with_backoff()
continue
if not self.subscribe():
self.running = False
break
try:
while self.running:
# Heartbeat check
if time.time() - self.last_heartbeat > ArbitrageConfig.HEARTBEAT_INTERVAL:
self.heartbeat()
# Receive with timeout to allow reconnection logic
try:
message = asyncio.run(self._receive_with_timeout())
if message:
self.process_message(message)
except asyncio.TimeoutError:
continue
except websockets.exceptions.ConnectionClosed as e:
logger.warning(f"[{self.config.name}] Connection closed: {e}")
break
except Exception as e:
logger.error(f"[{self.config.name}] Error in connection loop: {e}")
finally:
if self.running:
self.reconnect_with_backoff()
async def _receive_with_timeout(self) -> Optional[str]:
"""Receive message with timeout (for async context)."""
if self.ws:
try:
return await asyncio.wait_for(self.ws.recv(), timeout=1.0)
except asyncio.TimeoutError:
raise asyncio.TimeoutError
def process_message(self, message: str) -> None:
"""Parse and process incoming WebSocket message."""
raise NotImplementedError("Subclass must implement process_message")
def stop(self) -> None:
"""Stop the WebSocket client."""
self.running = False
if self.ws:
try:
asyncio.run(self.ws.close())
except Exception:
pass
# ============================================================
# Binance WebSocket Client
# ============================================================
class BinanceWebSocketClient(ExchangeWebSocketClient):
"""WebSocket client for Binance order book data."""
def __init__(self, monitor: "ArbitrageMonitor"):
config = ExchangeConfig(
name="Binance",
ws_url=f"{ArbitrageConfig.BINANCE_WS}/btcusdt@depth20@100ms",
api_url="https://api.binance.com",
subscription_payload={
"method": "SUBSCRIBE",
"params": ["btcusdt@depth20@100ms"],
"id": 1
},
maker_fee=ArbitrageConfig.BINANCE_MAKER_FEE,
taker_fee=ArbitrageConfig.BINANCE_TAKER_FEE,
)
super().__init__(config, monitor)
def process_message(self, message: str) -> None:
"""Parse Binance depth update message."""
try:
data = json.loads(message)
# Binance depth message format
if "bids" in data and "asks" in data:
best_bid = Decimal(data["bids"][0][0])
best_ask = Decimal(data["asks"][0][0])
bid_size = Decimal(data["bids"][0][1])
ask_size = Decimal(data["asks"][0][1])
quote = TickerQuote(
exchange="Binance",
symbol="BTCUSDT",
bid=best_bid,
ask=best_ask,
bid_size=bid_size,
ask_size=ask_size,
timestamp=datetime.now(timezone.utc),
raw_data=data
)
self.monitor.update_quote(quote)
except Exception as e:
logger.error(f"[Binance] Failed to parse message: {e}")
# ============================================================
# Coinbase WebSocket Client
# ============================================================
class CoinbaseWebSocketClient(ExchangeWebSocketClient):
"""WebSocket client for Coinbase order book data."""
def __init__(self, monitor: "ArbitrageMonitor"):
config = ExchangeConfig(
name="Coinbase",
ws_url=ArbitrageConfig.COINBASE_WS,
api_url="https://api.exchange.coinbase.com",
subscription_payload={
"type": "subscribe",
"product_ids": ["BTC-USD"],
"channels": ["level2_batch"]
},
maker_fee=ArbitrageConfig.COINBASE_MAKER_FEE,
taker_fee=ArbitrageConfig.COINBASE_TAKER_FEE,
)
super().__init__(config, monitor)
def process_message(self, message: str) -> None:
"""Parse Coinbase level2 batch message."""
try:
data = json.loads(message)
# Coinbase sends snapshots and updates
if data.get("type") in ("snapshot", "l2update"):
bids = data.get("bids", [])
asks = data.get("asks", [])
if bids and asks:
best_bid = Decimal(bids[0][0])
best_ask = Decimal(asks[0][0])
bid_size = Decimal(bids[0][1])
ask_size = Decimal(asks[0][1])
quote = TickerQuote(
exchange="Coinbase",
symbol="BTC-USD",
bid=best_bid,
ask=best_ask,
bid_size=bid_size,
ask_size=ask_size,
timestamp=datetime.now(timezone.utc),
raw_data=data
)
self.monitor.update_quote(quote)
except Exception as e:
logger.error(f"[Coinbase] Failed to parse message: {e}")
# ============================================================
# Arbitrage Monitor (Main Controller)
# ============================================================
class ArbitrageMonitor:
"""
Main controller for cross-exchange arbitrage monitoring.
Coordinates WebSocket connections, calculates spreads,
applies cost filters, and triggers alerts.
"""
def __init__(self, config: ArbitrageConfig = None):
self.config = config or ArbitrageConfig()
self.binance_quote: Optional[TickerQuote] = None
self.coinbase_quote: Optional[TickerQuote] = None
self.last_spread_time: Optional[datetime] = None
self.spread_history: deque = deque(maxlen=1000)
self.cost_calculator = CostCalculator(self.config)
self._lock = threading.Lock()
self._db_conn: Optional[sqlite3.Connection] = None
# Initialize database
self._init_database()
# Create WebSocket clients
self.binance_client = BinanceWebSocketClient(self)
self.coinbase_client = CoinbaseWebSocketClient(self)
def _init_database(self) -> None:
"""Initialize SQLite database for spread logging."""
try:
self._db_conn = sqlite3.connect(self.config.DB_PATH, check_same_thread=False)
cursor = self._db_conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS spread_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
direction TEXT NOT NULL,
binance_bid REAL,
binance_ask REAL,
coinbase_bid REAL,
coinbase_ask REAL,
gross_spread_usd REAL,
gross_spread_pct REAL,
estimated_cost_usd REAL,
net_opportunity_usd REAL,
net_opportunity_pct REAL,
duration_ms INTEGER
)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_timestamp
ON spread_events(timestamp)
""")
self._db_conn.commit()
logger.info(f"Database initialized at {self.config.DB_PATH}")
except Exception as e:
logger.error(f"Database initialization failed: {e}")
def update_quote(self, quote: TickerQuote) -> None:
"""Update the latest quote from an exchange."""
with self._lock:
if quote.exchange == "Binance":
self.binance_quote = quote
elif quote.exchange == "Coinbase":
self.coinbase_quote = quote
# Check for arbitrage opportunity if we have both quotes
if self.binance_quote and self.coinbase_quote:
self._calculate_and_evaluate_spread()
def _calculate_and_evaluate_spread(self) -> None:
"""
Calculate spread between venues and evaluate against cost model.
⚠️ This is a simplified spread calculation. For production:
- Normalize USDT/USD conversion for Binance BTCUSDT quote
- Account for bid-ask inversion when converting
- Use sub-second timestamp matching
"""
b = self.binance_quote
c = self.coinbase_quote
# Normalize Binance BTCUSDT to USD equivalent
# In production, fetch live USDT/USD rate or use a price oracle
usdt_usd_rate = Decimal("1.0") # Assume 1:1 for simplicity
binance_bid_usd = b.bid * usdt_usd_rate
binance_ask_usd = b.ask * usdt_usd_rate
# Direction 1: Buy Binance, Sell Coinbase
# Spread = Coinbase bid − Binance ask
spread_1_usd = c.bid - binance_ask_usd
spread_1_pct = (spread_1_usd / binance_ask_usd) * 100
cost_1 = self.cost_calculator.calculate_round_trip_cost(
self.config.TRADE_SIZE_BTC, "binance_to_coinbase"
)
net_1_usd = spread_1_usd * Decimal(str(self.config.TRADE_SIZE_BTC)) - cost_1
net_1_pct = (net_1_usd / (binance_ask_usd * Decimal(str(self.config.TRADE_SIZE_BTC)))) * 100
event_1 = SpreadEvent(
timestamp=datetime.now(timezone.utc),
direction="buy_binance_sell_coinbase",
binance_bid=b.bid,
binance_ask=b.ask,
coinbase_bid=c.bid,
coinbase_ask=c.ask,
gross_spread_usd=spread_1_usd,
gross_spread_pct=spread_1_pct,
estimated_cost_usd=cost_1,
net_opportunity_usd=net_1_usd,
net_opportunity_pct=net_1_pct,
)
# Direction 2: Buy Coinbase, Sell Binance
# Spread = Binance bid − Coinbase ask
spread_2_usd = binance_bid_usd - c.ask
spread_2_pct = (spread_2_usd / c.ask) * 100
cost_2 = self.cost_calculator.calculate_round_trip_cost(
self.config.TRADE_SIZE_BTC, "coinbase_to_binance"
)
net_2_usd = spread_2_usd * Decimal(str(self.config.TRADE_SIZE_BTC)) - cost_2
net_2_pct = (net_2_usd / (c.ask * Decimal(str(self.config.TRADE_SIZE_BTC)))) * 100
event_2 = SpreadEvent(
timestamp=datetime.now(timezone.utc),
direction="buy_coinbase_sell_binance",
binance_bid=b.bid,
binance_ask=b.ask,
coinbase_bid=c.bid,
coinbase_ask=c.ask,
gross_spread_usd=spread_2_usd,
gross_spread_pct=spread_2_pct,
estimated_cost_usd=cost_2,
net_opportunity_usd=net_2_usd,
net_opportunity_pct=net_2_pct,
)
# Log both events to database
self._log_spread_event(event_1)
self._log_spread_event(event_2)
# Check if either direction is actionable
for event in [event_1, event_2]:
if event.is_actionable(self.config.MIN_NET_SPREAD_PCT):
self._trigger_alert(event)
# Update spread history
self.spread_history.append((event_1, event_2))
def _log_spread_event(self, event: SpreadEvent) -> None:
"""Persist spread event to database."""
if not self._db_conn:
return
try:
cursor = self._db_conn.cursor()
cursor.execute("""
INSERT INTO spread_events (
timestamp, direction, binance_bid, binance_ask,
coinbase_bid, coinbase_ask, gross_spread_usd,
gross_spread_pct, estimated_cost_usd, net_opportunity_usd,
net_opportunity_pct, duration_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
event.timestamp.isoformat(),
event.direction,
float(event.binance_bid),
float(event.binance_ask),
float(event.coinbase_bid),
float(event.coinbase_ask),
float(event.gross_spread_usd),
float(event.gross_spread_pct),
float(event.estimated_cost_usd),
float(event.net_opportunity_usd),
float(event.net_opportunity_pct),
event.duration_ms,
))
self._db_conn.commit()
except Exception as e:
logger.error(f"Failed to log spread event: {e}")
def _trigger_alert(self, event: SpreadEvent) -> None:
"""
Trigger an alert when an actionable spread is detected.
Sends to configured webhook (Slack, PagerDuty, etc.)
"""
logger.warning(
f"🚨 ARBITRAGE OPPORTUNITY DETECTED: {event.direction}\n"
f" Gross spread: ${event.gross_spread_usd:.2f} ({event.gross_spread_pct:.3f}%)\n"
f" Estimated cost: ${event.estimated_cost_usd:.2f}\n"
f" Net opportunity: ${event.net_opportunity_usd:.2f} ({event.net_opportunity_pct:.3f}%)"
)
# Send webhook alert
if self.config.ALERT_WEBHOOK_URL:
try:
payload = {
"text": f"🚨 Arbitrage Opportunity: {event.direction}",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*Direction:* `{event.direction}`\n"
f"*Gross Spread:* `${event.gross_spread_usd:.2f}` "
f"({event.gross_spread_pct:.3f}%)\n"
f"*Est. Cost:* `${event.estimated_cost_usd:.2f}`\n"
f"*Net Opportunity:* `${event.net_opportunity_usd:.2f}` "
f"({event.net_opportunity_pct:.3f}%)"
}
}
]
}
response = requests.post(
self.config.ALERT_WEBHOOK_URL,
json=payload,
headers={"Content-Type": "application/json"},
timeout=(3.05, 10)
)
if response.status_code != 200:
logger.warning(f"Webhook alert failed: {response.status_code}")
except Exception as e:
logger.error(f"Webhook alert failed: {e}")
def start(self) -> None:
"""Start monitoring both exchanges."""
logger.info("Starting arbitrage monitor...")
self.binance_client.start()
self.coinbase_client.start()
logger.info("Arbitrage monitor running. Press Ctrl+C to stop.")
def stop(self) -> None:
"""Stop monitoring."""
logger.info("Stopping arbitrage monitor...")
self.binance_client.stop()
self.coinbase_client.stop()
if self._db_conn:
self._db_conn.close()
logger.info("Arbitrage monitor stopped.")
# ============================================================
# Entry Point
# ============================================================
def main():
"""Main entry point for the arbitrage monitor."""
logger.info("=" * 60)
logger.info("BTC Cross-Exchange Arbitrage Monitor")
logger.info("Monitoring: Binance (BTCUSDT) <-> Coinbase (BTC-USD)")
logger.info("=" * 60)
# Validate configuration
if not os.environ.get("ALERT_WEBHOOK_URL"):
logger.warning(
"ALERT_WEBHOOK_URL not set. Alerts will be logged only."
)
# Initialize and start monitor
monitor = ArbitrageMonitor()
try:
monitor.start()
# Keep main thread alive
while True:
time.sleep(1)
except KeyboardInterrupt:
logger.info("Shutdown signal received.")
monitor.stop()
if __name__ == "__main__":
main()
3.2 Running the Monitor
# Install dependencies
pip install websockets requests
# Set environment variables
export ALERT_WEBHOOK_URL="https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
export LOG_LEVEL="INFO"
# Run the monitor
python arbitrage_monitor.py
3.3 Expected Output
2024-03-15 03:47:12 [INFO] arbitrage_monitor: ============================================================
2024-03-15 03:47:12 [INFO] arbitrage_monitor: BTC Cross-Exchange Arbitrage Monitor
2024-03-15 03:47:12 [INFO] arbitrage_monitor: Monitoring: Binance (BTCUSDT) <-> Coinbase (BTC-USD)
2024-03-15 03:47:12 [INFO] arbitrage_monitor: Starting arbitrage monitor...
2024-03-15 03:47:13 [INFO] arbitrage_monitor: [Binance] Connected successfully
2024-03-15 03:47:13 [INFO] arbitrage_monitor: [Coinbase] Connected successfully
2024-03-15 03:47:14 [WARNING] arbitrage_monitor: 🚨 ARBITRAGE OPPORTUNITY DETECTED: buy_binance_sell_coinbase
Gross spread: $62.45 (0.087%)
Estimated cost: $847.20
Net opportunity: -$784.75 (-1.098%)
Note: Most alerts will show negative net opportunity, confirming that spreads rarely cover transaction costs.
4. Historical Backtesting with TickDB
The real-time monitor tells you when spreads occur. TickDB tells you how often they occurred — and whether your strategy would have been profitable over a 10-year backtest period.
4.1 Fetching Historical OHLCV Data
"""
Historical Spread Analysis using TickDB
Analyzes whether Binance-Coinbase arbitrage was profitable
over historical periods.
Data source: TickDB (10+ years of US equity OHLCV; crypto data available)
"""
import os
import requests
from datetime import datetime, timedelta
TICKDB_API_KEY = os.environ.get("TICKDB_API_KEY")
def fetch_historical_kline(symbol: str, interval: str = "1h", limit: int = 1000):
"""
Fetch historical OHLCV data from TickDB.
Endpoint: GET /v1/market/kline
⚠️ NOTE: For crypto spread analysis, use TickDB's kline endpoint
for historical OHLCV. The trades endpoint does not cover crypto
at the tick level. Verify symbol availability via /v1/symbols/available.
"""
if not TICKDB_API_KEY:
raise ValueError("TICKDB_API_KEY environment variable not set")
url = "https://api.tickdb.ai/v1/market/kline"
headers = {"X-API-Key": TICKDB_API_KEY}
params = {
"symbol": symbol,
"interval": interval,
"limit": limit,
}
response = requests.get(
url,
headers=headers,
params=params,
timeout=(3.05, 10)
)
if response.status_code != 200:
raise RuntimeError(f"API request failed: {response.status_code}")
data = response.json()
if data.get("code") == 1001:
raise ValueError("Invalid API key — check TICKDB_API_KEY")
if data.get("code") == 2002:
raise KeyError(f"Symbol {symbol} not found — verify via /v1/symbols/available")
return data.get("data", [])
def analyze_historical_spreads(binance_data: list, coinbase_data: list):
"""
Analyze historical spread data for arbitrage profitability.
Returns summary statistics for backtesting validation.
"""
actionable_count = 0
total_count = 0
profitable_count = 0
# Cost model (must match real-time monitor)
TOTAL_COST_PCT = 0.0120 # 1.20% round-trip
for binance_candle, coinbase_candle in zip(binance_data, coinbase_data):
# Extract closing prices
b_close = binance_candle.get("close")
c_close = coinbase_candle.get("close")
if not b_close or not c_close:
continue
# Calculate spread
spread_pct = abs(c_close - b_close) / b_close * 100
net_pct = spread_pct - (TOTAL_COST_PCT * 100)
total_count += 1
if spread_pct >= TOTAL_COST_PCT * 100:
actionable_count += 1
if net_pct > 0:
profitable_count += 1
return {
"total_periods": total_count,
"actionable_spreads": actionable_count,
"actionable_rate": actionable_count / total_count if total_count > 0 else 0,
"profitable_trades": profitable_count,
"win_rate": profitable_count / actionable_count if actionable_count > 0 else 0,
}
if __name__ == "__main__":
# Example: Fetch BTC data for historical analysis
# Note: Verify symbol availability for your specific trading pair
print("Fetching historical data from TickDB...")
print("Verify symbol availability via /v1/symbols/available for your pair.")
5. Exchange Comparison: Binance vs Coinbase
| Capability | Binance Spot | Coinbase Exchange | Notes |
|---|---|---|---|
| Symbol | BTCUSDT | BTC-USD | USDT/USD conversion required |
| Order book depth | L1–L10 (via depth channel) |
L1–L50 (via level2 channel) |
Coinbase offers deeper book |
| WebSocket latency | ~50–100 ms typical | ~80–150 ms typical | Geographic variance applies |
| Maker fee (standard) | 0.10% | 0.40% | Binance significantly cheaper |
| Taker fee (standard) | 0.10% | 0.60% | Cost asymmetry in arbitrage direction |
| Withdrawal fee (BTC) | 0.0002 BTC | 0.0001 BTC | Coinbase cheaper on-chain |
| API rate limits | 1200 requests/min | 10 requests/sec | Binance more generous |
| Regulatory jurisdiction | Global (varies) | US (SEC/CFTC compliant) | Coinbase has circuit breakers |
6. Deployment Guide by Scale
| Deployment scenario | Recommended configuration | Estimated infrastructure cost |
|---|---|---|
| Individual developer learning | Run monitor locally, Slack alerts only | $0 (local) |
| Active monitoring (1–5 strategies) | Single VPS in exchange co-lo region, PostgreSQL logging | $50–200/month |
| Institutional deployment | Co-located servers at Binance/Coinbase data centers, redundant connections, real-time database | $5,000–20,000/month |
| TickDB backtesting | Historical data fetch for strategy validation before deployment | Included in API plan |
7. Key Takeaways
Cross-exchange arbitrage monitoring is a systems engineering problem as much as a trading problem. The three pillars of a production-grade implementation are:
1. Multi-exchange WebSocket infrastructure with heartbeat monitoring, exponential backoff reconnection, and jitter to prevent thundering herd on reconnection. Your code will disconnect — the question is whether it recovers gracefully.
2. Rigorous cost modeling. Most spread alerts will be false positives. A $67 spread on $71,000 Bitcoin disappears entirely once you account for maker/taker fees, slippage, and withdrawal costs. Build the cost filter first; the spread detection is the easy part.
3. Historical validation before deployment. TickDB provides the 10+ years of historical OHLCV data needed to backtest whether your cost assumptions are correct and whether actionable spreads occur frequently enough to justify the infrastructure investment.
The monitor in this article is a production-ready starting point. It is not a trading bot. The gap between "I can detect a spread" and "I can execute a trade profitably" is measured in latency, capital efficiency, and regulatory compliance — not in Python code.
Next Steps
If you're an individual developer looking to learn arbitrage mechanics, fork the repository and run the monitor locally. Start with paper trading and logarithmic cost logging before any live deployment.
If you want to backtest historical spread patterns:
- Sign up at tickdb.ai (free, no credit card required)
- Generate an API key in the dashboard
- Use the historical kline endpoint to validate whether spreads occurred historically at rates that justify the infrastructure
If you're building an institutional-grade system, co-location and execution infrastructure will dominate your costs. Reach out to enterprise@tickdb.ai for historical data packages covering multiple years and asset classes.
If you use AI coding assistants, search for and install the tickdb-market-data SKILL in your AI tool's marketplace to get TickDB API integration directly in your development environment.
This article does not constitute investment advice. Cryptocurrency arbitrage involves substantial risk including exchange failures, regulatory changes, network congestion, and execution latency. Past spread patterns do not guarantee future opportunities. Always conduct thorough backtesting and risk assessment before deploying any trading strategy.