notification_manager.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. #!/usr/bin/env python3
  2. """
  3. Notification Manager - Handles all bot notifications and messages.
  4. """
  5. import logging
  6. from typing import Optional, Dict, Any, List
  7. from datetime import datetime
  8. from src.utils.price_formatter import get_formatter
  9. logger = logging.getLogger(__name__)
  10. class NotificationManager:
  11. """Handles all notification logic for the trading bot."""
  12. def __init__(self):
  13. """Initialize the notification manager."""
  14. self.bot_application = None
  15. def set_bot_application(self, application):
  16. """Set the bot application for sending messages."""
  17. self.bot_application = application
  18. async def send_long_success_notification(self, query, token, amount, price, order_details, stop_loss_price=None, trade_lifecycle_id=None):
  19. """Send notification for successful long order."""
  20. try:
  21. # Use PriceFormatter for consistent formatting
  22. formatter = get_formatter() # Get formatter
  23. price_str = formatter.format_price_with_symbol(price, token)
  24. amount_str = f"{amount:.6f} {token}"
  25. value_str = formatter.format_price_with_symbol(amount * price, token)
  26. order_id_str = order_details.get('id', 'N/A')
  27. message = (
  28. f"✅ Successfully opened <b>LONG</b> position for {amount_str} at ~{price_str}\n\n"
  29. f"💰 Value: {value_str}\n"
  30. f"🆔 Order ID: <code>{order_id_str}</code>"
  31. )
  32. if trade_lifecycle_id:
  33. message += f"\n🆔 Lifecycle ID: <code>{trade_lifecycle_id[:8]}...</code>"
  34. if stop_loss_price:
  35. sl_price_str = formatter.format_price_with_symbol(stop_loss_price, token)
  36. message += f"\n🛑 Stop Loss pending at {sl_price_str}"
  37. await query.edit_message_text(text=message, parse_mode='HTML')
  38. except Exception as e:
  39. logger.error(f"Error sending long success notification: {e}")
  40. async def send_short_success_notification(self, query, token, amount, price, order_details, stop_loss_price=None, trade_lifecycle_id=None):
  41. """Send notification for successful short order."""
  42. try:
  43. formatter = get_formatter() # Get formatter
  44. price_str = formatter.format_price_with_symbol(price, token)
  45. amount_str = f"{amount:.6f} {token}"
  46. value_str = formatter.format_price_with_symbol(amount * price, token)
  47. order_id_str = order_details.get('id', 'N/A')
  48. message = (
  49. f"✅ Successfully opened <b>SHORT</b> position for {amount_str} at ~{price_str}\n\n"
  50. f"💰 Value: {value_str}\n"
  51. f"🆔 Order ID: <code>{order_id_str}</code>"
  52. )
  53. if trade_lifecycle_id:
  54. message += f"\n🆔 Lifecycle ID: <code>{trade_lifecycle_id[:8]}...</code>"
  55. if stop_loss_price:
  56. sl_price_str = formatter.format_price_with_symbol(stop_loss_price, token)
  57. message += f"\n🛑 Stop Loss pending at {sl_price_str}"
  58. await query.edit_message_text(text=message, parse_mode='HTML')
  59. except Exception as e:
  60. logger.error(f"Error sending short success notification: {e}")
  61. async def send_exit_success_notification(self, query, token, position_type, amount, price, pnl, order_details, trade_lifecycle_id=None):
  62. """Send notification for successful exit order."""
  63. try:
  64. formatter = get_formatter() # Get formatter
  65. # Price is the execution price, PnL is calculated based on it
  66. # For market orders, price might be approximate or from fill later
  67. price_str = formatter.format_price_with_symbol(price, token) if price > 0 else "Market Price"
  68. amount_str = f"{amount:.6f} {token}"
  69. pnl_str = formatter.format_price_with_symbol(pnl)
  70. pnl_emoji = "🟢" if pnl >= 0 else "🔴"
  71. order_id_str = order_details.get('id', 'N/A')
  72. cancelled_sl_count = order_details.get('cancelled_stop_losses', 0)
  73. message = (
  74. f"✅ Successfully closed <b>{position_type}</b> position for {amount_str}\n\n"
  75. f"🆔 Exit Order ID: <code>{order_id_str}</code>\n"
  76. # P&L and price are more reliably determined when MarketMonitor processes the fill.
  77. # This notification confirms the exit order was PLACED.
  78. f"⏳ Awaiting fill confirmation for final price and P&L."
  79. )
  80. if trade_lifecycle_id:
  81. message += f"\n🆔 Lifecycle ID: <code>{trade_lifecycle_id[:8]}...</code> (Closed)"
  82. if cancelled_sl_count > 0:
  83. message += f"\n⚠️ Cancelled {cancelled_sl_count} linked stop loss order(s)."
  84. await query.edit_message_text(text=message, parse_mode='HTML')
  85. except Exception as e:
  86. logger.error(f"Error sending exit success notification: {e}")
  87. async def send_sl_success_notification(self, query, token, position_type, amount, stop_price, order_details, trade_lifecycle_id=None):
  88. """Send notification for successful stop loss order setup."""
  89. try:
  90. formatter = get_formatter() # Get formatter
  91. stop_price_str = formatter.format_price_with_symbol(stop_price, token)
  92. amount_str = f"{amount:.6f} {token}"
  93. order_id_str = order_details.get('exchange_order_id', 'N/A') # From order_placed_details
  94. message = (
  95. f"🛑 Successfully set <b>STOP LOSS</b> for {position_type} {amount_str}\n\n"
  96. f"🎯 Trigger Price: {stop_price_str}\n"
  97. f"🆔 SL Order ID: <code>{order_id_str}</code>"
  98. )
  99. if trade_lifecycle_id:
  100. message += f"\n🆔 Linked to Lifecycle ID: <code>{trade_lifecycle_id[:8]}...</code>"
  101. await query.edit_message_text(text=message, parse_mode='HTML')
  102. except Exception as e:
  103. logger.error(f"Error sending SL success notification: {e}")
  104. async def send_tp_success_notification(self, query, token, position_type, amount, tp_price, order_details, trade_lifecycle_id=None):
  105. """Send notification for successful take profit order setup."""
  106. try:
  107. formatter = get_formatter() # Get formatter
  108. tp_price_str = formatter.format_price_with_symbol(tp_price, token)
  109. amount_str = f"{amount:.6f} {token}"
  110. order_id_str = order_details.get('exchange_order_id', 'N/A') # From order_placed_details
  111. message = (
  112. f"🎯 Successfully set <b>TAKE PROFIT</b> for {position_type} {amount_str}\n\n"
  113. f"💰 Target Price: {tp_price_str}\n"
  114. f"🆔 TP Order ID: <code>{order_id_str}</code>"
  115. )
  116. if trade_lifecycle_id:
  117. message += f"\n🆔 Linked to Lifecycle ID: <code>{trade_lifecycle_id[:8]}...</code>"
  118. await query.edit_message_text(text=message, parse_mode='HTML')
  119. except Exception as e:
  120. logger.error(f"Error sending TP success notification: {e}")
  121. async def send_coo_success_notification(self, query, token: str, cancelled_count: int,
  122. failed_count: int, cancelled_linked_sls: int = 0,
  123. cancelled_orders: List[Dict[str, Any]] = None):
  124. """Send notification for successful cancel all orders operation."""
  125. success_message = f"""
  126. ✅ <b>Cancel Orders Results</b>
  127. 📊 <b>Summary:</b>
  128. • Token: {token}
  129. • Cancelled: {cancelled_count} orders
  130. • Failed: {failed_count} orders
  131. • Total Attempted: {cancelled_count + failed_count} orders"""
  132. if cancelled_linked_sls > 0:
  133. success_message += f"""
  134. • 🛑 Linked Stop Losses Cancelled: {cancelled_linked_sls}"""
  135. # Show details of cancelled orders if available
  136. if cancelled_orders and len(cancelled_orders) > 0:
  137. success_message += f"""
  138. 🗑️ <b>Successfully Cancelled:</b>"""
  139. for order in cancelled_orders:
  140. side = order.get('side', 'Unknown')
  141. amount = order.get('amount', 0)
  142. price = order.get('price', 0)
  143. side_emoji = "🟢" if side.lower() == 'buy' else "🔴"
  144. success_message += f"""
  145. {side_emoji} {side.upper()} {amount} @ ${price:,.2f}"""
  146. # Overall status
  147. if cancelled_count == (cancelled_count + failed_count) and failed_count == 0:
  148. success_message += f"""
  149. 🎉 All {token} orders successfully cancelled!"""
  150. elif cancelled_count > 0:
  151. success_message += f"""
  152. ⚠️ Some orders cancelled. {failed_count} failed."""
  153. else:
  154. success_message += f"""
  155. ❌ Could not cancel any {token} orders."""
  156. success_message += f"""
  157. ⏰ <b>Time:</b> {datetime.now().strftime('%H:%M:%S')}
  158. 📊 Use /orders to verify no pending orders remain.
  159. """
  160. await query.edit_message_text(success_message, parse_mode='HTML')
  161. logger.info(f"Cancel orders complete: {token} - {cancelled_count} cancelled, {failed_count} failed{f', {cancelled_linked_sls} linked SLs cancelled' if cancelled_linked_sls > 0 else ''}")
  162. async def send_alarm_triggered_notification(self, token: str, target_price: float,
  163. current_price: float, direction: str):
  164. """Send notification when a price alarm is triggered."""
  165. if not self.bot_application:
  166. logger.warning("Bot application not set, cannot send alarm notification")
  167. return
  168. direction_emoji = "📈" if direction == 'above' else "📉"
  169. alarm_message = f"""
  170. 🔔 <b>Price Alarm Triggered!</b>
  171. {direction_emoji} <b>Alert Details:</b>
  172. • Token: {token}
  173. • Target Price: ${target_price:,.2f}
  174. • Current Price: ${current_price:,.2f}
  175. • Direction: {direction.upper()}
  176. ⏰ <b>Trigger Time:</b> {datetime.now().strftime('%H:%M:%S')}
  177. 💡 <b>Quick Actions:</b>
  178. • /market {token} - View market data
  179. • /price {token} - Quick price check
  180. • /long {token} [amount] - Open long position
  181. • /short {token} [amount] - Open short position
  182. """
  183. try:
  184. from src.config.config import Config
  185. if Config.TELEGRAM_CHAT_ID:
  186. await self.bot_application.bot.send_message(
  187. chat_id=Config.TELEGRAM_CHAT_ID,
  188. text=alarm_message,
  189. parse_mode='HTML'
  190. )
  191. logger.info(f"Alarm notification sent: {token} {direction} ${target_price}")
  192. except Exception as e:
  193. logger.error(f"Failed to send alarm notification: {e}")
  194. async def send_external_trade_notification(self, symbol: str, side: str, amount: float,
  195. price: float, action_type: str, timestamp: str):
  196. """Send notification for external trades detected."""
  197. if not self.bot_application:
  198. logger.warning("Bot application not set, cannot send external trade notification")
  199. return
  200. # Extract token from symbol
  201. token = symbol.split('/')[0] if '/' in symbol else symbol
  202. # Format timestamp
  203. try:
  204. trade_time = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
  205. time_str = trade_time.strftime('%H:%M:%S')
  206. except:
  207. time_str = "Unknown"
  208. # Format message based on action type
  209. if action_type == "position_opened":
  210. message = f"""
  211. 🚀 <b>Position Opened (External)</b>
  212. 📊 <b>Trade Details:</b>
  213. • Token: {token}
  214. • Direction: {side.upper()}
  215. • Size: {amount} {token}
  216. • Entry Price: ${price:,.2f}
  217. • Value: ${amount * price:,.2f}
  218. ✅ <b>Status:</b> New position opened externally
  219. ⏰ <b>Time:</b> {time_str}
  220. 📱 Use /positions to view all positions
  221. """
  222. elif action_type == "position_closed":
  223. message = f"""
  224. 🎯 <b>Position Closed (External)</b>
  225. 📊 <b>Trade Details:</b>
  226. • Token: {token}
  227. • Direction: {side.upper()}
  228. • Size: {amount} {token}
  229. • Exit Price: ${price:,.2f}
  230. • Value: ${amount * price:,.2f}
  231. ✅ <b>Status:</b> Position closed externally
  232. ⏰ <b>Time:</b> {time_str}
  233. 📊 Use /stats to view updated performance
  234. """
  235. elif action_type == "position_increased":
  236. message = f"""
  237. 📈 <b>Position Increased (External)</b>
  238. 📊 <b>Trade Details:</b>
  239. • Token: {token}
  240. • Direction: {side.upper()}
  241. • Added Size: {amount} {token}
  242. • Price: ${price:,.2f}
  243. • Value: ${amount * price:,.2f}
  244. ✅ <b>Status:</b> Position size increased externally
  245. ⏰ <b>Time:</b> {time_str}
  246. 📈 Use /positions to view current position
  247. """
  248. else:
  249. # Generic external trade notification
  250. side_emoji = "🟢" if side.lower() == 'buy' else "🔴"
  251. message = f"""
  252. 🔄 <b>External Trade Detected</b>
  253. 📊 <b>Trade Details:</b>
  254. • Token: {token}
  255. • Side: {side.upper()}
  256. • Amount: {amount} {token}
  257. • Price: ${price:,.2f}
  258. • Value: ${amount * price:,.2f}
  259. {side_emoji} <b>Source:</b> External Platform Trade
  260. ⏰ <b>Time:</b> {time_str}
  261. 📈 <b>Note:</b> This trade was executed outside the Telegram bot
  262. 📊 Stats have been automatically updated
  263. """
  264. try:
  265. from src.config.config import Config
  266. if Config.TELEGRAM_CHAT_ID:
  267. await self.bot_application.bot.send_message(
  268. chat_id=Config.TELEGRAM_CHAT_ID,
  269. text=message,
  270. parse_mode='HTML'
  271. )
  272. logger.info(f"External trade notification sent: {action_type} for {token}")
  273. except Exception as e:
  274. logger.error(f"Failed to send external trade notification: {e}")
  275. async def send_generic_notification(self, message: str):
  276. """Send a generic notification message."""
  277. if not self.bot_application:
  278. logger.warning("Bot application not set, cannot send generic notification")
  279. return
  280. try:
  281. from src.config.config import Config
  282. if Config.TELEGRAM_CHAT_ID:
  283. await self.bot_application.bot.send_message(
  284. chat_id=Config.TELEGRAM_CHAT_ID,
  285. text=message,
  286. parse_mode='HTML'
  287. )
  288. logger.info("Generic notification sent")
  289. except Exception as e:
  290. logger.error(f"Failed to send generic notification: {e}")
  291. async def send_stop_loss_execution_notification(self, stop_loss_info: Dict, symbol: str, side: str, amount: float, price: float, action_type: str, timestamp: str):
  292. """Send notification for external stop loss execution."""
  293. try:
  294. token = symbol.split('/')[0] if '/' in symbol else symbol.split(':')[0]
  295. # Extract stop loss details
  296. trigger_price = stop_loss_info.get('trigger_price', price)
  297. position_side = stop_loss_info.get('position_side', 'unknown')
  298. entry_price = stop_loss_info.get('entry_price', 0)
  299. detected_at = stop_loss_info.get('detected_at')
  300. # Format timestamp
  301. try:
  302. time_obj = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
  303. time_str = time_obj.strftime('%H:%M:%S')
  304. except:
  305. time_str = "Unknown"
  306. # Calculate P&L if we have entry price
  307. pnl_info = ""
  308. if entry_price > 0:
  309. if position_side == 'long':
  310. pnl = amount * (price - entry_price)
  311. pnl_percent = ((price - entry_price) / entry_price) * 100
  312. else: # short
  313. pnl = amount * (entry_price - price)
  314. pnl_percent = ((entry_price - price) / entry_price) * 100
  315. pnl_emoji = "🟢" if pnl >= 0 else "🔴"
  316. pnl_info = f"""
  317. {pnl_emoji} <b>Stop Loss P&L:</b>
  318. • Entry Price: ${entry_price:,.2f}
  319. • Exit Price: ${price:,.2f}
  320. • Realized P&L: ${pnl:,.2f} ({pnl_percent:+.2f}%)
  321. • Result: {"PROFIT" if pnl >= 0 else "LOSS PREVENTION"}"""
  322. # Determine stop loss effectiveness
  323. effectiveness = ""
  324. if entry_price > 0 and trigger_price > 0:
  325. if position_side == 'long':
  326. # For longs, stop loss should trigger below entry
  327. if trigger_price < entry_price:
  328. loss_percent = ((trigger_price - entry_price) / entry_price) * 100
  329. effectiveness = f"• Loss Limited: {loss_percent:.1f}% ✅"
  330. else:
  331. effectiveness = "• Unusual: Stop above entry 🟡"
  332. else: # short
  333. # For shorts, stop loss should trigger above entry
  334. if trigger_price > entry_price:
  335. loss_percent = ((entry_price - trigger_price) / entry_price) * 100
  336. effectiveness = f"• Loss Limited: {loss_percent:.1f}% ✅"
  337. else:
  338. effectiveness = "• Unusual: Stop below entry 🟡"
  339. trade_value = amount * price
  340. position_emoji = "📈" if position_side == 'long' else "📉"
  341. message = f"""
  342. 🛑 <b>STOP LOSS EXECUTED</b>
  343. {position_emoji} <b>{position_side.upper()} Position Protected:</b>
  344. • Token: {token}
  345. • Position Type: {position_side.upper()}
  346. • Stop Loss Size: {amount} {token}
  347. • Trigger Price: ${trigger_price:,.2f}
  348. • Execution Price: ${price:,.2f}
  349. • Exit Value: ${trade_value:,.2f}
  350. 🎯 <b>Stop Loss Details:</b>
  351. • Status: EXECUTED ✅
  352. • Order Side: {side.upper()}
  353. • Action Type: {action_type.replace('_', ' ').title()}
  354. {effectiveness}
  355. {pnl_info}
  356. ⏰ <b>Execution Time:</b> {time_str}
  357. 🤖 <b>Source:</b> External Hyperliquid Order
  358. 📊 <b>Risk Management:</b> Loss prevention successful
  359. 💡 Your external stop loss order worked as intended!
  360. """
  361. await self.send_notification(message.strip())
  362. logger.info(f"🛑 Stop loss execution notification sent: {token} {position_side} @ ${price:.2f}")
  363. except Exception as e:
  364. logger.error(f"❌ Error sending stop loss execution notification: {e}")