""" Bitfinex exchange connector implementation. Supports WebSocket connections to Bitfinex with proper channel subscription management. """ import json 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 BitfinexConnector(BaseExchangeConnector): """ Bitfinex WebSocket connector implementation. Supports: - Channel subscription management - Order book streams - Trade streams - Symbol normalization """ # Bitfinex WebSocket URLs WEBSOCKET_URL = "wss://api-pub.bitfinex.com/ws/2" API_URL = "https://api-pub.bitfinex.com" def __init__(self, api_key: str = None, api_secret: str = None): """Initialize Bitfinex connector.""" super().__init__("bitfinex", self.WEBSOCKET_URL) self.api_key = api_key self.api_secret = api_secret # Bitfinex-specific message handlers self.message_handlers.update({ 'subscribed': self._handle_subscription_response, 'unsubscribed': self._handle_unsubscription_response, 'error': self._handle_error_message, 'info': self._handle_info_message, 'data': self._handle_data_message }) # Channel management self.channels = {} # channel_id -> channel_info self.subscribed_symbols = set() logger.info("Bitfinex connector initialized") def _get_message_type(self, data) -> str: """Determine message type from Bitfinex message data.""" if isinstance(data, dict): if 'event' in data: return data['event'] elif 'error' in data: return 'error' elif isinstance(data, list) and len(data) >= 2: # Data message format: [CHANNEL_ID, data] return 'data' return 'unknown' def normalize_symbol(self, symbol: str) -> str: """Normalize symbol to Bitfinex format.""" # Bitfinex uses 't' prefix for trading pairs if symbol.upper() == 'BTCUSDT': return 'tBTCUSD' elif symbol.upper() == 'ETHUSDT': return 'tETHUSD' elif symbol.upper().endswith('USDT'): base = symbol[:-4].upper() return f"t{base}USD" else: # Generic conversion normalized = symbol.upper().replace('-', '').replace('/', '') return f"t{normalized}" if not normalized.startswith('t') else normalized def _denormalize_symbol(self, bitfinex_symbol: str) -> str: """Convert Bitfinex symbol back to standard format.""" if bitfinex_symbol.startswith('t'): symbol = bitfinex_symbol[1:] # Remove 't' prefix if symbol.endswith('USD'): return symbol[:-3] + 'USDT' return symbol return bitfinex_symbol async def subscribe_orderbook(self, symbol: str) -> None: """Subscribe to order book updates for a symbol.""" try: set_correlation_id() bitfinex_symbol = self.normalize_symbol(symbol) subscription_msg = { "event": "subscribe", "channel": "book", "symbol": bitfinex_symbol, "prec": "P0", "freq": "F0", "len": "25" } success = await self._send_message(subscription_msg) if success: if symbol not in self.subscriptions: self.subscriptions[symbol] = [] if 'orderbook' not in self.subscriptions[symbol]: self.subscriptions[symbol].append('orderbook') self.subscribed_symbols.add(bitfinex_symbol) logger.info(f"Subscribed to order book for {symbol} ({bitfinex_symbol}) on Bitfinex") else: logger.error(f"Failed to subscribe to order book for {symbol} on Bitfinex") 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.""" try: set_correlation_id() bitfinex_symbol = self.normalize_symbol(symbol) subscription_msg = { "event": "subscribe", "channel": "trades", "symbol": bitfinex_symbol } success = await self._send_message(subscription_msg) if success: if symbol not in self.subscriptions: self.subscriptions[symbol] = [] if 'trades' not in self.subscriptions[symbol]: self.subscriptions[symbol].append('trades') self.subscribed_symbols.add(bitfinex_symbol) logger.info(f"Subscribed to trades for {symbol} ({bitfinex_symbol}) on Bitfinex") else: logger.error(f"Failed to subscribe to trades for {symbol} on Bitfinex") 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.""" try: bitfinex_symbol = self.normalize_symbol(symbol) # Find channel ID for this symbol's order book channel_id = None for cid, info in self.channels.items(): if info.get('channel') == 'book' and info.get('symbol') == bitfinex_symbol: channel_id = cid break if channel_id: unsubscription_msg = { "event": "unsubscribe", "chanId": channel_id } success = await self._send_message(unsubscription_msg) if success: 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_symbols.discard(bitfinex_symbol) logger.info(f"Unsubscribed from order book for {symbol} on Bitfinex") else: logger.error(f"Failed to unsubscribe from order book for {symbol} on Bitfinex") else: logger.warning(f"No active order book subscription found for {symbol}") 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.""" try: bitfinex_symbol = self.normalize_symbol(symbol) # Find channel ID for this symbol's trades channel_id = None for cid, info in self.channels.items(): if info.get('channel') == 'trades' and info.get('symbol') == bitfinex_symbol: channel_id = cid break if channel_id: unsubscription_msg = { "event": "unsubscribe", "chanId": channel_id } success = await self._send_message(unsubscription_msg) if success: 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_symbols.discard(bitfinex_symbol) logger.info(f"Unsubscribed from trades for {symbol} on Bitfinex") else: logger.error(f"Failed to unsubscribe from trades for {symbol} on Bitfinex") else: logger.warning(f"No active trades subscription found for {symbol}") except Exception as e: logger.error(f"Error unsubscribing from trades for {symbol}: {e}") raise async def get_symbols(self) -> List[str]: """Get available symbols from Bitfinex.""" try: import aiohttp async with aiohttp.ClientSession() as session: async with session.get(f"{self.API_URL}/v1/symbols") as response: if response.status == 200: data = await response.json() symbols = [self._denormalize_symbol(f"t{s.upper()}") for s in data] logger.info(f"Retrieved {len(symbols)} symbols from Bitfinex") return symbols else: logger.error(f"Failed to get symbols from Bitfinex: HTTP {response.status}") return [] except Exception as e: logger.error(f"Error getting symbols from Bitfinex: {e}") return [] async def get_orderbook_snapshot(self, symbol: str, depth: int = 20) -> Optional[OrderBookSnapshot]: """Get order book snapshot from Bitfinex REST API.""" try: import aiohttp bitfinex_symbol = self.normalize_symbol(symbol) url = f"{self.API_URL}/v2/book/{bitfinex_symbol}/P0" params = {'len': min(depth, 100)} async with aiohttp.ClientSession() as session: async with session.get(url, params=params) as response: if response.status == 200: data = await response.json() return self._parse_orderbook_snapshot(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: List, symbol: str) -> OrderBookSnapshot: """Parse Bitfinex order book data.""" try: bids = [] asks = [] for level in data: price = float(level[0]) count = int(level[1]) amount = float(level[2]) if validate_price(price) and validate_volume(abs(amount)): if amount > 0: bids.append(PriceLevel(price=price, size=amount)) else: asks.append(PriceLevel(price=price, size=abs(amount))) return OrderBookSnapshot( symbol=symbol, exchange=self.exchange_name, timestamp=datetime.now(timezone.utc), bids=bids, asks=asks ) 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_subscription_response(self, data: Dict) -> None: """Handle subscription response.""" channel_id = data.get('chanId') channel = data.get('channel') symbol = data.get('symbol', '') if channel_id: self.channels[channel_id] = { 'channel': channel, 'symbol': symbol } logger.info(f"Bitfinex subscription confirmed: {channel} for {symbol} (ID: {channel_id})") async def _handle_unsubscription_response(self, data: Dict) -> None: """Handle unsubscription response.""" channel_id = data.get('chanId') if channel_id in self.channels: del self.channels[channel_id] logger.info(f"Bitfinex unsubscription confirmed for channel {channel_id}") async def _handle_error_message(self, data: Dict) -> None: """Handle error message.""" error_msg = data.get('msg', 'Unknown error') error_code = data.get('code', 'unknown') logger.error(f"Bitfinex error {error_code}: {error_msg}") async def _handle_info_message(self, data: Dict) -> None: """Handle info message.""" logger.info(f"Bitfinex info: {data}") async def _handle_data_message(self, data: List) -> None: """Handle data message from Bitfinex.""" try: if len(data) < 2: return channel_id = data[0] message_data = data[1] if channel_id not in self.channels: logger.warning(f"Received data for unknown channel: {channel_id}") return channel_info = self.channels[channel_id] channel_type = channel_info.get('channel') symbol = channel_info.get('symbol', '') if channel_type == 'book': await self._handle_orderbook_data(message_data, symbol) elif channel_type == 'trades': await self._handle_trades_data(message_data, symbol) except Exception as e: logger.error(f"Error handling data message: {e}") async def _handle_orderbook_data(self, data, symbol: str) -> None: """Handle order book data from Bitfinex.""" try: set_correlation_id() if not isinstance(data, list): return standard_symbol = self._denormalize_symbol(symbol) # Handle snapshot vs update if len(data) > 0 and isinstance(data[0], list): # Snapshot - array of [price, count, amount] bids = [] asks = [] for level in data: if len(level) >= 3: price = float(level[0]) count = int(level[1]) amount = float(level[2]) if validate_price(price) and validate_volume(abs(amount)): if amount > 0: bids.append(PriceLevel(price=price, size=amount)) else: asks.append(PriceLevel(price=price, size=abs(amount))) orderbook = OrderBookSnapshot( symbol=standard_symbol, exchange=self.exchange_name, timestamp=datetime.now(timezone.utc), bids=bids, asks=asks ) self._notify_data_callbacks(orderbook) logger.debug(f"Processed order book snapshot for {standard_symbol}") except Exception as e: logger.error(f"Error handling order book data: {e}") async def _handle_trades_data(self, data, symbol: str) -> None: """Handle trades data from Bitfinex.""" try: set_correlation_id() if not isinstance(data, list): return standard_symbol = self._denormalize_symbol(symbol) # Handle snapshot vs update if len(data) > 0 and isinstance(data[0], list): # Snapshot - array of trades for trade_data in data: await self._process_single_trade(trade_data, standard_symbol) elif len(data) >= 4: # Single trade update await self._process_single_trade(data, standard_symbol) except Exception as e: logger.error(f"Error handling trades data: {e}") async def _process_single_trade(self, trade_data: List, symbol: str) -> None: """Process a single trade from Bitfinex.""" try: if len(trade_data) < 4: return trade_id = str(trade_data[0]) timestamp = int(trade_data[1]) / 1000 # Convert to seconds amount = float(trade_data[2]) price = float(trade_data[3]) if not validate_price(price) or not validate_volume(abs(amount)): return side = 'buy' if amount > 0 else 'sell' trade = TradeEvent( symbol=symbol, exchange=self.exchange_name, timestamp=datetime.fromtimestamp(timestamp, tz=timezone.utc), price=price, size=abs(amount), side=side, trade_id=trade_id ) self._notify_data_callbacks(trade) logger.debug(f"Processed trade for {symbol}: {side} {abs(amount)} @ {price}") except Exception as e: logger.error(f"Error processing single trade: {e}") def get_bitfinex_stats(self) -> Dict[str, Any]: """Get Bitfinex-specific statistics.""" base_stats = self.get_stats() bitfinex_stats = { 'active_channels': len(self.channels), 'subscribed_symbols': list(self.subscribed_symbols), 'authenticated': bool(self.api_key and self.api_secret) } base_stats.update(bitfinex_stats) return base_stats