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.
- Módulo
- 3.7 · Cripto derivados y estrategias específicas
- Duración
- 75 min
Lecciones del nivel 3
3.1 · Futuros en profundidad
3.2 · Spreads de futuros
- 3.2.1 Spreads de calendario, butterflies y condors
- 3.2.2 Spreads intercommodity: crack, spark y crush
- 3.2.3 Spreads de tipos y de índices: 2s10s, NOB, NQ/ES y Europa frente a EE. UU.
- 3.2.4 Ratios de cobertura: por nominal, por DV01 y por volatilidad
- 3.2.5 Datos y backtesting de spreads: cotizados, sintéticos y por patas
- 3.2.6 Estacionalidad de spreads: análisis multi-año, patrones y trampas
3.3 · Forex avanzado
3.4 · Teoría y valoración de opciones
- 3.4.1 Payoffs, paridad put-call y límites de arbitraje
- 3.4.2 Árboles binomiales, Black-Scholes-Merton, Black-76 y Monte Carlo
- 3.4.3 Griegas: delta, gamma, vega, theta, rho, vanna, volga y charm
- 3.4.4 Volatilidad implícita: Newton-Raphson, Brent, py_vollib y QuantLib
- 3.4.5 Sonrisa, skew y estructura temporal; superficie de volatilidad con SVI
- 3.4.6 Volatilidad realizada frente a implícita; prima de riesgo de volatilidad; VIX y VSTOXX
- 3.4.7 Modelos avanzados: Heston, SABR y volatilidad local
3.5 · Estructuras de opciones en Python
- 3.5.1 Estructuras direccionales y verticales: call, put, covered call, collar y spreads
- 3.5.2 Estructuras de volatilidad y rango: straddles, strangles, cóndores y mariposas
- 3.5.3 Calendarios, diagonales, ratios, backspreads, risk reversals y jade lizard
- 3.5.4 Herramienta propia: payoff, griegas agregadas y escenarios
- 3.5.5 Gestión: ajustes, roll, cierre anticipado, asignación temprana y pin risk
3.6 · Estrategias sistemáticas de volatilidad y opciones
3.7 · Cripto derivados y estrategias específicas
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.
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):
import hashlib
import hmac
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
import pandas as pd
dia = pd.read_parquet("cripto3_diario.parquet")
cierre = (dia[dia["fecha"] == "2024-03-04"]
.pivot(index="exchange", columns="activo",
values="cierre_spot")
.loc[["alfa", "cenit"]])
cierreactivo XBQ XEQ
exchange
alfa 65269.2 3471.15
cenit 65260.9 3468.69Un 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:
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, oLa 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.
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).
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:
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)(<class '__main__.Comun'>, <class 'ccxt.base.exchange.Exchange'>)
250 True 10000rateLimit 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:
mk = alfa.load_markets()
m = mk["XBQ/USDQ"]
print(list(mk), m["id"], m["taker"])
print(m["precision"], m["limits"]["cost"])
print(alfa.amount_to_precision("XBQ/USDQ", 0.123456789),
alfa.price_to_precision("XBQ/USDQ", 65_270.12999))
print(alfa.market_id("XEQ/USDQ"), alfa.safe_symbol("XEQUSDQ"))['XBQ/USDQ', 'XEQ/USDQ'] XBQUSDQ 0.001
{'price': 0.01, 'amount': 0.0001} {'min': 10.0, 'max': None}
0.1234 65270.13
XEQUSDQ XEQ/USDQamount_to_precision 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
ob = alfa.fetch_order_book("XBQ/USDQ", limit=3)
print("bids", ob["bids"])
print("asks", ob["asks"])
print(ob["datetime"], ob["nonce"])
saldo = alfa.fetch_balance()
print(saldo["USDQ"])
print({k: saldo["total"][k] for k in ["USDQ", "XBQ", "XEQ"]})bids [[65262.67, 0.3], [65249.62, 0.6], [65236.57, 0.9]]
asks [[65275.73, 0.3], [65288.78, 0.6], [65301.83, 0.9]]
2024-03-05T00:00:00.250Z None
{'free': 100000.0, 'used': 0.0, 'total': 100000.0}
{'USDQ': 100000.0, 'XBQ': 0.5, 'XEQ': 10.0}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:
o = alfa.create_order("XBQ/USDQ", "market", "buy", 0.5)
campos = ["id", "status", "side", "amount", "filled", "average",
"cost", "fee"]
for k in campos:
print(f"{k:>8}: {o[k]}") id: A-000001
status: closed
side: buy
amount: 0.5
filled: 0.5
average: 65280.95
cost: 32640.475
fee: {'cost': '32.640475', 'currency': 'USDQ'}0,3 XBQ 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:
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"])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:
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.
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]
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}") 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=FalseCuatro 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:
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")sin limitador: 5 respuestas y después RateLimitExceeded
con limitador: 12 respuestas en 3.00 s de reloj lógicoCinco 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:
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"])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:
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"]);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:
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)venue alfa cenit
cantidad
0.1 65341.01 65300.06
0.5 65346.23 65305.29
1.5 65356.68 65315.73La 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:
consolidado = pd.DataFrame(
{n: ex.fetch_balance()["total"] for n, ex in venues.items()}
).loc[["USDQ", "XBQ", "XEQ"]]
consolidado["total"] = consolidado.sum(axis=1)
consolidado.round(4) alfa cenit total
USDQ 67326.8845 100000.0 167326.8845
XBQ 1.0000 0.5 1.5000
XEQ 10.0000 10.0 20.0000La 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:
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:
Fuente Mermaid
stateDiagram-v2 [*] --> SinLibro SinLibro --> AlDia: foto AlDia --> AlDia: delta seq + 1 AlDia --> SinLibro: hueco SinLibro --> SinLibro: delta sin foto
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)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:
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()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:
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
- Añade al cliente síncrono un
fetch_tickerque construya un ticker unificado (bid,ask,lastcomo punto medio) desdepublic/depth, y úsalo para comparar la horquilla en puntos básicos de XBQ y XEQ en los dos exchanges.
Solución
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") 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 pbEn 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é.
- 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
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))vendidos 1.0 XBQ a 65,252.23: 2.6 pb por debajo del medio
InsufficientFunds | reintentable: FalseSe 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.
- 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_orderycancel_orderen 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
-
Manual de ccxt: propiedades
rateLimit,enableRateLimit(activado por defecto) ytimeout(10.000 ms por defecto,RequestTimeoutal agotarse), estructura de orden yclientOrderId(«solo en los exchanges que lo admiten»), mecanismo de reintentos (maxRetriesOnFailure, solo fallos de red o de servidor), jerarquía de excepciones yset_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 -
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
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
timeouta 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 aceptadoUn 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 mismoclientOrderIden cada intento, el exchange rechazó la segunda como duplicada y la consulta devolvió la única que había.¿Cuál de estos errores tiene sentido reintentar tal cual, tras una espera?
- A
InsufficientFunds - B
AuthenticationError - C
RateLimitExceeded - D
InvalidOrder
Ver respuesta
Respuesta C.
RateLimitExceededRateLimitExceededdesciende deNetworkError: es un problema de ritmo y se resuelve esperando. Los otros tres descienden deExchangeError: la petición está mal o la cuenta no puede hacerla, y repetirla da el mismo error.- A
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 = 100ms (10 peticiones por segundo) - B
rateLimit = 200ms, justo en el límite - C
rateLimit = 250ms (4 por segundo), por debajo de la recarga - DDesactivar el limitador y capturar los 429
Ver respuesta
Respuesta C.
rateLimit = 250ms (4 por segundo), por debajo de la recargaCon 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.
- A
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
InvalidNoncey 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
InvalidNoncey se vuelve a suscribir para recibir una foto nuevaSin 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.
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 enparams. - 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 losparse_*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_orderno dice si la orden existe: el reintento automático duplicó la orden del simulador; el mismoclientOrderIden 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.