---
title: "3.7.4 ccxt para varios exchanges"
description: "ccxt ejecutado de verdad contra dos exchanges simulados: mercados, saldos, órdenes, errores tipados, límites, idempotencia y websockets con ccxt.pro."
url: "https://pythonparatrading.com/curso-python-trading/nivel-3/ccxt-multi-exchange/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-3/ccxt-multi-exchange.png"
---

# 3.7.4 · 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.

- Curso: [Nivel 3: Derivados: futuros, spreads, forex y opciones](https://pythonparatrading.com/curso-python-trading/nivel-3/)
- Módulo: 3.7 · Cripto derivados y estrategias específicas
- Duración: 75 min
- Requisitos: [1.3.5 · Datos de cripto y on-chain](https://pythonparatrading.com/curso-python-trading/nivel-1/datos-de-cripto-y-on-chain/), [1.4.1 · Descarga por REST y websockets: paginación, límites y reintentos](https://pythonparatrading.com/curso-python-trading/nivel-1/descarga-rest-websockets-y-reintentos/), [3.7.2 · Arbitraje de funding, basis con futuros y arbitraje entre exchanges](https://pythonparatrading.com/curso-python-trading/nivel-3/arbitraje-de-funding-basis-y-entre-exchanges/)

## Al terminar esta lección

- Explicar qué unifica ccxt (símbolos, mercados, precisión, estructuras de libro, saldo y orden, errores) y qué sigue dependiendo de cada exchange.
- Escribir un adaptador de ccxt (`describe`, `sign`, `handle_errors`, `parse_*`) y usar los métodos unificados contra un servidor local.
- Clasificar los errores de ccxt en reintentables y no reintentables, y enviar órdenes idempotentes con `clientOrderId` tras un timeout.
- Respetar los límites de peticiones con el limitador de ccxt y comparar precio efectivo y saldo entre varios exchanges.
- Mantener un libro por websocket con ccxt.pro: foto, deltas, control de secuencia y resuscripción.

## 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 todos[^1]. 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.

```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):

```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
```
```output
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:

```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.

```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`).

```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:

```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)
```
```output
(<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 `RequestTimeout`[^1]. 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:

```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"))
```
```output
['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

```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"]})
```
```output
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:

```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]}")
```
```output
      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:

```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"])
```
```output
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:

```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.

```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]
```

```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}")
```
```output
            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 `InvalidOrder`[^1].

## 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:

```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")
```
```output
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).* Dos series sobre el número de petición, de 1 a 20. En ráfaga (una petición cada 2 ms) las fichas bajan de 4 a 0 en las cinco primeras y las 15 siguientes se rechazan con un 429 (destacadas, en el cero). Con una petición cada 250 ms el cubo se queda en 4 fichas tras cada petición y se aceptan las 20.

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 veces[^1].
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:

```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"])
```
```output
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:

```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"]);
```
```output
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 admiten[^1]; 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:

```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)
```
```output
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:

```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)
```
```output
            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 incremental[^2].

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:

```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:

```mermaid
stateDiagram-v2
  [*] --> SinLibro
  SinLibro --> AlDia: foto
  AlDia --> AlDia: delta seq + 1
  AlDia --> SinLibro: hueco
  SinLibro --> SinLibro: delta sin foto
```

```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)
```
```output
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:

```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()
```
```output
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:

```python skip title="exchange_real.py"
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 llamada[^1].
- **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.**

```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")
```
```output
 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é.

2. 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.**

```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))
```
```output
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.

3. 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.

[^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](https://docs.ccxt.com/docs/manual) y
    [github.com/ccxt/ccxt/wiki/Manual](https://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]: 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](https://github.com/ccxt/ccxt/wiki/ccxt.pro.manual), consultado el
    22/10/2026.

## Autoevaluación

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

   - A. Reenviarla enseguida: si dio timeout, no llegó
   - B. Consultar la orden por su `clientOrderId` (o reenviarla con el mismo) antes de dar nada por hecho: el exchange pudo haberla aceptado
   - C. Esperar al día siguiente
   - D. Subir `timeout` a 60 segundos y no hacer nada más

   *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?

   - A. `InsufficientFunds`
   - B. `AuthenticationError`
   - C. `RateLimitExceeded`
   - D. `InvalidOrder`

   *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?

   - A. `rateLimit = 100` ms (10 peticiones por segundo)
   - B. `rateLimit = 200` ms, justo en el límite
   - C. `rateLimit = 250` ms (4 por segundo), por debajo de la recarga
   - D. Desactivar el limitador y capturar los 429

   *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?

   - A. Aplica el 8 y sigue
   - B. Descarta el libro, rechaza la suscripción con `InvalidNonce` y se vuelve a suscribir para recibir una foto nueva
   - C. Espera a que llegue el 7
   - D. Interpola el 7

   *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?

   - A. Sí: cenit es más barato
   - 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
   - C. No: siempre conviene el exchange con más volumen
   - D. Sí, porque las comisiones son iguales en todos

   *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

### Research

- [Máquinas de Estado Finito para Ejecución de Trading Cuantitativo en Python](https://pythonparatrading.com/maquinas-estado-finito-trading-python/) · Research-0029 · 49 min
- [Modelos Cuantitativos y Ejecución Algorítmica](https://pythonparatrading.com/modelos-cuantitativos-ejecucion-algoritmica/) · Research-0009 · 11 min

### Documentación

- [ccxt: manual (API unificada, límites, errores, clientOrderId, reintentos y sandbox)](https://docs.ccxt.com/docs/manual) · docs.ccxt.com
- [ccxt: manual de CCXT Pro (websockets, watch_order_book, estructuras incrementales)](https://github.com/ccxt/ccxt/wiki/ccxt.pro.manual) · github.com
- [websockets: servidor asyncio (referencia)](https://websockets.readthedocs.io/en/stable/reference/asyncio/server.html) · websockets.readthedocs.io

### Datos

- [Datos del nivel 3: cierres diarios por exchange de XBQ y XEQ (Parquet)](https://pythonparatrading.com/curso/datos/nivel-3/cripto3_diario.parquet) · Descarga · cripto3_diario.parquet

Lección anterior: [3.7.3 · Opciones cripto, datos on-chain y DeFi](https://pythonparatrading.com/curso-python-trading/nivel-3/opciones-cripto-deribit-on-chain-y-defi/)

Lección siguiente: [3.7.5 · MiCA: el marco de la UE para criptoactivos y sus proveedores (CASP)](https://pythonparatrading.com/curso-python-trading/nivel-3/mica-y-casp-en-la-ue/)
