""" Huobi Global exchange connector implementation. Supports WebSocket connections to Huobi with proper symbol mapping. """ import json import gzip import hmac import hashlib import base64 import time from typing import Dict, List, Optional, Any from datetime import datetime, timezone from ..models.core import OrderBookSnapshot, TradeEvent, PriceLevel from ..utils.logging import get_logger, set_correlation_id from ..utils.exceptions import ValidationError, ConnectionError from ..utils.validation import validate_symbol, validate_price, validate_volume from .base_connector import BaseExchangeConnector logger = get_logger(__name__) class HuobiConnector(BaseExchangeConnector): """ Huobi Global WebSocket connector implementation. Supports: - Order book streams - Trade streams - Symbol normalization - GZIP message decompression - Authentication for private channels """ # Huobi WebSocket URLs WEBSOCKET_URL = "wss://api.huobi.pro/ws" WEBSOCKET_PRIVATE_URL = "wss://api.huobi.pro/ws/v2" API_URL = "https://api.huobi.pro" def __init__(self, api_key: str = None, api_secret: str = None): """ Initialize Huobi connector. Args: api_key: API key for authentication (optional) api_secret: API secret for authentication (optional) """ super().__init__("huobi", self.WEBSOCKET_URL) # Authentication credentials (optional) self.api_key = api_key self.api_secret = api_secret # Huobi-specific message handlers self.message_handlers.update({ 'market.*.depth.step0': self._handle_orderbook_update, 'market.*.trade.detail': self._handle_trade_update, 'ping': self._handle_ping, 'pong': self._handle_pong }) # Subscription tracking self.subscribed_topics = set() logger.info("Huobi connector initialized") def _get_message_type(self, data: Dict) -> str: """ Determine message type from Huobi message data. Args: data: Parsed message data Returns: str: Message type identifier """ # Huobi message format if 'ping' in data: return 'ping' elif 'pong' in data: return 'pong' elif 'ch' in data: # Data channel message channel = data['ch'] if 'depth' in channel: return 'market.*.depth.step0' elif 'trade' in channel: return 'market.*.trade.detail' else: return channel elif 'subbed' in data: return 'subscription_response' elif 'unsubbed' in data: return 'unsubscription_response' elif 'status' in data and data.get('status') == 'error': return 'error' return 'unknown' def normalize_symbol(self, symbol: str) -> str: """ Normalize symbol to Huobi format. Args: symbol: Standard symbol format (e.g., 'BTCUSDT') Returns: str: Huobi symbol format (e.g., 'btcusdt') """ # Huobi uses lowercase symbols normalized = symbol.lower().replace('-', '').replace('/', '') # Validate symbol format if not validate_symbol(normalized.upper()): raise ValidationError(f"Invalid symbol format: {symbol}", "INVALID_SYMBOL") return normalized def _denormalize_symbol(self, huobi_symbol: str) -> str: """ Convert Huobi symbol back to standard format. Args: huobi_symbol: Huobi symbol format (e.g., 'btcusdt') Returns: str: Standard symbol format (e.g., 'BTCUSDT') """ return huobi_symbol.upper() async def _decompress_message(self, message: bytes) -> str: """ Decompress GZIP message from Huobi. Args: message: Compressed message bytes Returns: str: Decompressed message string """ try: return gzip.decompress(message).decode('utf-8') except Exception as e: logger.error(f"Error decompressing message: {e}") return "" async def _process_message(self, message: str) -> None: """ Override message processing to handle GZIP compression. Args: message: Raw message (could be compressed) """ try: # Check if message is compressed (binary) if isinstance(message, bytes): message = await self._decompress_message(message) if not message: return # Parse JSON message data = json.loads(message) # Handle ping/pong first if 'ping' in data: await self._handle_ping(data) return # Determine message type and route to appropriate handler message_type = self._get_message_type(data) if message_type in self.message_handlers: await self.message_handlers[message_type](data) else: logger.debug(f"Unhandled message type '{message_type}' from {self.exchange_name}") except json.JSONDecodeError as e: logger.warning(f"Invalid JSON message from {self.exchange_name}: {e}") except Exception as e: logger.error(f"Error processing message from {self.exchange_name}: {e}") async def subscribe_orderbook(self, symbol: str) -> None: """ Subscribe to order book updates for a symbol. Args: symbol: Trading symbol (e.g., 'BTCUSDT') """ try: set_correlation_id() huobi_symbol = self.normalize_symbol(symbol) topic = f"market.{huobi_symbol}.depth.step0" # Create subscription message subscription_msg = { "sub": topic, "id": str(int(time.time())) } # Send subscription success = await self._send_message(subscription_msg) if success: # Track subscription if symbol not in self.subscriptions: self.subscriptions[symbol] = [] if 'orderbook' not in self.subscriptions[symbol]: self.subscriptions[symbol].append('orderbook') self.subscribed_topics.add(topic) logger.info(f"Subscribed to order book for {symbol} ({huobi_symbol}) on Huobi") else: logger.error(f"Failed to subscribe to order book for {symbol} on Huobi") except Exception as e: logger.error(f"Error subscribing to order book for {symbol}: {e}") raise async def subscribe_trades(self, symbol: str) -> None: """ Subscribe to trade updates for a symbol. Args: symbol: Trading symbol (e.g., 'BTCUSDT') """ try: set_correlation_id() huobi_symbol = self.normalize_symbol(symbol) topic = f"market.{huobi_symbol}.trade.detail" # Create subscription message subscription_msg = { "sub": topic, "id": str(int(time.time())) } # Send subscription success = await self._send_message(subscription_msg) if success: # Track subscription if symbol not in self.subscriptions: self.subscriptions[symbol] = [] if 'trades' not in self.subscriptions[symbol]: self.subscriptions[symbol].append('trades') self.subscribed_topics.add(topic) logger.info(f"Subscribed to trades for {symbol} ({huobi_symbol}) on Huobi") else: logger.error(f"Failed to subscribe to trades for {symbol} on Huobi") except Exception as e: logger.error(f"Error subscribing to trades for {symbol}: {e}") raise async def unsubscribe_orderbook(self, symbol: str) -> None: """ Unsubscribe from order book updates for a symbol. Args: symbol: Trading symbol (e.g., 'BTCUSDT') """ try: huobi_symbol = self.normalize_symbol(symbol) topic = f"market.{huobi_symbol}.depth.step0" # Create unsubscription message unsubscription_msg = { "unsub": topic, "id": str(int(time.time())) } # Send unsubscription success = await self._send_message(unsubscription_msg) if success: # Remove from tracking if symbol in self.subscriptions and 'orderbook' in self.subscriptions[symbol]: self.subscriptions[symbol].remove('orderbook') if not self.subscriptions[symbol]: del self.subscriptions[symbol] self.subscribed_topics.discard(topic) logger.info(f"Unsubscribed from order book for {symbol} ({huobi_symbol}) on Huobi") else: logger.error(f"Failed to unsubscribe from order book for {symbol} on Huobi") except Exception as e: logger.error(f"Error unsubscribing from order book for {symbol}: {e}") raise async def unsubscribe_trades(self, symbol: str) -> None: """ Unsubscribe from trade updates for a symbol. Args: symbol: Trading symbol (e.g., 'BTCUSDT') """ try: huobi_symbol = self.normalize_symbol(symbol) topic = f"market.{huobi_symbol}.trade.detail" # Create unsubscription message unsubscription_msg = { "unsub": topic, "id": str(int(time.time())) } # Send unsubscription success = await self._send_message(unsubscription_msg) if success: # Remove from tracking if symbol in self.subscriptions and 'trades' in self.subscriptions[symbol]: self.subscriptions[symbol].remove('trades') if not self.subscriptions[symbol]: del self.subscriptions[symbol] self.subscribed_topics.discard(topic) logger.info(f"Unsubscribed from trades for {symbol} ({huobi_symbol}) on Huobi") else: logger.error(f"Failed to unsubscribe from trades for {symbol} on Huobi") except Exception as e: logger.error(f"Error unsubscribing from trades for {symbol}: {e}") raise async def get_symbols(self) -> List[str]: """ Get list of available trading symbols from Huobi. Returns: List[str]: List of available symbols in standard format """ try: import aiohttp async with aiohttp.ClientSession() as session: async with session.get(f"{self.API_URL}/v1/common/symbols") as response: if response.status == 200: data = await response.json() if data.get('status') != 'ok': logger.error(f"Huobi API error: {data}") return [] symbols = [] symbol_data = data.get('data', []) for symbol_info in symbol_data: if symbol_info.get('state') == 'online': symbol = symbol_info.get('symbol', '') # Convert to standard format standard_symbol = self._denormalize_symbol(symbol) symbols.append(standard_symbol) logger.info(f"Retrieved {len(symbols)} symbols from Huobi") return symbols else: logger.error(f"Failed to get symbols from Huobi: HTTP {response.status}") return [] except Exception as e: logger.error(f"Error getting symbols from Huobi: {e}") return [] async def get_orderbook_snapshot(self, symbol: str, depth: int = 20) -> Optional[OrderBookSnapshot]: """ Get current order book snapshot from Huobi REST API. Args: symbol: Trading symbol depth: Number of price levels to retrieve Returns: OrderBookSnapshot: Current order book or None if unavailable """ try: import aiohttp huobi_symbol = self.normalize_symbol(symbol) # Huobi supports depths: 5, 10, 20 valid_depths = [5, 10, 20] api_depth = min(valid_depths, key=lambda x: abs(x - depth)) url = f"{self.API_URL}/market/depth" params = { 'symbol': huobi_symbol, 'depth': api_depth, 'type': 'step0' } async with aiohttp.ClientSession() as session: async with session.get(url, params=params) as response: if response.status == 200: data = await response.json() if data.get('status') != 'ok': logger.error(f"Huobi API error: {data}") return None tick_data = data.get('tick', {}) return self._parse_orderbook_snapshot(tick_data, symbol) else: logger.error(f"Failed to get order book for {symbol}: HTTP {response.status}") return None except Exception as e: logger.error(f"Error getting order book snapshot for {symbol}: {e}") return None def _parse_orderbook_snapshot(self, data: Dict, symbol: str) -> OrderBookSnapshot: """ Parse Huobi order book data into OrderBookSnapshot. Args: data: Raw Huobi order book data symbol: Trading symbol Returns: OrderBookSnapshot: Parsed order book """ try: # Parse bids and asks bids = [] for bid_data in data.get('bids', []): price = float(bid_data[0]) size = float(bid_data[1]) if validate_price(price) and validate_volume(size): bids.append(PriceLevel(price=price, size=size)) asks = [] for ask_data in data.get('asks', []): price = float(ask_data[0]) size = float(ask_data[1]) if validate_price(price) and validate_volume(size): asks.append(PriceLevel(price=price, size=size)) # Create order book snapshot orderbook = OrderBookSnapshot( symbol=symbol, exchange=self.exchange_name, timestamp=datetime.fromtimestamp(int(data.get('ts', 0)) / 1000, tz=timezone.utc), bids=bids, asks=asks, sequence_id=data.get('version') ) return orderbook except Exception as e: logger.error(f"Error parsing order book snapshot: {e}") raise ValidationError(f"Invalid order book data: {e}", "PARSE_ERROR") async def _handle_orderbook_update(self, data: Dict) -> None: """ Handle order book update from Huobi. Args: data: Order book update data """ try: set_correlation_id() # Extract symbol from channel channel = data.get('ch', '') if not channel: logger.warning("Order book update missing channel") return # Parse channel: market.btcusdt.depth.step0 parts = channel.split('.') if len(parts) < 2: logger.warning("Invalid order book channel format") return huobi_symbol = parts[1] symbol = self._denormalize_symbol(huobi_symbol) tick_data = data.get('tick', {}) # Parse bids and asks bids = [] for bid_data in tick_data.get('bids', []): price = float(bid_data[0]) size = float(bid_data[1]) if validate_price(price) and validate_volume(size): bids.append(PriceLevel(price=price, size=size)) asks = [] for ask_data in tick_data.get('asks', []): price = float(ask_data[0]) size = float(ask_data[1]) if validate_price(price) and validate_volume(size): asks.append(PriceLevel(price=price, size=size)) # Create order book snapshot orderbook = OrderBookSnapshot( symbol=symbol, exchange=self.exchange_name, timestamp=datetime.fromtimestamp(int(tick_data.get('ts', 0)) / 1000, tz=timezone.utc), bids=bids, asks=asks, sequence_id=tick_data.get('version') ) # Notify callbacks self._notify_data_callbacks(orderbook) logger.debug(f"Processed order book update for {symbol}") except Exception as e: logger.error(f"Error handling order book update: {e}") async def _handle_trade_update(self, data: Dict) -> None: """ Handle trade update from Huobi. Args: data: Trade update data """ try: set_correlation_id() # Extract symbol from channel channel = data.get('ch', '') if not channel: logger.warning("Trade update missing channel") return # Parse channel: market.btcusdt.trade.detail parts = channel.split('.') if len(parts) < 2: logger.warning("Invalid trade channel format") return huobi_symbol = parts[1] symbol = self._denormalize_symbol(huobi_symbol) tick_data = data.get('tick', {}) trades_data = tick_data.get('data', []) # Process each trade for trade_data in trades_data: price = float(trade_data.get('price', 0)) amount = float(trade_data.get('amount', 0)) # Validate data if not validate_price(price) or not validate_volume(amount): logger.warning(f"Invalid trade data: price={price}, amount={amount}") continue # Determine side (Huobi uses 'direction' field) direction = trade_data.get('direction', 'unknown') side = 'buy' if direction == 'buy' else 'sell' # Create trade event trade = TradeEvent( symbol=symbol, exchange=self.exchange_name, timestamp=datetime.fromtimestamp(int(trade_data.get('ts', 0)) / 1000, tz=timezone.utc), price=price, size=amount, side=side, trade_id=str(trade_data.get('tradeId', trade_data.get('id', ''))) ) # Notify callbacks self._notify_data_callbacks(trade) logger.debug(f"Processed trade for {symbol}: {side} {amount} @ {price}") except Exception as e: logger.error(f"Error handling trade update: {e}") async def _handle_ping(self, data: Dict) -> None: """ Handle ping message from Huobi and respond with pong. Args: data: Ping message data """ try: ping_value = data.get('ping') if ping_value: # Respond with pong pong_msg = {"pong": ping_value} await self._send_message(pong_msg) logger.debug(f"Responded to Huobi ping with pong: {ping_value}") except Exception as e: logger.error(f"Error handling ping: {e}") async def _handle_pong(self, data: Dict) -> None: """ Handle pong response from Huobi. Args: data: Pong response data """ logger.debug("Received Huobi pong") def _get_auth_signature(self, method: str, host: str, path: str, params: Dict[str, str]) -> str: """ Generate authentication signature for Huobi. Args: method: HTTP method host: API host path: Request path params: Request parameters Returns: str: Authentication signature """ if not self.api_key or not self.api_secret: return "" try: # Sort parameters sorted_params = sorted(params.items()) query_string = '&'.join([f"{k}={v}" for k, v in sorted_params]) # Create signature string signature_string = f"{method}\n{host}\n{path}\n{query_string}" # Generate signature signature = base64.b64encode( hmac.new( self.api_secret.encode('utf-8'), signature_string.encode('utf-8'), hashlib.sha256 ).digest() ).decode('utf-8') return signature except Exception as e: logger.error(f"Error generating auth signature: {e}") return "" def get_huobi_stats(self) -> Dict[str, Any]: """Get Huobi-specific statistics.""" base_stats = self.get_stats() huobi_stats = { 'subscribed_topics': list(self.subscribed_topics), 'authenticated': bool(self.api_key and self.api_secret) } base_stats.update(huobi_stats) return base_stats