Lección 4.4.4 · 75 min
APIs de exchanges cripto: ccxt, Binance, Bybit, Kraken y Coinbase
ccxt firma las peticiones por ti, pero cuando una orden se rechaza por la firma, la hora o el nonce, hay que saber qué firma. Esta lección implementa los cuatro esquemas de autenticación de Binance, Bybit, Kraken y Coinbase, los comprueba con los vectores de su documentación y los ejecuta contra un servidor local con reloj lógico: ventanas de tiempo, nonces compartidos y el stream de usuario de Binance con la orden del ejemplo.
- Módulo
- 4.4 · Brokers y APIs
- Duración
- 75 min
Lecciones del nivel 4
4.1 · Teoría y optimización de carteras
- 4.1.1 Markowitz: frontera eficiente y restricciones reales
- 4.1.2 Estimación de covarianzas: shrinkage, modelos factoriales y denoising
- 4.1.3 Black-Litterman: del equilibrio del mercado a las visiones
- 4.1.4 Risk parity, mínima varianza, HRP y HERC
- 4.1.5 Optimización con CVaR, drawdown, rotación y costes
- 4.1.6 Modelos factoriales: CAPM, Fama-French, PCA y atribución
- 4.1.7 Rebalanceo y carteras de estrategias
- 4.1.8 Herramientas de carteras: Riskfolio-Lib, PyPortfolioOpt, skfolio y bt
4.2 · Gestión del riesgo y tamaño de posición
- 4.2.1 Riesgo por operación, stops y riesgo de ruina
- 4.2.2 Criterio de Kelly y volatility targeting: cuánto arriesgar
- 4.2.3 VaR, Expected Shortfall y backtesting del VaR
- 4.2.4 Stress testing y riesgos de correlación, liquidez, gap y contraparte
- 4.2.5 Control del drawdown y kill switch
- 4.2.6 Riesgo de derivados: griegas de cartera, margen y liquidaciones
4.3 · Microestructura y ejecución
4.4 · Brokers y APIs
- 4.4.1 Elegir bróker desde España o la UE: regulación, activos, costes y API
- 4.4.2 Interactive Brokers: TWS e IB Gateway, ib_async, contratos, datos y órdenes
- 4.4.3 Alpaca, Tradier, MetaTrader 5 con Python y OANDA v20
- 4.4.4 APIs de exchanges cripto: ccxt, Binance, Bybit, Kraken y Coinbase
- 4.4.5 Credenciales seguras: variables de entorno, gestores de secretos y permisos mínimos
4.5 · Sistema de trading en vivo
- 4.5.1 Arquitectura de un sistema en vivo: de los datos a la reconciliación
- 4.5.2 Gestión de órdenes: estados, fills parciales, cancelaciones y reconexiones
- 4.5.3 Paper trading, shadow trading y seguimiento del slippage
- 4.5.4 Programación de tareas y alertas: cron, APScheduler, Prefect, Telegram, Slack y email
- 4.5.5 Tolerancia a fallos: idempotencia, estado persistente y reconciliación al arrancar
- 4.5.6 NautilusTrader o LEAN en real: del backtest a producción con el mismo código
Lo que ccxt hace por debajo
La lección 3.7.4 usó ccxt contra dos exchanges simulados: mercados, órdenes, errores tipados, límites, idempotencia
con clientOrderId y libros por websocket. ccxt traduce cada llamada a la API nativa del exchange: construye la
ruta, añade la hora, firma con la clave y lee la respuesta. Casi siempre basta. Hace falta conocer la API nativa en
tres casos: cuando una petición se rechaza por la firma, la hora o el nonce y hay que entender por qué; cuando el
exchange ofrece algo que ccxt no unifica (el stream de usuario de cada exchange, modos de prevención de auto-casación,
órdenes por websocket); y cuando la latencia o el control de cada byte importan.
El método sign de una clase de ccxt construye la petición sin enviarla, así que se puede ver lo que mandaría a
Binance sin red ni cuenta. Las claves son falsas y la hora, fija:
import ccxt
ex = ccxt.binance({"apiKey": "FALSA-CLAVE", "secret": "FALSA-SECRETO"})
ex.nonce = lambda: 1_760_449_080_805 # hora fija, en ms
orden = {"symbol": "XSNUSDT", "side": "BUY", "type": "LIMIT",
"timeInForce": "GTC", "quantity": "2", "price": "61.25"}
peticion = ex.sign("order", ["private"], "POST", dict(orden))
print(peticion["method"], peticion["url"])
print("cabeceras:", sorted(peticion["headers"]))
campos = dict(kv.split("=") for kv in peticion["body"].split("&"))
print("campos del cuerpo:", list(campos))
print("recvWindow:", campos["recvWindow"],
"| prefijo del id inyectado:", campos["newClientOrderId"][:10])
propia = ex.sign("order", ["private"], "POST",
orden | {"newClientOrderId": "R4-cripto-0001"})
print("con id propio:", "newClientOrderId=R4-cripto-0001"
in propia["body"])POST https://api.binance.com/api/v3/order
cabeceras: ['Content-Type', 'X-MBX-APIKEY']
campos del cuerpo: ['timestamp', 'symbol', 'side', 'type', 'timeInForce', 'quantity', 'price', 'newClientOrderId', 'recvWindow', 'signature']
recvWindow: 10000 | prefijo del id inyectado: x-TKT5PX2F
con id propio: Trueccxt 4.5.89 manda a api.binance.com/api/v3/order un formulario con la hora (timestamp), los parámetros, una
ventana de validez (recvWindow de 10.000 ms, la opción por defecto de esta clase) y la firma al final, y pone la
clave en la cabecera X-MBX-APIKEY. Añade algo que no se pidió: un newClientOrderId con el prefijo x-TKT5PX2F,
que es el identificador de bróker de ccxt en Binance (ex.options["broker"]) seguido de una parte aleatoria. Si se
da un identificador propio, ccxt lo respeta y no inyecta nada. Para un sistema, el identificador propio no es
opcional: es el que permite consultar una orden tras un timeout sin duplicarla (lección 3.7.4).
Cuatro exchanges, cuatro esquemas de autenticación
| Binance spot | Bybit V5 | Kraken spot | Coinbase Advanced Trade | |
|---|---|---|---|---|
| Qué se firma | Cadena de parámetros (query + cuerpo) | timestamp + clave + recv_window + query o cuerpo JSON | Ruta + SHA-256(nonce + datos) | Un JWT con método, host y ruta |
| Algoritmo | HMAC-SHA256 (hex), RSA o Ed25519 (base64) | HMAC-SHA256 (hex) o RSA (base64) | HMAC-SHA512 con el secreto decodificado (base64) | ES256 (ECDSA P-256) |
| Dónde va | Parámetro signature; clave en X-MBX-APIKEY | Cabeceras X-BAPI-API-KEY, X-BAPI-TIMESTAMP, X-BAPI-RECV-WINDOW, X-BAPI-SIGN | Cabeceras API-Key y API-Sign | Authorization: Bearer <JWT> |
| Control del tiempo | recvWindow (5.000 ms por defecto, máximo 60.000) | recv_window (5.000 ms por defecto) | nonce siempre creciente por clave | nbf y exp = nbf + 120 s |
| Entorno de pruebas | testnet.binance.vision y modo demo (demo-api.binance.com) | api-testnet.bybit.com y demo (api-demo.bybit.com) | Sin entorno de pruebas de spot en la documentación consultada | Sandbox con respuestas estáticas |
Las fuentes están en las notas de cada sección1Nota 1 · Binance, documentación oficial de la API de spot (repositorio fuente de developers.binance.com): rest-api.md (endpoints firmados, HMAC, RSA, Ed25519, recvWindow, límites), web-socket-api.md (userDataStream.subscribe.signature, ping), user-data-stream.md (executionReport), errors.md (-1021, -1022), faqs/api_key_types.md y CHANGELOG.md (entradas del 2025-04-07, 2025-12-17 y 2026-01-21). Consultada el 22/10/2026.2Nota 2 · Bybit, Integration Guidance (autenticación), WebSocket Connect, Error codes, Rate limit y Demo trading; código de ejemplo oficial en api-usage-examples. Consultadas el 22/10/2026.3Nota 3 · Kraken, Spot REST authentication, API keys, errores de spot, executions (WebSocket v2) y GetWebSocketsToken. Consultadas el 22/10/2026.4Nota 4 · Coinbase, API key authentication, claves antiguas, WebSocket overview, canal user y sandbox. Consultadas el 22/10/2026.. Dos novedades recientes de Binance cambian código que
funcionaba: desde el 15 de enero de 2026 la carga se codifica por porcentaje antes de firmar (las peticiones que
no lo hagan se rechazan con -1022 INVALID_SIGNATURE), y la documentación de tipos de clave dice que las claves HMAC
están obsoletas y recomienda Ed25519. Coinbase, por su parte, dejó sin validez sus claves antiguas («legacy») en febrero de 2025:
Advanced Trade solo acepta el JWT, y su documentación pide claves ECDSA (ES256), no Ed25519.
Fuente Mermaid
flowchart TB
P[Parámetros] --> C[Cadena exacta]
R[Reloj o nonce] --> C
C --> F[Firma con secreto]
F --> H[Cabeceras o parámetro]
H --> S[Servidor recalcula]
S --> V{Válida y a tiempo}El servidor no descifra nada: recalcula la firma con su copia del secreto (o con la clave pública, en RSA, Ed25519 y ES256) sobre los bytes que recibe y compara. Cualquier diferencia entre la cadena firmada y la enviada (el orden de los parámetros, un espacio, una codificación) da una firma distinta.
Las firmas, contra los vectores de la documentación
Un vector de prueba es una entrada con su salida publicada: si el código reproduce la salida, implementa bien el esquema. Binance y Kraken publican ejemplos completos para HMAC, con una clave y un secreto de ejemplo que no son de ninguna cuenta. Las celdas no imprimen esos secretos: los usan y comparan la firma.
Binance: HMAC-SHA256 de la cadena de parámetros
La documentación de seguridad de los endpoints de Binance da la cadena de una orden límite de LTCBTC, su secreto y
la firma esperada, y un segundo ejemplo con un símbolo de caracteres no ASCII (seis dígitos de ancho
completo, de U+FF11 a U+FF16) que se codifica por porcentaje antes de firmar. La WebSocket API tiene otra regla: los parámetros se
ordenan alfabéticamente y se firma el texto en UTF-8, sin codificar1.
import hashlib
import hmac
from urllib.parse import quote
SECRETO_DOC = ("NhqPtmdSJYdKjVHjA7PZj4Mge3R5YNiP1e3UZjInClVN65XA"
"bvqqM6A7H5fATj0j") # secreto de ejemplo publicado
CLAVE_DOC = ("vmPUZE6mv9SD5VNHk4HlWFsOr6aKE2zvsw0MuIgwCIPy6utIco14"
"y7Ju91duEh8A") # clave de ejemplo publicada
def firma_binance(secreto, carga):
return hmac.new(secreto.encode(), carga.encode(),
hashlib.sha256).hexdigest()
NO_ASCII = "\uff11\uff12\uff13\uff14\uff15\uff16" # dígitos anchos
def carga_rest(params):
"""Pares en el orden dado, valores codificados por porcentaje."""
return "&".join(f"{k}={quote(str(v), safe='')}"
for k, v in params.items())
rest = {"symbol": "LTCBTC", "side": "BUY", "type": "LIMIT",
"timeInForce": "GTC", "quantity": "1", "price": "0.1",
"recvWindow": "5000", "timestamp": "1499827319559"}
ws = {"apiKey": CLAVE_DOC, "price": "52000.00",
"quantity": "0.01000000", "recvWindow": "100", "side": "SELL",
"symbol": "BTCUSDT", "timeInForce": "GTC",
"timestamp": "1645423376532", "type": "LIMIT"}
vectores = [
("REST ASCII", carga_rest(rest), "c8db56825ae71d6d79447849e617115f"
"4a920fa2acdcab2b053c4b2838bd6b71"),
("REST no ASCII", carga_rest(rest | {"symbol": NO_ASCII}),
"e1353ec6b14d888f1164ae9af8228a3d"
"bd508bc82eb867db8ab6046442f33ef3"),
("WS API", "&".join(f"{k}={v}" for k, v in sorted(ws.items())),
"aa1b5712c094bc4e57c05a1a5c1fd8d8"
"8dcd628338ea863fec7b88e59fe2db24"),
]
for nombre, carga, esperada in vectores:
firma = firma_binance(SECRETO_DOC, carga)
ok = hmac.compare_digest(firma, esperada)
print(f"{nombre:<14} {carga[:44]}… coincide: {ok}")
no_ascii = rest | {"symbol": NO_ASCII}
sin_codificar = firma_binance(
SECRETO_DOC, "&".join(f"{k}={v}" for k, v in no_ascii.items()))
print("no ASCII sin codificar coincide:",
sin_codificar == vectores[1][2])REST ASCII symbol=LTCBTC&side=BUY&type=LIMIT&timeInForc… coincide: True
REST no ASCII symbol=%EF%BC%91%EF%BC%92%EF%BC%93%EF%BC%94%… coincide: True
WS API apiKey=vmPUZE6mv9SD5VNHk4HlWFsOr6aKE2zvsw0Mu… coincide: True
no ASCII sin codificar coincide: FalseLas tres firmas coinciden con la documentación. La última línea es el cambio de enero de 2026 visto desde el código:
el mismo símbolo sin codificar da otra firma, y Binance rechazaría la petición. hmac.compare_digest compara en
tiempo constante, como debe hacer cualquier servidor que verifique firmas (en el cliente no es necesario, pero
tampoco cuesta).
Kraken: HMAC-SHA512 con el SHA-256 del nonce
En Kraken, API-Sign es el HMAC-SHA512 de la ruta (/0/private/AddOrder) seguida del SHA-256 binario de
nonce + datos del POST, con el secreto decodificado de base64 como clave, y el resultado en base64. La guía de
autenticación da un ejemplo completo con su firma3:
import base64
SECRETO_KRAKEN_DOC = ("kQH5HW/8p1uGOVjbgWA7FunAmGO8lsSUXNsu3eow76sz84Q"
"18fWxnyRzBHCd3pd5nE9qa99HAZtuZuj6F1huXg==")
def firma_kraken(secreto_b64, ruta, nonce, datos):
resumen = hashlib.sha256((str(nonce) + datos).encode()).digest()
mac = hmac.new(base64.b64decode(secreto_b64),
ruta.encode() + resumen, hashlib.sha512)
return base64.b64encode(mac.digest()).decode()
datos = ("nonce=1616492376594&ordertype=limit&pair=XBTUSD"
"&price=37500&type=buy&volume=1.25")
esperada = ("4/dpxb3iT4tp/ZCVEwSnEsLxx0bqyhLpdfOpc6fn7OR8+UClSV5n9E6"
"aSS8MPtnRfp32bAb0nmbRn6H8ndwLUQ==")
firma = firma_kraken(SECRETO_KRAKEN_DOC, "/0/private/AddOrder",
1616492376594, datos)
print("coincide:", firma == esperada, "| longitud:", len(firma))coincide: True | longitud: 88Bybit: la firma depende de los bytes del cuerpo
Bybit V5 firma timestamp + api_key + recv_window + carga, donde la carga es la query en un GET y el cuerpo JSON
en un POST, con HMAC-SHA256 en hexadecimal en minúsculas2. La guía muestra dos ejemplos de cadena y de firma,
pero no publica el secreto con el que se calcularon, así que no sirven como vector: la implementación se comprueba
con el servidor local de la sección siguiente. Lo que sí se puede ver sin vector es la trampa de los POST: el
cuerpo es texto, y json.dumps con sus separadores por defecto no produce los mismos bytes que con
separators=(",", ":"), que es lo que usa el código de ejemplo oficial de Bybit.
import json
def firma_bybit(secreto, ts, clave, ventana, carga):
texto = f"{ts}{clave}{ventana}{carga}"
return hmac.new(secreto.encode(), texto.encode(),
hashlib.sha256).hexdigest()
cuerpo = {"category": "spot", "symbol": "XSNUSDT", "side": "Buy",
"orderType": "Limit", "qty": "2", "price": "61.25",
"orderLinkId": "R4-cripto-0001"}
compacto = json.dumps(cuerpo, separators=(",", ":"))
con_espacios = json.dumps(cuerpo)
for nombre, texto in [("compacto", compacto),
("con espacios", con_espacios)]:
f = firma_bybit("FALSA-SECRETO-BYBIT", 1760449080805,
"FALSA-CLAVE-BYBIT", 5000, texto)
print(f"{nombre:<13} {len(texto)} bytes firma {f[:16]}…")compacto 128 bytes firma 61e79e68de63afd9…
con espacios 141 bytes firma 7e3ec9e21fcf210b…Trece bytes de diferencia (los espacios después de cada : y de cada ,) dan firmas sin nada en común. Si el cliente
firma un texto y la librería HTTP envía otro (por ejemplo, porque se le pasa el diccionario y lo serializa ella), el
servidor responde con el error de firma. La regla vale para todos: se firma la cadena que se envía, y se envía la
cadena que se firmó.
Ed25519 y el JWT de Coinbase
Binance recomienda claves Ed25519: la firma se hace con la clave privada y el servidor la comprueba con la pública,
así que el exchange no guarda nada con lo que se pueda firmar. Los ejemplos de Ed25519 de su documentación no
publican la clave privada, de modo que no sirven como vector (el que aparece como firma tiene 344 caracteres en
base64, la longitud de una firma RSA de 2.048 bits; una de Ed25519 tiene 88). El algoritmo se comprueba con los
vectores de su especificación, la RFC 8032, con la librería cryptography del entorno5Nota 5 · S. Josefsson e I. Liusvaara, RFC 8032: Edwards-Curve Digital Signature Algorithm (EdDSA), sección 7.1, IETF, 2017.:
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey)
RFC8032 = [ # TEST 1 y TEST 2: (clave secreta, mensaje, firma)
("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
"", "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e0652249"
"01555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a"
"100b"),
("4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb",
"72", "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223eb"
"db69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612"
"bb0c00"),
]
for i, (sk, msg, firma) in enumerate(RFC8032, 1):
clave = Ed25519PrivateKey.from_private_bytes(bytes.fromhex(sk))
print(f"TEST {i}:", clave.sign(bytes.fromhex(msg)).hex() == firma)
# una clave de juguete, derivada de un texto: nunca de una cuenta
clave_ed = Ed25519PrivateKey.from_private_bytes(
hashlib.sha256(b"clave de juguete del curso").digest())
carga = carga_rest({"symbol": "XSNUSDT", "side": "BUY",
"type": "LIMIT", "timeInForce": "GTC",
"quantity": "2", "price": "61.25",
"timestamp": "1760449080805"})
firma_ed = base64.b64encode(clave_ed.sign(carga.encode())).decode()
clave_ed.public_key().verify(base64.b64decode(firma_ed), carga.encode())
print("firma Ed25519 en base64:", len(firma_ed), "caracteres;",
"en la URL:", quote(firma_ed, safe="")[:12] + "…")TEST 1: True
TEST 2: True
firma Ed25519 en base64: 88 caracteres; en la URL: f9UQrpOxwkXX…Los dos vectores de la RFC coinciden. La firma de la orden ocupa 88 caracteres en base64 y, como el base64 puede
llevar +, / e =, va codificada por porcentaje en la petición, que es el tercer paso del procedimiento de
Binance para RSA y Ed25519. Ed25519 es determinista: la misma clave y el mismo mensaje dan siempre la misma firma, y
por eso tiene vectores.
ECDSA no lo es. Coinbase Advanced Trade se autentica con un JWT firmado con ES256 (ECDSA con la curva P-256 y
SHA-256): cabecera con alg, kid (el nombre de la clave, organizations/{org_id}/apiKeys/{key_id}) y un nonce
aleatorio; claims sub (otra vez el nombre de la clave), iss (cdp), nbf (ahora), exp (ahora + 120 s) y uri,
que liga el token a una petición: POST api.coinbase.com/api/v3/brokerage/orders. La documentación pide un JWT
distinto para cada petición4. PyJWT no está en el entorno; con cryptography se construye a mano, y hay un
detalle que muestra qué hace una librería por nosotros: ECDSA produce la firma en formato DER, y el JWT la quiere como
los 64 bytes de r y s concatenados.
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import (
decode_dss_signature, encode_dss_signature)
def b64url(b):
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def de_b64url(s):
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
def jwt_es256(privada, kid, claims, nonce):
cab = {"alg": "ES256", "kid": kid, "nonce": nonce, "typ": "JWT"}
partes = [b64url(json.dumps(x, separators=(",", ":")).encode())
for x in (cab, claims)]
msg = ".".join(partes).encode()
r, s = decode_dss_signature(privada.sign(msg,
ec.ECDSA(hashes.SHA256())))
firma = r.to_bytes(32, "big") + s.to_bytes(32, "big")
return ".".join(partes + [b64url(firma)])
def verificar_es256(publica, token):
cab, cuerpo, firma = token.split(".")
raw = de_b64url(firma)
der = encode_dss_signature(int.from_bytes(raw[:32], "big"),
int.from_bytes(raw[32:], "big"))
publica.verify(der, f"{cab}.{cuerpo}".encode(),
ec.ECDSA(hashes.SHA256())) # lanza si no vale
return json.loads(de_b64url(cab)), json.loads(de_b64url(cuerpo))
KID = "organizations/org-falsa/apiKeys/clave-falsa"
privada_cb = ec.derive_private_key(
int.from_bytes(hashlib.sha256(b"juguete ES256").digest(), "big"),
ec.SECP256R1())
def claims_coinbase(ahora, metodo, ruta):
return {"sub": KID, "iss": "cdp", "nbf": ahora, "exp": ahora + 120,
"uri": f"{metodo} api.coinbase.com{ruta}"}
token = jwt_es256(privada_cb, KID, claims_coinbase(
1_760_449_080, "POST", "/api/v3/brokerage/orders"), "00" * 16)
cab, claims = verificar_es256(privada_cb.public_key(), token)
print("partes:", len(token.split(".")), "| firma:",
len(de_b64url(token.split(".")[2])), "bytes")
print(cab["alg"], cab["kid"])
print(claims["uri"], "| vida:", claims["exp"] - claims["nbf"], "s")partes: 3 | firma: 64 bytes
ES256 organizations/org-falsa/apiKeys/clave-falsa
POST api.coinbase.com/api/v3/brokerage/orders | vida: 120 sEl token tiene tres partes y una firma de 64 bytes, y la verificación con la clave pública pasa. La celda no imprime el token: es una credencial de portador (quien lo tenga puede usarlo durante dos minutos), y en un log debe tratarse como la clave misma (lección 4.4.5). La firma cambia en cada ejecución, porque ECDSA usa un número aleatorio; por eso Coinbase no puede publicar un vector, y la comprobación es la verificación con la clave pública.
Un servidor que verifica las cuatro
El simulador de esta sección imita las rutas privadas de órdenes de los cuatro exchanges y aplica sus reglas de autenticación tal como las describe su documentación, con un reloj lógico: la hora del servidor es una variable que la lección mueve, y la del cliente es esa hora más un desfase. Así se puede ver qué pasa con un reloj adelantado o atrasado sin depender del reloj de la máquina. Las reglas:
- Binance:
timestamp < serverTime + 1000yserverTime − timestamp ≤ recvWindow; si no,-1021. Firma mal,-1022. - Bybit:
server_time − recv_window ≤ timestamp < server_time + 1000; si no,retCode10002 («The request time exceeds the time window range»). Firma mal, 10004. Las respuestas de error de Bybit llegan con HTTP 200 y el error enretCode. - Kraken: el nonce de cada clave tiene que ser mayor que el último aceptado; si no,
EAPI:Invalid nonce. Los errores van en la listaerrorde la respuesta. - Coinbase: JWT con firma válida para el
kid,nbf ≤ ahora < expyuriigual a la petición; si no, 401. El simulador no da ninguna tolerancia ennbf: un servidor real puede darla, y eso hay que comprobarlo en su sandbox.
import asyncio
from urllib.parse import parse_qsl
from aiohttp import web, ClientSession
RELOJ = {"ms": 1_760_449_080_000} # hora del servidor
CLAVES = {"binance": ("FALSA-CLAVE-BN", "FALSA-SECRETO-BN"),
"bybit": ("FALSA-CLAVE-BY", "FALSA-SECRETO-BY"),
"kraken": ("FALSA-CLAVE-KR", base64.b64encode(
b"FALSO-SECRETO-KR" * 4).decode())}
ULTIMO_NONCE = {}
ORDENES = []
async def binance_orden(req):
clave, secreto = CLAVES["binance"]
texto = req.query_string + (await req.text())
carga, _, firma = texto.rpartition("&signature=")
p = dict(parse_qsl(carga))
ts, ventana = int(p["timestamp"]), int(p.get("recvWindow", 5000))
if req.headers.get("X-MBX-APIKEY") != clave or not \
hmac.compare_digest(firma_binance(secreto, carga), firma):
return web.json_response({"code": -1022, "msg": "Signature "
"for this request is not valid."},
status=400)
if not (ts < RELOJ["ms"] + 1000 and RELOJ["ms"] - ts <= ventana):
return web.json_response({"code": -1021, "msg": "Timestamp for "
"this request is outside of the "
"recvWindow."}, status=400)
ORDENES.append(("binance", p["newClientOrderId"]))
return web.json_response({"clientOrderId": p["newClientOrderId"],
"status": "NEW"})
async def bybit_orden(req):
clave, secreto = CLAVES["bybit"]
h, cuerpo = req.headers, await req.text()
ts, ventana = int(h["X-BAPI-TIMESTAMP"]), int(
h.get("X-BAPI-RECV-WINDOW", 5000))
esperada = firma_bybit(secreto, ts, clave, ventana, cuerpo)
if h["X-BAPI-API-KEY"] != clave or esperada != h["X-BAPI-SIGN"]:
return web.json_response({"retCode": 10004, "retMsg": "Error "
"sign, please check your signature "
"generation algorithm."})
if not RELOJ["ms"] - ventana <= ts < RELOJ["ms"] + 1000:
return web.json_response({"retCode": 10002, "retMsg": "The "
"request time exceeds the time "
"window range."})
d = json.loads(cuerpo)
ORDENES.append(("bybit", d["orderLinkId"]))
return web.json_response({"retCode": 0, "retMsg": "OK", "result": {
"orderLinkId": d["orderLinkId"]}})
SECRETOS_KRAKEN = dict([CLAVES["kraken"]]) # clave -> secreto
async def kraken_orden(req):
clave = req.headers.get("API-Key")
secreto = SECRETOS_KRAKEN.get(clave, "")
datos = await req.text()
p = dict(parse_qsl(datos))
nonce = int(p["nonce"])
firma = firma_kraken(secreto, req.path.removeprefix("/kraken"),
nonce, datos)
if not secreto or firma != req.headers.get("API-Sign"):
return web.json_response({"error": ["EAPI:Invalid signature"],
"result": {}})
if nonce <= ULTIMO_NONCE.get(clave, 0):
return web.json_response({"error": ["EAPI:Invalid nonce"],
"result": {}})
ULTIMO_NONCE[clave] = nonce
ORDENES.append(("kraken", p["cl_ord_id"]))
return web.json_response({"error": [], "result": {
"cl_ord_id": p["cl_ord_id"]}})
async def coinbase_orden(req):
token = req.headers.get("Authorization", "").removeprefix("Bearer ")
try:
cab, c = verificar_es256(privada_cb.public_key(), token)
except Exception:
return web.json_response({"error": "UNAUTHENTICATED"},
status=401)
ahora = RELOJ["ms"] // 1000
ruta = req.path.removeprefix("/coinbase")
uri = f"{req.method} api.coinbase.com{ruta}"
if cab["kid"] != KID or not c["nbf"] <= ahora < c["exp"] \
or c["uri"] != uri:
return web.json_response({"error": "UNAUTHENTICATED"},
status=401)
d = await req.json()
ORDENES.append(("coinbase", d["client_order_id"]))
return web.json_response({"success": True, "success_response": {
"client_order_id": d["client_order_id"]}})
app = web.Application()
app.router.add_post("/binance/api/v3/order", binance_orden)
app.router.add_post("/bybit/v5/order/create", bybit_orden)
app.router.add_post("/kraken/0/private/AddOrder", kraken_orden)
app.router.add_post("/coinbase/api/v3/brokerage/orders",
coinbase_orden)
corredor = web.AppRunner(app)
await corredor.setup()
sitio = web.TCPSite(corredor, "127.0.0.1", 0)
await sitio.start()
BASE = f"http://127.0.0.1:{sitio._server.sockets[0].getsockname()[1]}"
print("rutas:", len(app.router.routes()))rutas: 4Los clientes son funciones pequeñas, una por exchange, que reciben la hora del cliente (ms) y un identificador
propio. Cada una construye la cadena, la firma y envía exactamente esa cadena:
sesion = ClientSession()
async def enviar_binance(ms, cid, ventana=5000):
clave, secreto = CLAVES["binance"]
carga = carga_rest({"symbol": "XSNUSDT", "side": "BUY",
"type": "LIMIT", "timeInForce": "GTC",
"quantity": "2", "price": "61.25",
"newClientOrderId": cid,
"recvWindow": ventana, "timestamp": ms})
cuerpo = f"{carga}&signature={firma_binance(secreto, carga)}"
async with sesion.post(BASE + "/binance/api/v3/order", data=cuerpo,
headers={"X-MBX-APIKEY": clave}) as r:
d = await r.json()
return "ok" if r.status == 200 else str(d["code"])
async def enviar_bybit(ms, cid, ventana=5000):
clave, secreto = CLAVES["bybit"]
texto = json.dumps(cuerpo | {"orderLinkId": cid},
separators=(",", ":"))
cab = {"X-BAPI-API-KEY": clave, "X-BAPI-TIMESTAMP": str(ms),
"X-BAPI-RECV-WINDOW": str(ventana),
"X-BAPI-SIGN": firma_bybit(secreto, ms, clave, ventana,
texto),
"Content-Type": "application/json"}
async with sesion.post(BASE + "/bybit/v5/order/create", data=texto,
headers=cab) as r:
d = await r.json()
return "ok" if d["retCode"] == 0 else str(d["retCode"])
async def enviar_kraken(nonce, cid, clave_secreto=None):
clave, secreto = clave_secreto or CLAVES["kraken"]
datos = (f"nonce={nonce}&ordertype=limit&pair=XSNUSD&price=61.25"
f"&type=buy&volume=2&cl_ord_id={cid}")
cab = {"API-Key": clave, "API-Sign": firma_kraken(
secreto, "/0/private/AddOrder", nonce, datos),
"Content-Type": "application/x-www-form-urlencoded"}
async with sesion.post(BASE + "/kraken/0/private/AddOrder",
data=datos, headers=cab) as r:
d = await r.json()
return "ok" if not d["error"] else d["error"][0]
async def enviar_coinbase(ms, cid):
ruta = "/api/v3/brokerage/orders"
nonce = hashlib.sha256(cid.encode()).hexdigest()
jwt = jwt_es256(privada_cb, KID,
claims_coinbase(ms // 1000, "POST", ruta), nonce)
cab = {"Authorization": f"Bearer {jwt}"}
async with sesion.post(BASE + "/coinbase" + ruta,
json={"client_order_id": cid},
headers=cab) as r:
await r.read()
return "ok" if r.status == 200 else str(r.status)
for f in (enviar_binance, enviar_bybit, enviar_coinbase):
print(f.__name__, await f(RELOJ["ms"], f"prueba-{f.__name__[7:]}"))
print("enviar_kraken",
await enviar_kraken(RELOJ["ms"], "prueba-kraken"))enviar_binance ok
enviar_bybit ok
enviar_coinbase ok
enviar_kraken okCon el reloj del cliente en hora, las cuatro órdenes se aceptan: las firmas que se comprobaron con los vectores funcionan también contra el servidor.
El reloj del cliente
Ahora el mismo cliente con el reloj desfasado, de dos minutos y diez segundos de retraso a dos minutos y diez segundos de adelanto. Es lo que pasa en una máquina sin sincronización horaria (NTP) o en una máquina virtual que se suspende y se reanuda:
import pandas as pd
desfases = [-130, -60, -6, -4, 0, 0.9, 1.1, 60, 130] # segundos
filas = {}
for d in desfases:
ms = RELOJ["ms"] + int(d * 1000)
cid = f"d{d:+}"
filas[d] = {"binance": await enviar_binance(ms, "bn" + cid),
"bybit": await enviar_bybit(ms, "by" + cid),
"coinbase": await enviar_coinbase(ms, "cb" + cid)}
ventanas = pd.DataFrame(filas)
ventanas.columns = [f"{d:+g} s" for d in desfases]
ventanas -130 s -60 s -6 s -4 s +0 s +0.9 s +1.1 s +60 s +130 s
binance -1021 -1021 -1021 ok ok ok -1021 -1021 -1021
bybit 10002 10002 10002 ok ok ok 10002 10002 10002
coinbase 401 ok ok ok ok ok 401 401 401La tabla es la figura de la cabecera. Binance y Bybit aceptan un reloj atrasado hasta su ventana (5 segundos aquí) y
uno adelantado menos de un segundo: con 1,1 s de adelanto ya rechazan, con 0,9 aceptan. Coinbase no tiene ventana
de recepción pero el JWT vale dos minutos: acepta un reloj atrasado hasta 120 segundos y rechaza uno adelantado, por
poco que sea, porque el nbf queda en el futuro del servidor (con la regla estricta del simulador). La asimetría
es deliberada en los tres: una petición vieja puede ser una repetición (alguien que la captura y la reenvía),
y la ventana limita cuánto tiempo sirve.
Subir recvWindow al máximo de 60.000 ms «arregla» los rechazos de Binance, pero es tapar el síntoma: cuanto mayor
la ventana, más tiempo vale una petición capturada, y una orden que tarda un minuto en llegar llega a un mercado que
ya es otro. La solución es sincronizar el reloj de la máquina y, como medida adicional, medir el desfase con la hora
del servidor (Binance la da en GET /api/v3/time) y alarmar si pasa de unos cientos de milisegundos.
Kraken: un nonce por clave
Kraken no mira la hora. Exige que el nonce de cada clave crezca siempre, y su guía avisa de que los problemas aparecen cuando una clave se comparte entre procesos o cuando el reloj del sistema se corrige hacia atrás; también de que demasiados nonces inválidos pueden acabar en un bloqueo temporal3. La simulación: dos procesos usan la hora en milisegundos como nonce y mandan 20 órdenes cada uno, con una latencia de red aleatoria (semilla fija) entre el momento en que generan el nonce y el momento en que la petición llega al servidor. Cada proceso envía por su propia conexión, así que sus peticiones llegan en el orden en que las generó; las de uno y otro sí se mezclan.
import numpy as np
def llegadas(semilla=4404, n=20):
rng = np.random.default_rng(semilla)
sucesos = []
for proc in ("A", "B"):
t = 1_760_449_100_000 + (0 if proc == "A" else 37)
llega = 0
for i in range(n):
t += int(rng.integers(40, 160)) # cada cuánto envía
red = int(rng.lognormal(np.log(30), 0.8))
llega = max(t + red, llega + 1) # una conexión: en orden
sucesos.append((llega, t, proc, i))
return sorted(sucesos) # orden de llegada
clave_b = ("FALSA-CLAVE-KR-B",
base64.b64encode(b"FALSO-SECRETO-KR-B" * 4).decode())
CLAVES_PROC = {"A": CLAVES["kraken"], "B": clave_b}
SECRETOS_KRAKEN.update([clave_b]) # alta de la clave B
resultado = {}
for modo in ("compartida", "una por proceso"):
ULTIMO_NONCE.clear()
res = []
for llega, nonce, proc, i in llegadas():
kp = (CLAVES["kraken"] if modo == "compartida"
else CLAVES_PROC[proc])
r = await enviar_kraken(nonce, f"{modo[0]}{proc}{i}", kp)
res.append((llega, proc, r))
resultado[modo] = res
malas = sum(r != "ok" for _, _, r in res)
print(f"clave {modo:<16} rechazadas {malas:>2} de {len(res)}")clave compartida rechazadas 5 de 40
clave una por proceso rechazadas 0 de 40Con la clave compartida, 5 de las 40 órdenes se rechazan con EAPI:Invalid nonce: cada vez que una petición de un
proceso adelanta en la red a una del otro con un nonce mayor, la segunda llega tarde. Con una clave por proceso no
se rechaza ninguna, aunque las latencias sean las mismas. La ventana de nonce que Kraken permite configurar tolera
algo de desorden, pero la solución limpia es que cada clave tenga un único generador de nonces: una clave por proceso
o un único proceso que firme.
Streams de usuario
Las órdenes se envían por REST, pero lo que les pasa después llega por websocket. Cada exchange autentica su stream privado a su manera1234:
| Exchange | Cómo se autentica | Canal de órdenes | Mantener viva la sesión |
|---|---|---|---|
| Binance | WebSocket API: userDataStream.subscribe.signature con apiKey, timestamp y firma (cualquier clave), o session.logon con Ed25519 y userDataStream.subscribe | Eventos executionReport envueltos en {"subscriptionId", "event"} | El servidor manda un ping cada 20 s y corta si no recibe pong en un minuto |
| Bybit | {"op": "auth", "args": [clave, expires, firma]}, firma = HMAC de "GET/realtime" + expires | Temas order y execution | Un {"op": "ping"} cada 20 s, recomendado |
| Kraken | Token de GetWebSocketsToken, que hay que usar en 15 minutos; no caduca mientras la conexión siga | Canal executions (órdenes y ejecuciones) en ws-auth.kraken.com/v2 | Una conexión nueva pide token nuevo |
| Coinbase | JWT sin uri en el mensaje subscribe, en menos de 5 s tras conectar | Canal user en advanced-trade-ws-user.coinbase.com | Canal heartbeats |
En Binance hubo un cambio de fondo: el método del listenKey (POST /api/v3/userDataStream y la conexión a
stream.binance.com con esa clave) quedó obsoleto en abril de 2025 y dejó de estar disponible el 20 de febrero de
2026. Un sistema escrito antes, o una librería sin actualizar, deja de recibir ejecuciones sin que ninguna orden
falle: solo deja de saber qué pasa con ellas.
Fuente Mermaid
stateDiagram-v2 [*] --> Conectado Conectado --> Suscrito: firma aceptada Conectado --> [*]: firma rechazada Suscrito --> Suscrito: evento o ping Suscrito --> Reconciliar: corte Reconciliar --> Conectado: órdenes por REST
El fichero de ejemplo del nivel trae tres executionReport de una orden límite de 2 XSN a 61,25 USDT: NEW, una
ejecución parcial de 1,2 y la final de 0,8 a 61,24. Sus campos son los de la documentación del stream de usuario,
con dos matices que conviene leer bien: I es el identificador de la ejecución (no un campo para ignorar) y M
es el que se ignora. El servidor local los manda por la WebSocket API después de comprobar la firma de la suscripción,
que se calcula sobre los parámetros ordenados alfabéticamente:
from pathlib import Path
EJ = json.loads(Path("brokers4_ejemplos.json").read_text())
INFORMES = EJ["binance_execution_report"]["eventos"]
async def ws_api(req):
ws = web.WebSocketResponse()
await ws.prepare(req)
m = json.loads((await ws.receive()).data)
p = m["params"]
carga = "&".join(f"{k}={v}" for k, v in sorted(p.items())
if k != "signature")
clave, secreto = CLAVES["binance"]
ok = (p["apiKey"] == clave and hmac.compare_digest(
firma_binance(secreto, carga), p["signature"])
and RELOJ["ms"] - int(p["timestamp"]) <= 5000)
if not ok:
await ws.send_json({"id": m["id"], "status": 400, "error": {
"code": -1022, "msg": "Signature for this request is not "
"valid."}})
await ws.close()
return ws
await ws.send_json({"id": m["id"], "status": 200,
"result": {"subscriptionId": 0}})
for e in INFORMES:
await ws.send_json({"subscriptionId": 0, "event": e})
return ws
app_ws = web.Application()
app_ws.router.add_get("/ws-api/v3", ws_api)
corredor_ws = web.AppRunner(app_ws)
await corredor_ws.setup()
sitio_ws = web.TCPSite(corredor_ws, "127.0.0.1", 0)
await sitio_ws.start()
WS = (f"http://127.0.0.1:{sitio_ws._server.sockets[0].getsockname()[1]}"
"/ws-api/v3")
clave, secreto = CLAVES["binance"]
params = {"apiKey": clave, "timestamp": RELOJ["ms"]}
params["signature"] = firma_binance(
secreto, "&".join(f"{k}={v}" for k, v in sorted(params.items())))
ws = await sesion.ws_connect(WS)
await ws.send_json({"id": "sub-1", "method":
"userDataStream.subscribe.signature",
"params": params})
print(await ws.receive_json())
acumulado, caja, comision = 0.0, 0.0, {}
while True:
e = (await ws.receive_json())["event"]
l, z = float(e["l"]), float(e["z"])
acumulado += l
caja += float(e["Y"])
if e["N"]:
comision[e["N"]] = comision.get(e["N"], 0) + float(e["n"])
assert abs(acumulado - z) < 1e-12, "falta una ejecución"
print(f"{e['x']:<6} {e['X']:<17} l={e['l'][:4]} z={e['z'][:4]} "
f"L={e['L'][:5]} ejec={e['I']} maker={e['m']}")
if e["X"] in {"FILLED", "CANCELED", "EXPIRED", "REJECTED"}:
break
media = float(e["Z"]) / float(e["z"])
print(f"media Z/z = {media:.4f}; efectivo {caja:.3f} = Z {e['Z'][:7]}")
print("comisión:", comision){'id': 'sub-1', 'status': 200, 'result': {'subscriptionId': 0}}
NEW NEW l=0.00 z=0.00 L=0.000 ejec=11234567 maker=False
TRADE PARTIALLY_FILLED l=1.20 z=1.20 L=61.25 ejec=11234568 maker=True
TRADE FILLED l=0.80 z=2.00 L=61.24 ejec=11234569 maker=False
media Z/z = 61.2460; efectivo 122.492 = Z 122.492
comisión: {'XSN': 0.002}El cliente suma las cantidades de cada ejecución (l) y comprueba en cada evento que coinciden con el acumulado que
manda el exchange (z): si un mensaje se perdiera, la comprobación fallaría en el siguiente. El precio medio sale de
dividir el efectivo acumulado (Z) entre la cantidad (z), como indica la documentación: 61,246 USDT. La primera
ejecución fue como creador (m = True, la orden estaba en el libro) y la segunda como tomador; la comisión se
cobra en XSN, la moneda comprada: 0,002 en total, el 0,1 % de lo comprado.
Lo mismo, en la estructura unificada de ccxt
ccxt traduce estos eventos a su estructura de orden (watch_orders en ccxt.pro, fetch_order por REST). El fichero
de ejemplo trae esa estructura para la misma orden. La celda comprueba que sus claves son las de safe_order, la
función con la que ccxt construye la estructura en la versión instalada, y que sus cifras cuadran con los eventos:
unificada = EJ["ccxt_orden"]["orden"]
claves_ccxt = set(ccxt.Exchange().safe_order({"id": "1"}))
print("claves que faltan:", claves_ccxt - set(unificada),
"| sobran:", set(unificada) - claves_ccxt)
comparar = {"filled": (unificada["filled"], float(e["z"])),
"cost": (unificada["cost"], float(e["Z"])),
"average": (unificada["average"], round(media, 6)),
"fee": (unificada["fee"]["cost"], comision["XSN"]),
"clientOrderId": (unificada["clientOrderId"], e["c"]),
"lastTradeTimestamp": (unificada["lastTradeTimestamp"],
e["T"])}
for k, (a, b) in comparar.items():
print(f"{k:<19} ccxt {a!s:<15} stream {b!s:<15} {a == b}")claves que faltan: {'stopPrice'} | sobran: {'info'}
filled ccxt 2.0 stream 2.0 True
cost ccxt 122.492 stream 122.492 True
average ccxt 61.246 stream 61.246 True
fee ccxt 0.002 stream 0.002 True
clientOrderId ccxt R4-cripto-0001 stream R4-cripto-0001 True
lastTradeTimestamp ccxt 1760449081445 stream 1760449081445 TrueLas cifras coinciden. A la estructura del ejemplo le falta una clave de las que produce safe_order (stopPrice,
que en ccxt es un alias antiguo de triggerPrice) y trae info, donde ccxt guarda la respuesta nativa sin tocar.
Ese campo info es el puente entre los dos mundos: cuando algo no está en la estructura unificada (el I de la
ejecución, el modo de prevención de auto-casación V), está en info.
import asyncio
import os
import ccxt.pro
async def main():
ex = ccxt.pro.binance({"apiKey": os.environ["BINANCE_KEY"],
"secret": os.environ["BINANCE_SECRET"]})
ex.set_sandbox_mode(True) # testnet
try:
while True:
for o in await ex.watch_orders("XSN/USDT"):
print(o["clientOrderId"], o["status"], o["filled"],
o["info"].get("I")) # campo nativo
finally:
await ex.close()
asyncio.run(main())Qué método usa ccxt.pro por debajo para el stream de usuario de Binance (el antiguo listenKey o la WebSocket API)
depende de la versión: tras un cambio como el de febrero de 2026, hay que comprobar que la versión instalada usa el
mecanismo vigente antes de confiar en watch_orders.
Lo que sale mal con las APIs nativas
- Firmar una cadena y enviar otra. Orden de los parámetros, codificación, espacios del JSON. Se construye la cadena una vez y se usa para las dos cosas.
- El reloj. Rechazos
-1021o10002en ráfagas suelen ser un reloj que deriva. NTP en la máquina y una alarma con el desfase frente a la hora del exchange. - Nonces compartidos. Una clave de Kraken en dos procesos, o dos hilos con contadores separados. Un generador por clave.
- Streams que cambian. El
listenKeyde Binance dejó de funcionar en febrero de 2026; las claves HMAC de Coinbase, en febrero de 2025. Leer el registro de cambios de cada exchange es parte del mantenimiento del sistema. - Errores con HTTP 200. Bybit (
retCode) y Kraken (error) devuelven 200 con el error dentro: un cliente que solo mira el código HTTP da por buenas órdenes rechazadas. - Límites por IP. En Binance los límites se cuentan por IP, no por clave, con las cabeceras
X-MBX-USED-WEIGHT-*yX-MBX-ORDER-COUNT-*; tras un 429, seguir enviando acaba en un 418 y un bloqueo de la IP de 2 minutos a 3 días. Bybit permite 600 peticiones en 5 segundos por IP y bloquea 10 minutos con un 403. - Credenciales. Todo lo anterior supone claves con los permisos justos, guardadas fuera del código (lección 4.4.5).
await ws.close()
await sesion.close()
await corredor.cleanup()
await corredor_ws.cleanup()
print("órdenes aceptadas por el simulador:", len(ORDENES))órdenes aceptadas por el simulador: 90Ejercicios
- Añade al simulador la autenticación del websocket privado de Bybit: el cliente manda
{"op": "auth", "args": [clave, expires, firma]}con la firma HMAC de"GET/realtime" + expires, y el servidor acepta si la firma es correcta yexpireses posterior a su hora. Prueba unexpiresen el futuro y otro en el pasado.
Solución
async def bybit_privado(req):
ws = web.WebSocketResponse()
await ws.prepare(req)
m = json.loads((await ws.receive()).data)
clave, expira, firma = m["args"]
_, secreto = CLAVES["bybit"]
esperada = hmac.new(secreto.encode(),
f"GET/realtime{expira}".encode(),
hashlib.sha256).hexdigest()
ok = clave == CLAVES["bybit"][0] and firma == esperada \
and expira > RELOJ["ms"]
await ws.send_json({"success": ok, "op": "auth",
"ret_msg": "" if ok else "auth failed"})
await ws.close()
return ws
app_by = web.Application()
app_by.router.add_get("/v5/private", bybit_privado)
c_by = web.AppRunner(app_by)
await c_by.setup()
s_by = web.TCPSite(c_by, "127.0.0.1", 0)
await s_by.start()
url_by = f"http://127.0.0.1:{s_by._server.sockets[0].getsockname()[1]}"
async with ClientSession() as s:
for nombre, expira in [("futuro", RELOJ["ms"] + 1000),
("pasado", RELOJ["ms"] - 1000)]:
clave, secreto = CLAVES["bybit"]
f = hmac.new(secreto.encode(), f"GET/realtime{expira}".encode(),
hashlib.sha256).hexdigest()
w = await s.ws_connect(url_by + "/v5/private")
await w.send_json({"op": "auth", "args": [clave, expira, f]})
print(nombre, await w.receive_json())
await w.close()
await c_by.cleanup()futuro {'success': True, 'op': 'auth', 'ret_msg': ''}
pasado {'success': False, 'op': 'auth', 'ret_msg': 'auth failed'}El expires del futuro se acepta y el del pasado no: es una ventana de validez como recvWindow, pero fijada por
el cliente (la documentación de Bybit lo calcula como la hora actual más un segundo). La firma no incluye ningún
dato de la suscripción: autentica la conexión, y a partir de ahí las suscripciones a order o execution van sin
firmar.
- En la simulación de Kraken con la clave compartida, ¿cuántos rechazos habría si el servidor aceptase nonces hasta 100 ms por debajo del último aceptado (sin repetir ninguno)? Modifica la comprobación y vuelve a contar.
Solución
def con_ventana(sucesos, ventana):
ultimo, usados, malas = 0, set(), 0
for llega, nonce, proc, i in sucesos:
if nonce in usados or nonce <= ultimo - ventana:
malas += 1
continue
usados.add(nonce)
ultimo = max(ultimo, nonce)
return malas
for v in (0, 50, 100, 200):
print(f"ventana {v:>3} ms: rechazadas {con_ventana(llegadas(), v)}")ventana 0 ms: rechazadas 5
ventana 50 ms: rechazadas 2
ventana 100 ms: rechazadas 1
ventana 200 ms: rechazadas 0Con ventana 0 la regla es la estricta, con un detalle: aquí un nonce igual al último ya está en usados y se
rechaza igual, así que el recuento coincide con el del simulador. Con 100 ms de tolerancia los rechazos bajan a
casi cero, porque los desórdenes de la simulación vienen de latencias de red de decenas de milisegundos. La ventana
es una tolerancia, no una solución: cuanto mayor, más fácil es que una petición repetida con un nonce viejo pase.
Esta es una interpretación del simulador; la documentación de Kraken describe la ventana como «una tolerancia entre
valores de nonce» sin más detalle, y su comportamiento exacto hay que comprobarlo en la cuenta.
- Un sistema opera en Binance con claves HMAC creadas en 2024 y recibe las ejecuciones con un
listenKey. Haz la lista de cambios que necesita para seguir funcionando en 2026 y de lo que comprobarías en el testnet antes de pasarlo a real.
Solución
Tres cambios. El stream de usuario: sustituir el listenKey por la WebSocket API con
userDataStream.subscribe.signature (sirve con la clave actual) o, mejor, con session.logon y
userDataStream.subscribe usando una clave Ed25519; los eventos llegan ahora envueltos en subscriptionId y
event, así que el parser cambia. La firma: codificar por porcentaje la carga antes de firmar (obligatorio desde el
15 de enero de 2026; sin ello, -1022 en cuanto aparezca un carácter no ASCII o reservado). Las claves: crear una
Ed25519 (la recomendada; las HMAC están marcadas como obsoletas), con los permisos justos y restringida por IP, y
retirar la HMAC cuando la nueva funcione (procedimiento de rotación de la lección 4.4.5). En el testnet: una orden
límite que se ejecute en dos partes y comprobar que los executionReport llegan, que z cuadra con la suma de l y
que la reconexión reconcilia por REST; una petición con el reloj desplazado 6 segundos para ver el -1021; y una
desconexión forzada para comprobar que el sistema se vuelve a suscribir. Si se usa ccxt, comprobar además qué
mecanismo usa la versión instalada para watch_orders.
Footnotes
-
Binance, documentación oficial de la API de spot (repositorio fuente de developers.binance.com):
rest-api.md(endpoints firmados, HMAC, RSA, Ed25519,recvWindow, límites),web-socket-api.md(userDataStream.subscribe.signature, ping),user-data-stream.md(executionReport),errors.md(-1021,-1022),faqs/api_key_types.mdyCHANGELOG.md(entradas del 2025-04-07, 2025-12-17 y 2026-01-21). Consultada el 22/10/2026. ↩ ↩2 ↩3 -
Bybit, Integration Guidance (autenticación), WebSocket Connect, Error codes, Rate limit y Demo trading; código de ejemplo oficial en api-usage-examples. Consultadas el 22/10/2026. ↩ ↩2 ↩3
-
Kraken, Spot REST authentication, API keys, errores de spot, executions (WebSocket v2) y GetWebSocketsToken. Consultadas el 22/10/2026. ↩ ↩2 ↩3 ↩4
-
Coinbase, API key authentication, claves antiguas, WebSocket overview, canal user y sandbox. Consultadas el 22/10/2026. ↩ ↩2 ↩3
-
S. Josefsson e I. Liusvaara, RFC 8032: Edwards-Curve Digital Signature Algorithm (EdDSA), sección 7.1, IETF, 2017. ↩
Respuesta plegada en cada pregunta
Autoevaluación
Un bot manda a Binance
timestamp6 segundos por detrás del reloj del servidor conrecvWindow5000. ¿Qué pasa?- ASe acepta: el retraso es pequeño
- BSe rechaza con
-1022 INVALID_SIGNATURE - CSe rechaza con
-1021 INVALID_TIMESTAMP: el servidor exige queserverTime − timestamp ≤ recvWindow - DSe acepta, pero con comisión mayor
Ver respuesta
Respuesta C. Se rechaza con
-1021 INVALID_TIMESTAMP: el servidor exige queserverTime − timestamp ≤ recvWindowLa regla documentada es
timestamp < serverTime + 1 syserverTime − timestamp ≤ recvWindow. Con 6.000 ms de retraso y una ventana de 5.000, la petición está fuera. La solución es sincronizar el reloj (NTP), no subir la ventana hasta 60.000 ms.Dos procesos usan la misma clave de Kraken con un nonce igual a la hora en milisegundos. ¿Qué problema aparece?
- ALas peticiones que llegan fuera de orden traen un nonce menor que el último aceptado y se rechazan con
EAPI:Invalid nonce - BNinguno: cada proceso tiene su reloj
- CKraken cobra el doble
- DLa firma deja de ser válida
Ver respuesta
Respuesta A. Las peticiones que llegan fuera de orden traen un nonce menor que el último aceptado y se rechazan con
EAPI:Invalid nonceEl nonce de cada clave tiene que crecer siempre. Dos procesos con la misma clave compiten: la petición que llega después con un nonce menor se rechaza. En la simulación de la lección, 5 de 40 peticiones; con una clave por proceso, ninguna.
- ALas peticiones que llegan fuera de orden traen un nonce menor que el último aceptado y se rechazan con
¿Por qué la firma de Bybit de un
POSTdepende de cómo se serializa el JSON del cuerpo?- APorque Bybit reordena las claves del JSON
- BPorque Bybit solo admite XML
- CNo depende
- DPorque la firma es un HMAC de
timestamp + api_key + recv_window + cuerpo, con el cuerpo como texto: un espacio de más cambia los bytes y la firma
Ver respuesta
Respuesta D. Porque la firma es un HMAC de
timestamp + api_key + recv_window + cuerpo, con el cuerpo como texto: un espacio de más cambia los bytes y la firmaEl servidor recalcula el HMAC sobre los bytes que recibe. Si se firma
json.dumps(cuerpo)con espacios y se envía otro texto sin ellos, la firma no coincide (retCode 10004). Hay que firmar exactamente la cadena que se envía.Desde el 20 de febrero de 2026, ¿cómo se recibe el stream de usuario de spot en Binance?
- ACon un
listenKeydePOST /api/v3/userDataStream - BPor la WebSocket API:
userDataStream.subscribe.signature(cualquier tipo de clave) osession.logonyuserDataStream.subscribe(claves Ed25519) - CSolo con ccxt
- DPor correo electrónico
Ver respuesta
Respuesta B. Por la WebSocket API:
userDataStream.subscribe.signature(cualquier tipo de clave) osession.logonyuserDataStream.subscribe(claves Ed25519)El registro de cambios de Binance anunció que los endpoints
userDataStreamdel listenKey dejaban de estar disponibles el 2026-02-20. La suscripción se hace ahora por la WebSocket API, y los eventos llegan envueltos en{"subscriptionId": …, "event": {…}}.- ACon un
Un JWT de Coinbase Advanced Trade se firma a las 10:00:00 con
exp=nbf+ 120. ¿Se puede reutilizar a las 10:01:30 para otra orden?- ASí, mientras no haya caducado
- BSí, si se cambia el
kid - CNo: el claim
urilleva el método y la ruta de una petición concreta y la documentación pide un JWT distinto por petición; además caduca a los dos minutos - DSolo en el sandbox
Ver respuesta
Respuesta C. No: el claim
urilleva el método y la ruta de una petición concreta y la documentación pide un JWT distinto por petición; además caduca a los dos minutosLa documentación dice que el JWT caduca a los 2 minutos y que hay que generar uno distinto para cada petición única. El
uri(POST api.coinbase.com/api/v3/brokerage/orders) liga el token a un método y una ruta.
Ideas clave
- ccxt firma, añade
timestampyrecvWindow(10.000 ms por defecto en Binance en la versión 4.5.89) y, si no se da un identificador propio, inyecta uno con su prefijo de bróker: la API nativa es lo que hay debajo. - Cuatro esquemas: HMAC-SHA256 en hexadecimal de la cadena de parámetros (Binance), HMAC-SHA256 de
timestamp + clave + ventana + carga(Bybit), HMAC-SHA512 en base64 de ruta + SHA-256 de nonce y datos (Kraken) y JWT ES256 por petición (Coinbase). Binance recomienda claves Ed25519 y marca las HMAC como obsoletas. - Se firma la cadena exacta que se envía: orden de los parámetros, codificación por porcentaje (obligatoria en Binance desde enero de 2026) y espacios del JSON cambian la firma.
- El reloj importa: Binance y Bybit rechazan lo que llega más de un segundo adelantado o más viejo que la ventana; Coinbase, lo que está fuera de
nbf–exp. Kraken no mira la hora pero exige nonces crecientes por clave: una clave por proceso. - Los streams de usuario se autentican de forma distinta (firma en la suscripción, token de 15 minutos, JWT, mensaje
auth) y piden latidos propios; el estado de la orden se comprueba con el acumulado (z,Z) y se concilia por REST al reconectar.
Recursos
Documentación
- Binance Spot API: documentación oficial (seguridad de endpoints, WebSocket API, user data stream, errores)
- Bybit V5: guía de integración (autenticación) y conexión websocket
- Kraken: autenticación de la API REST de spot
- Coinbase Advanced Trade: autenticación con claves de API (JWT)
- RFC 8032: Edwards-Curve Digital Signature Algorithm (EdDSA)