123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997 |
- #!/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
- }
- }
|