positions.py 18 KB

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