Temps réel

Flux WebSocket

Un relais. Les événements des places couvertes sont normalisés et fusionnés en une seule sortie, poussée toutes les 100 ms. C’est la seule transformation effectuée : une transaction entre, une transaction sort.

Ce n’est pas une source de métriques (ni fenêtre glissante, ni compteur, ni moyenne), et il n’y a pas d’historique : vous recevez ce qui se passe à partir de votre connexion. Le passé est servi par l’API REST.

#Connexion

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

La clé est présentée dans l’URL, comme paramètre api_key, et elle est vérifiée à la poignée de main, avant que la connexion ne s’ouvre. C’est la même clé que pour l’API REST. Trois refus sont possibles à la poignée de main, tous en 403 avec l’en-tête X-Deny-Reason : clé absente, invalide ou révoquée (key) ; offre sans le flux, ce qui est le cas de Free (websocket) ; et, une fois ouverte, la connexion de trop par rapport au plafond de l’offre est fermée avec le code 4003.

La session s’ouvre donc déjà authentifiée : le premier message peut être l’abonnement. L’ancienne trame op: auth est toujours acceptée et répond ok, mais elle est facultative.

{"op": "auth", "api_key": "your_key"}

#S’abonner

{
  "op": "subscribe",
  "channels": ["trades.futures.BTCUSDT", "book.BTCUSDT"],
  "depth": 5
}

Le désabonnement est symétrique : op: unsubscribe, réponse op: unsubscribed.

  • La réponse liste tous les canaux actifs de la connexion, triés, pas seulement ceux de la requête. C’est un état, pas un accusé de réception.
  • depth s’applique à la connexion, pas à un canal. Chaque subscribe réinitialise la profondeur de tous les carnets d’ordres de la session ; omis, il retombe à 5. Valeurs acceptées : 1 à 5.
  • L’abonnement est tout ou rien. Si un seul canal de la liste est invalide, aucun n’est appliqué et l’erreur dit pourquoi. Un abonnement partiel laisserait croire que vous recevez ce que vous avez demandé.
  • Dès qu’un abonnement à un carnet d’ordres est accepté, l’état courant est envoyé immédiatement, sans attendre le tick suivant.

#Les quatre canaux

CanalNature et cadence
trades.futures.<SYMBOL>Événements. Un paquet toutes les 100 ms, absent s’il n’y a rien à dire.
trades.spot.<SYMBOL>Événements, même cadence.
liquidations.<SYMBOL>Événements, même cadence.
book.<SYMBOL>État. Diffusé à chaque tick, même sans changement.

Vingt perpétuels sont disponibles sur les quatre canaux. Les deux stablecoins n’existent qu’en spot : s’abonner à leur flux futures, à leur carnet d’ordres ou à leurs liquidations renvoie une erreur explicite, jamais un canal vide en silence.

Les canaux et l’offre

Chaque canal appartient à une famille de données, la même que sur l’API REST : trades.futures, trades.spot et book relèvent de la microstructure, liquidations des dérivés. Une famille absente de votre offre l’est sur tous les canaux, flux compris. Concrètement : Free n’a pas du tout le flux (403 à la poignée de main), et tout le flux est ouvert à partir de Traders. Le temps réel n’est pas la famille des ticks bruts, réservée à Startup, qui désigne l’historique tick par tick de l’API REST.

Un canal hors offre reçoit {"op": "error", "message": "canal 'book.BTCUSDT' hors offre : la famille 'microstructure' n'est pas incluse dans votre abonnement."} et, comme pour tout abonnement, aucun canal de la trame n’est appliqué. Le nombre de connexions simultanées par compte dépend aussi de l’offre : 3 en Traders, 10 en Financial, 20 en Startup. Voir les tarifs.

#Format des messages

Chaque message poussé porte quatre clés communes.

ChampTypeDescription
channelstringLe canal d’origine.
tsintegerHorodatage serveur du tick, en millisecondes.
seqintegerNuméro de séquence, monotone par canal et par connexion. Un saut signale des messages perdus.
venues_inintegerCombien de places ont alimenté ce message. Un nombre, jamais une liste.

Transactions

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 est le côté du preneur : buy signifie qu’un acheteur a consommé l’offre. Les transactions sont regroupées par tranches de 100 ms, mais aucune n’est perdue : chacune garde son horodatage d’origine dans data[].ts, distinct du ts du tick.

Liquidations

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 est ici le côté de la position liquidée : long signifie qu’une position acheteuse a été liquidée. Toutes les places n’ont pas cette convention ; la normalisation est faite pour vous.

Carnet d’ordres

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]]
  }
}

Chaque ligne est [prix, quantité], la quantité étant la somme des places à ce niveau de prix. Les bids sont décroissants, les asks croissants. Les prix sont ramenés à une grille commune par actif, sans quoi chaque place tomberait dans ses propres paliers et le carnet consolidé aurait plus de lignes, chacune plus mince.

La couverture du carnet d’ordres est plus étroite que celle des transactions : une place n’expose pas de fenêtre bornée exploitable. Comme partout, venues_in dit la couverture réelle du message.

#Quatre choses à savoir

Le carnet consolidé peut être croisé

Sur une place unique, le meilleur acheteur est toujours en dessous du meilleur vendeur : le moteur d’appariement les exécuterait l’un contre l’autre. Entre plusieurs places, ce garde-fou n’existe pas. Le meilleur bid d’une place peut donc dépasser le meilleur ask d’une autre.

Ce n’est pas une corruption du flux : c’est une vraie fenêtre d’arbitrage, qui existe parce que déplacer du capital entre places prend du temps et coûte des frais. Ordre de grandeur mesuré sur Bitcoin : trois à huit points de base.

Le nombre de transactions ne mesure pas l’activité

Certaines places fractionnent leurs exécutions à l’extrême, jusqu’à des ordres d’un satoshi, une fraction de centime. Dans le flux consolidé, le nombre de transactions est donc dominé par de minuscules exécutions. Pour mesurer l’activité, additionnez qty ou qty × price, ne comptez jamais les lignes.

Un saut de seq signifie que vous avez perdu des messages

La file d’envoi de chaque client est bornée à 256 trames, environ huit ticks (800 ms d’à-coup absorbable). Un client qui ne consomme pas assez vite voit ses messages les plus anciens abandonnés ; il se dégrade seul, sans affecter les autres.

Sur les canaux d’événements, un message abandonné est définitivement perdu ; c’est le saut de seq qui vous le dit. Sur le carnet d’ordres, seul le dernier état est gardé : un saut n’y a aucune conséquence, l’état suivant remplace entièrement le précédent.

La clé est présentée dans l’URL, pas dans une trame

La clé est vérifiée à la poignée de main, avant que la connexion ne s’ouvre : une offre qui n’inclut pas le flux reçoit un 403 et rien n’est établi. C’est pourquoi elle voyage comme paramètre d’URL : un navigateur ne peut pas poser d’en-tête sur un WebSocket, et la vérification doit avoir lieu avant l’ouverture.

La trame {"op": "auth"} est toujours acceptée et répond ok, mais elle n’est plus nécessaire : la session s’ouvre déjà authentifiée. Le paramètre ?api_key= n’est accepté que sur la poignée de main WebSocket, jamais sur les routes REST, et la ligne de requête de /ws n’est pas journalisée par la passerelle.

Le nombre de connexions simultanées dépend de l’offre

Chaque offre borne le nombre de connexions ouvertes en même temps, pour tout le compte, toutes clés confondues. La connexion de trop est fermée aussitôt avec le code 4003 et le plafond dans la raison.

Une déconnexion libère immédiatement sa place : il n’y a rien à attendre ni rien à réinitialiser.

venues_in est un nombre, jamais une liste

Le flux ne nomme jamais sa place source. venues_in donne la couverture sans donner la source, et il est indispensable : sans lui, impossible de distinguer un marché calme de la moitié des places en panne. Une place muette depuis plus de dix secondes en sort automatiquement, et une place au carnet vide n’y est jamais comptée.

#Limites et fermetures

LimiteEn cas de dépassement
Clé absente ou invalide dans l’URL403 à la poignée de main, X-Deny-Reason: key
Flux absent de l’offre (Free)403 à la poignée de main, X-Deny-Reason: websocket
Simultaneous connections per account: 3 on Traders, 10 on Financial, 20 on Startupfermeture 4003, le plafond dans la raison
Canal d’une famille hors offreop: error, aucun canal de la trame n’est appliqué
Ouvertures : 1 par seconde, rafale de 10429 à la poignée de main
File d’envoi de 256 tramesles plus anciennes abandonnées, seq saute
10 débordements tolérésfermeture 4003, raison « client too slow »
depth entre 1 et 5erreur, l’abonnement n’est pas appliqué
Code de fermetureRaison
4003 (plafond)Plafond de connexions simultanées de l’offre atteint, pour tout le compte. Une déconnexion libère aussitôt sa place.
4003 (client trop lent)File saturée de façon prolongée. Un client qui perd huit cents millisecondes de flux dix fois de suite ne rattrapera pas son retard.
4001Trame d’auth explicite refusée : la clé qu’elle porte est invalide.
4002Session jamais authentifiée en 5 s. Résiduel, puisque la clé est vérifiée à la poignée de main.

Toute demande impossible reçoit une erreur nommée, jamais un silence : symbole inconnu, canal inconnu, marché futures demandé sur un stablecoin, depth hors bornes, abonnement avant authentification, message illisible. L’erreur dit ce qui était attendu.

#Exemple complet

Authentification, abonnement, détection des pertes et lecture du carnet croisé.
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())