trading_engine.py 67 KB

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