Quantitative Finance Insights

Quantitative Finance Insights

Building a Crypto Trading Bot from Scratch - 4: Connecting to Crypto Exchanges

QPY's avatar
QPY
Dec 03, 2025
∙ Paid

a wallet with bitcoins falling out of it
Photo by Shubham Dhage on Unsplash

After nailing down the architecture and data models, it was time to tackle the part that actually connects my trading bot to the real world: the exchange adapter.

This is where theory meets practice. You can have the most beautiful abstractions in the world, but at some point, you need to fetch real price data and place real orders. And that means dealing with exchange APIs—which are messy, inconsistent, and sometimes downright weird.

This is where ccxt saves your sanity. So you must understand how ccxt works and why you really need it.

What Is CCXT and Why Does It Exist?

CCXT (CryptoCurrency eXchange Trading Library) is an open-source library that provides a unified API for 100+ cryptocurrency exchanges. Instead of learning Binance’s API, then Coinbase’s API, then Kraken’s API—each with their own quirks and formats—you learn one API and it works everywhere.

Here’s the magic: you write code below:

exchange = ccxt.binance()
candles = exchange.fetch_ohlcv(’BTC/USDT’, ‘1h’)

And it just works. Want to switch to Kraken? Change one line:

exchange = ccxt.kraken()
candles = exchange.fetch_ohlcv(’BTC/USDT’, ‘1h’)

The data format is the same. The methods are the same. You’re insulated from the underlying differences.

This is exactly what I needed for my exchange-agnostic architecture.

Building the Binance Adapter

Even though ccxt provides a unified interface, I didn’t want my core system calling ccxt directly. Why? Because:

  1. Data model conversion: ccxt returns raw arrays and dictionaries. I want my system to work with Candle, Trade, and Position objects.

  2. Error handling: ccxt throws a bunch of different exceptions. I want to normalize these into my own exception hierarchy.

  3. Future flexibility: If I ever want to move away from ccxt or add custom exchange behavior, having an adapter layer makes that possible.

So I built a BinanceAdapter that wraps ccxt and translates between ccxt’s world and my system’s world.

Here’s the basic structure:

import ccxt
from src.exchange.exchange_base import ExchangeBase
from src.models.candle import Candle
from src.models.trade import Trade

class BinanceAdapter(ExchangeBase):
    def __init__(self, api_key: str, api_secret: str, testnet: bool = False):
        super().__init__(api_key, api_secret, testnet)

        # Initialize ccxt Binance client
        self.exchange = ccxt.binance({
            ‘apiKey’: api_key,
            ‘secret’: api_secret,
            ‘enableRateLimit’: True,  # This is crucial
            ‘options’: {
                ‘defaultType’: ‘spot’,  # Spot trading
            }
        })

        if testnet:
            self.exchange.set_sandbox_mode(True)

Notice enableRateLimit: True. This tells ccxt to automatically throttle requests to avoid hitting Binance’s rate limits. Without this, you’ll get banned from the API really quickly. Trust me, I learned this the hard way.

Fetching Historical Data

The most fundamental operation is fetching historical price data. This is what feeds your strategies.

Here’s my fetch_ohlcv implementation:

async def fetch_ohlcv(
    self,
    symbol: str,
    timeframe: str,
    limit: int = 100,
    since: Optional[int] = None
) -> List[Candle]:
    “”“
    Fetch historical OHLCV data from Binance.

    Args:
        symbol: Trading pair symbol (e.g., “BTC/USDT”)
        timeframe: Candle timeframe (e.g., “1m”, “5m”, “1h”, “1d”)
        limit: Maximum number of candles to fetch
        since: Timestamp in milliseconds to start from

    Returns:
        List of Candle objects, oldest to newest
    “”“
    try:
        # Normalize symbol format (handle “BTCUSDT” vs “BTC/USDT”)
        normalized_symbol = self.normalize_symbol(symbol)

        # Fetch OHLCV data from Binance via ccxt
        ohlcv_data = await self._run_async(
            self.exchange.fetch_ohlcv,
            normalized_symbol,
            timeframe,
            since,
            limit
        )

        # Convert to Candle objects
        candles = []
        for ohlcv in ohlcv_data:
            # CCXT format: [timestamp, open, high, low, close, volume]
            candle = Candle(
                timestamp=datetime.fromtimestamp(ohlcv[0] / 1000),
                symbol=symbol.replace(’/’, ‘’),  # Store as “BTCUSDT”
                timeframe=timeframe,
                open=float(ohlcv[1]),
                high=float(ohlcv[2]),
                low=float(ohlcv[3]),
                close=float(ohlcv[4]),
                volume=float(ohlcv[5])
            )
            candles.append(candle)

        return candles

    except ccxt.NetworkError as e:
        raise ExchangeError(f”Network error fetching OHLCV: {e}”)
    except ccxt.ExchangeError as e:
        raise ExchangeError(f”Exchange error fetching OHLCV: {e}”)

Let me break down what’s happening here:

Symbol normalization

Exchanges are inconsistent about symbol formats. Some use “BTCUSDT”, some use “BTC/USDT”, some use “BTC-USDT”. My normalize_symbol method handles this:

def normalize_symbol(self, symbol: str) -> str:
    “”“Convert any symbol format to Binance/ccxt format (BTC/USDT).”“”
    if ‘/’ in symbol:
        return symbol  # Already correct

    if ‘-’ in symbol:
        return symbol.replace(’-’, ‘/’)

    # For “BTCUSDT”, try to intelligently split
    quote_currencies = [’USDT’, ‘BUSD’, ‘BTC’, ‘ETH’, ‘BNB’, ‘USDC’]
    for quote in quote_currencies:
        if symbol.endswith(quote):
            base = symbol[:-len(quote)]
            return f”{base}/{quote}”

    return symbol  # Let ccxt handle edge cases

This might seem paranoid, but it prevents so many bugs when integrating different parts of the system.

Array to object conversion

ccxt returns OHLCV data as arrays: [timestamp, open, high, low, close, volume]. This is efficient but not very readable. I convert each array to a Candle object with named fields.

The timestamp conversion is important: ccxt uses milliseconds (Unix timestamp * 1000), but Python’s datetime.fromtimestamp() expects seconds. Hence the division by 1000.

Error handling

ccxt can throw various exceptions: NetworkError for connection issues, ExchangeError for API errors, etc. I catch these and convert them to my own ExchangeError so the rest of my system doesn’t need to know about ccxt-specific exceptions.

Placing Orders

Fetching data is read-only and safe. Placing orders is where things get real—and scary.

Here’s my place_order implementation:

User's avatar

Continue reading this post for free, courtesy of QPY.

Or purchase a paid subscription.
© 2025 QPY · Privacy ∙ Terms ∙ Collection notice
Start your SubstackGet the app
Substack is the home for great culture