Kaynağa Gözat

Enhance position metrics and synchronization in trading commands.

- Introduced total margin and equity used calculations for improved risk assessment in info_commands.
- Added Return on Equity (ROE) calculations to provide deeper insights into position performance.
- Enhanced external event monitoring to auto-sync orphaned positions, ensuring accurate lifecycle tracking.
- Improved logging for position synchronization processes to aid in diagnostics and reliability.
Carles Sentis 1 hafta önce
ebeveyn
işleme
0eee516e46

+ 52 - 3
src/commands/info_commands.py

@@ -169,6 +169,8 @@ class InfoCommands:
             if open_positions:
                 total_unrealized = 0
                 total_position_value = 0
+                total_margin_used = 0
+                total_equity_used = 0  # For ROE calculation
                 
                 # Fetch all exchange orders once to use throughout the command if needed by other parts
                 # For this specific change, we'll use it inside the loop, but good practice to fetch once.
@@ -224,20 +226,41 @@ class InfoCommands:
                             unrealized_pnl = (entry_price - mark_price) * abs_current_amount
                     unrealized_pnl = unrealized_pnl or 0.0 # Ensure it's not None for calculations
 
-                    # Tiered P&L Percentage Calculation
+                    # Tiered P&L Percentage Calculation (prioritize exchange percentage)
                     pnl_percentage = 0.0
                     exchange_pnl_percentage = position_trade.get('unrealized_pnl_percentage') # From exchange, e.g., 50.5 for 50.5%
                     margin_used = position_trade.get('margin_used')
 
                     if exchange_pnl_percentage is not None:
                         pnl_percentage = exchange_pnl_percentage 
+                        logger.debug(f"Using exchange percentage for {symbol}: {exchange_pnl_percentage}%")
                     elif margin_used is not None and margin_used > 0 and unrealized_pnl != 0:
                         pnl_percentage = (unrealized_pnl / margin_used) * 100
+                        logger.debug(f"Using margin-based calculation for {symbol}: {pnl_percentage}%")
                     elif entry_price != 0 and abs_current_amount != 0 and unrealized_pnl != 0:
                         initial_value = entry_price * abs_current_amount
                         pnl_percentage = (unrealized_pnl / initial_value) * 100
+                        logger.debug(f"Using position value calculation for {symbol}: {pnl_percentage}%")
                     # else pnl_percentage remains 0.0
 
+                    # Get ROE (Return on Equity) from exchange data
+                    roe_percentage = None
+                    # Try to get ROE from the raw exchange data
+                    exchange_data = self.trading_engine.get_positions()
+                    if exchange_data:
+                        for pos_data in exchange_data:
+                            if pos_data.get('symbol') == symbol:
+                                info_data = pos_data.get('info', {})
+                                position_info = info_data.get('position', {})
+                                roe_raw = position_info.get('returnOnEquity')
+                                if roe_raw is not None:
+                                    try:
+                                        roe_percentage = float(roe_raw) * 100  # Convert to percentage
+                                        logger.debug(f"Found ROE for {symbol}: {roe_percentage}%")
+                                    except (ValueError, TypeError):
+                                        logger.warning(f"Could not parse ROE value: {roe_raw} for {symbol}")
+                                break
+
                     # Add to totals
                     individual_position_value = position_trade.get('position_value')
                     if individual_position_value is None: # Fallback if not in DB
@@ -246,6 +269,16 @@ class InfoCommands:
                     total_position_value += individual_position_value
                     total_unrealized += unrealized_pnl
                     
+                    # Add margin to total
+                    if margin_used is not None:
+                        total_margin_used += margin_used
+                    
+                    # Add equity used for ROE calculation
+                    if roe_percentage is not None and roe_percentage != 0:
+                        # Calculate equity used from: unrealized_pnl = equity_used * (roe_percentage/100)
+                        equity_used = abs(unrealized_pnl / (roe_percentage / 100)) if roe_percentage != 0 else 0
+                        total_equity_used += equity_used
+                    
                     # --- Position Header Formatting (Emoji, Direction, Leverage) ---
                     pos_emoji = ""
                     direction_text = ""
@@ -299,7 +332,10 @@ class InfoCommands:
                         positions_text += f"   📈 Mark: {mark_price_str}\n"
                     
                     pnl_line_emoji = "🟢" if unrealized_pnl >= 0 else "🔴"
-                    positions_text += f"   {pnl_line_emoji} P&L: ${unrealized_pnl:,.2f} ({pnl_percentage:+.2f}%)\n"
+                    if roe_percentage is not None:
+                        positions_text += f"   {pnl_line_emoji} P&L: ${unrealized_pnl:,.2f} ({pnl_percentage:+.2f}% | ROE: {roe_percentage:+.2f}%)\n"
+                    else:
+                        positions_text += f"   {pnl_line_emoji} P&L: ${unrealized_pnl:,.2f} ({pnl_percentage:+.2f}%)\n"
                     
                     # Show exchange-provided risk data if available
                     if margin_used is not None:
@@ -381,8 +417,21 @@ class InfoCommands:
                 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"
-                positions_text += f"   {portfolio_emoji} Total Unrealized P&L: ${total_unrealized:,.2f}\n\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)"
+                    if total_equity_used > 0:
+                        equity_pnl_percentage = (total_unrealized / total_equity_used) * 100
+                        positions_text += f" | ROE: {equity_pnl_percentage:+.2f}%"
+                    positions_text += "\n"
+                positions_text += "\n"
                 positions_text += f"🤖 <b>Legend:</b> 🤖 Bot-created • 🔄 External/synced\n"
+                positions_text += f"📊 <b>Percentages:</b> Standard % (margin-based) | ROE % (equity-based)\n"
                 positions_text += f"💡 Use /sl [token] [price] or /tp [token] [price] to set risk management"
                 
             else:

+ 95 - 1
src/monitoring/external_event_monitor.py

@@ -294,6 +294,83 @@ class ExternalEventMonitor:
         except Exception as e:
             logger.error(f"Error sending position change notification: {e}")
     
+    async def _auto_sync_single_position(self, symbol: str, exchange_position: Dict[str, Any], stats) -> bool:
+        """Auto-sync a single orphaned position to create a lifecycle record."""
+        try:
+            import uuid
+            from src.utils.token_display_formatter import get_formatter
+            
+            formatter = get_formatter()
+            contracts_abs = abs(float(exchange_position.get('contracts', 0)))
+            
+            if contracts_abs <= 1e-9:
+                return False
+            
+            entry_price_from_exchange = float(exchange_position.get('entryPrice', 0)) or float(exchange_position.get('entryPx', 0))
+            
+            # Determine position side
+            position_side, order_side = '', ''
+            ccxt_side = exchange_position.get('side', '').lower()
+            if ccxt_side == 'long': 
+                position_side, order_side = 'long', 'buy'
+            elif ccxt_side == 'short': 
+                position_side, order_side = 'short', 'sell'
+            
+            if not position_side:
+                contracts_val = float(exchange_position.get('contracts', 0))
+                if contracts_val > 1e-9: 
+                    position_side, order_side = 'long', 'buy'
+                elif contracts_val < -1e-9: 
+                    position_side, order_side = 'short', 'sell'
+                else:
+                    return False
+            
+            if not position_side:
+                logger.error(f"AUTO-SYNC: Could not determine position side for {symbol}.")
+                return False
+            
+            final_entry_price = entry_price_from_exchange
+            if not final_entry_price or final_entry_price <= 0:
+                # Fallback to a reasonable estimate (current mark price)
+                mark_price = float(exchange_position.get('markPrice', 0)) or float(exchange_position.get('markPx', 0))
+                if mark_price > 0:
+                    final_entry_price = mark_price
+                else:
+                    logger.error(f"AUTO-SYNC: Could not determine entry price for {symbol}.")
+                    return False
+            
+            logger.info(f"🔄 AUTO-SYNC: Creating lifecycle for {symbol} {position_side.upper()} {contracts_abs} @ {formatter.format_price_with_symbol(final_entry_price, symbol)}")
+            
+            unique_sync_id = str(uuid.uuid4())[:8]
+            lifecycle_id = stats.create_trade_lifecycle(
+                symbol=symbol, 
+                side=order_side, 
+                entry_order_id=f"external_sync_{unique_sync_id}",
+                trade_type='external_sync'
+            )
+            
+            if lifecycle_id:
+                success = stats.update_trade_position_opened(
+                    lifecycle_id, 
+                    final_entry_price, 
+                    contracts_abs,
+                    f"external_fill_sync_{unique_sync_id}"
+                )
+                
+                if success:
+                    logger.info(f"✅ AUTO-SYNC: Successfully synced position for {symbol} (Lifecycle: {lifecycle_id[:8]})")
+                    return True
+                else:
+                    logger.error(f"❌ AUTO-SYNC: Failed to update lifecycle to 'position_opened' for {symbol}")
+            else:
+                logger.error(f"❌ AUTO-SYNC: Failed to create lifecycle for {symbol}")
+            
+            return False
+            
+        except Exception as e:
+            logger.error(f"❌ AUTO-SYNC: Error syncing position for {symbol}: {e}")
+            return False
+    
     async def _check_external_trades(self):
         """Check for trades made outside the Telegram bot and update stats."""
         try:
@@ -481,6 +558,23 @@ class ExternalEventMonitor:
                     # NEW: Enhanced external trade processing with position state detection
                     if not fill_processed_this_iteration:
                         existing_lc = stats.get_trade_by_symbol_and_status(full_symbol, 'position_opened')
+                        
+                        # If no lifecycle exists but we have a position on exchange, try to auto-sync first
+                        if not existing_lc:
+                            current_positions = self.trading_engine.get_positions() or []
+                            exchange_position = None
+                            for pos in current_positions:
+                                if pos.get('symbol') == full_symbol:
+                                    exchange_position = pos
+                                    break
+                            
+                            if exchange_position and abs(float(exchange_position.get('contracts', 0))) > 1e-9:
+                                logger.info(f"🔄 AUTO-SYNC: Position exists on exchange for {full_symbol} but no lifecycle found. Auto-syncing before processing fill.")
+                                success = await self._auto_sync_single_position(full_symbol, exchange_position, stats)
+                                if success:
+                                    # Re-check for lifecycle after auto-sync
+                                    existing_lc = stats.get_trade_by_symbol_and_status(full_symbol, 'position_opened')
+                        
                         action_type = await self._determine_position_action_type(
                             full_symbol, side_from_fill, amount_from_fill, existing_lc
                         )
@@ -576,7 +670,7 @@ class ExternalEventMonitor:
                         db_open_symbols = {pos_db.get('symbol') for pos_db in all_open_positions_in_db}
 
                         if full_symbol in db_open_symbols:
-                            logger.error(f"🚨 DIAGNOSTIC: Contradiction for {full_symbol}! get_open_positions() includes it, but get_trade_by_symbol_and_status('{full_symbol}', 'position_opened') failed to find it within _check_external_trades context for fill {trade_id}. This needs investigation into TradingStats symbol querying.")
+                            logger.debug(f"Position {full_symbol} found in open positions but no active lifecycle - likely auto-sync failed or timing issue for fill {trade_id}")
                         
                         # Record as unmatched external trade
                         linked_order_db_id = None

+ 3 - 2
src/stats/trading_stats.py

@@ -97,11 +97,12 @@ class TradingStats:
                             amount_requested: float, price: Optional[float] = None, 
                             bot_order_ref_id: Optional[str] = None, 
                             exchange_order_id: Optional[str] = None, 
-                            timestamp: Optional[str] = None) -> bool:
+                            timestamp: Optional[str] = None,
+                            status: str = 'open') -> bool:
         """Record order placement."""
         result = self.order_manager.record_order_placed(
             symbol, side, order_type, amount_requested, price, 
-            bot_order_ref_id, exchange_order_id
+            bot_order_ref_id, exchange_order_id, status
         )
         return result is not None
     

+ 1 - 1
trading_bot.py

@@ -14,7 +14,7 @@ from datetime import datetime
 from pathlib import Path
 
 # Bot version
-BOT_VERSION = "2.3.160"
+BOT_VERSION = "2.3.161"
 
 # Add src directory to Python path
 sys.path.insert(0, str(Path(__file__).parent / "src"))