trading_engine.py 56 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029
  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 cached exchange position data
  116. try:
  117. positions = self.get_positions() # Use cached positions method instead of direct client call
  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. "trade_lifecycle_id": lifecycle_id if 'lifecycle_id' in locals() else None # Return lifecycle_id
  276. }
  277. except ZeroDivisionError as e:
  278. 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'}")
  279. return {"success": False, "error": f"Math error (division by zero), check prices: {e}"}
  280. except Exception as e:
  281. logger.error(f"Error executing long order: {e}", exc_info=True)
  282. return {"success": False, "error": str(e)}
  283. async def execute_short_order(self, token: str, usdc_amount: float,
  284. limit_price_arg: Optional[float] = None,
  285. stop_loss_price: Optional[float] = None) -> Dict[str, Any]:
  286. symbol = f"{token}/USDC:USDC"
  287. try:
  288. if usdc_amount <= 0:
  289. return {"success": False, "error": "Invalid USDC amount"}
  290. market_data = self.get_market_data(symbol)
  291. if not market_data or not market_data.get('ticker'):
  292. return {"success": False, "error": f"Could not fetch market data for {token}"}
  293. current_price = float(market_data['ticker'].get('last', 0.0) or 0.0)
  294. if current_price <= 0:
  295. if not (limit_price_arg and limit_price_arg > 0):
  296. return {"success": False, "error": f"Invalid current price ({current_price}) for {token} and no valid limit price provided."}
  297. order_type_for_stats = 'limit' if limit_price_arg is not None else 'market'
  298. order_placement_price: float
  299. token_amount: float
  300. if limit_price_arg is not None:
  301. if limit_price_arg <= 0:
  302. return {"success": False, "error": "Limit price must be positive."}
  303. order_placement_price = limit_price_arg
  304. token_amount = usdc_amount / order_placement_price
  305. else: # Market order
  306. if current_price <= 0:
  307. return {"success": False, "error": f"Cannot place market order for {token} due to invalid current price: {current_price}"}
  308. order_placement_price = current_price
  309. token_amount = usdc_amount / order_placement_price
  310. # 1. Generate bot_order_ref_id and record order placement intent
  311. bot_order_ref_id = uuid.uuid4().hex
  312. order_db_id = self.stats.record_order_placed(
  313. symbol=symbol, side='sell', order_type=order_type_for_stats,
  314. amount_requested=token_amount, price=order_placement_price if order_type_for_stats == 'limit' else None,
  315. bot_order_ref_id=bot_order_ref_id, status='pending_submission'
  316. )
  317. if not order_db_id:
  318. logger.error(f"Failed to record order intent in DB for {symbol} with bot_ref_id {bot_order_ref_id}")
  319. return {"success": False, "error": "Failed to record order intent in database."}
  320. # 2. Place the order with the exchange
  321. if order_type_for_stats == 'limit':
  322. logger.info(f"Placing LIMIT SELL order ({bot_order_ref_id}) for {token_amount:.6f} {symbol} at ${order_placement_price:,.2f}")
  323. exchange_order_data, error_msg = self.client.place_limit_order(symbol, 'sell', token_amount, order_placement_price)
  324. else: # Market order
  325. logger.info(f"Placing MARKET SELL order ({bot_order_ref_id}) for {token_amount:.6f} {symbol} (approx. price ${order_placement_price:,.2f})")
  326. exchange_order_data, error_msg = self.client.place_market_order(symbol, 'sell', token_amount)
  327. if error_msg:
  328. logger.error(f"Order placement failed for {symbol} ({bot_order_ref_id}): {error_msg}")
  329. self.stats.update_order_status(order_db_id=order_db_id, new_status='failed_submission', bot_order_ref_id=bot_order_ref_id)
  330. return {"success": False, "error": f"Order placement failed: {error_msg}"}
  331. if not exchange_order_data:
  332. logger.error(f"Order placement call failed for {symbol} ({bot_order_ref_id}). Client returned no data and no error.")
  333. 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)
  334. return {"success": False, "error": "Order placement failed at client level (no order object or error)."}
  335. exchange_oid = exchange_order_data.get('id')
  336. # 3. Update order in DB with exchange_order_id and status
  337. if exchange_oid:
  338. new_status_after_placement = 'open' # Default for limit, or submitted for market
  339. if order_type_for_stats == 'market':
  340. new_status_after_placement = 'submitted' # Market orders are submitted, fills come via monitor
  341. self.stats.update_order_status(
  342. order_db_id=order_db_id,
  343. new_status=new_status_after_placement,
  344. set_exchange_order_id=exchange_oid
  345. )
  346. else:
  347. logger.warning(f"No exchange_order_id received for order {order_db_id} ({bot_order_ref_id}).")
  348. # DO NOT record trade here. MarketMonitor will handle fills.
  349. # action_type = self.stats.record_trade_with_enhanced_tracking(...)
  350. if stop_loss_price and exchange_oid and exchange_oid != 'N/A':
  351. # Record the pending SL order in the orders table
  352. sl_bot_order_ref_id = uuid.uuid4().hex
  353. sl_order_db_id = self.stats.record_order_placed(
  354. symbol=symbol,
  355. side='buy', # SL for a short is a buy
  356. order_type='STOP_LIMIT_TRIGGER',
  357. amount_requested=token_amount,
  358. price=stop_loss_price,
  359. bot_order_ref_id=sl_bot_order_ref_id,
  360. status='pending_trigger',
  361. parent_bot_order_ref_id=bot_order_ref_id # Link to the main sell order
  362. )
  363. if sl_order_db_id:
  364. 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}")
  365. else:
  366. logger.error(f"Failed to record pending SL order in DB for parent BotRef {bot_order_ref_id}")
  367. # 🆕 PHASE 4: Create trade lifecycle for this entry order
  368. if exchange_oid:
  369. entry_order_record = self.stats.get_order_by_exchange_id(exchange_oid)
  370. if entry_order_record:
  371. lifecycle_id = self.stats.create_trade_lifecycle(
  372. symbol=symbol,
  373. side='sell',
  374. entry_order_id=exchange_oid, # Store exchange order ID
  375. stop_loss_price=stop_loss_price,
  376. trade_type='bot'
  377. )
  378. if lifecycle_id and stop_loss_price:
  379. # Get the stop loss order that was just created
  380. sl_order_record = self.stats.get_order_by_bot_ref_id(sl_bot_order_ref_id)
  381. if sl_order_record:
  382. self.stats.link_stop_loss_to_trade(lifecycle_id, exchange_oid, stop_loss_price)
  383. logger.info(f"📊 Created trade lifecycle {lifecycle_id} with linked stop loss for SELL {symbol}")
  384. else:
  385. logger.info(f"📊 Created trade lifecycle {lifecycle_id} for SELL {symbol}")
  386. elif lifecycle_id:
  387. logger.info(f"📊 Created trade lifecycle {lifecycle_id} for SELL {symbol}")
  388. return {
  389. "success": True,
  390. "order_placed_details": {
  391. "bot_order_ref_id": bot_order_ref_id,
  392. "exchange_order_id": exchange_oid,
  393. "order_db_id": order_db_id,
  394. "symbol": symbol,
  395. "side": "sell",
  396. "type": order_type_for_stats,
  397. "amount_requested": token_amount,
  398. "price_requested": order_placement_price if order_type_for_stats == 'limit' else None
  399. },
  400. "token_amount": token_amount,
  401. "stop_loss_pending": stop_loss_price is not None,
  402. "trade_lifecycle_id": lifecycle_id if 'lifecycle_id' in locals() else None # Return lifecycle_id
  403. }
  404. except ZeroDivisionError as e:
  405. 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'}")
  406. return {"success": False, "error": f"Math error (division by zero), check prices: {e}"}
  407. except Exception as e:
  408. logger.error(f"Error executing short order: {e}", exc_info=True)
  409. return {"success": False, "error": str(e)}
  410. async def execute_exit_order(self, token: str) -> Dict[str, Any]:
  411. """Execute an exit order to close a position."""
  412. position = self.find_position(token)
  413. if not position:
  414. return {"success": False, "error": f"No open position found for {token}"}
  415. try:
  416. symbol = f"{token}/USDC:USDC"
  417. position_type, exit_side, contracts_to_close = self.get_position_direction(position)
  418. order_type_for_stats = 'market' # Exit orders are typically market
  419. # 1. Generate bot_order_ref_id and record order placement intent
  420. bot_order_ref_id = uuid.uuid4().hex
  421. # Price for a market order is not specified at placement for stats recording, will be determined by fill.
  422. order_db_id = self.stats.record_order_placed(
  423. symbol=symbol, side=exit_side, order_type=order_type_for_stats,
  424. amount_requested=contracts_to_close, price=None, # Market order, price determined by fill
  425. bot_order_ref_id=bot_order_ref_id, status='pending_submission'
  426. )
  427. if not order_db_id:
  428. logger.error(f"Failed to record exit order intent in DB for {symbol} with bot_ref_id {bot_order_ref_id}")
  429. return {"success": False, "error": "Failed to record exit order intent in database."}
  430. # 2. Execute market order to close position
  431. logger.info(f"Placing MARKET {exit_side.upper()} order ({bot_order_ref_id}) to close {contracts_to_close:.6f} {symbol}")
  432. exchange_order_data, error_msg = self.client.place_market_order(symbol, exit_side, contracts_to_close)
  433. if error_msg:
  434. logger.error(f"Exit order execution failed for {symbol} ({bot_order_ref_id}): {error_msg}")
  435. self.stats.update_order_status(order_db_id=order_db_id, new_status='failed_submission', bot_order_ref_id=bot_order_ref_id)
  436. return {"success": False, "error": f"Exit order execution failed: {error_msg}"}
  437. if not exchange_order_data:
  438. logger.error(f"Exit order execution call failed for {symbol} ({bot_order_ref_id}). Client returned no data and no error.")
  439. 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)
  440. return {"success": False, "error": "Exit order execution failed (no order object or error)."}
  441. exchange_oid = exchange_order_data.get('id')
  442. # 3. Update order in DB with exchange_order_id and status
  443. if exchange_oid:
  444. # Market orders are submitted; MarketMonitor will confirm fills.
  445. self.stats.update_order_status(
  446. order_db_id=order_db_id,
  447. new_status='submitted',
  448. set_exchange_order_id=exchange_oid
  449. )
  450. else:
  451. logger.warning(f"No exchange_order_id received for exit order {order_db_id} ({bot_order_ref_id}).")
  452. # DO NOT record trade here. MarketMonitor will handle fills.
  453. # The old code below is removed:
  454. # order_id = order_data.get('id', 'N/A')
  455. # actual_price = order_data.get('average', 0)
  456. # ... logic for actual_price fallback ...
  457. # if order_id != 'N/A':
  458. # self.bot_trade_ids.add(order_id)
  459. # action_type = self.stats.record_trade_with_enhanced_tracking(...)
  460. # Cancel any pending stop losses for this symbol since position will be closed
  461. if self.stats:
  462. cancelled_sl_count = self.stats.cancel_pending_stop_losses_by_symbol(
  463. symbol=symbol,
  464. new_status='cancelled_manual_exit'
  465. )
  466. if cancelled_sl_count > 0:
  467. logger.info(f"🛑 Cancelled {cancelled_sl_count} pending stop losses for {symbol} due to manual exit order")
  468. # NOTE: Exit orders do not create new trade cycles - they close existing ones
  469. # The MarketMonitor will handle closing the trade cycle when the exit order fills
  470. # Fetch the lifecycle ID of the position being closed
  471. lifecycle_id_to_close = None
  472. if self.stats:
  473. active_trade_lc = self.stats.get_trade_by_symbol_and_status(symbol, 'position_opened')
  474. if active_trade_lc:
  475. lifecycle_id_to_close = active_trade_lc.get('trade_lifecycle_id')
  476. return {
  477. "success": True,
  478. "order_placed_details": {
  479. "bot_order_ref_id": bot_order_ref_id,
  480. "exchange_order_id": exchange_oid,
  481. "order_db_id": order_db_id,
  482. "symbol": symbol,
  483. "side": exit_side,
  484. "type": order_type_for_stats,
  485. "amount_requested": contracts_to_close
  486. },
  487. "position_type_closed": position_type, # Info about the position it intends to close
  488. "contracts_intended_to_close": contracts_to_close,
  489. "cancelled_stop_losses": cancelled_sl_count if self.stats else 0,
  490. "trade_lifecycle_id": lifecycle_id_to_close # Return lifecycle_id of the closed position
  491. }
  492. except Exception as e:
  493. logger.error(f"Error executing exit order: {e}")
  494. return {"success": False, "error": str(e)}
  495. async def execute_stop_loss_order(self, token: str, stop_price: float) -> Dict[str, Any]:
  496. """Execute a stop loss order."""
  497. position = self.find_position(token)
  498. if not position:
  499. return {"success": False, "error": f"No open position found for {token}"}
  500. try:
  501. symbol = f"{token}/USDC:USDC"
  502. position_type, exit_side, contracts = self.get_position_direction(position)
  503. entry_price = float(position.get('entryPx', 0))
  504. # Validate stop loss price based on position direction
  505. if position_type == "LONG" and stop_price >= entry_price:
  506. return {"success": False, "error": "Stop loss price should be below entry price for long positions"}
  507. elif position_type == "SHORT" and stop_price <= entry_price:
  508. return {"success": False, "error": "Stop loss price should be above entry price for short positions"}
  509. order_type_for_stats = 'limit' # Stop loss is a limit order at stop_price
  510. # 1. Generate bot_order_ref_id and record order placement intent
  511. bot_order_ref_id = uuid.uuid4().hex
  512. order_db_id = self.stats.record_order_placed(
  513. symbol=symbol, side=exit_side, order_type=order_type_for_stats,
  514. amount_requested=contracts, price=stop_price,
  515. bot_order_ref_id=bot_order_ref_id, status='pending_submission'
  516. )
  517. if not order_db_id:
  518. logger.error(f"Failed to record SL order intent in DB for {symbol} with bot_ref_id {bot_order_ref_id}")
  519. return {"success": False, "error": "Failed to record SL order intent in database."}
  520. # 2. Place limit order at stop loss price
  521. logger.info(f"Placing STOP LOSS (LIMIT {exit_side.upper()}) order ({bot_order_ref_id}) for {contracts:.6f} {symbol} at ${stop_price:,.2f}")
  522. exchange_order_data, error_msg = self.client.place_limit_order(symbol, exit_side, contracts, stop_price)
  523. if error_msg:
  524. logger.error(f"Stop loss order placement failed for {symbol} ({bot_order_ref_id}): {error_msg}")
  525. self.stats.update_order_status(order_db_id=order_db_id, new_status='failed_submission', bot_order_ref_id=bot_order_ref_id)
  526. return {"success": False, "error": f"Stop loss order placement failed: {error_msg}"}
  527. if not exchange_order_data:
  528. logger.error(f"Stop loss order placement call failed for {symbol} ({bot_order_ref_id}). Client returned no data and no error.")
  529. 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)
  530. return {"success": False, "error": "Stop loss order placement failed (no order object or error)."}
  531. exchange_oid = exchange_order_data.get('id')
  532. # 3. Update order in DB with exchange_order_id and status
  533. if exchange_oid:
  534. self.stats.update_order_status(
  535. order_db_id=order_db_id,
  536. new_status='open', # SL/TP limit orders are 'open' until triggered/filled
  537. set_exchange_order_id=exchange_oid
  538. )
  539. else:
  540. logger.warning(f"No exchange_order_id received for SL order {order_db_id} ({bot_order_ref_id}).")
  541. # NOTE: Stop loss orders are protective orders for existing positions
  542. # They do not create new trade cycles - they protect existing trade cycles
  543. # Fetch the lifecycle_id for the current position
  544. lifecycle_id = None
  545. if self.stats:
  546. active_trade_lc = self.stats.get_trade_by_symbol_and_status(symbol, 'position_opened')
  547. if active_trade_lc:
  548. lifecycle_id = active_trade_lc.get('trade_lifecycle_id')
  549. if exchange_oid: # If SL order placed successfully on exchange
  550. self.stats.link_stop_loss_to_trade(lifecycle_id, exchange_oid, stop_price)
  551. logger.info(f"🛡️ Linked SL order {exchange_oid} to lifecycle {lifecycle_id} for {symbol}")
  552. return {
  553. "success": True,
  554. "order_placed_details": {
  555. "bot_order_ref_id": bot_order_ref_id,
  556. "exchange_order_id": exchange_oid,
  557. "order_db_id": order_db_id,
  558. "symbol": symbol,
  559. "side": exit_side,
  560. "type": order_type_for_stats,
  561. "amount_requested": contracts,
  562. "price_requested": stop_price
  563. },
  564. "position_type_for_sl": position_type, # Info about the position it's protecting
  565. "contracts_for_sl": contracts,
  566. "stop_price_set": stop_price,
  567. "trade_lifecycle_id": lifecycle_id # Return lifecycle_id
  568. }
  569. except Exception as e:
  570. logger.error(f"Error executing stop loss order: {e}")
  571. return {"success": False, "error": str(e)}
  572. async def execute_take_profit_order(self, token: str, profit_price: float) -> Dict[str, Any]:
  573. """Execute a take profit order."""
  574. position = self.find_position(token)
  575. if not position:
  576. return {"success": False, "error": f"No open position found for {token}"}
  577. try:
  578. symbol = f"{token}/USDC:USDC"
  579. position_type, exit_side, contracts = self.get_position_direction(position)
  580. entry_price = float(position.get('entryPx', 0))
  581. # Validate take profit price based on position direction
  582. if position_type == "LONG" and profit_price <= entry_price:
  583. return {"success": False, "error": "Take profit price should be above entry price for long positions"}
  584. elif position_type == "SHORT" and profit_price >= entry_price:
  585. return {"success": False, "error": "Take profit price should be below entry price for short positions"}
  586. order_type_for_stats = 'limit' # Take profit is a limit order at profit_price
  587. # 1. Generate bot_order_ref_id and record order placement intent
  588. bot_order_ref_id = uuid.uuid4().hex
  589. order_db_id = self.stats.record_order_placed(
  590. symbol=symbol, side=exit_side, order_type=order_type_for_stats,
  591. amount_requested=contracts, price=profit_price,
  592. bot_order_ref_id=bot_order_ref_id, status='pending_submission'
  593. )
  594. if not order_db_id:
  595. logger.error(f"Failed to record TP order intent in DB for {symbol} with bot_ref_id {bot_order_ref_id}")
  596. return {"success": False, "error": "Failed to record TP order intent in database."}
  597. # 2. Place limit order at take profit price
  598. logger.info(f"Placing TAKE PROFIT (LIMIT {exit_side.upper()}) order ({bot_order_ref_id}) for {contracts:.6f} {symbol} at ${profit_price:,.2f}")
  599. exchange_order_data, error_msg = self.client.place_limit_order(symbol, exit_side, contracts, profit_price)
  600. if error_msg:
  601. logger.error(f"Take profit order placement failed for {symbol} ({bot_order_ref_id}): {error_msg}")
  602. self.stats.update_order_status(order_db_id=order_db_id, new_status='failed_submission', bot_order_ref_id=bot_order_ref_id)
  603. return {"success": False, "error": f"Take profit order placement failed: {error_msg}"}
  604. if not exchange_order_data:
  605. logger.error(f"Take profit order placement call failed for {symbol} ({bot_order_ref_id}). Client returned no data and no error.")
  606. 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)
  607. return {"success": False, "error": "Take profit order placement failed (no order object or error)."}
  608. exchange_oid = exchange_order_data.get('id')
  609. # 3. Update order in DB with exchange_order_id and status
  610. if exchange_oid:
  611. self.stats.update_order_status(
  612. order_db_id=order_db_id,
  613. new_status='open', # SL/TP limit orders are 'open' until triggered/filled
  614. set_exchange_order_id=exchange_oid
  615. )
  616. else:
  617. logger.warning(f"No exchange_order_id received for TP order {order_db_id} ({bot_order_ref_id}).")
  618. # NOTE: Take profit orders are protective orders for existing positions
  619. # They do not create new trade cycles - they protect existing trade cycles
  620. # Fetch the lifecycle_id for the current position
  621. lifecycle_id = None
  622. if self.stats:
  623. active_trade_lc = self.stats.get_trade_by_symbol_and_status(symbol, 'position_opened')
  624. if active_trade_lc:
  625. lifecycle_id = active_trade_lc.get('trade_lifecycle_id')
  626. if exchange_oid: # If TP order placed successfully on exchange
  627. self.stats.link_take_profit_to_trade(lifecycle_id, exchange_oid, profit_price)
  628. logger.info(f"🎯 Linked TP order {exchange_oid} to lifecycle {lifecycle_id} for {symbol}")
  629. return {
  630. "success": True,
  631. "order_placed_details": {
  632. "bot_order_ref_id": bot_order_ref_id,
  633. "exchange_order_id": exchange_oid,
  634. "order_db_id": order_db_id,
  635. "symbol": symbol,
  636. "side": exit_side,
  637. "type": order_type_for_stats,
  638. "amount_requested": contracts,
  639. "price_requested": profit_price
  640. },
  641. "position_type_for_tp": position_type, # Info about the position it's for
  642. "contracts_for_tp": contracts,
  643. "profit_price_set": profit_price,
  644. "trade_lifecycle_id": lifecycle_id # Return lifecycle_id
  645. }
  646. except Exception as e:
  647. logger.error(f"Error executing take profit order: {e}")
  648. return {"success": False, "error": str(e)}
  649. def cancel_all_orders(self, symbol: str) -> Tuple[List[Dict[str, Any]], Optional[str]]:
  650. """Cancel all open orders for a specific symbol. Returns (cancelled_orders, error_message)."""
  651. try:
  652. logger.info(f"Attempting to cancel all orders for {symbol}")
  653. # Get all open orders
  654. all_orders = self.client.get_open_orders()
  655. if all_orders is None:
  656. error_msg = f"Could not fetch orders to cancel {symbol} orders"
  657. logger.error(error_msg)
  658. return [], error_msg
  659. # Filter orders for the specific symbol
  660. symbol_orders = [order for order in all_orders if order.get('symbol') == symbol]
  661. if not symbol_orders:
  662. logger.info(f"No open orders found for {symbol}")
  663. return [], None # No error, just no orders to cancel
  664. # Cancel each order individually
  665. cancelled_orders = []
  666. failed_orders = []
  667. for order in symbol_orders:
  668. order_id = order.get('id')
  669. if order_id:
  670. try:
  671. success = self.client.cancel_order(order_id, symbol)
  672. if success:
  673. cancelled_orders.append(order)
  674. logger.info(f"Successfully cancelled order {order_id} for {symbol}")
  675. else:
  676. failed_orders.append(order)
  677. logger.warning(f"Failed to cancel order {order_id} for {symbol}")
  678. except Exception as e:
  679. logger.error(f"Exception cancelling order {order_id}: {e}")
  680. failed_orders.append(order)
  681. # Update order status in database if we have stats
  682. if self.stats:
  683. for order in cancelled_orders:
  684. order_id = order.get('id')
  685. if order_id:
  686. # Try to find this order in our database and update its status
  687. db_order = self.stats.get_order_by_exchange_id(order_id)
  688. if db_order:
  689. self.stats.update_order_status(
  690. exchange_order_id=order_id,
  691. new_status='cancelled_manually'
  692. )
  693. # Cancel any linked pending stop losses for this symbol
  694. cleanup_count = self.stats.cancel_pending_stop_losses_by_symbol(
  695. symbol,
  696. 'cancelled_manual_exit'
  697. )
  698. if cleanup_count > 0:
  699. logger.info(f"Cleaned up {cleanup_count} pending stop losses for {symbol}")
  700. # Prepare result
  701. if failed_orders:
  702. error_msg = f"Cancelled {len(cancelled_orders)}/{len(symbol_orders)} orders. {len(failed_orders)} failed."
  703. logger.warning(error_msg)
  704. return cancelled_orders, error_msg
  705. else:
  706. logger.info(f"Successfully cancelled all {len(cancelled_orders)} orders for {symbol}")
  707. return cancelled_orders, None
  708. except Exception as e:
  709. error_msg = f"Error cancelling orders for {symbol}: {str(e)}"
  710. logger.error(error_msg, exc_info=True)
  711. return [], error_msg
  712. # Alias methods for consistency with command handlers
  713. async def execute_sl_order(self, token: str, stop_price: float) -> Dict[str, Any]:
  714. """Alias for execute_stop_loss_order."""
  715. return await self.execute_stop_loss_order(token, stop_price)
  716. async def execute_tp_order(self, token: str, profit_price: float) -> Dict[str, Any]:
  717. """Alias for execute_take_profit_order."""
  718. return await self.execute_take_profit_order(token, profit_price)
  719. async def execute_coo_order(self, token: str) -> Dict[str, Any]:
  720. """Cancel all orders for a token and format response like the old code expected."""
  721. try:
  722. symbol = f"{token}/USDC:USDC"
  723. # Call the synchronous cancel_all_orders method
  724. cancelled_orders, error_msg = self.cancel_all_orders(symbol)
  725. if error_msg:
  726. logger.error(f"Error cancelling all orders for {token}: {error_msg}")
  727. return {"success": False, "error": error_msg}
  728. if not cancelled_orders:
  729. logger.info(f"No orders found to cancel for {token}")
  730. return {
  731. "success": True,
  732. "message": f"No orders found for {token}",
  733. "cancelled_orders": [],
  734. "cancelled_count": 0,
  735. "failed_count": 0,
  736. "cancelled_linked_stop_losses": 0
  737. }
  738. # Get cleanup count from stats if available
  739. cleanup_count = 0
  740. if self.stats:
  741. cleanup_count = self.stats.cancel_pending_stop_losses_by_symbol(
  742. symbol,
  743. 'cancelled_manual_exit'
  744. )
  745. logger.info(f"Successfully cancelled {len(cancelled_orders)} orders for {token}")
  746. return {
  747. "success": True,
  748. "message": f"Successfully cancelled {len(cancelled_orders)} orders for {token}",
  749. "cancelled_orders": cancelled_orders,
  750. "cancelled_count": len(cancelled_orders),
  751. "failed_count": 0, # Failed orders are handled in the error case above
  752. "cancelled_linked_stop_losses": cleanup_count
  753. }
  754. except Exception as e:
  755. logger.error(f"Error in execute_coo_order for {token}: {e}")
  756. return {"success": False, "error": str(e)}
  757. def is_bot_trade(self, exchange_order_id: str) -> bool:
  758. """Check if an order (by its exchange ID) was recorded by this bot in the orders table."""
  759. if not self.stats:
  760. return False
  761. order_data = self.stats.get_order_by_exchange_id(exchange_order_id)
  762. return order_data is not None # If found, it was a bot-managed order
  763. def get_stats(self) -> TradingStats:
  764. """Get trading statistics object."""
  765. return self.stats
  766. async def execute_triggered_stop_order(self, original_trigger_order_db_id: int) -> Dict[str, Any]:
  767. """Executes an actual stop order on the exchange after its trigger condition was met."""
  768. if not self.stats:
  769. return {"success": False, "error": "TradingStats not available."}
  770. trigger_order_details = self.stats.get_order_by_db_id(original_trigger_order_db_id)
  771. if not trigger_order_details:
  772. return {"success": False, "error": f"Original trigger order DB ID {original_trigger_order_db_id} not found."}
  773. logger.info(f"Executing triggered stop order based on original trigger DB ID: {original_trigger_order_db_id}, details: {trigger_order_details}")
  774. symbol = trigger_order_details.get('symbol')
  775. # Side of the actual SL order to be placed (e.g., if trigger was 'sell', actual order is 'sell')
  776. sl_order_side = trigger_order_details.get('side')
  777. amount = trigger_order_details.get('amount_requested')
  778. stop_price = trigger_order_details.get('price') # This was the trigger price
  779. parent_bot_ref_id_of_trigger = trigger_order_details.get('bot_order_ref_id') # The ref ID of the trigger order itself
  780. if not all([symbol, sl_order_side, amount, stop_price]):
  781. msg = f"Missing critical details from trigger order DB ID {original_trigger_order_db_id} to place actual SL order."
  782. logger.error(msg)
  783. return {"success": False, "error": msg}
  784. # 🧠 SMART STOP LOSS LOGIC: Check if price has moved beyond stop loss
  785. # Get current market price to determine order type
  786. current_price = None
  787. try:
  788. market_data = self.get_market_data(symbol)
  789. if market_data and market_data.get('ticker'):
  790. current_price = float(market_data['ticker'].get('last', 0))
  791. except Exception as price_error:
  792. logger.warning(f"Could not fetch current price for {symbol}: {price_error}")
  793. # Determine if we need market order (price moved beyond stop) or limit order (normal case)
  794. use_market_order = False
  795. order_type_for_actual_sl = 'limit' # Default to limit
  796. order_price = stop_price # Default to stop price
  797. if current_price and current_price > 0:
  798. if sl_order_side.lower() == 'buy':
  799. # SHORT position stop loss (BUY to close)
  800. # If current price > stop price, use market order (price moved beyond stop)
  801. if current_price > stop_price:
  802. use_market_order = True
  803. logger.warning(f"🚨 SHORT SL: Price ${current_price:.4f} > Stop ${stop_price:.4f} - Using MARKET order for immediate execution")
  804. else:
  805. logger.info(f"📊 SHORT SL: Price ${current_price:.4f} ≤ Stop ${stop_price:.4f} - Using LIMIT order at stop price")
  806. elif sl_order_side.lower() == 'sell':
  807. # LONG position stop loss (SELL to close)
  808. # If current price < stop price, use market order (price moved beyond stop)
  809. if current_price < stop_price:
  810. use_market_order = True
  811. logger.warning(f"🚨 LONG SL: Price ${current_price:.4f} < Stop ${stop_price:.4f} - Using MARKET order for immediate execution")
  812. else:
  813. logger.info(f"📊 LONG SL: Price ${current_price:.4f} ≥ Stop ${stop_price:.4f} - Using LIMIT order at stop price")
  814. if use_market_order:
  815. order_type_for_actual_sl = 'market'
  816. order_price = None # Market orders don't have a specific price
  817. # 1. Generate a new bot_order_ref_id for this actual SL order
  818. actual_sl_bot_order_ref_id = uuid.uuid4().hex
  819. # We can link this actual SL order back to the trigger order that spawned it.
  820. actual_sl_order_db_id = self.stats.record_order_placed(
  821. symbol=symbol,
  822. side=sl_order_side,
  823. order_type=order_type_for_actual_sl,
  824. amount_requested=amount,
  825. price=order_price, # None for market, stop_price for limit
  826. bot_order_ref_id=actual_sl_bot_order_ref_id,
  827. status='pending_submission',
  828. parent_bot_order_ref_id=parent_bot_ref_id_of_trigger # Linking actual SL to its trigger order
  829. )
  830. if not actual_sl_order_db_id:
  831. 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}"
  832. logger.error(msg)
  833. return {"success": False, "error": msg}
  834. # 2. Place the actual SL order on the exchange
  835. if use_market_order:
  836. 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}")
  837. exchange_order_data, error_msg = self.client.place_market_order(symbol, sl_order_side, amount)
  838. else:
  839. 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}")
  840. exchange_order_data, error_msg = self.client.place_limit_order(symbol, sl_order_side, amount, stop_price)
  841. if error_msg:
  842. order_type_desc = "market" if use_market_order else "limit"
  843. logger.error(f"Actual SL {order_type_desc} order placement failed for {symbol} (BotRef {actual_sl_bot_order_ref_id}): {error_msg}")
  844. 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)
  845. return {"success": False, "error": f"Actual SL {order_type_desc} order placement failed: {error_msg}"}
  846. if not exchange_order_data:
  847. order_type_desc = "market" if use_market_order else "limit"
  848. 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.")
  849. 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)
  850. return {"success": False, "error": f"Actual SL {order_type_desc} order placement failed at client level."}
  851. exchange_oid = exchange_order_data.get('id')
  852. # 3. Update the actual SL order in DB with exchange_order_id and appropriate status
  853. if exchange_oid:
  854. # Market orders are 'submitted' until filled, limit orders are 'open' until triggered/filled
  855. new_status = 'submitted' if use_market_order else 'open'
  856. self.stats.update_order_status(
  857. order_db_id=actual_sl_order_db_id,
  858. new_status=new_status,
  859. set_exchange_order_id=exchange_oid
  860. )
  861. else:
  862. order_type_desc = "market" if use_market_order else "limit"
  863. logger.warning(f"No exchange_order_id received for actual SL {order_type_desc} order (BotRef {actual_sl_bot_order_ref_id}).")
  864. success_message = f"Actual Stop Loss {'MARKET' if use_market_order else 'LIMIT'} order placed successfully"
  865. if use_market_order:
  866. success_message += " for immediate execution (price moved beyond stop level)."
  867. else:
  868. success_message += f" at ${stop_price}."
  869. return {
  870. "success": True,
  871. "message": success_message,
  872. "placed_sl_order_details": {
  873. "bot_order_ref_id": actual_sl_bot_order_ref_id,
  874. "exchange_order_id": exchange_oid,
  875. "order_db_id": actual_sl_order_db_id,
  876. "symbol": symbol,
  877. "side": sl_order_side,
  878. "type": order_type_for_actual_sl,
  879. "amount_requested": amount,
  880. "price_requested": order_price,
  881. "original_trigger_price": stop_price,
  882. "current_market_price": current_price,
  883. "used_market_order": use_market_order
  884. }
  885. }