123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294 |
- import logging
- from typing import Dict, Any, List, Optional
- from datetime import datetime, timezone
- from telegram import Update
- from telegram.ext import ContextTypes
- from .base import InfoCommandsBase
- logger = logging.getLogger(__name__)
- class PositionsCommands(InfoCommandsBase):
- """Handles all position-related commands."""
- async def positions_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Handle the /positions command."""
- try:
- if not self._is_authorized(update):
- await self._reply(update, "❌ Unauthorized access.")
- return
- stats = self.trading_engine.get_stats()
- if not stats:
- await self._reply(update, "❌ Trading stats not available.")
- return
- # Get open positions from DB
- open_positions = stats.get_open_positions()
- if not open_positions:
- await self._reply(update, "📭 No open positions\n\n💡 Use /long or /short to open a position")
- return
- # Get current exchange orders for stop loss detection
- exchange_orders = self.trading_engine.get_orders() or []
- # Initialize totals
- total_position_value = 0.0
- total_unrealized = 0.0
- total_margin_used = 0.0
- # Build position details
- positions_text = "📊 <b>Open Positions</b>\n\n"
-
- for position_trade in open_positions:
- try:
- # Get position data with defaults
- symbol = position_trade['symbol']
- base_asset = symbol.split('/')[0] if '/' in symbol else symbol
- position_side = position_trade.get('position_side', 'unknown')
-
- # Safely convert numeric values with proper null checks
- entry_price = 0.0
- if position_trade.get('entry_price') is not None:
- try:
- entry_price = float(position_trade['entry_price'])
- except (ValueError, TypeError):
- logger.warning(f"Could not convert entry_price for {symbol}")
-
- current_amount = 0.0
- if position_trade.get('current_position_size') is not None:
- try:
- current_amount = float(position_trade['current_position_size'])
- except (ValueError, TypeError):
- logger.warning(f"Could not convert current_position_size for {symbol}")
-
- abs_current_amount = abs(current_amount)
- trade_type = position_trade.get('trade_type', 'manual')
-
- # Calculate position duration
- position_opened_at_str = position_trade.get('position_opened_at')
- duration_str = "N/A"
- if position_opened_at_str:
- try:
- opened_at_dt = datetime.fromisoformat(position_opened_at_str)
- if opened_at_dt.tzinfo is None:
- opened_at_dt = opened_at_dt.replace(tzinfo=timezone.utc)
- now_utc = datetime.now(timezone.utc)
- duration = now_utc - opened_at_dt
-
- days = duration.days
- hours, remainder = divmod(duration.seconds, 3600)
- minutes, _ = divmod(remainder, 60)
-
- parts = []
- if days > 0:
- parts.append(f"{days}d")
- if hours > 0:
- parts.append(f"{hours}h")
- if minutes > 0 or (days == 0 and hours == 0):
- parts.append(f"{minutes}m")
- duration_str = " ".join(parts) if parts else "0m"
- except ValueError:
- logger.warning(f"Could not parse position_opened_at: {position_opened_at_str} for {symbol}")
- duration_str = "Error"
-
- # Get price data with defaults
- mark_price = entry_price # Default to entry price
- if position_trade.get('mark_price') is not None:
- try:
- mark_price = float(position_trade['mark_price'])
- except (ValueError, TypeError):
- logger.warning(f"Could not convert mark_price for {symbol}")
-
- # Calculate unrealized PnL
- unrealized_pnl = 0.0
- if position_trade.get('unrealized_pnl') is not None:
- try:
- unrealized_pnl = float(position_trade['unrealized_pnl'])
- except (ValueError, TypeError):
- logger.warning(f"Could not convert unrealized_pnl for {symbol}")
-
- # Get ROE from database
- roe_percentage = 0.0
- if position_trade.get('roe_percentage') is not None:
- try:
- roe_percentage = float(position_trade['roe_percentage'])
- except (ValueError, TypeError):
- logger.warning(f"Could not convert roe_percentage for {symbol}")
- # Add to totals
- individual_position_value = 0.0
- if position_trade.get('position_value') is not None:
- try:
- individual_position_value = float(position_trade['position_value'])
- except (ValueError, TypeError):
- logger.warning(f"Could not convert position_value for {symbol}")
-
- if individual_position_value <= 0:
- individual_position_value = abs_current_amount * mark_price
-
- total_position_value += individual_position_value
- total_unrealized += unrealized_pnl
-
- # Add margin to total
- margin_used = 0.0
- if position_trade.get('margin_used') is not None:
- try:
- margin_used = float(position_trade['margin_used'])
- except (ValueError, TypeError):
- logger.warning(f"Could not convert margin_used for {symbol}")
-
- if margin_used > 0:
- total_margin_used += margin_used
-
- # --- Position Header Formatting (Emoji, Direction, Leverage) ---
- pos_emoji = "🟢" if position_side == 'long' else "🔴"
- direction_text = position_side.upper()
-
- leverage = position_trade.get('leverage')
- if leverage is not None:
- try:
- leverage_val = float(leverage)
- leverage_str = f"x{leverage_val:.1f}".rstrip('0').rstrip('.') if '.' in f"{leverage_val:.1f}" else f"x{int(leverage_val)}"
- direction_text = f"{direction_text} {leverage_str}"
- except ValueError:
- logger.warning(f"Could not parse leverage value: {leverage} for {symbol}")
- # --- Format Output String ---
- formatter = self._get_formatter()
- # Get price precisions
- entry_price_str = await formatter.format_price_with_symbol(entry_price, base_asset)
- mark_price_str = await formatter.format_price_with_symbol(mark_price, base_asset)
- # Get amount precision for position size
- size_str = await formatter.format_amount(abs_current_amount, base_asset)
- type_indicator = ""
- if position_trade.get('trade_lifecycle_id'):
- type_indicator = " 🤖"
- elif trade_type == 'external':
- type_indicator = " 🔄"
-
- positions_text += f"{pos_emoji} <b>{base_asset} ({direction_text}){type_indicator}</b>\n"
- positions_text += f" 📏 Size: {size_str} {base_asset}\n"
- positions_text += f" 💰 Entry: {entry_price_str}\n"
- positions_text += f" ⏳ Duration: {duration_str}\n"
-
- # Display individual position value
- positions_text += f" 🏦 Value: ${individual_position_value:,.2f}\n"
-
- if margin_used > 0:
- positions_text += f" 💳 Margin: ${margin_used:,.2f}\n"
- if mark_price > 0 and abs(mark_price - entry_price) > 1e-9:
- positions_text += f" 📈 Mark: {mark_price_str}\n"
-
- pnl_emoji = "🟢" if unrealized_pnl >= 0 else "🔴"
- positions_text += f" {pnl_emoji} P&L: ${unrealized_pnl:,.2f}\n"
-
- # Show ROE
- roe_emoji = "🟢" if roe_percentage >= 0 else "🔴"
- positions_text += f" {roe_emoji} ROE: {roe_percentage:+.2f}%\n"
-
- # Show exchange-provided risk data if available
- if position_trade.get('liquidation_price') is not None and position_trade.get('liquidation_price') > 0:
- liq_price_str = await formatter.format_price_with_symbol(position_trade.get('liquidation_price'), base_asset)
- positions_text += f" ⚠️ Liquidation: {liq_price_str}\n"
-
- # Show stop loss if linked in database
- if position_trade.get('stop_loss_price'):
- sl_price = position_trade['stop_loss_price']
- positions_text += f" 🛑 Stop Loss: {await formatter.format_price_with_symbol(sl_price, base_asset)}\n"
-
- # Show take profit if linked in database
- if position_trade.get('take_profit_price'):
- tp_price = position_trade['take_profit_price']
- positions_text += f" 🎯 Take Profit: {await formatter.format_price_with_symbol(tp_price, base_asset)}\n"
-
- positions_text += f" 🆔 Lifecycle ID: {position_trade['trade_lifecycle_id'][:8]}\n\n"
- except Exception as e:
- logger.error(f"Error processing position {position_trade.get('symbol', 'unknown')}: {e}")
- continue
- # Calculate total unrealized P&L and total ROE
- total_unrealized_pnl = 0.0
- total_roe = 0.0
- for pos in open_positions:
- size = float(pos.get('size', 0))
- entry_price = float(pos.get('entryPrice', 0))
- mark_price = float(pos.get('markPrice', 0))
- roe = float(pos.get('roe_percentage', 0))
- if size != 0 and entry_price != 0:
- position_value = abs(size * entry_price)
- total_unrealized_pnl += size * (mark_price - entry_price)
- total_roe += roe * position_value
- total_position_value += position_value
- # Weighted average ROE
- avg_roe = (total_roe / total_position_value) if total_position_value > 0 else 0.0
- roe_emoji = "🟢" if avg_roe >= 0 else "🔴"
- # Add portfolio summary
- portfolio_emoji = "🟢" if total_unrealized >= 0 else "🔴"
- positions_text += f"💼 <b>Total Portfolio:</b>\n"
- positions_text += f" 🏦 Total Positions Value: ${total_position_value:,.2f}\n"
- if total_margin_used > 0:
- positions_text += f" 💳 Total Margin Used: ${total_margin_used:,.2f}\n"
- leverage_ratio = total_position_value / total_margin_used if total_margin_used > 0 else 1.0
- positions_text += f" ⚖️ Portfolio Leverage: {leverage_ratio:.2f}x\n"
- positions_text += f" {portfolio_emoji} Total Unrealized P&L: ${total_unrealized:,.2f}\n"
- if total_margin_used > 0:
- margin_pnl_percentage = (total_unrealized / total_margin_used) * 100
- positions_text += f" 📊 Portfolio Return: {margin_pnl_percentage:+.2f}% (on margin)\n"
- positions_text += "\n"
- positions_text += f"🤖 <b>Legend:</b> 🤖 Bot-created • 🔄 External/synced • 🛡️ External SL\n"
- positions_text += f"💡 Use /sl [token] [price] or /tp [token] [price] to set risk management"
- await self._reply(update, positions_text.strip())
- except Exception as e:
- logger.error(f"Error in positions command: {e}")
- await self._reply(update, "❌ Error retrieving position information.")
- def _get_external_stop_losses(self, symbol: str, position_side: str, entry_price: float,
- current_amount: float, exchange_orders: List[Dict[str, Any]]) -> List[float]:
- """Get external stop losses for a position."""
- external_sls = []
- for order in exchange_orders:
- try:
- order_symbol = order.get('symbol')
- order_side = order.get('side', '').lower()
- order_type = order.get('type', '').lower()
- order_price = float(order.get('price', 0))
- trigger_price = order.get('info', {}).get('triggerPrice')
- is_reduce_only = order.get('reduceOnly', False) or order.get('info', {}).get('reduceOnly', False)
- order_amount = float(order.get('amount', 0))
-
- if (order_symbol == symbol and is_reduce_only and
- abs(order_amount - current_amount) < 0.01 * current_amount):
-
- sl_trigger_price = 0
- if trigger_price:
- try:
- sl_trigger_price = float(trigger_price)
- except (ValueError, TypeError):
- pass
- if not sl_trigger_price and order_price > 0:
- sl_trigger_price = order_price
-
- is_valid_sl = False
- if position_side == 'long' and order_side == 'sell':
- if sl_trigger_price > 0 and sl_trigger_price < entry_price:
- is_valid_sl = True
- elif position_side == 'short' and order_side == 'buy':
- if sl_trigger_price > 0 and sl_trigger_price > entry_price:
- is_valid_sl = True
-
- if is_valid_sl:
- external_sls.append(sl_trigger_price)
-
- except Exception as e:
- logger.warning(f"Error processing order for external SL: {e}")
- continue
-
- return external_sls
|