Introduction aux Pipelines de Données Orderbook en Temps Réel

Les données de carnet d'ordres (orderbook) constituent le fondement de nombreuses stratégies de trading algorithmique, d'analyse de marché et de systèmes de surveillance des risques. Dans cet article, nous explorerons comment construire un pipeline de données orderbook capable de gérer à la fois la lecture historique et l'intégration en temps réel via WebSocket, avec une latence mesurée en millisecondes.

Étude de Cas : Migration d'une Scale-up Fintech Parisienne

Contexte Métier

Notre cliente, une scale-up fintech parisienne spécialisée dans les signaux de trading algorithmique, gérait un volume quotidien de 2,4 millions de mises à jour de carnets d'ordres provenant de six exchangeurs différents. L'équipe technique, basée à Paris et composée de quatre développeurs seniors, faisait face à des défis croissants de scalabilité et de latence.

Douleurs du Fournisseur Précédent

Avant leur migration vers notre architecture basée sur Tardis WebSocket, l'entreprise utilisait une solution basée sur des API REST polling. Cette approche présentait plusieurs limitations critiques :

Étapes Concrètes de la Migration

La migration vers notre architecture Tardis WebSocket s'est déroulée en quatre phases distinctes sur une période de six semaines :

Phase 1 - Analyse et Cartographie (Semaine 1-2) : Audit complet du système existant, identification des points de intégration critiques et définition des métriques de succès. L'équipe a cartographié 847 points de données différents dans leur pipeline existant.

Phase 2 - Développement du Connecteur Tardis (Semaine 2-3) : Implémentation du connecteur WebSocket avec gestion intelligente de la reconnexion, bufferisation des messages et normalisation des formats de données. Le code source du connecteur a été写成 en Python 3.11 avec async/await pour une performance optimale.

Phase 3 - Déploiement Canari (Semaine 4-5) : Déploiement progressif avec répartition du trafic (10% → 30% → 50% → 100%) entre l'ancien système et le nouveau. Cette approche a permis d'identifier et de résoudre les problèmes de compatibilité sans impact sur les utilisateurs finaux.

Phase 4 - Bascule et Optimisation (Semaine 6) : Migration complète vers le nouveau système avec monitoring intensif des 72 premières heures et ajustements de performance en temps réel.

Métriques à 30 Jours Post-Migration

Les résultats mesurés un mois après la migration complète démontrent l'efficacité de l'architecture Tardis WebSocket :

Architecture Technique du Pipeline Orderbook

Vue d'Ensemble de l'Architecture

Notre architecture de pipeline de données orderbook repose sur trois composants principaux : le collecteur de données via WebSocket, le moteur de traitement stream-processing, et la couche de persistence optimisée pour les requêtes temporelles.

Composant 1 : Connexion WebSocket avec Tardis

Le connecteur WebSocket assure la connexion bidirectionnelle avec les servers de données. Voici l'implémentation complète du connecteur avec gestion avancée des erreurs et reconnexion automatique :

import asyncio
import json
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Callable
import hashlib
import hmac

class TardisWebSocketClient:
    """Client WebSocket pour la connexion aux flux Tardis avec support historique et temps réel."""
    
    def __init__(
        self,
        api_key: str,
        api_secret: str,
        exchanges: List[str],
        channels: List[str],
        on_orderbook_update: Callable,
        on_error: Callable,
        base_url: str = "wss://api.tardis.dev/v1/ws"
    ):
        self.api_key = api_key
        self.api_secret = api_secret
        self.exchanges = exchanges
        self.channels = channels
        self.on_orderbook_update = on_orderbook_update
        self.on_error = on_error
        self.base_url = base_url
        self.websocket = None
        self.reconnect_delay = 1
        self.max_reconnect_delay = 60
        self.is_running = False
        self.message_buffer = []
        self.buffer_size = 1000
        self.last_heartbeat = None
        self.sequence_numbers = {}
        self.logger = logging.getLogger(__name__)
        
    def _generate_auth_signature(self, timestamp: str, nonce: str) -> str:
        """Génère la signature d'authentification HMAC-SHA256."""
        message = f"{timestamp}{nonce}{self.api_key}"
        signature = hmac.new(
            self.api_secret.encode('utf-8'),
            message.encode('utf-8'),
            hashlib.sha256
        ).hexdigest()
        return signature
    
    def _create_auth_message(self) -> Dict:
        """Crée le message d'authentification pour Tardis."""
        timestamp = datetime.utcnow().isoformat() + 'Z'
        nonce = hashlib.sha256(
            f"{timestamp}{self.api_key}".encode()
        ).hexdigest()[:16]
        signature = self._generate_auth_signature(timestamp, nonce)
        
        return {
            "type": "auth",
            "apiKey": self.api_key,
            "timestamp": timestamp,
            "nonce": nonce,
            "signature": signature,
            "signatureVersion": 2
        }
    
    def _create_subscribe_message(
        self, 
        exchange: str, 
        channel: str,
        symbol: Optional[str] = None,
        from_timestamp: Optional[datetime] = None
    ) -> Dict:
        """Crée le message d'abonnement pour un canal spécifique."""
        message = {
            "type": "subscribe",
            "exchange": exchange,
            "channel": channel,
        }
        
        if symbol:
            message["symbol"] = symbol
            
        if from_timestamp:
            message["fromTimestamp"] = from_timestamp.isoformat() + 'Z'
            
        return message
    
    async def connect(self, historical_from: Optional[datetime] = None):
        """Établit la connexion WebSocket et s'abonne aux canaux."""
        try:
            self.logger.info(f"Connexion à {self.base_url}")
            self.websocket = await asyncio.get_event_loop().create_connection(
                self._create_websocket_endpoint(),
                self.base_url.replace('wss://', '').replace('https://', '').replace('http://', ''),
                ssl=True
            )
            
            # Envoyer le message d'authentification
            auth_message = self._create_auth_message()
            await self._send_message(auth_message)
            
            # Attendre la confirmation d'authentification
            auth_response = await self._wait_for_message_type("auth_success", timeout=10)
            if not auth_response:
                raise Exception("Échec de l'authentification avec Tardis")
            
            self.logger.info("Authentification réussie")
            
            # S'abonner aux canaux pour chaque exchange
            for exchange in self.exchanges:
                for channel in self.channels:
                    subscribe_msg = self._create_subscribe_message(
                        exchange=exchange,
                        channel=channel,
                        from_timestamp=historical_from if historical_from else None
                    )
                    await self._send_message(subscribe_msg)
                    self.logger.info(f"Abonnement à {exchange}/{channel}")
                    
            self.is_running = True
            self.reconnect_delay = 1
            
        except Exception as e:
            self.logger.error(f"Erreur de connexion : {e}")
            self.on_error(f"Connexion_erreur: {str(e)}")
            await self._schedule_reconnect(historical_from)
    
    async def _create_websocket_endpoint(self):
        """Crée l'endpoint WebSocket personnalisé."""
        from websockets.client import WebSocketClientProtocol
        
        class TardisWebSocket(WebSocketClientProtocol):
            async def get_mask(self, mask):
                return mask
        
        return TardisWebSocket
    
    async def _send_message(self, message: Dict):
        """Envoie un message JSON via WebSocket."""
        if self.websocket and self.websocket.open:
            await self.websocket.send(json.dumps(message))
    
    async def _wait_for_message_type(self, message_type: str, timeout: float = 5) -> Optional[Dict]:
        """Attend un type de message spécifique avec timeout."""
        try:
            start_time = asyncio.get_event_loop().time()
            while asyncio.get_event_loop().time() - start_time < timeout:
                if self.websocket and self.websocket.open:
                    message = await asyncio.wait_for(
                        self.websocket.recv(),
                        timeout=timeout
                    )
                    data = json.loads(message)
                    if data.get("type") == message_type:
                        return data
            return None
        except asyncio.TimeoutError:
            return None
    
    async def _schedule_reconnect(self, historical_from: Optional[datetime]):
        """Planifie une reconnexion avec backoff exponentiel."""
        self.is_running = False
        self.logger.info(f"Reconnexion dans {self.reconnect_delay} secondes...")
        await asyncio.sleep(self.reconnect_delay)
        self.reconnect_delay = min(
            self.reconnect_delay * 2, 
            self.max_reconnect_delay
        )
        await self.connect(historical_from)
    
    async def start_historical_replay(
        self, 
        start_time: datetime, 
        end_time: datetime,
        exchange: str,
        channel: str,
        symbol: Optional[str] = None
    ):
        """Démarre la relecture historique avec progression."""
        self.logger.info(f"Début de la relecture historique {start_time} -> {end_time}")
        
        current_time = start_time
        total_duration = (end_time - start_time).total_seconds()
        processed_messages = 0
        
        while current_time < end_time and self.is_running:
            try:
                # Créer une connexion pour la période historique
                historical_client = TardisWebSocketClient(
                    api_key=self.api_key,
                    api_secret=self.api_secret,
                    exchanges=[exchange],
                    channels=[channel],
                    on_orderbook_update=self.on_orderbook_update,
                    on_error=self.on_error
                )
                
                await historical_client.connect(historical_from=current_time)
                
                # Traiter les messages historiques
                while current_time < end_time and historical_client.is_running:
                    message = await asyncio.wait_for(
                        historical_client.websocket.recv(),
                        timeout=1.0
                    )
                    await self._process_message(message)
                    processed_messages += 1
                    
                    # Calculer et afficher la progression
                    elapsed = (current_time - start_time).total_seconds()
                    progress = (elapsed / total_duration) * 100
                    if processed_messages % 10000 == 0:
                        self.logger.info(f"Progression: {progress:.2f}% ({processed_messages} messages)")
                    
                    current_time += timedelta(milliseconds=100)
                
                await historical_client.disconnect()
                
            except Exception as e:
                self.logger.error(f"Erreur pendant la relecture : {e}")
                await asyncio.sleep(1)
        
        self.logger.info(f"Relecture terminée: {processed_messages} messages traités")
    
    async def _process_message(self, raw_message: str):
        """Traite un message entrant avec validation et transformation."""
        try:
            data = json.loads(raw_message)
            message_type = data.get("type")
            
            if message_type == "heartbeat":
                self.last_heartbeat = datetime.utcnow()
                return
            
            elif message_type == "orderbook_snapshot":
                normalized = self._normalize_orderbook_snapshot(data)
                await self._add_to_buffer(normalized)
                await self.on_orderbook_update(normalized)
                
            elif message_type == "orderbook_update":
                normalized = self._normalize_orderbook_update(data)
                await self._add_to_buffer(normalized)
                await self.on_orderbook_update(normalized)
                
            elif message_type == "error":
                self.logger.error(f"Erreur Tardis : {data}")
                self.on_error(f"Tardis_erreur: {data.get('message', 'Unknown')}")
            
            # Validation du numéro de séquence pour détection des pertes
            if "exchangeSequence" in data:
                exchange_id = data.get("exchange")
                seq = data.get("exchangeSequence")
                if exchange_id in self.sequence_numbers:
                    expected = self.sequence_numbers[exchange_id] + 1
                    if seq != expected:
                        self.logger.warning(
                            f"Séquence interrompue pour {exchange_id}: "
                            f"attendu {expected}, reçu {seq}"
                        )
                        self.on_error(f"Sequence_gap_{exchange_id}:{expected}:{seq}")
                self.sequence_numbers[exchange_id] = seq
                
        except json.JSONDecodeError as e:
            self.logger.error(f"Erreur de parsing JSON : {e}")
            self.on_error(f"JSON_parse_error: {str(e)}")
        except Exception as e:
            self.logger.error(f"Erreur de traitement : {e}")
            self.on_error(f"Traitement_erreur: {str(e)}")
    
    def _normalize_orderbook_snapshot(self, data: Dict) -> Dict:
        """Normalise un snapshot de carnet d'ordres vers format interne."""
        return {
            "timestamp": datetime.fromisoformat(data["timestamp"].replace('Z', '+00:00')),
            "exchange": data["exchange"],
            "symbol": data["symbol"],
            "type": "snapshot",
            "bids": [[float(price), float(size)] for price, size in data.get("bids", [])],
            "asks": [[float(price), float(size)] for price, size in data.get("asks", [])],
            "sequence": data.get("exchangeSequence"),
            "local_timestamp": datetime.utcnow()
        }
    
    def _normalize_orderbook_update(self, data: Dict) -> Dict:
        """Normalise une mise à jour de carnet d'ordres vers format interne."""
        normalized = {
            "timestamp": datetime.fromisoformat(data["timestamp"].replace('Z', '+00:00')),
            "exchange": data["exchange"],
            "symbol": data["symbol"],
            "type": "update",
            "sequence": data.get("exchangeSequence"),
            "local_timestamp": datetime.utcnow()
        }
        
        if "bids" in data:
            normalized["bids"] = [
                [float(price), float(size)] for price, size in data["bids"]
            ]
        if "asks" in data:
            normalized["asks"] = [
                [float(price), float(size)] for price, size in data["asks"]
            ]
            
        return normalized
    
    async def _add_to_buffer(self, data: Dict):
        """Ajoute les données au buffer circulaire."""
        self.message_buffer.append(data)
        if len(self.message_buffer) > self.buffer_size:
            self.message_buffer.pop(0)
    
    async def run(self):
        """Boucle principale de traitement des messages."""
        await self.connect()
        
        while self.is_running:
            try:
                if self.websocket and self.websocket.open:
                    message = await asyncio.wait_for(
                        self.websocket.recv(),
                        timeout=30.0
                    )
                    await self._process_message(message)
                    
            except asyncio.TimeoutError:
                if not self.last_heartbeat:
                    self.logger.warning("Pas de heartbeat reçu")
                elif (datetime.utcnow() - self.last_heartbeat).seconds > 60:
                    self.logger.error("Heartbeat expiré, reconnexion...")
                    await self._schedule_reconnect(None)
                    
            except Exception as e:
                self.logger.error(f"Erreur dans la boucle principale : {e}")
                self.on_error(f"Boucle_principale_erreur: {str(e)}")
                await self._schedule_reconnect(None)
    
    async def disconnect(self):
        """Ferme proprement la connexion WebSocket."""
        self.is_running = False
        if self.websocket and self.websocket.open:
            await self.websocket.close()
        self.logger.info("Déconnexion effectuée")


Exemple d'utilisation avec OrderbookAggregator

class OrderbookAggregator: """Agrégateur de données orderbook multi-sources avec gestion du orderbook local.""" def __init__(self): self.orderbooks: Dict[str, Dict] = {} self.update_counters: Dict[str, int] = {} self.last_update_times: Dict[str, datetime] = {} self.latencies: List[float] = [] async def on_orderbook_update(self, data: Dict): """Callback pour traiter les mises à jour du orderbook.""" key = f"{data['exchange']}:{data['symbol']}" if key not in self.orderbooks: self.orderbooks[key] = { "bids": {}, "asks": {}, "last_sequence": None } ob = self.orderbooks[key] # Traiter le snapshot complet if data["type"] == "snapshot": ob["bids"] = {price: size for price, size in data["bids"]} ob["asks"] = {price: size for price, size in data["asks"]} ob["last_sequence"] = data["sequence"] # Appliquer les mises à jour delta else: for price, size in data.get("bids", []): if size == 0: ob["bids"].pop(price, None) else: ob["bids"][price] = size for price, size in data.get("asks", []): if size == 0: ob["asks"].pop(price, None) else: ob["asks"][price] = size ob["last_sequence"] = data["sequence"] # Calculer et stocker la latence latency_ms = (data["local_timestamp"] - data["timestamp"]).total_seconds() * 1000 self.latencies.append(latency_ms) if len(self.latencies) > 10000: self.latencies.pop(0) self.update_counters[key] = self.update_counters.get(key, 0) + 1 self.last_update_times[key] = datetime.utcnow() # Log les métriques toutes les 1000 mises à jour if self.update_counters[key] % 1000 == 0: avg_latency = sum(self.latencies[-1000:]) / len(self.latencies[-1000:]) print(f"{key}: {self.update_counters[key]} updates, " f"latence moyenne: {avg_latency:.2f}ms") def get_best_bid_ask(self, exchange: str, symbol: str) -> Optional[Dict]: """Retourne le meilleur bid/ask pour un paire donnée.""" key = f"{exchange}:{symbol}" if key not in self.orderbooks: return None ob = self.orderbooks[key] bids = ob["bids"] asks = ob["asks"] if not bids or not asks: return None best_bid = max(bids.items(), key=lambda x: x[0]) best_ask = min(asks.items(), key=lambda x: x[0]) return { "exchange": exchange, "symbol": symbol, "best_bid": best_bid[0], "best_bid_size": best_bid[1], "best_ask": best_ask[0], "best_ask_size": best_ask[1], "spread": best_ask[0] - best_bid[0], "spread_pct": ((best_ask[0] - best_bid[0]) / best_bid[0]) * 100, "mid_price": (best_bid[0] + best_ask[0]) / 2, "timestamp": self.last_update_times.get(key) } async def main(): """Point d'entrée principal pour tester le pipeline.""" # Configuration du logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) # Initialisation de l'agrégateur aggregator = OrderbookAggregator() # Liste des erreurs pour monitoring errors = [] def error_handler(error_msg: str): errors.append({ "timestamp": datetime.utcnow(), "error": error_msg }) print(f"⚠️ Erreur détectée: {error_msg}") # Configuration du client WebSocket client = TardisWebSocketClient( api_key="VOTRE_CLE_API_TARDIS", api_secret="VOTRE_SECRET_API_TARDIS", exchanges=["binance", "coinbase", "kraken"], channels=["orderbook"], on_orderbook_update=aggregator.on_orderbook_update, on_error=error_handler ) # Option 1: Connexion temps réel uniquement print("Démarrage de la connexion temps réel...") await client.run() # Option 2: Relecture historique puis connexion temps réel # historical_start = datetime(2024, 1, 1, 0, 0, 0) # historical_end = datetime(2024, 1, 1, 1, 0, 0) # # print("Démarrage de la relecture historique...") # await client.start_historical_replay( # start_time=historical_start, # end_time=historical_end, # exchange="binance", # channel="orderbook", # symbol="BTC-USD" # ) # # print("Connexion au flux temps réel...") # await client.run() if __name__ == "__main__": asyncio.run(main())

Composant 2 : Moteur de Stream Processing avec Ordre d'Exécution Garanti

Le moteur de traitement stream est conçu pour garantir l'ordre d'exécution des messages et gérer les problématiques de backpressure. Voici l'implémentation complète avec support pour le orderbook local et la détection d'anomalies :

import asyncio
import heapq
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Callable, Any
from datetime import datetime, timedelta
from enum import Enum
import statistics
import logging

class ProcessingPriority(Enum):
    """Priorités de traitement des messages."""
    CRITICAL = 1  # Ordrebooks complets
    HIGH = 2      # Mises à jour avec sequence gap
    NORMAL = 3    # Mises à jour régulières
    LOW = 4       # Données historiques moins urgentes

@dataclass(order=True)
class PrioritizedMessage:
    """Message avec priorité pour le traitement par heap."""
    priority: int
    timestamp: datetime = field(compare=False)
    sequence: int = field(compare=False, default=0)
    exchange: str = field(compare=False, default="")
    data: Dict = field(compare=False, default_factory=dict)
    retry_count: int = field(compare=False, default=0)

@dataclass
class ProcessingMetrics:
    """Métriques de performance du traitement."""
    messages_processed: int = 0
    messages_failed: int = 0
    processing_times: List[float] = field(default_factory=list)
    queue_sizes: List[int] = field(default_factory=list)
    sequence_gaps: int = 0
    last_sequence_per_exchange: Dict[str, int] = field(default_factory=dict)
    
    def get_statistics(self) -> Dict[str, Any]:
        """Calcule les statistiques de performance."""
        if not self.processing_times:
            return {"error": "Pas assez de données"}
            
        sorted_times = sorted(self.processing_times)
        return {
            "total_processed": self.messages_processed,
            "total_failed": self.messages_failed,
            "success_rate": self.messages_processed / 
                           (self.messages_processed + self.messages_failed) * 100,
            "avg_processing_time_ms": statistics.mean(self.processing_times),
            "p50_processing_time_ms": sorted_times[len(sorted_times) // 2],
            "p95_processing_time_ms": sorted_times[int(len(sorted_times) * 0.95)],
            "p99_processing_time_ms": sorted_times[int(len(sorted_times) * 0.99)],
            "max_processing_time_ms": max(self.processing_times),
            "avg_queue_size": statistics.mean(self.queue_sizes) if self.queue_sizes else 0,
            "max_queue_size": max(self.queue_sizes) if self.queue_sizes else 0,
            "sequence_gaps": self.sequence_gaps
        }

class OrderbookLocal:
    """Gestionnaire de orderbook local avec reconstruction incrémentale."""
    
    def __init__(self, max_levels: int = 20):
        self.max_levels = max_levels
        self.bids: Dict[float, float] = {}  # price -> size
        self.asks: Dict[float, float] = {}  # price -> size
        self.last_update: Optional[datetime] = None
        self.update_count: int = 0
        self.spread_history: List[float] = []
        self.logger = logging.getLogger(__name__)
        
    def apply_snapshot(self, bids: List[List[float]], asks: List[List[float]], timestamp: datetime):
        """Applique un snapshot complet."""
        self.bids.clear()
        self.asks.clear()
        
        # Trier et limiter les niveaux
        for price, size in sorted(bids, key=lambda x: -x[0])[:self.max_levels]:
            self.bids[price] = size
        for price, size in sorted(asks, key=lambda x: x[0])[:self.max_levels]:
            self.asks[price] = size
            
        self.last_update = timestamp
        self.update_count += 1
        self._update_spread()
        
    def apply_update(self, bids: List[List[float]], asks: List[List[float]], timestamp: datetime):
        """Applique une mise à jour incrémentale."""
        for price, size in bids:
            if size == 0:
                self.bids.pop(price, None)
            else:
                self.bids[price] = size
                
        for price, size in asks:
            if size == 0:
                self.asks.pop(price, None)
            else:
                self.asks[price] = size
                
        self.last_update = timestamp
        self.update_count += 1
        self._update_spread()
    
    def _update_spread(self):
        """Met à jour l'historique du spread."""
        best_bid = max(self.bids.keys()) if self.bids else 0
        best_ask = min(self.asks.keys()) if self.asks else 0
        
        if best_bid and best_ask:
            spread = best_ask - best_bid
            self.spread_history.append(spread)
            if len(self.spread_history) > 1000:
                self.spread_history.pop(0)
    
    def get_depth(self, levels: int = 10) -> Dict[str, Any]:
        """Retourne la profondeur du marché."""
        sorted_bids = sorted(self.bids.items(), key=lambda x: -x[0])[:levels]
        sorted_asks = sorted(self.asks.items(), key=lambda x: x[0])[:levels]
        
        bid_volume = sum(size for _, size in sorted_bids)
        ask_volume = sum(size for _, size in sorted_asks)
        bid_value = sum(price * size for price, size in sorted_bids)
        ask_value = sum(price * size for price, size in sorted_asks)
        
        return {
            "timestamp": self.last_update,
            "bid_levels": [
                {"price": price, "size": size, "cumulative": 0}
                for price, size in sorted_bids
            ],
            "ask_levels": [
                {"price": price, "size": size, "cumulative": 0}
                for price, size in sorted_asks
            ],
            "total_bid_volume": bid_volume,
            "total_ask_volume": ask_volume,
            "bid_value": bid_value,
            "ask_value": ask_value,
            "imbalance": (bid_volume - ask_volume) / (bid_volume + ask_volume) if (bid_volume + ask_volume) > 0 else 0,
            "spread": (min(self.asks.keys()) - max(self.bids.keys())) if self.bids and self.asks else None,
            "mid_price": (min(self.asks.keys()) + max(self.bids.keys())) / 2 if self.bids and self.asks else None
        }
    
    def detect_anomalies(self, volatility_threshold: float = 0.05) -> List[Dict]:
        """Détecte les anomalies dans le orderbook."""
        anomalies = []
        
        if len(self.spread_history) < 10:
            return anomalies
            
        # Calculer la volatilité du spread
        spread_std = statistics.stdev(self.spread_history[-100:])
        spread_mean = statistics.mean(self.spread_history[-100:])
        
        if spread_mean > 0 and spread_std / spread_mean > volatility_threshold:
            anomalies.append({
                "type": "high_spread_volatility",
                "severity": "warning",
                "message": f"Volatilité du spread elevated: {spread_std/spread_mean:.2%}",
                "timestamp": datetime.utcnow()
            })
        
        # Détecter les ordres de taille inhabituelle
        all_bid_sizes = list(self.bids.values())
        all_ask_sizes = list(self.asks.values())
        
        if all_bid_sizes:
            avg_bid = statistics.mean(all_bid_sizes)
            max_bid = max(all_bid_sizes)
            if max_bid > avg_bid * 10:
                anomalies.append({
                    "type": "large_order_detected",
                    "severity": "info",
                    "message": f"Gros ordre détecté: {max_bid} vs moyenne {avg_bid:.2f}",
                    "side": "bid",
                    "size": max_bid,
                    "timestamp": datetime.utcnow()
                })
                
        return anomalies


class StreamProcessor:
    """Moteur de traitement de flux avec gestion de l'ordre et backpressure."""
    
    def __init__(
        self,
        max_queue_size: int = 100000,
        batch_size: int = 100,
        processing_timeout: float = 1.0,
        enable_sequence_validation: bool = True
    ):
        self.max_queue_size = max_queue_size
        self.batch_size = batch_size
        self.processing_timeout = processing_timeout
        self.enable_sequence_validation = enable_sequence_validation
        
        # Files de priorité pour le traitement
        self.priority_queues: Dict[ProcessingPriority, List] = {
            priority: [] for priority in ProcessingPriority
        }
        
        # Orderbooks locaux par exchange/symbol
        self.orderbooks: Dict[str, OrderbookLocal] = {}
        
        # Métriques de performance
        self.metrics = ProcessingMetrics()
        
        # Callbacks
        self.processors: Dict[str, Callable] = {}
        self.anomaly_handlers: List[Callable] = []
        
        # État
        self.is_running = False
        self.is_paused = False
        self.total_queue_size = 0
        
        self.logger = logging.getLogger(__name__)
        
    def register_processor(self, event_type: str, handler: Callable):
        """Enregistre un processeur pour un type d'événement."""
        self.processors[event_type] = handler
        
    def register_anomaly_handler(self, handler: Callable):
        """Enregistre un gestionnaire d'anomalies."""
        self.anomaly_handlers.append(handler)
    
    def _determine_priority(self, message: Dict) -> ProcessingPriority:
        """Détermine la priorité de traitement d'un message."""
        if message.get("type") == "snapshot":
            return ProcessingPriority.CRITICAL
            
        if self.enable_sequence_validation:
            exchange = message.get("exchange", "")
            sequence = message.get("sequence", 0)
            
            if exchange in self.metrics.last_sequence_per_exchange:
                expected = self.metrics.last_sequence_per_exchange[exchange] + 1
                if sequence != expected:
                    self.metrics.sequence_gaps += 1
                    return ProcessingPriority.HIGH
                    
        return ProcessingPriority.NORMAL
    
    def enqueue(self, message: Dict) -> bool:
        """Ajoute un message à la file de traitement."""
        if self.total_queue_size >= self.max_queue_size:
            self.logger.warning(f"Queue pleine ({self.max_queue_size}), backpressure actif")
            return False
            
        priority = self._determine_priority(message)
        
        prioritized = PrioritizedMessage(
            priority=priority.value,
            timestamp=message.get("timestamp", datetime.utcnow()),
            sequence=message.get("sequence", 0),
            exchange=message.get("exchange", ""),
            data=message
        )
        
        heapq.heappush(self.priority_queues[priority], prioritized)
        self.total_queue_size += 1
        
        return True
    
    def _get_next_message(self) -> Optional[PrioritizedMessage]:
        """Récupère