"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

  1. Both WebSocket connections subscribe to their respective BTC order books.
  2. The normalization layer converts Binance's BTCUSDT to USD using the USDT/USD feed, and aligns timestamps.
  3. The spread calculation engine computes spread = Coinbase bid − Binance ask (and the inverse).
  4. The cost filter rejects any spread below the configured threshold (default: 1.5%).
  5. Opportunities above threshold trigger an alert via webhook.
  6. 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:

  1. Sign up at tickdb.ai (free, no credit card required)
  2. Generate an API key in the dashboard
  3. 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.