""" Bybit exchange connector implementation. Supports WebSocket connections to Bybit with unified trading account support. """ import json import hmac import hashlib 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 BybitConnector(BaseExchangeConnector): """ Bybit WebSocket connector implementation. Supports: - Unified Trading Account (UTA) WebSocket streams - Order book streams - Trade streams - Symbol normalization - Authentication for private channels """ # Bybit WebSocket URLs WEBSOCKET_URL = "wss://stream.bybit.com/v5/public/spot" WEBSOCKET_PRIVATE_URL = "wss://stream.bybit.com/v5/private" TESTNET_URL = "wss://stream-testnet.bybit.com/v5/public/spot" API_URL = "https://api.bybit.com" def __init__(self, use_testnet: bool = False, api_key: str = None, api_secret: str = None): """ Initialize Bybit connector. Args: use_testnet: Whether to use testnet environment api_key: API key for authentication (optional) api_secret: API secret for authentication (optional) """ websocket_url = self.TESTNET_URL if use_testnet else self.WEBSOCKET_URL super().__init__("bybit", websocket_url) # Authentication credentials (optional) self.api_key = api_key self.api_secret = api_secret self.use_testnet = use_testnet # Bybit-specific message handlers self.message_handlers.update({ 'orderbook': self._handle_orderbook_update, 'publicTrade': self._handle_trade_update, 'pong': self._handle_pong, 'subscribe': self._handle_subscription_response }) # Subscription tracking self.subscribed_topics = set() self.req_id = 1 logger.info(f"Bybit connector initialized ({'testnet' if use_testnet else 'mainnet'})") def _get_message_type(self, data: Dict) -> str: """ Determine message type from Bybit message data. Args: data: Parsed message data Returns: str: Message type identifier """ # Bybit V5 API message format if 'topic' in data: topic = data['topic'] if 'orderbook' in topic: return 'orderbook' elif 'publicTrade' in topic: return 'publicTrade' else: return topic elif 'op' in data: return data['op'] # 'subscribe', 'unsubscribe', 'ping', 'pong' elif 'success' in data: return 'response' return 'unknown' def normalize_symbol(self, symbol: str) -> str: """ Normalize symbol to Bybit format. Args: symbol: Standard symbol format (e.g., 'BTCUSDT') Returns: str: Bybit symbol format (e.g., 'BTCUSDT') """ # Bybit uses uppercase symbols without separators (same as Binance) normalized = symbol.upper().replace('-', '').replace('/', '') # Validate symbol format if not validate_symbol(normalized): raise ValidationError(f"Invalid symbol format: {symbol}", "INVALID_SYMBOL") return normalized 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() normalized_symbol = self.normalize_symbol(symbol) topic = f"orderbook.50.{normalized_symbol}" # Create subscription message subscription_msg = { "op": "subscribe", "args": [topic], "req_id": str(self.req_id) } self.req_id += 1 # 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} on Bybit") else: logger.error(f"Failed to subscribe to order book for {symbol} on Bybit") 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() normalized_symbol = self.normalize_symbol(symbol) topic = f"publicTrade.{normalized_symbol}" # Create subscription message subscription_msg = { "op": "subscribe", "args": [topic], "req_id": str(self.req_id) } self.req_id += 1 # 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} on Bybit") else: logger.error(f"Failed to subscribe to trades for {symbol} on Bybit") 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: normalized_symbol = self.normalize_symbol(symbol) topic = f"orderbook.50.{normalized_symbol}" # Create unsubscription message unsubscription_msg = { "op": "unsubscribe", "args": [topic], "req_id": str(self.req_id) } self.req_id += 1 # 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} on Bybit") else: logger.error(f"Failed to unsubscribe from order book for {symbol} on Bybit") 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: normalized_symbol = self.normalize_symbol(symbol) topic = f"publicTrade.{normalized_symbol}" # Create unsubscription message unsubscription_msg = { "op": "unsubscribe", "args": [topic], "req_id": str(self.req_id) } self.req_id += 1 # 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} on Bybit") else: logger.error(f"Failed to unsubscribe from trades for {symbol} on Bybit") 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 Bybit. Returns: List[str]: List of available symbols """ try: import aiohttp api_url = "https://api-testnet.bybit.com" if self.use_testnet else self.API_URL async with aiohttp.ClientSession() as session: async with session.get(f"{api_url}/v5/market/instruments-info", params={"category": "spot"}) as response: if response.status == 200: data = await response.json() if data.get('retCode') != 0: logger.error(f"Bybit API error: {data.get('retMsg')}") return [] symbols = [] instruments = data.get('result', {}).get('list', []) for instrument in instruments: if instrument.get('status') == 'Trading': symbol = instrument.get('symbol', '') symbols.append(symbol) logger.info(f"Retrieved {len(symbols)} symbols from Bybit") return symbols else: logger.error(f"Failed to get symbols from Bybit: HTTP {response.status}") return [] except Exception as e: logger.error(f"Error getting symbols from Bybit: {e}") return [] async def get_orderbook_snapshot(self, symbol: str, depth: int = 20) -> Optional[OrderBookSnapshot]: """ Get current order book snapshot from Bybit 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 normalized_symbol = self.normalize_symbol(symbol) api_url = "https://api-testnet.bybit.com" if self.use_testnet else self.API_URL # Bybit supports depths: 1, 25, 50, 100, 200 valid_depths = [1, 25, 50, 100, 200] api_depth = min(valid_depths, key=lambda x: abs(x - depth)) url = f"{api_url}/v5/market/orderbook" params = { 'category': 'spot', 'symbol': normalized_symbol, 'limit': api_depth } 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('retCode') != 0: logger.error(f"Bybit API error: {data.get('retMsg')}") return None result = data.get('result', {}) return self._parse_orderbook_snapshot(result, 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 Bybit order book data into OrderBookSnapshot. Args: data: Raw Bybit order book data symbol: Trading symbol Returns: OrderBookSnapshot: Parsed order book """ try: # Parse bids and asks bids = [] for bid_data in data.get('b', []): 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('a', []): 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=int(data.get('u', 0)) ) 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 Bybit. Args: data: Order book update data """ try: set_correlation_id() # Extract symbol from topic topic = data.get('topic', '') if not topic.startswith('orderbook'): logger.warning("Invalid orderbook topic") return # Extract symbol from topic: orderbook.50.BTCUSDT parts = topic.split('.') if len(parts) < 3: logger.warning("Invalid orderbook topic format") return symbol = parts[2] orderbook_data = data.get('data', {}) # Parse bids and asks bids = [] for bid_data in orderbook_data.get('b', []): 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 orderbook_data.get('a', []): 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=int(orderbook_data.get('u', 0)) ) # 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 Bybit. Args: data: Trade update data """ try: set_correlation_id() # Extract symbol from topic topic = data.get('topic', '') if not topic.startswith('publicTrade'): logger.warning("Invalid trade topic") return # Extract symbol from topic: publicTrade.BTCUSDT parts = topic.split('.') if len(parts) < 2: logger.warning("Invalid trade topic format") return symbol = parts[1] trades_data = data.get('data', []) # Process each trade for trade_data in trades_data: price = float(trade_data.get('p', 0)) size = float(trade_data.get('v', 0)) # Validate data if not validate_price(price) or not validate_volume(size): logger.warning(f"Invalid trade data: price={price}, size={size}") continue # Determine side (Bybit uses 'S' field) side_flag = trade_data.get('S', '') side = 'buy' if side_flag == 'Buy' else 'sell' # Create trade event trade = TradeEvent( symbol=symbol, exchange=self.exchange_name, timestamp=datetime.fromtimestamp(int(trade_data.get('T', 0)) / 1000, tz=timezone.utc), price=price, size=size, side=side, trade_id=str(trade_data.get('i', '')) ) # Notify callbacks self._notify_data_callbacks(trade) logger.debug(f"Processed trade for {symbol}: {side} {size} @ {price}") except Exception as e: logger.error(f"Error handling trade update: {e}") async def _handle_subscription_response(self, data: Dict) -> None: """ Handle subscription response from Bybit. Args: data: Subscription response data """ try: success = data.get('success', False) req_id = data.get('req_id', '') op = data.get('op', '') if success: logger.info(f"Bybit {op} successful (req_id: {req_id})") else: ret_msg = data.get('ret_msg', 'Unknown error') logger.error(f"Bybit {op} failed: {ret_msg} (req_id: {req_id})") except Exception as e: logger.error(f"Error handling subscription response: {e}") async def _handle_pong(self, data: Dict) -> None: """ Handle pong response from Bybit. Args: data: Pong response data """ logger.debug("Received Bybit pong") def _get_auth_signature(self, timestamp: str, recv_window: str = "5000") -> str: """ Generate authentication signature for Bybit. Args: timestamp: Current timestamp recv_window: Receive window Returns: str: Authentication signature """ if not self.api_key or not self.api_secret: return "" try: param_str = f"GET/realtime{timestamp}{self.api_key}{recv_window}" signature = hmac.new( self.api_secret.encode('utf-8'), param_str.encode('utf-8'), hashlib.sha256 ).hexdigest() return signature except Exception as e: logger.error(f"Error generating auth signature: {e}") return "" async def _send_ping(self) -> None: """Send ping to keep connection alive.""" try: ping_msg = { "op": "ping", "req_id": str(self.req_id) } self.req_id += 1 await self._send_message(ping_msg) logger.debug("Sent ping to Bybit") except Exception as e: logger.error(f"Error sending ping: {e}") def get_bybit_stats(self) -> Dict[str, Any]: """Get Bybit-specific statistics.""" base_stats = self.get_stats() bybit_stats = { 'subscribed_topics': list(self.subscribed_topics), 'use_testnet': self.use_testnet, 'authenticated': bool(self.api_key and self.api_secret), 'next_req_id': self.req_id } base_stats.update(bybit_stats) return base_stats