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 opérée : un trade entre, un trade sort.

Ce n’est pas une source de métriques — aucune fenêtre glissante, aucun compteur, aucune 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é se présente dans l’URL, en paramètre api_key, et elle est vérifiée au handshake — avant que la connexion ne s’ouvre. C’est la même clé que l’API REST. Trois refus 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 flux — c’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 reste acceptée et répond ok, mais elle est facultative.

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

#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 vaut pour la connexion, pas pour un canal. Chaque subscribe refixe la profondeur de tous les carnets 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 qu’on reçoit ce qu’on a demandé.
  • Dès qu’un abonnement à un carnet est accepté, l’état courant part immédiatement, sans attendre le tick suivant.

#Les quatre canaux

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

Dix perpétuels sont disponibles sur les quatre canaux. Les deux stablecoins n’existent qu’en comptant : s’abonner à leur flux à terme, à leur carnet ou à leurs liquidations rend une erreur explicite, jamais un canal silencieusement vide.

Les canaux et l’offre

Chaque canal relève d’une famille de données, la même que sur l’API REST : trades.futures, trades.spot et book sont 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 le flux du tout (403 au handshake), et tout le flux est ouvert dès 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 offres.

#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 compte, jamais une liste.

Trades

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 trades sont groupés par tranches de 100 ms, mais aucun n’est perdu : chacun 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 longue a été liquidée. Les places n’ont pas toutes cette convention — la normalisation est faite pour vous.

Carnet

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 laquelle chaque place tomberait dans ses propres cases et le carnet consolidé aurait plus de lignes, chacune moins fournie.

La couverture du carnet est plus étroite que celle des trades : une place n’expose pas une 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 sous le 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 fenêtre d’arbitrage réelle, 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 trades ne mesure pas l’activité

Certaines places fractionnent leurs exécutions à l’extrême — jusqu’à des ordres d’un satoshi, soit une fraction de centime. Dans le flux consolidé, le nombre de trades est donc dominé par des exécutions minuscules. Pour mesurer l’activité, sommez 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, soit environ huit ticks — 800 ms d’à-coup absorbable. Un client qui ne consomme pas assez vite voit ses messages les plus anciens écartés ; il se dégrade seul, sans affecter les autres.

Sur les canaux d’événements, un message écarté est définitivement perdu — c’est le saut de seq qui vous le dit. Sur le carnet, seul le dernier état est conservé : un saut y est sans conséquence, l’état suivant remplace intégralement le précédent.

La clé se présente dans l’URL, pas dans une trame

La clé est vérifiée au handshake, avant que la connexion ne s’ouvre : une offre qui n’inclut pas le flux reçoit un 403 et rien ne s’établit. C’est pour cela qu’elle voyage en paramètre d’URL — un navigateur ne peut pas poser d’en-tête sur une WebSocket, et le contrôle doit avoir lieu avant l’ouverture.

La trame {"op": "auth"} reste 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 le handshake 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 l’ensemble du compte — toutes clés confondues. La connexion de trop est fermée aussitôt avec le code 4003 et le plafond dans le motif.

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

venues_in est un compte, 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 d’une moitié de places tombées. Une place silencieuse depuis plus de dix secondes en sort automatiquement, et une place au carnet vide n’y est jamais comptée.

#Limites et fermetures

LimiteAu 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
Connexions simultanées par compte : 3 en Traders, 10 en Financial, 20 en Startupfermeture 4003, le plafond dans le motif
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 écartées, seq saute
10 débordements tolérésfermeture 4003, motif « client trop lent »
depth entre 1 et 5erreur, l’abonnement n’est pas appliqué
Code de fermetureMotif
4003 — plafondPlafond de connexions simultanées de l’offre atteint, pour le compte entier. Une déconnexion libère aussitôt son emplacement.
4003 — client trop lentFile saturée durablement. Un client qui perd dix fois de suite huit cents millisecondes de flux ne rattrapera pas.
4001Trame auth explicite refusée — la clé qu’elle porte est invalide.
4002Session jamais authentifiée dans les 5 s — résiduel, la clé étant vérifiée au handshake.

Toute demande impossible reçoit une erreur nommée, jamais un silence : symbole inconnu, canal inconnu, marché à terme 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:
    # La cle voyage dans l'URL, verifiee au handshake : la session s'ouvre
    # deja authentifiee. Une cle refusee, ou une offre sans flux, recoit 403
    # avant l'ouverture (InvalidStatus cote websockets).
    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())

        attendu: dict[str, int] = {}

        async for brut in ws:
            msg = json.loads(brut)
            canal, seq = msg["channel"], msg["seq"]

            # Un saut de sequence signale des messages perdus : le client ne
            # consomme pas assez vite.
            if canal in attendu and seq != attendu[canal]:
                print(f"!! {seq - attendu[canal]} message(s) perdu(s) sur {canal}")
            attendu[canal] = seq + 1

            if canal.startswith("book."):
                bids, asks = msg["data"]["bids"], msg["data"]["asks"]
                if bids and asks:
                    # Peut etre negatif : le carnet consolide se croise.
                    ecart = asks[0][0] - bids[0][0]
                    print(f"{canal} ecart={ecart:+.2f} places={msg['venues_in']}")
            else:
                print(f"{canal} {len(msg['data'])} evenement(s)")


asyncio.run(main())