Building a Crypto Trading Bot from Scratch - 4: Connecting to Crypto Exchanges
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:
Data model conversion: ccxt returns raw arrays and dictionaries. I want my system to work with
Candle,Trade, andPositionobjects.Error handling: ccxt throws a bunch of different exceptions. I want to normalize these into my own exception hierarchy.
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:


