import logging import time import asyncio import json import websockets from typing import Dict, Any, List, Optional, Callable import requests import hmac import hashlib from urllib.parse import urlencode from datetime import datetime from threading import Thread, Lock from collections import deque from .exchange_interface import ExchangeInterface logger = logging.getLogger(__name__) class MEXCInterface(ExchangeInterface): """MEXC Exchange API Interface with WebSocket support""" def __init__(self, api_key: str = None, api_secret: str = None, test_mode: bool = True): """Initialize MEXC exchange interface. Args: api_key: MEXC API key api_secret: MEXC API secret test_mode: If True, use test/sandbox environment (Note: MEXC doesn't have a true sandbox) """ super().__init__(api_key, api_secret, test_mode) self.base_url = "https://api.mexc.com" self.api_version = "api/v3" # WebSocket configuration self.ws_base_url = "wss://wbs.mexc.com/ws" self.websocket_tasks = {} self.is_streaming = False self.stream_lock = Lock() self.tick_callbacks = [] self.ticker_callbacks = [] # Data buffers for reliability self.recent_ticks = {} # {symbol: deque} self.current_prices = {} # {symbol: price} self.buffer_size = 1000 def add_tick_callback(self, callback: Callable[[Dict[str, Any]], None]): """Add callback for real-time tick data""" self.tick_callbacks.append(callback) logger.info(f"Added MEXC tick callback: {len(self.tick_callbacks)} total") def add_ticker_callback(self, callback: Callable[[Dict[str, Any]], None]): """Add callback for real-time ticker data""" self.ticker_callbacks.append(callback) logger.info(f"Added MEXC ticker callback: {len(self.ticker_callbacks)} total") def _notify_tick_callbacks(self, tick_data: Dict[str, Any]): """Notify all tick callbacks with new data""" for callback in self.tick_callbacks: try: callback(tick_data) except Exception as e: logger.error(f"Error in MEXC tick callback: {e}") def _notify_ticker_callbacks(self, ticker_data: Dict[str, Any]): """Notify all ticker callbacks with new data""" for callback in self.ticker_callbacks: try: callback(ticker_data) except Exception as e: logger.error(f"Error in MEXC ticker callback: {e}") async def start_websocket_streams(self, symbols: List[str], stream_types: List[str] = None): """Start WebSocket streams for multiple symbols Args: symbols: List of symbols in 'BTC/USDT' format stream_types: List of stream types ['trade', 'ticker', 'depth'] (default: ['trade', 'ticker']) """ if stream_types is None: stream_types = ['trade', 'ticker'] self.is_streaming = True logger.info(f"Starting MEXC WebSocket streams for {symbols} with types {stream_types}") # Initialize buffers for symbols for symbol in symbols: mexc_symbol = symbol.replace('/', '').upper() self.recent_ticks[mexc_symbol] = deque(maxlen=self.buffer_size) # Start streams for each symbol and stream type combination for symbol in symbols: for stream_type in stream_types: task = asyncio.create_task(self._websocket_stream(symbol, stream_type)) task_key = f"{symbol}_{stream_type}" self.websocket_tasks[task_key] = task async def stop_websocket_streams(self): """Stop all WebSocket streams""" logger.info("Stopping MEXC WebSocket streams") self.is_streaming = False # Cancel all tasks for task_key, task in self.websocket_tasks.items(): if not task.done(): task.cancel() try: await task except asyncio.CancelledError: pass self.websocket_tasks.clear() async def _websocket_stream(self, symbol: str, stream_type: str): """Individual WebSocket stream for a symbol and stream type""" mexc_symbol = symbol.replace('/', '').upper() # MEXC WebSocket stream naming convention if stream_type == 'trade': stream_name = f"{mexc_symbol}@trade" elif stream_type == 'ticker': stream_name = f"{mexc_symbol}@ticker" elif stream_type == 'depth': stream_name = f"{mexc_symbol}@depth" else: logger.error(f"Unsupported MEXC stream type: {stream_type}") return url = f"{self.ws_base_url}" while self.is_streaming: try: logger.info(f"Connecting to MEXC WebSocket: {stream_name}") async with websockets.connect(url) as websocket: # Subscribe to the stream subscribe_msg = { "method": "SUBSCRIPTION", "params": [stream_name] } await websocket.send(json.dumps(subscribe_msg)) logger.info(f"Subscribed to MEXC stream: {stream_name}") async for message in websocket: if not self.is_streaming: break try: await self._process_websocket_message(mexc_symbol, stream_type, message) except Exception as e: logger.warning(f"Error processing MEXC message for {stream_name}: {e}") except Exception as e: logger.error(f"MEXC WebSocket error for {stream_name}: {e}") if self.is_streaming: logger.info(f"Reconnecting MEXC WebSocket for {stream_name} in 5 seconds...") await asyncio.sleep(5) async def _process_websocket_message(self, symbol: str, stream_type: str, message: str): """Process incoming WebSocket message""" try: data = json.loads(message) # Handle subscription confirmation if data.get('id') is not None: logger.info(f"MEXC WebSocket subscription confirmed for {symbol} {stream_type}") return # Process data based on stream type if stream_type == 'trade' and 'data' in data: await self._process_trade_data(symbol, data['data']) elif stream_type == 'ticker' and 'data' in data: await self._process_ticker_data(symbol, data['data']) elif stream_type == 'depth' and 'data' in data: await self._process_depth_data(symbol, data['data']) except Exception as e: logger.error(f"Error processing MEXC WebSocket message: {e}") async def _process_trade_data(self, symbol: str, trade_data: Dict[str, Any]): """Process trade data from WebSocket""" try: # MEXC trade data format price = float(trade_data.get('p', 0)) quantity = float(trade_data.get('q', 0)) timestamp = datetime.fromtimestamp(int(trade_data.get('t', 0)) / 1000) is_buyer_maker = trade_data.get('m', False) trade_id = trade_data.get('i', '') # Create standardized tick tick = { 'symbol': symbol, 'timestamp': timestamp, 'price': price, 'volume': price * quantity, # Volume in quote currency 'quantity': quantity, 'side': 'sell' if is_buyer_maker else 'buy', 'trade_id': str(trade_id), 'is_buyer_maker': is_buyer_maker, 'exchange': 'MEXC', 'raw_data': trade_data } # Update buffers self.recent_ticks[symbol].append(tick) self.current_prices[symbol] = price # Notify callbacks self._notify_tick_callbacks(tick) except Exception as e: logger.error(f"Error processing MEXC trade data: {e}") async def _process_ticker_data(self, symbol: str, ticker_data: Dict[str, Any]): """Process ticker data from WebSocket""" try: # MEXC ticker data format ticker = { 'symbol': symbol, 'timestamp': datetime.now(), 'price': float(ticker_data.get('c', 0)), # Current price 'bid': float(ticker_data.get('b', 0)), # Best bid 'ask': float(ticker_data.get('a', 0)), # Best ask 'volume': float(ticker_data.get('v', 0)), # Volume 'high': float(ticker_data.get('h', 0)), # 24h high 'low': float(ticker_data.get('l', 0)), # 24h low 'change': float(ticker_data.get('P', 0)), # Price change % 'exchange': 'MEXC', 'raw_data': ticker_data } # Update current price self.current_prices[symbol] = ticker['price'] # Notify callbacks self._notify_ticker_callbacks(ticker) except Exception as e: logger.error(f"Error processing MEXC ticker data: {e}") async def _process_depth_data(self, symbol: str, depth_data: Dict[str, Any]): """Process order book depth data from WebSocket""" try: # Process depth data if needed for future features logger.debug(f"MEXC depth data received for {symbol}") except Exception as e: logger.error(f"Error processing MEXC depth data: {e}") def get_current_price(self, symbol: str) -> Optional[float]: """Get current price for a symbol from WebSocket data or REST API fallback""" mexc_symbol = symbol.replace('/', '').upper() # Try from WebSocket data first if mexc_symbol in self.current_prices: return self.current_prices[mexc_symbol] # Fallback to REST API try: ticker = self.get_ticker(symbol) if ticker and 'price' in ticker: return float(ticker['price']) except Exception as e: logger.warning(f"Failed to get current price for {symbol} from MEXC: {e}") return None def get_recent_ticks(self, symbol: str, count: int = 100) -> List[Dict[str, Any]]: """Get recent ticks for a symbol""" mexc_symbol = symbol.replace('/', '').upper() if mexc_symbol in self.recent_ticks: return list(self.recent_ticks[mexc_symbol])[-count:] return [] def connect(self) -> bool: """Connect to MEXC API.""" if not self.api_key or not self.api_secret: logger.warning("MEXC API credentials not provided. Running in read-only mode.") try: # Test public API connection by getting server time (ping) self.get_server_time() logger.info("Successfully connected to MEXC API in read-only mode") return True except Exception as e: logger.error(f"Failed to connect to MEXC API in read-only mode: {str(e)}") return False try: # Test connection by getting account info self.get_account_info() logger.info("Successfully connected to MEXC API with authentication") return True except Exception as e: logger.error(f"Failed to connect to MEXC API: {str(e)}") return False def _generate_signature(self, params: Dict[str, Any]) -> str: """Generate HMAC SHA256 signature for MEXC API. The signature is generated by creating a query string from all parameters (excluding the signature itself), then using HMAC SHA256 with the secret key. """ if not self.api_secret: raise ValueError("API secret is required for generating signatures") # Sort parameters by key to ensure consistent ordering # This is crucial for MEXC API signature validation sorted_params = sorted(params.items()) # Create query string query_string = '&'.join([f"{key}={value}" for key, value in sorted_params]) # Generate HMAC SHA256 signature signature = hmac.new( self.api_secret.encode('utf-8'), query_string.encode('utf-8'), hashlib.sha256 ).hexdigest() logger.debug(f"MEXC signature query string: {query_string}") logger.debug(f"MEXC signature: {signature}") return signature def _send_public_request(self, method: str, endpoint: str, params: Dict[str, Any] = None) -> Dict[str, Any]: """Send public request to MEXC API.""" url = f"{self.base_url}/{self.api_version}/{endpoint}" try: if method.upper() == 'GET': response = requests.get(url, params=params) else: response = requests.post(url, json=params) response.raise_for_status() return response.json() except Exception as e: logger.error(f"Error in public request to {endpoint}: {str(e)}") raise def _send_private_request(self, method: str, endpoint: str, params: Dict[str, Any] = None) -> Dict[str, Any]: """Send private/authenticated request to MEXC API.""" if not self.api_key or not self.api_secret: raise ValueError("API key and secret are required for private requests") if params is None: params = {} # Add timestamp using server time for better synchronization try: server_time_response = self._send_public_request('GET', 'time') server_time = server_time_response['serverTime'] params['timestamp'] = server_time except Exception as e: logger.warning(f"Failed to get server time, using local time: {e}") params['timestamp'] = int(time.time() * 1000) # Generate signature using the exact format from MEXC documentation # For order placement, the query string should be in this specific order: # symbol=X&side=X&type=X&quantity=X×tamp=X (for market orders) # symbol=X&side=X&type=X&quantity=X&price=X&timeInForce=X×tamp=X (for limit orders) if endpoint == 'order' and method == 'POST': # Special handling for order placement - use exact MEXC documentation format query_parts = [] # Required parameters in exact order per MEXC docs if 'symbol' in params: query_parts.append(f"symbol={params['symbol']}") if 'side' in params: query_parts.append(f"side={params['side']}") if 'type' in params: query_parts.append(f"type={params['type']}") if 'quantity' in params: query_parts.append(f"quantity={params['quantity']}") if 'price' in params: query_parts.append(f"price={params['price']}") if 'timeInForce' in params: query_parts.append(f"timeInForce={params['timeInForce']}") if 'timestamp' in params: query_parts.append(f"timestamp={params['timestamp']}") query_string = '&'.join(query_parts) else: # For other endpoints, use sorted parameters (original working method) sorted_params = sorted(params.items()) query_string = urlencode(sorted_params) # Generate signature signature = hmac.new( self.api_secret.encode('utf-8'), query_string.encode('utf-8'), hashlib.sha256 ).hexdigest() # Add signature to parameters params['signature'] = signature # Prepare request url = f"{self.base_url}/api/v3/{endpoint}" headers = { 'X-MEXC-APIKEY': self.api_key } # Do not add Content-Type - let requests handle it automatically # Log request details for debugging logger.debug(f"MEXC {method} request to {endpoint}") logger.debug(f"Query string for signature: {query_string}") logger.debug(f"Signature: {signature}") try: if method == 'GET': response = requests.get(url, params=params, headers=headers, timeout=30) elif method == 'POST': response = requests.post(url, params=params, headers=headers, timeout=30) elif method == 'DELETE': response = requests.delete(url, params=params, headers=headers, timeout=30) else: raise ValueError(f"Unsupported HTTP method: {method}") logger.debug(f"MEXC API response status: {response.status_code}") if response.status_code == 200: return response.json() else: logger.error(f"Error in private request to {endpoint}: {response.status_code} {response.reason}") logger.error(f"Response status: {response.status_code}") logger.error(f"Response content: {response.text}") response.raise_for_status() except requests.exceptions.RequestException as e: logger.error(f"Network error in private request to {endpoint}: {str(e)}") raise except Exception as e: logger.error(f"Unexpected error in private request to {endpoint}: {str(e)}") raise def get_server_time(self) -> Dict[str, Any]: """Get server time (ping test).""" return self._send_public_request('GET', 'time') def ping(self) -> Dict[str, Any]: """Test connectivity to the Rest API.""" return self._send_public_request('GET', 'ping') def get_account_info(self) -> Dict[str, Any]: """Get account information.""" params = {} # recvWindow will be set by _send_private_request return self._send_private_request('GET', 'account', params) def get_balance(self, asset: str) -> float: """Get balance of a specific asset. Args: asset: Asset symbol (e.g., 'BTC', 'USDT') Returns: float: Available balance of the asset """ try: params = {} # recvWindow will be set by _send_private_request account_info = self._send_private_request('GET', 'account', params) balances = account_info.get('balances', []) for balance in balances: if balance['asset'] == asset: return float(balance['free']) # Asset not found return 0.0 except Exception as e: logger.error(f"Error getting balance for {asset}: {str(e)}") return 0.0 def get_ticker(self, symbol: str) -> Dict[str, Any]: """Get current ticker data for a symbol. Args: symbol: Trading symbol (e.g., 'BTC/USDT') Returns: dict: Ticker data including price information """ mexc_symbol = symbol.replace('/', '') # Use official MEXC API endpoints from documentation endpoints_to_try = [ ('ticker/price', {'symbol': mexc_symbol}), # Symbol Price Ticker ('ticker/24hr', {'symbol': mexc_symbol}), # 24hr Ticker Price Change Statistics ('ticker/bookTicker', {'symbol': mexc_symbol}), # Symbol Order Book Ticker ] for endpoint, params in endpoints_to_try: try: logger.debug(f"Trying MEXC endpoint: {endpoint} for {mexc_symbol}") response = self._send_public_request('GET', endpoint, params) if not response: continue # Handle the response based on structure if isinstance(response, dict): ticker = response elif isinstance(response, list) and len(response) > 0: # Find the specific symbol in list response ticker = None for t in response: if t.get('symbol') == mexc_symbol: ticker = t break if ticker is None: continue else: continue # Convert to standardized format based on MEXC API response current_time = int(time.time() * 1000) # Handle different response formats from different endpoints if 'price' in ticker: # ticker/price endpoint price = float(ticker['price']) result = { 'symbol': symbol, 'bid': price, # Use price as fallback 'ask': price, # Use price as fallback 'last': price, 'volume': 0, # Not available in price endpoint 'timestamp': current_time } elif 'lastPrice' in ticker: # ticker/24hr endpoint result = { 'symbol': symbol, 'bid': float(ticker.get('bidPrice', ticker.get('lastPrice', 0))), 'ask': float(ticker.get('askPrice', ticker.get('lastPrice', 0))), 'last': float(ticker.get('lastPrice', 0)), 'volume': float(ticker.get('volume', ticker.get('quoteVolume', 0))), 'timestamp': int(ticker.get('closeTime', current_time)) } elif 'bidPrice' in ticker: # ticker/bookTicker endpoint result = { 'symbol': symbol, 'bid': float(ticker.get('bidPrice', 0)), 'ask': float(ticker.get('askPrice', 0)), 'last': float(ticker.get('bidPrice', 0)), # Use bid as fallback for last 'volume': 0, # Not available in book ticker 'timestamp': current_time } else: continue # Validate we have a valid price if result['last'] > 0: logger.info(f"MEXC: Got ticker from {endpoint} for {symbol}: ${result['last']:.2f}") return result except Exception as e: logger.warning(f"MEXC endpoint {endpoint} failed for {symbol}: {e}") continue # All endpoints failed logger.error(f"❌ MEXC: All ticker endpoints failed for {symbol}") return None def place_order(self, symbol: str, side: str, order_type: str, quantity: float, price: float = None) -> Dict[str, Any]: """Place an order on the exchange. Args: symbol: Trading symbol (e.g., 'BTC/USDT') side: Order side ('BUY' or 'SELL') order_type: Order type ('MARKET', 'LIMIT', etc.) quantity: Order quantity price: Order price (for limit orders) Returns: dict: Order information including order ID """ mexc_symbol = symbol.replace('/', '') # Prepare order parameters according to MEXC API specification # Parameters must be in specific order for proper signature generation params = { 'symbol': mexc_symbol, 'side': side.upper(), 'type': order_type.upper() } # Format quantity properly - respect MEXC precision requirements # ETH has 5 decimal places max on MEXC, most other symbols have 6-8 if 'ETH' in mexc_symbol: # ETH pairs: 5 decimal places maximum quantity_str = f"{quantity:.5f}".rstrip('0').rstrip('.') else: # Other pairs: 6 decimal places (conservative) quantity_str = f"{quantity:.6f}".rstrip('0').rstrip('.') params['quantity'] = quantity_str # Add price and timeInForce for limit orders if order_type.upper() == 'LIMIT': if price is None: raise ValueError("Price is required for LIMIT orders") # Format price properly - respect MEXC precision requirements # USDC pairs typically have 2 decimal places, USDT pairs may have more if 'USDC' in mexc_symbol: price_str = f"{price:.2f}".rstrip('0').rstrip('.') else: price_str = f"{price:.6f}".rstrip('0').rstrip('.') params['price'] = price_str params['timeInForce'] = 'GTC' # Good Till Cancelled try: logger.info(f"MEXC: Placing {side} {order_type} order for {symbol}: {quantity} @ {price}") order_result = self._send_private_request('POST', 'order', params) logger.info(f"MEXC: Order placed successfully: {order_result.get('orderId', 'N/A')}") return order_result except Exception as e: logger.error(f"MEXC: Error placing {side} {order_type} order for {symbol}: {str(e)}") raise def cancel_order(self, symbol: str, order_id: str) -> bool: """Cancel an existing order. Args: symbol: Trading symbol (e.g., 'BTC/USDT') order_id: ID of the order to cancel Returns: bool: True if cancellation successful, False otherwise """ mexc_symbol = symbol.replace('/', '') params = { 'symbol': mexc_symbol, 'orderId': order_id } try: cancel_result = self._send_private_request('DELETE', 'order', params) return True except Exception as e: logger.error(f"Error cancelling order {order_id} for {symbol}: {str(e)}") return False def get_order_status(self, symbol: str, order_id: str) -> Dict[str, Any]: """Get status of an existing order. Args: symbol: Trading symbol (e.g., 'BTC/USDT') order_id: ID of the order Returns: dict: Order status information """ mexc_symbol = symbol.replace('/', '') params = { 'symbol': mexc_symbol, 'orderId': order_id } try: order_info = self._send_private_request('GET', 'order', params) return order_info except Exception as e: logger.error(f"Error getting order status for {order_id} on {symbol}: {str(e)}") raise def get_open_orders(self, symbol: str = None) -> List[Dict[str, Any]]: """Get all open orders, optionally filtered by symbol. Args: symbol: Trading symbol (e.g., 'BTC/USDT'), or None for all symbols Returns: list: List of open orders """ params = {} if symbol: params['symbol'] = symbol.replace('/', '') try: open_orders = self._send_private_request('GET', 'openOrders', params) return open_orders except Exception as e: logger.error(f"Error getting open orders: {str(e)}") return [] def get_trading_fees(self) -> Dict[str, Any]: """Get current trading fee rates from MEXC API Returns: dict: Trading fee information including maker/taker rates """ try: # MEXC API endpoint for account commission rates account_info = self._send_private_request('GET', 'account', {}) # Extract commission rates from account info # MEXC typically returns commission rates in the account response maker_commission = account_info.get('makerCommission', 0) taker_commission = account_info.get('takerCommission', 0) # Convert from basis points to decimal (MEXC uses basis points: 10 = 0.001%) maker_rate = maker_commission / 100000 # Convert from basis points taker_rate = taker_commission / 100000 logger.info(f"MEXC: Retrieved trading fees - Maker: {maker_rate*100:.3f}%, Taker: {taker_rate*100:.3f}%") return { 'maker_rate': maker_rate, 'taker_rate': taker_rate, 'maker_commission': maker_commission, 'taker_commission': taker_commission, 'source': 'mexc_api', 'timestamp': int(time.time()) } except Exception as e: logger.error(f"Error getting MEXC trading fees: {e}") # Return fallback values return { 'maker_rate': 0.0000, # 0.00% fallback 'taker_rate': 0.0005, # 0.05% fallback 'source': 'fallback', 'error': str(e), 'timestamp': int(time.time()) } def get_symbol_trading_fees(self, symbol: str) -> Dict[str, Any]: """Get trading fees for a specific symbol Args: symbol: Trading symbol (e.g., 'ETH/USDT') Returns: dict: Symbol-specific trading fee information """ try: mexc_symbol = symbol.replace('/', '') # Try to get symbol-specific fee info from exchange info exchange_info_response = self._send_public_request('GET', 'exchangeInfo', {}) if exchange_info_response and 'symbols' in exchange_info_response: symbol_info = None for sym in exchange_info_response['symbols']: if sym.get('symbol') == mexc_symbol: symbol_info = sym break if symbol_info: # Some exchanges provide symbol-specific fees in exchange info logger.info(f"MEXC: Found symbol info for {symbol}") # For now, use account-level fees as symbol-specific fees # This can be enhanced if MEXC provides symbol-specific fee endpoints account_fees = self.get_trading_fees() account_fees['symbol'] = symbol account_fees['symbol_specific'] = False return account_fees # Fallback to account-level fees account_fees = self.get_trading_fees() account_fees['symbol'] = symbol account_fees['symbol_specific'] = False return account_fees except Exception as e: logger.error(f"Error getting symbol trading fees for {symbol}: {e}") return { 'symbol': symbol, 'maker_rate': 0.0000, 'taker_rate': 0.0005, 'source': 'fallback', 'symbol_specific': False, 'error': str(e), 'timestamp': int(time.time()) }