#!/usr/bin/env python3 """ Trading Engine - Handles order execution, position tracking, and trading logic. """ import os import json import logging from typing import Dict, Any, Optional, Tuple, List from datetime import datetime import uuid # For generating unique bot_order_ref_ids from src.config.config import Config from src.clients.hyperliquid_client import HyperliquidClient from src.trading.trading_stats import TradingStats from src.utils.price_formatter import set_global_trading_engine logger = logging.getLogger(__name__) class TradingEngine: """Handles all trading operations, order execution, and position tracking.""" def __init__(self): """Initialize the trading engine.""" self.client = HyperliquidClient() self.stats = None self.market_monitor = None # Will be set by the main bot # State persistence (Removed - state is now in DB) # self.state_file = "data/trading_engine_state.json" # Position and order tracking (All main state moved to DB via TradingStats) # Initialize stats (this will connect to/create the DB) self._initialize_stats() # Initialize price formatter with this trading engine set_global_trading_engine(self) def _initialize_stats(self): """Initialize trading statistics.""" try: self.stats = TradingStats() # Set initial balance balance = self.client.get_balance() if balance and balance.get('total'): usdc_balance = float(balance['total'].get('USDC', 0)) self.stats.set_initial_balance(usdc_balance) except Exception as e: logger.error(f"Could not initialize trading stats: {e}") def set_market_monitor(self, market_monitor): """Set the market monitor reference for accessing cached data.""" self.market_monitor = market_monitor def get_balance(self) -> Optional[Dict[str, Any]]: """Get account balance (uses cached data when available).""" # Try cached data first (updated every heartbeat) if self.market_monitor and hasattr(self.market_monitor, 'get_cached_balance'): cached_balance = self.market_monitor.get_cached_balance() cache_age = self.market_monitor.get_cache_age_seconds() # Use cached data if it's fresh (less than 30 seconds old) if cached_balance and cache_age < 30: logger.debug(f"Using cached balance (age: {cache_age:.1f}s)") return cached_balance # Fallback to fresh API call logger.debug("Using fresh balance API call") return self.client.get_balance() def get_positions(self) -> Optional[List[Dict[str, Any]]]: """Get all positions (uses cached data when available).""" # Try cached data first (updated every heartbeat) if self.market_monitor and hasattr(self.market_monitor, 'get_cached_positions'): cached_positions = self.market_monitor.get_cached_positions() cache_age = self.market_monitor.get_cache_age_seconds() # Use cached data if it's fresh (less than 30 seconds old) if cached_positions is not None and cache_age < 30: logger.debug(f"Using cached positions (age: {cache_age:.1f}s): {len(cached_positions)} positions") return cached_positions # Fallback to fresh API call logger.debug("Using fresh positions API call") return self.client.get_positions() def get_orders(self) -> Optional[List[Dict[str, Any]]]: """Get all open orders (uses cached data when available).""" # Try cached data first (updated every heartbeat) if self.market_monitor and hasattr(self.market_monitor, 'get_cached_orders'): cached_orders = self.market_monitor.get_cached_orders() cache_age = self.market_monitor.get_cache_age_seconds() # Use cached data if it's fresh (less than 30 seconds old) if cached_orders is not None and cache_age < 30: logger.debug(f"Using cached orders (age: {cache_age:.1f}s): {len(cached_orders)} orders") return cached_orders # Fallback to fresh API call logger.debug("Using fresh orders API call") return self.client.get_open_orders() def get_recent_fills(self) -> Optional[List[Dict[str, Any]]]: """Get recent fills/trades.""" return self.client.get_recent_fills() def get_market_data(self, symbol: str) -> Optional[Dict[str, Any]]: """Get market data for a symbol.""" return self.client.get_market_data(symbol) def find_position(self, token: str) -> Optional[Dict[str, Any]]: """Find an open position for a token.""" symbol = f"{token}/USDC:USDC" # 🆕 PHASE 4: Check trades table for open positions (single source of truth) if self.stats: open_trade = self.stats.get_trade_by_symbol_and_status(symbol, status='position_opened') if open_trade: # Convert trades format to position format for compatibility entry_price = open_trade.get('entry_price') current_amount = open_trade.get('current_position_size', 0) position_side = open_trade.get('position_side') if entry_price and current_amount and abs(current_amount) > 0: return { 'symbol': symbol, 'contracts': abs(current_amount), 'notional': abs(current_amount), 'side': 'long' if position_side == 'long' else 'short', 'size': current_amount, # Can be negative for short 'entryPx': entry_price, 'unrealizedPnl': open_trade.get('unrealized_pnl', 0), 'marginUsed': abs(current_amount * entry_price), # Add lifecycle info for debugging '_lifecycle_id': open_trade.get('trade_lifecycle_id'), '_trade_id': open_trade.get('id'), '_source': 'trades_table_phase4' } # 🔄 Fallback: Check exchange position data try: positions = self.client.get_positions() if positions: for pos in positions: if pos.get('symbol') == symbol and pos.get('contracts', 0) != 0: logger.debug(f"Found exchange position for {token}: {pos}") return pos except Exception as e: logger.warning(f"Could not fetch exchange positions: {e}") return None def get_position_direction(self, position: Dict[str, Any]) -> Tuple[str, str, float]: """ Get position direction info from CCXT position data. Returns: (position_type, exit_side, contracts_abs) """ contracts = float(position.get('contracts', 0)) side_field = position.get('side', '').lower() if side_field == 'long': return "LONG", "sell", abs(contracts) elif side_field == 'short': return "SHORT", "buy", abs(contracts) else: # Fallback to contracts sign (less reliable) if contracts > 0: return "LONG", "sell", abs(contracts) else: return "SHORT", "buy", abs(contracts) async def execute_long_order(self, token: str, usdc_amount: float, limit_price_arg: Optional[float] = None, stop_loss_price: Optional[float] = None) -> Dict[str, Any]: symbol = f"{token}/USDC:USDC" try: # Validate inputs if usdc_amount <= 0: return {"success": False, "error": "Invalid USDC amount"} # Get market data for price validation market_data = self.get_market_data(symbol) if not market_data or not market_data.get('ticker'): return {"success": False, "error": f"Could not fetch market data for {token}"} current_price = float(market_data['ticker'].get('last', 0.0) or 0.0) if current_price <= 0: # Allow trading if current_price is 0 but a valid limit_price_arg is provided if not (limit_price_arg and limit_price_arg > 0): return {"success": False, "error": f"Invalid current price ({current_price}) for {token} and no valid limit price provided."} order_type_for_stats = 'limit' if limit_price_arg is not None else 'market' order_placement_price: float token_amount: float # Determine price and amount for order placement if limit_price_arg is not None: if limit_price_arg <= 0: return {"success": False, "error": "Limit price must be positive."} order_placement_price = limit_price_arg token_amount = usdc_amount / order_placement_price else: # Market order if current_price <= 0: return {"success": False, "error": f"Cannot place market order for {token} due to invalid current price: {current_price}"} order_placement_price = current_price token_amount = usdc_amount / order_placement_price # 1. Generate bot_order_ref_id and record order placement intent bot_order_ref_id = uuid.uuid4().hex order_db_id = self.stats.record_order_placed( symbol=symbol, side='buy', order_type=order_type_for_stats, amount_requested=token_amount, price=order_placement_price if order_type_for_stats == 'limit' else None, bot_order_ref_id=bot_order_ref_id, status='pending_submission' ) if not order_db_id: logger.error(f"Failed to record order intent in DB for {symbol} with bot_ref_id {bot_order_ref_id}") return {"success": False, "error": "Failed to record order intent in database."} # 2. Place the order with the exchange if order_type_for_stats == 'limit': logger.info(f"Placing LIMIT BUY order ({bot_order_ref_id}) for {token_amount:.6f} {symbol} at ${order_placement_price:,.2f}") exchange_order_data, error_msg = self.client.place_limit_order(symbol, 'buy', token_amount, order_placement_price) else: # Market order logger.info(f"Placing MARKET BUY order ({bot_order_ref_id}) for {token_amount:.6f} {symbol} (approx. price ${order_placement_price:,.2f})") exchange_order_data, error_msg = self.client.place_market_order(symbol, 'buy', token_amount) if error_msg: logger.error(f"Order placement failed for {symbol} ({bot_order_ref_id}): {error_msg}") self.stats.update_order_status(order_db_id=order_db_id, new_status='failed_submission', bot_order_ref_id=bot_order_ref_id) return {"success": False, "error": f"Order placement failed: {error_msg}"} if not exchange_order_data: logger.error(f"Order placement call failed for {symbol} ({bot_order_ref_id}). Client returned no data and no error.") self.stats.update_order_status(order_db_id=order_db_id, new_status='failed_submission_no_data', bot_order_ref_id=bot_order_ref_id) return {"success": False, "error": "Order placement failed at client level (no order object or error)."} exchange_oid = exchange_order_data.get('id') # 3. Update order in DB with exchange_order_id and status if exchange_oid: # If it's a market order that might have filled, client response might indicate status. # For Hyperliquid, a successful market order usually means it's filled or being filled. # Limit orders will be 'open'. # We will rely on MarketMonitor to confirm fills for market orders too via fill data. new_status_after_placement = 'open' # Default for limit, or submitted for market if order_type_for_stats == 'market': # Market orders might be considered 'submitted' until fill is confirmed by MarketMonitor # Or, if API indicates immediate fill, could be 'filled' - for now, let's use 'open' or 'submitted' new_status_after_placement = 'submitted' # More accurate for market until fill is seen self.stats.update_order_status( order_db_id=order_db_id, new_status=new_status_after_placement, set_exchange_order_id=exchange_oid ) else: logger.warning(f"No exchange_order_id received for order {order_db_id} ({bot_order_ref_id}). Status remains pending_submission or requires manual check.") # Potentially update status to 'submission_no_exch_id' # DO NOT record trade here. MarketMonitor will handle fills. # action_type = self.stats.record_trade_with_enhanced_tracking(...) if stop_loss_price and exchange_oid and exchange_oid != 'N/A': # Record the pending SL order in the orders table sl_bot_order_ref_id = uuid.uuid4().hex sl_order_db_id = self.stats.record_order_placed( symbol=symbol, side='sell', # SL for a long is a sell order_type='STOP_LIMIT_TRIGGER', # Indicates a conditional order that will become a limit order amount_requested=token_amount, price=stop_loss_price, # This is the trigger price, and also the limit price for the SL order bot_order_ref_id=sl_bot_order_ref_id, status='pending_trigger', parent_bot_order_ref_id=bot_order_ref_id # Link to the main buy order ) if sl_order_db_id: logger.info(f"Pending Stop Loss order recorded in DB: ID {sl_order_db_id}, BotRef {sl_bot_order_ref_id}, ParentBotRef {bot_order_ref_id}") else: logger.error(f"Failed to record pending SL order in DB for parent BotRef {bot_order_ref_id}") # 🆕 PHASE 4: Create trade lifecycle for this entry order if exchange_oid: entry_order_record = self.stats.get_order_by_exchange_id(exchange_oid) if entry_order_record: lifecycle_id = self.stats.create_trade_lifecycle( symbol=symbol, side='buy', entry_order_id=exchange_oid, # Store exchange order ID stop_loss_price=stop_loss_price, trade_type='bot' ) if lifecycle_id and stop_loss_price: # Get the stop loss order that was just created sl_order_record = self.stats.get_order_by_bot_ref_id(sl_bot_order_ref_id) if sl_order_record: self.stats.link_stop_loss_to_trade(lifecycle_id, exchange_oid, stop_loss_price) logger.info(f"📊 Created trade lifecycle {lifecycle_id} with linked stop loss for BUY {symbol}") else: logger.info(f"📊 Created trade lifecycle {lifecycle_id} for BUY {symbol}") elif lifecycle_id: logger.info(f"📊 Created trade lifecycle {lifecycle_id} for BUY {symbol}") return { "success": True, "order_placed_details": { "bot_order_ref_id": bot_order_ref_id, "exchange_order_id": exchange_oid, "order_db_id": order_db_id, "symbol": symbol, "side": "buy", "type": order_type_for_stats, "amount_requested": token_amount, "price_requested": order_placement_price if order_type_for_stats == 'limit' else None }, # "action_type": action_type, # Removed as trade is not recorded here "token_amount": token_amount, # "actual_price": final_price_for_stats, # Removed as fill is not processed here "stop_loss_pending": stop_loss_price is not None } except ZeroDivisionError as e: logger.error(f"Error executing long order due to ZeroDivisionError (likely price issue): {e}. LimitArg: {limit_price_arg}, CurrentPrice: {current_price if 'current_price' in locals() else 'N/A'}") return {"success": False, "error": f"Math error (division by zero), check prices: {e}"} except Exception as e: logger.error(f"Error executing long order: {e}", exc_info=True) return {"success": False, "error": str(e)} async def execute_short_order(self, token: str, usdc_amount: float, limit_price_arg: Optional[float] = None, stop_loss_price: Optional[float] = None) -> Dict[str, Any]: symbol = f"{token}/USDC:USDC" try: if usdc_amount <= 0: return {"success": False, "error": "Invalid USDC amount"} market_data = self.get_market_data(symbol) if not market_data or not market_data.get('ticker'): return {"success": False, "error": f"Could not fetch market data for {token}"} current_price = float(market_data['ticker'].get('last', 0.0) or 0.0) if current_price <= 0: if not (limit_price_arg and limit_price_arg > 0): return {"success": False, "error": f"Invalid current price ({current_price}) for {token} and no valid limit price provided."} order_type_for_stats = 'limit' if limit_price_arg is not None else 'market' order_placement_price: float token_amount: float if limit_price_arg is not None: if limit_price_arg <= 0: return {"success": False, "error": "Limit price must be positive."} order_placement_price = limit_price_arg token_amount = usdc_amount / order_placement_price else: # Market order if current_price <= 0: return {"success": False, "error": f"Cannot place market order for {token} due to invalid current price: {current_price}"} order_placement_price = current_price token_amount = usdc_amount / order_placement_price # 1. Generate bot_order_ref_id and record order placement intent bot_order_ref_id = uuid.uuid4().hex order_db_id = self.stats.record_order_placed( symbol=symbol, side='sell', order_type=order_type_for_stats, amount_requested=token_amount, price=order_placement_price if order_type_for_stats == 'limit' else None, bot_order_ref_id=bot_order_ref_id, status='pending_submission' ) if not order_db_id: logger.error(f"Failed to record order intent in DB for {symbol} with bot_ref_id {bot_order_ref_id}") return {"success": False, "error": "Failed to record order intent in database."} # 2. Place the order with the exchange if order_type_for_stats == 'limit': logger.info(f"Placing LIMIT SELL order ({bot_order_ref_id}) for {token_amount:.6f} {symbol} at ${order_placement_price:,.2f}") exchange_order_data, error_msg = self.client.place_limit_order(symbol, 'sell', token_amount, order_placement_price) else: # Market order logger.info(f"Placing MARKET SELL order ({bot_order_ref_id}) for {token_amount:.6f} {symbol} (approx. price ${order_placement_price:,.2f})") exchange_order_data, error_msg = self.client.place_market_order(symbol, 'sell', token_amount) if error_msg: logger.error(f"Order placement failed for {symbol} ({bot_order_ref_id}): {error_msg}") self.stats.update_order_status(order_db_id=order_db_id, new_status='failed_submission', bot_order_ref_id=bot_order_ref_id) return {"success": False, "error": f"Order placement failed: {error_msg}"} if not exchange_order_data: logger.error(f"Order placement call failed for {symbol} ({bot_order_ref_id}). Client returned no data and no error.") self.stats.update_order_status(order_db_id=order_db_id, new_status='failed_submission_no_data', bot_order_ref_id=bot_order_ref_id) return {"success": False, "error": "Order placement failed at client level (no order object or error)."} exchange_oid = exchange_order_data.get('id') # 3. Update order in DB with exchange_order_id and status if exchange_oid: new_status_after_placement = 'open' # Default for limit, or submitted for market if order_type_for_stats == 'market': new_status_after_placement = 'submitted' # Market orders are submitted, fills come via monitor self.stats.update_order_status( order_db_id=order_db_id, new_status=new_status_after_placement, set_exchange_order_id=exchange_oid ) else: logger.warning(f"No exchange_order_id received for order {order_db_id} ({bot_order_ref_id}).") # DO NOT record trade here. MarketMonitor will handle fills. # action_type = self.stats.record_trade_with_enhanced_tracking(...) if stop_loss_price and exchange_oid and exchange_oid != 'N/A': # Record the pending SL order in the orders table sl_bot_order_ref_id = uuid.uuid4().hex sl_order_db_id = self.stats.record_order_placed( symbol=symbol, side='buy', # SL for a short is a buy order_type='STOP_LIMIT_TRIGGER', amount_requested=token_amount, price=stop_loss_price, bot_order_ref_id=sl_bot_order_ref_id, status='pending_trigger', parent_bot_order_ref_id=bot_order_ref_id # Link to the main sell order ) if sl_order_db_id: logger.info(f"Pending Stop Loss order recorded in DB: ID {sl_order_db_id}, BotRef {sl_bot_order_ref_id}, ParentBotRef {bot_order_ref_id}") else: logger.error(f"Failed to record pending SL order in DB for parent BotRef {bot_order_ref_id}") # 🆕 PHASE 4: Create trade lifecycle for this entry order if exchange_oid: entry_order_record = self.stats.get_order_by_exchange_id(exchange_oid) if entry_order_record: lifecycle_id = self.stats.create_trade_lifecycle( symbol=symbol, side='sell', entry_order_id=exchange_oid, # Store exchange order ID stop_loss_price=stop_loss_price, trade_type='bot' ) if lifecycle_id and stop_loss_price: # Get the stop loss order that was just created sl_order_record = self.stats.get_order_by_bot_ref_id(sl_bot_order_ref_id) if sl_order_record: self.stats.link_stop_loss_to_trade(lifecycle_id, exchange_oid, stop_loss_price) logger.info(f"📊 Created trade lifecycle {lifecycle_id} with linked stop loss for SELL {symbol}") else: logger.info(f"📊 Created trade lifecycle {lifecycle_id} for SELL {symbol}") elif lifecycle_id: logger.info(f"📊 Created trade lifecycle {lifecycle_id} for SELL {symbol}") return { "success": True, "order_placed_details": { "bot_order_ref_id": bot_order_ref_id, "exchange_order_id": exchange_oid, "order_db_id": order_db_id, "symbol": symbol, "side": "sell", "type": order_type_for_stats, "amount_requested": token_amount, "price_requested": order_placement_price if order_type_for_stats == 'limit' else None }, "token_amount": token_amount, "stop_loss_pending": stop_loss_price is not None } except ZeroDivisionError as e: logger.error(f"Error executing short order due to ZeroDivisionError (likely price issue): {e}. LimitArg: {limit_price_arg}, CurrentPrice: {current_price if 'current_price' in locals() else 'N/A'}") return {"success": False, "error": f"Math error (division by zero), check prices: {e}"} except Exception as e: logger.error(f"Error executing short order: {e}", exc_info=True) return {"success": False, "error": str(e)} async def execute_exit_order(self, token: str) -> Dict[str, Any]: """Execute an exit order to close a position.""" position = self.find_position(token) if not position: return {"success": False, "error": f"No open position found for {token}"} try: symbol = f"{token}/USDC:USDC" position_type, exit_side, contracts_to_close = self.get_position_direction(position) order_type_for_stats = 'market' # Exit orders are typically market # 1. Generate bot_order_ref_id and record order placement intent bot_order_ref_id = uuid.uuid4().hex # Price for a market order is not specified at placement for stats recording, will be determined by fill. order_db_id = self.stats.record_order_placed( symbol=symbol, side=exit_side, order_type=order_type_for_stats, amount_requested=contracts_to_close, price=None, # Market order, price determined by fill bot_order_ref_id=bot_order_ref_id, status='pending_submission' ) if not order_db_id: logger.error(f"Failed to record exit order intent in DB for {symbol} with bot_ref_id {bot_order_ref_id}") return {"success": False, "error": "Failed to record exit order intent in database."} # 2. Execute market order to close position logger.info(f"Placing MARKET {exit_side.upper()} order ({bot_order_ref_id}) to close {contracts_to_close:.6f} {symbol}") exchange_order_data, error_msg = self.client.place_market_order(symbol, exit_side, contracts_to_close) if error_msg: logger.error(f"Exit order execution failed for {symbol} ({bot_order_ref_id}): {error_msg}") self.stats.update_order_status(order_db_id=order_db_id, new_status='failed_submission', bot_order_ref_id=bot_order_ref_id) return {"success": False, "error": f"Exit order execution failed: {error_msg}"} if not exchange_order_data: logger.error(f"Exit order execution call failed for {symbol} ({bot_order_ref_id}). Client returned no data and no error.") self.stats.update_order_status(order_db_id=order_db_id, new_status='failed_submission_no_data', bot_order_ref_id=bot_order_ref_id) return {"success": False, "error": "Exit order execution failed (no order object or error)."} exchange_oid = exchange_order_data.get('id') # 3. Update order in DB with exchange_order_id and status if exchange_oid: # Market orders are submitted; MarketMonitor will confirm fills. self.stats.update_order_status( order_db_id=order_db_id, new_status='submitted', set_exchange_order_id=exchange_oid ) else: logger.warning(f"No exchange_order_id received for exit order {order_db_id} ({bot_order_ref_id}).") # DO NOT record trade here. MarketMonitor will handle fills. # The old code below is removed: # order_id = order_data.get('id', 'N/A') # actual_price = order_data.get('average', 0) # ... logic for actual_price fallback ... # if order_id != 'N/A': # self.bot_trade_ids.add(order_id) # action_type = self.stats.record_trade_with_enhanced_tracking(...) # Cancel any pending stop losses for this symbol since position will be closed if self.stats: cancelled_sl_count = self.stats.cancel_pending_stop_losses_by_symbol( symbol=symbol, new_status='cancelled_manual_exit' ) if cancelled_sl_count > 0: logger.info(f"🛑 Cancelled {cancelled_sl_count} pending stop losses for {symbol} due to manual exit order") # NOTE: Exit orders do not create new trade cycles - they close existing ones # The MarketMonitor will handle closing the trade cycle when the exit order fills return { "success": True, "order_placed_details": { "bot_order_ref_id": bot_order_ref_id, "exchange_order_id": exchange_oid, "order_db_id": order_db_id, "symbol": symbol, "side": exit_side, "type": order_type_for_stats, "amount_requested": contracts_to_close }, "position_type_closed": position_type, # Info about the position it intends to close "contracts_intended_to_close": contracts_to_close, "cancelled_stop_losses": cancelled_sl_count if self.stats else 0 } except Exception as e: logger.error(f"Error executing exit order: {e}") return {"success": False, "error": str(e)} async def execute_stop_loss_order(self, token: str, stop_price: float) -> Dict[str, Any]: """Execute a stop loss order.""" position = self.find_position(token) if not position: return {"success": False, "error": f"No open position found for {token}"} try: symbol = f"{token}/USDC:USDC" position_type, exit_side, contracts = self.get_position_direction(position) entry_price = float(position.get('entryPx', 0)) # Validate stop loss price based on position direction if position_type == "LONG" and stop_price >= entry_price: return {"success": False, "error": "Stop loss price should be below entry price for long positions"} elif position_type == "SHORT" and stop_price <= entry_price: return {"success": False, "error": "Stop loss price should be above entry price for short positions"} order_type_for_stats = 'limit' # Stop loss is a limit order at stop_price # 1. Generate bot_order_ref_id and record order placement intent bot_order_ref_id = uuid.uuid4().hex order_db_id = self.stats.record_order_placed( symbol=symbol, side=exit_side, order_type=order_type_for_stats, amount_requested=contracts, price=stop_price, bot_order_ref_id=bot_order_ref_id, status='pending_submission' ) if not order_db_id: logger.error(f"Failed to record SL order intent in DB for {symbol} with bot_ref_id {bot_order_ref_id}") return {"success": False, "error": "Failed to record SL order intent in database."} # 2. Place limit order at stop loss price logger.info(f"Placing STOP LOSS (LIMIT {exit_side.upper()}) order ({bot_order_ref_id}) for {contracts:.6f} {symbol} at ${stop_price:,.2f}") exchange_order_data, error_msg = self.client.place_limit_order(symbol, exit_side, contracts, stop_price) if error_msg: logger.error(f"Stop loss order placement failed for {symbol} ({bot_order_ref_id}): {error_msg}") self.stats.update_order_status(order_db_id=order_db_id, new_status='failed_submission', bot_order_ref_id=bot_order_ref_id) return {"success": False, "error": f"Stop loss order placement failed: {error_msg}"} if not exchange_order_data: logger.error(f"Stop loss order placement call failed for {symbol} ({bot_order_ref_id}). Client returned no data and no error.") self.stats.update_order_status(order_db_id=order_db_id, new_status='failed_submission_no_data', bot_order_ref_id=bot_order_ref_id) return {"success": False, "error": "Stop loss order placement failed (no order object or error)."} exchange_oid = exchange_order_data.get('id') # 3. Update order in DB with exchange_order_id and status if exchange_oid: self.stats.update_order_status( order_db_id=order_db_id, new_status='open', # SL/TP limit orders are 'open' until triggered/filled set_exchange_order_id=exchange_oid ) else: logger.warning(f"No exchange_order_id received for SL order {order_db_id} ({bot_order_ref_id}).") # NOTE: Stop loss orders are protective orders for existing positions # They do not create new trade cycles - they protect existing trade cycles return { "success": True, "order_placed_details": { "bot_order_ref_id": bot_order_ref_id, "exchange_order_id": exchange_oid, "order_db_id": order_db_id, "symbol": symbol, "side": exit_side, "type": order_type_for_stats, "amount_requested": contracts, "price_requested": stop_price }, "position_type_for_sl": position_type, # Info about the position it's protecting "contracts_for_sl": contracts, "stop_price_set": stop_price } except Exception as e: logger.error(f"Error executing stop loss order: {e}") return {"success": False, "error": str(e)} async def execute_take_profit_order(self, token: str, profit_price: float) -> Dict[str, Any]: """Execute a take profit order.""" position = self.find_position(token) if not position: return {"success": False, "error": f"No open position found for {token}"} try: symbol = f"{token}/USDC:USDC" position_type, exit_side, contracts = self.get_position_direction(position) entry_price = float(position.get('entryPx', 0)) # Validate take profit price based on position direction if position_type == "LONG" and profit_price <= entry_price: return {"success": False, "error": "Take profit price should be above entry price for long positions"} elif position_type == "SHORT" and profit_price >= entry_price: return {"success": False, "error": "Take profit price should be below entry price for short positions"} order_type_for_stats = 'limit' # Take profit is a limit order at profit_price # 1. Generate bot_order_ref_id and record order placement intent bot_order_ref_id = uuid.uuid4().hex order_db_id = self.stats.record_order_placed( symbol=symbol, side=exit_side, order_type=order_type_for_stats, amount_requested=contracts, price=profit_price, bot_order_ref_id=bot_order_ref_id, status='pending_submission' ) if not order_db_id: logger.error(f"Failed to record TP order intent in DB for {symbol} with bot_ref_id {bot_order_ref_id}") return {"success": False, "error": "Failed to record TP order intent in database."} # 2. Place limit order at take profit price logger.info(f"Placing TAKE PROFIT (LIMIT {exit_side.upper()}) order ({bot_order_ref_id}) for {contracts:.6f} {symbol} at ${profit_price:,.2f}") exchange_order_data, error_msg = self.client.place_limit_order(symbol, exit_side, contracts, profit_price) if error_msg: logger.error(f"Take profit order placement failed for {symbol} ({bot_order_ref_id}): {error_msg}") self.stats.update_order_status(order_db_id=order_db_id, new_status='failed_submission', bot_order_ref_id=bot_order_ref_id) return {"success": False, "error": f"Take profit order placement failed: {error_msg}"} if not exchange_order_data: logger.error(f"Take profit order placement call failed for {symbol} ({bot_order_ref_id}). Client returned no data and no error.") self.stats.update_order_status(order_db_id=order_db_id, new_status='failed_submission_no_data', bot_order_ref_id=bot_order_ref_id) return {"success": False, "error": "Take profit order placement failed (no order object or error)."} exchange_oid = exchange_order_data.get('id') # 3. Update order in DB with exchange_order_id and status if exchange_oid: self.stats.update_order_status( order_db_id=order_db_id, new_status='open', # SL/TP limit orders are 'open' until triggered/filled set_exchange_order_id=exchange_oid ) else: logger.warning(f"No exchange_order_id received for TP order {order_db_id} ({bot_order_ref_id}).") # NOTE: Take profit orders are protective orders for existing positions # They do not create new trade cycles - they protect existing trade cycles return { "success": True, "order_placed_details": { "bot_order_ref_id": bot_order_ref_id, "exchange_order_id": exchange_oid, "order_db_id": order_db_id, "symbol": symbol, "side": exit_side, "type": order_type_for_stats, "amount_requested": contracts, "price_requested": profit_price }, "position_type_for_tp": position_type, # Info about the position it's for "contracts_for_tp": contracts, "profit_price_set": profit_price } except Exception as e: logger.error(f"Error executing take profit order: {e}") return {"success": False, "error": str(e)} def cancel_all_orders(self, symbol: str) -> Tuple[List[Dict[str, Any]], Optional[str]]: """Cancel all open orders for a specific symbol. Returns (cancelled_orders, error_message).""" try: logger.info(f"Attempting to cancel all orders for {symbol}") # Get all open orders all_orders = self.client.get_open_orders() if all_orders is None: error_msg = f"Could not fetch orders to cancel {symbol} orders" logger.error(error_msg) return [], error_msg # Filter orders for the specific symbol symbol_orders = [order for order in all_orders if order.get('symbol') == symbol] if not symbol_orders: logger.info(f"No open orders found for {symbol}") return [], None # No error, just no orders to cancel # Cancel each order individually cancelled_orders = [] failed_orders = [] for order in symbol_orders: order_id = order.get('id') if order_id: try: success = self.client.cancel_order(order_id, symbol) if success: cancelled_orders.append(order) logger.info(f"Successfully cancelled order {order_id} for {symbol}") else: failed_orders.append(order) logger.warning(f"Failed to cancel order {order_id} for {symbol}") except Exception as e: logger.error(f"Exception cancelling order {order_id}: {e}") failed_orders.append(order) # Update order status in database if we have stats if self.stats: for order in cancelled_orders: order_id = order.get('id') if order_id: # Try to find this order in our database and update its status db_order = self.stats.get_order_by_exchange_id(order_id) if db_order: self.stats.update_order_status( exchange_order_id=order_id, new_status='cancelled_manually' ) # Cancel any linked pending stop losses for this symbol cleanup_count = self.stats.cancel_pending_stop_losses_by_symbol( symbol, 'cancelled_manual_exit' ) if cleanup_count > 0: logger.info(f"Cleaned up {cleanup_count} pending stop losses for {symbol}") # Prepare result if failed_orders: error_msg = f"Cancelled {len(cancelled_orders)}/{len(symbol_orders)} orders. {len(failed_orders)} failed." logger.warning(error_msg) return cancelled_orders, error_msg else: logger.info(f"Successfully cancelled all {len(cancelled_orders)} orders for {symbol}") return cancelled_orders, None except Exception as e: error_msg = f"Error cancelling orders for {symbol}: {str(e)}" logger.error(error_msg, exc_info=True) return [], error_msg # Alias methods for consistency with command handlers async def execute_sl_order(self, token: str, stop_price: float) -> Dict[str, Any]: """Alias for execute_stop_loss_order.""" return await self.execute_stop_loss_order(token, stop_price) async def execute_tp_order(self, token: str, profit_price: float) -> Dict[str, Any]: """Alias for execute_take_profit_order.""" return await self.execute_take_profit_order(token, profit_price) async def execute_coo_order(self, token: str) -> Dict[str, Any]: """Cancel all orders for a token and format response like the old code expected.""" try: symbol = f"{token}/USDC:USDC" # Call the synchronous cancel_all_orders method cancelled_orders, error_msg = self.cancel_all_orders(symbol) if error_msg: logger.error(f"Error cancelling all orders for {token}: {error_msg}") return {"success": False, "error": error_msg} if not cancelled_orders: logger.info(f"No orders found to cancel for {token}") return { "success": True, "message": f"No orders found for {token}", "cancelled_orders": [], "cancelled_count": 0, "failed_count": 0, "cancelled_linked_stop_losses": 0 } # Get cleanup count from stats if available cleanup_count = 0 if self.stats: cleanup_count = self.stats.cancel_pending_stop_losses_by_symbol( symbol, 'cancelled_manual_exit' ) logger.info(f"Successfully cancelled {len(cancelled_orders)} orders for {token}") return { "success": True, "message": f"Successfully cancelled {len(cancelled_orders)} orders for {token}", "cancelled_orders": cancelled_orders, "cancelled_count": len(cancelled_orders), "failed_count": 0, # Failed orders are handled in the error case above "cancelled_linked_stop_losses": cleanup_count } except Exception as e: logger.error(f"Error in execute_coo_order for {token}: {e}") return {"success": False, "error": str(e)} def is_bot_trade(self, exchange_order_id: str) -> bool: """Check if an order (by its exchange ID) was recorded by this bot in the orders table.""" if not self.stats: return False order_data = self.stats.get_order_by_exchange_id(exchange_order_id) return order_data is not None # If found, it was a bot-managed order def get_stats(self) -> TradingStats: """Get trading statistics object.""" return self.stats async def execute_triggered_stop_order(self, original_trigger_order_db_id: int) -> Dict[str, Any]: """Executes an actual stop order on the exchange after its trigger condition was met.""" if not self.stats: return {"success": False, "error": "TradingStats not available."} trigger_order_details = self.stats.get_order_by_db_id(original_trigger_order_db_id) if not trigger_order_details: return {"success": False, "error": f"Original trigger order DB ID {original_trigger_order_db_id} not found."} logger.info(f"Executing triggered stop order based on original trigger DB ID: {original_trigger_order_db_id}, details: {trigger_order_details}") symbol = trigger_order_details.get('symbol') # Side of the actual SL order to be placed (e.g., if trigger was 'sell', actual order is 'sell') sl_order_side = trigger_order_details.get('side') amount = trigger_order_details.get('amount_requested') stop_price = trigger_order_details.get('price') # This was the trigger price parent_bot_ref_id_of_trigger = trigger_order_details.get('bot_order_ref_id') # The ref ID of the trigger order itself if not all([symbol, sl_order_side, amount, stop_price]): msg = f"Missing critical details from trigger order DB ID {original_trigger_order_db_id} to place actual SL order." logger.error(msg) return {"success": False, "error": msg} # 🧠 SMART STOP LOSS LOGIC: Check if price has moved beyond stop loss # Get current market price to determine order type current_price = None try: market_data = self.get_market_data(symbol) if market_data and market_data.get('ticker'): current_price = float(market_data['ticker'].get('last', 0)) except Exception as price_error: logger.warning(f"Could not fetch current price for {symbol}: {price_error}") # Determine if we need market order (price moved beyond stop) or limit order (normal case) use_market_order = False order_type_for_actual_sl = 'limit' # Default to limit order_price = stop_price # Default to stop price if current_price and current_price > 0: if sl_order_side.lower() == 'buy': # SHORT position stop loss (BUY to close) # If current price > stop price, use market order (price moved beyond stop) if current_price > stop_price: use_market_order = True logger.warning(f"🚨 SHORT SL: Price ${current_price:.4f} > Stop ${stop_price:.4f} - Using MARKET order for immediate execution") else: logger.info(f"📊 SHORT SL: Price ${current_price:.4f} ≤ Stop ${stop_price:.4f} - Using LIMIT order at stop price") elif sl_order_side.lower() == 'sell': # LONG position stop loss (SELL to close) # If current price < stop price, use market order (price moved beyond stop) if current_price < stop_price: use_market_order = True logger.warning(f"🚨 LONG SL: Price ${current_price:.4f} < Stop ${stop_price:.4f} - Using MARKET order for immediate execution") else: logger.info(f"📊 LONG SL: Price ${current_price:.4f} ≥ Stop ${stop_price:.4f} - Using LIMIT order at stop price") if use_market_order: order_type_for_actual_sl = 'market' order_price = None # Market orders don't have a specific price # 1. Generate a new bot_order_ref_id for this actual SL order actual_sl_bot_order_ref_id = uuid.uuid4().hex # We can link this actual SL order back to the trigger order that spawned it. actual_sl_order_db_id = self.stats.record_order_placed( symbol=symbol, side=sl_order_side, order_type=order_type_for_actual_sl, amount_requested=amount, price=order_price, # None for market, stop_price for limit bot_order_ref_id=actual_sl_bot_order_ref_id, status='pending_submission', parent_bot_order_ref_id=parent_bot_ref_id_of_trigger # Linking actual SL to its trigger order ) if not actual_sl_order_db_id: msg = f"Failed to record actual SL order intent in DB (spawned from trigger {original_trigger_order_db_id}). BotRef: {actual_sl_bot_order_ref_id}" logger.error(msg) return {"success": False, "error": msg} # 2. Place the actual SL order on the exchange if use_market_order: logger.info(f"🚨 Placing ACTUAL SL ORDER (MARKET {sl_order_side.upper()}) from trigger {original_trigger_order_db_id}. New BotRef: {actual_sl_bot_order_ref_id}, Amount: {amount}, Trigger was: ${stop_price}") exchange_order_data, error_msg = self.client.place_market_order(symbol, sl_order_side, amount) else: logger.info(f"📊 Placing ACTUAL SL ORDER (LIMIT {sl_order_side.upper()}) from trigger {original_trigger_order_db_id}. New BotRef: {actual_sl_bot_order_ref_id}, Amount: {amount}, Price: ${stop_price}") exchange_order_data, error_msg = self.client.place_limit_order(symbol, sl_order_side, amount, stop_price) if error_msg: order_type_desc = "market" if use_market_order else "limit" logger.error(f"Actual SL {order_type_desc} order placement failed for {symbol} (BotRef {actual_sl_bot_order_ref_id}): {error_msg}") self.stats.update_order_status(order_db_id=actual_sl_order_db_id, new_status='failed_submission', bot_order_ref_id=actual_sl_bot_order_ref_id) return {"success": False, "error": f"Actual SL {order_type_desc} order placement failed: {error_msg}"} if not exchange_order_data: order_type_desc = "market" if use_market_order else "limit" logger.error(f"Actual SL {order_type_desc} order placement call failed for {symbol} (BotRef {actual_sl_bot_order_ref_id}). No data/error from client.") self.stats.update_order_status(order_db_id=actual_sl_order_db_id, new_status='failed_submission_no_data', bot_order_ref_id=actual_sl_bot_order_ref_id) return {"success": False, "error": f"Actual SL {order_type_desc} order placement failed at client level."} exchange_oid = exchange_order_data.get('id') # 3. Update the actual SL order in DB with exchange_order_id and appropriate status if exchange_oid: # Market orders are 'submitted' until filled, limit orders are 'open' until triggered/filled new_status = 'submitted' if use_market_order else 'open' self.stats.update_order_status( order_db_id=actual_sl_order_db_id, new_status=new_status, set_exchange_order_id=exchange_oid ) else: order_type_desc = "market" if use_market_order else "limit" logger.warning(f"No exchange_order_id received for actual SL {order_type_desc} order (BotRef {actual_sl_bot_order_ref_id}).") success_message = f"Actual Stop Loss {'MARKET' if use_market_order else 'LIMIT'} order placed successfully" if use_market_order: success_message += " for immediate execution (price moved beyond stop level)." else: success_message += f" at ${stop_price}." return { "success": True, "message": success_message, "placed_sl_order_details": { "bot_order_ref_id": actual_sl_bot_order_ref_id, "exchange_order_id": exchange_oid, "order_db_id": actual_sl_order_db_id, "symbol": symbol, "side": sl_order_side, "type": order_type_for_actual_sl, "amount_requested": amount, "price_requested": order_price, "original_trigger_price": stop_price, "current_market_price": current_price, "used_market_order": use_market_order } }