""" Coinbase Pro exchange connector implementation. Supports WebSocket connections to Coinbase Pro (now Coinbase Advanced Trade). """ import json 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 CoinbaseConnector(BaseExchangeConnector): """ Coinbase Pro WebSocket connector implementation. Supports: - Order book level2 streams - Trade streams (matches) - Symbol normalization - Authentication for private channels (if needed) """ # Coinbase Pro WebSocket URLs WEBSOCKET_URL = "wss://ws-feed.exchange.coinbase.com" SANDBOX_URL = "wss://ws-feed-public.sandbox.exchange.coinbase.com" API_URL = "https://api.exchange.coinbase.com" def __init__(self, use_sandbox: bool = False, api_key: str = None, api_secret: str = None, passphrase: str = None): """ Initialize Coinbase connector. Args: use_sandbox: Whether to use sandbox environment api_key: API key for authentication (optional) api_secret: API secret for authentication (optional) passphrase: API passphrase for authentication (optional) """ websocket_url = self.SANDBOX_URL if use_sandbox else self.WEBSOCKET_URL super().__init__("coinbase", websocket_url) # Authentication credentials (optional) self.api_key = api_key self.api_secret = api_secret self.passphrase = passphrase self.use_sandbox = use_sandbox # Coinbase-specific message handlers self.message_handlers.update({ 'l2update': self._handle_orderbook_update, 'match': self._handle_trade_update, 'snapshot': self._handle_orderbook_snapshot, 'error': self._handle_error_message, 'subscriptions': self._handle_subscription_response }) # Channel management self.subscribed_channels = set() self.product_ids = set() logger.info(f"Coinbase connector initialized ({'sandbox' if use_sandbox else 'production'})") def _get_message_type(self, data: Dict) -> str: """ Determine message type from Coinbase message data. Args: data: Parsed message data Returns: str: Message type identifier """ # Coinbase uses 'type' field for message type return data.get('type', 'unknown') def normalize_symbol(self, symbol: str) -> str: """ Normalize symbol to Coinbase format. Args: symbol: Standard symbol format (e.g., 'BTCUSDT') Returns: str: Coinbase product ID format (e.g., 'BTC-USD') """ # Convert standard format to Coinbase product ID if symbol.upper() == 'BTCUSDT': return 'BTC-USD' elif symbol.upper() == 'ETHUSDT': return 'ETH-USD' elif symbol.upper() == 'ADAUSDT': return 'ADA-USD' elif symbol.upper() == 'DOTUSDT': return 'DOT-USD' elif symbol.upper() == 'LINKUSDT': return 'LINK-USD' 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 or try to parse if '-' in symbol: return symbol.upper() else: # Default fallback return symbol.upper() def _denormalize_symbol(self, product_id: str) -> str: """ Convert Coinbase product ID back to standard format. Args: product_id: Coinbase product ID (e.g., 'BTC-USD') Returns: str: Standard symbol format (e.g., 'BTCUSDT') """ if '-' in product_id: base, quote = product_id.split('-', 1) if quote == 'USD': return f"{base}USDT" else: return f"{base}{quote}" return product_id async def subscribe_orderbook(self, symbol: str) -> None: """ Subscribe to order book level2 updates for a symbol. Args: symbol: Trading symbol (e.g., 'BTCUSDT') """ try: set_correlation_id() product_id = self.normalize_symbol(symbol) # Create subscription message subscription_msg = { "type": "subscribe", "product_ids": [product_id], "channels": ["level2"] } # Add authentication if credentials provided if self.api_key and self.api_secret and self.passphrase: subscription_msg.update(self._get_auth_headers(subscription_msg)) # 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_channels.add('level2') self.product_ids.add(product_id) logger.info(f"Subscribed to order book for {symbol} ({product_id}) on Coinbase") else: logger.error(f"Failed to subscribe to order book for {symbol} on Coinbase") 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 (matches) for a symbol. Args: symbol: Trading symbol (e.g., 'BTCUSDT') """ try: set_correlation_id() product_id = self.normalize_symbol(symbol) # Create subscription message subscription_msg = { "type": "subscribe", "product_ids": [product_id], "channels": ["matches"] } # Add authentication if credentials provided if self.api_key and self.api_secret and self.passphrase: subscription_msg.update(self._get_auth_headers(subscription_msg)) # 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_channels.add('matches') self.product_ids.add(product_id) logger.info(f"Subscribed to trades for {symbol} ({product_id}) on Coinbase") else: logger.error(f"Failed to subscribe to trades for {symbol} on Coinbase") 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: product_id = self.normalize_symbol(symbol) # Create unsubscription message unsubscription_msg = { "type": "unsubscribe", "product_ids": [product_id], "channels": ["level2"] } # 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.product_ids.discard(product_id) logger.info(f"Unsubscribed from order book for {symbol} ({product_id}) on Coinbase") else: logger.error(f"Failed to unsubscribe from order book for {symbol} on Coinbase") 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: product_id = self.normalize_symbol(symbol) # Create unsubscription message unsubscription_msg = { "type": "unsubscribe", "product_ids": [product_id], "channels": ["matches"] } # 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.product_ids.discard(product_id) logger.info(f"Unsubscribed from trades for {symbol} ({product_id}) on Coinbase") else: logger.error(f"Failed to unsubscribe from trades for {symbol} on Coinbase") 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 Coinbase. Returns: List[str]: List of available symbols in standard format """ try: import aiohttp api_url = "https://api-public.sandbox.exchange.coinbase.com" if self.use_sandbox else self.API_URL async with aiohttp.ClientSession() as session: async with session.get(f"{api_url}/products") as response: if response.status == 200: data = await response.json() symbols = [] for product in data: if product.get('status') == 'online' and product.get('trading_disabled') is False: product_id = product.get('id', '') # Convert to standard format standard_symbol = self._denormalize_symbol(product_id) symbols.append(standard_symbol) logger.info(f"Retrieved {len(symbols)} symbols from Coinbase") return symbols else: logger.error(f"Failed to get symbols from Coinbase: HTTP {response.status}") return [] except Exception as e: logger.error(f"Error getting symbols from Coinbase: {e}") return [] async def get_orderbook_snapshot(self, symbol: str, depth: int = 20) -> Optional[OrderBookSnapshot]: """ Get current order book snapshot from Coinbase REST API. Args: symbol: Trading symbol depth: Number of price levels to retrieve (Coinbase supports up to 50) Returns: OrderBookSnapshot: Current order book or None if unavailable """ try: import aiohttp product_id = self.normalize_symbol(symbol) api_url = "https://api-public.sandbox.exchange.coinbase.com" if self.use_sandbox else self.API_URL # Coinbase supports level 1, 2, or 3 level = 2 # Level 2 gives us aggregated order book url = f"{api_url}/products/{product_id}/book" params = {'level': level} 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: Dict, symbol: str) -> OrderBookSnapshot: """ Parse Coinbase order book data into OrderBookSnapshot. Args: data: Raw Coinbase 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, sequence_id=data.get('sequence') ) 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") def _get_auth_headers(self, message: Dict) -> Dict[str, str]: """ Generate authentication headers for Coinbase Pro API. Args: message: Message to authenticate Returns: Dict: Authentication headers """ if not all([self.api_key, self.api_secret, self.passphrase]): return {} try: timestamp = str(time.time()) message_str = json.dumps(message, separators=(',', ':')) # Create signature message_to_sign = timestamp + 'GET' + '/users/self/verify' + message_str signature = base64.b64encode( hmac.new( base64.b64decode(self.api_secret), message_to_sign.encode('utf-8'), hashlib.sha256 ).digest() ).decode('utf-8') return { 'CB-ACCESS-KEY': self.api_key, 'CB-ACCESS-SIGN': signature, 'CB-ACCESS-TIMESTAMP': timestamp, 'CB-ACCESS-PASSPHRASE': self.passphrase } except Exception as e: logger.error(f"Error generating auth headers: {e}") return {} async def _handle_orderbook_snapshot(self, data: Dict) -> None: """ Handle order book snapshot from Coinbase. Args: data: Order book snapshot data """ try: set_correlation_id() product_id = data.get('product_id', '') if not product_id: logger.warning("Order book snapshot missing product_id") return symbol = self._denormalize_symbol(product_id) # 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, sequence_id=data.get('sequence') ) # Notify callbacks self._notify_data_callbacks(orderbook) logger.debug(f"Processed order book snapshot for {symbol}") except Exception as e: logger.error(f"Error handling order book snapshot: {e}") async def _handle_orderbook_update(self, data: Dict) -> None: """ Handle order book level2 update from Coinbase. Args: data: Order book update data """ try: set_correlation_id() product_id = data.get('product_id', '') if not product_id: logger.warning("Order book update missing product_id") return symbol = self._denormalize_symbol(product_id) # Coinbase l2update format: changes array with [side, price, size] changes = data.get('changes', []) bids = [] asks = [] for change in changes: if len(change) >= 3: side = change[0] # 'buy' or 'sell' price = float(change[1]) size = float(change[2]) if validate_price(price) and validate_volume(size): if side == 'buy': bids.append(PriceLevel(price=price, size=size)) elif side == 'sell': asks.append(PriceLevel(price=price, size=size)) # Create order book update (partial snapshot) orderbook = OrderBookSnapshot( symbol=symbol, exchange=self.exchange_name, timestamp=datetime.fromisoformat(data.get('time', '').replace('Z', '+00:00')), bids=bids, asks=asks, sequence_id=data.get('sequence') ) # 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 (match) update from Coinbase. Args: data: Trade update data """ try: set_correlation_id() product_id = data.get('product_id', '') if not product_id: logger.warning("Trade update missing product_id") return symbol = self._denormalize_symbol(product_id) price = float(data.get('price', 0)) size = float(data.get('size', 0)) # Validate data if not validate_price(price) or not validate_volume(size): logger.warning(f"Invalid trade data: price={price}, size={size}") return # Determine side (Coinbase uses 'side' field for taker side) side = data.get('side', 'unknown') # 'buy' or 'sell' # Create trade event trade = TradeEvent( symbol=symbol, exchange=self.exchange_name, timestamp=datetime.fromisoformat(data.get('time', '').replace('Z', '+00:00')), price=price, size=size, side=side, trade_id=str(data.get('trade_id', '')) ) # 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 confirmation from Coinbase. Args: data: Subscription response data """ try: channels = data.get('channels', []) logger.info(f"Coinbase subscription confirmed for channels: {channels}") except Exception as e: logger.error(f"Error handling subscription response: {e}") async def _handle_error_message(self, data: Dict) -> None: """ Handle error message from Coinbase. Args: data: Error message data """ message = data.get('message', 'Unknown error') reason = data.get('reason', '') logger.error(f"Coinbase error: {message}") if reason: logger.error(f"Coinbase error reason: {reason}") # Handle specific error types if 'Invalid signature' in message: logger.error("Authentication failed - check API credentials") elif 'Product not found' in message: logger.error("Invalid product ID - check symbol mapping") def get_coinbase_stats(self) -> Dict[str, Any]: """Get Coinbase-specific statistics.""" base_stats = self.get_stats() coinbase_stats = { 'subscribed_channels': list(self.subscribed_channels), 'product_ids': list(self.product_ids), 'use_sandbox': self.use_sandbox, 'authenticated': bool(self.api_key and self.api_secret and self.passphrase) } base_stats.update(coinbase_stats) return base_stats