trading_engine.py 68 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208
  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, timezone
  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.stats import TradingStats
  14. from src.utils.token_display_formatter import set_global_trading_engine, get_formatter
  15. from telegram.ext import CallbackContext
  16. logger = logging.getLogger(__name__)
  17. class TradingEngine:
  18. """Handles all trading operations, order execution, and position tracking."""
  19. def __init__(self):
  20. """Initialize the trading engine."""
  21. self.client = HyperliquidClient()
  22. self.stats: Optional[TradingStats] = None
  23. self.market_monitor = None # Will be set by the main bot
  24. # State persistence (Removed - state is now in DB)
  25. # self.state_file = "data/trading_engine_state.json"
  26. # Position and order tracking (All main state moved to DB via TradingStats)
  27. # Initialize stats object, but don't do async setup here
  28. self._initialize_stats_sync()
  29. # Initialize price formatter with this trading engine
  30. set_global_trading_engine(self)
  31. def _initialize_stats_sync(self):
  32. """Initialize trading statistics synchronously."""
  33. try:
  34. self.stats = TradingStats()
  35. except Exception as e:
  36. logger.error(f"Could not initialize TradingStats object: {e}")
  37. # The application can't run without stats, so we might want to raise
  38. raise
  39. async def async_init(self):
  40. """Asynchronously initialize the trading engine components."""
  41. logger.info("🚀 Starting asynchronous initialization of TradingEngine...")
  42. await self._initialize_stats_async()
  43. logger.info("✅ Asynchronous initialization of TradingEngine complete.")
  44. async def _initialize_stats_async(self):
  45. """Fetch initial data asynchronously after the loop has started."""
  46. if not self.stats:
  47. logger.error("TradingStats not initialized, cannot perform async init.")
  48. return
  49. try:
  50. # Set initial balance
  51. # NOTE: client.get_balance() is synchronous.
  52. balance = self.client.get_balance()
  53. if balance and balance.get('total'):
  54. usdc_balance = float(balance['total'].get('USDC', 0))
  55. # The set_initial_balance method is synchronous
  56. self.stats.set_initial_balance(usdc_balance)
  57. except Exception as e:
  58. logger.error(f"Could not set initial balance during async init: {e}", exc_info=True)
  59. def set_market_monitor(self, market_monitor):
  60. """Set the market monitor reference for accessing cached data."""
  61. self.market_monitor = market_monitor
  62. def get_balance(self) -> Optional[Dict[str, Any]]:
  63. """Get account balance (uses cached data when available)."""
  64. # Try cached data first (updated every heartbeat)
  65. if self.market_monitor and hasattr(self.market_monitor, 'get_cached_balance'):
  66. cached_balance = self.market_monitor.get_cached_balance()
  67. cache_age = self.market_monitor.get_cache_age_seconds()
  68. # Use cached data if it's fresh (less than 30 seconds old)
  69. if cached_balance and cache_age is not None and cache_age < 30:
  70. logger.debug(f"Using cached balance (age: {cache_age:.1f}s)")
  71. return cached_balance
  72. # Fallback to fresh API call
  73. logger.debug("Using fresh balance API call")
  74. return self.client.get_balance()
  75. def get_positions(self) -> Optional[List[Dict[str, Any]]]:
  76. """Get all positions (uses cached data when available)."""
  77. # Try cached data first (updated every heartbeat)
  78. if self.market_monitor and hasattr(self.market_monitor, 'get_cached_positions'):
  79. cached_positions = self.market_monitor.get_cached_positions()
  80. cache_age = self.market_monitor.get_cache_age_seconds()
  81. # Use cached data if it's fresh (less than 30 seconds old)
  82. if cached_positions is not None and cache_age is not None and cache_age < 30:
  83. logger.debug(f"Using cached positions (age: {cache_age:.1f}s): {len(cached_positions)} positions")
  84. return cached_positions
  85. # Fallback to fresh API call
  86. logger.debug("Using fresh positions API call")
  87. return self.client.get_positions()
  88. def get_orders(self) -> Optional[List[Dict[str, Any]]]:
  89. """Get all open orders (uses cached data when available)."""
  90. # Try cached data first (updated every heartbeat)
  91. if self.market_monitor and hasattr(self.market_monitor, 'get_cached_orders'):
  92. cached_orders = self.market_monitor.get_cached_orders()
  93. cache_age = self.market_monitor.get_cache_age_seconds()
  94. # Use cached data if it's fresh (less than 30 seconds old)
  95. if cached_orders is not None and cache_age is not None and cache_age < 30:
  96. logger.debug(f"Using cached orders (age: {cache_age:.1f}s): {len(cached_orders)} orders")
  97. return cached_orders
  98. # Fallback to fresh API call
  99. logger.debug("Using fresh orders API call")
  100. return self.client.get_open_orders()
  101. def get_recent_fills(self) -> Optional[List[Dict[str, Any]]]:
  102. """Get recent fills/trades."""
  103. return self.client.get_recent_fills()
  104. async def get_market_data(self, symbol: str) -> Optional[Dict[str, Any]]:
  105. """Get market data for a symbol."""
  106. return self.client.get_market_data(symbol)
  107. # 🆕 Method to get token precision info
  108. _markets_cache: Optional[List[Dict[str, Any]]] = None
  109. _markets_cache_timestamp: Optional[datetime] = None
  110. async def get_token_info(self, base_asset: str) -> Dict[str, Any]:
  111. """Fetch (and cache) market data to find precision for a given base_asset."""
  112. # Cache markets for 1 hour to avoid frequent API calls
  113. if self._markets_cache is None or \
  114. (self._markets_cache_timestamp and
  115. (datetime.now(timezone.utc) - self._markets_cache_timestamp).total_seconds() > 3600):
  116. try:
  117. logger.info("Fetching and caching markets for token info...")
  118. markets_data = await self.client.get_markets() # This returns a list of market dicts
  119. if markets_data:
  120. self._markets_cache = markets_data
  121. self._markets_cache_timestamp = datetime.now(timezone.utc)
  122. logger.info(f"Successfully cached {len(self._markets_cache)} markets.")
  123. else:
  124. logger.warning("get_markets() returned no data. Using empty cache.")
  125. self._markets_cache = {} # Set to empty dict to avoid re-fetching immediately
  126. self._markets_cache_timestamp = datetime.now(timezone.utc)
  127. except Exception as e:
  128. logger.error(f"Error fetching markets for token info: {e}. Will use defaults.")
  129. self._markets_cache = {} # Prevent re-fetching on immediate subsequent calls
  130. self._markets_cache_timestamp = datetime.now(timezone.utc)
  131. default_precision = {'amount': 6, 'price': 2} # Default if not found
  132. target_symbol_prefix = f"{base_asset.upper()}/"
  133. if self._markets_cache:
  134. # Assuming self._markets_cache is a Dict keyed by symbols,
  135. # and values are market detail dicts.
  136. for market_details_dict in self._markets_cache.values():
  137. symbol = market_details_dict.get('symbol')
  138. if symbol and symbol.upper().startswith(target_symbol_prefix):
  139. precision = market_details_dict.get('precision')
  140. if precision and isinstance(precision, dict) and \
  141. 'amount' in precision and 'price' in precision:
  142. logger.debug(f"Found precision for {base_asset}: {precision}")
  143. return {
  144. 'precision': precision,
  145. 'base_precision': precision.get('amount'), # For direct access
  146. 'quote_precision': precision.get('price') # For direct access
  147. }
  148. else:
  149. logger.warning(f"Market {symbol} found for {base_asset}, but precision data is missing or malformed: {precision if 'precision' in locals() else market_details_dict.get('precision')}")
  150. return { # Return default but log that market was found
  151. 'precision': default_precision,
  152. 'base_precision': default_precision['amount'],
  153. 'quote_precision': default_precision['price']
  154. }
  155. logger.warning(f"No market symbol starting with '{target_symbol_prefix}' found in cached markets for {base_asset}.")
  156. else:
  157. logger.warning("Markets cache is empty, cannot find token info.")
  158. return { # Fallback to defaults
  159. 'precision': default_precision,
  160. 'base_precision': default_precision['amount'],
  161. 'quote_precision': default_precision['price']
  162. }
  163. async def find_position(self, token: str) -> Optional[Dict[str, Any]]:
  164. """Find an open position for a token."""
  165. symbol = f"{token}/USDC:USDC"
  166. # 🆕 PHASE 4: Check trades table for open positions (single source of truth)
  167. if self.stats:
  168. open_trade = self.stats.get_trade_by_symbol_and_status(symbol, status='position_opened')
  169. if open_trade:
  170. # Convert trades format to position format for compatibility
  171. entry_price = open_trade.get('entry_price')
  172. current_amount = open_trade.get('current_position_size', 0)
  173. position_side = open_trade.get('position_side')
  174. if entry_price and current_amount and abs(current_amount) > 0:
  175. return {
  176. 'symbol': symbol,
  177. 'contracts': abs(current_amount),
  178. 'notional': abs(current_amount),
  179. 'side': 'long' if position_side == 'long' else 'short',
  180. 'size': current_amount, # Can be negative for short
  181. 'entryPx': entry_price,
  182. 'unrealizedPnl': open_trade.get('unrealized_pnl', 0),
  183. 'marginUsed': abs(current_amount * entry_price),
  184. # Add lifecycle info for debugging
  185. '_lifecycle_id': open_trade.get('trade_lifecycle_id'),
  186. '_trade_id': open_trade.get('id'),
  187. '_source': 'trades_table_phase4'
  188. }
  189. # 🔄 Fallback: Check cached exchange position data
  190. try:
  191. positions = self.get_positions() # Use cached positions method instead of direct client call
  192. if positions:
  193. for pos in positions:
  194. if pos.get('symbol') == symbol and pos.get('contracts', 0) != 0:
  195. logger.debug(f"Found exchange position for {token}: {pos}")
  196. return pos
  197. except Exception as e:
  198. logger.warning(f"Could not fetch exchange positions: {e}")
  199. return None
  200. def get_position_direction(self, position: Dict[str, Any]) -> Tuple[str, str, float]:
  201. """
  202. Get position direction info from CCXT position data.
  203. Returns: (position_type, exit_side, contracts_abs)
  204. """
  205. contracts = float(position.get('contracts', 0))
  206. side_field = position.get('side', '').lower()
  207. if side_field == 'long':
  208. return "LONG", "sell", abs(contracts)
  209. elif side_field == 'short':
  210. return "SHORT", "buy", abs(contracts)
  211. else:
  212. # Fallback to contracts sign (less reliable)
  213. if contracts > 0:
  214. return "LONG", "sell", abs(contracts)
  215. else:
  216. return "SHORT", "buy", abs(contracts)
  217. async def execute_long_order(self, token: str, usdc_amount: float,
  218. limit_price_arg: Optional[float] = None,
  219. stop_loss_price: Optional[float] = None) -> Dict[str, Any]:
  220. symbol = f"{token}/USDC:USDC"
  221. token_info = await self.get_token_info(token)
  222. formatter = get_formatter(token_info)
  223. try:
  224. # Validate inputs
  225. if usdc_amount <= 0:
  226. return {"success": False, "error": "Invalid USDC amount"}
  227. # Get market data for price validation
  228. market_data = await self.get_market_data(symbol)
  229. if not market_data or not market_data.get('ticker'):
  230. return {"success": False, "error": f"Could not fetch market data for {token}"}
  231. current_price = float(market_data['ticker'].get('last', 0.0) or 0.0)
  232. if current_price <= 0:
  233. # Allow trading if current_price is 0 but a valid limit_price_arg is provided
  234. if not (limit_price_arg and limit_price_arg > 0):
  235. return {"success": False, "error": f"Invalid current price ({current_price}) for {token} and no valid limit price provided."}
  236. order_type_for_stats = 'limit' if limit_price_arg is not None else 'market'
  237. order_placement_price: float
  238. token_amount: float
  239. # Determine price and amount for order placement
  240. if limit_price_arg is not None:
  241. if limit_price_arg <= 0:
  242. return {"success": False, "error": "Limit price must be positive."}
  243. order_placement_price = limit_price_arg
  244. token_amount = usdc_amount / order_placement_price
  245. else: # Market order
  246. if current_price <= 0:
  247. return {"success": False, "error": f"Cannot place market order for {token} due to invalid current price: {current_price}"}
  248. order_placement_price = current_price
  249. token_amount = usdc_amount / order_placement_price
  250. # 1. Generate bot_order_ref_id and record order placement intent
  251. bot_order_ref_id = uuid.uuid4().hex
  252. order_db_id = self.stats.record_order_placed(
  253. symbol=symbol, side='buy', order_type=order_type_for_stats,
  254. amount_requested=token_amount, price=order_placement_price if order_type_for_stats == 'limit' else None,
  255. bot_order_ref_id=bot_order_ref_id, status='pending_submission'
  256. )
  257. if not order_db_id:
  258. logger.error(f"Failed to record order intent in DB for {symbol} with bot_ref_id {bot_order_ref_id}")
  259. return {"success": False, "error": "Failed to record order intent in database."}
  260. # 2. Place the order with the exchange
  261. if order_type_for_stats == 'limit':
  262. # Use token for formatting, symbol for logging context
  263. logger.info(f"Placing LIMIT BUY order ({bot_order_ref_id}) for {formatter.format_amount(token_amount, token)} {symbol} at {formatter.format_price_with_symbol(order_placement_price, token)}")
  264. exchange_order_data, error_msg = self.client.place_limit_order(symbol, 'buy', token_amount, order_placement_price)
  265. else: # Market order
  266. logger.info(f"Placing MARKET BUY order ({bot_order_ref_id}) for {formatter.format_amount(token_amount, token)} {symbol} (approx. price {formatter.format_price_with_symbol(order_placement_price, token)})")
  267. exchange_order_data, error_msg = self.client.place_market_order(symbol, 'buy', token_amount)
  268. if error_msg:
  269. logger.error(f"Order placement failed for {symbol} ({bot_order_ref_id}): {error_msg}")
  270. self.stats.update_order_status(order_db_id=order_db_id, new_status='failed_submission', bot_order_ref_id=bot_order_ref_id)
  271. return {"success": False, "error": f"Order placement failed: {error_msg}"}
  272. if not exchange_order_data:
  273. logger.error(f"Order placement call failed for {symbol} ({bot_order_ref_id}). Client returned no data and no error.")
  274. 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)
  275. return {"success": False, "error": "Order placement failed at client level (no order object or error)."}
  276. exchange_oid = exchange_order_data.get('id')
  277. # 3. Update order in DB with exchange_order_id and status
  278. if exchange_oid:
  279. # If it's a market order that might have filled, client response might indicate status.
  280. # For Hyperliquid, a successful market order usually means it's filled or being filled.
  281. # Limit orders will be 'open'.
  282. # We will rely on MarketMonitor to confirm fills for market orders too via fill data.
  283. new_status_after_placement = 'open' # Default for limit, or submitted for market
  284. if order_type_for_stats == 'market':
  285. # Market orders might be considered 'submitted' until fill is confirmed by MarketMonitor
  286. # Or, if API indicates immediate fill, could be 'filled' - for now, let's use 'open' or 'submitted'
  287. new_status_after_placement = 'submitted' # More accurate for market until fill is seen
  288. self.stats.update_order_status(
  289. order_db_id=order_db_id,
  290. new_status=new_status_after_placement,
  291. set_exchange_order_id=exchange_oid
  292. )
  293. else:
  294. 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.")
  295. # Potentially update status to 'submission_no_exch_id'
  296. # DO NOT record trade here. MarketMonitor will handle fills.
  297. # action_type = self.stats.record_trade_with_enhanced_tracking(...)
  298. # MODIFICATION: Remove SL order recording at this stage. SL price is stored with lifecycle.
  299. # if stop_loss_price and exchange_oid and exchange_oid != 'N/A':
  300. # # Record the pending SL order in the orders table
  301. # sl_bot_order_ref_id = uuid.uuid4().hex
  302. # sl_order_db_id = self.stats.record_order_placed(
  303. # symbol=symbol,
  304. # side='sell', # SL for a long is a sell
  305. # order_type='STOP_LIMIT_TRIGGER', # Indicates a conditional order that will become a limit order
  306. # amount_requested=token_amount,
  307. # price=stop_loss_price, # This is the trigger price, and also the limit price for the SL order
  308. # bot_order_ref_id=sl_bot_order_ref_id,
  309. # status='pending_trigger',
  310. # parent_bot_order_ref_id=bot_order_ref_id # Link to the main buy order
  311. # )
  312. # if sl_order_db_id:
  313. # 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}")
  314. # else:
  315. # logger.error(f"Failed to record pending SL order in DB for parent BotRef {bot_order_ref_id}")
  316. # 🆕 PHASE 4: Create trade lifecycle for this entry order
  317. lifecycle_id = None # Initialize lifecycle_id
  318. if exchange_oid:
  319. entry_order_record = self.stats.get_order_by_exchange_id(exchange_oid)
  320. if entry_order_record:
  321. lifecycle_id = self.stats.create_trade_lifecycle(
  322. symbol=symbol,
  323. side='buy',
  324. entry_order_id=exchange_oid, # Store exchange order ID
  325. entry_bot_order_ref_id=bot_order_ref_id, # Pass the entry order's bot_order_ref_id
  326. stop_loss_price=stop_loss_price, # Store SL price with lifecycle
  327. take_profit_price=None, # Assuming TP is handled separately or not in this command
  328. trade_type='bot'
  329. )
  330. if lifecycle_id:
  331. logger.info(f"📊 Created trade lifecycle {lifecycle_id} for BUY {symbol} with SL price {stop_loss_price if stop_loss_price else 'N/A'}")
  332. # MODIFICATION: Do not link a pending DB trigger SL here.
  333. # if stop_loss_price and sl_order_db_id: # sl_order_db_id is no longer created here
  334. # self.stats.link_stop_loss_to_trade(lifecycle_id, f"pending_db_trigger_{sl_order_db_id}", stop_loss_price)
  335. # logger.info(f"📊 Created trade lifecycle {lifecycle_id} and linked pending SL (ID: pending_db_trigger_{sl_order_db_id}) for BUY {symbol}")
  336. return {
  337. "success": True,
  338. "order_placed_details": {
  339. "bot_order_ref_id": bot_order_ref_id,
  340. "exchange_order_id": exchange_oid,
  341. "order_db_id": order_db_id,
  342. "symbol": symbol,
  343. "side": "buy",
  344. "type": order_type_for_stats,
  345. "amount_requested": token_amount,
  346. "price_requested": order_placement_price if order_type_for_stats == 'limit' else None
  347. },
  348. # "action_type": action_type, # Removed as trade is not recorded here
  349. "token_amount": token_amount,
  350. # "actual_price": final_price_for_stats, # Removed as fill is not processed here
  351. "stop_loss_pending_activation": stop_loss_price is not None, # Indicates SL needs to be placed after fill
  352. "stop_loss_price_if_pending": stop_loss_price,
  353. "trade_lifecycle_id": lifecycle_id if 'lifecycle_id' in locals() else None # Return lifecycle_id
  354. }
  355. except ZeroDivisionError as e:
  356. 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'}")
  357. return {"success": False, "error": f"Math error (division by zero), check prices: {e}"}
  358. except Exception as e:
  359. logger.error(f"Error executing long order: {e}", exc_info=True)
  360. return {"success": False, "error": str(e)}
  361. async def execute_short_order(self, token: str, usdc_amount: float,
  362. limit_price_arg: Optional[float] = None,
  363. stop_loss_price: Optional[float] = None) -> Dict[str, Any]:
  364. symbol = f"{token}/USDC:USDC"
  365. token_info = await self.get_token_info(token)
  366. formatter = get_formatter(token_info)
  367. try:
  368. if usdc_amount <= 0:
  369. return {"success": False, "error": "Invalid USDC amount"}
  370. market_data = await self.get_market_data(symbol)
  371. if not market_data or not market_data.get('ticker'):
  372. return {"success": False, "error": f"Could not fetch market data for {token}"}
  373. current_price = float(market_data['ticker'].get('last', 0.0) or 0.0)
  374. if current_price <= 0:
  375. if not (limit_price_arg and limit_price_arg > 0):
  376. return {"success": False, "error": f"Invalid current price ({current_price}) for {token} and no valid limit price provided."}
  377. order_type_for_stats = 'limit' if limit_price_arg is not None else 'market'
  378. order_placement_price: float
  379. token_amount: float
  380. if limit_price_arg is not None:
  381. if limit_price_arg <= 0:
  382. return {"success": False, "error": "Limit price must be positive."}
  383. order_placement_price = limit_price_arg
  384. token_amount = usdc_amount / order_placement_price
  385. else: # Market order
  386. if current_price <= 0:
  387. return {"success": False, "error": f"Cannot place market order for {token} due to invalid current price: {current_price}"}
  388. order_placement_price = current_price
  389. token_amount = usdc_amount / order_placement_price
  390. # 1. Generate bot_order_ref_id and record order placement intent
  391. bot_order_ref_id = uuid.uuid4().hex
  392. order_db_id = self.stats.record_order_placed(
  393. symbol=symbol, side='sell', order_type=order_type_for_stats,
  394. amount_requested=token_amount, price=order_placement_price if order_type_for_stats == 'limit' else None,
  395. bot_order_ref_id=bot_order_ref_id, status='pending_submission'
  396. )
  397. if not order_db_id:
  398. logger.error(f"Failed to record order intent in DB for {symbol} with bot_ref_id {bot_order_ref_id}")
  399. return {"success": False, "error": "Failed to record order intent in database."}
  400. # 2. Place the order with the exchange
  401. if order_type_for_stats == 'limit':
  402. logger.info(f"Placing LIMIT SELL order ({bot_order_ref_id}) for {formatter.format_amount(token_amount, token)} {symbol} at {formatter.format_price_with_symbol(order_placement_price, token)}")
  403. exchange_order_data, error_msg = self.client.place_limit_order(symbol, 'sell', token_amount, order_placement_price)
  404. else: # Market order
  405. logger.info(f"Placing MARKET SELL order ({bot_order_ref_id}) for {formatter.format_amount(token_amount, token)} {symbol} (approx. price {formatter.format_price_with_symbol(order_placement_price, token)})")
  406. exchange_order_data, error_msg = self.client.place_market_order(symbol, 'sell', token_amount)
  407. if error_msg:
  408. logger.error(f"Order placement failed for {symbol} ({bot_order_ref_id}): {error_msg}")
  409. self.stats.update_order_status(order_db_id=order_db_id, new_status='failed_submission', bot_order_ref_id=bot_order_ref_id)
  410. return {"success": False, "error": f"Order placement failed: {error_msg}"}
  411. if not exchange_order_data:
  412. logger.error(f"Order placement call failed for {symbol} ({bot_order_ref_id}). Client returned no data and no error.")
  413. 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)
  414. return {"success": False, "error": "Order placement failed at client level (no order object or error)."}
  415. exchange_oid = exchange_order_data.get('id')
  416. # 3. Update order in DB with exchange_order_id and status
  417. if exchange_oid:
  418. new_status_after_placement = 'open' # Default for limit, or submitted for market
  419. if order_type_for_stats == 'market':
  420. new_status_after_placement = 'submitted' # Market orders are submitted, fills come via monitor
  421. self.stats.update_order_status(
  422. order_db_id=order_db_id,
  423. new_status=new_status_after_placement,
  424. set_exchange_order_id=exchange_oid
  425. )
  426. else:
  427. logger.warning(f"No exchange_order_id received for order {order_db_id} ({bot_order_ref_id}).")
  428. # DO NOT record trade here. MarketMonitor will handle fills.
  429. # action_type = self.stats.record_trade_with_enhanced_tracking(...)
  430. # MODIFICATION: Remove SL order recording at this stage. SL price is stored with lifecycle.
  431. # if stop_loss_price and exchange_oid and exchange_oid != 'N/A':
  432. # # Record the pending SL order in the orders table
  433. # sl_bot_order_ref_id = uuid.uuid4().hex
  434. # sl_order_db_id = self.stats.record_order_placed(
  435. # symbol=symbol,
  436. # side='buy', # SL for a short is a buy
  437. # order_type='STOP_LIMIT_TRIGGER',
  438. # amount_requested=token_amount,
  439. # price=stop_loss_price,
  440. # bot_order_ref_id=sl_bot_order_ref_id,
  441. # status='pending_trigger',
  442. # parent_bot_order_ref_id=bot_order_ref_id # Link to the main sell order
  443. # )
  444. # if sl_order_db_id:
  445. # 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}")
  446. # else:
  447. # logger.error(f"Failed to record pending SL order in DB for parent BotRef {bot_order_ref_id}")
  448. # 🆕 PHASE 4: Create trade lifecycle for this entry order
  449. lifecycle_id = None # Initialize lifecycle_id
  450. if exchange_oid:
  451. entry_order_record = self.stats.get_order_by_exchange_id(exchange_oid)
  452. if entry_order_record:
  453. lifecycle_id = self.stats.create_trade_lifecycle(
  454. symbol=symbol,
  455. side='sell',
  456. entry_order_id=exchange_oid, # Store exchange order ID
  457. entry_bot_order_ref_id=bot_order_ref_id, # Pass the entry order's bot_order_ref_id
  458. stop_loss_price=stop_loss_price, # Store SL price with lifecycle
  459. take_profit_price=None, # Assuming TP is handled separately
  460. trade_type='bot'
  461. )
  462. if lifecycle_id:
  463. logger.info(f"📊 Created trade lifecycle {lifecycle_id} for SELL {symbol} with SL price {stop_loss_price if stop_loss_price else 'N/A'}")
  464. # MODIFICATION: Do not link a pending DB trigger SL here.
  465. # sl_order_record = self.stats.get_order_by_bot_ref_id(sl_bot_order_ref_id)
  466. # if sl_order_record:
  467. # self.stats.link_stop_loss_to_trade(lifecycle_id, f"pending_db_trigger_{sl_order_db_id}", stop_loss_price)
  468. # logger.info(f"📊 Created trade lifecycle {lifecycle_id} and linked pending SL (ID: pending_db_trigger_{sl_order_db_id}) for SELL {symbol}")
  469. return {
  470. "success": True,
  471. "order_placed_details": {
  472. "bot_order_ref_id": bot_order_ref_id,
  473. "exchange_order_id": exchange_oid,
  474. "order_db_id": order_db_id,
  475. "symbol": symbol,
  476. "side": "sell",
  477. "type": order_type_for_stats,
  478. "amount_requested": token_amount,
  479. "price_requested": order_placement_price if order_type_for_stats == 'limit' else None
  480. },
  481. "token_amount": token_amount,
  482. "stop_loss_pending_activation": stop_loss_price is not None, # Indicates SL needs to be placed after fill
  483. "stop_loss_price_if_pending": stop_loss_price,
  484. "trade_lifecycle_id": lifecycle_id if 'lifecycle_id' in locals() else None # Return lifecycle_id
  485. }
  486. except ZeroDivisionError as e:
  487. 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'}")
  488. return {"success": False, "error": f"Math error (division by zero), check prices: {e}"}
  489. except Exception as e:
  490. logger.error(f"Error executing short order: {e}", exc_info=True)
  491. return {"success": False, "error": str(e)}
  492. async def execute_exit_order(self, token: str) -> Dict[str, Any]:
  493. """Execute an exit order to close a position."""
  494. position = await self.find_position(token)
  495. if not position:
  496. return {"success": False, "error": f"No open position found for {token}"}
  497. formatter = get_formatter() # Get formatter
  498. try:
  499. symbol = f"{token}/USDC:USDC"
  500. position_type, exit_side, contracts_to_close = self.get_position_direction(position)
  501. order_type_for_stats = 'market' # Exit orders are typically market
  502. # 1. Generate bot_order_ref_id and record order placement intent
  503. bot_order_ref_id = uuid.uuid4().hex
  504. # Price for a market order is not specified at placement for stats recording, will be determined by fill.
  505. order_db_id = self.stats.record_order_placed(
  506. symbol=symbol, side=exit_side, order_type=order_type_for_stats,
  507. amount_requested=contracts_to_close, price=None, # Market order, price determined by fill
  508. bot_order_ref_id=bot_order_ref_id, status='pending_submission'
  509. )
  510. if not order_db_id:
  511. logger.error(f"Failed to record exit order intent in DB for {symbol} with bot_ref_id {bot_order_ref_id}")
  512. return {"success": False, "error": "Failed to record exit order intent in database."}
  513. # 2. Execute market order to close position
  514. logger.info(f"Placing MARKET {exit_side.upper()} order ({bot_order_ref_id}) to close {formatter.format_amount(contracts_to_close, token)} {symbol}")
  515. exchange_order_data, error_msg = self.client.place_market_order(symbol, exit_side, contracts_to_close)
  516. if error_msg:
  517. logger.error(f"Exit order execution failed for {symbol} ({bot_order_ref_id}): {error_msg}")
  518. self.stats.update_order_status(order_db_id=order_db_id, new_status='failed_submission', bot_order_ref_id=bot_order_ref_id)
  519. return {"success": False, "error": f"Exit order execution failed: {error_msg}"}
  520. if not exchange_order_data:
  521. logger.error(f"Exit order execution call failed for {symbol} ({bot_order_ref_id}). Client returned no data and no error.")
  522. 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)
  523. return {"success": False, "error": "Exit order execution failed (no order object or error)."}
  524. exchange_oid = exchange_order_data.get('id')
  525. # 3. Update order in DB with exchange_order_id and status
  526. if exchange_oid:
  527. # Market orders are submitted; MarketMonitor will confirm fills.
  528. self.stats.update_order_status(
  529. order_db_id=order_db_id,
  530. new_status='submitted',
  531. set_exchange_order_id=exchange_oid
  532. )
  533. else:
  534. logger.warning(f"No exchange_order_id received for exit order {order_db_id} ({bot_order_ref_id}).")
  535. # DO NOT record trade here. MarketMonitor will handle fills.
  536. # The old code below is removed:
  537. # order_id = order_data.get('id', 'N/A')
  538. # actual_price = order_data.get('average', 0)
  539. # ... logic for actual_price fallback ...
  540. # if order_id != 'N/A':
  541. # self.bot_trade_ids.add(order_id)
  542. # action_type = self.stats.record_trade_with_enhanced_tracking(...)
  543. # Cancel any pending stop losses for this symbol since position will be closed
  544. cancelled_sl_count = 0
  545. if self.stats and hasattr(self.stats.order_manager, 'cancel_pending_stop_losses_by_symbol'):
  546. try:
  547. cancelled_sl_count = self.stats.order_manager.cancel_pending_stop_losses_by_symbol(
  548. symbol=symbol,
  549. new_status='cancelled_manual_exit'
  550. )
  551. if cancelled_sl_count > 0:
  552. logger.info(f"🛑 Cancelled {cancelled_sl_count} pending stop losses for {symbol} due to manual exit order")
  553. except Exception as e:
  554. logger.warning(f"Could not cancel pending stop losses for {symbol}: {e}")
  555. # NOTE: Exit orders do not create new trade cycles - they close existing ones
  556. # The MarketMonitor will handle closing the trade cycle when the exit order fills
  557. # Fetch the lifecycle ID of the position being closed
  558. lifecycle_id_to_close = None
  559. if self.stats:
  560. active_trade_lc = self.stats.get_trade_by_symbol_and_status(symbol, 'position_opened')
  561. if active_trade_lc:
  562. lifecycle_id_to_close = active_trade_lc.get('trade_lifecycle_id')
  563. return {
  564. "success": True,
  565. "order_placed_details": {
  566. "bot_order_ref_id": bot_order_ref_id,
  567. "exchange_order_id": exchange_oid,
  568. "order_db_id": order_db_id,
  569. "symbol": symbol,
  570. "side": exit_side,
  571. "type": order_type_for_stats,
  572. "amount_requested": contracts_to_close
  573. },
  574. "position_type_closed": position_type, # Info about the position it intends to close
  575. "contracts_intended_to_close": contracts_to_close,
  576. "cancelled_stop_losses": cancelled_sl_count,
  577. "trade_lifecycle_id": lifecycle_id_to_close # Return lifecycle_id of the closed position
  578. }
  579. except Exception as e:
  580. logger.error(f"Error executing exit order: {e}")
  581. return {"success": False, "error": str(e)}
  582. async def execute_stop_loss_order(self, token: str, stop_price: float) -> Dict[str, Any]:
  583. """Execute a stop loss order."""
  584. position = await self.find_position(token)
  585. if not position:
  586. return {"success": False, "error": f"No open position found for {token} to set stop loss."}
  587. formatter = get_formatter() # Get formatter
  588. try:
  589. symbol = f"{token}/USDC:USDC"
  590. position_type, exit_side, contracts = self.get_position_direction(position)
  591. entry_price = float(position.get('entryPx', 0))
  592. # Validate stop loss price based on position direction
  593. if position_type == "LONG" and stop_price >= entry_price:
  594. return {"success": False, "error": "Stop loss price should be below entry price for long positions"}
  595. elif position_type == "SHORT" and stop_price <= entry_price:
  596. return {"success": False, "error": "Stop loss price should be above entry price for short positions"}
  597. order_type_for_stats = 'limit' # MODIFICATION: SL from /sl command is now a direct limit order
  598. # 1. Generate bot_order_ref_id and record order placement intent
  599. bot_order_ref_id = uuid.uuid4().hex
  600. # For a direct limit SL, the 'price' recorded is the limit price.
  601. order_db_id = self.stats.record_order_placed(
  602. symbol=symbol, side=exit_side, order_type=order_type_for_stats,
  603. amount_requested=contracts, price=stop_price, # price here is the limit price for the SL
  604. bot_order_ref_id=bot_order_ref_id, status='pending_submission'
  605. )
  606. if not order_db_id:
  607. logger.error(f"Failed to record SL limit order intent in DB for {symbol} (direct /sl command) with bot_ref {bot_order_ref_id}")
  608. return {"success": False, "error": "Failed to record SL limit order intent in database."}
  609. # 2. Place a direct LIMIT order for the stop loss
  610. logger.info(f"Placing direct LIMIT STOP LOSS ({exit_side.upper()}) order ({bot_order_ref_id}) for {formatter.format_amount(contracts, token)} {symbol} at limit price {formatter.format_price_with_symbol(stop_price, token)}")
  611. exchange_order_data, error_msg = self.client.place_limit_order(symbol, exit_side, contracts, price=stop_price)
  612. if error_msg:
  613. logger.error(f"Direct SL Limit order placement failed for {symbol} ({bot_order_ref_id}): {error_msg}")
  614. self.stats.update_order_status(order_db_id=order_db_id, new_status='failed_submission', bot_order_ref_id=bot_order_ref_id)
  615. return {"success": False, "error": f"Direct SL Limit order placement failed: {error_msg}"}
  616. if not exchange_order_data:
  617. logger.error(f"Direct SL Limit order placement call failed for {symbol} ({bot_order_ref_id}). Client returned no data/error.")
  618. 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)
  619. return {"success": False, "error": "Direct SL Limit order placement failed (no order object or error from client)."}
  620. exchange_oid = exchange_order_data.get('id')
  621. # 3. Update order in DB with exchange_order_id and status
  622. if exchange_oid:
  623. self.stats.update_order_status(
  624. order_db_id=order_db_id,
  625. new_status='open', # Limit orders are 'open' until filled
  626. set_exchange_order_id=exchange_oid
  627. )
  628. else:
  629. logger.warning(f"No exchange_order_id received for SL limit order {order_db_id} ({bot_order_ref_id}).")
  630. # NOTE: Stop loss orders are protective orders for existing positions
  631. # They do not create new trade cycles - they protect existing trade cycles
  632. # Fetch the lifecycle_id for the current position
  633. lifecycle_id = None
  634. active_trade_lc = None # Define active_trade_lc to ensure it's available for the if block
  635. if self.stats:
  636. active_trade_lc = self.stats.get_trade_by_symbol_and_status(symbol, 'position_opened')
  637. if active_trade_lc:
  638. lifecycle_id = active_trade_lc.get('trade_lifecycle_id')
  639. if exchange_oid: # If SL order placed successfully on exchange
  640. # Ensure that if an old SL (e.g. stop-market) existed and is being replaced,
  641. # it might need to be cancelled first. However, current flow assumes this /sl places a new/updated one.
  642. # For simplicity, link_stop_loss_to_trade will update if one already exists or insert.
  643. await self.stats.link_stop_loss_to_trade(lifecycle_id, exchange_oid, stop_price)
  644. logger.info(f"🛡️ Linked SL limit order {exchange_oid} to lifecycle {lifecycle_id} for {symbol} (from /sl command)")
  645. return {
  646. "success": True,
  647. "order_placed_details": {
  648. "bot_order_ref_id": bot_order_ref_id,
  649. "exchange_order_id": exchange_oid,
  650. "order_db_id": order_db_id,
  651. "symbol": symbol,
  652. "side": exit_side,
  653. "type": order_type_for_stats,
  654. "amount_requested": contracts,
  655. "price_requested": stop_price
  656. },
  657. "position_type_for_sl": position_type, # Info about the position it's protecting
  658. "contracts_for_sl": contracts,
  659. "stop_price_set": stop_price,
  660. "trade_lifecycle_id": lifecycle_id # Return lifecycle_id
  661. }
  662. except Exception as e:
  663. logger.error(f"Error executing stop loss order: {e}")
  664. return {"success": False, "error": str(e)}
  665. async def execute_take_profit_order(self, token: str, profit_price: float) -> Dict[str, Any]:
  666. """Execute a take profit order."""
  667. position = await self.find_position(token)
  668. if not position:
  669. return {"success": False, "error": f"No open position found for {token} to set take profit."}
  670. formatter = get_formatter() # Get formatter
  671. try:
  672. symbol = f"{token}/USDC:USDC"
  673. position_type, exit_side, contracts = self.get_position_direction(position)
  674. entry_price = float(position.get('entryPx', 0))
  675. # Validate take profit price based on position direction
  676. if position_type == "LONG" and profit_price <= entry_price:
  677. return {"success": False, "error": "Take profit price should be above entry price for long positions"}
  678. elif position_type == "SHORT" and profit_price >= entry_price:
  679. return {"success": False, "error": "Take profit price should be below entry price for short positions"}
  680. order_type_for_stats = 'limit' # Take profit is a limit order at profit_price
  681. # 1. Generate bot_order_ref_id and record order placement intent
  682. bot_order_ref_id = uuid.uuid4().hex
  683. order_db_id = self.stats.record_order_placed(
  684. symbol=symbol, side=exit_side, order_type=order_type_for_stats,
  685. amount_requested=contracts, price=profit_price,
  686. bot_order_ref_id=bot_order_ref_id, status='pending_submission'
  687. )
  688. if not order_db_id:
  689. logger.error(f"Failed to record TP order intent in DB for {symbol} with bot_ref_id {bot_order_ref_id}")
  690. return {"success": False, "error": "Failed to record TP order intent in database."}
  691. # 2. Place limit order at take profit price
  692. logger.info(f"Placing TAKE PROFIT (LIMIT {exit_side.upper()}) order ({bot_order_ref_id}) for {formatter.format_amount(contracts, token)} {symbol} at {formatter.format_price_with_symbol(profit_price, token)}")
  693. exchange_order_data, error_msg = self.client.place_limit_order(symbol, exit_side, contracts, profit_price)
  694. if error_msg:
  695. logger.error(f"Take profit order placement failed for {symbol} ({bot_order_ref_id}): {error_msg}")
  696. self.stats.update_order_status(order_db_id=order_db_id, new_status='failed_submission', bot_order_ref_id=bot_order_ref_id)
  697. return {"success": False, "error": f"Take profit order placement failed: {error_msg}"}
  698. if not exchange_order_data:
  699. logger.error(f"Take profit order placement call failed for {symbol} ({bot_order_ref_id}). Client returned no data and no error.")
  700. 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)
  701. return {"success": False, "error": "Take profit order placement failed (no order object or error)."}
  702. exchange_oid = exchange_order_data.get('id')
  703. # 3. Update order in DB with exchange_order_id and status
  704. if exchange_oid:
  705. self.stats.update_order_status(
  706. order_db_id=order_db_id,
  707. new_status='open', # SL/TP limit orders are 'open' until triggered/filled
  708. set_exchange_order_id=exchange_oid
  709. )
  710. else:
  711. logger.warning(f"No exchange_order_id received for TP order {order_db_id} ({bot_order_ref_id}).")
  712. # NOTE: Take profit orders are protective orders for existing positions
  713. # They do not create new trade cycles - they protect existing trade cycles
  714. # Fetch the lifecycle_id for the current position
  715. lifecycle_id = None
  716. if self.stats:
  717. active_trade_lc = self.stats.get_trade_by_symbol_and_status(symbol, 'position_opened')
  718. if active_trade_lc:
  719. lifecycle_id = active_trade_lc.get('trade_lifecycle_id')
  720. if exchange_oid: # If TP order placed successfully on exchange
  721. await self.stats.link_take_profit_to_trade(lifecycle_id, exchange_oid, profit_price)
  722. logger.info(f"🎯 Linked TP order {exchange_oid} to lifecycle {lifecycle_id} for {symbol}")
  723. return {
  724. "success": True,
  725. "order_placed_details": {
  726. "bot_order_ref_id": bot_order_ref_id,
  727. "exchange_order_id": exchange_oid,
  728. "order_db_id": order_db_id,
  729. "symbol": symbol,
  730. "side": exit_side,
  731. "type": order_type_for_stats,
  732. "amount_requested": contracts,
  733. "price_requested": profit_price
  734. },
  735. "position_type_for_tp": position_type, # Info about the position it's for
  736. "contracts_for_tp": contracts,
  737. "profit_price_set": profit_price,
  738. "trade_lifecycle_id": lifecycle_id # Return lifecycle_id
  739. }
  740. except Exception as e:
  741. logger.error(f"Error executing take profit order: {e}")
  742. return {"success": False, "error": str(e)}
  743. def cancel_all_orders(self, symbol: str) -> Tuple[List[Dict[str, Any]], Optional[str]]:
  744. """Cancel all open orders for a specific symbol. Returns (cancelled_orders, error_message)."""
  745. try:
  746. logger.info(f"Attempting to cancel all orders for {symbol}")
  747. # Get all open orders
  748. all_orders = self.client.get_open_orders()
  749. if all_orders is None:
  750. error_msg = f"Could not fetch orders to cancel {symbol} orders"
  751. logger.error(error_msg)
  752. return [], error_msg
  753. # Filter orders for the specific symbol
  754. symbol_orders = [order for order in all_orders if order.get('symbol') == symbol]
  755. if not symbol_orders:
  756. logger.info(f"No open orders found for {symbol}")
  757. return [], None # No error, just no orders to cancel
  758. # Cancel each order individually
  759. cancelled_orders = []
  760. failed_orders = []
  761. for order in symbol_orders:
  762. order_id = order.get('id')
  763. if order_id:
  764. try:
  765. success = self.client.cancel_order(order_id, symbol)
  766. if success:
  767. cancelled_orders.append(order)
  768. logger.info(f"Successfully cancelled order {order_id} for {symbol}")
  769. else:
  770. failed_orders.append(order)
  771. logger.warning(f"Failed to cancel order {order_id} for {symbol}")
  772. except Exception as e:
  773. logger.error(f"Exception cancelling order {order_id}: {e}")
  774. failed_orders.append(order)
  775. # Update order status in database if we have stats
  776. if self.stats:
  777. for order in cancelled_orders:
  778. order_id = order.get('id')
  779. if order_id:
  780. # Try to find this order in our database and update its status
  781. db_order = self.stats.get_order_by_exchange_id(order_id)
  782. if db_order:
  783. self.stats.update_order_status(
  784. exchange_order_id=order_id,
  785. new_status='cancelled_manually'
  786. )
  787. # Cancel any linked pending stop losses for this symbol
  788. if hasattr(self.stats.order_manager, 'cancel_pending_stop_losses_by_symbol'):
  789. try:
  790. cleanup_count = self.stats.order_manager.cancel_pending_stop_losses_by_symbol(
  791. symbol,
  792. 'cancelled_manual_exit'
  793. )
  794. if cleanup_count > 0:
  795. logger.info(f"Cleaned up {cleanup_count} pending stop losses for {symbol}")
  796. except Exception as e:
  797. logger.error(f"Error cancelling pending stop losses for {symbol}: {e}")
  798. # Prepare result
  799. if failed_orders:
  800. error_msg = f"Cancelled {len(cancelled_orders)}/{len(symbol_orders)} orders. {len(failed_orders)} failed."
  801. logger.warning(error_msg)
  802. return cancelled_orders, error_msg
  803. else:
  804. logger.info(f"Successfully cancelled all {len(cancelled_orders)} orders for {symbol}")
  805. return cancelled_orders, None
  806. except Exception as e:
  807. error_msg = f"Error cancelling orders for {symbol}: {str(e)}"
  808. logger.error(error_msg, exc_info=True)
  809. return [], error_msg
  810. # Alias methods for consistency with command handlers
  811. async def execute_sl_order(self, token: str, stop_price: float) -> Dict[str, Any]:
  812. """Alias for execute_stop_loss_order."""
  813. return await self.execute_stop_loss_order(token, stop_price)
  814. async def execute_tp_order(self, token: str, profit_price: float) -> Dict[str, Any]:
  815. """Alias for execute_take_profit_order."""
  816. return await self.execute_take_profit_order(token, profit_price)
  817. async def execute_coo_order(self, token: str) -> Dict[str, Any]:
  818. """Cancel all orders for a token and format response like the old code expected."""
  819. try:
  820. symbol = f"{token}/USDC:USDC"
  821. # Call the synchronous cancel_all_orders method
  822. cancelled_orders, error_msg = self.cancel_all_orders(symbol)
  823. if error_msg:
  824. logger.error(f"Error cancelling all orders for {token}: {error_msg}")
  825. return {"success": False, "error": error_msg}
  826. if not cancelled_orders:
  827. logger.info(f"No orders found to cancel for {token}")
  828. return {
  829. "success": True,
  830. "message": f"No orders found for {token}",
  831. "cancelled_orders": [],
  832. "cancelled_count": 0,
  833. "failed_count": 0,
  834. "cancelled_linked_stop_losses": 0
  835. }
  836. # Get cleanup count from stats if available
  837. cleanup_count = 0
  838. if self.stats:
  839. cleanup_count = self.stats.cancel_pending_stop_losses_by_symbol(
  840. symbol,
  841. 'cancelled_manual_exit'
  842. )
  843. logger.info(f"Successfully cancelled {len(cancelled_orders)} orders for {token}")
  844. return {
  845. "success": True,
  846. "message": f"Successfully cancelled {len(cancelled_orders)} orders for {token}",
  847. "cancelled_orders": cancelled_orders,
  848. "cancelled_count": len(cancelled_orders),
  849. "failed_count": 0, # Failed orders are handled in the error case above
  850. "cancelled_linked_stop_losses": cleanup_count
  851. }
  852. except Exception as e:
  853. logger.error(f"Error in execute_coo_order for {token}: {e}")
  854. return {"success": False, "error": str(e)}
  855. async def place_limit_stop_for_lifecycle(self, lifecycle_id: str, symbol: str, sl_price: float, position_side: str, amount_to_cover: float) -> Dict[str, Any]:
  856. """Places a limit stop-loss order for an active trade lifecycle."""
  857. formatter = get_formatter()
  858. token = symbol.split('/')[0] if '/' in symbol else symbol
  859. if not all([lifecycle_id, symbol, sl_price > 0, position_side in ['long', 'short'], amount_to_cover > 0]):
  860. err_msg = f"Invalid parameters for place_limit_stop_for_lifecycle: lc_id={lifecycle_id}, sym={symbol}, sl_price={sl_price}, pos_side={position_side}, amt={amount_to_cover}"
  861. logger.error(err_msg)
  862. return {"success": False, "error": err_msg}
  863. sl_order_side = 'sell' if position_side == 'long' else 'buy'
  864. order_type_for_stats = 'limit' # Explicitly a limit order
  865. # 1. Generate bot_order_ref_id and record order placement intent
  866. bot_order_ref_id = uuid.uuid4().hex
  867. order_db_id = self.stats.record_order_placed(
  868. symbol=symbol, side=sl_order_side, order_type=order_type_for_stats,
  869. amount_requested=amount_to_cover, price=sl_price,
  870. bot_order_ref_id=bot_order_ref_id, status='pending_submission'
  871. )
  872. if not order_db_id:
  873. msg = f"Failed to record SL limit order intent in DB for {symbol} (Lifecycle: {lifecycle_id}) with bot_ref {bot_order_ref_id}"
  874. logger.error(msg)
  875. return {"success": False, "error": msg}
  876. # 2. Place the limit order on the exchange
  877. logger.info(f"Placing LIMIT STOP LOSS ({sl_order_side.upper()}) for lifecycle {lifecycle_id[:8]} ({bot_order_ref_id}): {formatter.format_amount(amount_to_cover, token)} {symbol} @ {formatter.format_price_with_symbol(sl_price, token)}")
  878. exchange_order_data, error_msg = self.client.place_limit_order(symbol, sl_order_side, amount_to_cover, sl_price)
  879. if error_msg:
  880. logger.error(f"SL Limit order placement failed for {symbol} ({bot_order_ref_id}, LC: {lifecycle_id[:8]}): {error_msg}")
  881. self.stats.update_order_status(order_db_id=order_db_id, new_status='failed_submission', bot_order_ref_id=bot_order_ref_id)
  882. return {"success": False, "error": f"SL Limit order placement failed: {error_msg}"}
  883. if not exchange_order_data:
  884. logger.error(f"SL Limit order placement call failed for {symbol} ({bot_order_ref_id}, LC: {lifecycle_id[:8]}). Client returned no data/error.")
  885. 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)
  886. return {"success": False, "error": "SL Limit order placement failed (no order object or error from client)."}
  887. exchange_oid = exchange_order_data.get('id')
  888. # 3. Update order in DB with exchange_order_id and status
  889. if exchange_oid:
  890. self.stats.update_order_status(
  891. order_db_id=order_db_id,
  892. new_status='open', # Limit orders are 'open' until filled
  893. set_exchange_order_id=exchange_oid
  894. )
  895. # 4. Link this exchange SL order to the trade lifecycle
  896. await self.stats.link_stop_loss_to_trade(lifecycle_id, exchange_oid, sl_price)
  897. logger.info(f"🛡️ Successfully placed and linked SL limit order {exchange_oid} to lifecycle {lifecycle_id} for {symbol}")
  898. else:
  899. logger.warning(f"No exchange_order_id received for SL limit order {order_db_id} ({bot_order_ref_id}, LC: {lifecycle_id[:8]}). Status remains pending_submission.")
  900. return {
  901. "success": True,
  902. "order_placed_details": {
  903. "bot_order_ref_id": bot_order_ref_id,
  904. "exchange_order_id": exchange_oid,
  905. "order_db_id": order_db_id,
  906. "symbol": symbol,
  907. "side": sl_order_side,
  908. "type": order_type_for_stats,
  909. "amount_requested": amount_to_cover,
  910. "price_requested": sl_price
  911. },
  912. "trade_lifecycle_id": lifecycle_id
  913. }
  914. def is_bot_trade(self, exchange_order_id: str) -> bool:
  915. """Check if an order (by its exchange ID) was recorded by this bot in the orders table."""
  916. if not self.stats:
  917. return False
  918. order_data = self.stats.get_order_by_exchange_id(exchange_order_id)
  919. return order_data is not None # If found, it was a bot-managed order
  920. def get_stats(self) -> TradingStats:
  921. """Get trading statistics object."""
  922. return self.stats
  923. async def execute_triggered_stop_order(self, original_trigger_order_db_id: int) -> Dict[str, Any]:
  924. """Executes an actual stop order on the exchange after its trigger condition was met."""
  925. if not self.stats:
  926. return {"success": False, "error": "TradingStats not available."}
  927. formatter = get_formatter() # Get formatter
  928. trigger_order_details = self.stats.get_order_by_db_id(original_trigger_order_db_id)
  929. if not trigger_order_details:
  930. return {"success": False, "error": f"Original trigger order DB ID {original_trigger_order_db_id} not found."}
  931. logger.info(f"Executing triggered stop order based on original trigger DB ID: {original_trigger_order_db_id}, details: {trigger_order_details}")
  932. symbol = trigger_order_details.get('symbol')
  933. # Side of the actual SL order to be placed (e.g., if trigger was 'sell', actual order is 'sell')
  934. sl_order_side = trigger_order_details.get('side')
  935. amount = trigger_order_details.get('amount_requested')
  936. stop_price = trigger_order_details.get('price') # This was the trigger price
  937. parent_bot_ref_id_of_trigger = trigger_order_details.get('bot_order_ref_id') # The ref ID of the trigger order itself
  938. if not all([symbol, sl_order_side, amount, stop_price]):
  939. msg = f"Missing critical details from trigger order DB ID {original_trigger_order_db_id} to place actual SL order."
  940. logger.error(msg)
  941. return {"success": False, "error": msg}
  942. # 🧠 SMART STOP LOSS LOGIC: Check if price has moved beyond stop loss
  943. # Get current market price to determine order type
  944. current_price = None
  945. try:
  946. market_data = await self.get_market_data(symbol)
  947. if market_data and market_data.get('ticker'):
  948. current_price = float(market_data['ticker'].get('last', 0))
  949. except Exception as price_error:
  950. logger.warning(f"Could not fetch current price for {symbol}: {price_error}")
  951. # Determine if we need market order (price moved beyond stop) or limit order (normal case)
  952. use_market_order = False
  953. order_type_for_actual_sl = 'limit' # Default to limit
  954. order_price = stop_price # Default to stop price
  955. if current_price and current_price > 0:
  956. token = symbol.split('/')[0] if symbol else "TOKEN" # Extract token for formatter
  957. current_price_str = formatter.format_price_with_symbol(current_price, token)
  958. stop_price_str = formatter.format_price_with_symbol(stop_price, token)
  959. if sl_order_side.lower() == 'buy':
  960. # SHORT position stop loss (BUY to close)
  961. # If current price > stop price, use market order (price moved beyond stop)
  962. if current_price > stop_price:
  963. use_market_order = True
  964. logger.warning(f"🚨 SHORT SL: Price {current_price_str} > Stop {stop_price_str} - Using MARKET order for immediate execution")
  965. else:
  966. logger.info(f"📊 SHORT SL: Price {current_price_str} ≤ Stop {stop_price_str} - Using LIMIT order at stop price")
  967. elif sl_order_side.lower() == 'sell':
  968. # LONG position stop loss (SELL to close)
  969. # If current price < stop price, use market order (price moved beyond stop)
  970. if current_price < stop_price:
  971. use_market_order = True
  972. logger.warning(f"🚨 LONG SL: Price {current_price_str} < Stop {stop_price_str} - Using MARKET order for immediate execution")
  973. else:
  974. logger.info(f"📊 LONG SL: Price {current_price_str} ≥ Stop {stop_price_str} - Using LIMIT order at stop price")
  975. if use_market_order:
  976. order_type_for_actual_sl = 'market'
  977. order_price = None # Market orders don't have a specific price
  978. # 1. Generate a new bot_order_ref_id for this actual SL order
  979. actual_sl_bot_order_ref_id = uuid.uuid4().hex
  980. # We can link this actual SL order back to the trigger order that spawned it.
  981. actual_sl_order_db_id = self.stats.record_order_placed(
  982. symbol=symbol,
  983. side=sl_order_side,
  984. order_type=order_type_for_actual_sl,
  985. amount_requested=amount,
  986. price=order_price, # None for market, stop_price for limit
  987. bot_order_ref_id=actual_sl_bot_order_ref_id,
  988. status='pending_submission',
  989. parent_bot_order_ref_id=parent_bot_ref_id_of_trigger # Linking actual SL to its trigger order
  990. )
  991. if not actual_sl_order_db_id:
  992. 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}"
  993. logger.error(msg)
  994. return {"success": False, "error": msg}
  995. # 2. Place the actual SL order on the exchange
  996. if use_market_order:
  997. logger.info(f"🚨 Placing ACTUAL SL ORDER (MARKET {sl_order_side.upper()}) from trigger {original_trigger_order_db_id}. New BotRef: {actual_sl_bot_order_ref_id}, Amount: {formatter.format_amount(amount, token)}, Trigger was: {formatter.format_price_with_symbol(stop_price, token)}")
  998. exchange_order_data, error_msg = self.client.place_market_order(symbol, sl_order_side, amount)
  999. else:
  1000. logger.info(f"📊 Placing ACTUAL SL ORDER (LIMIT {sl_order_side.upper()}) from trigger {original_trigger_order_db_id}. New BotRef: {actual_sl_bot_order_ref_id}, Amount: {formatter.format_amount(amount, token)}, Price: {formatter.format_price_with_symbol(stop_price, token)}")
  1001. exchange_order_data, error_msg = self.client.place_limit_order(symbol, sl_order_side, amount, stop_price)
  1002. if error_msg:
  1003. order_type_desc = "market" if use_market_order else "limit"
  1004. logger.error(f"Actual SL {order_type_desc} order placement failed for {symbol} (BotRef {actual_sl_bot_order_ref_id}): {error_msg}")
  1005. 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)
  1006. return {"success": False, "error": f"Actual SL {order_type_desc} order placement failed: {error_msg}"}
  1007. if not exchange_order_data:
  1008. order_type_desc = "market" if use_market_order else "limit"
  1009. 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.")
  1010. 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)
  1011. return {"success": False, "error": f"Actual SL {order_type_desc} order placement failed at client level."}
  1012. exchange_oid = exchange_order_data.get('id')
  1013. # 3. Update the actual SL order in DB with exchange_order_id and appropriate status
  1014. if exchange_oid:
  1015. # Market orders are 'submitted' until filled, limit orders are 'open' until triggered/filled
  1016. new_status = 'submitted' if use_market_order else 'open'
  1017. self.stats.update_order_status(
  1018. order_db_id=actual_sl_order_db_id,
  1019. new_status=new_status,
  1020. set_exchange_order_id=exchange_oid
  1021. )
  1022. else:
  1023. order_type_desc = "market" if use_market_order else "limit"
  1024. logger.warning(f"No exchange_order_id received for actual SL {order_type_desc} order (BotRef {actual_sl_bot_order_ref_id}).")
  1025. success_message = f"Actual Stop Loss {'MARKET' if use_market_order else 'LIMIT'} order placed successfully"
  1026. if use_market_order:
  1027. success_message += " for immediate execution (price moved beyond stop level)."
  1028. else:
  1029. success_message += f" at {formatter.format_price_with_symbol(stop_price, token)}."
  1030. return {
  1031. "success": True,
  1032. "message": success_message,
  1033. "placed_sl_order_details": {
  1034. "bot_order_ref_id": actual_sl_bot_order_ref_id,
  1035. "exchange_order_id": exchange_oid,
  1036. "order_db_id": actual_sl_order_db_id,
  1037. "symbol": symbol,
  1038. "side": sl_order_side,
  1039. "type": order_type_for_actual_sl,
  1040. "amount_requested": amount,
  1041. "price_requested": order_price,
  1042. "original_trigger_price": stop_price,
  1043. "current_market_price": current_price,
  1044. "used_market_order": use_market_order
  1045. }
  1046. }