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 = "š Open Positions\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} {base_asset} ({direction_text}){type_indicator}\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"š¼ Total Portfolio:\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"š¤ Legend: š¤ 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