Lección 3.7.4 · 75 min

ccxt para operar en varios exchanges

ccxt traduce las APIs de más de cien exchanges a una sola: los mismos métodos, las mismas estructuras y los mismos errores. Esta lección la ejecuta de verdad, sin red, contra dos exchanges simulados en un hilo: un adaptador propio, órdenes que barren el libro, errores tipados, un límite de peticiones, un timeout que duplica una orden y un libro por websocket con control de secuencia.

Fig. — Precio efectivo de una compra de XBQ en los dos exchanges simulados, comisión incluida, según la cantidad
Lecciones del nivel 3
  1. 3.1 · Futuros en profundidad

    1. 3.1.1 Contratos de futuros principales
    2. 3.1.2 Curva de futuros: base, cost of carry, contango y roll yield
    3. 3.1.3 Gestión del roll, márgenes, SPAN y apalancamiento implícito
    4. 3.1.4 Sistemas de futuros diversificados: el enfoque de Carver
  2. 3.2 · Spreads de futuros

    1. 3.2.1 Spreads de calendario, butterflies y condors
    2. 3.2.2 Spreads intercommodity: crack, spark y crush
    3. 3.2.3 Spreads de tipos y de índices: 2s10s, NOB, NQ/ES y Europa frente a EE. UU.
    4. 3.2.4 Ratios de cobertura: por nominal, por DV01 y por volatilidad
    5. 3.2.5 Datos y backtesting de spreads: cotizados, sintéticos y por patas
    6. 3.2.6 Estacionalidad de spreads: análisis multi-año, patrones y trampas
  3. 3.3 · Forex avanzado

    1. 3.3.1 Microestructura FX: OTC, ECN, last look y sesiones
    2. 3.3.2 Carry y riesgo de crash, momentum y value en divisas; cestas
    3. 3.3.3 Riesgo de divisa en carteras multi-moneda; ejecución vía OANDA, IBKR o MT5
  4. 3.4 · Teoría y valoración de opciones

    1. 3.4.1 Payoffs, paridad put-call y límites de arbitraje
    2. 3.4.2 Árboles binomiales, Black-Scholes-Merton, Black-76 y Monte Carlo
    3. 3.4.3 Griegas: delta, gamma, vega, theta, rho, vanna, volga y charm
    4. 3.4.4 Volatilidad implícita: Newton-Raphson, Brent, py_vollib y QuantLib
    5. 3.4.5 Sonrisa, skew y estructura temporal; superficie de volatilidad con SVI
    6. 3.4.6 Volatilidad realizada frente a implícita; prima de riesgo de volatilidad; VIX y VSTOXX
    7. 3.4.7 Modelos avanzados: Heston, SABR y volatilidad local
  5. 3.5 · Estructuras de opciones en Python

    1. 3.5.1 Estructuras direccionales y verticales: call, put, covered call, collar y spreads
    2. 3.5.2 Estructuras de volatilidad y rango: straddles, strangles, cóndores y mariposas
    3. 3.5.3 Calendarios, diagonales, ratios, backspreads, risk reversals y jade lizard
    4. 3.5.4 Herramienta propia: payoff, griegas agregadas y escenarios
    5. 3.5.5 Gestión: ajustes, roll, cierre anticipado, asignación temprana y pin risk
  6. 3.6 · Estrategias sistemáticas de volatilidad y opciones

    1. 3.6.1 Venta sistemática de prima: put-write, strangles y su riesgo de cola
    2. 3.6.2 Delta hedging, gamma scalping y dispersión
    3. 3.6.3 Estrategias con VIX y VSTOXX: futuros y ETP de volatilidad
    4. 3.6.4 Backtesting de opciones: datos, fills y ajustes por eventos
  7. 3.7 · Cripto derivados y estrategias específicas

    1. 3.7.1 Perpetuos: mecánica del funding, mark price y liquidaciones
    2. 3.7.2 Arbitraje de funding, basis con futuros y arbitraje entre exchanges
    3. 3.7.3 Opciones cripto, datos on-chain y DeFi
    4. 3.7.4 ccxt para operar en varios exchanges
    5. 3.7.5 MiCA: el marco de la UE para criptoactivos y sus proveedores (CASP)

Una API para muchos exchanges

Cada exchange cripto tiene su propia API: rutas, nombres de campos, formatos de símbolo, firma de las peticiones, códigos de error y límites. ccxt es una librería de código abierto (licencia MIT) que las traduce a una sola: el mismo create_order devuelve la misma estructura en todos1Nota 1 · Manual de ccxt: propiedades rateLimit, enableRateLimit (activado por defecto) y timeout (10.000 ms por defecto, RequestTimeout al agotarse), estructura de orden y clientOrderId («solo en los exchanges que lo admiten»), mecanismo de reintentos (maxRetriesOnFailure, solo fallos de red o de servidor), jerarquía de excepciones y set_sandbox_mode, docs.ccxt.com y github.com/ccxt/ccxt/wiki/Manual, consultados el 22/10/2026. Licencia MIT según el repositorio de GitHub. La jerarquía del diagrama es la de la versión 4.5.89 instalada (ccxt.base.errors).. La versión del entorno del curso, 4.5.89, trae 103 clases de exchange (len(ccxt.exchanges)). La lección 1.3.5 la usó para descargar velas y paginar; esta la usa para lo que hace falta al operar: mercados, saldos, órdenes, errores, límites y datos en tiempo real.

Tu sistemaAPI unificadaAdaptador alfaAdaptador cenitAPI nativa alfaAPI nativa cenit
Fuente Mermaid
flowchart TB
  S[Tu sistema] --> U[API unificada]
  U --> A[Adaptador alfa]
  U --> C[Adaptador cenit]
  A --> NA[API nativa alfa]
  C --> NC[API nativa cenit]

Lo que unifica ccxt: el símbolo (XBQ/USDQ, y XBQ/USDQ:USDQ para un perpetuo lineal), la ficha de cada mercado con su precisión, sus límites y sus comisiones, las estructuras de libro, saldo y orden, y una jerarquía de excepciones. Lo que no unifica: qué tipos de orden admite cada exchange, sus límites reales, sus comisiones por nivel y cuándo cae. Todo lo específico viaja en el argumento params de cada método, que ccxt pasa tal cual a la API nativa.

Operar contra un exchange real necesita red y claves, así que aquí todo corre contra un exchange simulado en un hilo del propio proceso. No es un objeto que imita a ccxt: es un servidor HTTP con su propia API nativa (rutas, firma HMAC, códigos de error, un cubo de fichas para el límite de peticiones), y ccxt habla con él a través de un adaptador, igual que con Binance o con Kraken. Los dos exchanges simulados, alfa y cenit, parten del cierre de XBQ y XEQ del 4 de marzo de 2024 en los datos del nivel (cripto3_diario.parquet, sintéticos; alfa imita a Binance y cenit a BitMEX, pero sus comisiones y sus libros son inventados para la lección):

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]Precios de partida: el cierre del 4 de marzo de 2024
activo        XBQ      XEQ
exchange
alfa      65269.2  3471.15
cenit     65260.9  3468.69

Un exchange simulado en un hilo

El estado de cada exchange es un libro de cinco niveles por lado alrededor del cierre (2 pb entre niveles y 0,3 monedas más en cada nivel), los saldos de una cuenta (100.000 USDQ, 0,5 XBQ y 10 XEQ), las órdenes y un reloj virtual que avanza 250 ms por respuesta, para que las marcas de tiempo de las salidas no dependan del momento en que se ejecute la lección. La casación es la mínima: una orden de mercado barre niveles hasta llenarse; una limitada que no cruza espera en el libro con su efectivo bloqueado:

IN [02]python
RELOJ = {"ms": 0.0}   # reloj lógico del límite: lo avanza el cliente

class Venue:
    """Libro, saldos y órdenes de un exchange; reloj virtual."""

    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: la próxima orden
        self.lock = threading.Lock()

    @staticmethod
    def libro(medio, niveles=5):
        paso = medio * 2e-4              # 2 pb entre niveles
        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):              # cubo: 5 fichas, 5 por segundo
        ahora = RELOJ["ms"]              # tiempo lógico, no del reloj
        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):
        """Casa una orden contra el libro; devuelve (http, cuerpo)."""
        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

La API nativa es la de muchos exchanges: rutas públicas (public/markets, public/depth) y privadas (private/balance, private/order, private/openOrders), una firma HMAC-SHA256 de la marca de tiempo, el método, la ruta y el cuerpo con el secreto de la cuenta, errores en JSON con un código propio y un HTTP 429 cuando se vacía el cubo de fichas. Dos detalles hacen que la lección dé siempre el mismo resultado, en cualquier máquina y con cualquier carga. El cubo no se recarga con el reloj del sistema sino con un reloj lógico (RELOJ) que avanza el limitador del cliente. Y si se le pide retener la próxima orden, el servidor la crea y no responde hasta recibir un aviso (threading.Event), que es lo que ve un cliente cuando un exchange real va lento.

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": "demasiadas"})
            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": "firma"})
            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:           # la orden ya existe, pero
            espera.wait(60)              # no responde hasta el aviso
        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                         # clientes que cortan por 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]

El servidor escucha en un puerto que elige el sistema operativo (("127.0.0.1", 0)), así que la lección no choca con nada que ya esté en marcha. handle_error vacío evita que las conexiones que el cliente corta por timeout llenen la salida de trazas.

El adaptador: cuatro piezas

Una clase de exchange de ccxt hereda de ccxt.Exchange y rellena cuatro piezas. Las de los exchanges reales las genera el proyecto ccxt a partir de una descripción de cada API; la de aquí está escrita a mano, pero usa la misma maquinaria:

  • describe: identidad, URL, rateLimit (milisegundos entre peticiones), capacidades (has) y el mapa de códigos de error nativos a excepciones de ccxt.
  • sign: construye la petición. En las rutas privadas añade la clave, la marca de tiempo y la firma HMAC, que el servidor comprueba.
  • handle_errors: recibe cada respuesta y, si trae un código de error, lanza la excepción tipada que le corresponde.
  • parse_*: traduce las respuestas nativas a las estructuras unificadas (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:
    """Lo que no depende de si el cliente es síncrono o asíncrono."""

    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)

La clase Comun reúne lo que no depende del transporte, para reutilizarla en el cliente asíncrono de ccxt.pro al final de la lección. El cliente síncrono añade los métodos unificados, cada uno con una llamada a self.request, que es donde ccxt aplica el limitador, firma, envía con requests, comprueba errores y reintenta si se le pide:

IN [05]python
class Simulado(Comun, ccxt.Exchange):
    def throttle(self, cost=None):
        """El limitador de ccxt duerme rateLimit × cost ms; aquí
        avanza el reloj lógico lo mismo, sin dormir."""
        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):                   # un time.sleep en el reloj lógico
    RELOJ["ms"] += ms

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

rateLimit es de 250 ms, el limitador viene activado por defecto y el timeout por defecto es de 10.000 ms; pasado ese tiempo sin respuesta, ccxt lanza RequestTimeout1. En las clases reales, esos valores los fija cada exchange: la de Binance de esta versión, por ejemplo, declara rateLimit = 50.

Mercados, precisión y límites

load_markets pide los mercados una vez y los guarda: los métodos siguientes los consultan sin volver a la red. Cada mercado lleva su identificador nativo (XBQUSDQ), su símbolo unificado, sus comisiones, su precisión y sus límites:

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]Mercados unificados, precisión y límites
['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 recorta la cantidad al paso del mercado (0,0001 XBQ) y price_to_precision redondea el precio al tick (0,01). Hacerlo en el cliente evita rechazos por cantidades con demasiados decimales, una de las causas más comunes de InvalidOrder en los exchanges reales. market_id y safe_symbol traducen entre el símbolo unificado y el identificador nativo en las dos direcciones.

Libro y saldo

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]Libro y saldo
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}

El libro sale como listas [precio, cantidad], con los bids de mayor a menor y los asks de menor a mayor; la marca de tiempo es la del reloj virtual del servidor. nonce es None porque este exchange no numera sus libros por REST; los que lo hacen permiten saber si dos fotos son consecutivas. El saldo tiene la estructura unificada: por moneda, free (disponible), used (bloqueado en órdenes) y total, y los mismos tres valores agrupados en free, used y total.

Órdenes: de mercado, limitadas y su estado

Una compra de mercado de 0,5 XBQ con 0,3 en el mejor nivel barre dos niveles:

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]Una orden de mercado que barre dos niveles
      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 a 65.275,73 y 0,2 a 65.288,78 dan un precio medio de 65.280,95, 0,8 pb por encima del mejor ask y 1,8 pb por encima del medio. cost lo calcula ccxt (filled × average) y la comisión, el 0,10 % de alfa, sale de la respuesta del exchange. Una orden limitada por debajo del mercado se queda abierta y bloquea su efectivo:

IN [09]python
lim = alfa.create_order("XBQ/USDQ", "limit", "buy", 0.2, 60_000,
                        {"clientOrderId": "rebote-0001"})
print(lim["id"], lim["status"], lim["clientOrderId"])
print("abiertas:", [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]Una orden limitada que espera en el libro
A-000002 open rebote-0001
abiertas: ['A-000002']
USDQ: {'free': 55326.884525, 'used': 12000.0, 'total': 67326.884525}
canceled | USDQ: {'free': 67326.884525, 'used': 0.0, 'total': 67326.884525}

Mientras la orden está abierta, 12.000 USDQ (0,2 × 60.000) pasan de free a used; al cancelarla vuelven. El estado de la orden es uno de los valores unificados de ccxt:

limitadamercadose ejecutacancel_ordercaducaopenclosedcanceledexpired
Fuente Mermaid
stateDiagram-v2
  [*] --> open: limitada
  [*] --> closed: mercado
  open --> closed: se ejecuta
  open --> canceled: cancel_order
  open --> expired: caduca
  closed --> [*]
  canceled --> [*]
  expired --> [*]

Un sistema de ejecución lleva la cuenta de este ciclo por cada orden, con su id y su clientOrderId, y concilia con fetch_open_orders y fetch_order al arrancar y tras cualquier error: el estado verdadero está en el exchange, no en la memoria del proceso.

Errores: qué se reintenta

ccxt convierte los errores de cada exchange en una jerarquía común. Lo que decide qué hacer es la rama: NetworkError (dentro de OperationFailed) agrupa los problemas de transporte y de ritmo; ExchangeError, los de la petición o la cuenta.

BaseErrorExchangeErrorOperationFailedNetworkErrorInsufficientFundsInvalidOrderOrderNotFoundDuplicateOrderIdRequestTimeoutRateLimitExceeded
Fuente Mermaid
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 = {
    "sin saldo": lambda: alfa.create_order("XBQ/USDQ", "market",
                                           "buy", 50),
    "demasiado pequeña": lambda: alfa.create_order(
        "XBQ/USDQ", "market", "buy", 0.0001),
    "orden inexistente": lambda: alfa.cancel_order("A-999999"),
    "símbolo que no existe": lambda: alfa.fetch_order_book("XZZ/USDQ"),
    "secreto equivocado": 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:>21}: {type(e).__name__:<19} red={red}")
OUT [10]Errores tipados: lo que dice el exchange y lo que dice ccxt
            sin saldo: InsufficientFunds   red=False
    demasiado pequeña: InvalidOrder        red=False
    orden inexistente: OrderNotFound       red=False
símbolo que no existe: BadSymbol           red=False
   secreto equivocado: AuthenticationError red=False

Cuatro de los cinco errores los devuelve el exchange y los traduce handle_errors; el del símbolo que no existe lo detecta ccxt sin enviar nada, porque market_id busca en los mercados cargados. Ninguno es de red: repetir cualquiera de ellas da el mismo error. El manual de ccxt lo dice para su mecanismo de reintentos: solo repite los fallos de red o de servidor, nunca un InsufficientFunds o un InvalidOrder1.

Límites de peticiones

El servidor admite ráfagas de 5 peticiones y recarga 5 fichas por segundo. Sin limitador, el cliente lo agota enseguida; con el limitador de ccxt a 250 ms entre peticiones, nunca:

IN [11]python
cenit.load_markets()
esperar(1_000)                     # el cubo de cenit, lleno
rapido = conectar("cenit", enableRateLimit=False)
rapido.set_markets(cenit.markets)  # sin petición de mercados
ok = 0
try:
    for _ in range(20):
        rapido.fetch_order_book("XBQ/USDQ")
        ok += 1
except ccxt.RateLimitExceeded as e:
    print(f"sin limitador: {ok} respuestas y después "
          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"con limitador: {ok} respuestas en "
      f"{(RELOJ['ms'] - t0) / 1000:.2f} s de reloj lógico")
OUT [11]Ráfaga sin limitador y ritmo con limitador
sin limitador: 5 respuestas y después RateLimitExceeded
con limitador: 12 respuestas en 3.00 s de reloj lógico
Fig. — Fichas que quedan en el cubo del servidor simulado (5 de capacidad, 5 por segundo) tras cada una de 20 peticiones: en ráfaga y con el limitador de ccxt a 250 ms (esquema)

Cinco respuestas y después un 429, que handle_errors convierte en RateLimitExceeded. Con el limitador, doce peticiones seguidas sin un rechazo: el limitador espera 250 ms antes de cada una, 3 s en total, y el cubo recupera 1,25 fichas por petición. En ccxt real esa espera es un time.sleep; aquí, para que el resultado no dependa de la carga de la máquina, el adaptador redefine throttle y avanza el reloj lógico la misma cantidad. En un exchange real el límite no es un número fijo: depende del tipo de petición, del nivel de la cuenta y a veces del peso de cada ruta (la lección 3.7.3 resume el sistema de créditos de Deribit). ccxt acepta un cost por ruta para modelar esos pesos; lo seguro es dejar el limitador activado y tratar el 429 como un aviso de que el ritmo propio está mal ajustado, no como un error raro.

Un timeout no dice si la orden existe

ccxt puede reintentar solo las peticiones que fallan por red: options["maxRetriesOnFailure"] fija cuántas veces1. Para una consulta es cómodo. Para una orden, peligroso. En la celda, el servidor crea la orden y no responde hasta que la celda lo libera; el cliente espera 500 ms, recibe RequestTimeout y reintenta:

IN [12]python
lento = conectar("cenit", timeout=500,
                 options={"maxRetriesOnFailure": 1})
lento.load_markets()
pausa = threading.Event()
Api.venues["cenit"].retener = pausa   # la próxima orden, sin respuesta
o = lento.create_order("XEQ/USDQ", "limit", "buy", 1.0, 3_000)
pausa.set()                           # libera el hilo del servidor
abiertas = cenit.fetch_open_orders()
print("devuelta:", o["id"], "| abiertas:",
      [x["id"] for x in abiertas])
for x in abiertas:
    cenit.cancel_order(x["id"])
OUT [12]Un timeout con reintento automático duplica la orden
devuelta: C-000002 | abiertas: ['C-000001', 'C-000002']

ccxt devuelve C-000002 como si todo hubiera ido bien, pero en el exchange hay dos órdenes abiertas: la del primer intento, que se creó aunque la respuesta no llegara, y la del reintento. Con dinero de verdad, una posición doble.

La solución es la idempotencia: cada orden lleva un identificador propio (clientOrderId), el mismo en todos los intentos. Si el primer intento llegó, el exchange rechaza el segundo por duplicado y basta con consultar la orden por ese identificador:

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"intento {i + 1}: {type(e).__name__}")
            esperar(300)
    raise RuntimeError("sin respuesta")

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("orden:", o["id"], o["status"], o["clientOrderId"])
print("abiertas:", [x["id"] for x in cenit.fetch_open_orders()])
pausa.set()
cenit.cancel_order(o["id"]);
OUT [13]Idempotencia: el mismo clientOrderId en cada intento
intento 1: RequestTimeout
orden: C-000003 open mr-2024-03-05-001
abiertas: ['C-000003']

Un timeout, un rechazo por duplicado y una consulta: una sola orden abierta, la C-000003. El manual de ccxt advierte que clientOrderId solo existe en los exchanges que lo admiten1; donde no lo hay, la alternativa es conciliar con fetch_open_orders antes de reenviar. En los dos casos el reintento automático de ccxt debe quedar desactivado para las órdenes.

Varios exchanges a la vez

Con un objeto por exchange, el código es el mismo para los dos. Para decidir dónde comprar no basta el mejor ask: cuenta el precio efectivo, que recorre la profundidad del libro y suma la comisión taker de cada mercado:

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")            # el libro no llega
    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]Dos exchanges: dónde comprar, con comisiones
venue         alfa     cenit
cantidad
0.1       65341.01  65300.06
0.5       65346.23  65305.29
1.5       65356.68  65315.73

La figura de la cabecera dibuja la misma cuenta para tamaños de 0,05 a 3 XBQ. Cenit es más barato en todo el rango, unos 41 USDQ por XBQ (6 pb): su medio es 8 USDQ menor y su comisión, la mitad. Con libros de distinta profundidad, las curvas podrían cruzarse y la mejor elección cambiaría con el tamaño; por eso un enrutador decide orden a orden con los libros del momento. Después, el saldo se consolida por moneda:

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]Saldo consolidado
            alfa     cenit        total
USDQ  67326.8845  100000.0  167326.8845
XBQ       1.0000       0.5       1.5000
XEQ      10.0000      10.0      20.0000

La consolidación es una suma, pero el saldo no es uno: los 67.327 USDQ de alfa y los 100.000 de cenit son créditos contra dos empresas distintas. Si una suspende las retiradas, como dedalo en los datos (lección 3.7.2), su parte deja de estar disponible aunque siga apareciendo en la tabla. Por eso un sistema multi-exchange limita lo que deja en cada uno y vigila sus incidencias.

Websockets con ccxt.pro

Consultar el libro por REST en bucle gasta peticiones y llega tarde. Los exchanges publican el libro por websocket: una foto completa al suscribirse y después solo los cambios (deltas), cada uno con un número de secuencia. CCXT Pro, que hoy es una parte gratuita de ccxt, añade los métodos watch_* (watch_order_book, watch_ticker, watch_trades, watch_balance, watch_orders…) con la misma interfaz que los REST y estructuras que se actualizan de forma incremental2Nota 2 · Manual de CCXT Pro: «CCXT Pro is a free part of CCXT that adds support for WebSocket streaming», API unificada watch_* y estructuras incrementales, github.com/ccxt/ccxt/wiki/ccxt.pro.manual, consultado el 22/10/2026..

El servidor local de websockets envía, a cada suscripción, una foto del libro de alfa y once deltas. Si la suscripción pide un hueco, se salta un delta, como cuando se pierde un mensaje en una reconexión:

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]

El adaptador asíncrono hereda de ccxt.pro.Exchange y reutiliza Comun. watch_order_book se suscribe y espera el siguiente libro; handle_message recibe cada mensaje, aplica la foto o el delta al libro local (self.order_book, la estructura incremental de ccxt.pro) y comprueba que la secuencia sea la siguiente:

fotodelta seq + 1huecodelta sin fotoSinLibroAlDia
Fuente Mermaid
stateDiagram-v2
  [*] --> SinLibro
  SinLibro --> AlDia: foto
  AlDia --> AlDia: delta seq + 1
  AlDia --> SinLibro: hueco
  SinLibro --> SinLibro: delta sin foto
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 sin foto: se ignoran
        else:
            ob = self.orderbooks[symbol]
            if msg["seq"] != ob["nonce"] + 1:
                del self.orderbooks[symbol]
                client.subscriptions.pop(h, None)
                client.reject(InvalidNonce(
                    f"esperaba {ob['nonce'] + 1}, "
                    f"llegó {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("aplicados:", pro.aplicados)
OUT [17]watch_order_book con ccxt.pro y control de secuencia
InvalidNonce: esperaba 7, llegó 8
aplicados: [1, 2, 3, 4, 5, 6]

El cliente aplica la foto y los deltas 2 a 6. Al llegar el 8 sin el 7, no lo aplica: borra el libro, olvida la suscripción y rechaza el watch pendiente con InvalidNonce, la excepción de ccxt para secuencias rotas. El bucle termina con una excepción en lugar de seguir con un libro que ya no es el del exchange. Volver a suscribirse trae una foto nueva:

IN [18]python
pro.aplicados.clear()
libro = lambda: pro.orderbooks.get("XBQ/USDQ")
while libro() is None or libro()["nonce"] < 12:   # el estado manda
    await asyncio.wait_for(pro.watch_order_book("XBQ/USDQ"), 30)
ob = libro()
print("aplicados:", pro.aplicados)
print("mejor bid", ob["bids"][0], "| mejor ask", ob["asks"][0])
await pro.close()
ws_srv.close()
await ws_srv.wait_closed()
OUT [18]Resuscribirse: foto nueva y libro al día
aplicados: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
mejor bid [65262.67, 0.3] | mejor ask [65275.73, 0.3985]

Los deltas que quedaban de la primera suscripción llegan sin libro y se ignoran; después llegan la foto y los once deltas de la segunda, y el libro queda al día con la secuencia 12. El mejor ask cambió de 0,3 a 0,3985 XBQ con uno de los deltas. Con exchanges reales, ccxt.pro hace este trabajo dentro de cada clase (cada exchange numera a su manera); la regla es la misma: un libro local solo sirve si se puede demostrar que no le falta ningún mensaje.

Lo que cambia con un exchange real

El código de las celdas sirve casi sin cambios para un exchange real: cambia la clase y aparecen las claves. Este es el esqueleto, sin ejecutar porque necesita red y una cuenta:

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},   # nunca en órdenes
})
ex.set_sandbox_mode(True)        # entorno de pruebas, si lo hay
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()

Cuatro precauciones que la simulación enseña y un exchange real exige:

  • Claves con permisos mínimos. Solo lectura para datos y saldo; operar sin retirada para el sistema de trading; y claves en variables de entorno o en un gestor de secretos, nunca en el código.
  • Entorno de pruebas primero. set_sandbox_mode(True) cambia las URL al entorno de pruebas del exchange si existe, y hay que llamarlo justo después de crear el objeto, antes de cualquier otra llamada1.
  • Conciliación al arrancar. Saldos, órdenes abiertas y posiciones se leen del exchange cada vez que el sistema arranca o pierde la conexión.
  • Un exchange es una contraparte. La lección 3.7.2 mide lo que pasa cuando uno quiebra; en la UE, la lección 3.7.5 explica qué exige MiCA a los proveedores de servicios de criptoactivos.

Ejercicios

  1. Añade al cliente síncrono un fetch_ticker que construya un ticker unificado (bid, ask, last como punto medio) desde public/depth, y úsalo para comparar la horquilla en puntos básicos de XBQ y XEQ en los dos exchanges.
Solución
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" horquilla {pb:.2f} pb")
OUT [20]Un fetch_ticker desde el libro
 alfa XBQ/USDQ: bid 65,262.67 ask 65,275.73 horquilla 2.00 pb
 alfa XEQ/USDQ: bid 3,470.80 ask 3,471.50 horquilla 2.02 pb
cenit XBQ/USDQ: bid 65,254.37 ask 65,267.43 horquilla 2.00 pb
cenit XEQ/USDQ: bid 3,468.34 ask 3,469.04 horquilla 2.02 pb

En los cuatro mercados la horquilla es de 2 pb (2,02 en XEQ por el redondeo al tick de 0,01 sobre un precio de unos 3.470): los libros del simulador tienen la misma forma en los dos exchanges y solo cambia el medio. En exchanges reales la horquilla y la profundidad son distintas en cada uno y cambian con el día y la hora, y por eso el enrutado se decide con el libro del momento. safe_ticker rellena los campos que faltan (high, low, vwap…) con None: la estructura es la unificada aunque el exchange no los dé.

  1. Vende en alfa, con una orden de mercado, todo el XBQ que tengas y mide el deslizamiento frente al medio. Después intenta vender 2 XBQ más y comprueba qué excepción llega y si es reintentable.
Solución
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"vendidos {v['filled']} XBQ a {v['average']:,.2f}: "
      f"{desl:.1f} pb por debajo del medio")
try:
    alfa.create_order("XBQ/USDQ", "market", "sell", 2)
except ccxt.BaseError as e:
    print(type(e).__name__, "| reintentable:",
          isinstance(e, ccxt.NetworkError))
OUT [21]Vender todo y pasarse
vendidos 1.0 XBQ a 65,252.23: 2.6 pb por debajo del medio
InsufficientFunds | reintentable: False

Se vende 1,0 XBQ (los 0,5 iniciales y los 0,5 comprados) contra tres niveles del bid, a un precio medio de 65.252,23 USDQ, 2,6 pb por debajo del medio, a lo que se suma la comisión del 0,10 %. La segunda venta lanza InsufficientFunds, que desciende de ExchangeError: reintentarla daría el mismo error, porque el problema es el saldo, no la red.

  1. Escribe la política de reintentos de un sistema que opera en varios exchanges: qué excepciones se reintentan, con qué espera, y qué hace con create_order y cancel_order en cada caso.
Solución

Por ramas de la jerarquía. RateLimitExceeded y DDoSProtection: esperar (con espera exponencial y algo de azar, lección 1.4.1) y bajar el ritmo propio; no es un fallo puntual sino un ritmo mal ajustado. RequestTimeout, ExchangeNotAvailable y el resto de NetworkError: en consultas (libro, saldo, órdenes abiertas), reintentar con espera y un máximo; en create_order, no reintentar a ciegas: reenviar con el mismo clientOrderId (el exchange rechaza el duplicado) o, si no lo admite, consultar las órdenes abiertas antes de decidir; en cancel_order, repetir es seguro, y un OrderNotFound posterior suele significar que la orden ya se canceló o se ejecutó, así que se consulta su estado. InsufficientFunds, InvalidOrder, BadSymbol y AuthenticationError: no reintentar; registrar, alertar y revisar la lógica o la configuración. Además, el reintento automático de ccxt (maxRetriesOnFailure) a cero para las órdenes, y una conciliación completa (saldos y órdenes abiertas en cada exchange) al arrancar y tras cualquier racha de errores de red.

Footnotes

  1. Manual de ccxt: propiedades rateLimit, enableRateLimit (activado por defecto) y timeout (10.000 ms por defecto, RequestTimeout al agotarse), estructura de orden y clientOrderId («solo en los exchanges que lo admiten»), mecanismo de reintentos (maxRetriesOnFailure, solo fallos de red o de servidor), jerarquía de excepciones y set_sandbox_mode, docs.ccxt.com y github.com/ccxt/ccxt/wiki/Manual, consultados el 22/10/2026. Licencia MIT según el repositorio de GitHub. La jerarquía del diagrama es la de la versión 4.5.89 instalada (ccxt.base.errors). ↩ ↩2 ↩3 ↩4 ↩5 ↩6

  2. Manual de CCXT Pro: «CCXT Pro is a free part of CCXT that adds support for WebSocket streaming», API unificada watch_* y estructuras incrementales, github.com/ccxt/ccxt/wiki/ccxt.pro.manual, consultado el 22/10/2026. ↩

Respuesta plegada en cada pregunta

Autoevaluación

  1. Una orden de compra recibe un RequestTimeout. ¿Qué es lo correcto?

    • AReenviarla enseguida: si dio timeout, no llegó
    • BConsultar la orden por su clientOrderId (o reenviarla con el mismo) antes de dar nada por hecho: el exchange pudo haberla aceptado
    • CEsperar al día siguiente
    • DSubir timeout a 60 segundos y no hacer nada más
    Ver respuesta

    Respuesta B. Consultar la orden por su clientOrderId (o reenviarla con el mismo) antes de dar nada por hecho: el exchange pudo haberla aceptado

    Un timeout dice que no llegó la respuesta, no que no llegara la orden. En la lección, el reintento automático de ccxt (maxRetriesOnFailure) creó dos órdenes; con el mismo clientOrderId en cada intento, el exchange rechazó la segunda como duplicada y la consulta devolvió la única que había.

  2. ¿Cuál de estos errores tiene sentido reintentar tal cual, tras una espera?

    • AInsufficientFunds
    • BAuthenticationError
    • CRateLimitExceeded
    • DInvalidOrder
    Ver respuesta

    Respuesta C. RateLimitExceeded

    RateLimitExceeded desciende de NetworkError: es un problema de ritmo y se resuelve esperando. Los otros tres descienden de ExchangeError: la petición está mal o la cuenta no puede hacerla, y repetirla da el mismo error.

  3. En el simulador, el límite es un cubo de 5 fichas que se recarga a 5 por segundo. ¿Qué ritmo del limitador de ccxt evita los 429 de forma sostenida?

    • ArateLimit = 100 ms (10 peticiones por segundo)
    • BrateLimit = 200 ms, justo en el límite
    • CrateLimit = 250 ms (4 por segundo), por debajo de la recarga
    • DDesactivar el limitador y capturar los 429
    Ver respuesta

    Respuesta C. rateLimit = 250 ms (4 por segundo), por debajo de la recarga

    Con 4 peticiones por segundo contra una recarga de 5, el cubo nunca se vacía: las 20 peticiones de la figura se aceptan. A 200 ms se va justo y cualquier variación de latencia acaba en un rechazo; a 100 ms se agota en un segundo.

  4. En el websocket, tras el delta con secuencia 6 llega el 8. ¿Qué hace el cliente de la lección?

    • AAplica el 8 y sigue
    • BDescarta el libro, rechaza la suscripción con InvalidNonce y se vuelve a suscribir para recibir una foto nueva
    • CEspera a que llegue el 7
    • DInterpola el 7
    Ver respuesta

    Respuesta B. Descarta el libro, rechaza la suscripción con InvalidNonce y se vuelve a suscribir para recibir una foto nueva

    Sin el delta 7 el libro local ya no es el del exchange, y aplicar los siguientes encima lo dejaría mal sin avisar. Lo seguro es tirar el libro, ignorar los deltas sin foto y empezar de nuevo desde una foto completa.

  5. El mejor ask de cenit es 65.267,43 y el de alfa 65.275,73. ¿Basta eso para decidir dónde comprar 1,5 XBQ?

    • ASí: cenit es más barato
    • BNo: hay que recorrer la profundidad de cada libro y sumar la comisión taker de cada exchange, y además mirar si el saldo está en el exchange adecuado
    • CNo: siempre conviene el exchange con más volumen
    • DSí, porque las comisiones son iguales en todos
    Ver respuesta

    Respuesta B. No: hay que recorrer la profundidad de cada libro y sumar la comisión taker de cada exchange, y además mirar si el saldo está en el exchange adecuado

    El precio que se paga sale del libro entero y de la comisión. En la lección, cenit es más barato por las dos cosas (medio 8 USDQ menor y comisión de 0,05 % frente a 0,10 %), unos 41 USDQ por XBQ. Con otra comisión o menos profundidad la decisión podría cambiar con el tamaño.

Ideas clave

  • ccxt unifica símbolos (XBQ/USDQ), mercados con precisión y límites, y las estructuras de libro, saldo (free, used, total) y orden; lo propio de cada exchange se pasa en params.
  • Un adaptador de ccxt son cuatro piezas: describe (URL, límites, mapa de errores), sign (firma de las peticiones privadas), handle_errors (códigos nativos a excepciones) y los parse_* a las estructuras unificadas.
  • Los errores se separan por herencia: NetworkError (timeouts, ritmo, caídas) se reintenta con espera; ExchangeError (saldo, orden inválida, autenticación) no.
  • Un timeout en create_order no dice si la orden existe: el reintento automático duplicó la orden del simulador; el mismo clientOrderId en cada intento y una consulta posterior lo evitan.
  • Con varios exchanges, el precio que cuenta es el efectivo (libro y comisión) y el saldo hay que consolidarlo por moneda; el riesgo de contraparte sigue siendo de cada exchange (lección 3.7.2).
  • ccxt.pro (parte gratuita de ccxt) da watch_* por websocket; un libro local solo vale si se controla la secuencia y se vuelve a la foto ante un hueco.

Recursos