650 lines
24 KiB
Python
650 lines
24 KiB
Python
"""
|
|
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 |