Il y a trois mois, je lançais PolyArb, un petit bot d'arbitrage que je développais le soir après mon travail de dev backend. Le bot avalait déjà 4,2 millions d'order book updates par jour répartis sur trois exchanges, et mon notebook Jupyter commençait à ressembler à une usine à gaz : trois parsers différents, trois fuseaux horaires implicites, trois conventions de décimales (2 pour BTC-USDT sur Binance, 1 pour le spot OKX, 1 ou 2 selon le pair sur Bybit). Une simple migration de websocket m'a coûté deux soirées. C'est précisément ce problème que résout un Normalized Book Snapshot : transformer trois flux hétérogènes en un seul objet stable, typé, et exploitable par le reste de votre stack — y compris par un LLM via l'API S'inscrire ici HolySheep AI.
Pourquoi la normalisation est non négociable
Chaque exchange expose son flux de profondeur selon sa propre grammaire. Binance envoie un diff stream avec lastUpdateId et des tableaux [price, qty] ordonnés du meilleur au pire prix, sans timestamp explicite côté message (on doit le synchroniser via l'event time du serveur). OKX pousse un canal books5/books50/books-l2-tbt avec un ts en millisecondes et un format {checksum, asks, bids, ts} où les prix sont des strings. Bybit, sur le canal orderbook.50.SYMBOL, livre un objet {s, b, a, u, seq} où u et seq servent à détecter les trous — différent du mécanisme de Binance.
Sans couche d'abstraction, votre code de stratégie devient tributaire des changements d'API de chaque plateforme. Avec une couche normalisée, vous pouvez brancher un nouveau connecteur en une journée, et — c'est le point clé — envoyer un payload uniforme à un modèle de langage pour de l'analyse qualitative (« explique pourquoi le spread BTC-USDT s'est creusé à 14h32 UTC »).
Comparaison des formats natifs
| Champ | Binance Spot | OKX Spot | Bybit Spot |
|---|---|---|---|
| Granularité WS | 1000 ms (depth) / 100 ms (depth@100ms) | 100 ms (books5) ou tick-by-tick | 20–100 ms selon le canal |
| Identifiant de séquence | lastUpdateId (uint64) | checksum + ts (string) | u (updateId) + seq |
| Type prix | float64 | string décimal | string décimal |
| Décimales prix (BTC-USDT) | 2 | 1 ou 2 | 1 ou 2 |
| Décimales quantité | 5 | 8 | 5 |
| Timestamp message | implicite (eventTime serveur) | ts en ms (string) | ts en ms (number) |
| Vérification d'intégrité | lastUpdateId + U/u combo | CRC32 checksum | continuité u/seq |
| Latence p95 mesurée Paris↔exchange | 38 ms | 47 ms | 52 ms |
Schéma normalisé « BookSnapshot »
Voici le schéma cible, en Python avec Pydantic v2, que j'utilise désormais dans tous mes connecteurs :
from pydantic import BaseModel, Field
from typing import List, Literal
class Level(BaseModel):
"""Un niveau de prix dans la profondeur."""
price: float = Field(..., ge=0, description="Prix unitaire, normalisé en float64")
qty: float = Field(..., ge=0, description="Quantité disponible à ce prix")
class BookSnapshot(BaseModel):
"""Snapshot de profondeur normalisé cross-exchange."""
exchange: Literal["binance", "okx", "bybit"]
symbol: str = Field(..., description="Paire normalisée, ex: 'BTC-USDT'")
ts_exchange: int = Field(..., description="Timestamp exchange en ms epoch")
ts_local: int = Field(..., description="Timestamp local de réception en ms epoch")
bids: List[Level] = Field(default_factory=list)
asks: List[Level] = Field(default_factory=list)
seq: str | int = Field(..., description="ID de séquence (string pour OKX, uint pour Binance/Bybit)")
checksum: str | None = Field(None, description="Checksum fourni par l'exchange, ou None")
@property
def mid(self) -> float:
if not self.b