454 lines
18 KiB
Python
454 lines
18 KiB
Python
"""
|
|
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 |