market_monitor.py 98 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675
  1. #!/usr/bin/env python3
  2. """
  3. Market Monitor - Handles external trade monitoring and heartbeat functionality.
  4. """
  5. import logging
  6. import asyncio
  7. from datetime import datetime, timedelta, timezone
  8. from typing import Optional, Dict, Any, List
  9. import os
  10. import json
  11. from src.config.config import Config
  12. from src.monitoring.alarm_manager import AlarmManager
  13. logger = logging.getLogger(__name__)
  14. class MarketMonitor:
  15. """Handles external trade monitoring and market events."""
  16. def __init__(self, trading_engine, notification_manager=None):
  17. """Initialize the market monitor."""
  18. self.trading_engine = trading_engine
  19. self.notification_manager = notification_manager
  20. self.client = trading_engine.client
  21. self._last_processed_trade_time = datetime.now(timezone.utc) - timedelta(seconds=120)
  22. self._monitoring_active = False
  23. # 🆕 External stop loss tracking
  24. self._external_stop_loss_orders = {} # Format: {exchange_order_id: {'token': str, 'trigger_price': float, 'side': str, 'detected_at': datetime}}
  25. # External trade monitoring
  26. # self.state_file = "data/market_monitor_state.json" # Removed, state now in DB
  27. self.last_processed_trade_time: Optional[datetime] = None
  28. # Alarm management
  29. self.alarm_manager = AlarmManager()
  30. # Order monitoring
  31. self.last_known_orders = set()
  32. self.last_known_positions = {}
  33. self._load_state()
  34. async def start(self):
  35. """Start the market monitor."""
  36. if self._monitoring_active:
  37. return
  38. self._monitoring_active = True
  39. logger.info("🔄 Market monitor started")
  40. # Initialize tracking
  41. await self._initialize_tracking()
  42. # Start monitoring task
  43. self._monitor_task = asyncio.create_task(self._monitor_loop())
  44. async def stop(self):
  45. """Stop the market monitor."""
  46. if not self._monitoring_active:
  47. return
  48. self._monitoring_active = False
  49. if self._monitor_task:
  50. self._monitor_task.cancel()
  51. try:
  52. await self._monitor_task
  53. except asyncio.CancelledError:
  54. pass
  55. self._save_state()
  56. logger.info("🛑 Market monitor stopped")
  57. def _load_state(self):
  58. """Load market monitor state from SQLite DB via TradingStats."""
  59. stats = self.trading_engine.get_stats()
  60. if not stats:
  61. logger.warning("⚠️ TradingStats not available, cannot load MarketMonitor state.")
  62. self.last_processed_trade_time = None
  63. return
  64. try:
  65. last_time_str = stats._get_metadata('market_monitor_last_processed_trade_time')
  66. if last_time_str:
  67. self.last_processed_trade_time = datetime.fromisoformat(last_time_str)
  68. # Ensure it's timezone-aware (UTC)
  69. if self.last_processed_trade_time.tzinfo is None:
  70. self.last_processed_trade_time = self.last_processed_trade_time.replace(tzinfo=timezone.utc)
  71. else:
  72. self.last_processed_trade_time = self.last_processed_trade_time.astimezone(timezone.utc)
  73. logger.info(f"🔄 Loaded MarketMonitor state from DB: last_processed_trade_time = {self.last_processed_trade_time.isoformat()}")
  74. else:
  75. logger.info("💨 No MarketMonitor state (last_processed_trade_time) found in DB. Will start with fresh external trade tracking.")
  76. self.last_processed_trade_time = None
  77. except Exception as e:
  78. logger.error(f"Error loading MarketMonitor state from DB: {e}. Proceeding with default state.")
  79. self.last_processed_trade_time = None
  80. def _save_state(self):
  81. """Save market monitor state to SQLite DB via TradingStats."""
  82. stats = self.trading_engine.get_stats()
  83. if not stats:
  84. logger.warning("⚠️ TradingStats not available, cannot save MarketMonitor state.")
  85. return
  86. try:
  87. if self.last_processed_trade_time:
  88. # Ensure timestamp is UTC before saving
  89. lptt_utc = self.last_processed_trade_time
  90. if lptt_utc.tzinfo is None:
  91. lptt_utc = lptt_utc.replace(tzinfo=timezone.utc)
  92. else:
  93. lptt_utc = lptt_utc.astimezone(timezone.utc)
  94. stats._set_metadata('market_monitor_last_processed_trade_time', lptt_utc.isoformat())
  95. logger.info(f"💾 Saved MarketMonitor state (last_processed_trade_time) to DB: {lptt_utc.isoformat()}")
  96. else:
  97. # If it's None, we might want to remove the key or save it as an empty string
  98. # For now, let's assume we only save if there is a time. Or remove it.
  99. stats._set_metadata('market_monitor_last_processed_trade_time', '') # Or handle deletion
  100. logger.info("💾 MarketMonitor state (last_processed_trade_time) is None, saved as empty in DB.")
  101. except Exception as e:
  102. logger.error(f"Error saving MarketMonitor state to DB: {e}")
  103. async def _initialize_tracking(self):
  104. """Initialize order and position tracking."""
  105. try:
  106. # Get current open orders to initialize tracking
  107. orders = self.trading_engine.get_orders()
  108. if orders:
  109. self.last_known_orders = {order.get('id') for order in orders if order.get('id')}
  110. logger.info(f"📋 Initialized tracking with {len(self.last_known_orders)} open orders")
  111. # Get current positions for P&L tracking
  112. positions = self.trading_engine.get_positions()
  113. if positions:
  114. for position in positions:
  115. symbol = position.get('symbol')
  116. contracts = float(position.get('contracts', 0))
  117. entry_price = float(position.get('entryPx', 0))
  118. if symbol and contracts != 0:
  119. self.last_known_positions[symbol] = {
  120. 'contracts': contracts,
  121. 'entry_price': entry_price
  122. }
  123. logger.info(f"📊 Initialized tracking with {len(self.last_known_positions)} positions")
  124. except Exception as e:
  125. logger.error(f"❌ Error initializing tracking: {e}")
  126. async def _monitor_loop(self):
  127. """Main monitoring loop that runs every BOT_HEARTBEAT_SECONDS."""
  128. try:
  129. loop_count = 0
  130. while self._monitoring_active:
  131. # 🆕 PHASE 4: Check trades table for pending stop loss activation first (highest priority)
  132. await self._activate_pending_stop_losses_from_trades()
  133. await self._check_order_fills()
  134. await self._check_price_alarms()
  135. await self._check_external_trades()
  136. await self._check_pending_triggers()
  137. await self._check_automatic_risk_management()
  138. await self._check_external_stop_loss_orders()
  139. # Run orphaned stop loss cleanup every 10 heartbeats (less frequent but regular)
  140. loop_count += 1
  141. if loop_count % 10 == 0:
  142. await self._cleanup_orphaned_stop_losses()
  143. await self._cleanup_external_stop_loss_tracking()
  144. # 🆕 AUTO-SYNC: Check for orphaned positions every 10 heartbeats
  145. await self._auto_sync_orphaned_positions()
  146. loop_count = 0 # Reset counter to prevent overflow
  147. await asyncio.sleep(Config.BOT_HEARTBEAT_SECONDS)
  148. except asyncio.CancelledError:
  149. logger.info("Market monitor loop cancelled")
  150. raise
  151. except Exception as e:
  152. logger.error(f"Error in market monitor loop: {e}")
  153. # Restart after error
  154. if self._monitoring_active:
  155. await asyncio.sleep(5)
  156. await self._monitor_loop()
  157. async def _check_order_fills(self):
  158. """Check for filled orders and send notifications."""
  159. try:
  160. # Get current orders and positions
  161. current_orders = self.trading_engine.get_orders() or []
  162. current_positions = self.trading_engine.get_positions() or []
  163. # Get current order IDs
  164. current_order_ids = {order.get('id') for order in current_orders if order.get('id')}
  165. # Find filled orders (orders that were in last_known_orders but not in current_orders)
  166. disappeared_order_ids = self.last_known_orders - current_order_ids
  167. if disappeared_order_ids:
  168. logger.info(f"🎯 Detected {len(disappeared_order_ids)} bot orders no longer open: {list(disappeared_order_ids)}. Corresponding fills (if any) are processed by external trade checker.")
  169. await self._process_disappeared_orders(disappeared_order_ids)
  170. # Update tracking data for open bot orders
  171. self.last_known_orders = current_order_ids
  172. # Position state is primarily managed by TradingStats based on all fills.
  173. # This local tracking can provide supplementary logging if needed.
  174. # await self._update_position_tracking(current_positions)
  175. except Exception as e:
  176. logger.error(f"❌ Error checking order fills: {e}")
  177. async def _process_filled_orders(self, filled_order_ids: set, current_positions: list):
  178. """Process filled orders using enhanced position tracking."""
  179. try:
  180. # For bot-initiated orders, we'll detect changes in position size
  181. # and send appropriate notifications using the enhanced system
  182. # This method will be triggered when orders placed through the bot are filled
  183. # The external trade monitoring will handle trades made outside the bot
  184. # Update position tracking based on current positions
  185. await self._update_position_tracking(current_positions)
  186. except Exception as e:
  187. logger.error(f"❌ Error processing filled orders: {e}")
  188. async def _update_position_tracking(self, current_positions: list):
  189. """Update position tracking and calculate P&L changes."""
  190. try:
  191. new_position_map = {}
  192. for position in current_positions:
  193. symbol = position.get('symbol')
  194. contracts = float(position.get('contracts', 0))
  195. entry_price = float(position.get('entryPx', 0))
  196. if symbol and contracts != 0:
  197. new_position_map[symbol] = {
  198. 'contracts': contracts,
  199. 'entry_price': entry_price
  200. }
  201. # Compare with previous positions to detect changes
  202. for symbol, new_data in new_position_map.items():
  203. old_data = self.last_known_positions.get(symbol)
  204. if not old_data:
  205. # New position opened
  206. logger.info(f"📈 New position detected (observed by MarketMonitor): {symbol} {new_data['contracts']} @ ${new_data['entry_price']:.4f}. TradingStats is the definitive source.")
  207. elif abs(new_data['contracts'] - old_data['contracts']) > 0.000001:
  208. # Position size changed
  209. change = new_data['contracts'] - old_data['contracts']
  210. logger.info(f"📊 Position change detected (observed by MarketMonitor): {symbol} {change:+.6f} contracts. TradingStats is the definitive source.")
  211. # Check for closed positions
  212. for symbol in self.last_known_positions:
  213. if symbol not in new_position_map:
  214. logger.info(f"📉 Position closed (observed by MarketMonitor): {symbol}. TradingStats is the definitive source.")
  215. # Update tracking
  216. self.last_known_positions = new_position_map
  217. except Exception as e:
  218. logger.error(f"❌ Error updating position tracking: {e}")
  219. async def _process_disappeared_orders(self, disappeared_order_ids: set):
  220. """Log and investigate bot orders that have disappeared from the exchange."""
  221. stats = self.trading_engine.get_stats()
  222. if not stats:
  223. logger.warning("⚠️ TradingStats not available in _process_disappeared_orders.")
  224. return
  225. try:
  226. total_linked_cancelled = 0
  227. external_cancellations = []
  228. for exchange_oid in disappeared_order_ids:
  229. order_in_db = stats.get_order_by_exchange_id(exchange_oid)
  230. if order_in_db:
  231. last_status = order_in_db.get('status', 'unknown')
  232. order_type = order_in_db.get('type', 'unknown')
  233. symbol = order_in_db.get('symbol', 'unknown')
  234. token = symbol.split('/')[0] if '/' in symbol else symbol.split(':')[0]
  235. logger.info(f"Order {exchange_oid} was in our DB with status '{last_status}' but has now disappeared from exchange.")
  236. # Check if this was an unexpected disappearance (likely external cancellation)
  237. active_statuses = ['open', 'submitted', 'partially_filled', 'pending_submission']
  238. if last_status in active_statuses:
  239. logger.warning(f"⚠️ EXTERNAL CANCELLATION: Order {exchange_oid} with status '{last_status}' was likely cancelled externally on Hyperliquid")
  240. stats.update_order_status(exchange_order_id=exchange_oid, new_status='cancelled_externally')
  241. # Track external cancellations for notification
  242. external_cancellations.append({
  243. 'exchange_oid': exchange_oid,
  244. 'token': token,
  245. 'type': order_type,
  246. 'last_status': last_status
  247. })
  248. # Send notification about external cancellation
  249. if self.notification_manager:
  250. await self.notification_manager.send_generic_notification(
  251. f"⚠️ <b>External Order Cancellation Detected</b>\n\n"
  252. f"Token: {token}\n"
  253. f"Order Type: {order_type.replace('_', ' ').title()}\n"
  254. f"Exchange Order ID: <code>{exchange_oid[:8]}...</code>\n"
  255. f"Previous Status: {last_status.replace('_', ' ').title()}\n"
  256. f"Source: Cancelled directly on Hyperliquid\n"
  257. f"Time: {datetime.now().strftime('%H:%M:%S')}\n\n"
  258. f"🤖 Bot status updated automatically"
  259. )
  260. # 🔧 EDGE CASE FIX: Wait briefly before cancelling stop losses
  261. # Sometimes orders are cancelled externally but fills come through simultaneously
  262. logger.info(f"⏳ Waiting 3 seconds to check for potential fills before cancelling stop losses for {exchange_oid}")
  263. await asyncio.sleep(3)
  264. # Re-check the order status after waiting - it might have been filled
  265. order_in_db_updated = stats.get_order_by_exchange_id(exchange_oid)
  266. if order_in_db_updated and order_in_db_updated.get('status') in ['filled', 'partially_filled']:
  267. logger.info(f"✅ Order {exchange_oid} was filled during the wait period - NOT cancelling stop losses")
  268. # Don't cancel stop losses - let them be activated normally
  269. continue
  270. # Additional check: Look for very recent fills that might match this order
  271. recent_fill_found = await self._check_for_recent_fills_for_order(exchange_oid, order_in_db)
  272. if recent_fill_found:
  273. logger.info(f"✅ Found recent fill for order {exchange_oid} - NOT cancelling stop losses")
  274. continue
  275. else:
  276. # Normal completion/cancellation - update status
  277. stats.update_order_status(exchange_order_id=exchange_oid, new_status='disappeared_from_exchange')
  278. # Cancel any pending stop losses linked to this order (only if not filled)
  279. if order_in_db.get('bot_order_ref_id'):
  280. # Double-check one more time that the order wasn't filled
  281. final_order_check = stats.get_order_by_exchange_id(exchange_oid)
  282. if final_order_check and final_order_check.get('status') in ['filled', 'partially_filled']:
  283. logger.info(f"🛑 Order {exchange_oid} was filled - preserving stop losses")
  284. continue
  285. cancelled_sl_count = stats.cancel_linked_orders(
  286. parent_bot_order_ref_id=order_in_db['bot_order_ref_id'],
  287. new_status='cancelled_parent_disappeared'
  288. )
  289. total_linked_cancelled += cancelled_sl_count
  290. if cancelled_sl_count > 0:
  291. logger.info(f"Cancelled {cancelled_sl_count} pending stop losses linked to disappeared order {exchange_oid}")
  292. if self.notification_manager:
  293. await self.notification_manager.send_generic_notification(
  294. f"🛑 <b>Linked Stop Losses Cancelled</b>\n\n"
  295. f"Token: {token}\n"
  296. f"Cancelled: {cancelled_sl_count} stop loss(es)\n"
  297. f"Reason: Parent order {exchange_oid[:8]}... disappeared\n"
  298. f"Likely Cause: External cancellation on Hyperliquid\n"
  299. f"Time: {datetime.now().strftime('%H:%M:%S')}"
  300. )
  301. else:
  302. logger.warning(f"Order {exchange_oid} disappeared from exchange but was not found in our DB. This might be an order placed externally.")
  303. # Send summary notification if multiple external cancellations occurred
  304. if len(external_cancellations) > 1:
  305. tokens_affected = list(set(item['token'] for item in external_cancellations))
  306. if self.notification_manager:
  307. await self.notification_manager.send_generic_notification(
  308. f"⚠️ <b>Multiple External Cancellations Detected</b>\n\n"
  309. f"Orders Cancelled: {len(external_cancellations)}\n"
  310. f"Tokens Affected: {', '.join(tokens_affected)}\n"
  311. f"Source: Direct cancellation on Hyperliquid\n"
  312. f"Linked Stop Losses Cancelled: {total_linked_cancelled}\n"
  313. f"Time: {datetime.now().strftime('%H:%M:%S')}\n\n"
  314. f"💡 Check individual orders for details"
  315. )
  316. except Exception as e:
  317. logger.error(f"❌ Error processing disappeared orders: {e}", exc_info=True)
  318. async def _check_price_alarms(self):
  319. """Check price alarms and trigger notifications."""
  320. try:
  321. active_alarms = self.alarm_manager.get_all_active_alarms()
  322. if not active_alarms:
  323. return
  324. # Group alarms by token to minimize API calls
  325. tokens_to_check = list(set(alarm['token'] for alarm in active_alarms))
  326. for token in tokens_to_check:
  327. try:
  328. # Get current market price
  329. symbol = f"{token}/USDC:USDC"
  330. market_data = self.trading_engine.get_market_data(symbol)
  331. if not market_data or not market_data.get('ticker'):
  332. continue
  333. current_price = float(market_data['ticker'].get('last', 0))
  334. if current_price <= 0:
  335. continue
  336. # Check alarms for this token
  337. token_alarms = [alarm for alarm in active_alarms if alarm['token'] == token]
  338. for alarm in token_alarms:
  339. target_price = alarm['target_price']
  340. direction = alarm['direction']
  341. # Check if alarm should trigger
  342. should_trigger = False
  343. if direction == 'above' and current_price >= target_price:
  344. should_trigger = True
  345. elif direction == 'below' and current_price <= target_price:
  346. should_trigger = True
  347. if should_trigger:
  348. # Trigger the alarm
  349. triggered_alarm = self.alarm_manager.trigger_alarm(alarm['id'], current_price)
  350. if triggered_alarm:
  351. await self._send_alarm_notification(triggered_alarm)
  352. except Exception as e:
  353. logger.error(f"Error checking alarms for {token}: {e}")
  354. except Exception as e:
  355. logger.error(f"❌ Error checking price alarms: {e}")
  356. async def _send_alarm_notification(self, alarm: Dict[str, Any]):
  357. """Send notification for triggered alarm."""
  358. try:
  359. # Send through notification manager if available
  360. if self.notification_manager:
  361. await self.notification_manager.send_alarm_triggered_notification(
  362. alarm['token'],
  363. alarm['target_price'],
  364. alarm['triggered_price'],
  365. alarm['direction']
  366. )
  367. else:
  368. # Fallback to logging if notification manager not available
  369. logger.info(f"🔔 ALARM TRIGGERED: {alarm['token']} @ ${alarm['triggered_price']:,.2f}")
  370. except Exception as e:
  371. logger.error(f"❌ Error sending alarm notification: {e}")
  372. async def _check_external_trades(self):
  373. """Check for trades made outside the Telegram bot and update stats."""
  374. try:
  375. # Get recent fills from exchange
  376. recent_fills = self.trading_engine.get_recent_fills()
  377. if not recent_fills:
  378. logger.debug("No recent fills data available")
  379. return
  380. # Get last processed timestamp from database
  381. if not hasattr(self, '_last_processed_trade_time') or self._last_processed_trade_time is None:
  382. try:
  383. last_time_str = self.trading_engine.stats._get_metadata('last_processed_trade_time')
  384. if last_time_str:
  385. self._last_processed_trade_time = datetime.fromisoformat(last_time_str)
  386. logger.debug(f"Loaded last_processed_trade_time from DB: {self._last_processed_trade_time}")
  387. else:
  388. # If no last processed time, start from 1 hour ago to avoid processing too much history
  389. self._last_processed_trade_time = datetime.now(timezone.utc) - timedelta(hours=1)
  390. logger.info("No last_processed_trade_time found, setting to 1 hour ago (UTC).")
  391. except Exception as e:
  392. logger.warning(f"Could not load last_processed_trade_time from DB: {e}")
  393. self._last_processed_trade_time = datetime.now(timezone.utc) - timedelta(hours=1)
  394. # Process new fills
  395. external_trades_processed = 0
  396. symbols_with_fills = set() # Track symbols that had fills for stop loss activation
  397. for fill in recent_fills:
  398. try:
  399. # Parse fill data - CCXT format from fetch_my_trades
  400. trade_id = fill.get('id') # CCXT uses 'id' for trade ID
  401. timestamp_ms = fill.get('timestamp') # CCXT uses 'timestamp' (milliseconds)
  402. symbol = fill.get('symbol') # CCXT uses 'symbol' in full format like 'LTC/USDC:USDC'
  403. side = fill.get('side') # CCXT uses 'side' ('buy' or 'sell')
  404. amount = float(fill.get('amount', 0)) # CCXT uses 'amount'
  405. price = float(fill.get('price', 0)) # CCXT uses 'price'
  406. # Convert timestamp
  407. if timestamp_ms:
  408. timestamp_dt = datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc)
  409. else:
  410. timestamp_dt = datetime.now(timezone.utc)
  411. # Skip if already processed
  412. if timestamp_dt <= self._last_processed_trade_time:
  413. continue
  414. # Process as external trade if we reach here
  415. if symbol and side and amount > 0 and price > 0:
  416. # Symbol is already in full format for CCXT
  417. full_symbol = symbol
  418. token = symbol.split('/')[0] if '/' in symbol else symbol.split(':')[0]
  419. # Check if this might be a bot order fill by looking for exchange order ID
  420. # CCXT might have this in 'info' sub-object with the raw exchange data
  421. exchange_order_id_from_fill = None
  422. if 'info' in fill and isinstance(fill['info'], dict):
  423. # Look for Hyperliquid order ID in the raw response
  424. exchange_order_id_from_fill = fill['info'].get('oid')
  425. # 🆕 Check if this fill corresponds to an external stop loss order
  426. is_external_stop_loss = False
  427. stop_loss_info = None
  428. if exchange_order_id_from_fill and exchange_order_id_from_fill in self._external_stop_loss_orders:
  429. is_external_stop_loss = True
  430. stop_loss_info = self._external_stop_loss_orders[exchange_order_id_from_fill]
  431. logger.info(f"🛑 EXTERNAL STOP LOSS EXECUTION: {token} - Order {exchange_order_id_from_fill} filled @ ${price:.2f}")
  432. logger.info(f"🔍 Processing {'external stop loss' if is_external_stop_loss else 'external trade'}: {trade_id} - {side} {amount} {full_symbol} @ ${price:.2f}")
  433. stats = self.trading_engine.stats
  434. if not stats:
  435. logger.warning("⚠️ TradingStats not available in _check_external_trades.")
  436. continue
  437. # If this is an external stop loss execution, handle it specially
  438. if is_external_stop_loss and stop_loss_info:
  439. # 🧹 PHASE 3: Close active trade for stop loss execution
  440. active_trade = stats.get_active_trade_by_symbol(full_symbol, status='active')
  441. if active_trade:
  442. entry_price = active_trade.get('entry_price', 0)
  443. active_trade_side = active_trade.get('side')
  444. # Calculate realized P&L
  445. if active_trade_side == 'buy': # Long position
  446. realized_pnl = amount * (price - entry_price)
  447. else: # Short position
  448. realized_pnl = amount * (entry_price - price)
  449. stats.update_active_trade_closed(
  450. active_trade['id'], realized_pnl, timestamp_dt.isoformat()
  451. )
  452. logger.info(f"🛑 Active trade {active_trade['id']} closed via external stop loss - P&L: ${realized_pnl:.2f}")
  453. # Send specialized stop loss execution notification
  454. if self.notification_manager:
  455. await self.notification_manager.send_stop_loss_execution_notification(
  456. stop_loss_info, full_symbol, side, amount, price, 'long_closed', timestamp_dt.isoformat()
  457. )
  458. # Remove from tracking since it's now executed
  459. del self._external_stop_loss_orders[exchange_order_id_from_fill]
  460. logger.info(f"🛑 Processed external stop loss execution: {side} {amount} {full_symbol} @ ${price:.2f} (long_closed)")
  461. else:
  462. # Handle as regular external trade
  463. # Check if this corresponds to a bot order by exchange_order_id
  464. linked_order_db_id = None
  465. if exchange_order_id_from_fill:
  466. order_in_db = stats.get_order_by_exchange_id(exchange_order_id_from_fill)
  467. if order_in_db:
  468. linked_order_db_id = order_in_db.get('id')
  469. logger.info(f"🔗 Linked external fill {trade_id} to bot order DB ID {linked_order_db_id} (Exchange OID: {exchange_order_id_from_fill})")
  470. # Update order status to filled if it was open
  471. current_status = order_in_db.get('status', '')
  472. if current_status in ['open', 'partially_filled', 'pending_submission']:
  473. # Determine if this is a partial or full fill
  474. order_amount_requested = float(order_in_db.get('amount_requested', 0))
  475. if abs(amount - order_amount_requested) < 0.000001: # Allow small floating point differences
  476. new_status_after_fill = 'filled'
  477. else:
  478. new_status_after_fill = 'partially_filled'
  479. stats.update_order_status(
  480. order_db_id=linked_order_db_id,
  481. new_status=new_status_after_fill
  482. )
  483. logger.info(f"📊 Updated bot order {linked_order_db_id} status: {current_status} → {new_status_after_fill}")
  484. # Check if this order is now fully filled and has pending stop losses to activate
  485. if new_status_after_fill == 'filled':
  486. await self._activate_pending_stop_losses(order_in_db, stats)
  487. # 🧹 PHASE 3: Record trade simply, use active_trades for tracking
  488. stats.record_trade(
  489. full_symbol, side, amount, price,
  490. exchange_fill_id=trade_id, trade_type="external",
  491. timestamp=timestamp_dt.isoformat(),
  492. linked_order_table_id_to_link=linked_order_db_id
  493. )
  494. # Derive action type from trade context for notifications
  495. if linked_order_db_id:
  496. # Bot order - determine action from order context
  497. order_side = order_in_db.get('side', side).lower()
  498. if order_side == 'buy':
  499. action_type = 'long_opened'
  500. elif order_side == 'sell':
  501. action_type = 'short_opened'
  502. else:
  503. action_type = 'position_opened'
  504. else:
  505. # External trade - determine from current active trades
  506. existing_active_trade = stats.get_active_trade_by_symbol(full_symbol, status='active')
  507. if existing_active_trade:
  508. # Has active position - this is likely a closure
  509. existing_side = existing_active_trade.get('side')
  510. if existing_side == 'buy' and side.lower() == 'sell':
  511. action_type = 'long_closed'
  512. elif existing_side == 'sell' and side.lower() == 'buy':
  513. action_type = 'short_closed'
  514. else:
  515. action_type = 'position_modified'
  516. else:
  517. # No active position - this opens a new one
  518. if side.lower() == 'buy':
  519. action_type = 'long_opened'
  520. else:
  521. action_type = 'short_opened'
  522. # 🧹 PHASE 3: Update active trades based on action
  523. if linked_order_db_id:
  524. # Bot order - update linked active trade
  525. order_data = stats.get_order_by_db_id(linked_order_db_id)
  526. if order_data:
  527. exchange_order_id = order_data.get('exchange_order_id')
  528. # Find active trade by entry order ID
  529. all_active_trades = stats.get_all_active_trades()
  530. for at in all_active_trades:
  531. if at.get('entry_order_id') == exchange_order_id:
  532. active_trade_id = at['id']
  533. current_status = at['status']
  534. if current_status == 'pending' and action_type in ['long_opened', 'short_opened']:
  535. # Entry order filled - update active trade to active
  536. stats.update_active_trade_opened(
  537. active_trade_id, price, amount, timestamp_dt.isoformat()
  538. )
  539. logger.info(f"🆕 Active trade {active_trade_id} opened via fill {trade_id}")
  540. elif current_status == 'active' and action_type in ['long_closed', 'short_closed']:
  541. # Exit order filled - calculate P&L and close active trade
  542. entry_price = at.get('entry_price', 0)
  543. active_trade_side = at.get('side')
  544. # Calculate realized P&L
  545. if active_trade_side == 'buy': # Long position
  546. realized_pnl = amount * (price - entry_price)
  547. else: # Short position
  548. realized_pnl = amount * (entry_price - price)
  549. stats.update_active_trade_closed(
  550. active_trade_id, realized_pnl, timestamp_dt.isoformat()
  551. )
  552. logger.info(f"🆕 Active trade {active_trade_id} closed via fill {trade_id} - P&L: ${realized_pnl:.2f}")
  553. break
  554. elif action_type in ['long_opened', 'short_opened']:
  555. # External trade that opened a position - create external active trade
  556. active_trade_id = stats.create_active_trade(
  557. symbol=full_symbol,
  558. side=side.lower(),
  559. entry_order_id=None, # External order
  560. trade_type='external'
  561. )
  562. if active_trade_id:
  563. stats.update_active_trade_opened(
  564. active_trade_id, price, amount, timestamp_dt.isoformat()
  565. )
  566. logger.info(f"🆕 Created external active trade {active_trade_id} for {side.upper()} {full_symbol}")
  567. elif action_type in ['long_closed', 'short_closed']:
  568. # External closure - close any active trade for this symbol
  569. active_trade = stats.get_active_trade_by_symbol(full_symbol, status='active')
  570. if active_trade:
  571. entry_price = active_trade.get('entry_price', 0)
  572. active_trade_side = active_trade.get('side')
  573. # Calculate realized P&L
  574. if active_trade_side == 'buy': # Long position
  575. realized_pnl = amount * (price - entry_price)
  576. else: # Short position
  577. realized_pnl = amount * (entry_price - price)
  578. stats.update_active_trade_closed(
  579. active_trade['id'], realized_pnl, timestamp_dt.isoformat()
  580. )
  581. logger.info(f"🆕 External closure: Active trade {active_trade['id']} closed - P&L: ${realized_pnl:.2f}")
  582. # Track symbol for potential stop loss activation
  583. symbols_with_fills.add(token)
  584. # Send notification for external trade
  585. if self.notification_manager:
  586. await self.notification_manager.send_external_trade_notification(
  587. full_symbol, side, amount, price, action_type, timestamp_dt.isoformat()
  588. )
  589. logger.info(f"📋 Processed external trade: {side} {amount} {full_symbol} @ ${price:.2f} ({action_type}) using timestamp {timestamp_dt.isoformat()}")
  590. external_trades_processed += 1
  591. # Update last processed time
  592. self._last_processed_trade_time = timestamp_dt
  593. except Exception as e:
  594. logger.error(f"Error processing fill {fill}: {e}")
  595. continue
  596. # Save the last processed timestamp to database
  597. if external_trades_processed > 0:
  598. self.trading_engine.stats._set_metadata('last_processed_trade_time', self._last_processed_trade_time.isoformat())
  599. logger.info(f"💾 Saved MarketMonitor state (last_processed_trade_time) to DB: {self._last_processed_trade_time.isoformat()}")
  600. logger.info(f"📊 Processed {external_trades_processed} external trades")
  601. # Additional check: Activate any pending stop losses for symbols that had fills
  602. # This is a safety net for cases where the fill linking above didn't work
  603. await self._check_pending_stop_losses_for_filled_symbols(symbols_with_fills)
  604. except Exception as e:
  605. logger.error(f"❌ Error checking external trades: {e}", exc_info=True)
  606. async def _check_pending_stop_losses_for_filled_symbols(self, symbols_with_fills: set):
  607. """
  608. Safety net: Check if any symbols that just had fills have pending stop losses
  609. that should be activated. This handles cases where fill linking failed.
  610. """
  611. try:
  612. if not symbols_with_fills:
  613. return
  614. stats = self.trading_engine.stats
  615. if not stats:
  616. return
  617. # Get all pending stop losses
  618. pending_stop_losses = stats.get_orders_by_status('pending_trigger', 'stop_limit_trigger')
  619. if not pending_stop_losses:
  620. return
  621. # Check each pending stop loss to see if its symbol had fills
  622. activated_any = False
  623. for sl_order in pending_stop_losses:
  624. symbol = sl_order.get('symbol', '')
  625. token = symbol.split('/')[0] if '/' in symbol else ''
  626. if token in symbols_with_fills:
  627. # This symbol had fills - check if we should activate the stop loss
  628. parent_bot_ref_id = sl_order.get('parent_bot_order_ref_id')
  629. if parent_bot_ref_id:
  630. # Get the parent order
  631. parent_order = stats.get_order_by_bot_ref_id(parent_bot_ref_id)
  632. if parent_order and parent_order.get('status') in ['filled', 'partially_filled']:
  633. logger.info(f"🛑 Safety net activation: Found pending SL for {token} with filled parent order {parent_bot_ref_id}")
  634. await self._activate_pending_stop_losses(parent_order, stats)
  635. activated_any = True
  636. if activated_any:
  637. logger.info("✅ Safety net activated pending stop losses for symbols with recent fills")
  638. except Exception as e:
  639. logger.error(f"Error in safety net stop loss activation: {e}", exc_info=True)
  640. async def _check_pending_triggers(self):
  641. """Check and process pending conditional triggers (e.g., SL/TP)."""
  642. stats = self.trading_engine.get_stats()
  643. if not stats:
  644. logger.warning("⚠️ TradingStats not available in _check_pending_triggers.")
  645. return
  646. try:
  647. # Fetch pending SL triggers (adjust type if TP triggers are different)
  648. # For now, assuming 'STOP_LIMIT_TRIGGER' is the type used for SLs that become limit orders
  649. pending_sl_triggers = stats.get_orders_by_status(status='pending_trigger', order_type_filter='stop_limit_trigger')
  650. if not pending_sl_triggers:
  651. return
  652. logger.debug(f"Found {len(pending_sl_triggers)} pending SL triggers to check.")
  653. for trigger_order in pending_sl_triggers:
  654. symbol = trigger_order['symbol']
  655. trigger_price = trigger_order['price'] # This is the stop price
  656. trigger_side = trigger_order['side'] # This is the side of the SL order (e.g., sell for a long position's SL)
  657. order_db_id = trigger_order['id']
  658. parent_ref_id = trigger_order.get('parent_bot_order_ref_id')
  659. if not symbol or trigger_price is None:
  660. logger.warning(f"Invalid trigger order data for DB ID {order_db_id}, skipping: {trigger_order}")
  661. continue
  662. market_data = self.trading_engine.get_market_data(symbol)
  663. if not market_data or not market_data.get('ticker'):
  664. logger.warning(f"Could not fetch market data for {symbol} to check SL trigger {order_db_id}.")
  665. continue
  666. current_price = float(market_data['ticker'].get('last', 0))
  667. if current_price <= 0:
  668. logger.warning(f"Invalid current price ({current_price}) for {symbol} checking SL trigger {order_db_id}.")
  669. continue
  670. trigger_hit = False
  671. if trigger_side.lower() == 'sell' and current_price <= trigger_price:
  672. trigger_hit = True
  673. logger.info(f"🔴 SL TRIGGER HIT (Sell): Order DB ID {order_db_id}, Symbol {symbol}, Trigger@ ${trigger_price:.4f}, Market@ ${current_price:.4f}")
  674. elif trigger_side.lower() == 'buy' and current_price >= trigger_price:
  675. trigger_hit = True
  676. logger.info(f"🟢 SL TRIGGER HIT (Buy): Order DB ID {order_db_id}, Symbol {symbol}, Trigger@ ${trigger_price:.4f}, Market@ ${current_price:.4f}")
  677. if trigger_hit:
  678. logger.info(f"Attempting to execute actual stop order for triggered DB ID: {order_db_id} (Parent Bot Ref: {trigger_order.get('parent_bot_order_ref_id')})")
  679. execution_result = await self.trading_engine.execute_triggered_stop_order(original_trigger_order_db_id=order_db_id)
  680. notification_message_detail = ""
  681. if execution_result.get("success"):
  682. new_trigger_status = 'triggered_order_placed'
  683. placed_sl_details = execution_result.get("placed_sl_order_details", {})
  684. logger.info(f"Successfully placed actual SL order from trigger {order_db_id}. New SL Order DB ID: {placed_sl_details.get('order_db_id')}, Exchange ID: {placed_sl_details.get('exchange_order_id')}")
  685. notification_message_detail = f"Actual SL order placed (New DB ID: {placed_sl_details.get('order_db_id', 'N/A')})."
  686. else:
  687. new_trigger_status = 'trigger_execution_failed'
  688. error_msg = execution_result.get("error", "Unknown error during SL execution.")
  689. logger.error(f"Failed to execute actual SL order from trigger {order_db_id}: {error_msg}")
  690. notification_message_detail = f"Failed to place actual SL order: {error_msg}"
  691. stats.update_order_status(order_db_id=order_db_id, new_status=new_trigger_status)
  692. if self.notification_manager:
  693. await self.notification_manager.send_generic_notification(
  694. f"🔔 Stop-Loss Update!\nSymbol: {symbol}\nSide: {trigger_side.upper()}\nTrigger Price: ${trigger_price:.4f}\nMarket Price: ${current_price:.4f}\n(Original Trigger DB ID: {order_db_id}, Parent: {parent_ref_id or 'N/A'})\nStatus: {new_trigger_status.replace('_', ' ').title()}\nDetails: {notification_message_detail}"
  695. )
  696. except Exception as e:
  697. logger.error(f"❌ Error checking pending SL triggers: {e}", exc_info=True)
  698. async def _check_automatic_risk_management(self):
  699. """Check for automatic stop loss triggers based on Config.STOP_LOSS_PERCENTAGE as safety net."""
  700. try:
  701. # Skip if risk management is disabled or percentage is 0
  702. if not getattr(Config, 'RISK_MANAGEMENT_ENABLED', True) or Config.STOP_LOSS_PERCENTAGE <= 0:
  703. return
  704. # Get current positions
  705. positions = self.trading_engine.get_positions()
  706. if not positions:
  707. # If no positions exist, clean up any orphaned pending stop losses
  708. await self._cleanup_orphaned_stop_losses()
  709. return
  710. for position in positions:
  711. try:
  712. symbol = position.get('symbol', '')
  713. contracts = float(position.get('contracts', 0))
  714. entry_price = float(position.get('entryPx', 0))
  715. mark_price = float(position.get('markPx', 0))
  716. unrealized_pnl = float(position.get('unrealizedPnl', 0))
  717. # Skip if no position or missing data
  718. if contracts == 0 or entry_price <= 0 or mark_price <= 0:
  719. continue
  720. # Calculate PnL percentage based on entry value
  721. entry_value = abs(contracts) * entry_price
  722. if entry_value <= 0:
  723. continue
  724. pnl_percentage = (unrealized_pnl / entry_value) * 100
  725. # Check if loss exceeds the safety threshold
  726. if pnl_percentage <= -Config.STOP_LOSS_PERCENTAGE:
  727. token = symbol.split('/')[0] if '/' in symbol else symbol.split(':')[0]
  728. position_side = "LONG" if contracts > 0 else "SHORT"
  729. logger.warning(f"🚨 AUTOMATIC STOP LOSS TRIGGERED: {token} {position_side} position has {pnl_percentage:.2f}% loss (threshold: -{Config.STOP_LOSS_PERCENTAGE}%)")
  730. # Send notification before attempting exit
  731. if self.notification_manager:
  732. await self.notification_manager.send_generic_notification(
  733. f"🚨 AUTOMATIC STOP LOSS TRIGGERED!\n"
  734. f"Token: {token}\n"
  735. f"Position: {position_side} {abs(contracts):.6f}\n"
  736. f"Entry Price: ${entry_price:.4f}\n"
  737. f"Current Price: ${mark_price:.4f}\n"
  738. f"Unrealized PnL: ${unrealized_pnl:.2f} ({pnl_percentage:.2f}%)\n"
  739. f"Safety Threshold: -{Config.STOP_LOSS_PERCENTAGE}%\n"
  740. f"Action: Executing emergency exit order..."
  741. )
  742. # Execute emergency exit order
  743. exit_result = await self.trading_engine.execute_exit_order(token)
  744. if exit_result.get('success'):
  745. logger.info(f"✅ Emergency exit order placed successfully for {token}. Order details: {exit_result.get('order_placed_details', {})}")
  746. # Cancel any pending stop losses for this symbol since position is now closed
  747. stats = self.trading_engine.get_stats()
  748. if stats:
  749. cancelled_sl_count = stats.cancel_pending_stop_losses_by_symbol(
  750. symbol=symbol,
  751. new_status='cancelled_auto_exit'
  752. )
  753. if cancelled_sl_count > 0:
  754. logger.info(f"🛑 Cancelled {cancelled_sl_count} pending stop losses for {symbol} after automatic exit")
  755. if self.notification_manager:
  756. await self.notification_manager.send_generic_notification(
  757. f"✅ <b>Emergency Exit Completed</b>\n\n"
  758. f"📊 <b>Position:</b> {token} {position_side}\n"
  759. f"📉 <b>Loss:</b> {pnl_percentage:.2f}% (${unrealized_pnl:.2f})\n"
  760. f"⚠️ <b>Threshold:</b> -{Config.STOP_LOSS_PERCENTAGE}%\n"
  761. f"✅ <b>Action:</b> Position automatically closed\n"
  762. f"💰 <b>Exit Price:</b> ~${mark_price:.2f}\n"
  763. f"🆔 <b>Order ID:</b> {exit_result.get('order_placed_details', {}).get('exchange_order_id', 'N/A')}\n"
  764. f"{f'🛑 <b>Cleanup:</b> Cancelled {cancelled_sl_count} pending stop losses' if cancelled_sl_count > 0 else ''}\n\n"
  765. f"🛡️ This was an automatic safety stop triggered by the risk management system."
  766. )
  767. else:
  768. error_msg = exit_result.get('error', 'Unknown error')
  769. logger.error(f"❌ Failed to execute emergency exit order for {token}: {error_msg}")
  770. if self.notification_manager:
  771. await self.notification_manager.send_generic_notification(
  772. f"❌ <b>CRITICAL: Emergency Exit Failed!</b>\n\n"
  773. f"📊 <b>Position:</b> {token} {position_side}\n"
  774. f"📉 <b>Loss:</b> {pnl_percentage:.2f}%\n"
  775. f"❌ <b>Error:</b> {error_msg}\n\n"
  776. f"⚠️ <b>MANUAL INTERVENTION REQUIRED</b>\n"
  777. f"Please close this position manually via /exit {token}"
  778. )
  779. except Exception as pos_error:
  780. logger.error(f"Error processing position for automatic stop loss: {pos_error}")
  781. continue
  782. except Exception as e:
  783. logger.error(f"❌ Error in automatic risk management check: {e}", exc_info=True)
  784. async def _cleanup_orphaned_stop_losses(self):
  785. """Clean up pending stop losses that no longer have corresponding positions OR whose parent orders have been cancelled/failed."""
  786. try:
  787. stats = self.trading_engine.get_stats()
  788. if not stats:
  789. return
  790. # Get all pending stop loss triggers
  791. pending_stop_losses = stats.get_orders_by_status('pending_trigger', 'stop_limit_trigger')
  792. if not pending_stop_losses:
  793. return
  794. logger.debug(f"Checking {len(pending_stop_losses)} pending stop losses for orphaned orders")
  795. # Get current positions to check against
  796. current_positions = self.trading_engine.get_positions()
  797. position_symbols = set()
  798. if current_positions:
  799. for pos in current_positions:
  800. symbol = pos.get('symbol')
  801. contracts = float(pos.get('contracts', 0))
  802. if symbol and contracts != 0:
  803. position_symbols.add(symbol)
  804. # Check each pending stop loss
  805. orphaned_count = 0
  806. for sl_order in pending_stop_losses:
  807. symbol = sl_order.get('symbol')
  808. order_db_id = sl_order.get('id')
  809. parent_bot_ref_id = sl_order.get('parent_bot_order_ref_id')
  810. should_cancel = False
  811. cancel_reason = ""
  812. # Check if parent order exists and its status
  813. if parent_bot_ref_id:
  814. parent_order = stats.get_order_by_bot_ref_id(parent_bot_ref_id)
  815. if parent_order:
  816. parent_status = parent_order.get('status', '').lower()
  817. # Cancel if parent order failed, was cancelled, or disappeared
  818. if parent_status in ['failed_submission', 'failed_submission_no_data', 'cancelled_manually',
  819. 'cancelled_externally', 'disappeared_from_exchange']:
  820. should_cancel = True
  821. cancel_reason = f"parent order {parent_status}"
  822. elif parent_status == 'filled':
  823. # Parent order filled but no position - position might have been closed externally
  824. if symbol not in position_symbols:
  825. should_cancel = True
  826. cancel_reason = "parent filled but position no longer exists"
  827. # If parent is still 'open', 'submitted', or 'partially_filled', keep the stop loss
  828. else:
  829. # Parent order not found in DB - this is truly orphaned
  830. should_cancel = True
  831. cancel_reason = "parent order not found in database"
  832. else:
  833. # No parent reference - fallback to old logic (position-based check)
  834. if symbol not in position_symbols:
  835. should_cancel = True
  836. cancel_reason = "no position exists and no parent reference"
  837. if should_cancel:
  838. # Cancel this orphaned stop loss
  839. success = stats.update_order_status(
  840. order_db_id=order_db_id,
  841. new_status='cancelled_orphaned_no_position'
  842. )
  843. if success:
  844. orphaned_count += 1
  845. token = symbol.split('/')[0] if '/' in symbol else symbol.split(':')[0]
  846. logger.info(f"🧹 Cancelled orphaned stop loss for {token} (Order DB ID: {order_db_id}) - {cancel_reason}")
  847. if orphaned_count > 0:
  848. logger.info(f"🧹 Cleanup completed: Cancelled {orphaned_count} orphaned stop loss orders")
  849. if self.notification_manager:
  850. await self.notification_manager.send_generic_notification(
  851. f"🧹 <b>Cleanup Completed</b>\n\n"
  852. f"Cancelled {orphaned_count} orphaned stop loss order(s)\n"
  853. f"Reason: Parent orders cancelled/failed or positions closed externally\n"
  854. f"Time: {datetime.now().strftime('%H:%M:%S')}\n\n"
  855. f"💡 This automatic cleanup ensures stop losses stay synchronized with actual orders and positions."
  856. )
  857. except Exception as e:
  858. logger.error(f"❌ Error cleaning up orphaned stop losses: {e}", exc_info=True)
  859. async def _activate_pending_stop_losses_from_trades(self):
  860. """🆕 PHASE 4: Check trades table for pending stop loss activation first (highest priority)"""
  861. try:
  862. stats = self.trading_engine.get_stats()
  863. if not stats:
  864. return
  865. # Get open positions that need stop loss activation
  866. trades_needing_sl = stats.get_pending_stop_loss_activations()
  867. if not trades_needing_sl:
  868. return
  869. logger.debug(f"🆕 Found {len(trades_needing_sl)} open positions needing stop loss activation")
  870. for position_trade in trades_needing_sl:
  871. try:
  872. symbol = position_trade['symbol']
  873. token = symbol.split('/')[0] if '/' in symbol else symbol
  874. stop_loss_price = position_trade['stop_loss_price']
  875. position_side = position_trade['position_side']
  876. current_amount = position_trade.get('current_position_size', 0)
  877. lifecycle_id = position_trade['trade_lifecycle_id']
  878. # Get current market price
  879. current_price = None
  880. try:
  881. market_data = self.trading_engine.get_market_data(symbol)
  882. if market_data and market_data.get('ticker'):
  883. current_price = float(market_data['ticker'].get('last', 0))
  884. except Exception as price_error:
  885. logger.warning(f"Could not fetch current price for {symbol}: {price_error}")
  886. # Determine stop loss side based on position side
  887. sl_side = 'sell' if position_side == 'long' else 'buy' # Long SL = sell, Short SL = buy
  888. # Check if trigger condition is already met
  889. trigger_already_hit = False
  890. trigger_reason = ""
  891. if current_price and current_price > 0:
  892. if sl_side == 'sell' and current_price <= stop_loss_price:
  893. # LONG position stop loss - price below trigger
  894. trigger_already_hit = True
  895. trigger_reason = f"LONG SL: Current ${current_price:.4f} ≤ Stop ${stop_loss_price:.4f}"
  896. elif sl_side == 'buy' and current_price >= stop_loss_price:
  897. # SHORT position stop loss - price above trigger
  898. trigger_already_hit = True
  899. trigger_reason = f"SHORT SL: Current ${current_price:.4f} ≥ Stop ${stop_loss_price:.4f}"
  900. if trigger_already_hit:
  901. # Execute immediate market close
  902. logger.warning(f"🚨 IMMEDIATE SL EXECUTION (Trades Table): {token} - {trigger_reason}")
  903. try:
  904. exit_result = await self.trading_engine.execute_exit_order(token)
  905. if exit_result.get('success'):
  906. logger.info(f"✅ Immediate {position_side.upper()} SL execution successful for {token}")
  907. if self.notification_manager:
  908. await self.notification_manager.send_generic_notification(
  909. f"🚨 <b>Immediate Stop Loss Execution</b>\n\n"
  910. f"🆕 <b>Source: Unified Trades Table (Phase 4)</b>\n"
  911. f"Token: {token}\n"
  912. f"Position Type: {position_side.upper()}\n"
  913. f"SL Trigger Price: ${stop_loss_price:.4f}\n"
  914. f"Current Market Price: ${current_price:.4f}\n"
  915. f"Trigger Logic: {trigger_reason}\n"
  916. f"Action: Market close order placed immediately\n"
  917. f"Order ID: {exit_result.get('order_placed_details', {}).get('exchange_order_id', 'N/A')}\n"
  918. f"Lifecycle ID: {lifecycle_id[:8]}\n"
  919. f"Time: {datetime.now().strftime('%H:%M:%S')}\n\n"
  920. f"⚡ Single source of truth prevents missed stop losses"
  921. )
  922. else:
  923. logger.error(f"❌ Failed to execute immediate SL for {token}: {exit_result.get('error')}")
  924. except Exception as exec_error:
  925. logger.error(f"❌ Exception during immediate SL execution for {token}: {exec_error}")
  926. else:
  927. # Normal activation - place stop loss order
  928. try:
  929. sl_result = await self.trading_engine.execute_stop_loss_order(token, stop_loss_price)
  930. if sl_result.get('success'):
  931. sl_order_id = sl_result.get('order_placed_details', {}).get('exchange_order_id')
  932. # Link the stop loss order to the trade lifecycle
  933. stats.link_stop_loss_to_trade(lifecycle_id, sl_order_id, stop_loss_price)
  934. logger.info(f"✅ Activated {position_side.upper()} stop loss for {token}: ${stop_loss_price:.4f}")
  935. if self.notification_manager:
  936. await self.notification_manager.send_generic_notification(
  937. f"🛑 <b>Stop Loss Activated</b>\n\n"
  938. f"🆕 <b>Source: Unified Trades Table (Phase 4)</b>\n"
  939. f"Token: {token}\n"
  940. f"Position Type: {position_side.upper()}\n"
  941. f"Stop Loss Price: ${stop_loss_price:.4f}\n"
  942. f"Current Price: ${current_price:.4f if current_price else 'Unknown'}\n"
  943. f"Order ID: {sl_order_id or 'N/A'}\n"
  944. f"Lifecycle ID: {lifecycle_id[:8]}\n"
  945. f"Time: {datetime.now().strftime('%H:%M:%S')}\n\n"
  946. f"🛡️ Your position is now protected"
  947. )
  948. else:
  949. logger.error(f"❌ Failed to activate SL for {token}: {sl_result.get('error')}")
  950. except Exception as activation_error:
  951. logger.error(f"❌ Exception during SL activation for {token}: {activation_error}")
  952. except Exception as trade_error:
  953. logger.error(f"❌ Error processing position trade for SL activation: {trade_error}")
  954. except Exception as e:
  955. logger.error(f"❌ Error activating pending stop losses from trades table: {e}", exc_info=True)
  956. async def _activate_pending_stop_losses(self, order_in_db, stats):
  957. """Activate pending stop losses for a filled order, checking current price for immediate execution."""
  958. try:
  959. # Fetch pending stop losses for this order
  960. pending_stop_losses = stats.get_orders_by_status('pending_trigger', 'stop_limit_trigger', order_in_db['bot_order_ref_id'])
  961. if not pending_stop_losses:
  962. return
  963. symbol = order_in_db.get('symbol')
  964. token = symbol.split('/')[0] if '/' in symbol and symbol else 'Unknown'
  965. logger.debug(f"Found {len(pending_stop_losses)} pending stop loss(es) for filled order {order_in_db.get('exchange_order_id', 'N/A')}")
  966. # Get current market price for the symbol
  967. current_price = None
  968. try:
  969. market_data = self.trading_engine.get_market_data(symbol)
  970. if market_data and market_data.get('ticker'):
  971. current_price = float(market_data['ticker'].get('last', 0))
  972. if current_price <= 0:
  973. current_price = None
  974. except Exception as price_error:
  975. logger.warning(f"Could not fetch current price for {symbol}: {price_error}")
  976. current_price = None
  977. # Check if we still have a position for this symbol after the fill
  978. if symbol:
  979. # Try to get current position
  980. try:
  981. position = self.trading_engine.find_position(token)
  982. if position and float(position.get('contracts', 0)) != 0:
  983. # Position exists - check each stop loss
  984. activated_count = 0
  985. immediately_executed_count = 0
  986. for sl_order in pending_stop_losses:
  987. sl_trigger_price = float(sl_order.get('price', 0))
  988. sl_side = sl_order.get('side', '').lower() # 'sell' for long SL, 'buy' for short SL
  989. sl_db_id = sl_order.get('id')
  990. sl_amount = sl_order.get('amount_requested', 0)
  991. if not sl_trigger_price or not sl_side or not sl_db_id:
  992. logger.warning(f"Invalid stop loss data for DB ID {sl_db_id}, skipping")
  993. continue
  994. # Check if trigger condition is already met
  995. trigger_already_hit = False
  996. trigger_reason = ""
  997. if current_price and current_price > 0:
  998. if sl_side == 'sell' and current_price <= sl_trigger_price:
  999. # LONG position stop loss - price has fallen below trigger
  1000. # Long SL = SELL order that triggers when price drops below stop price
  1001. trigger_already_hit = True
  1002. trigger_reason = f"LONG SL: Current price ${current_price:.4f} ≤ Stop price ${sl_trigger_price:.4f}"
  1003. elif sl_side == 'buy' and current_price >= sl_trigger_price:
  1004. # SHORT position stop loss - price has risen above trigger
  1005. # Short SL = BUY order that triggers when price rises above stop price
  1006. trigger_already_hit = True
  1007. trigger_reason = f"SHORT SL: Current price ${current_price:.4f} ≥ Stop price ${sl_trigger_price:.4f}"
  1008. if trigger_already_hit:
  1009. # Execute immediate market close instead of activating stop loss
  1010. logger.warning(f"🚨 IMMEDIATE SL EXECUTION: {token} - {trigger_reason}")
  1011. # Update the stop loss status to show it was immediately executed
  1012. stats.update_order_status(order_db_id=sl_db_id, new_status='immediately_executed_on_activation')
  1013. # Execute market order to close position
  1014. try:
  1015. exit_result = await self.trading_engine.execute_exit_order(token)
  1016. if exit_result.get('success'):
  1017. immediately_executed_count += 1
  1018. position_side = "LONG" if sl_side == 'sell' else "SHORT"
  1019. logger.info(f"✅ Immediate {position_side} SL execution successful for {token}. Market order placed: {exit_result.get('order_placed_details', {}).get('exchange_order_id', 'N/A')}")
  1020. if self.notification_manager:
  1021. await self.notification_manager.send_generic_notification(
  1022. f"🚨 <b>Immediate Stop Loss Execution</b>\n\n"
  1023. f"Token: {token}\n"
  1024. f"Position Type: {position_side}\n"
  1025. f"SL Trigger Price: ${sl_trigger_price:.4f}\n"
  1026. f"Current Market Price: ${current_price:.4f}\n"
  1027. f"Trigger Logic: {trigger_reason}\n"
  1028. f"Action: Market close order placed immediately\n"
  1029. f"Reason: Trigger condition already met when activating\n"
  1030. f"Order ID: {exit_result.get('order_placed_details', {}).get('exchange_order_id', 'N/A')}\n"
  1031. f"Time: {datetime.now().strftime('%H:%M:%S')}\n\n"
  1032. f"⚡ This prevents waiting for a trigger that's already passed"
  1033. )
  1034. else:
  1035. logger.error(f"❌ Failed to execute immediate SL for {token}: {exit_result.get('error', 'Unknown error')}")
  1036. if self.notification_manager:
  1037. await self.notification_manager.send_generic_notification(
  1038. f"❌ <b>Immediate SL Execution Failed</b>\n\n"
  1039. f"Token: {token}\n"
  1040. f"SL Price: ${sl_trigger_price:.4f}\n"
  1041. f"Current Price: ${current_price:.4f}\n"
  1042. f"Trigger Logic: {trigger_reason}\n"
  1043. f"Error: {exit_result.get('error', 'Unknown error')}\n\n"
  1044. f"⚠️ Manual intervention may be required"
  1045. )
  1046. # Revert status since execution failed
  1047. stats.update_order_status(order_db_id=sl_db_id, new_status='activation_execution_failed')
  1048. except Exception as exec_error:
  1049. logger.error(f"❌ Exception during immediate SL execution for {token}: {exec_error}")
  1050. stats.update_order_status(order_db_id=sl_db_id, new_status='activation_execution_error')
  1051. else:
  1052. # Normal activation - trigger condition not yet met
  1053. activated_count += 1
  1054. position_side = "LONG" if sl_side == 'sell' else "SHORT"
  1055. logger.info(f"✅ Activating {position_side} stop loss for {token}: SL price ${sl_trigger_price:.4f} (Current: ${current_price:.4f if current_price else 'Unknown'})")
  1056. # Send summary notification for normal activations
  1057. if activated_count > 0 and self.notification_manager:
  1058. await self.notification_manager.send_generic_notification(
  1059. f"🛑 <b>Stop Losses Activated</b>\n\n"
  1060. f"Symbol: {token}\n"
  1061. f"Activated: {activated_count} stop loss(es)\n"
  1062. f"Current Price: ${current_price:.4f if current_price else 'Unknown'}\n"
  1063. f"Status: Monitoring for trigger conditions"
  1064. f"{f'\\n\\n⚡ Additionally executed {immediately_executed_count} stop loss(es) immediately due to current market conditions' if immediately_executed_count > 0 else ''}"
  1065. )
  1066. elif immediately_executed_count > 0 and activated_count == 0:
  1067. # All stop losses were immediately executed
  1068. if self.notification_manager:
  1069. await self.notification_manager.send_generic_notification(
  1070. f"⚡ <b>All Stop Losses Executed Immediately</b>\n\n"
  1071. f"Symbol: {token}\n"
  1072. f"Executed: {immediately_executed_count} stop loss(es)\n"
  1073. f"Reason: Market price already beyond trigger levels\n"
  1074. f"Current Price: ${current_price:.4f if current_price else 'Unknown'}\n\n"
  1075. f"🚀 Position(s) closed at market to prevent further losses"
  1076. )
  1077. else:
  1078. # No position exists (might have been closed immediately) - cancel the stop losses
  1079. cancelled_count = stats.cancel_linked_orders(
  1080. parent_bot_order_ref_id=order_in_db['bot_order_ref_id'],
  1081. new_status='cancelled_no_position'
  1082. )
  1083. if cancelled_count > 0:
  1084. logger.info(f"❌ Cancelled {cancelled_count} pending stop losses for {symbol} - no position found")
  1085. if self.notification_manager:
  1086. await self.notification_manager.send_generic_notification(
  1087. f"🛑 <b>Stop Losses Cancelled</b>\n\n"
  1088. f"Symbol: {token}\n"
  1089. f"Cancelled: {cancelled_count} stop loss(es)\n"
  1090. f"Reason: No open position found"
  1091. )
  1092. except Exception as pos_check_error:
  1093. logger.warning(f"Could not check position for {symbol} during SL activation: {pos_check_error}")
  1094. # In case of error, still try to activate (safer to have redundant SLs than none)
  1095. except Exception as e:
  1096. logger.error(f"Error in _activate_pending_stop_losses: {e}", exc_info=True)
  1097. async def _check_for_recent_fills_for_order(self, exchange_oid, order_in_db):
  1098. """Check for very recent fills that might match this order."""
  1099. try:
  1100. # Get recent fills from exchange
  1101. recent_fills = self.trading_engine.get_recent_fills()
  1102. if not recent_fills:
  1103. return False
  1104. # Get last processed timestamp from database
  1105. if not hasattr(self, '_last_processed_trade_time') or self._last_processed_trade_time is None:
  1106. try:
  1107. last_time_str = self.trading_engine.stats._get_metadata('last_processed_trade_time')
  1108. if last_time_str:
  1109. self._last_processed_trade_time = datetime.fromisoformat(last_time_str)
  1110. logger.debug(f"Loaded last_processed_trade_time from DB: {self._last_processed_trade_time}")
  1111. else:
  1112. # If no last processed time, start from 1 hour ago to avoid processing too much history
  1113. self._last_processed_trade_time = datetime.now(timezone.utc) - timedelta(hours=1)
  1114. logger.info("No last_processed_trade_time found, setting to 1 hour ago (UTC).")
  1115. except Exception as e:
  1116. logger.warning(f"Could not load last_processed_trade_time from DB: {e}")
  1117. self._last_processed_trade_time = datetime.now(timezone.utc) - timedelta(hours=1)
  1118. # Process new fills
  1119. for fill in recent_fills:
  1120. try:
  1121. # Parse fill data - CCXT format from fetch_my_trades
  1122. trade_id = fill.get('id') # CCXT uses 'id' for trade ID
  1123. timestamp_ms = fill.get('timestamp') # CCXT uses 'timestamp' (milliseconds)
  1124. symbol = fill.get('symbol') # CCXT uses 'symbol' in full format like 'LTC/USDC:USDC'
  1125. side = fill.get('side') # CCXT uses 'side' ('buy' or 'sell')
  1126. amount = float(fill.get('amount', 0)) # CCXT uses 'amount'
  1127. price = float(fill.get('price', 0)) # CCXT uses 'price'
  1128. # Convert timestamp
  1129. if timestamp_ms:
  1130. timestamp_dt = datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc)
  1131. else:
  1132. timestamp_dt = datetime.now(timezone.utc)
  1133. # Skip if already processed
  1134. if timestamp_dt <= self._last_processed_trade_time:
  1135. continue
  1136. # Process as external trade if we reach here
  1137. if symbol and side and amount > 0 and price > 0:
  1138. # Symbol is already in full format for CCXT
  1139. full_symbol = symbol
  1140. token = symbol.split('/')[0] if '/' in symbol else symbol.split(':')[0]
  1141. # Check if this might be a bot order fill by looking for exchange order ID
  1142. # CCXT might have this in 'info' sub-object with the raw exchange data
  1143. exchange_order_id_from_fill = None
  1144. if 'info' in fill and isinstance(fill['info'], dict):
  1145. # Look for Hyperliquid order ID in the raw response
  1146. exchange_order_id_from_fill = fill['info'].get('oid')
  1147. if exchange_order_id_from_fill == exchange_oid:
  1148. logger.info(f"✅ Found recent fill for order {exchange_oid} - NOT cancelling stop losses")
  1149. return True
  1150. except Exception as e:
  1151. logger.error(f"Error processing fill {fill}: {e}")
  1152. continue
  1153. return False
  1154. except Exception as e:
  1155. logger.error(f"❌ Error checking for recent fills for order: {e}", exc_info=True)
  1156. return False
  1157. async def _check_external_stop_loss_orders(self):
  1158. """Check for externally placed stop loss orders and track them."""
  1159. try:
  1160. # Get current open orders
  1161. open_orders = self.trading_engine.get_orders()
  1162. if not open_orders:
  1163. return
  1164. # Get current positions to understand what could be stop losses
  1165. positions = self.trading_engine.get_positions()
  1166. if not positions:
  1167. return
  1168. # Create a map of current positions
  1169. position_map = {}
  1170. for position in positions:
  1171. symbol = position.get('symbol')
  1172. contracts = float(position.get('contracts', 0))
  1173. if symbol and contracts != 0:
  1174. token = symbol.split('/')[0] if '/' in symbol else symbol.split(':')[0]
  1175. position_map[token] = {
  1176. 'symbol': symbol,
  1177. 'contracts': contracts,
  1178. 'side': 'long' if contracts > 0 else 'short',
  1179. 'entry_price': float(position.get('entryPx', 0))
  1180. }
  1181. # Check each order to see if it could be a stop loss
  1182. newly_detected = 0
  1183. for order in open_orders:
  1184. try:
  1185. exchange_order_id = order.get('id')
  1186. symbol = order.get('symbol')
  1187. side = order.get('side') # 'buy' or 'sell'
  1188. amount = float(order.get('amount', 0))
  1189. price = float(order.get('price', 0))
  1190. order_type = order.get('type', '').lower()
  1191. if not all([exchange_order_id, symbol, side, amount, price]):
  1192. continue
  1193. # Skip if we're already tracking this order
  1194. if exchange_order_id in self._external_stop_loss_orders:
  1195. continue
  1196. # Check if this order could be a stop loss
  1197. token = symbol.split('/')[0] if '/' in symbol else symbol.split(':')[0]
  1198. # Must have a position in this token to have a stop loss
  1199. if token not in position_map:
  1200. continue
  1201. position = position_map[token]
  1202. # Check if this order matches stop loss pattern
  1203. is_stop_loss = False
  1204. if position['side'] == 'long' and side == 'sell':
  1205. # Long position with sell order - could be stop loss if price is below entry
  1206. if price < position['entry_price'] * 0.98: # Allow 2% buffer for approximation
  1207. is_stop_loss = True
  1208. elif position['side'] == 'short' and side == 'buy':
  1209. # Short position with buy order - could be stop loss if price is above entry
  1210. if price > position['entry_price'] * 1.02: # Allow 2% buffer for approximation
  1211. is_stop_loss = True
  1212. if is_stop_loss:
  1213. # Track this as an external stop loss order
  1214. self._external_stop_loss_orders[exchange_order_id] = {
  1215. 'token': token,
  1216. 'symbol': symbol,
  1217. 'trigger_price': price,
  1218. 'side': side,
  1219. 'amount': amount,
  1220. 'position_side': position['side'],
  1221. 'detected_at': datetime.now(timezone.utc),
  1222. 'entry_price': position['entry_price']
  1223. }
  1224. newly_detected += 1
  1225. logger.info(f"🛑 Detected external stop loss order: {token} {side.upper()} {amount} @ ${price:.2f} (protecting {position['side'].upper()} position)")
  1226. except Exception as e:
  1227. logger.error(f"Error analyzing order for stop loss detection: {e}")
  1228. continue
  1229. if newly_detected > 0:
  1230. logger.info(f"🔍 Detected {newly_detected} new external stop loss orders")
  1231. except Exception as e:
  1232. logger.error(f"❌ Error checking external stop loss orders: {e}")
  1233. async def _cleanup_external_stop_loss_tracking(self):
  1234. """Clean up external stop loss orders that are no longer active."""
  1235. try:
  1236. if not self._external_stop_loss_orders:
  1237. return
  1238. # Get current open orders
  1239. open_orders = self.trading_engine.get_orders()
  1240. if not open_orders:
  1241. # No open orders, clear all tracking
  1242. removed_count = len(self._external_stop_loss_orders)
  1243. self._external_stop_loss_orders.clear()
  1244. if removed_count > 0:
  1245. logger.info(f"🧹 Cleared {removed_count} external stop loss orders (no open orders)")
  1246. return
  1247. # Get set of current order IDs
  1248. current_order_ids = {order.get('id') for order in open_orders if order.get('id')}
  1249. # Remove any tracked stop loss orders that are no longer open
  1250. to_remove = []
  1251. for order_id, stop_loss_info in self._external_stop_loss_orders.items():
  1252. if order_id not in current_order_ids:
  1253. to_remove.append(order_id)
  1254. for order_id in to_remove:
  1255. stop_loss_info = self._external_stop_loss_orders[order_id]
  1256. del self._external_stop_loss_orders[order_id]
  1257. logger.info(f"🗑️ Removed external stop loss tracking for {stop_loss_info['token']} order {order_id} (no longer open)")
  1258. if to_remove:
  1259. logger.info(f"🧹 Cleaned up {len(to_remove)} external stop loss orders")
  1260. except Exception as e:
  1261. logger.error(f"❌ Error cleaning up external stop loss tracking: {e}")
  1262. async def _auto_sync_orphaned_positions(self):
  1263. """Automatically detect and sync orphaned positions (positions on exchange without trade lifecycle records)."""
  1264. try:
  1265. stats = self.trading_engine.get_stats()
  1266. if not stats:
  1267. return
  1268. # Get current exchange positions
  1269. exchange_positions = self.trading_engine.get_positions() or []
  1270. synced_count = 0
  1271. for exchange_pos in exchange_positions:
  1272. symbol = exchange_pos.get('symbol')
  1273. contracts = float(exchange_pos.get('contracts', 0))
  1274. if symbol and abs(contracts) > 0:
  1275. # Check if we have a trade lifecycle record for this position
  1276. existing_trade = stats.get_trade_by_symbol_and_status(symbol, 'position_opened')
  1277. if not existing_trade:
  1278. # 🚨 ORPHANED POSITION: Auto-create trade lifecycle record
  1279. entry_price = float(exchange_pos.get('entryPrice', 0))
  1280. position_side = 'long' if contracts > 0 else 'short'
  1281. order_side = 'buy' if contracts > 0 else 'sell'
  1282. token = symbol.split('/')[0] if '/' in symbol else symbol
  1283. # ✅ Use exchange data - no need to estimate!
  1284. if entry_price > 0:
  1285. logger.info(f"🔄 AUTO-SYNC: Orphaned position detected - {symbol} {position_side} {abs(contracts)} @ ${entry_price} (exchange data)")
  1286. else:
  1287. # Fallback only if exchange truly doesn't provide entry price
  1288. entry_price = await self._estimate_entry_price_for_orphaned_position(symbol, contracts)
  1289. logger.warning(f"🔄 AUTO-SYNC: Orphaned position detected - {symbol} {position_side} {abs(contracts)} @ ${entry_price} (estimated)")
  1290. # Get additional exchange data for notification
  1291. unrealized_pnl = float(exchange_pos.get('unrealizedPnl', 0))
  1292. position_value = float(exchange_pos.get('notional', 0))
  1293. liquidation_price = float(exchange_pos.get('liquidationPrice', 0))
  1294. leverage = float(exchange_pos.get('leverage', 1))
  1295. # Create trade lifecycle for external position
  1296. lifecycle_id = stats.create_trade_lifecycle(
  1297. symbol=symbol,
  1298. side=order_side,
  1299. entry_order_id=f"external_sync_{int(datetime.now().timestamp())}",
  1300. trade_type='external'
  1301. )
  1302. if lifecycle_id:
  1303. # Update to position_opened status
  1304. success = stats.update_trade_position_opened(
  1305. lifecycle_id=lifecycle_id,
  1306. entry_price=entry_price,
  1307. entry_amount=abs(contracts),
  1308. exchange_fill_id=f"external_fill_{int(datetime.now().timestamp())}"
  1309. )
  1310. if success:
  1311. synced_count += 1
  1312. logger.info(f"✅ AUTO-SYNC: Successfully synced orphaned position for {symbol}")
  1313. # Enhanced notification with exchange data
  1314. pnl_emoji = "🟢" if unrealized_pnl >= 0 else "🔴"
  1315. notification_text = (
  1316. f"🔄 <b>Position Auto-Synced</b>\n\n"
  1317. f"Token: {token}\n"
  1318. f"Direction: {position_side.upper()}\n"
  1319. f"Size: {abs(contracts):.6f} {token}\n"
  1320. f"Entry Price: ${entry_price:,.4f}\n"
  1321. f"Position Value: ${position_value:,.2f}\n"
  1322. f"{pnl_emoji} P&L: ${unrealized_pnl:,.2f}\n"
  1323. )
  1324. if leverage > 1:
  1325. notification_text += f"⚡ Leverage: {leverage:.1f}x\n"
  1326. if liquidation_price > 0:
  1327. notification_text += f"⚠️ Liquidation: ${liquidation_price:,.2f}\n"
  1328. notification_text += (
  1329. f"Reason: Position opened outside bot\n"
  1330. f"Time: {datetime.now().strftime('%H:%M:%S')}\n\n"
  1331. f"✅ Position now tracked in bot\n"
  1332. f"💡 Use /sl {token} [price] to set stop loss"
  1333. )
  1334. if self.notification_manager:
  1335. await self.notification_manager.send_generic_notification(notification_text)
  1336. else:
  1337. logger.error(f"❌ AUTO-SYNC: Failed to sync orphaned position for {symbol}")
  1338. else:
  1339. logger.error(f"❌ AUTO-SYNC: Failed to create lifecycle for orphaned position {symbol}")
  1340. if synced_count > 0:
  1341. logger.info(f"🔄 AUTO-SYNC: Synced {synced_count} orphaned position(s) this cycle")
  1342. except Exception as e:
  1343. logger.error(f"❌ Error in auto-sync orphaned positions: {e}", exc_info=True)
  1344. async def _estimate_entry_price_for_orphaned_position(self, symbol: str, contracts: float) -> float:
  1345. """Estimate entry price for an orphaned position by checking recent fills and market data."""
  1346. try:
  1347. # Method 1: Check recent fills from the exchange
  1348. recent_fills = self.trading_engine.get_recent_fills()
  1349. if recent_fills:
  1350. # Look for recent fills for this symbol
  1351. symbol_fills = [fill for fill in recent_fills if fill.get('symbol') == symbol]
  1352. if symbol_fills:
  1353. # Get the most recent fill as entry price estimate
  1354. latest_fill = symbol_fills[0] # Assuming sorted by newest first
  1355. fill_price = float(latest_fill.get('price', 0))
  1356. if fill_price > 0:
  1357. logger.info(f"💡 AUTO-SYNC: Found recent fill price for {symbol}: ${fill_price:.4f}")
  1358. return fill_price
  1359. # Method 2: Use current market price as fallback
  1360. market_data = self.trading_engine.get_market_data(symbol)
  1361. if market_data and market_data.get('ticker'):
  1362. current_price = float(market_data['ticker'].get('last', 0))
  1363. if current_price > 0:
  1364. logger.warning(f"⚠️ AUTO-SYNC: Using current market price as entry estimate for {symbol}: ${current_price:.4f}")
  1365. return current_price
  1366. # Method 3: Last resort - try bid/ask average
  1367. if market_data and market_data.get('ticker'):
  1368. bid = float(market_data['ticker'].get('bid', 0))
  1369. ask = float(market_data['ticker'].get('ask', 0))
  1370. if bid > 0 and ask > 0:
  1371. avg_price = (bid + ask) / 2
  1372. logger.warning(f"⚠️ AUTO-SYNC: Using bid/ask average as entry estimate for {symbol}: ${avg_price:.4f}")
  1373. return avg_price
  1374. # Method 4: Absolute fallback - return a small positive value to avoid 0
  1375. logger.error(f"❌ AUTO-SYNC: Could not estimate entry price for {symbol}, using fallback value of $1.00")
  1376. return 1.0
  1377. except Exception as e:
  1378. logger.error(f"❌ AUTO-SYNC: Error estimating entry price for {symbol}: {e}")
  1379. return 1.0 # Safe fallback
  1380. async def _handle_orphaned_position(self, symbol, contracts):
  1381. """Handle the orphaned position."""
  1382. try:
  1383. # This method is now deprecated in favor of _auto_sync_orphaned_positions
  1384. # Keeping for backwards compatibility but not implementing
  1385. logger.info(f"🧹 _handle_orphaned_position deprecated: use _auto_sync_orphaned_positions instead")
  1386. except Exception as e:
  1387. logger.error(f"❌ Error handling orphaned position: {e}", exc_info=True)