---
title: "4.4.4 Cripto: ccxt y APIs nativas"
description: "Firmas de Binance, Bybit, Kraken y Coinbase contra los vectores oficiales, ventanas de tiempo, nonces y streams de usuario, en un simulador local."
url: "https://pythonparatrading.com/curso-python-trading/nivel-4/apis-cripto-ccxt-y-nativas/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-4/apis-cripto-ccxt-y-nativas.png"
---

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

- Curso: [Nivel 4: Carteras, riesgo, ejecución y trading en real](https://pythonparatrading.com/curso-python-trading/nivel-4/)
- Módulo: 4.4 · Brokers y APIs
- Duración: 75 min
- Requisitos: [1.3.5 · Datos de cripto y on-chain](https://pythonparatrading.com/curso-python-trading/nivel-1/datos-de-cripto-y-on-chain/), [1.4.1 · Descarga por REST y websockets: paginación, límites y reintentos](https://pythonparatrading.com/curso-python-trading/nivel-1/descarga-rest-websockets-y-reintentos/), [3.7.4 · ccxt para operar en varios exchanges](https://pythonparatrading.com/curso-python-trading/nivel-3/ccxt-multi-exchange/), [4.4.3 · Alpaca, Tradier, MetaTrader 5 con Python y OANDA v20](https://pythonparatrading.com/curso-python-trading/nivel-4/alpaca-tradier-metatrader-5-y-oanda/)

## Al terminar esta lección

- Explicar qué hace ccxt al firmar una petición y cuándo conviene bajar a la API nativa de un exchange.
- Implementar las firmas HMAC-SHA256 de Binance y Bybit, HMAC-SHA512 de Kraken, Ed25519 y el JWT ES256 de Coinbase, y comprobarlas con vectores oficiales.
- Predecir qué peticiones rechaza cada exchange por desfase del reloj (`recvWindow`, `recv_window`, `nbf` y `exp`) y por nonces fuera de orden.
- Suscribirse al stream de usuario de Binance por la WebSocket API y reconstruir una orden desde sus `executionReport`.
- Comparar la orden reconstruida con la estructura unificada de ccxt y mantener vivas las sesiones privadas de cada exchange.

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

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

ccxt 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ón[^1][^2][^3][^4]. 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.

```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 codificar[^1].

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

Las 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 firma[^3]:

```python
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))
```
```output
coincide: True | longitud: 88
```

### Bybit: 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úsculas[^2]. 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.

```python
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]}…")
```
```output
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 entorno[^5]:

```python
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] + "…")
```
```output
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ón[^4]. `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.

```python
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")
```
```output
partes: 3 | firma: 64 bytes
ES256 organizations/org-falsa/apiKeys/clave-falsa
POST api.coinbase.com/api/v3/brokerage/orders | vida: 120 s
```

El 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 + 1000` y `serverTime − timestamp ≤ recvWindow`; si no, `-1021`. Firma mal,
  `-1022`.
- **Bybit**: `server_time − recv_window ≤ timestamp < server_time + 1000`; si no, `retCode` 10002 («The request time
  exceeds the time window range»). Firma mal, 10004. Las respuestas de error de Bybit llegan con HTTP 200 y el error
  en `retCode`.
- **Kraken**: el nonce de cada clave tiene que ser mayor que el último aceptado; si no, `EAPI:Invalid nonce`. Los
  errores van en la lista `error` de la respuesta.
- **Coinbase**: JWT con firma válida para el `kid`, `nbf ≤ ahora < exp` y `uri` igual a la petición; si no, 401. El
  simulador no da ninguna tolerancia en `nbf`: un servidor real puede darla, y eso hay que comprobarlo en su sandbox.

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

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

```python
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"))
```
```output
enviar_binance ok
enviar_bybit ok
enviar_coinbase ok
enviar_kraken ok
```

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

```python
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
```
```output
         -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    401
```

La 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 temporal[^3]. 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.

```python
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)}")
```
```output
clave compartida       rechazadas  5 de 40
clave una por proceso  rechazadas  0 de 40
```

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

*Fig. — Dos procesos con la misma clave de Kraken: llegada de cada petición y las rechazadas por nonce.* Dos filas de marcas, una por proceso, con las 20 peticiones de cada uno en unos 2,1 segundos. Las 5 marcas destacadas son peticiones rechazadas con EAPI:Invalid nonce: llegaron después de otra del otro proceso con un nonce mayor. Con una clave por proceso no hay ningún rechazo.

## 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 manera[^1][^2][^3][^4]:

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

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

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

```python
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}")
```
```output
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   True
```

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

```python skip title="binance_ccxtpro_ordenes.py"
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 `-1021` o `10002` en 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 `listenKey` de 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-*` y `X-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).

```python
await ws.close()
await sesion.close()
await corredor.cleanup()
await corredor_ws.cleanup()
print("órdenes aceptadas por el simulador:", len(ORDENES))
```
```output
órdenes aceptadas por el simulador: 90
```

## Ejercicios

1. 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 y `expires` es posterior a su hora. Prueba un `expires` en el futuro y otro en el
   pasado.

**Solución.**

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

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

```python
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)}")
```
```output
ventana   0 ms: rechazadas 5
ventana  50 ms: rechazadas 2
ventana 100 ms: rechazadas 1
ventana 200 ms: rechazadas 0
```

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

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

[^1]: Binance, [documentación oficial de la API de spot](https://github.com/binance/binance-spot-api-docs) (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.
[^2]: Bybit, [Integration Guidance](https://bybit-exchange.github.io/docs/v5/guide) (autenticación), [WebSocket Connect](https://bybit-exchange.github.io/docs/v5/ws/connect), [Error codes](https://bybit-exchange.github.io/docs/v5/error), [Rate limit](https://bybit-exchange.github.io/docs/v5/rate-limit) y [Demo trading](https://bybit-exchange.github.io/docs/v5/demo); código de ejemplo oficial en [api-usage-examples](https://github.com/bybit-exchange/api-usage-examples). Consultadas el 22/10/2026.
[^3]: Kraken, [Spot REST authentication](https://docs.kraken.com/exchange/guides/rest/authentication), [API keys](https://docs.kraken.com/exchange/guides/rest/api-keys), [errores de spot](https://docs.kraken.com/api/docs/guides/spot-errors), [executions (WebSocket v2)](https://docs.kraken.com/exchange/api-reference/spot-websocket-v2/executions) y [GetWebSocketsToken](https://docs.kraken.com/api-reference/trading/get-websockets-token). Consultadas el 22/10/2026.
[^4]: Coinbase, [API key authentication](https://docs.cdp.coinbase.com/coinbase-app/authentication-authorization/api-key-authentication), [claves antiguas](https://docs.cdp.coinbase.com/coinbase-app/authentication-authorization/legacy-keys), [WebSocket overview](https://docs.cdp.coinbase.com/coinbase-app/advanced-trade-apis/websocket/websocket-overview), [canal user](https://docs.cdp.coinbase.com/api-reference/advanced-trade-api/websocket/user) y [sandbox](https://docs.cdp.coinbase.com/coinbase-app/advanced-trade-apis/sandbox). Consultadas el 22/10/2026.
[^5]: S. Josefsson e I. Liusvaara, [RFC 8032: Edwards-Curve Digital Signature Algorithm (EdDSA)](https://www.rfc-editor.org/rfc/rfc8032), sección 7.1, IETF, 2017.

## Autoevaluación

1. Un bot manda a Binance `timestamp` 6 segundos por detrás del reloj del servidor con `recvWindow` 5000. ¿Qué pasa?

   - A. Se acepta: el retraso es pequeño
   - B. Se rechaza con `-1022 INVALID_SIGNATURE`
   - C. Se rechaza con `-1021 INVALID_TIMESTAMP`: el servidor exige que `serverTime − timestamp ≤ recvWindow`
   - D. Se acepta, pero con comisión mayor

   *Respuesta C: Se rechaza con `-1021 INVALID_TIMESTAMP`: el servidor exige que `serverTime − timestamp ≤ recvWindow`.* La regla documentada es `timestamp < serverTime + 1 s` y `serverTime − 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.

2. Dos procesos usan la misma clave de Kraken con un nonce igual a la hora en milisegundos. ¿Qué problema aparece?

   - A. Las peticiones que llegan fuera de orden traen un nonce menor que el último aceptado y se rechazan con `EAPI:Invalid nonce`
   - B. Ninguno: cada proceso tiene su reloj
   - C. Kraken cobra el doble
   - D. La firma deja de ser válida

   *Respuesta A: Las peticiones que llegan fuera de orden traen un nonce menor que el último aceptado y se rechazan con `EAPI:Invalid nonce`.* El 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.

3. ¿Por qué la firma de Bybit de un `POST` depende de cómo se serializa el JSON del cuerpo?

   - A. Porque Bybit reordena las claves del JSON
   - B. Porque Bybit solo admite XML
   - C. No depende
   - 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 firma

   *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 firma.* El 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.

4. Desde el 20 de febrero de 2026, ¿cómo se recibe el stream de usuario de spot en Binance?

   - A. Con un `listenKey` de `POST /api/v3/userDataStream`
   - B. Por la WebSocket API: `userDataStream.subscribe.signature` (cualquier tipo de clave) o `session.logon` y `userDataStream.subscribe` (claves Ed25519)
   - C. Solo con ccxt
   - D. Por correo electrónico

   *Respuesta B: Por la WebSocket API: `userDataStream.subscribe.signature` (cualquier tipo de clave) o `session.logon` y `userDataStream.subscribe` (claves Ed25519).* El registro de cambios de Binance anunció que los endpoints `userDataStream` del 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": {…}}`.

5. 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?

   - A. Sí, mientras no haya caducado
   - B. Sí, si se cambia el `kid`
   - C. No: el claim `uri` lleva 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
   - D. Solo en el sandbox

   *Respuesta C: No: el claim `uri` lleva 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.* La 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 `timestamp` y `recvWindow` (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

### Research

- [Máquinas de Estado Finito para Ejecución de Trading Cuantitativo en Python](https://pythonparatrading.com/maquinas-estado-finito-trading-python/) · Research-0029 · 49 min

### Documentación

- [Binance Spot API: documentación oficial (seguridad de endpoints, WebSocket API, user data stream, errores)](https://github.com/binance/binance-spot-api-docs) · github.com
- [Bybit V5: guía de integración (autenticación) y conexión websocket](https://bybit-exchange.github.io/docs/v5/guide) · bybit-exchange.github.io
- [Kraken: autenticación de la API REST de spot](https://docs.kraken.com/exchange/guides/rest/authentication) · docs.kraken.com
- [Coinbase Advanced Trade: autenticación con claves de API (JWT)](https://docs.cdp.coinbase.com/coinbase-app/authentication-authorization/api-key-authentication) · docs.cdp.coinbase.com
- [RFC 8032: Edwards-Curve Digital Signature Algorithm (EdDSA)](https://www.rfc-editor.org/rfc/rfc8032) · rfc-editor.org

### Datos

- [Datos del nivel 4: mensajes de ejemplo de brókeres y exchanges (JSON)](https://pythonparatrading.com/curso/datos/nivel-4/brokers4_ejemplos.json) · Descarga · brokers4_ejemplos.json

Lección anterior: [4.4.3 · Alpaca, Tradier, MetaTrader 5 con Python y OANDA v20](https://pythonparatrading.com/curso-python-trading/nivel-4/alpaca-tradier-metatrader-5-y-oanda/)

Lección siguiente: [4.4.5 · Credenciales seguras: variables de entorno, gestores de secretos y permisos mínimos](https://pythonparatrading.com/curso-python-trading/nivel-4/credenciales-seguras-y-permisos-minimos/)
