Explorar el Código

Update BOT_VERSION to 2.2.126 and enhance MarketMonitor with external fill handling and auto-sync logic for discrepancies.

- Incremented BOT_VERSION for the upcoming release.
- Added logic to process external fills that may close existing open positions, including verification against exchange data.
- Implemented auto-sync functionality to close bot lifecycles when discrepancies are detected between bot and exchange positions, improving synchronization and accuracy.
Carles Sentis hace 3 días
padre
commit
94ca147fe0
Se han modificado 2 ficheros con 186 adiciones y 2 borrados
  1. 185 1
      src/monitoring/market_monitor.py
  2. 1 1
      trading_bot.py

+ 185 - 1
src/monitoring/market_monitor.py

@@ -812,6 +812,96 @@ class MarketMonitor:
                                 logger.warning(f"⚠️ External SL (MM) {exchange_order_id_from_fill} for {full_symbol}, but no active lifecycle found.")
                     
                     # --- Fallback for Fills Not Handled by Lifecycle Logic Above ---
+                    if not fill_processed_this_iteration:
+                        # NEW: Attempt to match this fill to close an existing open position
+                        # This handles cases where an order disappeared from DB, then its fill is processed as external
+                        existing_open_lc = stats.get_trade_by_symbol_and_status(full_symbol, 'position_opened')
+                        if existing_open_lc:
+                            lc_id = existing_open_lc['trade_lifecycle_id']
+                            lc_entry_price = existing_open_lc.get('entry_price', 0)
+                            lc_position_side = existing_open_lc.get('position_side')
+                            lc_current_size_before_fill = existing_open_lc.get('current_position_size', 0) # Size before this fill
+
+                            is_potentially_closing_external_fill = False
+                            if lc_position_side == 'long' and side_from_fill.lower() == 'sell':
+                                is_potentially_closing_external_fill = True
+                            elif lc_position_side == 'short' and side_from_fill.lower() == 'buy':
+                                is_potentially_closing_external_fill = True
+                            
+                            if is_potentially_closing_external_fill:
+                                logger.info(f"ℹ️ Detected potentially closing external fill {trade_id} for {full_symbol} (Lifecycle: {lc_id}). Verifying exchange position state...")
+                                
+                                # Fetch fresh position data from the exchange to confirm closure
+                                fresh_positions_after_fill = self.trading_engine.get_positions() or []
+                                position_on_exchange_after_fill = None
+                                for pos in fresh_positions_after_fill:
+                                    if pos.get('symbol') == full_symbol:
+                                        position_on_exchange_after_fill = pos
+                                        break
+                                
+                                position_is_closed_on_exchange = False
+                                if position_on_exchange_after_fill is None:
+                                    position_is_closed_on_exchange = True
+                                    logger.info(f"✅ Exchange Verification: Position for {full_symbol} (Lifecycle: {lc_id}) not found after fill {trade_id}. Confirming closure.")
+                                elif abs(float(position_on_exchange_after_fill.get('contracts', 0))) < 1e-9: # Using a small tolerance for float comparison to zero
+                                    position_is_closed_on_exchange = True
+                                    logger.info(f"✅ Exchange Verification: Position for {full_symbol} (Lifecycle: {lc_id}) has zero size on exchange after fill {trade_id}. Confirming closure.")
+
+                                if position_is_closed_on_exchange:
+                                    # Position is confirmed closed on the exchange.
+                                    # P&L should be calculated based on the size that was closed by this fill,
+                                    # which we assume is lc_current_size_before_fill if the position is now entirely gone.
+                                    # If the fill amount (amount_from_fill) is less than lc_current_size_before_fill
+                                    # but the position is still gone, it implies other fills might have also occurred.
+                                    # For simplicity here, we use amount_from_fill for P&L calculation relating to this specific fill,
+                                    # assuming it's the one that effectively zeroed out the position or was the last part of it.
+                                    # A more robust P&L would use lc_current_size_before_fill if that was the true amount closed.
+                                    # Let's use lc_current_size_before_fill if the fill amount is very close to it, otherwise amount_from_fill.
+                                    
+                                    amount_for_pnl_calc = amount_from_fill
+                                    # If the position is fully closed, and this fill's amount is very close to the total size,
+                                    # assume this fill closed the entire remaining position.
+                                    if abs(lc_current_size_before_fill - amount_from_fill) < 0.000001 * amount_from_fill:
+                                         amount_for_pnl_calc = lc_current_size_before_fill
+
+
+                                    logger.info(f"ℹ️ Attempting to close lifecycle {lc_id} for {full_symbol} via confirmed external fill {trade_id}.")
+                                    realized_pnl = 0
+                                    if lc_position_side == 'long':
+                                        realized_pnl = amount_for_pnl_calc * (price_from_fill - lc_entry_price)
+                                    elif lc_position_side == 'short':
+                                        realized_pnl = amount_for_pnl_calc * (lc_entry_price - price_from_fill)
+                                    
+                                    success = stats.update_trade_position_closed(
+                                        lifecycle_id=lc_id, 
+                                        exit_price=price_from_fill, # Price of this specific fill
+                                        realized_pnl=realized_pnl, 
+                                        exchange_fill_id=trade_id
+                                    )
+                                    if success:
+                                        pnl_emoji = "🟢" if realized_pnl >= 0 else "🔴"
+                                        formatter = get_formatter()
+                                        logger.info(f"{pnl_emoji} Lifecycle CLOSED (Verified External): {lc_id}. PNL for fill: {formatter.format_price_with_symbol(realized_pnl)}")
+                                        symbols_with_fills.add(token)
+                                        if self.notification_manager:
+                                            await self.notification_manager.send_external_trade_notification(
+                                                full_symbol, side_from_fill, amount_from_fill, price_from_fill, 
+                                                f"verified_external_{lc_position_side}_close", 
+                                                timestamp_dt.isoformat(), realized_pnl
+                                            )
+                                        fill_processed_this_iteration = True
+                                    else:
+                                        logger.error(f"❌ Failed to close lifecycle {lc_id} via verified external fill {trade_id}.")
+                                else:
+                                    # Position still exists on exchange, so this fill was not a full closure.
+                                    # Do not mark fill_processed_this_iteration = True here.
+                                    # Let the original fallback `stats.record_trade` handle this fill as "external_unmatched".
+                                    # This is important so the fill is recorded, even if it does not close the lifecycle.
+                                    current_size_on_exchange = float(position_on_exchange_after_fill.get('contracts', 0)) if position_on_exchange_after_fill else 'Unknown'
+                                    logger.warning(f"⚠️ External fill {trade_id} for {full_symbol} (Lifecycle: {lc_id}, Amount: {amount_from_fill}) did NOT fully close position. Exchange size now: {current_size_on_exchange}. Lifecycle remains open. Fill will be recorded as 'external_unmatched'.")
+                                    # Future enhancement: Handle partial closure here by updating current_position_size and realizing partial P&L.
+
+                    # Original Fallback logic if still not processed
                     if not fill_processed_this_iteration:
                         linked_order_db_id = None
                         if exchange_order_id_from_fill:
@@ -1652,7 +1742,101 @@ class MarketMonitor:
                         logger.error(f"❌ AUTO-SYNC: Failed to create lifecycle for orphaned position {symbol}.")
 
             if synced_count > 0:
-                logger.info(f"🔄 AUTO-SYNC: Synced {synced_count} orphaned position(s) this cycle.")
+                logger.info(f"🔄 AUTO-SYNC: Synced {synced_count} orphaned position(s) this cycle (Exchange had position, Bot did not).")
+
+            # --- NEW LOGIC: Bot thinks position is open, but exchange does not --- #
+            bot_open_lifecycles = stats.get_trades_by_status('position_opened')
+            if not bot_open_lifecycles:
+                return # No open lifecycles according to the bot, nothing to check here.
+
+            # Create a map of current exchange positions for quick lookup: symbol -> position_data
+            current_exchange_positions_map = {}
+            for ex_pos in (self.cached_positions or []): # Use cached, recently updated positions
+                if ex_pos.get('symbol') and abs(float(ex_pos.get('contracts', 0))) > 1e-9:
+                    current_exchange_positions_map[ex_pos.get('symbol')] = ex_pos
+            
+            closed_due_to_discrepancy = 0
+            for lc in bot_open_lifecycles:
+                symbol = lc.get('symbol')
+                lc_id = lc.get('trade_lifecycle_id')
+                token = symbol.split('/')[0] if '/' in symbol else symbol
+
+                if symbol not in current_exchange_positions_map:
+                    # Bot has an open lifecycle, but no corresponding position found on exchange.
+                    logger.warning(f"🔄 AUTO-SYNC (Discrepancy): Bot lifecycle {lc_id} for {symbol} is 'position_opened', but NO position found on exchange. Closing lifecycle.")
+                    
+                    entry_price = lc.get('entry_price', 0)
+                    position_side = lc.get('position_side')
+                    position_size_for_pnl = lc.get('current_position_size', 0)
+                    exit_price_for_calc = 0
+                    price_source_info = "unknown"
+
+                    # Attempt to find a recent closing fill from the exchange
+                    try:
+                        recent_fills = self.trading_engine.get_recent_fills(symbol=symbol, limit=10) # Fetch last 10 fills for the symbol
+                        if recent_fills:
+                            closing_side = 'sell' if position_side == 'long' else 'buy'
+                            relevant_fills = sorted(
+                                [f for f in recent_fills if f.get('side') == closing_side and f.get('symbol') == symbol],
+                                key=lambda f: f.get('timestamp'), reverse=True # Most recent first
+                            )
+                            if relevant_fills:
+                                last_closing_fill = relevant_fills[0]
+                                exit_price_for_calc = float(last_closing_fill.get('price', 0))
+                                fill_timestamp = datetime.fromtimestamp(last_closing_fill.get('timestamp')/1000, tz=timezone.utc).isoformat() if last_closing_fill.get('timestamp') else "N/A"
+                                price_source_info = f"last exchange fill ({formatter.format_price(exit_price_for_calc, symbol)} @ {fill_timestamp})"
+                                logger.info(f"AUTO-SYNC: Using exit price {price_source_info} for {symbol} lifecycle {lc_id}.")
+                    except Exception as e:
+                        logger.warning(f"AUTO-SYNC: Error fetching recent fills for {symbol} to determine exit price: {e}")
+
+                    if not exit_price_for_calc or exit_price_for_calc <= 0:
+                        # Fallback to mark_price from lifecycle if available
+                        mark_price_from_lc = lc.get('mark_price')
+                        if mark_price_from_lc and float(mark_price_from_lc) > 0:
+                            exit_price_for_calc = float(mark_price_from_lc)
+                            price_source_info = "lifecycle mark_price"
+                            logger.info(f"AUTO-SYNC: No recent fill found. Using exit price from lifecycle mark_price: {formatter.format_price(exit_price_for_calc, symbol)} for {symbol} lifecycle {lc_id}.")
+                        else:
+                            # Last resort: use entry_price (implies 0 PNL for this closure action)
+                            exit_price_for_calc = entry_price
+                            price_source_info = "lifecycle entry_price (0 PNL)"
+                            logger.info(f"AUTO-SYNC: No recent fill or mark_price. Using entry_price: {formatter.format_price(exit_price_for_calc, symbol)} for {symbol} lifecycle {lc_id}.")
+                    
+                    realized_pnl = 0
+                    if position_side == 'long':
+                        realized_pnl = position_size_for_pnl * (exit_price_for_calc - entry_price)
+                    elif position_side == 'short':
+                        realized_pnl = position_size_for_pnl * (entry_price - exit_price_for_calc)
+                    
+                    success = stats.update_trade_position_closed(
+                        lifecycle_id=lc_id,
+                        exit_price=exit_price_for_calc, 
+                        realized_pnl=realized_pnl,
+                        exchange_fill_id=f"auto_sync_flat_{int(datetime.now().timestamp())}"
+                    )
+                    
+                    if success:
+                        closed_due_to_discrepancy += 1
+                        logger.info(f"✅ AUTO-SYNC (Discrepancy): Successfully closed bot lifecycle {lc_id} for {symbol}.")
+                        if self.notification_manager:
+                            pnl_emoji = "🟢" if realized_pnl >= 0 else "🔴"
+                            # formatter is already defined in the outer scope of _auto_sync_orphaned_positions
+                            notification_text = (
+                                f"🔄 <b>Position Auto-Closed (Discrepancy)</b>\n\n"
+                                f"Token: {token}\n"
+                                f"Lifecycle ID: {lc_id[:8]}...\n"
+                                f"Reason: Bot showed open position, but no corresponding position found on exchange.\n"
+                                f"Assumed Exit Price: {formatter.format_price(exit_price_for_calc, symbol)}\n"
+                                f"{pnl_emoji} Realized P&L for this closure: {formatter.format_price_with_symbol(realized_pnl)}\n"
+                                f"Time: {datetime.now().strftime('%H:%M:%S')}\n\n"
+                                f"ℹ️ Bot state synchronized with exchange."
+                            )
+                            await self.notification_manager.send_generic_notification(notification_text)
+                    else:
+                        logger.error(f"❌ AUTO-SYNC (Discrepancy): Failed to close bot lifecycle {lc_id} for {symbol}.")
+            
+            if closed_due_to_discrepancy > 0:
+                logger.info(f"🔄 AUTO-SYNC: Closed {closed_due_to_discrepancy} lifecycle(s) due to discrepancy (Bot had position, Exchange did not).")
 
         except Exception as e:
             logger.error(f"❌ Error in auto-sync orphaned positions: {e}", exc_info=True)

+ 1 - 1
trading_bot.py

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