Lesson 3.7.4 · 75 min

ccxt for trading on multiple exchanges

ccxt translates the APIs of more than a hundred exchanges into a single one: the same methods, the same structures and the same errors. This lesson runs it for real, with no network, against two exchanges simulated in a thread: a custom adapter, orders that sweep the book, typed errors, a rate limit, a timeout that duplicates an order and a websocket order book with sequence control.

Fig. — Effective price of an XBQ purchase on the two simulated exchanges, fee included, by quantity
Level 3 lessons
  1. 3.1 · Futures in depth

    1. 3.1.1 Main futures contracts
    2. 3.1.2 Futures curve: basis, cost of carry, contango and roll yield
    3. 3.1.3 Roll management, margins, SPAN and implied leverage
    4. 3.1.4 Diversified futures systems: Carver's approach
  2. 3.2 · Futures spreads

    1. 3.2.1 Calendar spreads, butterflies and condors
    2. 3.2.2 Intercommodity spreads: crack, spark and crush
    3. 3.2.3 Rate and index spreads: 2s10s, NOB, NQ/ES and Europe versus the U.S.
    4. 3.2.4 Hedge ratios: by notional, by DV01 and by volatility
    5. 3.2.5 Spread data and backtesting: listed, synthetic and legged
    6. 3.2.6 Seasonality of spreads: multi-year analysis, patterns and traps
  3. 3.3 · Advanced forex

    1. 3.3.1 FX microstructure: OTC, ECN, last look and sessions
    2. 3.3.2 Carry and crash risk, momentum and value in currencies; baskets
    3. 3.3.3 Currency risk in multi-currency portfolios; execution via OANDA, IBKR or MT5
  4. 3.4 · Option theory and pricing

    1. 3.4.1 Payoffs, put-call parity and arbitrage bounds
    2. 3.4.2 Binomial trees, Black-Scholes-Merton, Black-76 and Monte Carlo
    3. 3.4.3 Greeks: delta, gamma, vega, theta, rho, vanna, volga and charm
    4. 3.4.4 Implied volatility: Newton-Raphson, Brent, py_vollib and QuantLib
    5. 3.4.5 Smile, skew and term structure; volatility surface with SVI
    6. 3.4.6 Realized vs implied volatility; volatility risk premium; VIX and VSTOXX
    7. 3.4.7 Advanced models: Heston, SABR and local volatility
  5. 3.5 · Option structures in Python

    1. 3.5.1 Directional and vertical structures: call, put, covered call, collar and spreads
    2. 3.5.2 Volatility and range structures: straddles, strangles, condors and butterflies
    3. 3.5.3 Calendars, diagonals, ratios, backspreads, risk reversals and jade lizard
    4. 3.5.4 A custom tool: payoff, aggregate Greeks and scenarios
    5. 3.5.5 Management: adjustments, rolls, early closes, early assignment and pin risk
  6. 3.6 · Systematic volatility and options strategies

    1. 3.6.1 Systematic premium selling: put-write, strangles and their tail risk
    2. 3.6.2 Delta hedging, gamma scalping and dispersion
    3. 3.6.3 VIX and VSTOXX strategies: volatility futures and ETPs
    4. 3.6.4 Options backtesting: data, fills and event adjustments
  7. 3.7 · Crypto derivatives and specific strategies

    1. 3.7.1 Perpetuals: funding mechanics, mark price and liquidations
    2. 3.7.2 Funding arbitrage, basis with futures and cross-exchange arbitrage
    3. 3.7.3 Crypto options, on-chain data and DeFi
    4. 3.7.4 ccxt for trading on multiple exchanges
    5. 3.7.5 MiCA: the EU framework for crypto-assets and their service providers (CASPs)

One API for many exchanges

Each crypto exchange has its own API: routes, field names, symbol formats, request signing, error codes and limits. ccxt is an open-source library (MIT license) that translates them into a single one: the same create_order returns the same structure on all of them1Note 1 · ccxt manual: properties rateLimit, enableRateLimit (enabled by default) and timeout (10,000 ms by default, RequestTimeout when it runs out), order structure and clientOrderId (“only on exchanges that support it”), retry mechanism (maxRetriesOnFailure, network or server failures only), exception hierarchy and set_sandbox_mode, docs.ccxt.com and github.com/ccxt/ccxt/wiki/Manual, accessed on October 22, 2026. MIT license according to the GitHub repository. The hierarchy in the diagram is that of the installed version 4.5.89 (ccxt.base.errors).. The version in the course environment, 4.5.89, ships 103 exchange classes (len(ccxt.exchanges)). Lesson 1.3.5 used it to download candles and paginate; this one uses it for what trading requires: markets, balances, orders, errors, limits and real-time data.

Your systemUnified APIalfa adaptercenit adapteralfa native APIcenit native API
Mermaid source
flowchart TB
  S[Your system] --> U[Unified API]
  U --> A[alfa adapter]
  U --> C[cenit adapter]
  A --> NA[alfa native API]
  C --> NC[cenit native API]

What ccxt unifies: the symbol (XBQ/USDQ, and XBQ/USDQ:USDQ for a linear perpetual), each market's sheet with its precision, its limits and its fees, the order book, balance and order structures, and a hierarchy of exceptions. What it does not unify: which order types each exchange supports, its real limits, its tiered fees and when it goes down. Anything specific travels in the params argument of each method, which ccxt passes as is to the native API.

Trading against a real exchange requires a network and keys, so here everything runs against a simulated exchange in a thread of the same process. It is not an object that imitates ccxt: it is an HTTP server with its own native API (routes, HMAC signature, error codes, a token bucket for the rate limit), and ccxt talks to it through an adapter, just as it does with Binance or Kraken. The two simulated exchanges, alfa and cenit, start from the close of XBQ and XEQ on March 4, 2024 in the level data (cripto3_diario.parquet, synthetic; alfa imitates Binance and cenit BitMEX, but their fees and their books are made up for the lesson):

IN [01]python
import hashlib
import hmac
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse

import pandas as pd

dia = pd.read_parquet("cripto3_diario.parquet")
cierre = (dia[dia["fecha"] == "2024-03-04"]
          .pivot(index="exchange", columns="activo",
                 values="cierre_spot")
          .loc[["alfa", "cenit"]])
cierre
OUT [01]Starting prices: the close on March 4, 2024
activo        XBQ      XEQ
exchange
alfa      65269.2  3471.15
cenit     65260.9  3468.69

A simulated exchange in a thread

The state of each exchange is a five-level book per side around the close (2 bp between levels and 0.3 more coins at each level), the balances of an account (100,000 USDQ, 0.5 XBQ and 10 XEQ), the orders and a virtual clock that advances 250 ms per response, so that the timestamps in the outputs do not depend on when the lesson is run. Matching is minimal: a market order sweeps levels until it is filled; a limit order that does not cross waits in the book with its cash locked:

IN [02]python
RELOJ = {"ms": 0.0}   # logical clock for the limit: the client advances it

class Venue:
    """Book, balances and orders of an exchange; virtual clock."""

    def __init__(self, nombre, precios, comision, claves):
        self.nombre, self.comision, self.claves = (nombre, comision,
                                                   claves)
        self.saldos = {k: {"USDQ": 100_000.0, "XBQ": 0.5,
                           "XEQ": 10.0} for k in claves}
        self.usado = {k: {"USDQ": 0.0, "XBQ": 0.0, "XEQ": 0.0}
                      for k in claves}
        self.libros = {f"{a}USDQ": self.libro(p)
                       for a, p in precios.items()}
        self.ordenes, self.por_cliente = {}, {}
        self.reloj = 1_709_596_800_000   # 2024-03-05 00:00 UTC
        self.fichas, self.ultimo = 5.0, RELOJ["ms"]
        self.retener = None              # Event: the next order
        self.lock = threading.Lock()

    @staticmethod
    def libro(medio, niveles=5):
        paso = medio * 2e-4              # 2 bp between levels
        lado = lambda s: [[round(medio + s * paso * (i + .5), 2),
                           round(0.3 * (i + 1), 4)]
                          for i in range(niveles)]
        return {"bids": lado(-1), "asks": lado(+1)}

    def tic(self):
        self.reloj += 250
        return self.reloj

    def permitir(self):              # bucket: 5 tokens, 5 per second
        ahora = RELOJ["ms"]              # logical time, not wall clock
        self.fichas = min(5.0, self.fichas
                          + 5 * (ahora - self.ultimo) / 1000)
        self.ultimo = ahora
        if self.fichas < 1:
            return False
        self.fichas -= 1
        return True

    def ejecutar(self, d, key):
        """Matches an order against the book; returns (http, body)."""
        cid = d.get("clientOrderId")
        if cid and cid in self.por_cliente:
            return 409, {"code": "DUPLICATE_CLIENT_ID", "msg": cid}
        libro = self.libros.get(d["symbol"])
        if libro is None:
            return 400, {"code": "BAD_SYMBOL", "msg": d["symbol"]}
        qty, side = float(d["qty"]), d["side"]
        limite = float(d["price"]) if d.get("price") else None
        if qty * (limite or libro["asks"][0][0]) < 10:
            return 400, {"code": "MIN_NOTIONAL", "msg": "< 10 USDQ"}
        base, s = d["symbol"][:-4], self.saldos[key]
        resto, coste = qty, 0.0
        for px, cant in libro["asks" if side == "BUY" else "bids"]:
            if limite and ((side == "BUY" and px > limite) or
                           (side == "SELL" and px < limite)):
                break
            x = min(resto, cant)
            resto, coste = resto - x, coste + x * px
            if resto < 1e-12:
                break
        lleno, com = qty - resto, coste * self.comision
        if side == "BUY":
            bloq = resto * limite if limite else 0.0
            if coste + com + bloq > s["USDQ"] + 1e-9:
                return 400, {"code": "INSUFFICIENT_FUNDS",
                             "msg": "USDQ"}
            s["USDQ"] -= coste + com + bloq
            self.usado[key]["USDQ"] += bloq
            s[base] += lleno
        else:
            if qty > s[base] + 1e-12:
                return 400, {"code": "INSUFFICIENT_FUNDS", "msg": base}
            s[base] -= lleno
            s["USDQ"] += coste - com
        oid = f"{self.nombre[0].upper()}-{len(self.ordenes) + 1:06d}"
        o = {"orderId": oid, "clientOrderId": cid, "ts": self.tic(),
             "symbol": d["symbol"], "side": side, "type": d["type"],
             "price": d.get("price"), "qty": d["qty"],
             "filled": f"{lleno:.4f}",
             "avgPrice": f"{coste / lleno:.2f}" if lleno else None,
             "fee": f"{com:.6f}",
             "status": "FILLED" if resto < 1e-12 else
                       ("NEW" if limite else "EXPIRED")}
        self.ordenes[oid] = o
        if cid:
            self.por_cliente[cid] = oid
        return 200, o

    def cancelar(self, oid, key):
        o = self.ordenes.get(oid)
        if o is None or o["status"] != "NEW":
            return 404, {"code": "UNKNOWN_ORDER", "msg": oid}
        o["status"] = "CANCELED"
        if o["side"] == "BUY":
            bloq = ((float(o["qty"]) - float(o["filled"]))
                    * float(o["price"]))
            self.usado[key]["USDQ"] -= bloq
            self.saldos[key]["USDQ"] += bloq
        return 200, o

The native API is the one many exchanges have: public routes (public/markets, public/depth) and private ones (private/balance, private/order, private/openOrders), an HMAC-SHA256 signature of the timestamp, the method, the route and the body with the account's secret, JSON errors with their own code and an HTTP 429 when the token bucket empties. Two details make the lesson always give the same result, on any machine and under any load. The bucket is not refilled with the system clock but with a logical clock (RELOJ) that the client's rate limiter advances. And if it is asked to hold the next order, the server creates it and does not respond until it receives a signal (threading.Event), which is what a client sees when a real exchange is slow.

IN [03]python
class Api(BaseHTTPRequestHandler):
    venues = {}

    def log_message(self, *a):
        pass

    def responder(self, http, obj):
        cuerpo = json.dumps(obj).encode()
        self.send_response(http)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(cuerpo)))
        self.end_headers()
        self.wfile.write(cuerpo)

    def atender(self, metodo):
        u = urlparse(self.path)
        venue, _, ruta = u.path.strip("/").partition("/")
        v = self.venues[venue]
        q = {k: x[0] for k, x in parse_qs(u.query).items()}
        n = int(self.headers.get("Content-Length") or 0)
        cuerpo = self.rfile.read(n).decode() if n else ""
        espera = None
        with v.lock:
            if not v.permitir():
                return self.responder(429, {"code": "RATE_LIMIT",
                                            "msg": "too many"})
            key = None
            if ruta.startswith("private/"):
                key = self.headers.get("X-KEY", "")
                txt = (self.headers.get("X-TS", "") + metodo + "/"
                       + ruta + u.query + cuerpo)
                sec = v.claves.get(key, "")
                firma = hmac.new(sec.encode(), txt.encode(),
                                 hashlib.sha256).hexdigest()
                if not sec or not hmac.compare_digest(
                        firma, self.headers.get("X-SIGN", "")):
                    return self.responder(401, {"code": "AUTH",
                                                "msg": "signature"})
            http, obj = self.api(v, metodo, ruta, q,
                                 json.loads(cuerpo or "{}"), key)
            if metodo == "POST" and v.retener is not None:
                espera, v.retener = v.retener, None
        if espera is not None:           # the order already exists, but
            espera.wait(60)              # no response until the signal
        self.responder(http, obj)

    def api(self, v, metodo, ruta, q, d, key):
        if ruta == "public/markets":
            return 200, [{"symbol": s, "base": s[:-4],
                          "quote": "USDQ", "tickSize": "0.01",
                          "stepSize": "0.0001", "minNotional": "10",
                          "fee": str(v.comision)} for s in v.libros]
        if ruta == "public/depth":
            libro = v.libros.get(q.get("symbol"))
            if libro is None:
                return 400, {"code": "BAD_SYMBOL", "msg": "?"}
            n = int(q.get("limit", 5))
            return 200, {"ts": v.tic(), "bids": libro["bids"][:n],
                         "asks": libro["asks"][:n]}
        if ruta == "private/balance":
            return 200, {"ts": v.tic(), "assets": [
                {"asset": a, "free": v.saldos[key][a],
                 "locked": v.usado[key][a]} for a in v.saldos[key]]}
        if ruta == "private/order" and metodo == "POST":
            return v.ejecutar(d, key)
        if ruta == "private/order" and metodo == "DELETE":
            return v.cancelar(q.get("orderId"), key)
        if ruta == "private/order":
            oid = q.get("orderId") or v.por_cliente.get(
                q.get("clientOrderId"))
            if oid not in v.ordenes:
                return 404, {"code": "UNKNOWN_ORDER", "msg": "?"}
            return 200, v.ordenes[oid]
        if ruta == "private/openOrders":
            return 200, [o for o in v.ordenes.values()
                         if o["status"] == "NEW"]
        return 404, {"code": "NOT_FOUND", "msg": ruta}

    def do_GET(self):
        self.atender("GET")

    def do_POST(self):
        self.atender("POST")

    def do_DELETE(self):
        self.atender("DELETE")


class Servidor(ThreadingHTTPServer):
    daemon_threads = True

    def handle_error(self, request, client_address):
        pass                         # clients that disconnect on timeout

claves = {"k-alumno": "s3cr3t0"}
Api.venues = {
    "alfa": Venue("alfa", cierre.loc["alfa"], 0.0010, claves),
    "cenit": Venue("cenit", cierre.loc["cenit"], 0.0005, claves)}
srv = Servidor(("127.0.0.1", 0), Api)
threading.Thread(target=srv.serve_forever, daemon=True).start()
PUERTO = srv.server_address[1]

The server listens on a port chosen by the operating system (("127.0.0.1", 0)), so the lesson does not clash with anything already running. An empty handle_error keeps the connections the client cuts on timeout from filling the output with tracebacks.

The adapter: four pieces

A ccxt exchange class inherits from ccxt.Exchange and fills in four pieces. Those of the real exchanges are generated by the ccxt project from a description of each API; this one is written by hand, but it uses the same machinery:

  • describe: identity, URL, rateLimit (milliseconds between requests), capabilities (has) and the map from native error codes to ccxt exceptions.
  • sign: builds the request. On private routes it adds the key, the timestamp and the HMAC signature, which the server checks.
  • handle_errors: receives each response and, if it carries an error code, raises the matching typed exception.
  • parse_*: translates the native responses into the unified structures (safe_market_structure, safe_order, safe_balance, parse_order_book).
IN [04]python
import ccxt
from ccxt.base.errors import (AuthenticationError, BadSymbol,
                              DuplicateOrderId, InsufficientFunds,
                              InvalidOrder, OrderNotFound,
                              RateLimitExceeded)

class Comun:
    """What does not depend on whether the client is sync or async."""

    def describe(self):
        return self.deep_extend(super().describe(), {
            "id": "simulado", "name": "Simulado", "rateLimit": 250,
            "has": {"fetchOrderBook": True, "fetchBalance": True,
                    "createOrder": True, "cancelOrder": True,
                    "fetchOrder": True, "fetchOpenOrders": True},
            "urls": {"api": {"rest": "http://127.0.0.1"}},
            "requiredCredentials": {"apiKey": True, "secret": True},
            "exceptions": {"exact": {
                "AUTH": AuthenticationError,
                "BAD_SYMBOL": BadSymbol,
                "INSUFFICIENT_FUNDS": InsufficientFunds,
                "MIN_NOTIONAL": InvalidOrder,
                "UNKNOWN_ORDER": OrderNotFound,
                "DUPLICATE_CLIENT_ID": DuplicateOrderId,
                "RATE_LIMIT": RateLimitExceeded}}})

    def sign(self, path, api="public", method="GET", params={},
             headers=None, body=None):
        url, query = self.urls["api"]["rest"] + "/" + path, ""
        if method in ("GET", "DELETE") and params:
            query = self.urlencode(params)
            url += "?" + query
        elif params:
            body = self.json(params)
        headers = {"Content-Type": "application/json"}
        if api == "private":
            self.check_required_credentials()
            ts = str(self.milliseconds())
            txt = ts + method + "/" + path + query + (body or "")
            headers.update({
                "X-KEY": self.apiKey, "X-TS": ts,
                "X-SIGN": self.hmac(self.encode(txt),
                                    self.encode(self.secret),
                                    hashlib.sha256)})
        return {"url": url, "method": method, "body": body,
                "headers": headers}

    def handle_errors(self, code, reason, url, method, headers,
                      body, response, req_headers, req_body):
        if isinstance(response, dict) and "code" in response:
            self.throw_exactly_matched_exception(
                self.exceptions["exact"], response["code"],
                self.id + " " + body)
        return None

    def parse_mercados(self, r):
        return [self.safe_market_structure({
            "id": m["symbol"], "symbol": m["base"] + "/" + m["quote"],
            "base": m["base"], "quote": m["quote"],
            "baseId": m["base"], "quoteId": m["quote"],
            "type": "spot", "spot": True, "active": True,
            "taker": float(m["fee"]), "maker": float(m["fee"]),
            "precision": {"price": float(m["tickSize"]),
                          "amount": float(m["stepSize"])},
            "limits": {"cost": {"min": float(m["minNotional"])},
                       "amount": {"min": float(m["stepSize"])}},
            "info": m}) for m in r]

    def parse_order(self, o, market=None):
        estado = {"NEW": "open", "FILLED": "closed",
                  "CANCELED": "canceled", "EXPIRED": "expired"}
        market = self.safe_market(o["symbol"], market)
        return self.safe_order({
            "id": o["orderId"], "clientOrderId": o["clientOrderId"],
            "timestamp": o["ts"], "datetime": self.iso8601(o["ts"]),
            "symbol": market["symbol"], "type": o["type"].lower(),
            "side": o["side"].lower(), "price": o["price"],
            "amount": o["qty"], "filled": o["filled"],
            "average": o["avgPrice"], "status": estado[o["status"]],
            "fee": {"cost": o["fee"], "currency": market["quote"]},
            "info": o}, market)

The Comun class gathers what does not depend on the transport, so it can be reused in the ccxt.pro async client at the end of the lesson. The sync client adds the unified methods, each with a call to self.request, which is where ccxt applies the rate limiter, signs, sends with requests, checks for errors and retries if asked to:

IN [05]python
class Simulado(Comun, ccxt.Exchange):
    def throttle(self, cost=None):
        """The ccxt rate limiter sleeps rateLimit × cost ms; here it
        advances the logical clock by the same amount, without sleeping."""
        RELOJ["ms"] += self.rateLimit * (1 if cost is None else cost)

    def fetch_markets(self, params={}):
        return self.parse_mercados(
            self.request("public/markets", "public", "GET", params))

    def fetch_order_book(self, symbol, limit=None, params={}):
        self.load_markets()
        req = {"symbol": self.market_id(symbol), "limit": limit or 5}
        r = self.request("public/depth", "public", "GET",
                         self.extend(req, params))
        return self.parse_order_book(r, symbol, r["ts"])

    def fetch_balance(self, params={}):
        r = self.request("private/balance", "private", "GET", params)
        res = {"info": r, "timestamp": r["ts"],
               "datetime": self.iso8601(r["ts"])}
        for b in r["assets"]:
            res[b["asset"]] = {"free": str(b["free"]),
                               "used": str(b["locked"])}
        return self.safe_balance(res)

    def create_order(self, symbol, type, side, amount, price=None,
                     params={}):
        self.load_markets()
        m = self.market(symbol)
        req = {"symbol": m["id"], "side": side.upper(),
               "type": type.upper(),
               "qty": self.amount_to_precision(symbol, amount)}
        if type == "limit":
            req["price"] = self.price_to_precision(symbol, price)
        r = self.request("private/order", "private", "POST",
                         self.extend(req, params))
        return self.parse_order(r, m)

    def cancel_order(self, id, symbol=None, params={}):
        r = self.request("private/order", "private", "DELETE",
                         self.extend({"orderId": id}, params))
        return self.parse_order(r)

    def fetch_order(self, id, symbol=None, params={}):
        req = {"orderId": id} if id else {}
        r = self.request("private/order", "private", "GET",
                         self.extend(req, params))
        return self.parse_order(r)

    def fetch_open_orders(self, symbol=None, since=None, limit=None,
                          params={}):
        self.load_markets()
        r = self.request("private/openOrders", "private", "GET",
                         params)
        return self.parse_orders(r, None, since, limit)

def conectar(venue, secreto="s3cr3t0", **opciones):
    ex = Simulado({"apiKey": "k-alumno", "secret": secreto,
                   **opciones})
    ex.id = venue
    ex.urls["api"]["rest"] = f"http://127.0.0.1:{PUERTO}/{venue}"
    return ex

def esperar(ms):                   # a time.sleep on the logical clock
    RELOJ["ms"] += ms

alfa, cenit = conectar("alfa"), conectar("cenit")
print(type(alfa).__mro__[1:3])
print(alfa.rateLimit, alfa.enableRateLimit, alfa.timeout)
OUT [05]The sync client
(<class '__main__.Comun'>, <class 'ccxt.base.exchange.Exchange'>)
250 True 10000

rateLimit is 250 ms, the rate limiter is enabled by default and the default timeout is 10,000 ms; after that time without a response, ccxt raises RequestTimeout1. In the real classes, those values are set by each exchange: the Binance class in this version, for example, declares rateLimit = 50.

Markets, precision and limits

load_markets requests the markets once and stores them: the following methods look them up without going back to the network. Each market carries its native identifier (XBQUSDQ), its unified symbol, its fees, its precision and its limits:

IN [06]python
mk = alfa.load_markets()
m = mk["XBQ/USDQ"]
print(list(mk), m["id"], m["taker"])
print(m["precision"], m["limits"]["cost"])
print(alfa.amount_to_precision("XBQ/USDQ", 0.123456789),
      alfa.price_to_precision("XBQ/USDQ", 65_270.12999))
print(alfa.market_id("XEQ/USDQ"), alfa.safe_symbol("XEQUSDQ"))
OUT [06]Unified markets, precision and limits
['XBQ/USDQ', 'XEQ/USDQ'] XBQUSDQ 0.001
{'price': 0.01, 'amount': 0.0001} {'min': 10.0, 'max': None}
0.1234 65270.13
XEQUSDQ XEQ/USDQ

amount_to_precision truncates the amount to the market's step (0.0001 XBQ) and price_to_precision rounds the price to the tick (0.01). Doing it in the client avoids rejections for amounts with too many decimals, one of the most common causes of InvalidOrder on real exchanges. market_id and safe_symbol translate between the unified symbol and the native identifier in both directions.

Order book and balance

IN [07]python
ob = alfa.fetch_order_book("XBQ/USDQ", limit=3)
print("bids", ob["bids"])
print("asks", ob["asks"])
print(ob["datetime"], ob["nonce"])
saldo = alfa.fetch_balance()
print(saldo["USDQ"])
print({k: saldo["total"][k] for k in ["USDQ", "XBQ", "XEQ"]})
OUT [07]Order book and balance
bids [[65262.67, 0.3], [65249.62, 0.6], [65236.57, 0.9]]
asks [[65275.73, 0.3], [65288.78, 0.6], [65301.83, 0.9]]
2024-03-05T00:00:00.250Z None
{'free': 100000.0, 'used': 0.0, 'total': 100000.0}
{'USDQ': 100000.0, 'XBQ': 0.5, 'XEQ': 10.0}

The book comes as [price, amount] lists, with bids from highest to lowest and asks from lowest to highest; the timestamp is the server's virtual clock. nonce is None because this exchange does not number its books over REST; those that do let you know whether two snapshots are consecutive. The balance has the unified structure: per currency, free (available), used (locked in orders) and total, and the same three values grouped under free, used and total.

Orders: market, limit and their status

A market buy of 0.5 XBQ with 0.3 at the best level sweeps two levels:

IN [08]python
o = alfa.create_order("XBQ/USDQ", "market", "buy", 0.5)
campos = ["id", "status", "side", "amount", "filled", "average",
          "cost", "fee"]
for k in campos:
    print(f"{k:>8}: {o[k]}")
OUT [08]A market order that sweeps two levels
      id: A-000001
  status: closed
    side: buy
  amount: 0.5
  filled: 0.5
 average: 65280.95
    cost: 32640.475
     fee: {'cost': '32.640475', 'currency': 'USDQ'}

0.3 XBQ at 65,275.73 and 0.2 at 65,288.78 give an average price of 65,280.95, 0.8 bp above the best ask and 1.8 bp above the mid. cost is computed by ccxt (filled × average) and the fee, alfa's 0.10%, comes from the exchange's response. A limit order below the market stays open and locks its cash:

IN [09]python
lim = alfa.create_order("XBQ/USDQ", "limit", "buy", 0.2, 60_000,
                        {"clientOrderId": "rebound-0001"})
print(lim["id"], lim["status"], lim["clientOrderId"])
print("open:", [x["id"] for x in alfa.fetch_open_orders()])
print("USDQ:", alfa.fetch_balance()["USDQ"])
can = alfa.cancel_order(lim["id"])
print(can["status"], "| USDQ:", alfa.fetch_balance()["USDQ"])
OUT [09]A limit order that waits in the book
A-000002 open rebound-0001
open: ['A-000002']
USDQ: {'free': 55326.884525, 'used': 12000.0, 'total': 67326.884525}
canceled | USDQ: {'free': 67326.884525, 'used': 0.0, 'total': 67326.884525}

While the order is open, 12,000 USDQ (0.2 × 60,000) move from free to used; when it is canceled they come back. The order status is one of the unified ccxt values:

limitmarketfillscancel_orderexpiresopenclosedcanceledexpired
Mermaid source
stateDiagram-v2
  [*] --> open: limit
  [*] --> closed: market
  open --> closed: fills
  open --> canceled: cancel_order
  open --> expired: expires
  closed --> [*]
  canceled --> [*]
  expired --> [*]

An execution system keeps track of this cycle for each order, with its id and its clientOrderId, and reconciles with fetch_open_orders and fetch_order at startup and after any error: the true state is on the exchange, not in the process's memory.

Errors: what gets retried

ccxt converts each exchange's errors into a common hierarchy. What decides what to do is the branch: NetworkError (inside OperationFailed) groups transport and pacing problems; ExchangeError, those of the request or the account.

BaseErrorExchangeErrorOperationFailedNetworkErrorInsufficientFundsInvalidOrderOrderNotFoundDuplicateOrderIdRequestTimeoutRateLimitExceeded
Mermaid source
flowchart LR
  B[BaseError] --> E[ExchangeError]
  B --> O[OperationFailed]
  O --> N[NetworkError]
  E --> F[InsufficientFunds]
  E --> I[InvalidOrder]
  I --> NF[OrderNotFound]
  I --> D[DuplicateOrderId]
  N --> T[RequestTimeout]
  N --> R[RateLimitExceeded]
IN [10]python
intentos = {
    "no balance": lambda: alfa.create_order("XBQ/USDQ", "market",
                                            "buy", 50),
    "too small": lambda: alfa.create_order(
        "XBQ/USDQ", "market", "buy", 0.0001),
    "nonexistent order": lambda: alfa.cancel_order("A-999999"),
    "nonexistent symbol": lambda: alfa.fetch_order_book("XZZ/USDQ"),
    "wrong secret": lambda: conectar(
        "alfa", "otro").fetch_balance(),
}
for que, f in intentos.items():
    try:
        f()
    except ccxt.BaseError as e:
        red = isinstance(e, ccxt.NetworkError)
        print(f"{que:>18}: {type(e).__name__:<19} network={red}")
OUT [10]Typed errors: what the exchange says and what ccxt says
        no balance: InsufficientFunds   network=False
         too small: InvalidOrder        network=False
 nonexistent order: OrderNotFound       network=False
nonexistent symbol: BadSymbol           network=False
      wrong secret: AuthenticationError network=False

Four of the five errors are returned by the exchange and translated by handle_errors; the one for the nonexistent symbol is detected by ccxt without sending anything, because market_id looks it up in the loaded markets. None of them is a network error: repeating any of those requests gives the same error. The ccxt manual says so for its retry mechanism: it only repeats network or server failures, never an InsufficientFunds or an InvalidOrder1.

Rate limits

The server allows bursts of 5 requests and refills 5 tokens per second. Without a rate limiter, the client exhausts it right away; with the ccxt rate limiter at 250 ms between requests, never:

IN [11]python
cenit.load_markets()
esperar(1_000)                     # cenit's bucket, full
rapido = conectar("cenit", enableRateLimit=False)
rapido.set_markets(cenit.markets)  # no markets request
ok = 0
try:
    for _ in range(20):
        rapido.fetch_order_book("XBQ/USDQ")
        ok += 1
except ccxt.RateLimitExceeded as e:
    print(f"without rate limiter: {ok} responses and then "
          f"{type(e).__name__}")

esperar(1_000)
t0, ok = RELOJ["ms"], 0
for _ in range(12):
    cenit.fetch_order_book("XEQ/USDQ")
    ok += 1
print(f"with rate limiter: {ok} responses in "
      f"{(RELOJ['ms'] - t0) / 1000:.2f} s of logical clock")
OUT [11]A burst without rate limiter and pacing with rate limiter
without rate limiter: 5 responses and then RateLimitExceeded
with rate limiter: 12 responses in 3.00 s of logical clock
Fig. — Tokens left in the simulated server's bucket (capacity 5, 5 per second) after each of 20 requests: in a burst and with ccxt's limiter at 250 ms (schematic)

Five responses and then a 429, which handle_errors turns into RateLimitExceeded. With the rate limiter, twelve requests in a row without a rejection: the rate limiter waits 250 ms before each one, 3 s in total, and the bucket recovers 1.25 tokens per request. In real ccxt that wait is a time.sleep; here, so that the result does not depend on the machine's load, the adapter overrides throttle and advances the logical clock by the same amount. On a real exchange the limit is not a fixed number: it depends on the type of request, the account tier and sometimes the weight of each route (lesson 3.7.3 summarizes Deribit's credit system). ccxt accepts a cost per route to model those weights; the safe approach is to keep the rate limiter enabled and treat a 429 as a warning that your own pacing is badly tuned, not as a rare error.

A timeout does not tell whether the order exists

ccxt can retry on its own the requests that fail due to the network: options["maxRetriesOnFailure"] sets how many times1. For a query that is convenient. For an order, dangerous. In the cell, the server creates the order and does not respond until the cell releases it; the client waits 500 ms, gets RequestTimeout and retries:

IN [12]python
lento = conectar("cenit", timeout=500,
                 options={"maxRetriesOnFailure": 1})
lento.load_markets()
pausa = threading.Event()
Api.venues["cenit"].retener = pausa   # the next order, no response
o = lento.create_order("XEQ/USDQ", "limit", "buy", 1.0, 3_000)
pausa.set()                           # releases the server thread
abiertas = cenit.fetch_open_orders()
print("returned:", o["id"], "| open:",
      [x["id"] for x in abiertas])
for x in abiertas:
    cenit.cancel_order(x["id"])
OUT [12]A timeout with automatic retry duplicates the order
returned: C-000002 | open: ['C-000001', 'C-000002']

ccxt returns C-000002 as if everything had gone well, but there are two open orders on the exchange: the one from the first attempt, which was created even though the response never arrived, and the one from the retry. With real money, a double position.

The solution is idempotency: each order carries its own identifier (clientOrderId), the same on every attempt. If the first attempt arrived, the exchange rejects the second as a duplicate and it is enough to look up the order by that identifier:

IN [13]python
def enviar(ex, *args, cid, intentos=3):
    for i in range(intentos):
        try:
            return ex.create_order(*args, {"clientOrderId": cid})
        except ccxt.DuplicateOrderId:
            return ex.fetch_order(None, params={"clientOrderId": cid})
        except (ccxt.RequestTimeout, ccxt.NetworkError) as e:
            print(f"attempt {i + 1}: {type(e).__name__}")
            esperar(300)
    raise RuntimeError("no response")

seguro = conectar("cenit", timeout=500)
seguro.load_markets()
pausa = threading.Event()
Api.venues["cenit"].retener = pausa
o = enviar(seguro, "XEQ/USDQ", "limit", "buy", 1.0, 3_000,
           cid="mr-2024-03-05-001")
print("order:", o["id"], o["status"], o["clientOrderId"])
print("open:", [x["id"] for x in cenit.fetch_open_orders()])
pausa.set()
cenit.cancel_order(o["id"]);
OUT [13]Idempotency: the same clientOrderId on each attempt
attempt 1: RequestTimeout
order: C-000003 open mr-2024-03-05-001
open: ['C-000003']

A timeout, a rejection as a duplicate and a lookup: a single open order, C-000003. The ccxt manual warns that clientOrderId only exists on the exchanges that support it1; where there is none, the alternative is to reconcile with fetch_open_orders before resending. In both cases the automatic ccxt retry must stay disabled for orders.

Several exchanges at once

With one object per exchange, the code is the same for both. To decide where to buy, the best ask is not enough: what counts is the effective price, which walks the depth of the book and adds each market's taker fee:

IN [14]python
def coste_ejecucion(ob, cantidad, comision):
    resto, pagado = cantidad, 0.0
    for px, cant in ob["asks"]:
        x = min(resto, cant)
        resto, pagado = resto - x, pagado + x * px
        if resto <= 1e-12:
            break
    if resto > 1e-12:
        return float("nan")            # the book is not deep enough
    return pagado * (1 + comision) / cantidad

esperar(1_000)
venues = {"alfa": alfa, "cenit": cenit}
filas = []
for nombre, ex in venues.items():
    ob = ex.fetch_order_book("XBQ/USDQ", limit=5)
    tk = ex.market("XBQ/USDQ")["taker"]
    for q in [0.1, 0.5, 1.5]:
        filas.append((nombre, q, ob["asks"][0][0],
                      coste_ejecucion(ob, q, tk)))
tabla = pd.DataFrame(filas, columns=["venue", "cantidad",
                                     "mejor_ask", "precio_efectivo"])
tabla.pivot(index="cantidad", columns="venue",
            values="precio_efectivo").round(2)
OUT [14]Two exchanges: where to buy, with fees
venue         alfa     cenit
cantidad
0.1       65341.01  65300.06
0.5       65346.23  65305.29
1.5       65356.68  65315.73

The figure at the top draws the same calculation for sizes from 0.05 to 3 XBQ. Cenit is cheaper across the whole range, by about 41 USDQ per XBQ (6 bp): its mid is 8 USDQ lower and its fee, half. With books of different depth, the curves could cross and the best choice would change with size; that is why a router decides order by order with the books of the moment. Then, the balance is consolidated by currency:

IN [15]python
consolidado = pd.DataFrame(
    {n: ex.fetch_balance()["total"] for n, ex in venues.items()}
).loc[["USDQ", "XBQ", "XEQ"]]
consolidado["total"] = consolidado.sum(axis=1)
consolidado.round(4)
OUT [15]Consolidated balance
            alfa     cenit        total
USDQ  67326.8845  100000.0  167326.8845
XBQ       1.0000       0.5       1.5000
XEQ      10.0000      10.0      20.0000

Consolidation is a sum, but the balance is not a single one: alfa's 67,327 USDQ and cenit's 100,000 are claims against two different companies. If one suspends withdrawals, as dedalo does in the data (lesson 3.7.2), its share is no longer available even if it still shows up in the table. That is why a multi-exchange system caps what it leaves on each one and monitors their incidents.

Websockets with ccxt.pro

Polling the book over REST in a loop uses up requests and arrives late. Exchanges publish the book over websocket: a full snapshot on subscribing and then only the changes (deltas), each with a sequence number. CCXT Pro, which is now a free part of ccxt, adds the watch_* methods (watch_order_book, watch_ticker, watch_trades, watch_balance, watch_orders…) with the same interface as the REST ones and structures that are updated incrementally2Note 2 · CCXT Pro manual: “CCXT Pro is a free part of CCXT that adds support for WebSocket streaming”, unified watch_* API and incremental structures, github.com/ccxt/ccxt/wiki/ccxt.pro.manual, accessed on October 22, 2026..

The local websocket server sends, on each subscription, a snapshot of alfa's book and eleven deltas. If the subscription asks for a gap, one delta is skipped, as when a message is lost on a reconnection:

IN [16]python
import asyncio
import numpy as np
import websockets.asyncio.server as wss
from websockets.exceptions import ConnectionClosed

def mensajes(medio, n=12, hueco=None, semilla=7):
    rng = np.random.default_rng(semilla)
    foto = Venue.libro(medio)
    yield {"type": "snapshot", "seq": 1, "ts": 1_709_596_800_000,
           **foto}
    for s in range(2, n + 1):
        lado = "bids" if rng.random() < 0.5 else "asks"
        px = foto[lado][int(rng.integers(0, 3))][0]
        q = 0.0 if rng.random() < 0.2 else round(
            float(rng.uniform(0.05, 1.2)), 4)
        if s != hueco:
            yield {"type": "delta", "seq": s,
                   "ts": 1_709_596_800_000 + 100 * s, lado: [[px, q]]}

async def atender_ws(ws):
    try:
        async for raw in ws:
            req = json.loads(raw)
            medio = cierre.loc["alfa", req["symbol"][:-4]]
            for msg in mensajes(medio, hueco=req.get("hueco")):
                msg["symbol"] = req["symbol"]
                await ws.send(json.dumps(msg))
                await asyncio.sleep(0.005)
    except ConnectionClosed:
        pass

ws_srv = await wss.serve(atender_ws, "127.0.0.1", 0)
WS_PUERTO = ws_srv.sockets[0].getsockname()[1]

The async adapter inherits from ccxt.pro.Exchange and reuses Comun. watch_order_book subscribes and waits for the next book; handle_message receives each message, applies the snapshot or the delta to the local book (self.order_book, the ccxt.pro incremental structure) and checks that the sequence is the next one:

snapshotdelta seq + 1gapdelta without snapshotNoBookUpToDate
Mermaid source
stateDiagram-v2
  [*] --> NoBook
  NoBook --> UpToDate: snapshot
  UpToDate --> UpToDate: delta seq + 1
  UpToDate --> NoBook: gap
  NoBook --> NoBook: delta without snapshot
IN [17]python
import ccxt.pro
from ccxt.base.errors import InvalidNonce

class SimuladoPro(Comun, ccxt.pro.Exchange):
    aplicados = []

    async def fetch_markets(self, params={}):
        r = await self.request("public/markets", "public", "GET",
                               params)
        return self.parse_mercados(r)

    async def watch_order_book(self, symbol, limit=None, params={}):
        await self.load_markets()
        h = "book:" + symbol
        req = self.extend({"op": "subscribe",
                           "symbol": self.market_id(symbol)}, params)
        ob = await self.watch(self.urls["api"]["ws"], h, req, h)
        return ob.limit()

    def handle_message(self, client, msg):
        symbol = self.safe_symbol(msg["symbol"])
        h = "book:" + symbol
        if msg["type"] == "snapshot":
            ob = self.order_book({}, 10)
            ob.reset({"bids": msg["bids"], "asks": msg["asks"],
                      "nonce": msg["seq"], "timestamp": msg["ts"],
                      "symbol": symbol})
            self.orderbooks[symbol] = ob
        elif symbol not in self.orderbooks:
            return                     # deltas without snapshot: ignored
        else:
            ob = self.orderbooks[symbol]
            if msg["seq"] != ob["nonce"] + 1:
                del self.orderbooks[symbol]
                client.subscriptions.pop(h, None)
                client.reject(InvalidNonce(
                    f"expected {ob['nonce'] + 1}, "
                    f"got {msg['seq']}"), h)
                return
            for lado in ("bids", "asks"):
                for px, q in msg.get(lado, []):
                    ob[lado].store(px, q)
            ob["nonce"], ob["timestamp"] = msg["seq"], msg["ts"]
        self.aplicados.append(msg["seq"])
        client.resolve(ob, h)

pro = SimuladoPro({"apiKey": "k-alumno", "secret": "s3cr3t0"})
pro.urls["api"]["rest"] = f"http://127.0.0.1:{PUERTO}/alfa"
pro.urls["api"]["ws"] = f"ws://127.0.0.1:{WS_PUERTO}"
esperar(1_000)
try:
    while True:
        ob = await pro.watch_order_book("XBQ/USDQ",
                                        params={"hueco": 7})
except InvalidNonce as e:
    print("InvalidNonce:", e)
print("applied:", pro.aplicados)
OUT [17]watch_order_book with ccxt.pro and sequence control
InvalidNonce: expected 7, got 8
applied: [1, 2, 3, 4, 5, 6]

The client applies the snapshot and deltas 2 to 6. When 8 arrives without 7, it does not apply it: it deletes the book, forgets the subscription and rejects the pending watch with InvalidNonce, the ccxt exception for broken sequences. The loop ends with an exception instead of carrying on with a book that is no longer the exchange's. Subscribing again brings a new snapshot:

IN [18]python
pro.aplicados.clear()
libro = lambda: pro.orderbooks.get("XBQ/USDQ")
while libro() is None or libro()["nonce"] < 12:   # the state decides
    await asyncio.wait_for(pro.watch_order_book("XBQ/USDQ"), 30)
ob = libro()
print("applied:", pro.aplicados)
print("best bid", ob["bids"][0], "| best ask", ob["asks"][0])
await pro.close()
ws_srv.close()
await ws_srv.wait_closed()
OUT [18]Resubscribing: new snapshot and book up to date
applied: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
best bid [65262.67, 0.3] | best ask [65275.73, 0.3985]

The deltas left over from the first subscription arrive with no book and are ignored; then the snapshot and the eleven deltas of the second one arrive, and the book is up to date at sequence 12. The best ask changed from 0.3 to 0.3985 XBQ with one of the deltas. With real exchanges, ccxt.pro does this work inside each class (each exchange numbers in its own way); the rule is the same: a local order book is only useful if you can prove that it is not missing any message.

What changes with a real exchange

The code in the cells works almost unchanged for a real exchange: the class changes and the keys appear. This is the skeleton, not run because it needs a network and an account:

IN [19]exchange_real.pypython
import os
import ccxt
import ccxt.pro

ex = ccxt.binance({
    "apiKey": os.environ["BINANCE_KEY"],
    "secret": os.environ["BINANCE_SECRET"],
    "options": {"maxRetriesOnFailure": 0},   # never on orders
})
ex.set_sandbox_mode(True)        # test environment, if there is one
ex.load_markets()
saldo = ex.fetch_balance()
orden = ex.create_order("BTC/USDT", "limit", "buy", 0.001, 50_000,
                        {"clientOrderId": "mr-0001"})

async def libro():
    pro = ccxt.pro.binance()
    try:
        while True:
            ob = await pro.watch_order_book("BTC/USDT")
            print(ob["bids"][0], ob["asks"][0])
    finally:
        await pro.close()

Four precautions that the simulation teaches and a real exchange demands:

  • Keys with minimal permissions. Read-only for data and balance; trading without withdrawal for the trading system; and keys in environment variables or in a secrets manager, never in the code.
  • Test environment first. set_sandbox_mode(True) switches the URLs to the exchange's test environment if there is one, and it has to be called right after creating the object, before any other call1.
  • Reconciliation at startup. Balances, open orders and positions are read from the exchange every time the system starts or loses the connection.
  • An exchange is a counterparty. Lesson 3.7.2 measures what happens when one goes bust; in the EU, lesson 3.7.5 explains what MiCA requires of crypto-asset service providers.

Exercises

  1. Add to the sync client a fetch_ticker that builds a unified ticker (bid, ask, last as the mid point) from public/depth, and use it to compare the bid-ask spread in basis points of XBQ and XEQ on the two exchanges.
Solution
IN [20]python
class ConTicker(Simulado):
    def fetch_ticker(self, symbol, params={}):
        ob = self.fetch_order_book(symbol, 1, params)
        bid, ask = ob["bids"][0][0], ob["asks"][0][0]
        return self.safe_ticker({
            "symbol": symbol, "timestamp": ob["timestamp"],
            "datetime": ob["datetime"], "bid": bid, "ask": ask,
            "last": (bid + ask) / 2, "info": ob})

esperar(1_000)
for v in ["alfa", "cenit"]:
    ex = ConTicker({"apiKey": "k-alumno", "secret": "s3cr3t0"})
    ex.id = v
    ex.urls["api"]["rest"] = f"http://127.0.0.1:{PUERTO}/{v}"
    for s in ["XBQ/USDQ", "XEQ/USDQ"]:
        t = ex.fetch_ticker(s)
        pb = 1e4 * (t["ask"] - t["bid"]) / t["last"]
        print(f"{v:>5} {s}: bid {t['bid']:,.2f} ask {t['ask']:,.2f}"
              f" spread {pb:.2f} bp")
OUT [20]A fetch_ticker from the order book
 alfa XBQ/USDQ: bid 65,262.67 ask 65,275.73 spread 2.00 bp
 alfa XEQ/USDQ: bid 3,470.80 ask 3,471.50 spread 2.02 bp
cenit XBQ/USDQ: bid 65,254.37 ask 65,267.43 spread 2.00 bp
cenit XEQ/USDQ: bid 3,468.34 ask 3,469.04 spread 2.02 bp

In all four markets the bid-ask spread is 2 bp (2.02 in XEQ because of rounding to the 0.01 tick on a price of about 3,470): the simulator's books have the same shape on both exchanges and only the mid changes. On real exchanges the spread and the depth are different on each one and change with the day and the time, which is why routing is decided with the book of the moment. safe_ticker fills the missing fields (high, low, vwap…) with None: the structure is the unified one even if the exchange does not provide them.

  1. Sell on alfa, with a market order, all the XBQ you have and measure the slippage against the mid. Then try to sell 2 more XBQ and check which exception arrives and whether it is retryable.
Solution
IN [21]python
esperar(1_000)
libre = alfa.fetch_balance()["free"]["XBQ"]
ob = alfa.fetch_order_book("XBQ/USDQ", limit=5)
medio = (ob["bids"][0][0] + ob["asks"][0][0]) / 2
v = alfa.create_order("XBQ/USDQ", "market", "sell", libre)
desl = 1e4 * (medio - v["average"]) / medio
print(f"sold {v['filled']} XBQ at {v['average']:,.2f}: "
      f"{desl:.1f} bp below the mid")
try:
    alfa.create_order("XBQ/USDQ", "market", "sell", 2)
except ccxt.BaseError as e:
    print(type(e).__name__, "| retryable:",
          isinstance(e, ccxt.NetworkError))
OUT [21]Sell everything and overshoot
sold 1.0 XBQ at 65,252.23: 2.6 bp below the mid
InsufficientFunds | retryable: False

1.0 XBQ is sold (the initial 0.5 and the 0.5 bought) against three levels of the bid, at an average price of 65,252.23 USDQ, 2.6 bp below the mid, on top of which comes alfa's 0.10% fee. The second sale raises InsufficientFunds, which descends from ExchangeError: retrying it would give the same error, because the problem is the balance, not the network.

  1. Write the retry policy of a system that trades on several exchanges: which exceptions are retried, with what wait, and what it does with create_order and cancel_order in each case.
Solution

By branches of the hierarchy. RateLimitExceeded and DDoSProtection: wait (with exponential backoff and some jitter, lesson 1.4.1) and lower your own pace; it is not a one-off failure but badly tuned pacing. RequestTimeout, ExchangeNotAvailable and the rest of NetworkError: for queries (order book, balance, open orders), retry with a wait and a maximum; for create_order, do not retry blindly: resend with the same clientOrderId (the exchange rejects the duplicate) or, if it does not support it, check the open orders before deciding; for cancel_order, repeating is safe, and a later OrderNotFound usually means that the order was already canceled or filled, so its status is looked up. InsufficientFunds, InvalidOrder, BadSymbol and AuthenticationError: do not retry; log, alert and review the logic or the configuration. In addition, the automatic ccxt retry (maxRetriesOnFailure) set to zero for orders, and a full reconciliation (balances and open orders on each exchange) at startup and after any streak of network errors.

Footnotes

  1. ccxt manual: properties rateLimit, enableRateLimit (enabled by default) and timeout (10,000 ms by default, RequestTimeout when it runs out), order structure and clientOrderId (“only on exchanges that support it”), retry mechanism (maxRetriesOnFailure, network or server failures only), exception hierarchy and set_sandbox_mode, docs.ccxt.com and github.com/ccxt/ccxt/wiki/Manual, accessed on October 22, 2026. MIT license according to the GitHub repository. The hierarchy in the diagram is that of the installed version 4.5.89 (ccxt.base.errors). ↩ ↩2 ↩3 ↩4 ↩5 ↩6

  2. CCXT Pro manual: “CCXT Pro is a free part of CCXT that adds support for WebSocket streaming”, unified watch_* API and incremental structures, github.com/ccxt/ccxt/wiki/ccxt.pro.manual, accessed on October 22, 2026. ↩

Each answer is folded under its question

Self-assessment

  1. A buy order gets a RequestTimeout. What is the right thing to do?

    • AResend it right away: if it timed out, it did not arrive
    • BLook up the order by its clientOrderId (or resend it with the same one) before assuming anything: the exchange may have accepted it
    • CWait until the next day
    • DRaise timeout to 60 seconds and do nothing else
    Show answer

    Answer B. Look up the order by its clientOrderId (or resend it with the same one) before assuming anything: the exchange may have accepted it

    A timeout says the response did not arrive, not that the order did not. In the lesson, the automatic ccxt retry (maxRetriesOnFailure) created two orders; with the same clientOrderId on each attempt, the exchange rejected the second one as a duplicate and the lookup returned the only one there was.

  2. Which of these errors makes sense to retry as is, after a wait?

    • ARateLimitExceeded
    • BInsufficientFunds
    • CAuthenticationError
    • DInvalidOrder
    Show answer

    Answer A. RateLimitExceeded

    RateLimitExceeded descends from NetworkError: it is a pacing problem and is solved by waiting. The other three descend from ExchangeError: the request is wrong or the account cannot make it, and repeating it gives the same error.

  3. In the simulator, the limit is a bucket of 5 tokens that refills at 5 per second. What ccxt rate limiter pace avoids 429s on a sustained basis?

    • ArateLimit = 100 ms (10 requests per second)
    • BrateLimit = 200 ms, right at the limit
    • CDisable the rate limiter and catch the 429s
    • DrateLimit = 250 ms (4 per second), below the refill rate
    Show answer

    Answer D. rateLimit = 250 ms (4 per second), below the refill rate

    With 4 requests per second against a refill of 5, the bucket never empties: the 20 requests in the figure are accepted. At 200 ms it is tight and any variation in latency ends in a rejection; at 100 ms it runs out in one second.

  4. On the websocket, after the delta with sequence 6, number 8 arrives. What does the lesson's client do?

    • AApplies 8 and carries on
    • BWaits for 7 to arrive
    • CDiscards the book, rejects the subscription with InvalidNonce and subscribes again to receive a new snapshot
    • DInterpolates 7
    Show answer

    Answer C. Discards the book, rejects the subscription with InvalidNonce and subscribes again to receive a new snapshot

    Without delta 7 the local book is no longer the exchange's, and applying the following ones on top would leave it wrong without warning. The safe thing is to throw the book away, ignore deltas without a snapshot and start again from a full snapshot.

  5. The best ask on cenit is 65,267.43 and on alfa 65,275.73. Is that enough to decide where to buy 1.5 XBQ?

    • AYes: cenit is cheaper
    • BNo: you have to walk the depth of each book and add each exchange's taker fee, and also check whether the balance is on the right exchange
    • CNo: the exchange with the most volume is always better
    • DYes, because fees are the same everywhere
    Show answer

    Answer B. No: you have to walk the depth of each book and add each exchange's taker fee, and also check whether the balance is on the right exchange

    The price paid comes from the whole book and the fee. In the lesson, cenit is cheaper on both counts (mid 8 USDQ lower and a fee of 0.05% versus 0.10%), by about 41 USDQ per XBQ. With a different fee or less depth the decision could change with size.

Key points

  • ccxt unifies symbols (XBQ/USDQ), markets with precision and limits, and the order book, balance (free, used, total) and order structures; whatever is specific to each exchange goes in params.
  • A ccxt adapter has four pieces: describe (URL, limits, error map), sign (signing private requests), handle_errors (native codes to exceptions) and the parse_* methods to the unified structures.
  • Errors are separated by inheritance: NetworkError (timeouts, pacing, outages) is retried with a wait; ExchangeError (balance, invalid order, authentication) is not.
  • A timeout in create_order does not tell whether the order exists: the automatic retry duplicated the simulator's order; the same clientOrderId on each attempt and a later lookup prevent it.
  • With several exchanges, the price that counts is the effective one (book and fee) and the balance has to be consolidated by currency; counterparty risk remains specific to each exchange (lesson 3.7.2).
  • ccxt.pro (a free part of ccxt) provides watch_* over websocket; a local order book is only valid if the sequence is checked and it goes back to the snapshot when there is a gap.

Resources