positions.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. import logging
  2. from typing import Dict, Any, List, Optional
  3. from datetime import datetime, timezone
  4. from telegram import Update
  5. from telegram.ext import ContextTypes
  6. from .base import InfoCommandsBase
  7. logger = logging.getLogger(__name__)
  8. class PositionsCommands(InfoCommandsBase):
  9. """Handles all position-related commands."""
  10. async def positions_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
  11. """Handle the /positions command."""
  12. try:
  13. if not self._is_authorized(update):
  14. await self._reply(update, "❌ Unauthorized access.")
  15. return
  16. stats = self.trading_engine.get_stats()
  17. if not stats:
  18. await self._reply(update, "❌ Trading stats not available.")
  19. return
  20. # Get open positions from DB
  21. open_positions = stats.get_open_positions()
  22. if not open_positions:
  23. await self._reply(update, "📭 No open positions\n\n💡 Use /long or /short to open a position")
  24. return
  25. # Get current exchange orders for stop loss detection
  26. exchange_orders = self.trading_engine.get_orders() or []
  27. # Initialize totals
  28. total_position_value = 0.0
  29. total_unrealized = 0.0
  30. total_margin_used = 0.0
  31. # Build position details
  32. positions_text = "📊 <b>Open Positions</b>\n\n"
  33. for position_trade in open_positions:
  34. try:
  35. symbol = position_trade['symbol']
  36. base_asset = symbol.split('/')[0] if '/' in symbol else symbol.split(':')[0]
  37. position_side = position_trade.get('position_side', 'unknown')
  38. abs_current_amount = abs(float(position_trade.get('current_position_size', 0)))
  39. entry_price = float(position_trade.get('entry_price', 0))
  40. mark_price = position_trade.get('mark_price', entry_price)
  41. trade_type = position_trade.get('trade_type', 'unknown')
  42. # Calculate duration
  43. duration_str = "N/A"
  44. position_opened_at_str = position_trade.get('position_opened_at')
  45. if position_opened_at_str:
  46. try:
  47. position_opened_at = datetime.fromisoformat(position_opened_at_str.replace('Z', '+00:00'))
  48. duration = datetime.now(timezone.utc) - position_opened_at
  49. days = duration.days
  50. hours = duration.seconds // 3600
  51. minutes = (duration.seconds % 3600) // 60
  52. parts = []
  53. if days > 0:
  54. parts.append(f"{days}d")
  55. if hours > 0:
  56. parts.append(f"{hours}h")
  57. if minutes > 0 or (days == 0 and hours == 0):
  58. parts.append(f"{minutes}m")
  59. duration_str = " ".join(parts) if parts else "0m"
  60. except ValueError:
  61. logger.warning(f"Could not parse position_opened_at: {position_opened_at_str} for {symbol}")
  62. duration_str = "Error"
  63. # Calculate unrealized PnL
  64. unrealized_pnl = position_trade.get('unrealized_pnl')
  65. if unrealized_pnl is None:
  66. if position_side == 'long':
  67. unrealized_pnl = (mark_price - entry_price) * abs_current_amount
  68. else: # Short position
  69. unrealized_pnl = (entry_price - mark_price) * abs_current_amount
  70. unrealized_pnl = unrealized_pnl or 0.0
  71. # ROE Percentage from database
  72. roe_percentage = position_trade.get('roe_percentage', 0.0)
  73. # Add to totals
  74. individual_position_value = position_trade.get('position_value')
  75. if individual_position_value is None:
  76. individual_position_value = abs_current_amount * mark_price
  77. total_position_value += individual_position_value
  78. total_unrealized += unrealized_pnl
  79. margin_used = position_trade.get('margin_used')
  80. if margin_used is not None:
  81. total_margin_used += margin_used
  82. # Format position details
  83. formatter = self._get_formatter()
  84. entry_price_str = formatter.format_price_with_symbol(entry_price, base_asset)
  85. mark_price_str = formatter.format_price_with_symbol(mark_price, base_asset)
  86. size_str = formatter.format_amount(abs_current_amount, base_asset)
  87. # Position header
  88. pos_emoji = "🟢" if position_side == 'long' else "🔴"
  89. direction_text = position_side.upper()
  90. # Add leverage if available
  91. leverage = position_trade.get('leverage')
  92. if leverage is not None:
  93. try:
  94. leverage_val = float(leverage)
  95. leverage_str = f"x{leverage_val:.1f}".rstrip('0').rstrip('.') if '.' in f"{leverage_val:.1f}" else f"x{int(leverage_val)}"
  96. direction_text = f"{direction_text} {leverage_str}"
  97. except ValueError:
  98. logger.warning(f"Could not parse leverage value: {leverage} for {symbol}")
  99. # Add type indicator
  100. type_indicator = ""
  101. if position_trade.get('trade_lifecycle_id'):
  102. type_indicator = " 🤖"
  103. elif trade_type == 'external':
  104. type_indicator = " 🔄"
  105. # Build position text
  106. positions_text += f"{pos_emoji} <b>{base_asset} ({direction_text}){type_indicator}</b>\n"
  107. positions_text += f" 📏 Size: {size_str} {base_asset}\n"
  108. positions_text += f" 💰 Entry: {entry_price_str}\n"
  109. positions_text += f" ⏳ Duration: {duration_str}\n"
  110. positions_text += f" 🏦 Value: ${individual_position_value:,.2f}\n"
  111. if mark_price != 0 and abs(mark_price - entry_price) > 1e-9:
  112. positions_text += f" 📈 Mark: {mark_price_str}\n"
  113. pnl_emoji = "🟢" if unrealized_pnl >= 0 else "🔴"
  114. positions_text += f" {pnl_emoji} P&L: ${unrealized_pnl:,.2f}\n"
  115. roe_emoji = "🟢" if roe_percentage >= 0 else "🔴"
  116. positions_text += f" {roe_emoji} ROE: {roe_percentage:+.2f}%\n"
  117. # Add risk management info
  118. if margin_used is not None:
  119. positions_text += f" 💳 Margin Used: ${margin_used:,.2f}\n"
  120. if position_trade.get('liquidation_price') is not None and position_trade.get('liquidation_price') > 0:
  121. liq_price_str = formatter.format_price_with_symbol(position_trade.get('liquidation_price'), base_asset)
  122. positions_text += f" ⚠️ Liquidation: {liq_price_str}\n"
  123. if position_trade.get('stop_loss_price'):
  124. sl_price = position_trade['stop_loss_price']
  125. positions_text += f" 🛑 Stop Loss: {formatter.format_price_with_symbol(sl_price, base_asset)}\n"
  126. if position_trade.get('take_profit_price'):
  127. tp_price = position_trade['take_profit_price']
  128. positions_text += f" 🎯 Take Profit: {formatter.format_price_with_symbol(tp_price, base_asset)}\n"
  129. # Add external stop losses
  130. external_sls = self._get_external_stop_losses(symbol, position_side, entry_price, abs_current_amount, exchange_orders)
  131. for ext_sl_price in external_sls:
  132. positions_text += f" 🛡️ External SL: {formatter.format_price_with_symbol(ext_sl_price, base_asset)}\n"
  133. positions_text += f" 🆔 Lifecycle ID: {position_trade['trade_lifecycle_id'][:8]}\n\n"
  134. except Exception as e:
  135. logger.error(f"Error processing position {position_trade.get('symbol', 'unknown')}: {e}")
  136. continue
  137. # Add portfolio summary
  138. portfolio_emoji = "🟢" if total_unrealized >= 0 else "🔴"
  139. positions_text += f"💼 <b>Total Portfolio:</b>\n"
  140. positions_text += f" 🏦 Total Positions Value: ${total_position_value:,.2f}\n"
  141. if total_margin_used > 0:
  142. positions_text += f" 💳 Total Margin Used: ${total_margin_used:,.2f}\n"
  143. leverage_ratio = total_position_value / total_margin_used if total_margin_used > 0 else 1.0
  144. positions_text += f" ⚖️ Portfolio Leverage: {leverage_ratio:.2f}x\n"
  145. positions_text += f" {portfolio_emoji} Total Unrealized P&L: ${total_unrealized:,.2f}\n"
  146. if total_margin_used > 0:
  147. margin_pnl_percentage = (total_unrealized / total_margin_used) * 100
  148. positions_text += f" 📊 Portfolio Return: {margin_pnl_percentage:+.2f}% (on margin)\n"
  149. positions_text += "\n"
  150. positions_text += f"🤖 <b>Legend:</b> 🤖 Bot-created • 🔄 External/synced • 🛡️ External SL\n"
  151. positions_text += f"💡 Use /sl [token] [price] or /tp [token] [price] to set risk management"
  152. await self._reply(update, positions_text.strip())
  153. except Exception as e:
  154. logger.error(f"Error in positions command: {e}")
  155. await self._reply(update, "❌ Error retrieving position information.")
  156. def _get_external_stop_losses(self, symbol: str, position_side: str, entry_price: float,
  157. current_amount: float, exchange_orders: List[Dict[str, Any]]) -> List[float]:
  158. """Get external stop losses for a position."""
  159. external_sls = []
  160. for order in exchange_orders:
  161. try:
  162. order_symbol = order.get('symbol')
  163. order_side = order.get('side', '').lower()
  164. order_type = order.get('type', '').lower()
  165. order_price = float(order.get('price', 0))
  166. trigger_price = order.get('info', {}).get('triggerPrice')
  167. is_reduce_only = order.get('reduceOnly', False) or order.get('info', {}).get('reduceOnly', False)
  168. order_amount = float(order.get('amount', 0))
  169. if (order_symbol == symbol and is_reduce_only and
  170. abs(order_amount - current_amount) < 0.01 * current_amount):
  171. sl_trigger_price = 0
  172. if trigger_price:
  173. try:
  174. sl_trigger_price = float(trigger_price)
  175. except (ValueError, TypeError):
  176. pass
  177. if not sl_trigger_price and order_price > 0:
  178. sl_trigger_price = order_price
  179. is_valid_sl = False
  180. if position_side == 'long' and order_side == 'sell':
  181. if sl_trigger_price > 0 and sl_trigger_price < entry_price:
  182. is_valid_sl = True
  183. elif position_side == 'short' and order_side == 'buy':
  184. if sl_trigger_price > 0 and sl_trigger_price > entry_price:
  185. is_valid_sl = True
  186. if is_valid_sl:
  187. external_sls.append(sl_trigger_price)
  188. except Exception as e:
  189. logger.warning(f"Error processing order for external SL: {e}")
  190. continue
  191. return external_sls