import logging import time from typing import Dict, Any, List, Optional import requests import hmac import hashlib from urllib.parse import urlencode, quote_plus from .exchange_interface import ExchangeInterface logger = logging.getLogger(__name__) # https://github.com/mexcdevelop/mexc-api-postman/blob/main/MEXC%20V3.postman_collection.json # MEXC V3.postman_collection.json class MEXCInterface(ExchangeInterface): """MEXC Exchange API Interface""" def __init__(self, api_key: str = "", api_secret: str = "", test_mode: bool = True, trading_mode: str = 'simulation'): """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) trading_mode: 'simulation', 'testnet', or 'live'. Determines API endpoints used. """ super().__init__(api_key, api_secret, test_mode) self.trading_mode = trading_mode # Store the trading mode # MEXC API Base URLs self.base_url = "https://api.mexc.com" # Live API URL if self.trading_mode == 'testnet': # Note: MEXC does not have a separate testnet for spot trading. # We use the live API for 'testnet' mode and rely on 'simulation' for true dry-runs. logger.warning("MEXC does not have a separate testnet for spot trading. Using live API for 'testnet' mode.") self.api_version = "api/v3" self.recv_window = 5000 # 5 seconds window for request validity # Session for HTTP requests self.session = requests.Session() logger.info(f"MEXCInterface initialized in {self.trading_mode} mode. Ensure correct API endpoints are being used.") def connect(self) -> bool: """Test connection to MEXC API by fetching account info.""" if not self.api_key or not self.api_secret: logger.error("MEXC API key or secret not set. Cannot connect.") return False # Test connection by making a small, authenticated request try: account_info = self.get_account_info() if account_info: logger.info("Successfully connected to MEXC API and retrieved account info.") return True else: logger.error("Failed to connect to MEXC API: Could not retrieve account info.") return False except Exception as e: logger.error(f"Exception during MEXC API connection test: {e}") return False def _format_spot_symbol(self, symbol: str) -> str: """Formats a symbol to MEXC spot API standard and converts USDT to USDC for execution.""" if '/' in symbol: base, quote = symbol.split('/') # Convert USDT to USDC for MEXC execution (MEXC API only supports USDC pairs) if quote.upper() == 'USDT': quote = 'USDC' return f"{base.upper()}{quote.upper()}" else: # Convert USDT to USDC for symbols like ETHUSDT -> ETHUSDC if symbol.upper().endswith('USDT'): symbol = symbol.upper().replace('USDT', 'USDC') return symbol.upper() def _format_futures_symbol(self, symbol: str) -> str: """Formats a symbol to MEXC futures API standard (e.g., 'ETH/USDT' -> 'ETH_USDT').""" # This method is included for completeness but should not be used for spot trading return symbol.replace('/', '_').upper() def _generate_signature(self, params: Dict[str, Any]) -> str: """Generate signature for private API calls using MEXC's parameter ordering""" # MEXC uses specific parameter ordering for signature generation # Based on working Postman collection: symbol, side, type, quantity, price, timestamp, recvWindow, then others # Remove signature if present clean_params = {k: v for k, v in params.items() if k != 'signature'} # MEXC parameter order (from working Postman collection) mexc_order = ['symbol', 'side', 'type', 'quantity', 'price', 'timestamp', 'recvWindow'] ordered_params = [] # Add parameters in MEXC's expected order for param_name in mexc_order: if param_name in clean_params: ordered_params.append(f"{param_name}={clean_params[param_name]}") del clean_params[param_name] # Add any remaining parameters in alphabetical order for key in sorted(clean_params.keys()): ordered_params.append(f"{key}={clean_params[key]}") # Create query string query_string = '&'.join(ordered_params) logger.debug(f"MEXC signature query string: {query_string}") # 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: {signature}") return signature def _send_public_request(self, method: str, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Any: """Send a public API request to MEXC.""" if params is None: params = {} url = f"{self.base_url}/{self.api_version}/{endpoint}" headers = {'Accept': 'application/json'} try: response = requests.request(method, url, params=params, headers=headers, timeout=10) response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx) return response.json() except requests.exceptions.HTTPError as http_err: logger.error(f"HTTP error in public request to {endpoint}: {response.status_code} {response.reason}") logger.error(f"Response content: {response.text}") return {} except requests.exceptions.ConnectionError as conn_err: logger.error(f"Connection error in public request to {endpoint}: {conn_err}") return {} except requests.exceptions.Timeout as timeout_err: logger.error(f"Timeout error in public request to {endpoint}: {timeout_err}") return {} except Exception as e: logger.error(f"Error in public request to {endpoint}: {e}") return {} def _send_private_request(self, method: str, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]: """Send a private request to the exchange with proper signature and MEXC error handling""" if params is None: params = {} timestamp = str(int(time.time() * 1000)) # Add timestamp and recvWindow to params for signature and request params['timestamp'] = timestamp params['recvWindow'] = str(self.recv_window) # Generate signature with all parameters signature = self._generate_signature(params) params['signature'] = signature headers = { "X-MEXC-APIKEY": self.api_key } # For spot API, use the correct endpoint format if not endpoint.startswith('api/v3/'): endpoint = f"api/v3/{endpoint}" url = f"{self.base_url}/{endpoint}" try: if method.upper() == "GET": response = self.session.get(url, headers=headers, params=params, timeout=10) elif method.upper() == "POST": # For POST requests, MEXC expects parameters as query parameters, not form data # Based on Postman collection: Content-Type header is disabled response = self.session.post(url, headers=headers, params=params, timeout=10) elif method.upper() == "DELETE": response = self.session.delete(url, headers=headers, params=params, timeout=10) else: logger.error(f"Unsupported method: {method}") return None logger.debug(f"Request URL: {response.url}") logger.debug(f"Response status: {response.status_code}") if response.status_code == 200: return response.json() else: # Parse error response for specific error codes try: error_data = response.json() error_code = error_data.get('code') error_msg = error_data.get('msg', 'Unknown error') # Handle specific MEXC error codes if error_code == 30005: # Oversold logger.warning(f"MEXC Oversold detected (Code 30005) for {endpoint}. This indicates risk control measures are active.") logger.warning(f"Possible causes: Market manipulation detection, abnormal trading patterns, or position limits.") logger.warning(f"Action: Waiting before retry and reducing position size if needed.") # For oversold errors, we should not retry immediately # Return a special error structure that the trading executor can handle return { 'error': 'oversold', 'code': 30005, 'message': error_msg, 'retry_after': 60 # Suggest waiting 60 seconds } elif error_code == 30001: # Transaction direction not allowed logger.error(f"MEXC: Transaction direction not allowed for {endpoint}") return { 'error': 'direction_not_allowed', 'code': 30001, 'message': error_msg } elif error_code == 30004: # Insufficient position logger.error(f"MEXC: Insufficient position for {endpoint}") return { 'error': 'insufficient_position', 'code': 30004, 'message': error_msg } else: logger.error(f"MEXC API error: Code: {error_code}, Message: {error_msg}") return { 'error': 'api_error', 'code': error_code, 'message': error_msg } except: # Fallback if response is not JSON logger.error(f"API error: Status Code: {response.status_code}, Response: {response.text}") return None except requests.exceptions.HTTPError as http_err: logger.error(f"HTTP error for {endpoint}: Status Code: {response.status_code}, Response: {response.text}") logger.error(f"HTTP error details: {http_err}") return None except Exception as e: logger.error(f"Request error for {endpoint}: {e}") return None def get_account_info(self) -> Dict[str, Any]: """Get account information""" endpoint = "account" result = self._send_private_request("GET", endpoint, {}) return result if result is not None else {} def get_balance(self, asset: str) -> float: """Get available balance for a specific asset.""" account_info = self.get_account_info() if account_info and 'balances' in account_info: for balance in account_info['balances']: if balance.get('asset') == asset.upper(): return float(balance.get('free', 0.0)) logger.warning(f"Could not retrieve free balance for {asset}") return 0.0 def get_ticker(self, symbol: str) -> Optional[Dict[str, Any]]: """Get ticker information for a symbol.""" formatted_symbol = self._format_spot_symbol(symbol) endpoint = "ticker/24hr" params = {'symbol': formatted_symbol} response = self._send_public_request('GET', endpoint, params) if response: # MEXC ticker returns a dictionary if single symbol, list if all symbols if isinstance(response, dict): ticker_data = response elif isinstance(response, list) and len(response) > 0: # If the response is a list, try to find the specific symbol found_ticker = None for item in response: if isinstance(item, dict) and item.get('symbol') == formatted_symbol: found_ticker = item break if found_ticker: ticker_data = found_ticker else: logger.error(f"Ticker data for {formatted_symbol} not found in response list.") return None else: logger.error(f"Unexpected ticker response format: {response}") return None # Extract relevant info and format for universal use last_price = float(ticker_data.get('lastPrice', 0)) bid_price = float(ticker_data.get('bidPrice', 0)) ask_price = float(ticker_data.get('askPrice', 0)) volume = float(ticker_data.get('volume', 0)) # Base asset volume # Determine price change and percent change price_change = float(ticker_data.get('priceChange', 0)) price_change_percent = float(ticker_data.get('priceChangePercent', 0)) logger.info(f"MEXC: Got ticker from {endpoint} for {symbol}: ${last_price:.2f}") return { 'symbol': formatted_symbol, 'last': last_price, 'bid': bid_price, 'ask': ask_price, 'volume': volume, 'high': float(ticker_data.get('highPrice', 0)), 'low': float(ticker_data.get('lowPrice', 0)), 'change': price_change_percent, # This is usually priceChangePercent 'exchange': 'MEXC', 'raw_data': ticker_data } logger.error(f"Failed to get ticker for {symbol}") return None def get_api_symbols(self) -> List[str]: """Get list of symbols supported for API trading""" try: endpoint = "selfSymbols" result = self._send_private_request("GET", endpoint, {}) if result and 'data' in result: return result['data'] elif isinstance(result, list): return result else: logger.warning(f"Unexpected response format for API symbols: {result}") return [] except Exception as e: logger.error(f"Error getting API symbols: {e}") return [] def is_symbol_supported(self, symbol: str) -> bool: """Check if a symbol is supported for API trading""" formatted_symbol = self._format_spot_symbol(symbol) supported_symbols = self.get_api_symbols() return formatted_symbol in supported_symbols def place_order(self, symbol: str, side: str, order_type: str, quantity: float, price: Optional[float] = None) -> Dict[str, Any]: """Place a new order on MEXC.""" try: logger.info(f"MEXC: place_order called with symbol={symbol}, side={side}, order_type={order_type}, quantity={quantity}, price={price}") formatted_symbol = self._format_spot_symbol(symbol) logger.info(f"MEXC: Formatted symbol: {symbol} -> {formatted_symbol}") # Check if symbol is supported for API trading if not self.is_symbol_supported(symbol): supported_symbols = self.get_api_symbols() logger.error(f"Symbol {formatted_symbol} is not supported for API trading") logger.info(f"Supported symbols include: {supported_symbols[:10]}...") # Show first 10 return {} # Round quantity to MEXC precision requirements and ensure minimum order value # MEXC ETHUSDC requires precision based on baseAssetPrecision (5 decimals for ETH) original_quantity = quantity if 'ETH' in formatted_symbol: quantity = round(quantity, 5) # MEXC ETHUSDC precision: 5 decimals # Ensure minimum order value (typically $10+ for MEXC) if price and quantity * price < 10.0: quantity = round(10.0 / price, 5) # Adjust to minimum $10 order elif 'BTC' in formatted_symbol: quantity = round(quantity, 6) # MEXC BTCUSDC precision: 6 decimals if price and quantity * price < 10.0: quantity = round(10.0 / price, 6) # Adjust to minimum $10 order else: quantity = round(quantity, 5) # Default precision for MEXC if price and quantity * price < 10.0: quantity = round(10.0 / price, 5) # Adjust to minimum $10 order if quantity != original_quantity: logger.info(f"MEXC: Adjusted quantity: {original_quantity} -> {quantity}") # MEXC doesn't support MARKET orders for many pairs - use LIMIT orders instead if order_type.upper() == 'MARKET': # Convert market order to limit order with aggressive pricing for immediate execution if price is None: ticker = self.get_ticker(symbol) if ticker and 'last' in ticker: current_price = float(ticker['last']) # For buy orders, use slightly above market to ensure immediate execution # For sell orders, use slightly below market to ensure immediate execution if side.upper() == 'BUY': price = current_price * 1.002 # 0.2% premium for immediate buy execution else: price = current_price * 0.998 # 0.2% discount for immediate sell execution else: logger.error("Cannot get current price for market order conversion") return {} # Convert to limit order with immediate execution pricing order_type = 'LIMIT' logger.info(f"MEXC: Converting MARKET to aggressive LIMIT order at ${price:.2f} for immediate execution") # Prepare order parameters params = { 'symbol': formatted_symbol, 'side': side.upper(), 'type': order_type.upper(), 'quantity': str(quantity) # Quantity must be a string } if price is not None: # Format price to remove unnecessary decimal places (e.g., 2900.0 -> 2900) params['price'] = str(int(price)) if price == int(price) else str(price) logger.info(f"MEXC: Placing {side.upper()} {order_type.upper()} order for {quantity} {formatted_symbol} at price {price}") logger.info(f"MEXC: Order parameters: {params}") # Use the standard private request method which handles timestamp and signature endpoint = "order" result = self._send_private_request("POST", endpoint, params) if result: # Check if result contains error information if isinstance(result, dict) and 'error' in result: error_type = result.get('error') error_code = result.get('code') error_msg = result.get('message', 'Unknown error') logger.error(f"MEXC: Order failed with error {error_code}: {error_msg}") return result # Return error result for handling by trading executor else: logger.info(f"MEXC: Order placed successfully: {result}") return result else: logger.error(f"MEXC: Failed to place order - _send_private_request returned None/empty result") logger.error(f"MEXC: Failed order details - symbol: {formatted_symbol}, side: {side}, type: {order_type}, quantity: {quantity}, price: {price}") return {} except Exception as e: logger.error(f"MEXC: Exception in place_order: {e}") logger.error(f"MEXC: Exception details - symbol: {symbol}, side: {side}, type: {order_type}, quantity: {quantity}, price: {price}") import traceback logger.error(f"MEXC: Full traceback: {traceback.format_exc()}") return {} def cancel_order(self, symbol: str, order_id: str) -> Dict[str, Any]: """Cancel an existing order on MEXC.""" formatted_symbol = self._format_spot_symbol(symbol) endpoint = "order" params = { 'symbol': formatted_symbol, 'orderId': order_id } logger.info(f"MEXC: Cancelling order {order_id} for {formatted_symbol}") try: # MEXC API endpoint for cancelling orders is /api/v3/order (DELETE) cancel_result = self._send_private_request('DELETE', endpoint, params) if cancel_result: logger.info(f"MEXC: Order cancelled successfully: {cancel_result}") return cancel_result else: logger.error(f"MEXC: Error cancelling order: {cancel_result}") return {} except Exception as e: logger.error(f"MEXC: Exception cancelling order: {e}") return {} def get_order_status(self, symbol: str, order_id: str) -> Dict[str, Any]: """Get the status of an order on MEXC.""" formatted_symbol = self._format_spot_symbol(symbol) endpoint = "order" params = { 'symbol': formatted_symbol, 'orderId': order_id } logger.info(f"MEXC: Getting status for order {order_id} for {formatted_symbol}") try: # MEXC API endpoint for order status is /api/v3/order (GET) status_result = self._send_private_request('GET', endpoint, params) if status_result: logger.info(f"MEXC: Order status retrieved: {status_result}") return status_result else: logger.error(f"MEXC: Error getting order status: {status_result}") return {} except Exception as e: logger.error(f"MEXC: Exception getting order status: {e}") return {} def get_open_orders(self, symbol: Optional[str] = None) -> List[Dict[str, Any]]: """Get all open orders on MEXC for a symbol or all symbols.""" endpoint = "openOrders" params = {} if symbol: params['symbol'] = self._format_spot_symbol(symbol) logger.info(f"MEXC: Getting open orders for {symbol if symbol else 'all symbols'}") try: # MEXC API endpoint for open orders is /api/v3/openOrders (GET) open_orders = self._send_private_request('GET', endpoint, params) if open_orders and isinstance(open_orders, list): logger.info(f"MEXC: Retrieved {len(open_orders)} open orders.") return open_orders else: logger.error(f"MEXC: Error getting open orders: {open_orders}") return [] except Exception as e: logger.error(f"MEXC: Exception getting open orders: {e}") return [] def get_my_trades(self, symbol: str, limit: int = 100) -> List[Dict[str, Any]]: """Get trade history for a specific symbol.""" formatted_symbol = self._format_spot_symbol(symbol) endpoint = "myTrades" params = {'symbol': formatted_symbol, 'limit': limit} logger.info(f"MEXC: Getting trade history for {formatted_symbol} (limit: {limit})") try: # MEXC API endpoint for trade history is /api/v3/myTrades (GET) trade_history = self._send_private_request('GET', endpoint, params) if trade_history and isinstance(trade_history, list): logger.info(f"MEXC: Retrieved {len(trade_history)} trade records.") return trade_history else: logger.error(f"MEXC: Error getting trade history: {trade_history}") return [] except Exception as e: logger.error(f"MEXC: Exception getting trade history: {e}") return [] def get_server_time(self) -> int: """Get current MEXC server time in milliseconds.""" endpoint = "time" response = self._send_public_request('GET', endpoint) if response and 'serverTime' in response: return int(response['serverTime']) logger.error("Failed to get MEXC server time.") return int(time.time() * 1000) # Fallback to local time def get_all_balances(self) -> Dict[str, Dict[str, float]]: """Get all asset balances from MEXC account.""" account_info = self.get_account_info() balances = {} if account_info and 'balances' in account_info: for balance in account_info['balances']: asset = balance.get('asset') free = float(balance.get('free')) locked = float(balance.get('locked')) if asset: balances[asset.upper()] = {'free': free, 'locked': locked, 'total': free + locked} return balances def get_trading_fees(self) -> Dict[str, Any]: """Get current trading fee rates from MEXC API""" endpoint = "account/commission" response = self._send_private_request('GET', endpoint) if response and 'data' in response: fees_data = response['data'] return { 'maker': float(fees_data.get('makerCommission', 0.0)), 'taker': float(fees_data.get('takerCommission', 0.0)), 'default': float(fees_data.get('defaultCommission', 0.0)) } logger.error("Failed to get trading fees from MEXC API.") return {} def get_symbol_trading_fees(self, symbol: str) -> Dict[str, Any]: """Get trading fee rates for a specific symbol from MEXC API""" formatted_symbol = self._format_spot_symbol(symbol) endpoint = "account/commission" params = {'symbol': formatted_symbol} response = self._send_private_request('GET', endpoint, params) if response and 'data' in response: fees_data = response['data'] return { 'maker': float(fees_data.get('makerCommission', 0.0)), 'taker': float(fees_data.get('takerCommission', 0.0)), 'default': float(fees_data.get('defaultCommission', 0.0)) } logger.error(f"Failed to get trading fees for {symbol} from MEXC API.") return {}