trading_engine.py 54 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997
  1. #!/usr/bin/env python3
  2. """
  3. Trading Engine - Handles order execution, position tracking, and trading logic.
  4. """
  5. import os
  6. import json
  7. import logging
  8. from typing import Dict, Any, Optional, Tuple, List
  9. from datetime import datetime
  10. import uuid # For generating unique bot_order_ref_ids
  11. from src.config.config import Config
  12. from src.clients.hyperliquid_client import HyperliquidClient
  13. from src.trading.trading_stats import TradingStats
  14. from src.utils.price_formatter import set_global_trading_engine
  15. logger = logging.getLogger(__name__)
  16. class TradingEngine:
  17. """Handles all trading operations, order execution, and position tracking."""
  18. def __init__(self):
  19. """Initialize the trading engine."""
  20. self.client = HyperliquidClient()
  21. self.stats = None
  22. self.market_monitor = None # Will be set by the main bot
  23. # State persistence (Removed - state is now in DB)
  24. # self.state_file = "data/trading_engine_state.json"
  25. # Position and order tracking (All main state moved to DB via TradingStats)
  26. # Initialize stats (this will connect to/create the DB)
  27. self._initialize_stats()
  28. # Initialize price formatter with this trading engine
  29. set_global_trading_engine(self)
  30. def _initialize_stats(self):
  31. """Initialize trading statistics."""
  32. try:
  33. self.stats = TradingStats()
  34. # Set initial balance
  35. balance = self.client.get_balance()
  36. if balance and balance.get('total'):
  37. usdc_balance = float(balance['total'].get('USDC', 0))
  38. self.stats.set_initial_balance(usdc_balance)
  39. except Exception as e:
  40. logger.error(f"Could not initialize trading stats: {e}")
  41. def set_market_monitor(self, market_monitor):
  42. """Set the market monitor reference for accessing cached data."""
  43. self.market_monitor = market_monitor
  44. def get_balance(self) -> Optional[Dict[str, Any]]:
  45. """Get account balance (uses cached data when available)."""
  46. # Try cached data first (updated every heartbeat)
  47. if self.market_monitor and hasattr(self.market_monitor, 'get_cached_balance'):
  48. cached_balance = self.market_monitor.get_cached_balance()
  49. cache_age = self.market_monitor.get_cache_age_seconds()
  50. # Use cached data if it's fresh (less than 30 seconds old)
  51. if cached_balance and cache_age < 30:
  52. logger.debug(f"Using cached balance (age: {cache_age:.1f}s)")
  53. return cached_balance
  54. # Fallback to fresh API call
  55. logger.debug("Using fresh balance API call")
  56. return self.client.get_balance()
  57. def get_positions(self) -> Optional[List[Dict[str, Any]]]:
  58. """Get all positions (uses cached data when available)."""
  59. # Try cached data first (updated every heartbeat)
  60. if self.market_monitor and hasattr(self.market_monitor, 'get_cached_positions'):
  61. cached_positions = self.market_monitor.get_cached_positions()
  62. cache_age = self.market_monitor.get_cache_age_seconds()
  63. # Use cached data if it's fresh (less than 30 seconds old)
  64. if cached_positions is not None and cache_age < 30:
  65. logger.debug(f"Using cached positions (age: {cache_age:.1f}s): {len(cached_positions)} positions")
  66. return cached_positions
  67. # Fallback to fresh API call
  68. logger.debug("Using fresh positions API call")
  69. return self.client.get_positions()
  70. def get_orders(self) -> Optional[List[Dict[str, Any]]]:
  71. """Get all open orders (uses cached data when available)."""
  72. # Try cached data first (updated every heartbeat)
  73. if self.market_monitor and hasattr(self.market_monitor, 'get_cached_orders'):
  74. cached_orders = self.market_monitor.get_cached_orders()
  75. cache_age = self.market_monitor.get_cache_age_seconds()
  76. # Use cached data if it's fresh (less than 30 seconds old)
  77. if cached_orders is not None and cache_age < 30:
  78. logger.debug(f"Using cached orders (age: {cache_age:.1f}s): {len(cached_orders)} orders")
  79. return cached_orders
  80. # Fallback to fresh API call
  81. logger.debug("Using fresh orders API call")
  82. return self.client.get_open_orders()
  83. def get_recent_fills(self) -> Optional[List[Dict[str, Any]]]:
  84. """Get recent fills/trades."""
  85. return self.client.get_recent_fills()
  86. def get_market_data(self, symbol: str) -> Optional[Dict[str, Any]]:
  87. """Get market data for a symbol."""
  88. return self.client.get_market_data(symbol)
  89. def find_position(self, token: str) -> Optional[Dict[str, Any]]:
  90. """Find an open position for a token."""
  91. symbol = f"{token}/USDC:USDC"
  92. # 🆕 PHASE 4: Check trades table for open positions (single source of truth)
  93. if self.stats:
  94. open_trade = self.stats.get_trade_by_symbol_and_status(symbol, status='position_opened')
  95. if open_trade:
  96. # Convert trades format to position format for compatibility
  97. entry_price = open_trade.get('entry_price')
  98. current_amount = open_trade.get('current_position_size', 0)
  99. position_side = open_trade.get('position_side')
  100. if entry_price and current_amount and abs(current_amount) > 0:
  101. return {
  102. 'symbol': symbol,
  103. 'contracts': abs(current_amount),
  104. 'notional': abs(current_amount),
  105. 'side': 'long' if position_side == 'long' else 'short',
  106. 'size': current_amount, # Can be negative for short
  107. 'entryPx': entry_price,
  108. 'unrealizedPnl': open_trade.get('unrealized_pnl', 0),
  109. 'marginUsed': abs(current_amount * entry_price),
  110. # Add lifecycle info for debugging
  111. '_lifecycle_id': open_trade.get('trade_lifecycle_id'),
  112. '_trade_id': open_trade.get('id'),
  113. '_source': 'trades_table_phase4'
  114. }
  115. # 🔄 Fallback: Check exchange position data
  116. try:
  117. positions = self.client.get_positions()
  118. if positions:
  119. for pos in positions:
  120. if pos.get('symbol') == symbol and pos.get('contracts', 0) != 0:
  121. logger.debug(f"Found exchange position for {token}: {pos}")
  122. return pos
  123. except Exception as e:
  124. logger.warning(f"Could not fetch exchange positions: {e}")
  125. return None
  126. def get_position_direction(self, position: Dict[str, Any]) -> Tuple[str, str, float]:
  127. """
  128. Get position direction info from CCXT position data.
  129. Returns: (position_type, exit_side, contracts_abs)
  130. """
  131. contracts = float(position.get('contracts', 0))
  132. side_field = position.get('side', '').lower()
  133. if side_field == 'long':
  134. return "LONG", "sell", abs(contracts)
  135. elif side_field == 'short':
  136. return "SHORT", "buy", abs(contracts)
  137. else:
  138. # Fallback to contracts sign (less reliable)
  139. if contracts > 0:
  140. return "LONG", "sell", abs(contracts)
  141. else:
  142. return "SHORT", "buy", abs(contracts)
  143. async def execute_long_order(self, token: str, usdc_amount: float,
  144. limit_price_arg: Optional[float] = None,
  145. stop_loss_price: Optional[float] = None) -> Dict[str, Any]:
  146. symbol = f"{token}/USDC:USDC"
  147. try:
  148. # Validate inputs
  149. if usdc_amount <= 0:
  150. return {"success": False, "error": "Invalid USDC amount"}
  151. # Get market data for price validation
  152. market_data = self.get_market_data(symbol)
  153. if not market_data or not market_data.get('ticker'):
  154. return {"success": False, "error": f"Could not fetch market data for {token}"}
  155. current_price = float(market_data['ticker'].get('last', 0.0) or 0.0)
  156. if current_price <= 0:
  157. # Allow trading if current_price is 0 but a valid limit_price_arg is provided
  158. if not (limit_price_arg and limit_price_arg > 0):
  159. return {"success": False, "error": f"Invalid current price ({current_price}) for {token} and no valid limit price provided."}
  160. order_type_for_stats = 'limit' if limit_price_arg is not None else 'market'
  161. order_placement_price: float
  162. token_amount: float
  163. # Determine price and amount for order placement
  164. if limit_price_arg is not None:
  165. if limit_price_arg <= 0:
  166. return {"success": False, "error": "Limit price must be positive."}
  167. order_placement_price = limit_price_arg
  168. token_amount = usdc_amount / order_placement_price
  169. else: # Market order
  170. if current_price <= 0:
  171. return {"success": False, "error": f"Cannot place market order for {token} due to invalid current price: {current_price}"}
  172. order_placement_price = current_price
  173. token_amount = usdc_amount / order_placement_price
  174. # 1. Generate bot_order_ref_id and record order placement intent
  175. bot_order_ref_id = uuid.uuid4().hex
  176. order_db_id = self.stats.record_order_placed(
  177. symbol=symbol, side='buy', order_type=order_type_for_stats,
  178. amount_requested=token_amount, price=order_placement_price if order_type_for_stats == 'limit' else None,
  179. bot_order_ref_id=bot_order_ref_id, status='pending_submission'
  180. )
  181. if not order_db_id:
  182. logger.error(f"Failed to record order intent in DB for {symbol} with bot_ref_id {bot_order_ref_id}")
  183. return {"success": False, "error": "Failed to record order intent in database."}
  184. # 2. Place the order with the exchange
  185. if order_type_for_stats == 'limit':
  186. logger.info(f"Placing LIMIT BUY order ({bot_order_ref_id}) for {token_amount:.6f} {symbol} at ${order_placement_price:,.2f}")
  187. exchange_order_data, error_msg = self.client.place_limit_order(symbol, 'buy', token_amount, order_placement_price)
  188. else: # Market order
  189. logger.info(f"Placing MARKET BUY order ({bot_order_ref_id}) for {token_amount:.6f} {symbol} (approx. price ${order_placement_price:,.2f})")
  190. exchange_order_data, error_msg = self.client.place_market_order(symbol, 'buy', token_amount)
  191. if error_msg:
  192. logger.error(f"Order placement failed for {symbol} ({bot_order_ref_id}): {error_msg}")
  193. self.stats.update_order_status(order_db_id=order_db_id, new_status='failed_submission', bot_order_ref_id=bot_order_ref_id)
  194. return {"success": False, "error": f"Order placement failed: {error_msg}"}
  195. if not exchange_order_data:
  196. logger.error(f"Order placement call failed for {symbol} ({bot_order_ref_id}). Client returned no data and no error.")
  197. 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)
  198. return {"success": False, "error": "Order placement failed at client level (no order object or error)."}
  199. exchange_oid = exchange_order_data.get('id')
  200. # 3. Update order in DB with exchange_order_id and status
  201. if exchange_oid:
  202. # If it's a market order that might have filled, client response might indicate status.
  203. # For Hyperliquid, a successful market order usually means it's filled or being filled.
  204. # Limit orders will be 'open'.
  205. # We will rely on MarketMonitor to confirm fills for market orders too via fill data.
  206. new_status_after_placement = 'open' # Default for limit, or submitted for market
  207. if order_type_for_stats == 'market':
  208. # Market orders might be considered 'submitted' until fill is confirmed by MarketMonitor
  209. # Or, if API indicates immediate fill, could be 'filled' - for now, let's use 'open' or 'submitted'
  210. new_status_after_placement = 'submitted' # More accurate for market until fill is seen
  211. self.stats.update_order_status(
  212. order_db_id=order_db_id,
  213. new_status=new_status_after_placement,
  214. set_exchange_order_id=exchange_oid
  215. )
  216. else:
  217. 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.")
  218. # Potentially update status to 'submission_no_exch_id'
  219. # DO NOT record trade here. MarketMonitor will handle fills.
  220. # action_type = self.stats.record_trade_with_enhanced_tracking(...)
  221. if stop_loss_price and exchange_oid and exchange_oid != 'N/A':
  222. # Record the pending SL order in the orders table
  223. sl_bot_order_ref_id = uuid.uuid4().hex
  224. sl_order_db_id = self.stats.record_order_placed(
  225. symbol=symbol,
  226. side='sell', # SL for a long is a sell
  227. order_type='STOP_LIMIT_TRIGGER', # Indicates a conditional order that will become a limit order
  228. amount_requested=token_amount,
  229. price=stop_loss_price, # This is the trigger price, and also the limit price for the SL order
  230. bot_order_ref_id=sl_bot_order_ref_id,
  231. status='pending_trigger',
  232. parent_bot_order_ref_id=bot_order_ref_id # Link to the main buy order
  233. )
  234. if sl_order_db_id:
  235. 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}")
  236. else:
  237. logger.error(f"Failed to record pending SL order in DB for parent BotRef {bot_order_ref_id}")
  238. # 🆕 PHASE 4: Create trade lifecycle for this entry order
  239. if exchange_oid:
  240. entry_order_record = self.stats.get_order_by_exchange_id(exchange_oid)
  241. if entry_order_record:
  242. lifecycle_id = self.stats.create_trade_lifecycle(
  243. symbol=symbol,
  244. side='buy',
  245. entry_order_id=exchange_oid, # Store exchange order ID
  246. stop_loss_price=stop_loss_price,
  247. trade_type='bot'
  248. )
  249. if lifecycle_id and stop_loss_price:
  250. # Get the stop loss order that was just created
  251. sl_order_record = self.stats.get_order_by_bot_ref_id(sl_bot_order_ref_id)
  252. if sl_order_record:
  253. self.stats.link_stop_loss_to_trade(lifecycle_id, exchange_oid, stop_loss_price)
  254. logger.info(f"📊 Created trade lifecycle {lifecycle_id} with linked stop loss for BUY {symbol}")
  255. else:
  256. logger.info(f"📊 Created trade lifecycle {lifecycle_id} for BUY {symbol}")
  257. elif lifecycle_id:
  258. logger.info(f"📊 Created trade lifecycle {lifecycle_id} for BUY {symbol}")
  259. return {
  260. "success": True,
  261. "order_placed_details": {
  262. "bot_order_ref_id": bot_order_ref_id,
  263. "exchange_order_id": exchange_oid,
  264. "order_db_id": order_db_id,
  265. "symbol": symbol,
  266. "side": "buy",
  267. "type": order_type_for_stats,
  268. "amount_requested": token_amount,
  269. "price_requested": order_placement_price if order_type_for_stats == 'limit' else None
  270. },
  271. # "action_type": action_type, # Removed as trade is not recorded here
  272. "token_amount": token_amount,
  273. # "actual_price": final_price_for_stats, # Removed as fill is not processed here
  274. "stop_loss_pending": stop_loss_price is not None
  275. }
  276. except ZeroDivisionError as e:
  277. 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'}")
  278. return {"success": False, "error": f"Math error (division by zero), check prices: {e}"}
  279. except Exception as e:
  280. logger.error(f"Error executing long order: {e}", exc_info=True)
  281. return {"success": False, "error": str(e)}
  282. async def execute_short_order(self, token: str, usdc_amount: float,
  283. limit_price_arg: Optional[float] = None,
  284. stop_loss_price: Optional[float] = None) -> Dict[str, Any]:
  285. symbol = f"{token}/USDC:USDC"
  286. try:
  287. if usdc_amount <= 0:
  288. return {"success": False, "error": "Invalid USDC amount"}
  289. market_data = self.get_market_data(symbol)
  290. if not market_data or not market_data.get('ticker'):
  291. return {"success": False, "error": f"Could not fetch market data for {token}"}
  292. current_price = float(market_data['ticker'].get('last', 0.0) or 0.0)
  293. if current_price <= 0:
  294. if not (limit_price_arg and limit_price_arg > 0):
  295. return {"success": False, "error": f"Invalid current price ({current_price}) for {token} and no valid limit price provided."}
  296. order_type_for_stats = 'limit' if limit_price_arg is not None else 'market'
  297. order_placement_price: float
  298. token_amount: float
  299. if limit_price_arg is not None:
  300. if limit_price_arg <= 0:
  301. return {"success": False, "error": "Limit price must be positive."}
  302. order_placement_price = limit_price_arg
  303. token_amount = usdc_amount / order_placement_price
  304. else: # Market order
  305. if current_price <= 0:
  306. return {"success": False, "error": f"Cannot place market order for {token} due to invalid current price: {current_price}"}
  307. order_placement_price = current_price
  308. token_amount = usdc_amount / order_placement_price
  309. # 1. Generate bot_order_ref_id and record order placement intent
  310. bot_order_ref_id = uuid.uuid4().hex
  311. order_db_id = self.stats.record_order_placed(
  312. symbol=symbol, side='sell', order_type=order_type_for_stats,
  313. amount_requested=token_amount, price=order_placement_price if order_type_for_stats == 'limit' else None,
  314. bot_order_ref_id=bot_order_ref_id, status='pending_submission'
  315. )
  316. if not order_db_id:
  317. logger.error(f"Failed to record order intent in DB for {symbol} with bot_ref_id {bot_order_ref_id}")
  318. return {"success": False, "error": "Failed to record order intent in database."}
  319. # 2. Place the order with the exchange
  320. if order_type_for_stats == 'limit':
  321. logger.info(f"Placing LIMIT SELL order ({bot_order_ref_id}) for {token_amount:.6f} {symbol} at ${order_placement_price:,.2f}")
  322. exchange_order_data, error_msg = self.client.place_limit_order(symbol, 'sell', token_amount, order_placement_price)
  323. else: # Market order
  324. logger.info(f"Placing MARKET SELL order ({bot_order_ref_id}) for {token_amount:.6f} {symbol} (approx. price ${order_placement_price:,.2f})")
  325. exchange_order_data, error_msg = self.client.place_market_order(symbol, 'sell', token_amount)
  326. if error_msg:
  327. logger.error(f"Order placement failed for {symbol} ({bot_order_ref_id}): {error_msg}")
  328. self.stats.update_order_status(order_db_id=order_db_id, new_status='failed_submission', bot_order_ref_id=bot_order_ref_id)
  329. return {"success": False, "error": f"Order placement failed: {error_msg}"}
  330. if not exchange_order_data:
  331. logger.error(f"Order placement call failed for {symbol} ({bot_order_ref_id}). Client returned no data and no error.")
  332. 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)
  333. return {"success": False, "error": "Order placement failed at client level (no order object or error)."}
  334. exchange_oid = exchange_order_data.get('id')
  335. # 3. Update order in DB with exchange_order_id and status
  336. if exchange_oid:
  337. new_status_after_placement = 'open' # Default for limit, or submitted for market
  338. if order_type_for_stats == 'market':
  339. new_status_after_placement = 'submitted' # Market orders are submitted, fills come via monitor
  340. self.stats.update_order_status(
  341. order_db_id=order_db_id,
  342. new_status=new_status_after_placement,
  343. set_exchange_order_id=exchange_oid
  344. )
  345. else:
  346. logger.warning(f"No exchange_order_id received for order {order_db_id} ({bot_order_ref_id}).")
  347. # DO NOT record trade here. MarketMonitor will handle fills.
  348. # action_type = self.stats.record_trade_with_enhanced_tracking(...)
  349. if stop_loss_price and exchange_oid and exchange_oid != 'N/A':
  350. # Record the pending SL order in the orders table
  351. sl_bot_order_ref_id = uuid.uuid4().hex
  352. sl_order_db_id = self.stats.record_order_placed(
  353. symbol=symbol,
  354. side='buy', # SL for a short is a buy
  355. order_type='STOP_LIMIT_TRIGGER',
  356. amount_requested=token_amount,
  357. price=stop_loss_price,
  358. bot_order_ref_id=sl_bot_order_ref_id,
  359. status='pending_trigger',
  360. parent_bot_order_ref_id=bot_order_ref_id # Link to the main sell order
  361. )
  362. if sl_order_db_id:
  363. 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}")
  364. else:
  365. logger.error(f"Failed to record pending SL order in DB for parent BotRef {bot_order_ref_id}")
  366. # 🆕 PHASE 4: Create trade lifecycle for this entry order
  367. if exchange_oid:
  368. entry_order_record = self.stats.get_order_by_exchange_id(exchange_oid)
  369. if entry_order_record:
  370. lifecycle_id = self.stats.create_trade_lifecycle(
  371. symbol=symbol,
  372. side='sell',
  373. entry_order_id=exchange_oid, # Store exchange order ID
  374. stop_loss_price=stop_loss_price,
  375. trade_type='bot'
  376. )
  377. if lifecycle_id and stop_loss_price:
  378. # Get the stop loss order that was just created
  379. sl_order_record = self.stats.get_order_by_bot_ref_id(sl_bot_order_ref_id)
  380. if sl_order_record:
  381. self.stats.link_stop_loss_to_trade(lifecycle_id, exchange_oid, stop_loss_price)
  382. logger.info(f"📊 Created trade lifecycle {lifecycle_id} with linked stop loss for SELL {symbol}")
  383. else:
  384. logger.info(f"📊 Created trade lifecycle {lifecycle_id} for SELL {symbol}")
  385. elif lifecycle_id:
  386. logger.info(f"📊 Created trade lifecycle {lifecycle_id} for SELL {symbol}")
  387. return {
  388. "success": True,
  389. "order_placed_details": {
  390. "bot_order_ref_id": bot_order_ref_id,
  391. "exchange_order_id": exchange_oid,
  392. "order_db_id": order_db_id,
  393. "symbol": symbol,
  394. "side": "sell",
  395. "type": order_type_for_stats,
  396. "amount_requested": token_amount,
  397. "price_requested": order_placement_price if order_type_for_stats == 'limit' else None
  398. },
  399. "token_amount": token_amount,
  400. "stop_loss_pending": stop_loss_price is not None
  401. }
  402. except ZeroDivisionError as e:
  403. 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'}")
  404. return {"success": False, "error": f"Math error (division by zero), check prices: {e}"}
  405. except Exception as e:
  406. logger.error(f"Error executing short order: {e}", exc_info=True)
  407. return {"success": False, "error": str(e)}
  408. async def execute_exit_order(self, token: str) -> Dict[str, Any]:
  409. """Execute an exit order to close a position."""
  410. position = self.find_position(token)
  411. if not position:
  412. return {"success": False, "error": f"No open position found for {token}"}
  413. try:
  414. symbol = f"{token}/USDC:USDC"
  415. position_type, exit_side, contracts_to_close = self.get_position_direction(position)
  416. order_type_for_stats = 'market' # Exit orders are typically market
  417. # 1. Generate bot_order_ref_id and record order placement intent
  418. bot_order_ref_id = uuid.uuid4().hex
  419. # Price for a market order is not specified at placement for stats recording, will be determined by fill.
  420. order_db_id = self.stats.record_order_placed(
  421. symbol=symbol, side=exit_side, order_type=order_type_for_stats,
  422. amount_requested=contracts_to_close, price=None, # Market order, price determined by fill
  423. bot_order_ref_id=bot_order_ref_id, status='pending_submission'
  424. )
  425. if not order_db_id:
  426. logger.error(f"Failed to record exit order intent in DB for {symbol} with bot_ref_id {bot_order_ref_id}")
  427. return {"success": False, "error": "Failed to record exit order intent in database."}
  428. # 2. Execute market order to close position
  429. logger.info(f"Placing MARKET {exit_side.upper()} order ({bot_order_ref_id}) to close {contracts_to_close:.6f} {symbol}")
  430. exchange_order_data, error_msg = self.client.place_market_order(symbol, exit_side, contracts_to_close)
  431. if error_msg:
  432. logger.error(f"Exit order execution failed for {symbol} ({bot_order_ref_id}): {error_msg}")
  433. self.stats.update_order_status(order_db_id=order_db_id, new_status='failed_submission', bot_order_ref_id=bot_order_ref_id)
  434. return {"success": False, "error": f"Exit order execution failed: {error_msg}"}
  435. if not exchange_order_data:
  436. logger.error(f"Exit order execution call failed for {symbol} ({bot_order_ref_id}). Client returned no data and no error.")
  437. 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)
  438. return {"success": False, "error": "Exit order execution failed (no order object or error)."}
  439. exchange_oid = exchange_order_data.get('id')
  440. # 3. Update order in DB with exchange_order_id and status
  441. if exchange_oid:
  442. # Market orders are submitted; MarketMonitor will confirm fills.
  443. self.stats.update_order_status(
  444. order_db_id=order_db_id,
  445. new_status='submitted',
  446. set_exchange_order_id=exchange_oid
  447. )
  448. else:
  449. logger.warning(f"No exchange_order_id received for exit order {order_db_id} ({bot_order_ref_id}).")
  450. # DO NOT record trade here. MarketMonitor will handle fills.
  451. # The old code below is removed:
  452. # order_id = order_data.get('id', 'N/A')
  453. # actual_price = order_data.get('average', 0)
  454. # ... logic for actual_price fallback ...
  455. # if order_id != 'N/A':
  456. # self.bot_trade_ids.add(order_id)
  457. # action_type = self.stats.record_trade_with_enhanced_tracking(...)
  458. # Cancel any pending stop losses for this symbol since position will be closed
  459. if self.stats:
  460. cancelled_sl_count = self.stats.cancel_pending_stop_losses_by_symbol(
  461. symbol=symbol,
  462. new_status='cancelled_manual_exit'
  463. )
  464. if cancelled_sl_count > 0:
  465. logger.info(f"🛑 Cancelled {cancelled_sl_count} pending stop losses for {symbol} due to manual exit order")
  466. # NOTE: Exit orders do not create new trade cycles - they close existing ones
  467. # The MarketMonitor will handle closing the trade cycle when the exit order fills
  468. return {
  469. "success": True,
  470. "order_placed_details": {
  471. "bot_order_ref_id": bot_order_ref_id,
  472. "exchange_order_id": exchange_oid,
  473. "order_db_id": order_db_id,
  474. "symbol": symbol,
  475. "side": exit_side,
  476. "type": order_type_for_stats,
  477. "amount_requested": contracts_to_close
  478. },
  479. "position_type_closed": position_type, # Info about the position it intends to close
  480. "contracts_intended_to_close": contracts_to_close,
  481. "cancelled_stop_losses": cancelled_sl_count if self.stats else 0
  482. }
  483. except Exception as e:
  484. logger.error(f"Error executing exit order: {e}")
  485. return {"success": False, "error": str(e)}
  486. async def execute_stop_loss_order(self, token: str, stop_price: float) -> Dict[str, Any]:
  487. """Execute a stop loss order."""
  488. position = self.find_position(token)
  489. if not position:
  490. return {"success": False, "error": f"No open position found for {token}"}
  491. try:
  492. symbol = f"{token}/USDC:USDC"
  493. position_type, exit_side, contracts = self.get_position_direction(position)
  494. entry_price = float(position.get('entryPx', 0))
  495. # Validate stop loss price based on position direction
  496. if position_type == "LONG" and stop_price >= entry_price:
  497. return {"success": False, "error": "Stop loss price should be below entry price for long positions"}
  498. elif position_type == "SHORT" and stop_price <= entry_price:
  499. return {"success": False, "error": "Stop loss price should be above entry price for short positions"}
  500. order_type_for_stats = 'limit' # Stop loss is a limit order at stop_price
  501. # 1. Generate bot_order_ref_id and record order placement intent
  502. bot_order_ref_id = uuid.uuid4().hex
  503. order_db_id = self.stats.record_order_placed(
  504. symbol=symbol, side=exit_side, order_type=order_type_for_stats,
  505. amount_requested=contracts, price=stop_price,
  506. bot_order_ref_id=bot_order_ref_id, status='pending_submission'
  507. )
  508. if not order_db_id:
  509. logger.error(f"Failed to record SL order intent in DB for {symbol} with bot_ref_id {bot_order_ref_id}")
  510. return {"success": False, "error": "Failed to record SL order intent in database."}
  511. # 2. Place limit order at stop loss price
  512. logger.info(f"Placing STOP LOSS (LIMIT {exit_side.upper()}) order ({bot_order_ref_id}) for {contracts:.6f} {symbol} at ${stop_price:,.2f}")
  513. exchange_order_data, error_msg = self.client.place_limit_order(symbol, exit_side, contracts, stop_price)
  514. if error_msg:
  515. logger.error(f"Stop loss order placement failed for {symbol} ({bot_order_ref_id}): {error_msg}")
  516. self.stats.update_order_status(order_db_id=order_db_id, new_status='failed_submission', bot_order_ref_id=bot_order_ref_id)
  517. return {"success": False, "error": f"Stop loss order placement failed: {error_msg}"}
  518. if not exchange_order_data:
  519. logger.error(f"Stop loss order placement call failed for {symbol} ({bot_order_ref_id}). Client returned no data and no error.")
  520. 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)
  521. return {"success": False, "error": "Stop loss order placement failed (no order object or error)."}
  522. exchange_oid = exchange_order_data.get('id')
  523. # 3. Update order in DB with exchange_order_id and status
  524. if exchange_oid:
  525. self.stats.update_order_status(
  526. order_db_id=order_db_id,
  527. new_status='open', # SL/TP limit orders are 'open' until triggered/filled
  528. set_exchange_order_id=exchange_oid
  529. )
  530. else:
  531. logger.warning(f"No exchange_order_id received for SL order {order_db_id} ({bot_order_ref_id}).")
  532. # NOTE: Stop loss orders are protective orders for existing positions
  533. # They do not create new trade cycles - they protect existing trade cycles
  534. return {
  535. "success": True,
  536. "order_placed_details": {
  537. "bot_order_ref_id": bot_order_ref_id,
  538. "exchange_order_id": exchange_oid,
  539. "order_db_id": order_db_id,
  540. "symbol": symbol,
  541. "side": exit_side,
  542. "type": order_type_for_stats,
  543. "amount_requested": contracts,
  544. "price_requested": stop_price
  545. },
  546. "position_type_for_sl": position_type, # Info about the position it's protecting
  547. "contracts_for_sl": contracts,
  548. "stop_price_set": stop_price
  549. }
  550. except Exception as e:
  551. logger.error(f"Error executing stop loss order: {e}")
  552. return {"success": False, "error": str(e)}
  553. async def execute_take_profit_order(self, token: str, profit_price: float) -> Dict[str, Any]:
  554. """Execute a take profit order."""
  555. position = self.find_position(token)
  556. if not position:
  557. return {"success": False, "error": f"No open position found for {token}"}
  558. try:
  559. symbol = f"{token}/USDC:USDC"
  560. position_type, exit_side, contracts = self.get_position_direction(position)
  561. entry_price = float(position.get('entryPx', 0))
  562. # Validate take profit price based on position direction
  563. if position_type == "LONG" and profit_price <= entry_price:
  564. return {"success": False, "error": "Take profit price should be above entry price for long positions"}
  565. elif position_type == "SHORT" and profit_price >= entry_price:
  566. return {"success": False, "error": "Take profit price should be below entry price for short positions"}
  567. order_type_for_stats = 'limit' # Take profit is a limit order at profit_price
  568. # 1. Generate bot_order_ref_id and record order placement intent
  569. bot_order_ref_id = uuid.uuid4().hex
  570. order_db_id = self.stats.record_order_placed(
  571. symbol=symbol, side=exit_side, order_type=order_type_for_stats,
  572. amount_requested=contracts, price=profit_price,
  573. bot_order_ref_id=bot_order_ref_id, status='pending_submission'
  574. )
  575. if not order_db_id:
  576. logger.error(f"Failed to record TP order intent in DB for {symbol} with bot_ref_id {bot_order_ref_id}")
  577. return {"success": False, "error": "Failed to record TP order intent in database."}
  578. # 2. Place limit order at take profit price
  579. logger.info(f"Placing TAKE PROFIT (LIMIT {exit_side.upper()}) order ({bot_order_ref_id}) for {contracts:.6f} {symbol} at ${profit_price:,.2f}")
  580. exchange_order_data, error_msg = self.client.place_limit_order(symbol, exit_side, contracts, profit_price)
  581. if error_msg:
  582. logger.error(f"Take profit order placement failed for {symbol} ({bot_order_ref_id}): {error_msg}")
  583. self.stats.update_order_status(order_db_id=order_db_id, new_status='failed_submission', bot_order_ref_id=bot_order_ref_id)
  584. return {"success": False, "error": f"Take profit order placement failed: {error_msg}"}
  585. if not exchange_order_data:
  586. logger.error(f"Take profit order placement call failed for {symbol} ({bot_order_ref_id}). Client returned no data and no error.")
  587. 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)
  588. return {"success": False, "error": "Take profit order placement failed (no order object or error)."}
  589. exchange_oid = exchange_order_data.get('id')
  590. # 3. Update order in DB with exchange_order_id and status
  591. if exchange_oid:
  592. self.stats.update_order_status(
  593. order_db_id=order_db_id,
  594. new_status='open', # SL/TP limit orders are 'open' until triggered/filled
  595. set_exchange_order_id=exchange_oid
  596. )
  597. else:
  598. logger.warning(f"No exchange_order_id received for TP order {order_db_id} ({bot_order_ref_id}).")
  599. # NOTE: Take profit orders are protective orders for existing positions
  600. # They do not create new trade cycles - they protect existing trade cycles
  601. return {
  602. "success": True,
  603. "order_placed_details": {
  604. "bot_order_ref_id": bot_order_ref_id,
  605. "exchange_order_id": exchange_oid,
  606. "order_db_id": order_db_id,
  607. "symbol": symbol,
  608. "side": exit_side,
  609. "type": order_type_for_stats,
  610. "amount_requested": contracts,
  611. "price_requested": profit_price
  612. },
  613. "position_type_for_tp": position_type, # Info about the position it's for
  614. "contracts_for_tp": contracts,
  615. "profit_price_set": profit_price
  616. }
  617. except Exception as e:
  618. logger.error(f"Error executing take profit order: {e}")
  619. return {"success": False, "error": str(e)}
  620. def cancel_all_orders(self, symbol: str) -> Tuple[List[Dict[str, Any]], Optional[str]]:
  621. """Cancel all open orders for a specific symbol. Returns (cancelled_orders, error_message)."""
  622. try:
  623. logger.info(f"Attempting to cancel all orders for {symbol}")
  624. # Get all open orders
  625. all_orders = self.client.get_open_orders()
  626. if all_orders is None:
  627. error_msg = f"Could not fetch orders to cancel {symbol} orders"
  628. logger.error(error_msg)
  629. return [], error_msg
  630. # Filter orders for the specific symbol
  631. symbol_orders = [order for order in all_orders if order.get('symbol') == symbol]
  632. if not symbol_orders:
  633. logger.info(f"No open orders found for {symbol}")
  634. return [], None # No error, just no orders to cancel
  635. # Cancel each order individually
  636. cancelled_orders = []
  637. failed_orders = []
  638. for order in symbol_orders:
  639. order_id = order.get('id')
  640. if order_id:
  641. try:
  642. success = self.client.cancel_order(order_id, symbol)
  643. if success:
  644. cancelled_orders.append(order)
  645. logger.info(f"Successfully cancelled order {order_id} for {symbol}")
  646. else:
  647. failed_orders.append(order)
  648. logger.warning(f"Failed to cancel order {order_id} for {symbol}")
  649. except Exception as e:
  650. logger.error(f"Exception cancelling order {order_id}: {e}")
  651. failed_orders.append(order)
  652. # Update order status in database if we have stats
  653. if self.stats:
  654. for order in cancelled_orders:
  655. order_id = order.get('id')
  656. if order_id:
  657. # Try to find this order in our database and update its status
  658. db_order = self.stats.get_order_by_exchange_id(order_id)
  659. if db_order:
  660. self.stats.update_order_status(
  661. exchange_order_id=order_id,
  662. new_status='cancelled_manually'
  663. )
  664. # Cancel any linked pending stop losses for this symbol
  665. cleanup_count = self.stats.cancel_pending_stop_losses_by_symbol(
  666. symbol,
  667. 'cancelled_manual_exit'
  668. )
  669. if cleanup_count > 0:
  670. logger.info(f"Cleaned up {cleanup_count} pending stop losses for {symbol}")
  671. # Prepare result
  672. if failed_orders:
  673. error_msg = f"Cancelled {len(cancelled_orders)}/{len(symbol_orders)} orders. {len(failed_orders)} failed."
  674. logger.warning(error_msg)
  675. return cancelled_orders, error_msg
  676. else:
  677. logger.info(f"Successfully cancelled all {len(cancelled_orders)} orders for {symbol}")
  678. return cancelled_orders, None
  679. except Exception as e:
  680. error_msg = f"Error cancelling orders for {symbol}: {str(e)}"
  681. logger.error(error_msg, exc_info=True)
  682. return [], error_msg
  683. # Alias methods for consistency with command handlers
  684. async def execute_sl_order(self, token: str, stop_price: float) -> Dict[str, Any]:
  685. """Alias for execute_stop_loss_order."""
  686. return await self.execute_stop_loss_order(token, stop_price)
  687. async def execute_tp_order(self, token: str, profit_price: float) -> Dict[str, Any]:
  688. """Alias for execute_take_profit_order."""
  689. return await self.execute_take_profit_order(token, profit_price)
  690. async def execute_coo_order(self, token: str) -> Dict[str, Any]:
  691. """Cancel all orders for a token and format response like the old code expected."""
  692. try:
  693. symbol = f"{token}/USDC:USDC"
  694. # Call the synchronous cancel_all_orders method
  695. cancelled_orders, error_msg = self.cancel_all_orders(symbol)
  696. if error_msg:
  697. logger.error(f"Error cancelling all orders for {token}: {error_msg}")
  698. return {"success": False, "error": error_msg}
  699. if not cancelled_orders:
  700. logger.info(f"No orders found to cancel for {token}")
  701. return {
  702. "success": True,
  703. "message": f"No orders found for {token}",
  704. "cancelled_orders": [],
  705. "cancelled_count": 0,
  706. "failed_count": 0,
  707. "cancelled_linked_stop_losses": 0
  708. }
  709. # Get cleanup count from stats if available
  710. cleanup_count = 0
  711. if self.stats:
  712. cleanup_count = self.stats.cancel_pending_stop_losses_by_symbol(
  713. symbol,
  714. 'cancelled_manual_exit'
  715. )
  716. logger.info(f"Successfully cancelled {len(cancelled_orders)} orders for {token}")
  717. return {
  718. "success": True,
  719. "message": f"Successfully cancelled {len(cancelled_orders)} orders for {token}",
  720. "cancelled_orders": cancelled_orders,
  721. "cancelled_count": len(cancelled_orders),
  722. "failed_count": 0, # Failed orders are handled in the error case above
  723. "cancelled_linked_stop_losses": cleanup_count
  724. }
  725. except Exception as e:
  726. logger.error(f"Error in execute_coo_order for {token}: {e}")
  727. return {"success": False, "error": str(e)}
  728. def is_bot_trade(self, exchange_order_id: str) -> bool:
  729. """Check if an order (by its exchange ID) was recorded by this bot in the orders table."""
  730. if not self.stats:
  731. return False
  732. order_data = self.stats.get_order_by_exchange_id(exchange_order_id)
  733. return order_data is not None # If found, it was a bot-managed order
  734. def get_stats(self) -> TradingStats:
  735. """Get trading statistics object."""
  736. return self.stats
  737. async def execute_triggered_stop_order(self, original_trigger_order_db_id: int) -> Dict[str, Any]:
  738. """Executes an actual stop order on the exchange after its trigger condition was met."""
  739. if not self.stats:
  740. return {"success": False, "error": "TradingStats not available."}
  741. trigger_order_details = self.stats.get_order_by_db_id(original_trigger_order_db_id)
  742. if not trigger_order_details:
  743. return {"success": False, "error": f"Original trigger order DB ID {original_trigger_order_db_id} not found."}
  744. logger.info(f"Executing triggered stop order based on original trigger DB ID: {original_trigger_order_db_id}, details: {trigger_order_details}")
  745. symbol = trigger_order_details.get('symbol')
  746. # Side of the actual SL order to be placed (e.g., if trigger was 'sell', actual order is 'sell')
  747. sl_order_side = trigger_order_details.get('side')
  748. amount = trigger_order_details.get('amount_requested')
  749. stop_price = trigger_order_details.get('price') # This was the trigger price
  750. parent_bot_ref_id_of_trigger = trigger_order_details.get('bot_order_ref_id') # The ref ID of the trigger order itself
  751. if not all([symbol, sl_order_side, amount, stop_price]):
  752. msg = f"Missing critical details from trigger order DB ID {original_trigger_order_db_id} to place actual SL order."
  753. logger.error(msg)
  754. return {"success": False, "error": msg}
  755. # 🧠 SMART STOP LOSS LOGIC: Check if price has moved beyond stop loss
  756. # Get current market price to determine order type
  757. current_price = None
  758. try:
  759. market_data = self.get_market_data(symbol)
  760. if market_data and market_data.get('ticker'):
  761. current_price = float(market_data['ticker'].get('last', 0))
  762. except Exception as price_error:
  763. logger.warning(f"Could not fetch current price for {symbol}: {price_error}")
  764. # Determine if we need market order (price moved beyond stop) or limit order (normal case)
  765. use_market_order = False
  766. order_type_for_actual_sl = 'limit' # Default to limit
  767. order_price = stop_price # Default to stop price
  768. if current_price and current_price > 0:
  769. if sl_order_side.lower() == 'buy':
  770. # SHORT position stop loss (BUY to close)
  771. # If current price > stop price, use market order (price moved beyond stop)
  772. if current_price > stop_price:
  773. use_market_order = True
  774. logger.warning(f"🚨 SHORT SL: Price ${current_price:.4f} > Stop ${stop_price:.4f} - Using MARKET order for immediate execution")
  775. else:
  776. logger.info(f"📊 SHORT SL: Price ${current_price:.4f} ≤ Stop ${stop_price:.4f} - Using LIMIT order at stop price")
  777. elif sl_order_side.lower() == 'sell':
  778. # LONG position stop loss (SELL to close)
  779. # If current price < stop price, use market order (price moved beyond stop)
  780. if current_price < stop_price:
  781. use_market_order = True
  782. logger.warning(f"🚨 LONG SL: Price ${current_price:.4f} < Stop ${stop_price:.4f} - Using MARKET order for immediate execution")
  783. else:
  784. logger.info(f"📊 LONG SL: Price ${current_price:.4f} ≥ Stop ${stop_price:.4f} - Using LIMIT order at stop price")
  785. if use_market_order:
  786. order_type_for_actual_sl = 'market'
  787. order_price = None # Market orders don't have a specific price
  788. # 1. Generate a new bot_order_ref_id for this actual SL order
  789. actual_sl_bot_order_ref_id = uuid.uuid4().hex
  790. # We can link this actual SL order back to the trigger order that spawned it.
  791. actual_sl_order_db_id = self.stats.record_order_placed(
  792. symbol=symbol,
  793. side=sl_order_side,
  794. order_type=order_type_for_actual_sl,
  795. amount_requested=amount,
  796. price=order_price, # None for market, stop_price for limit
  797. bot_order_ref_id=actual_sl_bot_order_ref_id,
  798. status='pending_submission',
  799. parent_bot_order_ref_id=parent_bot_ref_id_of_trigger # Linking actual SL to its trigger order
  800. )
  801. if not actual_sl_order_db_id:
  802. 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}"
  803. logger.error(msg)
  804. return {"success": False, "error": msg}
  805. # 2. Place the actual SL order on the exchange
  806. if use_market_order:
  807. 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}")
  808. exchange_order_data, error_msg = self.client.place_market_order(symbol, sl_order_side, amount)
  809. else:
  810. 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}")
  811. exchange_order_data, error_msg = self.client.place_limit_order(symbol, sl_order_side, amount, stop_price)
  812. if error_msg:
  813. order_type_desc = "market" if use_market_order else "limit"
  814. logger.error(f"Actual SL {order_type_desc} order placement failed for {symbol} (BotRef {actual_sl_bot_order_ref_id}): {error_msg}")
  815. 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)
  816. return {"success": False, "error": f"Actual SL {order_type_desc} order placement failed: {error_msg}"}
  817. if not exchange_order_data:
  818. order_type_desc = "market" if use_market_order else "limit"
  819. 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.")
  820. 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)
  821. return {"success": False, "error": f"Actual SL {order_type_desc} order placement failed at client level."}
  822. exchange_oid = exchange_order_data.get('id')
  823. # 3. Update the actual SL order in DB with exchange_order_id and appropriate status
  824. if exchange_oid:
  825. # Market orders are 'submitted' until filled, limit orders are 'open' until triggered/filled
  826. new_status = 'submitted' if use_market_order else 'open'
  827. self.stats.update_order_status(
  828. order_db_id=actual_sl_order_db_id,
  829. new_status=new_status,
  830. set_exchange_order_id=exchange_oid
  831. )
  832. else:
  833. order_type_desc = "market" if use_market_order else "limit"
  834. logger.warning(f"No exchange_order_id received for actual SL {order_type_desc} order (BotRef {actual_sl_bot_order_ref_id}).")
  835. success_message = f"Actual Stop Loss {'MARKET' if use_market_order else 'LIMIT'} order placed successfully"
  836. if use_market_order:
  837. success_message += " for immediate execution (price moved beyond stop level)."
  838. else:
  839. success_message += f" at ${stop_price}."
  840. return {
  841. "success": True,
  842. "message": success_message,
  843. "placed_sl_order_details": {
  844. "bot_order_ref_id": actual_sl_bot_order_ref_id,
  845. "exchange_order_id": exchange_oid,
  846. "order_db_id": actual_sl_order_db_id,
  847. "symbol": symbol,
  848. "side": sl_order_side,
  849. "type": order_type_for_actual_sl,
  850. "amount_requested": amount,
  851. "price_requested": order_price,
  852. "original_trigger_price": stop_price,
  853. "current_market_price": current_price,
  854. "used_market_order": use_market_order
  855. }
  856. }