1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180 |
- #!/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, timezone
- 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.token_display_formatter import set_global_trading_engine, get_formatter
- from telegram.ext import CallbackContext
- 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)
-
- # 🆕 Method to get token precision info
- _markets_cache: Optional[List[Dict[str, Any]]] = None
- _markets_cache_timestamp: Optional[datetime] = None
- def get_token_info(self, base_asset: str) -> Dict[str, Any]:
- """Fetch (and cache) market data to find precision for a given base_asset."""
- # Cache markets for 1 hour to avoid frequent API calls
- if self._markets_cache is None or \
- (self._markets_cache_timestamp and
- (datetime.now(timezone.utc) - self._markets_cache_timestamp).total_seconds() > 3600):
- try:
- logger.info("Fetching and caching markets for token info...")
- markets_data = self.client.get_markets() # This returns a list of market dicts
- if markets_data:
- self._markets_cache = markets_data
- self._markets_cache_timestamp = datetime.now(timezone.utc)
- logger.info(f"Successfully cached {len(self._markets_cache)} markets.")
- else:
- logger.warning("get_markets() returned no data. Using empty cache.")
- self._markets_cache = {} # Set to empty dict to avoid re-fetching immediately
- self._markets_cache_timestamp = datetime.now(timezone.utc)
- except Exception as e:
- logger.error(f"Error fetching markets for token info: {e}. Will use defaults.")
- self._markets_cache = {} # Prevent re-fetching on immediate subsequent calls
- self._markets_cache_timestamp = datetime.now(timezone.utc)
-
- default_precision = {'amount': 6, 'price': 2} # Default if not found
- target_symbol_prefix = f"{base_asset.upper()}/"
- if self._markets_cache:
- # Assuming self._markets_cache is a Dict keyed by symbols,
- # and values are market detail dicts.
- for market_details_dict in self._markets_cache.values():
- symbol = market_details_dict.get('symbol')
- if symbol and symbol.upper().startswith(target_symbol_prefix):
- precision = market_details_dict.get('precision')
- if precision and isinstance(precision, dict) and \
- 'amount' in precision and 'price' in precision:
- logger.debug(f"Found precision for {base_asset}: {precision}")
- return {
- 'precision': precision,
- 'base_precision': precision.get('amount'), # For direct access
- 'quote_precision': precision.get('price') # For direct access
- }
- else:
- logger.warning(f"Market {symbol} found for {base_asset}, but precision data is missing or malformed: {precision if 'precision' in locals() else market_details_dict.get('precision')}")
- return { # Return default but log that market was found
- 'precision': default_precision,
- 'base_precision': default_precision['amount'],
- 'quote_precision': default_precision['price']
- }
- logger.warning(f"No market symbol starting with '{target_symbol_prefix}' found in cached markets for {base_asset}.")
- else:
- logger.warning("Markets cache is empty, cannot find token info.")
- return { # Fallback to defaults
- 'precision': default_precision,
- 'base_precision': default_precision['amount'],
- 'quote_precision': default_precision['price']
- }
-
- 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 cached exchange position data
- try:
- positions = self.get_positions() # Use cached positions method instead of direct client call
- 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"
- formatter = get_formatter() # Get formatter
-
- 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':
- # Use token for formatting, symbol for logging context
- logger.info(f"Placing LIMIT BUY order ({bot_order_ref_id}) for {formatter.format_amount(token_amount, token)} {symbol} at {formatter.format_price_with_symbol(order_placement_price, token)}")
- 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 {formatter.format_amount(token_amount, token)} {symbol} (approx. price {formatter.format_price_with_symbol(order_placement_price, token)})")
- 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(...)
-
- # MODIFICATION: Remove SL order recording at this stage. SL price is stored with lifecycle.
- # 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
- lifecycle_id = None # Initialize lifecycle_id
- 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
- entry_bot_order_ref_id=bot_order_ref_id, # Pass the entry order's bot_order_ref_id
- stop_loss_price=stop_loss_price, # Store SL price with lifecycle
- take_profit_price=None, # Assuming TP is handled separately or not in this command
- trade_type='bot'
- )
-
- if lifecycle_id:
- logger.info(f"📊 Created trade lifecycle {lifecycle_id} for BUY {symbol} with SL price {stop_loss_price if stop_loss_price else 'N/A'}")
- # MODIFICATION: Do not link a pending DB trigger SL here.
- # if stop_loss_price and sl_order_db_id: # sl_order_db_id is no longer created here
- # self.stats.link_stop_loss_to_trade(lifecycle_id, f"pending_db_trigger_{sl_order_db_id}", stop_loss_price)
- # logger.info(f"📊 Created trade lifecycle {lifecycle_id} and linked pending SL (ID: pending_db_trigger_{sl_order_db_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_activation": stop_loss_price is not None, # Indicates SL needs to be placed after fill
- "stop_loss_price_if_pending": stop_loss_price,
- "trade_lifecycle_id": lifecycle_id if 'lifecycle_id' in locals() else None # Return lifecycle_id
- }
- 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"
- formatter = get_formatter() # Get formatter
-
- 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 {formatter.format_amount(token_amount, token)} {symbol} at {formatter.format_price_with_symbol(order_placement_price, token)}")
- 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 {formatter.format_amount(token_amount, token)} {symbol} (approx. price {formatter.format_price_with_symbol(order_placement_price, token)})")
- 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(...)
-
- # MODIFICATION: Remove SL order recording at this stage. SL price is stored with lifecycle.
- # 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
- lifecycle_id = None # Initialize lifecycle_id
- 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
- entry_bot_order_ref_id=bot_order_ref_id, # Pass the entry order's bot_order_ref_id
- stop_loss_price=stop_loss_price, # Store SL price with lifecycle
- take_profit_price=None, # Assuming TP is handled separately
- trade_type='bot'
- )
-
- if lifecycle_id:
- logger.info(f"📊 Created trade lifecycle {lifecycle_id} for SELL {symbol} with SL price {stop_loss_price if stop_loss_price else 'N/A'}")
- # MODIFICATION: Do not link a pending DB trigger SL here.
- # 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, f"pending_db_trigger_{sl_order_db_id}", stop_loss_price)
- # logger.info(f"📊 Created trade lifecycle {lifecycle_id} and linked pending SL (ID: pending_db_trigger_{sl_order_db_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_activation": stop_loss_price is not None, # Indicates SL needs to be placed after fill
- "stop_loss_price_if_pending": stop_loss_price,
- "trade_lifecycle_id": lifecycle_id if 'lifecycle_id' in locals() else None # Return lifecycle_id
- }
- 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}"}
-
- formatter = get_formatter() # Get formatter
- 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 {formatter.format_amount(contracts_to_close, token)} {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
-
- # Fetch the lifecycle ID of the position being closed
- lifecycle_id_to_close = None
- if self.stats:
- active_trade_lc = self.stats.get_trade_by_symbol_and_status(symbol, 'position_opened')
- if active_trade_lc:
- lifecycle_id_to_close = active_trade_lc.get('trade_lifecycle_id')
-
- 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,
- "trade_lifecycle_id": lifecycle_id_to_close # Return lifecycle_id of the closed position
- }
- 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}"}
-
- formatter = get_formatter() # Get formatter
- 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' # MODIFICATION: SL from /sl command is now a direct limit order
- # 1. Generate bot_order_ref_id and record order placement intent
- bot_order_ref_id = uuid.uuid4().hex
- # For a direct limit SL, the 'price' recorded is the limit price.
- 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, # price here is the limit price for the SL
- bot_order_ref_id=bot_order_ref_id, status='pending_submission'
- )
- if not order_db_id:
- logger.error(f"Failed to record SL limit order intent in DB for {symbol} (direct /sl command) with bot_ref {bot_order_ref_id}")
- return {"success": False, "error": "Failed to record SL limit order intent in database."}
- # 2. Place a direct LIMIT order for the stop loss
- logger.info(f"Placing direct LIMIT STOP LOSS ({exit_side.upper()}) order ({bot_order_ref_id}) for {formatter.format_amount(contracts, token)} {symbol} at limit price {formatter.format_price_with_symbol(stop_price, token)}")
- exchange_order_data, error_msg = self.client.place_limit_order(symbol, exit_side, contracts, price=stop_price)
-
- if error_msg:
- logger.error(f"Direct SL Limit 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"Direct SL Limit order placement failed: {error_msg}"}
- if not exchange_order_data:
- logger.error(f"Direct SL Limit order placement call failed for {symbol} ({bot_order_ref_id}). Client returned no data/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": "Direct SL Limit order placement failed (no order object or error from client)."}
- 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', # Limit orders are 'open' until filled
- set_exchange_order_id=exchange_oid
- )
- else:
- logger.warning(f"No exchange_order_id received for SL limit 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
-
- # Fetch the lifecycle_id for the current position
- lifecycle_id = None
- active_trade_lc = None # Define active_trade_lc to ensure it's available for the if block
- if self.stats:
- active_trade_lc = self.stats.get_trade_by_symbol_and_status(symbol, 'position_opened')
- if active_trade_lc:
- lifecycle_id = active_trade_lc.get('trade_lifecycle_id')
- if exchange_oid: # If SL order placed successfully on exchange
- # Ensure that if an old SL (e.g. stop-market) existed and is being replaced,
- # it might need to be cancelled first. However, current flow assumes this /sl places a new/updated one.
- # For simplicity, link_stop_loss_to_trade will update if one already exists or insert.
- self.stats.link_stop_loss_to_trade(lifecycle_id, exchange_oid, stop_price)
- logger.info(f"🛡️ Linked SL limit order {exchange_oid} to lifecycle {lifecycle_id} for {symbol} (from /sl command)")
-
- 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,
- "trade_lifecycle_id": lifecycle_id # Return lifecycle_id
- }
- 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}"}
-
- formatter = get_formatter() # Get formatter
- 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 {formatter.format_amount(contracts, token)} {symbol} at {formatter.format_price_with_symbol(profit_price, token)}")
- 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
-
- # Fetch the lifecycle_id for the current position
- lifecycle_id = None
- if self.stats:
- active_trade_lc = self.stats.get_trade_by_symbol_and_status(symbol, 'position_opened')
- if active_trade_lc:
- lifecycle_id = active_trade_lc.get('trade_lifecycle_id')
- if exchange_oid: # If TP order placed successfully on exchange
- self.stats.link_take_profit_to_trade(lifecycle_id, exchange_oid, profit_price)
- logger.info(f"🎯 Linked TP order {exchange_oid} to lifecycle {lifecycle_id} for {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": 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,
- "trade_lifecycle_id": lifecycle_id # Return lifecycle_id
- }
- 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)}
-
- async def place_limit_stop_for_lifecycle(self, lifecycle_id: str, symbol: str, sl_price: float, position_side: str, amount_to_cover: float) -> Dict[str, Any]:
- """Places a limit stop-loss order for an active trade lifecycle."""
- formatter = get_formatter()
- token = symbol.split('/')[0] if '/' in symbol else symbol
- if not all([lifecycle_id, symbol, sl_price > 0, position_side in ['long', 'short'], amount_to_cover > 0]):
- err_msg = f"Invalid parameters for place_limit_stop_for_lifecycle: lc_id={lifecycle_id}, sym={symbol}, sl_price={sl_price}, pos_side={position_side}, amt={amount_to_cover}"
- logger.error(err_msg)
- return {"success": False, "error": err_msg}
- sl_order_side = 'sell' if position_side == 'long' else 'buy'
- order_type_for_stats = 'limit' # Explicitly a limit order
- # 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=sl_order_side, order_type=order_type_for_stats,
- amount_requested=amount_to_cover, price=sl_price,
- bot_order_ref_id=bot_order_ref_id, status='pending_submission'
- )
- if not order_db_id:
- msg = f"Failed to record SL limit order intent in DB for {symbol} (Lifecycle: {lifecycle_id}) with bot_ref {bot_order_ref_id}"
- logger.error(msg)
- return {"success": False, "error": msg}
- # 2. Place the limit order on the exchange
- logger.info(f"Placing LIMIT STOP LOSS ({sl_order_side.upper()}) for lifecycle {lifecycle_id[:8]} ({bot_order_ref_id}): {formatter.format_amount(amount_to_cover, token)} {symbol} @ {formatter.format_price_with_symbol(sl_price, token)}")
- exchange_order_data, error_msg = self.client.place_limit_order(symbol, sl_order_side, amount_to_cover, sl_price)
- if error_msg:
- logger.error(f"SL Limit order placement failed for {symbol} ({bot_order_ref_id}, LC: {lifecycle_id[:8]}): {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"SL Limit order placement failed: {error_msg}"}
- if not exchange_order_data:
- logger.error(f"SL Limit order placement call failed for {symbol} ({bot_order_ref_id}, LC: {lifecycle_id[:8]}). Client returned no data/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": "SL Limit order placement failed (no order object or error from client)."}
- 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', # Limit orders are 'open' until filled
- set_exchange_order_id=exchange_oid
- )
- # 4. Link this exchange SL order to the trade lifecycle
- self.stats.link_stop_loss_to_trade(lifecycle_id, exchange_oid, sl_price)
- logger.info(f"🛡️ Successfully placed and linked SL limit order {exchange_oid} to lifecycle {lifecycle_id} for {symbol}")
- else:
- logger.warning(f"No exchange_order_id received for SL limit order {order_db_id} ({bot_order_ref_id}, LC: {lifecycle_id[:8]}). Status remains pending_submission.")
- 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": sl_order_side,
- "type": order_type_for_stats,
- "amount_requested": amount_to_cover,
- "price_requested": sl_price
- },
- "trade_lifecycle_id": lifecycle_id
- }
-
- 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."}
- formatter = get_formatter() # Get formatter
- 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:
- token = symbol.split('/')[0] if symbol else "TOKEN" # Extract token for formatter
- current_price_str = formatter.format_price_with_symbol(current_price, token)
- stop_price_str = formatter.format_price_with_symbol(stop_price, token)
- 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_str} > Stop {stop_price_str} - Using MARKET order for immediate execution")
- else:
- logger.info(f"📊 SHORT SL: Price {current_price_str} ≤ Stop {stop_price_str} - 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_str} < Stop {stop_price_str} - Using MARKET order for immediate execution")
- else:
- logger.info(f"📊 LONG SL: Price {current_price_str} ≥ Stop {stop_price_str} - 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: {formatter.format_amount(amount, token)}, Trigger was: {formatter.format_price_with_symbol(stop_price, token)}")
- 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: {formatter.format_amount(amount, token)}, Price: {formatter.format_price_with_symbol(stop_price, token)}")
- 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 {formatter.format_price_with_symbol(stop_price, token)}."
- 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
- }
- }
|