""" Kraken exchange connector implementation. Supports WebSocket connections to Kraken exchange with their specific message format. """ import json import hashlib import hmac 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 KrakenConnector(BaseExchangeConnector): """ Kraken WebSocket connector implementation. Supports: - Order book streams - Trade streams - Symbol normalization for Kraken format - Authentication for private channels (if needed) """ # Kraken WebSocket URLs WEBSOCKET_URL = "wss://ws.kraken.com" WEBSOCKET_AUTH_URL = "wss://ws-auth.kraken.com" API_URL = "https://api.kraken.com" def __init__(self, api_key: str = None, api_secret: str = None): """ Initialize Kraken connector. Args: api_key: API key for authentication (optional) api_secret: API secret for authentication (optional) """ super().__init__("kraken", self.WEBSOCKET_URL) # Authentication credentials (optional) self.api_key = api_key self.api_secret = api_secret # Kraken-specific message handlers self.message_handlers.update({ 'book-10': self._handle_orderbook_update, 'book-25': self._handle_orderbook_update, 'book-100': self._handle_orderbook_update, 'book-500': self._handle_orderbook_update, 'book-1000': self._handle_orderbook_update, 'trade': self._handle_trade_update, 'systemStatus': self._handle_system_status, 'subscriptionStatus': self._handle_subscription_status, 'heartbeat': self._handle_heartbeat }) # Kraken-specific tracking self.channel_map = {} # channel_id -> (channel_name, symbol) self.subscription_ids = {} # symbol -> subscription_id self.system_status = 'unknown' logger.info("Kraken connector initialized") def _get_message_type(self, data: Dict) -> str: """ Determine message type from Kraken message data. Args: data: Parsed message data Returns: str: Message type identifier """ # Kraken messages can be arrays or objects if isinstance(data, list) and len(data) >= 2: # Data message format: [channelID, data, channelName, pair] if len(data) >= 4: channel_name = data[2] return channel_name else: return 'unknown' elif isinstance(data, dict): # Status/control messages if 'event' in data: return data['event'] elif 'errorMessage' in data: return 'error' return 'unknown' def normalize_symbol(self, symbol: str) -> str: """ Normalize symbol to Kraken format. Args: symbol: Standard symbol format (e.g., 'BTCUSDT') Returns: str: Kraken pair format (e.g., 'XBT/USD') """ # Kraken uses different symbol names symbol_map = { 'BTCUSDT': 'XBT/USD', 'ETHUSDT': 'ETH/USD', 'ADAUSDT': 'ADA/USD', 'DOTUSDT': 'DOT/USD', 'LINKUSDT': 'LINK/USD', 'LTCUSDT': 'LTC/USD', 'XRPUSDT': 'XRP/USD', 'BCHUSDT': 'BCH/USD', 'EOSUSDT': 'EOS/USD', 'XLMUSDT': 'XLM/USD' } if symbol.upper() in symbol_map: return symbol_map[symbol.upper()] else: # Generic conversion: BTCUSDT -> BTC/USD if symbol.endswith('USDT'): base = symbol[:-4] return f"{base}/USD" elif symbol.endswith('USD'): base = symbol[:-3] return f"{base}/USD" else: # Assume it's already in correct format return symbol.upper() def _denormalize_symbol(self, kraken_pair: str) -> str: """ Convert Kraken pair back to standard format. Args: kraken_pair: Kraken pair format (e.g., 'XBT/USD') Returns: str: Standard symbol format (e.g., 'BTCUSDT') """ # Reverse mapping reverse_map = { 'XBT/USD': 'BTCUSDT', 'ETH/USD': 'ETHUSDT', 'ADA/USD': 'ADAUSDT', 'DOT/USD': 'DOTUSDT', 'LINK/USD': 'LINKUSDT', 'LTC/USD': 'LTCUSDT', 'XRP/USD': 'XRPUSDT', 'BCH/USD': 'BCHUSDT', 'EOS/USD': 'EOSUSDT', 'XLM/USD': 'XLMUSDT' } if kraken_pair in reverse_map: return reverse_map[kraken_pair] else: # Generic conversion: BTC/USD -> BTCUSDT if '/' in kraken_pair: base, quote = kraken_pair.split('/', 1) if quote == 'USD': return f"{base}USDT" else: return f"{base}{quote}" return kraken_pair 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() kraken_pair = self.normalize_symbol(symbol) # Create subscription message subscription_msg = { "event": "subscribe", "pair": [kraken_pair], "subscription": { "name": "book", "depth": 25 # 25 levels } } # Add authentication if credentials provided if self.api_key and self.api_secret: subscription_msg["subscription"]["token"] = self._get_auth_token() # 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') logger.info(f"Subscribed to order book for {symbol} ({kraken_pair}) on Kraken") else: logger.error(f"Failed to subscribe to order book for {symbol} on Kraken") 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() kraken_pair = self.normalize_symbol(symbol) # Create subscription message subscription_msg = { "event": "subscribe", "pair": [kraken_pair], "subscription": { "name": "trade" } } # Add authentication if credentials provided if self.api_key and self.api_secret: subscription_msg["subscription"]["token"] = self._get_auth_token() # 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') logger.info(f"Subscribed to trades for {symbol} ({kraken_pair}) on Kraken") else: logger.error(f"Failed to subscribe to trades for {symbol} on Kraken") 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: kraken_pair = self.normalize_symbol(symbol) # Create unsubscription message unsubscription_msg = { "event": "unsubscribe", "pair": [kraken_pair], "subscription": { "name": "book" } } # 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] logger.info(f"Unsubscribed from order book for {symbol} ({kraken_pair}) on Kraken") else: logger.error(f"Failed to unsubscribe from order book for {symbol} on Kraken") 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: kraken_pair = self.normalize_symbol(symbol) # Create unsubscription message unsubscription_msg = { "event": "unsubscribe", "pair": [kraken_pair], "subscription": { "name": "trade" } } # 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] logger.info(f"Unsubscribed from trades for {symbol} ({kraken_pair}) on Kraken") else: logger.error(f"Failed to unsubscribe from trades for {symbol} on Kraken") 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 Kraken. 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}/0/public/AssetPairs") as response: if response.status == 200: data = await response.json() if data.get('error'): logger.error(f"Kraken API error: {data['error']}") return [] symbols = [] pairs = data.get('result', {}) for pair_name, pair_info in pairs.items(): # Skip dark pool pairs if '.d' in pair_name: continue # Get the WebSocket pair name ws_name = pair_info.get('wsname') if ws_name: # Convert to standard format standard_symbol = self._denormalize_symbol(ws_name) symbols.append(standard_symbol) logger.info(f"Retrieved {len(symbols)} symbols from Kraken") return symbols else: logger.error(f"Failed to get symbols from Kraken: HTTP {response.status}") return [] except Exception as e: logger.error(f"Error getting symbols from Kraken: {e}") return [] async def get_orderbook_snapshot(self, symbol: str, depth: int = 20) -> Optional[OrderBookSnapshot]: """ Get current order book snapshot from Kraken 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 kraken_pair = self.normalize_symbol(symbol) url = f"{self.API_URL}/0/public/Depth" params = { 'pair': kraken_pair, 'count': min(depth, 500) # Kraken max is 500 } 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('error'): logger.error(f"Kraken API error: {data['error']}") return None result = data.get('result', {}) # Kraken returns data with the actual pair name as key pair_data = None for key, value in result.items(): if isinstance(value, dict) and 'bids' in value and 'asks' in value: pair_data = value break if pair_data: return self._parse_orderbook_snapshot(pair_data, symbol) else: logger.error(f"No order book data found for {symbol}") return None 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 Kraken order book data into OrderBookSnapshot. Args: data: Raw Kraken 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.now(timezone.utc), bids=bids, asks=asks ) 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: List) -> None: """ Handle order book update from Kraken. Args: data: Order book update data (Kraken array format) """ try: set_correlation_id() # Kraken format: [channelID, data, channelName, pair] if len(data) < 4: logger.warning("Invalid Kraken order book update format") return channel_id = data[0] book_data = data[1] channel_name = data[2] kraken_pair = data[3] symbol = self._denormalize_symbol(kraken_pair) # Track channel mapping self.channel_map[channel_id] = (channel_name, symbol) # Parse order book data bids = [] asks = [] # Kraken book data can have 'b' (bids), 'a' (asks), 'bs' (bid snapshot), 'as' (ask snapshot) if 'b' in book_data: for bid_data in book_data['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)) if 'bs' in book_data: # Bid snapshot for bid_data in book_data['bs']: 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)) if 'a' in book_data: for ask_data in book_data['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)) if 'as' in book_data: # Ask snapshot for ask_data in book_data['as']: 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.now(timezone.utc), bids=bids, asks=asks ) # 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: List) -> None: """ Handle trade update from Kraken. Args: data: Trade update data (Kraken array format) """ try: set_correlation_id() # Kraken format: [channelID, data, channelName, pair] if len(data) < 4: logger.warning("Invalid Kraken trade update format") return channel_id = data[0] trade_data = data[1] channel_name = data[2] kraken_pair = data[3] symbol = self._denormalize_symbol(kraken_pair) # Track channel mapping self.channel_map[channel_id] = (channel_name, symbol) # Process trade data (array of trades) for trade_info in trade_data: if len(trade_info) >= 6: price = float(trade_info[0]) size = float(trade_info[1]) timestamp = float(trade_info[2]) side = trade_info[3] # 'b' for buy, 's' for sell order_type = trade_info[4] # 'm' for market, 'l' for limit misc = trade_info[5] if len(trade_info) > 5 else '' # Validate data if not validate_price(price) or not validate_volume(size): logger.warning(f"Invalid trade data: price={price}, size={size}") continue # Convert side trade_side = 'buy' if side == 'b' else 'sell' # Create trade event trade = TradeEvent( symbol=symbol, exchange=self.exchange_name, timestamp=datetime.fromtimestamp(timestamp, tz=timezone.utc), price=price, size=size, side=trade_side, trade_id=f"{timestamp}_{price}_{size}" # Generate ID ) # Notify callbacks self._notify_data_callbacks(trade) logger.debug(f"Processed trade for {symbol}: {trade_side} {size} @ {price}") except Exception as e: logger.error(f"Error handling trade update: {e}") async def _handle_system_status(self, data: Dict) -> None: """ Handle system status message from Kraken. Args: data: System status data """ try: status = data.get('status', 'unknown') version = data.get('version', 'unknown') self.system_status = status logger.info(f"Kraken system status: {status} (version: {version})") if status != 'online': logger.warning(f"Kraken system not online: {status}") except Exception as e: logger.error(f"Error handling system status: {e}") async def _handle_subscription_status(self, data: Dict) -> None: """ Handle subscription status message from Kraken. Args: data: Subscription status data """ try: status = data.get('status', 'unknown') channel_name = data.get('channelName', 'unknown') pair = data.get('pair', 'unknown') subscription = data.get('subscription', {}) if status == 'subscribed': logger.info(f"Kraken subscription confirmed: {channel_name} for {pair}") # Store subscription ID if provided if 'channelID' in data: channel_id = data['channelID'] symbol = self._denormalize_symbol(pair) self.channel_map[channel_id] = (channel_name, symbol) elif status == 'unsubscribed': logger.info(f"Kraken unsubscription confirmed: {channel_name} for {pair}") elif status == 'error': error_message = data.get('errorMessage', 'Unknown error') logger.error(f"Kraken subscription error: {error_message}") except Exception as e: logger.error(f"Error handling subscription status: {e}") async def _handle_heartbeat(self, data: Dict) -> None: """ Handle heartbeat message from Kraken. Args: data: Heartbeat data """ logger.debug("Received Kraken heartbeat") def _get_auth_token(self) -> str: """ Generate authentication token for Kraken WebSocket. Returns: str: Authentication token """ if not self.api_key or not self.api_secret: return "" try: # This is a simplified version - actual Kraken auth is more complex # and requires getting a token from the REST API first nonce = str(int(time.time() * 1000)) message = nonce + self.api_key signature = hmac.new( base64.b64decode(self.api_secret), message.encode('utf-8'), hashlib.sha512 ).hexdigest() return f"{self.api_key}:{signature}:{nonce}" except Exception as e: logger.error(f"Error generating auth token: {e}") return "" def get_kraken_stats(self) -> Dict[str, Any]: """Get Kraken-specific statistics.""" base_stats = self.get_stats() kraken_stats = { 'system_status': self.system_status, 'channel_mappings': len(self.channel_map), 'authenticated': bool(self.api_key and self.api_secret) } base_stats.update(kraken_stats) return base_stats