|
@@ -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)
|