trading_engine.py 67 KB

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