Tiempo real

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

WSSwss://api.bytnode.com/ws?api_key=cb_live_…Transporte WebSocket

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.
  • depth se aplica a la conexión, no a un canal. Cada subscribe reinicia 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

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

CampoTipoDescripción
channelstringEl canal de origen.
tsintegerMarca de tiempo del servidor para el tick, en milisegundos.
seqintegerNúmero de secuencia, monótono por canal y por conexión. Un salto indica mensajes perdidos.
venues_inintegerCuántos mercados alimentaron este mensaje. Un número, nunca una lista.

Operaciones

trades
{
  "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

liquidations
{
  "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

book
{
  "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ímiteAl superarlo
Clave ausente o inválida en la URL403 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 Startupcierre 4003, el límite en el motivo
Canal de una familia fuera del planop: error, no se aplica ningún canal de la trama
Aperturas: 1 por segundo, ráfaga de 10429 en el handshake
Cola de envío de 256 tramasse descartan las más antiguas, seq salta
10 desbordamientos toleradoscierre 4003, motivo «client too slow»
depth entre 1 y 5error, la suscripción no se aplica
Código de cierreMotivo
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.
4001Trama de auth explícita rechazada: la clave que lleva es inválida.
4002Sesió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

Autenticación, suscripción, detección de pérdidas y lectura del libro cruzado.
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())