positions.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  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. # Get position data with defaults
  36. symbol = position_trade['symbol']
  37. base_asset = symbol.split('/')[0] if '/' in symbol else symbol
  38. position_side = position_trade.get('position_side', 'unknown')
  39. # Safely convert numeric values with proper null checks
  40. entry_price = 0.0
  41. if position_trade.get('entry_price') is not None:
  42. try:
  43. entry_price = float(position_trade['entry_price'])
  44. except (ValueError, TypeError):
  45. logger.warning(f"Could not convert entry_price for {symbol}")
  46. current_amount = 0.0
  47. if position_trade.get('current_position_size') is not None:
  48. try:
  49. current_amount = float(position_trade['current_position_size'])
  50. except (ValueError, TypeError):
  51. logger.warning(f"Could not convert current_position_size for {symbol}")
  52. abs_current_amount = abs(current_amount)
  53. trade_type = position_trade.get('trade_type', 'manual')
  54. # Calculate position duration
  55. position_opened_at_str = position_trade.get('position_opened_at')
  56. duration_str = "N/A"
  57. if position_opened_at_str:
  58. try:
  59. opened_at_dt = datetime.fromisoformat(position_opened_at_str)
  60. if opened_at_dt.tzinfo is None:
  61. opened_at_dt = opened_at_dt.replace(tzinfo=timezone.utc)
  62. now_utc = datetime.now(timezone.utc)
  63. duration = now_utc - opened_at_dt
  64. days = duration.days
  65. hours, remainder = divmod(duration.seconds, 3600)
  66. minutes, _ = divmod(remainder, 60)
  67. parts = []
  68. if days > 0:
  69. parts.append(f"{days}d")
  70. if hours > 0:
  71. parts.append(f"{hours}h")
  72. if minutes > 0 or (days == 0 and hours == 0):
  73. parts.append(f"{minutes}m")
  74. duration_str = " ".join(parts) if parts else "0m"
  75. except ValueError:
  76. logger.warning(f"Could not parse position_opened_at: {position_opened_at_str} for {symbol}")
  77. duration_str = "Error"
  78. # Get price data with defaults
  79. mark_price = entry_price # Default to entry price
  80. if position_trade.get('mark_price') is not None:
  81. try:
  82. mark_price = float(position_trade['mark_price'])
  83. except (ValueError, TypeError):
  84. logger.warning(f"Could not convert mark_price for {symbol}")
  85. # Calculate unrealized PnL
  86. unrealized_pnl = 0.0
  87. if position_trade.get('unrealized_pnl') is not None:
  88. try:
  89. unrealized_pnl = float(position_trade['unrealized_pnl'])
  90. except (ValueError, TypeError):
  91. logger.warning(f"Could not convert unrealized_pnl for {symbol}")
  92. # Get ROE from database
  93. roe_percentage = 0.0
  94. if position_trade.get('roe_percentage') is not None:
  95. try:
  96. roe_percentage = float(position_trade['roe_percentage'])
  97. except (ValueError, TypeError):
  98. logger.warning(f"Could not convert roe_percentage for {symbol}")
  99. # Add to totals
  100. individual_position_value = 0.0
  101. if position_trade.get('position_value') is not None:
  102. try:
  103. individual_position_value = float(position_trade['position_value'])
  104. except (ValueError, TypeError):
  105. logger.warning(f"Could not convert position_value for {symbol}")
  106. if individual_position_value <= 0:
  107. individual_position_value = abs_current_amount * mark_price
  108. total_position_value += individual_position_value
  109. total_unrealized += unrealized_pnl
  110. # Add margin to total
  111. margin_used = 0.0
  112. if position_trade.get('margin_used') is not None:
  113. try:
  114. margin_used = float(position_trade['margin_used'])
  115. except (ValueError, TypeError):
  116. logger.warning(f"Could not convert margin_used for {symbol}")
  117. if margin_used > 0:
  118. total_margin_used += margin_used
  119. # --- Position Header Formatting (Emoji, Direction, Leverage) ---
  120. pos_emoji = "🟢" if position_side == 'long' else "🔴"
  121. direction_text = position_side.upper()
  122. leverage = position_trade.get('leverage')
  123. if leverage is not None:
  124. try:
  125. leverage_val = float(leverage)
  126. leverage_str = f"x{leverage_val:.1f}".rstrip('0').rstrip('.') if '.' in f"{leverage_val:.1f}" else f"x{int(leverage_val)}"
  127. direction_text = f"{direction_text} {leverage_str}"
  128. except ValueError:
  129. logger.warning(f"Could not parse leverage value: {leverage} for {symbol}")
  130. # --- Format Output String ---
  131. formatter = self._get_formatter()
  132. # Get price precisions
  133. entry_price_str = await formatter.format_price_with_symbol(entry_price, base_asset)
  134. mark_price_str = await formatter.format_price_with_symbol(mark_price, base_asset)
  135. # Get amount precision for position size
  136. size_str = await formatter.format_amount(abs_current_amount, base_asset)
  137. type_indicator = ""
  138. if position_trade.get('trade_lifecycle_id'):
  139. type_indicator = " 🤖"
  140. elif trade_type == 'external':
  141. type_indicator = " 🔄"
  142. positions_text += f"{pos_emoji} <b>{base_asset} ({direction_text}){type_indicator}</b>\n"
  143. positions_text += f" 📏 Size: {size_str} {base_asset}\n"
  144. positions_text += f" 💰 Entry: {entry_price_str}\n"
  145. positions_text += f" ⏳ Duration: {duration_str}\n"
  146. # Display individual position value
  147. positions_text += f" 🏦 Value: ${individual_position_value:,.2f}\n"
  148. if margin_used > 0:
  149. positions_text += f" 💳 Margin: ${margin_used:,.2f}\n"
  150. if mark_price > 0 and abs(mark_price - entry_price) > 1e-9:
  151. positions_text += f" 📈 Mark: {mark_price_str}\n"
  152. pnl_emoji = "🟢" if unrealized_pnl >= 0 else "🔴"
  153. positions_text += f" {pnl_emoji} P&L: ${unrealized_pnl:,.2f}\n"
  154. # Show ROE
  155. roe_emoji = "🟢" if roe_percentage >= 0 else "🔴"
  156. positions_text += f" {roe_emoji} ROE: {roe_percentage:+.2f}%\n"
  157. # Show exchange-provided risk data if available
  158. if position_trade.get('liquidation_price') is not None and position_trade.get('liquidation_price') > 0:
  159. liq_price_str = await formatter.format_price_with_symbol(position_trade.get('liquidation_price'), base_asset)
  160. positions_text += f" ⚠️ Liquidation: {liq_price_str}\n"
  161. # Show stop loss if linked in database
  162. if position_trade.get('stop_loss_price'):
  163. sl_price = position_trade['stop_loss_price']
  164. positions_text += f" 🛑 Stop Loss: {await formatter.format_price_with_symbol(sl_price, base_asset)}\n"
  165. # Show take profit if linked in database
  166. if position_trade.get('take_profit_price'):
  167. tp_price = position_trade['take_profit_price']
  168. positions_text += f" 🎯 Take Profit: {await formatter.format_price_with_symbol(tp_price, base_asset)}\n"
  169. positions_text += f" 🆔 Lifecycle ID: {position_trade['trade_lifecycle_id'][:8]}\n\n"
  170. except Exception as e:
  171. logger.error(f"Error processing position {position_trade.get('symbol', 'unknown')}: {e}")
  172. continue
  173. # Calculate total unrealized P&L and total ROE
  174. total_unrealized_pnl = 0.0
  175. total_roe = 0.0
  176. for pos in open_positions:
  177. size = float(pos.get('size', 0))
  178. entry_price = float(pos.get('entryPrice', 0))
  179. mark_price = float(pos.get('markPrice', 0))
  180. roe = float(pos.get('roe_percentage', 0))
  181. if size != 0 and entry_price != 0:
  182. position_value = abs(size * entry_price)
  183. total_unrealized_pnl += size * (mark_price - entry_price)
  184. total_roe += roe * position_value
  185. total_position_value += position_value
  186. # Weighted average ROE
  187. avg_roe = (total_roe / total_position_value) if total_position_value > 0 else 0.0
  188. roe_emoji = "🟢" if avg_roe >= 0 else "🔴"
  189. # Add portfolio summary
  190. portfolio_emoji = "🟢" if total_unrealized >= 0 else "🔴"
  191. positions_text += f"💼 <b>Total Portfolio:</b>\n"
  192. positions_text += f" 🏦 Total Positions Value: ${total_position_value:,.2f}\n"
  193. if total_margin_used > 0:
  194. positions_text += f" 💳 Total Margin Used: ${total_margin_used:,.2f}\n"
  195. leverage_ratio = total_position_value / total_margin_used if total_margin_used > 0 else 1.0
  196. positions_text += f" ⚖️ Portfolio Leverage: {leverage_ratio:.2f}x\n"
  197. positions_text += f" {portfolio_emoji} Total Unrealized P&L: ${total_unrealized:,.2f}\n"
  198. if total_margin_used > 0:
  199. margin_pnl_percentage = (total_unrealized / total_margin_used) * 100
  200. positions_text += f" 📊 Portfolio Return: {margin_pnl_percentage:+.2f}% (on margin)\n"
  201. positions_text += "\n"
  202. positions_text += f"🤖 <b>Legend:</b> 🤖 Bot-created • 🔄 External/synced • 🛡️ External SL\n"
  203. positions_text += f"💡 Use /sl [token] [price] or /tp [token] [price] to set risk management"
  204. await self._reply(update, positions_text.strip())
  205. except Exception as e:
  206. logger.error(f"Error in positions command: {e}")
  207. await self._reply(update, "❌ Error retrieving position information.")
  208. def _get_external_stop_losses(self, symbol: str, position_side: str, entry_price: float,
  209. current_amount: float, exchange_orders: List[Dict[str, Any]]) -> List[float]:
  210. """Get external stop losses for a position."""
  211. external_sls = []
  212. for order in exchange_orders:
  213. try:
  214. order_symbol = order.get('symbol')
  215. order_side = order.get('side', '').lower()
  216. order_type = order.get('type', '').lower()
  217. order_price = float(order.get('price', 0))
  218. trigger_price = order.get('info', {}).get('triggerPrice')
  219. is_reduce_only = order.get('reduceOnly', False) or order.get('info', {}).get('reduceOnly', False)
  220. order_amount = float(order.get('amount', 0))
  221. if (order_symbol == symbol and is_reduce_only and
  222. abs(order_amount - current_amount) < 0.01 * current_amount):
  223. sl_trigger_price = 0
  224. if trigger_price:
  225. try:
  226. sl_trigger_price = float(trigger_price)
  227. except (ValueError, TypeError):
  228. pass
  229. if not sl_trigger_price and order_price > 0:
  230. sl_trigger_price = order_price
  231. is_valid_sl = False
  232. if position_side == 'long' and order_side == 'sell':
  233. if sl_trigger_price > 0 and sl_trigger_price < entry_price:
  234. is_valid_sl = True
  235. elif position_side == 'short' and order_side == 'buy':
  236. if sl_trigger_price > 0 and sl_trigger_price > entry_price:
  237. is_valid_sl = True
  238. if is_valid_sl:
  239. external_sls.append(sl_trigger_price)
  240. except Exception as e:
  241. logger.warning(f"Error processing order for external SL: {e}")
  242. continue
  243. return external_sls