Flujo WebSocket
Un relé. Los eventos de los mercados cubiertos se normalizan y se fusionan en una sola salida, enviada cada 100 ms. Es la única transformación que se hace: entra una operación, sale una operación.
No es una fuente de métricas (ni ventana móvil, ni contador, ni media), y no hay historial: recibes lo que ocurre desde tu conexión. El pasado lo sirve la API REST.
#Conexión
La clave se presenta en la URL, como parámetro api_key, y se comprueba en el handshake, antes de que se abra la conexión. Es la misma clave que la de la API REST. En el handshake hay tres rechazos posibles, todos como 403 con la cabecera X-Deny-Reason: clave ausente, inválida o revocada (key); plan sin el flujo, como es el caso de Free (websocket); y, una vez abierta, la conexión que supera el límite del plan se cierra con el código 4003.
La sesión se abre por tanto ya autenticada: el primer mensaje puede ser la suscripción. La antigua trama op: auth se sigue aceptando y responde ok, pero es opcional.
{"op": "auth", "api_key": "your_key"}#Suscribirse
{
"op": "subscribe",
"channels": ["trades.futures.BTCUSDT", "book.BTCUSDT"],
"depth": 5
}Cancelar la suscripción es simétrico: op: unsubscribe, respuesta op: unsubscribed.
- La respuesta enumera todos los canales activos de la conexión, ordenados, no solo los de la petición. Es un estado, no un acuse de recibo.
depthse aplica a la conexión, no a un canal. Cadasubscribereinicia la profundidad de todos los libros de órdenes de la sesión; si se omite, vuelve a 5. Valores aceptados: de 1 a 5.- La suscripción es todo o nada. Si un solo canal de la lista es inválido, no se aplica ninguno y el error explica por qué. Una suscripción parcial te haría creer que recibes lo que pediste.
- En cuanto se acepta una suscripción a un libro de órdenes, el estado actual se envía de inmediato, sin esperar al siguiente tick.
#Los cuatro canales
| Canal | Naturaleza y cadencia |
|---|---|
| trades.futures.<SYMBOL> | Eventos. Un paquete cada 100 ms, ausente si no hay nada que decir. |
| trades.spot.<SYMBOL> | Eventos, misma cadencia. |
| liquidations.<SYMBOL> | Eventos, misma cadencia. |
| book.<SYMBOL> | Estado. Se emite en cada tick, aunque no haya cambios. |
Hay veinte perpetuos disponibles en los cuatro canales. Las dos stablecoins solo existen en spot: suscribirse a su flujo de futuros, a su libro de órdenes o a sus liquidaciones devuelve un error explícito, nunca un canal vacío en silencio.
Los canales y el plan
Cada canal pertenece a una familia de datos, la misma que en la API REST: trades.futures, trades.spot y book son de microestructura, liquidations de derivados. Una familia que falta en tu plan falta en todos los canales, incluido el flujo. En concreto: Free no tiene el flujo en absoluto (403 en el handshake), y todo el flujo está abierto a partir de Traders. El tiempo real no es la familia de ticks brutos, reservada a Startup, que designa el historial tick a tick de la API REST.
Un canal fuera del plan recibe {"op": "error", "message": "canal 'book.BTCUSDT' hors offre : la famille 'microstructure' n'est pas incluse dans votre abonnement."} y, como en cualquier suscripción, no se aplica ningún canal de la trama. El número de conexiones simultáneas por cuenta también depende del plan: 3 en Traders, 10 en Financial, 20 en Startup. Ver los precios.
#Formato de los mensajes
Cada mensaje enviado lleva cuatro claves comunes.
| Campo | Tipo | Descripción |
|---|---|---|
channel | string | El canal de origen. |
ts | integer | Marca de tiempo del servidor para el tick, en milisegundos. |
seq | integer | Número de secuencia, monótono por canal y por conexión. Un salto indica mensajes perdidos. |
venues_in | integer | Cuántos mercados alimentaron este mensaje. Un número, nunca una lista. |
Operaciones
{
"channel": "trades.futures.BTCUSDT",
"ts": 1787000000100,
"seq": 42,
"venues_in": 6,
"data": [
{ "ts": 1787000000037, "price": 71420.4, "qty": 0.125, "side": "buy" },
{ "ts": 1787000000091, "price": 71420.2, "qty": 1.400, "side": "sell" }
]
}side es el lado del taker: buy significa que un comprador consumió la oferta. Las operaciones se agrupan en tramos de 100 ms, pero no se pierde ninguna: cada una conserva su marca de tiempo original en data[].ts, distinta del ts del tick.
Liquidaciones
{
"channel": "liquidations.BTCUSDT",
"ts": 1787000000100,
"seq": 7,
"venues_in": 2,
"data": [
{ "ts": 1787000000064, "price": 71180.0, "qty": 2.5, "side": "long", "usd": 177950.0 }
]
}side es aquí el lado de la posición liquidada: long significa que se liquidó una posición larga. No todos los mercados usan esta convención; la normalización se hace por ti.
Libro de órdenes
{
"channel": "book.BTCUSDT",
"ts": 1787000000100,
"seq": 128,
"venues_in": 6,
"data": {
"bids": [[71420.0, 12.41], [71419.0, 8.07], [71418.0, 15.33]],
"asks": [[71421.0, 9.85], [71422.0, 4.12], [71423.0, 22.60]]
}
}Cada fila es [precio, cantidad], siendo la cantidad la suma de los mercados en ese nivel de precio. Los bids son decrecientes, los asks crecientes. Los precios se llevan a una rejilla común por activo; sin ella, cada mercado caería en sus propios niveles y el libro consolidado tendría más filas, cada una más fina.
La cobertura del libro de órdenes es más estrecha que la de las operaciones: un mercado no expone una ventana acotada aprovechable. Como en todas partes, venues_in indica la cobertura real del mensaje.
#Cuatro cosas que saber
El libro consolidado puede estar cruzado
En un solo mercado, el mejor comprador siempre está por debajo del mejor vendedor: el motor de emparejamiento los ejecutaría entre sí. Entre varios mercados esa salvaguarda no existe. El mejor bid de un mercado puede por tanto superar el mejor ask de otro.
No es una corrupción del flujo: es una ventana de arbitraje real, que existe porque mover capital entre mercados lleva tiempo y cuesta comisiones. Orden de magnitud medido en Bitcoin: de tres a ocho puntos básicos.
El número de operaciones no mide la actividad
Algunos mercados fraccionan sus ejecuciones al extremo, hasta órdenes de un satoshi, una fracción de céntimo. En el flujo consolidado, el número de operaciones está por tanto dominado por ejecuciones diminutas. Para medir la actividad, suma qty o qty × price; nunca cuentes las filas.
Un salto en seq significa que has perdido mensajes
La cola de envío de cada cliente está limitada a 256 tramas, unos ocho ticks (800 ms de sacudida absorbible). Un cliente que no consume lo bastante rápido ve descartados sus mensajes más antiguos; se degrada solo, sin afectar a los demás.
En los canales de eventos, un mensaje descartado se pierde definitivamente; el salto en seq es lo que te lo indica. En el libro de órdenes solo se guarda el último estado: un salto allí no tiene consecuencias, el siguiente estado sustituye por completo al anterior.
La clave se presenta en la URL, no en una trama
La clave se comprueba en el handshake, antes de que se abra la conexión: un plan que no incluye el flujo recibe un 403 y no se establece nada. Por eso viaja como parámetro de URL: un navegador no puede poner cabeceras en un WebSocket, y la comprobación debe hacerse antes de la apertura.
La trama {"op": "auth"} se sigue aceptando y responde ok, pero ya no es necesaria: la sesión se abre ya autenticada. El parámetro ?api_key= solo se acepta en el handshake de WebSocket, nunca en las rutas REST, y la pasarela no registra la línea de petición de /ws.
El número de conexiones simultáneas depende del plan
Cada plan limita el número de conexiones abiertas a la vez, para toda la cuenta, sumando todas las claves. La conexión que sobra se cierra de inmediato con el código 4003 y el límite en el motivo.
Una desconexión libera su plaza al instante: no hay nada que esperar ni que reiniciar.
venues_in es un número, nunca una lista
El flujo nunca nombra su mercado de origen. venues_in da la cobertura sin dar la fuente, y es indispensable: sin él, no hay forma de distinguir un mercado tranquilo de la mitad de los mercados caídos. Un mercado en silencio durante más de diez segundos sale automáticamente, y un mercado con el libro vacío nunca se cuenta.
#Límites y cierres
| Límite | Al superarlo |
|---|---|
| Clave ausente o inválida en la URL | 403 en el handshake, X-Deny-Reason: key |
| Flujo ausente del plan (Free) | 403 en el handshake, X-Deny-Reason: websocket |
| Simultaneous connections per account: 3 on Traders, 10 on Financial, 20 on Startup | cierre 4003, el límite en el motivo |
| Canal de una familia fuera del plan | op: error, no se aplica ningún canal de la trama |
| Aperturas: 1 por segundo, ráfaga de 10 | 429 en el handshake |
| Cola de envío de 256 tramas | se descartan las más antiguas, seq salta |
| 10 desbordamientos tolerados | cierre 4003, motivo «client too slow» |
| depth entre 1 y 5 | error, la suscripción no se aplica |
| Código de cierre | Motivo |
|---|---|
| 4003 (límite) | Se alcanzó el límite de conexiones simultáneas del plan, para toda la cuenta. Una desconexión libera su plaza al instante. |
| 4003 (cliente demasiado lento) | Cola saturada durante un periodo prolongado. Un cliente que pierde ochocientos milisegundos de flujo diez veces seguidas no se pondrá al día. |
| 4001 | Trama de auth explícita rechazada: la clave que lleva es inválida. |
| 4002 | Sesión nunca autenticada en 5 s. Residual, ya que la clave se comprueba en el handshake. |
Toda petición imposible recibe un error con nombre, nunca un silencio: símbolo desconocido, canal desconocido, mercado de futuros pedido para una stablecoin, depth fuera de rango, suscripción antes de autenticarse, mensaje ilegible. El error dice lo que se esperaba.
#Ejemplo completo
import asyncio
import json
import os
import websockets
async def main() -> None:
# The key travels in the URL, checked at the handshake: the session opens
# already authenticated. A refused key, or a plan without the stream, gets
# 403 before it opens (InvalidStatus on the websockets side).
url = f"wss://api.bytnode.com/ws?api_key={os.environ['BYTNODE_KEY']}"
async with websockets.connect(url) as ws:
await ws.send(json.dumps({
"op": "subscribe",
"channels": ["trades.futures.BTCUSDT", "book.BTCUSDT"],
"depth": 5,
}))
print(await ws.recv())
expected: dict[str, int] = {}
async for raw in ws:
msg = json.loads(raw)
channel, seq = msg["channel"], msg["seq"]
# A sequence jump signals lost messages: the client is not
# consuming fast enough.
if channel in expected and seq != expected[channel]:
print(f"!! {seq - expected[channel]} message(s) lost on {channel}")
expected[channel] = seq + 1
if channel.startswith("book."):
bids, asks = msg["data"]["bids"], msg["data"]["asks"]
if bids and asks:
# Can be negative: the consolidated book crosses.
spread = asks[0][0] - bids[0][0]
print(f"{channel} spread={spread:+.2f} venues={msg['venues_in']}")
else:
print(f"{channel} {len(msg['data'])} event(s)")
asyncio.run(main())