Files
gogo2/core/trading_executor.py
Dobromir Popov 439611cf88 trading works!
2025-07-15 01:10:37 +03:00

1999 lines
92 KiB
Python

"""
Trading Executor for MEXC API Integration
This module handles the execution of trading signals through the MEXC exchange API.
It includes position management, risk controls, and safety features.
https://github.com/mexcdevelop/mexc-api-postman/blob/main/MEXC%20V3.postman_collection.json
MEXC V3.postman_collection.json
"""
import logging
import time
import os
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Any
from dataclasses import dataclass
from threading import Lock, RLock
import threading
import time
import sys
# Add NN directory to path for exchange interfaces
sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'NN'))
from NN.exchanges.exchange_factory import ExchangeFactory
from NN.exchanges.exchange_interface import ExchangeInterface
from .config import get_config
from .config_sync import ConfigSynchronizer
logger = logging.getLogger(__name__)
@dataclass
class Position:
"""Represents an open trading position"""
symbol: str
side: str # 'LONG' or 'SHORT'
quantity: float
entry_price: float
entry_time: datetime
order_id: str
unrealized_pnl: float = 0.0
def calculate_pnl(self, current_price: float) -> float:
"""Calculate unrealized P&L for the position"""
if self.side == 'LONG':
self.unrealized_pnl = (current_price - self.entry_price) * self.quantity
else: # SHORT
self.unrealized_pnl = (self.entry_price - current_price) * self.quantity
return self.unrealized_pnl
@dataclass
class TradeRecord:
"""Record of a completed trade"""
symbol: str
side: str
quantity: float
entry_price: float
exit_price: float
entry_time: datetime
exit_time: datetime
pnl: float
fees: float
confidence: float
hold_time_seconds: float = 0.0 # Hold time in seconds
class TradingExecutor:
"""Handles trade execution through multiple exchange APIs with risk management"""
def __init__(self, config_path: str = "config.yaml"):
"""Initialize the trading executor"""
self.config = get_config(config_path)
self.exchanges_config = self.config.get('exchanges', {})
self.trading_config = self.config.get('trading', {})
# Initialize exchanges using factory
self.primary_exchange = ExchangeFactory.get_primary_exchange(self.exchanges_config)
self.all_exchanges = ExchangeFactory.create_multiple_exchanges(self.exchanges_config)
# Set primary exchange as main interface
self.exchange = self.primary_exchange
if not self.exchange:
logger.error("Failed to initialize primary exchange")
self.trading_enabled = False
self.simulation_mode = True
else:
primary_name = self.exchanges_config.get('primary', 'deribit')
primary_config = self.exchanges_config.get(primary_name, {})
# Determine trading and simulation modes
trading_mode = primary_config.get('trading_mode', 'simulation')
self.trading_enabled = self.trading_config.get('enabled', True)
self.simulation_mode = trading_mode == 'simulation'
logger.info(f"Trading Executor initialized with {primary_name} as primary exchange")
logger.info(f"Trading mode: {trading_mode}, Simulation: {self.simulation_mode}")
# Get primary exchange name and config
self.primary_name = self.exchanges_config.get('primary', 'mexc')
self.primary_config = self.exchanges_config.get(self.primary_name, {})
# Set exchange config for compatibility (replaces mexc_config)
self.exchange_config = self.primary_config
self.mexc_config = self.primary_config # Legacy compatibility
# Initialize config synchronizer with the primary exchange
self.config_sync = ConfigSynchronizer(
config_path=config_path,
mexc_interface=self.exchange if (self.trading_enabled and self.primary_name == 'mexc') else None
)
# Trading state management
self.current_position = {} # symbol -> position data
self.trade_history = []
self.daily_trades = 0
self.daily_pnl = 0.0
self.daily_loss = 0.0
self.last_trade_time = {}
self.consecutive_losses = 0 # Track consecutive losing trades
# Store trading mode for compatibility
self.trading_mode = self.primary_config.get('trading_mode', 'simulation')
# Initialize session stats
self.session_start_time = datetime.now()
self.session_trades = 0
self.session_pnl = 0.0
# Position tracking
self.positions = {} # symbol -> Position object
self.trade_records = [] # List of TradeRecord objects
logger.info(f"TradingExecutor initialized - Trading: {self.trading_enabled}, Mode: {self.trading_mode}")
# Legacy compatibility (deprecated)
self.dry_run = self.simulation_mode
# Thread safety with timeout support
self.lock = RLock()
self.lock_timeout = 10.0 # 10 second timeout for order execution
# Open order management
self.max_open_orders = 2 # Maximum number of open orders allowed
self.open_orders_count = 0 # Current count of open orders
# Trading symbols
self.symbols = self.config.get('symbols', ['ETH/USDT', 'BTC/USDT'])
# Connect to exchange
if self.trading_enabled:
logger.info("TRADING EXECUTOR: Attempting to connect to exchange...")
if not self._connect_exchange():
logger.error("TRADING EXECUTOR: Failed initial exchange connection. Trading will be disabled.")
self.trading_enabled = False
else:
logger.info("TRADING EXECUTOR: Trading is explicitly disabled in config.")
logger.info(f"Trading Executor initialized - Mode: {self.trading_mode}, Enabled: {self.trading_enabled}")
# Get exchange-specific configuration
self.exchange_config = self.primary_config
# Initialize config synchronizer for automatic fee updates (only for MEXC)
self.config_synchronizer = ConfigSynchronizer(
config_path=config_path,
mexc_interface=self.exchange if (self.trading_enabled and self.primary_name == 'mexc') else None
)
# Perform initial fee sync on startup if trading is enabled and using MEXC
if self.trading_enabled and self.exchange and self.primary_name == 'mexc':
try:
logger.info(f"TRADING EXECUTOR: Performing initial fee synchronization with {self.primary_name.upper()} API")
sync_result = self.config_synchronizer.sync_trading_fees(force=True)
if sync_result.get('status') == 'success':
logger.info("TRADING EXECUTOR: Fee synchronization completed successfully")
if sync_result.get('changes_made'):
logger.info(f"TRADING EXECUTOR: Fee changes applied: {list(sync_result['changes'].keys())}")
# Reload config to get updated fees
self.config = get_config(config_path)
self.exchange_config = self.config.get('exchanges', {}).get(self.primary_name, {})
elif sync_result.get('status') == 'warning':
logger.warning("TRADING EXECUTOR: Fee sync completed with warnings")
else:
logger.warning(f"TRADING EXECUTOR: Fee sync failed: {sync_result.get('status')}")
except Exception as e:
logger.warning(f"TRADING EXECUTOR: Initial fee sync failed: {e}")
elif self.trading_enabled and self.exchange:
logger.info(f"TRADING EXECUTOR: Using {self.primary_name.upper()} exchange - fee sync not available")
logger.info(f"Trading Executor initialized - Exchange: {self.primary_name.upper()}, Mode: {self.trading_mode}, Enabled: {self.trading_enabled}")
def _safe_exchange_call(self, method_name: str, *args, **kwargs):
"""Safely call exchange methods with null checking"""
if not self.exchange:
logger.warning(f"No exchange interface available for {method_name}")
return None
try:
method = getattr(self.exchange, method_name, None)
if method:
return method(*args, **kwargs)
else:
logger.error(f"Method {method_name} not available on exchange")
return None
except Exception as e:
logger.error(f"Error calling {method_name}: {e}")
return None
def _connect_exchange(self) -> bool:
"""Connect to the primary exchange"""
if not self.exchange:
logger.warning("No exchange interface available")
return False
if self.exchange.connect():
logger.info("Successfully connected to exchange")
return True
else:
logger.error("Failed to connect to exchange")
return False
def execute_signal(self, symbol: str, action: str, confidence: float,
current_price: Optional[float] = None) -> bool:
"""Execute a trading signal
Args:
symbol: Trading symbol (e.g., 'ETH/USDT')
action: Trading action ('BUY', 'SELL', 'HOLD')
confidence: Confidence level (0.0 to 1.0)
current_price: Current market price
Returns:
bool: True if trade executed successfully
"""
logger.debug(f"TRADING EXECUTOR: execute_signal called. trading_enabled: {self.trading_enabled}")
if not self.trading_enabled:
logger.info(f"Trading disabled - Signal: {action} {symbol} (confidence: {confidence:.2f}) - Reason: Trading executor is not enabled.")
return False
if action == 'HOLD':
return True
# Check safety conditions
if not self._check_safety_conditions(symbol, action):
return False
# Get current price if not provided
if current_price is None:
ticker = self.exchange.get_ticker(symbol)
if not ticker or 'last' not in ticker:
logger.error(f"Failed to get current price for {symbol} or ticker is malformed.")
return False
current_price = ticker['last']
# Assert that current_price is not None for type checking
assert current_price is not None, "current_price should not be None at this point"
# --- Balance check before executing trade (skip in simulation mode) ---
# Only perform balance check for live trading, not simulation
if not self.simulation_mode and (action == 'BUY' or (action == 'SELL' and symbol not in self.positions) or (action == 'SHORT')):
# Determine the quote asset (e.g., USDT, USDC) from the symbol
if '/' in symbol:
quote_asset = symbol.split('/')[1].upper() # Assuming symbol is like ETH/USDT
# Keep USDT as USDT for MEXC spot trading (no conversion needed)
else:
# Fallback for symbols like ETHUSDT (assuming last 4 chars are quote)
quote_asset = symbol[-4:].upper()
# Keep USDT as USDT for MEXC spot trading (no conversion needed)
# Calculate required capital for the trade
# If we are selling (to open a short position), we need collateral based on the position size
# For simplicity, assume required capital is the full position value in USD
required_capital = self._calculate_position_size(confidence, current_price)
# Get available balance for the quote asset (try USDT first, then USDC as fallback)
if quote_asset == 'USDT':
available_balance = self.exchange.get_balance('USDT')
if available_balance < required_capital:
# If USDT balance is insufficient, check USDC as fallback
usdc_balance = self.exchange.get_balance('USDC')
if usdc_balance >= required_capital:
available_balance = usdc_balance
quote_asset = 'USDC' # Use USDC instead
logger.info(f"BALANCE CHECK: Using USDC fallback balance for {symbol}")
else:
available_balance = self.exchange.get_balance(quote_asset)
logger.info(f"BALANCE CHECK: Symbol: {symbol}, Action: {action}, Required: ${required_capital:.2f} {quote_asset}, Available: ${available_balance:.2f} {quote_asset}")
# Allow some small precision tolerance (1 cent) and ensure sufficient balance
balance_tolerance = 0.01
if available_balance < (required_capital - balance_tolerance):
logger.warning(f"Trade blocked for {symbol} {action}: Insufficient {quote_asset} balance. "
f"Required: ${required_capital:.2f}, Available: ${available_balance:.2f}")
return False
else:
logger.info(f"BALANCE CHECK PASSED: {quote_asset} balance sufficient for trade")
elif self.simulation_mode:
logger.debug(f"SIMULATION MODE: Skipping balance check for {symbol} {action} - allowing trade for model training")
# --- End Balance check ---
# Try to acquire lock with timeout to prevent hanging
lock_acquired = self.lock.acquire(timeout=self.lock_timeout)
if not lock_acquired:
logger.error(f"TIMEOUT: Failed to acquire lock within {self.lock_timeout}s for {action} {symbol}")
logger.error(f"This indicates another operation is hanging. Cancelling {action} order.")
return False
try:
logger.info(f"LOCK ACQUIRED: Executing {action} for {symbol}")
start_time = time.time()
if action == 'BUY':
result = self._execute_buy(symbol, confidence, current_price)
elif action == 'SELL':
result = self._execute_sell(symbol, confidence, current_price)
elif action == 'SHORT': # Explicitly handle SHORT if it's a direct signal
result = self._execute_short(symbol, confidence, current_price)
else:
logger.warning(f"Unknown action: {action}")
result = False
execution_time = time.time() - start_time
logger.info(f"EXECUTION COMPLETE: {action} for {symbol} took {execution_time:.2f}s, result: {result}")
return result
except Exception as e:
logger.error(f"Error executing {action} signal for {symbol}: {e}")
return False
finally:
self.lock.release()
logger.debug(f"LOCK RELEASED: {action} for {symbol}")
def _get_open_orders_count(self) -> int:
"""Get current count of open orders across all symbols"""
try:
if self.simulation_mode:
return 0
total_open_orders = 0
for symbol in self.symbols:
open_orders = self.exchange.get_open_orders(symbol)
total_open_orders += len(open_orders)
return total_open_orders
except Exception as e:
logger.error(f"Error getting open orders count: {e}")
return 0
def _can_place_new_order(self) -> bool:
"""Check if we can place a new order based on open order limit"""
current_count = self._get_open_orders_count()
can_place = current_count < self.max_open_orders
if not can_place:
logger.warning(f"Cannot place new order: {current_count}/{self.max_open_orders} open orders")
return can_place
def sync_open_orders(self) -> Dict[str, Any]:
"""Synchronize open orders with exchange and update internal state
Returns:
dict: Sync result with status and order details
"""
try:
if self.simulation_mode:
return {
'status': 'success',
'message': 'Simulation mode - no real orders to sync',
'orders': [],
'count': 0
}
sync_result = {
'status': 'started',
'orders': [],
'count': 0,
'errors': []
}
total_orders = 0
all_orders = []
# Sync orders for each symbol
for symbol in self.symbols:
try:
open_orders = self.exchange.get_open_orders(symbol)
if open_orders:
symbol_orders = []
for order in open_orders:
order_info = {
'symbol': symbol,
'order_id': order.get('orderId'),
'side': order.get('side'),
'type': order.get('type'),
'quantity': float(order.get('origQty', 0)),
'price': float(order.get('price', 0)),
'status': order.get('status'),
'time': order.get('time')
}
symbol_orders.append(order_info)
all_orders.append(order_info)
total_orders += len(symbol_orders)
logger.info(f"Synced {len(symbol_orders)} open orders for {symbol}")
except Exception as e:
error_msg = f"Error syncing orders for {symbol}: {e}"
logger.error(error_msg)
sync_result['errors'].append(error_msg)
# Update internal state
self.open_orders_count = total_orders
sync_result.update({
'status': 'success',
'orders': all_orders,
'count': total_orders,
'message': f"Synced {total_orders} open orders across {len(self.symbols)} symbols"
})
logger.info(f"Open order sync completed: {total_orders} orders")
return sync_result
except Exception as e:
logger.error(f"Error in open order sync: {e}")
return {
'status': 'error',
'message': str(e),
'orders': [],
'count': 0,
'errors': [str(e)]
}
def _cancel_open_orders(self, symbol: str) -> int:
"""Cancel all open orders for a symbol and return count of cancelled orders"""
try:
if self.simulation_mode:
return 0
open_orders = self.exchange.get_open_orders(symbol)
cancelled_count = 0
for order in open_orders:
order_id = order.get('orderId')
if order_id:
try:
cancel_result = self.exchange.cancel_order(symbol, str(order_id))
if cancel_result:
cancelled_count += 1
logger.info(f"Cancelled order {order_id} for {symbol}")
time.sleep(0.1) # Small delay between cancellations
except Exception as e:
logger.warning(f"Failed to cancel order {order_id}: {e}")
return cancelled_count
except Exception as e:
logger.error(f"Error cancelling open orders for {symbol}: {e}")
return 0
def _check_safety_conditions(self, symbol: str, action: str) -> bool:
"""Check if it's safe to execute a trade"""
# Check if trading is stopped
if self.exchange_config.get('emergency_stop', False):
logger.warning("Emergency stop is active - no trades allowed")
return False
# Check symbol allowlist
allowed_symbols = self.exchange_config.get('allowed_symbols', [])
if allowed_symbols and symbol not in allowed_symbols:
logger.warning(f"Symbol {symbol} not in allowed list: {allowed_symbols}")
return False
# Check daily loss limit (use trading config as fallback)
max_daily_loss = self.exchange_config.get('max_daily_loss_usd',
self.trading_config.get('max_daily_loss_usd', 200.0))
if self.daily_loss >= max_daily_loss:
logger.warning(f"Daily loss limit reached: ${self.daily_loss:.2f} >= ${max_daily_loss}")
return False
# Check trade interval - allow bypass for test scenarios
min_interval = self.exchange_config.get('min_trade_interval_seconds',
self.trading_config.get('min_trade_interval_seconds', 5))
last_trade = self.last_trade_time.get(symbol, datetime.min)
time_since_last = (datetime.now() - last_trade).total_seconds()
# Allow bypass for high confidence sells (closing positions) or test scenarios
if time_since_last < min_interval:
# Allow immediate sells for closing positions or very high confidence (0.9+)
if action == 'SELL' and symbol in self.positions:
logger.info(f"Allowing immediate SELL to close position for {symbol}")
elif hasattr(self, '_test_mode') and self._test_mode:
logger.info(f"Test mode: bypassing trade interval for {symbol}")
else:
logger.info(f"Trade interval not met for {symbol} ({time_since_last:.1f}s < {min_interval}s)")
return False
# Check concurrent positions
max_positions = self.exchange_config.get('max_concurrent_positions',
self.trading_config.get('max_concurrent_positions', 3))
if len(self.positions) >= max_positions and action == 'BUY':
logger.warning(f"Maximum concurrent positions reached: {len(self.positions)}")
return False
# Check open order limit
if not self._can_place_new_order():
logger.warning(f"Maximum open orders reached: {self._get_open_orders_count()}/{self.max_open_orders}")
return False
return True
def _execute_buy(self, symbol: str, confidence: float, current_price: float) -> bool:
"""Execute a buy order"""
# Check if we have a short position to close
if symbol in self.positions:
position = self.positions[symbol]
if position.side == 'SHORT':
logger.info(f"Closing SHORT position in {symbol}")
return self._close_short_position(symbol, confidence, current_price)
else:
logger.info(f"Already have LONG position in {symbol}")
return False
# Cancel any existing open orders before placing new order
if not self.simulation_mode:
self._cancel_open_orders(symbol)
# Calculate position size
position_size = self._calculate_position_size(confidence, current_price)
quantity = position_size / current_price
logger.info(f"Executing BUY: {quantity:.6f} {symbol} at ${current_price:.2f} (value: ${position_size:.2f}, confidence: {confidence:.2f}) [{'SIM' if self.simulation_mode else 'LIVE'}]")
if self.simulation_mode:
# Create simulated position
self.positions[symbol] = Position(
symbol=symbol,
side='LONG',
quantity=quantity,
entry_price=current_price,
entry_time=datetime.now(),
order_id=f"sim_{int(datetime.now().timestamp())}"
)
logger.info(f"Simulated BUY order: {quantity:.6f} {symbol} at ${current_price:.2f}")
return True
else:
# Place real order with enhanced error handling
result = self._place_order_with_retry(symbol, 'BUY', 'MARKET', quantity, current_price)
if result and 'orderId' in result:
# Use actual fill information if available, otherwise fall back to order parameters
filled_quantity = result.get('executedQty', quantity)
fill_price = result.get('avgPrice', current_price)
# Only create position if order was actually filled
if result.get('filled', True): # Assume filled for backward compatibility
self.positions[symbol] = Position(
symbol=symbol,
side='LONG',
quantity=float(filled_quantity),
entry_price=float(fill_price),
entry_time=datetime.now(),
order_id=result['orderId']
)
logger.info(f"BUY position created: {filled_quantity:.6f} {symbol} at ${fill_price:.4f}")
self.last_trade_time[symbol] = datetime.now()
return True
else:
logger.error(f"BUY order placed but not filled: {result}")
return False
else:
logger.error("Failed to place BUY order")
return False
def _execute_sell(self, symbol: str, confidence: float, current_price: float) -> bool:
"""Execute a sell order (close long position or open short position)"""
if symbol in self.positions:
position = self.positions[symbol]
if position.side == 'LONG':
logger.info(f"Closing LONG position in {symbol}")
return self._close_long_position(symbol, confidence, current_price)
else:
logger.info(f"Already have SHORT position in {symbol}")
return False
else:
# No position to sell, open short position
logger.info(f"No position to sell in {symbol}. Opening short position")
return self._execute_short(symbol, confidence, current_price)
def _execute_short(self, symbol: str, confidence: float, current_price: float) -> bool:
"""Execute a short order (sell without holding the asset)"""
# Cancel any existing open orders before placing new order
if not self.simulation_mode:
self._cancel_open_orders(symbol)
# Calculate position size
position_size = self._calculate_position_size(confidence, current_price)
quantity = position_size / current_price
logger.info(f"Executing SHORT: {quantity:.6f} {symbol} at ${current_price:.2f} (value: ${position_size:.2f}, confidence: {confidence:.2f}) [{'SIM' if self.simulation_mode else 'LIVE'}]")
if self.simulation_mode:
# Create simulated short position
self.positions[symbol] = Position(
symbol=symbol,
side='SHORT',
quantity=quantity,
entry_price=current_price,
entry_time=datetime.now(),
order_id=f"sim_{int(datetime.now().timestamp())}"
)
logger.info(f"Simulated SHORT order: {quantity:.6f} {symbol} at ${current_price:.2f}")
return True
else:
# Place real short order with enhanced error handling
result = self._place_order_with_retry(symbol, 'SELL', 'MARKET', quantity, current_price)
if result and 'orderId' in result:
# Use actual fill information if available, otherwise fall back to order parameters
filled_quantity = result.get('executedQty', quantity)
fill_price = result.get('avgPrice', current_price)
# Only create position if order was actually filled
if result.get('filled', True): # Assume filled for backward compatibility
self.positions[symbol] = Position(
symbol=symbol,
side='SHORT',
quantity=float(filled_quantity),
entry_price=float(fill_price),
entry_time=datetime.now(),
order_id=result['orderId']
)
logger.info(f"SHORT position created: {filled_quantity:.6f} {symbol} at ${fill_price:.4f}")
self.last_trade_time[symbol] = datetime.now()
return True
else:
logger.error(f"SHORT order placed but not filled: {result}")
return False
else:
logger.error("Failed to place SHORT order")
return False
def _place_order_with_retry(self, symbol: str, side: str, order_type: str, quantity: float, current_price: float, max_retries: int = 3) -> Dict[str, Any]:
"""Place order with retry logic for MEXC error handling"""
order_start_time = time.time()
max_order_time = 8.0 # Maximum 8 seconds for order placement (leaves 2s buffer for lock timeout)
for attempt in range(max_retries):
# Check if we've exceeded the maximum order time
elapsed_time = time.time() - order_start_time
if elapsed_time > max_order_time:
logger.error(f"ORDER TIMEOUT: Order placement exceeded {max_order_time}s limit for {side} {symbol}")
logger.error(f"Elapsed time: {elapsed_time:.2f}s, cancelling order to prevent lock hanging")
return {}
try:
# For retries, use more aggressive pricing for LIMIT orders
order_price = current_price
if order_type.upper() == 'LIMIT' and attempt > 0:
# Increase aggressiveness with each retry
aggression_factor = 1 + (0.005 * (attempt + 1)) # 0.5%, 1.0%, 1.5% etc.
if side.upper() == 'BUY':
order_price = current_price * aggression_factor
else:
order_price = current_price / aggression_factor
logger.info(f"Retry {attempt + 1}: Using more aggressive price ${order_price:.4f} (vs market ${current_price:.4f})")
result = self.exchange.place_order(symbol, side, order_type, quantity, order_price)
# 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')
if error_type == 'oversold' and error_code == 30005:
logger.warning(f"MEXC Oversold error on attempt {attempt + 1}/{max_retries}")
logger.warning(f"Error: {error_msg}")
if attempt < max_retries - 1:
# Calculate wait time but respect timeout limit
suggested_wait = result.get('retry_after', 60) * (2 ** attempt)
elapsed_time = time.time() - order_start_time
remaining_time = max_order_time - elapsed_time
if suggested_wait > remaining_time:
logger.warning(f"Oversold retry wait ({suggested_wait}s) exceeds timeout limit")
logger.warning(f"Remaining time: {remaining_time:.2f}s, skipping retry to prevent hanging")
return {}
logger.info(f"Waiting {suggested_wait} seconds before retry due to oversold condition...")
time.sleep(suggested_wait)
# Reduce quantity for next attempt to avoid oversold
quantity = quantity * 0.8 # Reduce by 20%
logger.info(f"Reducing quantity to {quantity:.6f} for retry")
continue
else:
logger.error(f"Max retries reached for oversold condition")
return {}
elif error_type == 'direction_not_allowed':
logger.error(f"Trading direction not allowed for {symbol} {side}")
return {}
elif error_type == 'insufficient_position':
logger.error(f"Insufficient position for {symbol} {side}")
return {}
else:
logger.error(f"MEXC API error: {error_code} - {error_msg}")
if attempt < max_retries - 1:
wait_time = 5 * (attempt + 1) # Wait 5, 10, 15 seconds
elapsed_time = time.time() - order_start_time
remaining_time = max_order_time - elapsed_time
if wait_time > remaining_time:
logger.warning(f"API error retry wait ({wait_time}s) exceeds timeout, cancelling")
return {}
time.sleep(wait_time)
continue
else:
return {}
# Success case - order placed
elif isinstance(result, dict) and ('orderId' in result or 'symbol' in result):
logger.info(f"Order placed successfully on attempt {attempt + 1}")
# For LIMIT orders, verify that the order actually fills
if order_type.upper() == 'LIMIT' and 'orderId' in result:
order_id = result['orderId']
filled_result = self._wait_for_order_fill(symbol, order_id, max_wait_time=5.0)
if filled_result['filled']:
logger.info(f"LIMIT order {order_id} filled successfully")
# Update result with fill information
result.update(filled_result)
return result
else:
logger.warning(f"LIMIT order {order_id} not filled within timeout, cancelling...")
# Cancel the unfilled order
try:
self.exchange.cancel_order(symbol, order_id)
logger.info(f"Cancelled unfilled order {order_id}")
except Exception as e:
logger.error(f"Failed to cancel unfilled order {order_id}: {e}")
# If this was the last attempt, return failure
if attempt == max_retries - 1:
return {}
# Try again with a more aggressive price
logger.info(f"Retrying with more aggressive LIMIT pricing...")
continue
else:
# MARKET orders or orders without orderId - assume immediate fill
return result
# Empty result - treat as failure
else:
logger.warning(f"Empty result on attempt {attempt + 1}/{max_retries}")
if attempt < max_retries - 1:
wait_time = 2 * (attempt + 1) # Wait 2, 4, 6 seconds
elapsed_time = time.time() - order_start_time
remaining_time = max_order_time - elapsed_time
if wait_time > remaining_time:
logger.warning(f"Empty result retry wait ({wait_time}s) exceeds timeout, cancelling")
return {}
time.sleep(wait_time)
continue
else:
return {}
except Exception as e:
logger.error(f"Exception on order attempt {attempt + 1}/{max_retries}: {e}")
if attempt < max_retries - 1:
wait_time = 3 * (attempt + 1) # Wait 3, 6, 9 seconds
elapsed_time = time.time() - order_start_time
remaining_time = max_order_time - elapsed_time
if wait_time > remaining_time:
logger.warning(f"Exception retry wait ({wait_time}s) exceeds timeout, cancelling")
return {}
time.sleep(wait_time)
continue
else:
return {}
logger.error(f"Failed to place order after {max_retries} attempts")
return {}
def _wait_for_order_fill(self, symbol: str, order_id: str, max_wait_time: float = 5.0) -> Dict[str, Any]:
"""Wait for a LIMIT order to fill and return fill status
Args:
symbol: Trading symbol
order_id: Order ID to monitor
max_wait_time: Maximum time to wait for fill in seconds
Returns:
dict: {'filled': bool, 'status': str, 'executedQty': float, 'avgPrice': float}
"""
start_time = time.time()
check_interval = 0.2 # Check every 200ms
while time.time() - start_time < max_wait_time:
try:
order_status = self.exchange.get_order_status(symbol, order_id)
if order_status and isinstance(order_status, dict):
status = order_status.get('status', '').upper()
executed_qty = float(order_status.get('executedQty', 0))
orig_qty = float(order_status.get('origQty', 0))
avg_price = float(order_status.get('cummulativeQuoteQty', 0)) / executed_qty if executed_qty > 0 else 0
logger.debug(f"Order {order_id} status: {status}, executed: {executed_qty}/{orig_qty}")
if status == 'FILLED':
return {
'filled': True,
'status': status,
'executedQty': executed_qty,
'avgPrice': avg_price,
'fillTime': time.time()
}
elif status in ['CANCELED', 'REJECTED', 'EXPIRED']:
return {
'filled': False,
'status': status,
'executedQty': executed_qty,
'avgPrice': avg_price,
'reason': f'Order {status.lower()}'
}
elif status == 'PARTIALLY_FILLED':
# For partial fills, continue waiting but log progress
fill_percentage = (executed_qty / orig_qty * 100) if orig_qty > 0 else 0
logger.debug(f"Order {order_id} partially filled: {fill_percentage:.1f}%")
# Wait before next check
time.sleep(check_interval)
except Exception as e:
logger.error(f"Error checking order status for {order_id}: {e}")
time.sleep(check_interval)
# Timeout - check final status
try:
final_status = self.exchange.get_order_status(symbol, order_id)
if final_status:
status = final_status.get('status', '').upper()
executed_qty = float(final_status.get('executedQty', 0))
avg_price = float(final_status.get('cummulativeQuoteQty', 0)) / executed_qty if executed_qty > 0 else 0
return {
'filled': status == 'FILLED',
'status': status,
'executedQty': executed_qty,
'avgPrice': avg_price,
'reason': 'timeout' if status != 'FILLED' else None
}
except Exception as e:
logger.error(f"Error getting final order status for {order_id}: {e}")
return {
'filled': False,
'status': 'UNKNOWN',
'executedQty': 0,
'avgPrice': 0,
'reason': 'timeout_and_status_check_failed'
}
def _close_short_position(self, symbol: str, confidence: float, current_price: float) -> bool:
"""Close a short position by buying"""
if symbol not in self.positions:
logger.warning(f"No position to close in {symbol}")
return False
position = self.positions[symbol]
if position.side != 'SHORT':
logger.warning(f"Position in {symbol} is not SHORT, cannot close with BUY")
return False
logger.info(f"Closing SHORT position: {position.quantity:.6f} {symbol} at ${current_price:.2f} "
f"(confidence: {confidence:.2f})")
if self.simulation_mode:
logger.info(f"SIMULATION MODE ({self.trading_mode.upper()}) - Short close logged but not executed")
# Calculate simulated fees in simulation mode
trading_fees = self.exchange_config.get('trading_fees', {})
taker_fee_rate = trading_fees.get('taker_fee', trading_fees.get('default_fee', 0.0006))
simulated_fees = position.quantity * current_price * taker_fee_rate
# Calculate P&L for short position and hold time
pnl = position.calculate_pnl(current_price)
exit_time = datetime.now()
hold_time_seconds = (exit_time - position.entry_time).total_seconds()
# Create trade record
trade_record = TradeRecord(
symbol=symbol,
side='SHORT',
quantity=position.quantity,
entry_price=position.entry_price,
exit_price=current_price,
entry_time=position.entry_time,
exit_time=exit_time,
pnl=pnl,
fees=simulated_fees,
confidence=confidence,
hold_time_seconds=hold_time_seconds
)
self.trade_history.append(trade_record)
self.daily_loss += max(0, -pnl) # Add to daily loss if negative
# Update consecutive losses
if pnl < -0.001: # A losing trade
self.consecutive_losses += 1
elif pnl > 0.001: # A winning trade
self.consecutive_losses = 0
else: # Breakeven trade
self.consecutive_losses = 0
# Remove position
del self.positions[symbol]
self.last_trade_time[symbol] = datetime.now()
self.daily_trades += 1
logger.info(f"Position closed - P&L: ${pnl:.2f}")
return True
try:
# Get order type from config
order_type = self.exchange_config.get('order_type', 'market').lower()
# For limit orders, set price slightly above market for immediate execution
limit_price = None
if order_type == 'limit':
# Set buy price slightly above market to ensure immediate execution
limit_price = current_price * 1.001 # 0.1% above market
# Place buy order to close short
if order_type == 'market':
order = self.exchange.place_order(
symbol=symbol,
side='buy', # Buy to close short position
order_type=order_type,
quantity=position.quantity
)
else:
# For limit orders, price is required
assert limit_price is not None, "limit_price required for limit orders"
order = self.exchange.place_order(
symbol=symbol,
side='buy', # Buy to close short position
order_type=order_type,
quantity=position.quantity,
price=limit_price
)
if order:
# Calculate fees using real API data when available
fees = self._calculate_real_trading_fees(order, symbol, position.quantity, current_price)
# Calculate P&L, fees, and hold time
pnl = position.calculate_pnl(current_price)
exit_time = datetime.now()
hold_time_seconds = (exit_time - position.entry_time).total_seconds()
# Create trade record
trade_record = TradeRecord(
symbol=symbol,
side='SHORT',
quantity=position.quantity,
entry_price=position.entry_price,
exit_price=current_price,
entry_time=position.entry_time,
exit_time=exit_time,
pnl=pnl - fees,
fees=fees,
confidence=confidence,
hold_time_seconds=hold_time_seconds
)
self.trade_history.append(trade_record)
self.daily_loss += max(0, -(pnl - fees)) # Add to daily loss if negative
# Update consecutive losses
if pnl < -0.001: # A losing trade
self.consecutive_losses += 1
elif pnl > 0.001: # A winning trade
self.consecutive_losses = 0
else: # Breakeven trade
self.consecutive_losses = 0
# Remove position
del self.positions[symbol]
self.last_trade_time[symbol] = datetime.now()
self.daily_trades += 1
logger.info(f"SHORT close order executed: {order}")
logger.info(f"SHORT position closed - P&L: ${pnl - fees:.2f}")
return True
else:
logger.error("Failed to place SHORT close order")
return False
except Exception as e:
logger.error(f"Error closing SHORT position: {e}")
return False
def _close_long_position(self, symbol: str, confidence: float, current_price: float) -> bool:
"""Close a long position by selling"""
if symbol not in self.positions:
logger.warning(f"No position to close in {symbol}")
return False
position = self.positions[symbol]
if position.side != 'LONG':
logger.warning(f"Position in {symbol} is not LONG, cannot close with SELL")
return False
logger.info(f"Closing LONG position: {position.quantity:.6f} {symbol} at ${current_price:.2f} "
f"(confidence: {confidence:.2f})")
if self.simulation_mode:
logger.info(f"SIMULATION MODE ({self.trading_mode.upper()}) - Long close logged but not executed")
# Calculate simulated fees in simulation mode
taker_fee_rate = self.mexc_config.get('trading_fees', {}).get('taker_fee', 0.0006)
simulated_fees = position.quantity * current_price * taker_fee_rate
# Calculate P&L for long position and hold time
pnl = position.calculate_pnl(current_price)
exit_time = datetime.now()
hold_time_seconds = (exit_time - position.entry_time).total_seconds()
# Create trade record
trade_record = TradeRecord(
symbol=symbol,
side='LONG',
quantity=position.quantity,
entry_price=position.entry_price,
exit_price=current_price,
entry_time=position.entry_time,
exit_time=exit_time,
pnl=pnl,
fees=simulated_fees,
confidence=confidence,
hold_time_seconds=hold_time_seconds
)
self.trade_history.append(trade_record)
self.daily_loss += max(0, -pnl) # Add to daily loss if negative
# Update consecutive losses
if pnl < -0.001: # A losing trade
self.consecutive_losses += 1
elif pnl > 0.001: # A winning trade
self.consecutive_losses = 0
else: # Breakeven trade
self.consecutive_losses = 0
# Remove position
del self.positions[symbol]
self.last_trade_time[symbol] = datetime.now()
self.daily_trades += 1
logger.info(f"Position closed - P&L: ${pnl:.2f}")
return True
try:
# Get order type from config
order_type = self.exchange_config.get('order_type', 'market').lower()
# For limit orders, set price slightly below market for immediate execution
limit_price = None
if order_type == 'limit':
# Set sell price slightly below market to ensure immediate execution
limit_price = current_price * 0.999 # 0.1% below market
# Place sell order to close long
if order_type == 'market':
order = self.exchange.place_order(
symbol=symbol,
side='sell', # Sell to close long position
order_type=order_type,
quantity=position.quantity
)
else:
# For limit orders, price is required
assert limit_price is not None, "limit_price required for limit orders"
order = self.exchange.place_order(
symbol=symbol,
side='sell', # Sell to close long position
order_type=order_type,
quantity=position.quantity,
price=limit_price
)
if order:
# Calculate fees using real API data when available
fees = self._calculate_real_trading_fees(order, symbol, position.quantity, current_price)
# Calculate P&L, fees, and hold time
pnl = position.calculate_pnl(current_price)
exit_time = datetime.now()
hold_time_seconds = (exit_time - position.entry_time).total_seconds()
# Create trade record
trade_record = TradeRecord(
symbol=symbol,
side='LONG',
quantity=position.quantity,
entry_price=position.entry_price,
exit_price=current_price,
entry_time=position.entry_time,
exit_time=exit_time,
pnl=pnl - fees,
fees=fees,
confidence=confidence,
hold_time_seconds=hold_time_seconds
)
self.trade_history.append(trade_record)
self.daily_loss += max(0, -(pnl - fees)) # Add to daily loss if negative
# Update consecutive losses
if pnl < -0.001: # A losing trade
self.consecutive_losses += 1
elif pnl > 0.001: # A winning trade
self.consecutive_losses = 0
else: # Breakeven trade
self.consecutive_losses = 0
# Remove position
del self.positions[symbol]
self.last_trade_time[symbol] = datetime.now()
self.daily_trades += 1
logger.info(f"LONG close order executed: {order}")
logger.info(f"LONG position closed - P&L: ${pnl - fees:.2f}")
return True
else:
logger.error("Failed to place LONG close order")
return False
except Exception as e:
logger.error(f"Error closing LONG position: {e}")
return False
def _calculate_position_size(self, confidence: float, current_price: float) -> float:
"""Calculate position size - use 100% of account balance for short-term scalping"""
# Get account balance (simulation or real)
account_balance = self._get_account_balance_for_sizing()
# Use 100% of account balance since we're holding for seconds/minutes only
# Scale by confidence: 70-100% of balance based on confidence (0.7-1.0 range)
confidence_multiplier = max(0.7, min(1.0, confidence))
position_value = account_balance * confidence_multiplier
# Apply reduction based on consecutive losses (risk management)
reduction_factor = self.mexc_config.get('consecutive_loss_reduction_factor', 0.8)
adjusted_reduction_factor = reduction_factor ** self.consecutive_losses
position_value *= adjusted_reduction_factor
logger.debug(f"Position calculation: account=${account_balance:.2f}, "
f"confidence_mult={confidence_multiplier:.2f}, "
f"position=${position_value:.2f}, confidence={confidence:.2f}")
return position_value
def _get_account_balance_for_sizing(self) -> float:
"""Get account balance for position sizing calculations"""
if self.simulation_mode:
return self.mexc_config.get('simulation_account_usd', 100.0)
else:
# For live trading, get actual USDT/USDC balance
try:
balances = self.get_account_balance()
usdt_balance = balances.get('USDT', {}).get('total', 0)
usdc_balance = balances.get('USDC', {}).get('total', 0)
return max(usdt_balance, usdc_balance)
except Exception as e:
logger.warning(f"Failed to get live account balance: {e}, using simulation default")
return self.mexc_config.get('simulation_account_usd', 100.0)
def update_positions(self, symbol: str, current_price: float):
"""Update position P&L with current market price"""
if symbol in self.positions:
with self.lock:
self.positions[symbol].calculate_pnl(current_price)
def get_positions(self) -> Dict[str, Position]:
"""Get current positions"""
return self.positions.copy()
def get_trade_history(self) -> List[TradeRecord]:
"""Get trade history"""
return self.trade_history.copy()
def get_daily_stats(self) -> Dict[str, Any]:
"""Get daily trading statistics with enhanced fee analysis"""
total_pnl = sum(trade.pnl for trade in self.trade_history)
total_fees = sum(trade.fees for trade in self.trade_history)
gross_pnl = total_pnl + total_fees # P&L before fees
winning_trades = len([t for t in self.trade_history if t.pnl > 0.001]) # Avoid rounding issues
losing_trades = len([t for t in self.trade_history if t.pnl < -0.001]) # Avoid rounding issues
total_trades = len(self.trade_history)
breakeven_trades = total_trades - winning_trades - losing_trades
# Calculate average trade values
avg_trade_pnl = total_pnl / max(1, total_trades)
avg_trade_fee = total_fees / max(1, total_trades)
avg_winning_trade = sum(t.pnl for t in self.trade_history if t.pnl > 0.001) / max(1, winning_trades)
avg_losing_trade = sum(t.pnl for t in self.trade_history if t.pnl < -0.001) / max(1, losing_trades)
# Enhanced fee analysis from config
fee_structure = self.mexc_config.get('trading_fees', {})
maker_fee_rate = fee_structure.get('maker', 0.0000)
taker_fee_rate = fee_structure.get('taker', 0.0005)
default_fee_rate = fee_structure.get('default', 0.0005)
# Calculate fee efficiency
total_volume = sum(trade.quantity * trade.exit_price for trade in self.trade_history)
effective_fee_rate = (total_fees / max(0.01, total_volume)) if total_volume > 0 else 0
fee_impact_on_pnl = (total_fees / max(0.01, abs(gross_pnl))) * 100 if gross_pnl != 0 else 0
return {
'daily_trades': self.daily_trades,
'daily_loss': self.daily_loss,
'total_pnl': total_pnl,
'gross_pnl': gross_pnl,
'total_fees': total_fees,
'winning_trades': winning_trades,
'losing_trades': losing_trades,
'breakeven_trades': breakeven_trades,
'total_trades': total_trades,
'win_rate': winning_trades / max(1, total_trades),
'avg_trade_pnl': avg_trade_pnl,
'avg_trade_fee': avg_trade_fee,
'avg_winning_trade': avg_winning_trade,
'avg_losing_trade': avg_losing_trade,
'positions_count': len(self.positions),
'fee_rates': {
'maker': f"{maker_fee_rate*100:.3f}%",
'taker': f"{taker_fee_rate*100:.3f}%",
'default': f"{default_fee_rate*100:.3f}%",
'effective': f"{effective_fee_rate*100:.3f}%" # Actual rate based on trades
},
'fee_analysis': {
'total_volume': total_volume,
'fee_impact_percent': fee_impact_on_pnl,
'is_fee_efficient': fee_impact_on_pnl < 5.0, # Less than 5% impact is good
'fee_savings_vs_market': (0.001 - effective_fee_rate) * total_volume if effective_fee_rate < 0.001 else 0
}
}
def emergency_stop(self):
"""Emergency stop all trading"""
logger.warning("EMERGENCY STOP ACTIVATED")
self.trading_enabled = False
# Close all positions if in live mode
if not self.dry_run:
for symbol, position in self.positions.items():
try:
ticker = self.exchange.get_ticker(symbol)
if ticker:
self._execute_sell(symbol, 1.0, ticker['last'])
except Exception as e:
logger.error(f"Error closing position {symbol} during emergency stop: {e}")
def reset_daily_stats(self):
"""Reset daily statistics (call at start of new day)"""
self.daily_trades = 0
self.daily_loss = 0.0
logger.info("Daily trading statistics reset")
def get_account_balance(self) -> Dict[str, Dict[str, float]]:
"""Get account balance information from the primary exchange (universal method).
Returns:
Dict with asset balances in format:
{
'USDT': {'free': 100.0, 'locked': 0.0, 'total': 100.0, 'type': 'spot'},
'ETH': {'free': 0.5, 'locked': 0.0, 'total': 0.5, 'type': 'spot'},
...
}
"""
try:
if not self.exchange:
logger.error("Exchange interface not available")
return {}
# Use the universal get_all_balances method that works with all exchanges
if hasattr(self.exchange, 'get_all_balances'):
raw_balances = self.exchange.get_all_balances()
if raw_balances:
# Convert to the expected format with 'type' field
combined_balances = {}
for asset, balance_data in raw_balances.items():
if isinstance(balance_data, dict):
combined_balances[asset] = {
'free': balance_data.get('free', 0.0),
'locked': balance_data.get('locked', 0.0),
'total': balance_data.get('total', 0.0),
'type': 'spot' # Default to spot for now
}
logger.info(f"Retrieved balances for {len(combined_balances)} assets from {self.primary_name}")
return combined_balances
else:
logger.warning(f"No balances returned from {self.primary_name} exchange")
return {}
else:
logger.error(f"Exchange {self.primary_name} does not support get_all_balances method")
return {}
except Exception as e:
logger.error(f"Error getting account balance: {e}")
return {}
def _calculate_real_trading_fees(self, order_result: Dict[str, Any], symbol: str,
quantity: float, price: float) -> float:
"""Calculate trading fees using real API data when available
Args:
order_result: Order result from exchange API
symbol: Trading symbol
quantity: Order quantity
price: Execution price
Returns:
float: Trading fee amount in quote currency
"""
try:
# Try to get actual fee from API response first
if order_result and 'fills' in order_result:
total_commission = 0.0
for fill in order_result['fills']:
commission = float(fill.get('commission', 0))
total_commission += commission
if total_commission > 0:
logger.info(f"Using real API fee: {total_commission}")
return total_commission
# Fall back to config-based calculation
return self._calculate_trading_fee(order_result, symbol, quantity, price)
except Exception as e:
logger.warning(f"Error calculating real fees: {e}, falling back to config-based")
return self._calculate_trading_fee(order_result, symbol, quantity, price)
def _calculate_trading_fee(self, order_result: Dict[str, Any], symbol: str,
quantity: float, price: float) -> float:
"""Calculate trading fee based on order execution details with enhanced MEXC API support
Args:
order_result: Order result from exchange API
symbol: Trading symbol
quantity: Order quantity
price: Execution price
Returns:
float: Trading fee amount in quote currency
"""
try:
# 1. Try to get actual fee from API response (most accurate)
# MEXC API can return fees in different formats depending on the endpoint
# Check for 'fills' array (most common for filled orders)
if order_result and 'fills' in order_result:
total_commission = 0.0
commission_asset = None
for fill in order_result['fills']:
commission = float(fill.get('commission', 0))
commission_asset = fill.get('commissionAsset', '')
total_commission += commission
if total_commission > 0:
logger.info(f"Using actual API fee from fills: {total_commission} {commission_asset}")
# If commission is in different asset, we might need conversion
# For now, assume it's in quote currency (USDC/USDT)
return total_commission
# 2. Check if order result has fee information directly
fee_fields = ['fee', 'commission', 'tradeFee', 'fees']
for field in fee_fields:
if order_result and field in order_result:
fee = float(order_result[field])
if fee > 0:
logger.info(f"Using API fee field '{field}': {fee}")
return fee
# 3. Check for executedQty and cummulativeQuoteQty for more accurate calculation
if order_result and 'executedQty' in order_result and 'cummulativeQuoteQty' in order_result:
executed_qty = float(order_result['executedQty'])
executed_value = float(order_result['cummulativeQuoteQty'])
if executed_qty > 0 and executed_value > 0:
# Use executed values instead of provided price/quantity
quantity = executed_qty
price = executed_value / executed_qty
logger.info(f"Using executed order data: {quantity} @ {price:.6f}")
# 4. Fall back to config-based fee calculation with enhanced logic
trading_fees = self.mexc_config.get('trading_fees', {})
# Determine if this was a maker or taker trade
order_type = order_result.get('type', 'MARKET') if order_result else 'MARKET'
order_status = order_result.get('status', 'UNKNOWN') if order_result else 'UNKNOWN'
time_in_force = order_result.get('timeInForce', 'GTC') if order_result else 'GTC'
# Enhanced maker/taker detection logic
if order_type.upper() == 'LIMIT':
# For limit orders, check execution speed and market conditions
if order_status == 'FILLED':
# If it's an IOC (Immediate or Cancel) order, it's likely a taker
if time_in_force == 'IOC' or time_in_force == 'FOK':
fee_rate = trading_fees.get('taker', 0.0005)
logger.info(f"Using taker fee rate for {time_in_force} limit order: {fee_rate*100:.3f}%")
else:
# For GTC orders, assume taker if aggressive pricing is used
# This is a heuristic based on our trading strategy
fee_rate = trading_fees.get('taker', 0.0005)
logger.info(f"Using taker fee rate for aggressive limit order: {fee_rate*100:.3f}%")
else:
# If not immediately filled, likely a maker (though we don't usually reach here)
fee_rate = trading_fees.get('maker', 0.0000)
logger.info(f"Using maker fee rate for pending/partial limit order: {fee_rate*100:.3f}%")
elif order_type.upper() == 'LIMIT_MAKER':
# LIMIT_MAKER orders are guaranteed to be makers
fee_rate = trading_fees.get('maker', 0.0000)
logger.info(f"Using maker fee rate for LIMIT_MAKER order: {fee_rate*100:.3f}%")
else:
# Market orders and other types are always takers
fee_rate = trading_fees.get('taker', 0.0005)
logger.info(f"Using taker fee rate for {order_type} order: {fee_rate*100:.3f}%")
# Calculate fee amount
trade_value = quantity * price
fee_amount = trade_value * fee_rate
logger.info(f"Calculated fee: ${fee_amount:.6f} ({fee_rate*100:.3f}% of ${trade_value:.2f})")
return fee_amount
except Exception as e:
logger.warning(f"Error calculating trading fee: {e}")
# Ultimate fallback using default rate
default_fee_rate = self.mexc_config.get('trading_fees', {}).get('default', 0.0005)
fallback_rate = self.mexc_config.get('trading_fee', default_fee_rate) # Legacy support
fee_amount = quantity * price * fallback_rate
logger.info(f"Using fallback fee: ${fee_amount:.6f} ({fallback_rate*100:.3f}%)")
return fee_amount
def get_fee_analysis(self) -> Dict[str, Any]:
"""Get detailed fee analysis and statistics
Returns:
Dict with fee breakdowns, rates, and impact analysis
"""
try:
fee_structure = self.mexc_config.get('trading_fees', {})
maker_rate = fee_structure.get('maker', 0.0000)
taker_rate = fee_structure.get('taker', 0.0005)
default_rate = fee_structure.get('default', 0.0005)
# Calculate total fees paid
total_fees = sum(trade.fees for trade in self.trade_history)
total_volume = sum(trade.quantity * trade.exit_price for trade in self.trade_history)
# Estimate fee breakdown (since we don't track maker vs taker separately)
# Assume most of our limit orders are takers due to our pricing strategy
estimated_taker_volume = total_volume * 0.9 # 90% taker assumption
estimated_maker_volume = total_volume * 0.1 # 10% maker assumption
estimated_taker_fees = estimated_taker_volume * taker_rate
estimated_maker_fees = estimated_maker_volume * maker_rate
# Fee impact analysis
total_pnl = sum(trade.pnl for trade in self.trade_history)
gross_pnl = total_pnl + total_fees
fee_impact_percent = (total_fees / max(1, abs(gross_pnl))) * 100 if gross_pnl != 0 else 0
return {
'fee_rates': {
'maker': {
'rate': maker_rate,
'rate_percent': f"{maker_rate*100:.3f}%"
},
'taker': {
'rate': taker_rate,
'rate_percent': f"{taker_rate*100:.3f}%"
},
'default': {
'rate': default_rate,
'rate_percent': f"{default_rate*100:.3f}%"
}
},
'total_fees': total_fees,
'total_volume': total_volume,
'estimated_breakdown': {
'taker_fees': estimated_taker_fees,
'maker_fees': estimated_maker_fees,
'taker_volume': estimated_taker_volume,
'maker_volume': estimated_maker_volume
},
'impact_analysis': {
'fee_impact_percent': fee_impact_percent,
'pnl_after_fees': total_pnl,
'pnl_before_fees': gross_pnl,
'avg_fee_per_trade': total_fees / max(1, len(self.trade_history))
},
'fee_efficiency': {
'volume_to_fee_ratio': total_volume / max(0.01, total_fees),
'is_efficient': fee_impact_percent < 5.0 # Less than 5% impact is good
}
}
except Exception as e:
logger.error(f"Error calculating fee analysis: {e}")
return {
'error': str(e),
'fee_rates': {
'maker': {'rate': 0.0000, 'rate_percent': '0.000%'},
'taker': {'rate': 0.0005, 'rate_percent': '0.050%'}
}
}
def sync_fees_with_api(self, force: bool = False) -> Dict[str, Any]:
"""Manually trigger fee synchronization with MEXC API
Args:
force: Force sync even if last sync was recent
Returns:
dict: Sync result with status and details
"""
if not self.config_synchronizer:
return {
'status': 'error',
'error': 'Config synchronizer not initialized'
}
try:
logger.info("TRADING EXECUTOR: Manual fee sync requested")
sync_result = self.config_synchronizer.sync_trading_fees(force=force)
# If fees were updated, reload config
if sync_result.get('changes_made'):
logger.info("TRADING EXECUTOR: Reloading config after fee sync")
self.config = get_config(self.config_synchronizer.config_path)
# Update to use primary exchange config
self.exchanges_config = self.config.get('exchanges', {})
self.primary_name = self.exchanges_config.get('primary', 'mexc')
self.primary_config = self.exchanges_config.get(self.primary_name, {})
self.mexc_config = self.primary_config # Legacy compatibility
return sync_result
except Exception as e:
logger.error(f"TRADING EXECUTOR: Error in manual fee sync: {e}")
return {
'status': 'error',
'error': str(e)
}
def auto_sync_fees_if_needed(self) -> bool:
"""Automatically sync fees if needed (called periodically)
Returns:
bool: True if sync was performed successfully
"""
if not self.config_synchronizer:
return False
try:
return self.config_synchronizer.auto_sync_fees()
except Exception as e:
logger.error(f"TRADING EXECUTOR: Error in auto fee sync: {e}")
return False
def get_fee_sync_status(self) -> Dict[str, Any]:
"""Get current fee synchronization status
Returns:
dict: Fee sync status and history
"""
if not self.config_synchronizer:
return {
'sync_available': False,
'error': 'Config synchronizer not initialized'
}
try:
status = self.config_synchronizer.get_sync_status()
status['sync_available'] = True
return status
except Exception as e:
logger.error(f"TRADING EXECUTOR: Error getting sync status: {e}")
return {
'sync_available': False,
'error': str(e)
}
def execute_trade(self, symbol: str, action: str, quantity: float) -> bool:
"""Execute a trade directly (compatibility method for dashboard)
Args:
symbol: Trading symbol (e.g., 'ETH/USDT')
action: Trading action ('BUY', 'SELL')
quantity: Quantity to trade
Returns:
bool: True if trade executed successfully
"""
try:
# Get current price
current_price = None
ticker = self.exchange.get_ticker(symbol)
if ticker:
current_price = ticker['last']
else:
logger.error(f"Failed to get current price for {symbol}")
return False
# Calculate confidence based on manual trade (high confidence)
confidence = 1.0
# Execute using the existing signal execution method
return self.execute_signal(symbol, action, confidence, current_price)
except Exception as e:
logger.error(f"Error executing trade {action} for {symbol}: {e}")
return False
def get_closed_trades(self) -> List[Dict[str, Any]]:
"""Get closed trades in dashboard format"""
try:
trades = []
for trade in self.trade_history:
trade_dict = {
'symbol': trade.symbol,
'side': trade.side,
'quantity': trade.quantity,
'entry_price': trade.entry_price,
'exit_price': trade.exit_price,
'entry_time': trade.entry_time,
'exit_time': trade.exit_time,
'pnl': trade.pnl,
'fees': trade.fees,
'confidence': trade.confidence,
'hold_time_seconds': trade.hold_time_seconds
}
trades.append(trade_dict)
return trades
except Exception as e:
logger.error(f"Error getting closed trades: {e}")
return []
def get_current_position(self, symbol: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Get current position for a symbol or all positions
Args:
symbol: Optional symbol to get position for. If None, returns first position.
Returns:
dict: Position information or None if no position
"""
try:
if symbol:
if symbol in self.positions:
pos = self.positions[symbol]
return {
'symbol': pos.symbol,
'side': pos.side,
'size': pos.quantity,
'price': pos.entry_price,
'entry_time': pos.entry_time,
'unrealized_pnl': pos.unrealized_pnl
}
return None
else:
# Return first position if no symbol specified
if self.positions:
first_symbol = list(self.positions.keys())[0]
return self.get_current_position(first_symbol)
return None
except Exception as e:
logger.error(f"Error getting current position: {e}")
return None
def get_leverage(self) -> float:
"""Get current leverage setting"""
return self.mexc_config.get('leverage', 50.0)
def set_leverage(self, leverage: float) -> bool:
"""Set leverage (for UI control)
Args:
leverage: New leverage value
Returns:
bool: True if successful
"""
try:
# Update in-memory config
self.mexc_config['leverage'] = leverage
logger.info(f"TRADING EXECUTOR: Leverage updated to {leverage}x")
return True
except Exception as e:
logger.error(f"Error setting leverage: {e}")
return False
def get_account_info(self) -> Dict[str, Any]:
"""Get account information for UI display"""
try:
account_balance = self._get_account_balance_for_sizing()
leverage = self.get_leverage()
return {
'account_balance': account_balance,
'leverage': leverage,
'trading_mode': self.trading_mode,
'simulation_mode': self.simulation_mode,
'trading_enabled': self.trading_enabled,
'position_sizing': {
'base_percent': self.mexc_config.get('base_position_percent', 5.0),
'max_percent': self.mexc_config.get('max_position_percent', 20.0),
'min_percent': self.mexc_config.get('min_position_percent', 2.0)
}
}
except Exception as e:
logger.error(f"Error getting account info: {e}")
return {
'account_balance': 100.0,
'leverage': 50.0,
'trading_mode': 'simulation',
'simulation_mode': True,
'trading_enabled': False,
'position_sizing': {
'base_percent': 5.0,
'max_percent': 20.0,
'min_percent': 2.0
}
}
def set_test_mode(self, enabled: bool = True):
"""Enable test mode to bypass safety checks for testing"""
self._test_mode = enabled
if enabled:
logger.info("TRADING EXECUTOR: Test mode enabled - bypassing safety checks")
else:
logger.info("TRADING EXECUTOR: Test mode disabled - normal safety checks active")
def sync_position_with_mexc(self, symbol: str, desired_state: str) -> bool:
"""Synchronize dashboard position state with actual MEXC account positions
Args:
symbol: Trading symbol (e.g., 'ETH/USDT')
desired_state: Desired position state ('NO_POSITION', 'LONG', 'SHORT')
Returns:
bool: True if synchronization successful
"""
try:
logger.info(f"POSITION SYNC: Starting sync for {symbol} - desired state: {desired_state}")
if self.simulation_mode:
logger.info("POSITION SYNC: Simulation mode - skipping MEXC account sync")
return True
# Step 1: Cancel all pending orders for the symbol
cancelled_orders = self._cancel_open_orders(symbol)
if cancelled_orders > 0:
logger.info(f"POSITION SYNC: Cancelled {cancelled_orders} pending orders for {symbol}")
time.sleep(1) # Wait for cancellations to process
# Step 2: Get current MEXC account balances and positions
current_balances = self._get_mexc_account_balances()
current_holdings = self._get_current_holdings(symbol, current_balances)
# Step 3: Determine current position state from MEXC account
current_state = self._determine_position_state(symbol, current_holdings)
logger.info(f"POSITION SYNC: Current MEXC state: {current_state}, Holdings: {current_holdings}")
# Step 4: If states match, no action needed
if current_state == desired_state:
logger.info(f"POSITION SYNC: States already match ({current_state}) - no action needed")
return True
# Step 5: Execute corrective trades based on state mismatch
return self._execute_corrective_trades(symbol, current_state, desired_state, current_holdings)
except Exception as e:
logger.error(f"POSITION SYNC ERROR: Failed to sync {symbol}: {e}")
import traceback
logger.error(f"POSITION SYNC: Full traceback: {traceback.format_exc()}")
return False
def _get_mexc_account_balances(self) -> Dict[str, Dict[str, float]]:
"""Get current MEXC account balances"""
try:
return self.exchange.get_all_balances()
except Exception as e:
logger.error(f"Failed to get MEXC account balances: {e}")
return {}
def _get_current_holdings(self, symbol: str, balances: Dict[str, Dict[str, float]]) -> Dict[str, Any]:
"""Extract current holdings for the symbol from account balances"""
try:
# Parse symbol to get base and quote assets
if '/' in symbol:
base_asset, quote_asset = symbol.split('/')
else:
# Handle symbols like ETHUSDT
if symbol.upper().endswith('USDT'):
base_asset = symbol[:-4]
quote_asset = 'USDT'
elif symbol.upper().endswith('USDC'):
base_asset = symbol[:-4]
quote_asset = 'USDC'
else:
logger.error(f"Cannot parse symbol: {symbol}")
return {'base': 0.0, 'quote': 0.0, 'base_asset': 'UNKNOWN', 'quote_asset': 'UNKNOWN'}
base_asset = base_asset.upper()
quote_asset = quote_asset.upper()
# Get balances for base and quote assets
base_balance = balances.get(base_asset, {}).get('total', 0.0)
quote_balance = balances.get(quote_asset, {}).get('total', 0.0)
# Also check USDC if quote is USDT (MEXC uses USDC for trading)
if quote_asset == 'USDT':
usdc_balance = balances.get('USDC', {}).get('total', 0.0)
quote_balance = max(quote_balance, usdc_balance)
return {
'base': base_balance,
'quote': quote_balance,
'base_asset': base_asset, # Note: This contains string values but method returns Dict[str, float]
'quote_asset': quote_asset # We'll handle this in the calling method
}
except Exception as e:
logger.error(f"Error getting current holdings for {symbol}: {e}")
return {'base': 0.0, 'quote': 0.0, 'base_asset': 'UNKNOWN', 'quote_asset': 'UNKNOWN'}
def _determine_position_state(self, symbol: str, holdings: Dict[str, Any]) -> str:
"""Determine position state from current holdings"""
try:
base_balance = holdings.get('base', 0.0)
quote_balance = holdings.get('quote', 0.0)
# Minimum balance thresholds (to ignore dust)
min_base_threshold = 0.001 # 0.001 ETH minimum
min_quote_threshold = 1.0 # $1 minimum
has_base = base_balance >= min_base_threshold
has_quote = quote_balance >= min_quote_threshold
if has_base and not has_quote:
return 'LONG' # Holding crypto asset
elif not has_base and has_quote:
return 'SHORT' # Holding only fiat (after selling crypto)
elif has_base and has_quote:
# Mixed holdings - determine which is larger
try:
current_price = self._get_current_price_for_sync(symbol)
if current_price:
base_value = base_balance * current_price
if base_value > quote_balance * 1.5: # 50% threshold
return 'LONG'
else:
return 'SHORT'
except:
return 'LONG' # Default to LONG if price unavailable
else:
return 'NO_POSITION' # No significant holdings
except Exception as e:
logger.error(f"Error determining position state: {e}")
return 'NO_POSITION'
def _get_current_price_for_sync(self, symbol: str) -> Optional[float]:
"""Get current price for position synchronization"""
try:
ticker = self.exchange.get_ticker(symbol)
if ticker and 'last' in ticker:
return float(ticker['last'])
return None
except Exception as e:
logger.error(f"Error getting current price for sync: {e}")
return None
def _execute_corrective_trades(self, symbol: str, current_state: str, desired_state: str, holdings: Dict[str, float]) -> bool:
"""Execute trades to correct position state mismatch"""
try:
logger.info(f"CORRECTIVE TRADE: {current_state} -> {desired_state} for {symbol}")
current_price = self._get_current_price_for_sync(symbol)
if not current_price:
logger.error("Cannot execute corrective trades without current price")
return False
base_balance = holdings.get('base', 0.0)
quote_balance = holdings.get('quote', 0.0)
base_asset = holdings.get('base_asset', 'ETH')
if desired_state == 'NO_POSITION':
# Need to sell all crypto holdings
if base_balance > 0.001: # Minimum to avoid dust
logger.info(f"CORRECTIVE: Selling {base_balance:.6f} {base_asset} to reach NO_POSITION")
result = self._place_order_with_retry(symbol, 'SELL', 'LIMIT', base_balance, current_price * 0.999)
if result:
# Wait for order fill and update internal position tracking
time.sleep(2)
if symbol in self.positions:
del self.positions[symbol]
logger.info(f"CORRECTIVE: Successfully sold holdings for NO_POSITION")
return True
else:
logger.error("CORRECTIVE: Failed to sell holdings")
return False
else:
logger.info("CORRECTIVE: Already at NO_POSITION (no crypto holdings)")
return True
elif desired_state == 'LONG':
# Need to buy crypto with available quote currency
if quote_balance < 10.0: # Minimum order value
logger.warning(f"CORRECTIVE: Insufficient quote balance ({quote_balance:.2f}) for LONG position")
return False
# Use 95% of quote balance for the trade (leaving some for fees)
trade_amount = quote_balance * 0.95
quantity = trade_amount / current_price
logger.info(f"CORRECTIVE: Buying {quantity:.6f} {base_asset} with ${trade_amount:.2f} for LONG position")
result = self._place_order_with_retry(symbol, 'BUY', 'LIMIT', quantity, current_price * 1.001)
if result:
# Update internal position tracking
time.sleep(2)
self.positions[symbol] = Position(
symbol=symbol,
side='LONG',
quantity=quantity,
entry_price=current_price,
entry_time=datetime.now(),
order_id=result.get('orderId', f"corrective_{int(time.time())}")
)
logger.info(f"CORRECTIVE: Successfully established LONG position")
return True
else:
logger.error("CORRECTIVE: Failed to buy for LONG position")
return False
elif desired_state == 'SHORT':
# Need to sell crypto holdings to get to cash-only position
if base_balance > 0.001:
logger.info(f"CORRECTIVE: Selling {base_balance:.6f} {base_asset} for SHORT position")
result = self._place_order_with_retry(symbol, 'SELL', 'LIMIT', base_balance, current_price * 0.999)
if result:
# Update internal position tracking for SHORT
time.sleep(2)
# For spot trading, SHORT means we sold our crypto and are holding fiat
# This is effectively being "short" the crypto asset
self.positions[symbol] = Position(
symbol=symbol,
side='SHORT',
quantity=base_balance, # Track the amount we sold
entry_price=current_price,
entry_time=datetime.now(),
order_id=result.get('orderId', f"corrective_{int(time.time())}")
)
logger.info(f"CORRECTIVE: Successfully established SHORT position")
return True
else:
logger.error("CORRECTIVE: Failed to sell for SHORT position")
return False
else:
logger.info("CORRECTIVE: Already in SHORT position (holding fiat only)")
return True
else:
logger.error(f"CORRECTIVE: Unknown desired state: {desired_state}")
return False
except Exception as e:
logger.error(f"Error executing corrective trades: {e}")
import traceback
logger.error(f"CORRECTIVE: Full traceback: {traceback.format_exc()}")
return False