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.
- Module
- 3.7 · Crypto derivatives and specific strategies
- Duration
- 75 min
- Prerequisites
- 1.3.5 · Crypto and on-chain data, 1.4.1 · Downloading over REST and websockets: pagination, rate limits and retries, 3.7.2 · Funding arbitrage, basis with futures and cross-exchange arbitrage
- Author
- Jesús Cuesta
- Updated
Level 3 lessons
3.1 · Futures in depth
3.2 · Futures spreads
- 3.2.1 Calendar spreads, butterflies and condors
- 3.2.2 Intercommodity spreads: crack, spark and crush
- 3.2.3 Rate and index spreads: 2s10s, NOB, NQ/ES and Europe versus the U.S.
- 3.2.4 Hedge ratios: by notional, by DV01 and by volatility
- 3.2.5 Spread data and backtesting: listed, synthetic and legged
- 3.2.6 Seasonality of spreads: multi-year analysis, patterns and traps
3.3 · Advanced forex
3.4 · Option theory and pricing
- 3.4.1 Payoffs, put-call parity and arbitrage bounds
- 3.4.2 Binomial trees, Black-Scholes-Merton, Black-76 and Monte Carlo
- 3.4.3 Greeks: delta, gamma, vega, theta, rho, vanna, volga and charm
- 3.4.4 Implied volatility: Newton-Raphson, Brent, py_vollib and QuantLib
- 3.4.5 Smile, skew and term structure; volatility surface with SVI
- 3.4.6 Realized vs implied volatility; volatility risk premium; VIX and VSTOXX
- 3.4.7 Advanced models: Heston, SABR and local volatility
3.5 · Option structures in Python
- 3.5.1 Directional and vertical structures: call, put, covered call, collar and spreads
- 3.5.2 Volatility and range structures: straddles, strangles, condors and butterflies
- 3.5.3 Calendars, diagonals, ratios, backspreads, risk reversals and jade lizard
- 3.5.4 A custom tool: payoff, aggregate Greeks and scenarios
- 3.5.5 Management: adjustments, rolls, early closes, early assignment and pin risk
3.6 · Systematic volatility and options strategies
3.7 · Crypto derivatives and specific strategies
- 3.7.1 Perpetuals: funding mechanics, mark price and liquidations
- 3.7.2 Funding arbitrage, basis with futures and cross-exchange arbitrage
- 3.7.3 Crypto options, on-chain data and DeFi
- 3.7.4 ccxt for trading on multiple exchanges
- 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.
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):
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"]])
cierreactivo XBQ XEQ
exchange
alfa 65269.2 3471.15
cenit 65260.9 3468.69A 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:
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, oThe 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.
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).
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:
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)(<class '__main__.Comun'>, <class 'ccxt.base.exchange.Exchange'>)
250 True 10000rateLimit 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:
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"))['XBQ/USDQ', 'XEQ/USDQ'] XBQUSDQ 0.001
{'price': 0.01, 'amount': 0.0001} {'min': 10.0, 'max': None}
0.1234 65270.13
XEQUSDQ XEQ/USDQamount_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
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"]})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:
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]}") 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:
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"])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:
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.
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]
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}") no balance: InsufficientFunds network=False
too small: InvalidOrder network=False
nonexistent order: OrderNotFound network=False
nonexistent symbol: BadSymbol network=False
wrong secret: AuthenticationError network=FalseFour 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:
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")without rate limiter: 5 responses and then RateLimitExceeded
with rate limiter: 12 responses in 3.00 s of logical clockFive 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:
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"])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:
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"]);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:
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)venue alfa cenit
cantidad
0.1 65341.01 65300.06
0.5 65346.23 65305.29
1.5 65356.68 65315.73The 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:
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) alfa cenit total
USDQ 67326.8845 100000.0 167326.8845
XBQ 1.0000 0.5 1.5000
XEQ 10.0000 10.0 20.0000Consolidation 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:
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:
Mermaid source
stateDiagram-v2 [*] --> NoBook NoBook --> UpToDate: snapshot UpToDate --> UpToDate: delta seq + 1 UpToDate --> NoBook: gap NoBook --> NoBook: delta without snapshot
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)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:
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()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:
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
- Add to the sync client a
fetch_tickerthat builds a unified ticker (bid,ask,lastas the mid point) frompublic/depth, and use it to compare the bid-ask spread in basis points of XBQ and XEQ on the two exchanges.
Solution
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") 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 bpIn 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.
- 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
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))sold 1.0 XBQ at 65,252.23: 2.6 bp below the mid
InsufficientFunds | retryable: False1.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.
- 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_orderandcancel_orderin 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
-
ccxt manual: properties
rateLimit,enableRateLimit(enabled by default) andtimeout(10,000 ms by default,RequestTimeoutwhen it runs out), order structure andclientOrderId(“only on exchanges that support it”), retry mechanism (maxRetriesOnFailure, network or server failures only), exception hierarchy andset_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 -
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
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
timeoutto 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 itA 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 sameclientOrderIdon each attempt, the exchange rejected the second one as a duplicate and the lookup returned the only one there was.Which of these errors makes sense to retry as is, after a wait?
- A
RateLimitExceeded - B
InsufficientFunds - C
AuthenticationError - D
InvalidOrder
Show answer
Answer A.
RateLimitExceededRateLimitExceededdescends fromNetworkError: it is a pacing problem and is solved by waiting. The other three descend fromExchangeError: the request is wrong or the account cannot make it, and repeating it gives the same error.- A
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?
- A
rateLimit = 100ms (10 requests per second) - B
rateLimit = 200ms, right at the limit - CDisable the rate limiter and catch the 429s
- D
rateLimit = 250ms (4 per second), below the refill rate
Show answer
Answer D.
rateLimit = 250ms (4 per second), below the refill rateWith 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.
- A
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
InvalidNonceand subscribes again to receive a new snapshot - DInterpolates 7
Show answer
Answer C. Discards the book, rejects the subscription with
InvalidNonceand subscribes again to receive a new snapshotWithout 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.
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 inparams. - A ccxt adapter has four pieces:
describe(URL, limits, error map),sign(signing private requests),handle_errors(native codes to exceptions) and theparse_*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_orderdoes not tell whether the order exists: the automatic retry duplicated the simulator's order; the sameclientOrderIdon 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.