|
@@ -1,492 +0,0 @@
|
|
|
-#!/usr/bin/env python3
|
|
|
-"""
|
|
|
-Handles synchronization of bot's position state with the exchange.
|
|
|
-"""
|
|
|
-
|
|
|
-import logging
|
|
|
-import asyncio
|
|
|
-import uuid
|
|
|
-from datetime import datetime, timezone
|
|
|
-from typing import Optional, Dict, Any, List
|
|
|
-
|
|
|
-from src.utils.token_display_formatter import get_formatter
|
|
|
-
|
|
|
-logger = logging.getLogger(__name__)
|
|
|
-
|
|
|
-class PositionSynchronizer:
|
|
|
- def __init__(self, trading_engine, notification_manager, market_monitor_cache):
|
|
|
- self.trading_engine = trading_engine
|
|
|
- self.notification_manager = notification_manager
|
|
|
- self.market_monitor_cache = market_monitor_cache # To access cached orders/positions
|
|
|
- # Add necessary initializations
|
|
|
-
|
|
|
- # Methods like _auto_sync_orphaned_positions, _immediate_startup_auto_sync, _estimate_entry_price_for_orphaned_position will go here
|
|
|
- pass
|
|
|
-
|
|
|
- async def _auto_sync_orphaned_positions(self):
|
|
|
- """Automatically detect and sync orphaned positions (positions on exchange without trade lifecycle records)."""
|
|
|
- try:
|
|
|
- stats = self.trading_engine.get_stats()
|
|
|
- if not stats:
|
|
|
- return
|
|
|
-
|
|
|
- formatter = get_formatter()
|
|
|
-
|
|
|
- exchange_positions = self.market_monitor_cache.cached_positions or [] # Use fresh cache from market_monitor_cache
|
|
|
- synced_count = 0
|
|
|
-
|
|
|
- for exchange_pos in exchange_positions:
|
|
|
- symbol = exchange_pos.get('symbol')
|
|
|
- contracts_abs = abs(float(exchange_pos.get('contracts', 0)))
|
|
|
-
|
|
|
- if not (symbol and contracts_abs > 1e-9): # Ensure position is substantial
|
|
|
- continue
|
|
|
-
|
|
|
- existing_trade = stats.get_trade_by_symbol_and_status(symbol, 'position_opened')
|
|
|
-
|
|
|
- if not existing_trade:
|
|
|
- entry_price_from_exchange = float(exchange_pos.get('entryPrice', 0)) or float(exchange_pos.get('entryPx', 0))
|
|
|
-
|
|
|
- position_side, order_side = '', ''
|
|
|
- ccxt_side = exchange_pos.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:
|
|
|
- raw_info = exchange_pos.get('info', {}).get('position', {})
|
|
|
- if isinstance(raw_info, dict):
|
|
|
- szi_str = raw_info.get('szi')
|
|
|
- if szi_str is not None:
|
|
|
- try: szi_val = float(szi_str)
|
|
|
- except ValueError: szi_val = 0
|
|
|
- if szi_val > 1e-9: position_side, order_side = 'long', 'buy'
|
|
|
- elif szi_val < -1e-9: position_side, order_side = 'short', 'sell'
|
|
|
-
|
|
|
- if not position_side:
|
|
|
- contracts_val = float(exchange_pos.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:
|
|
|
- logger.warning(f"AUTO-SYNC: Position size is effectively 0 for {symbol} after side checks, skipping sync. Data: {exchange_pos}")
|
|
|
- continue
|
|
|
-
|
|
|
- if not position_side:
|
|
|
- logger.error(f"AUTO-SYNC: CRITICAL - Could not determine position side for {symbol}. Data: {exchange_pos}. Skipping.")
|
|
|
- continue
|
|
|
-
|
|
|
- token = symbol.split('/')[0] if '/' in symbol else symbol
|
|
|
- actual_contracts_size = contracts_abs
|
|
|
-
|
|
|
- final_entry_price = entry_price_from_exchange
|
|
|
- price_source_log = "(exchange data)"
|
|
|
- if not final_entry_price or final_entry_price <= 0:
|
|
|
- estimated_entry_price = await self._estimate_entry_price_for_orphaned_position(symbol, actual_contracts_size, position_side)
|
|
|
- if estimated_entry_price > 0:
|
|
|
- final_entry_price = estimated_entry_price
|
|
|
- price_source_log = "(estimated)"
|
|
|
- else:
|
|
|
- logger.error(f"AUTO-SYNC: Could not determine/estimate entry price for {symbol}. Skipping sync.")
|
|
|
- continue
|
|
|
-
|
|
|
- logger.info(f"🔄 AUTO-SYNC: Orphaned position detected - {symbol} {position_side.upper()} {actual_contracts_size} @ ${final_entry_price:.4f} {price_source_log}")
|
|
|
-
|
|
|
- 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, actual_contracts_size,
|
|
|
- f"external_fill_sync_{unique_sync_id}"
|
|
|
- )
|
|
|
-
|
|
|
- if success:
|
|
|
- synced_count += 1
|
|
|
- logger.info(f"✅ AUTO-SYNC: Successfully synced orphaned position for {symbol} (Lifecycle: {lifecycle_id[:8]}).")
|
|
|
-
|
|
|
- if self.notification_manager:
|
|
|
- unrealized_pnl = float(exchange_pos.get('unrealizedPnl', 0))
|
|
|
- pnl_emoji = "🟢" if unrealized_pnl >= 0 else "🔴"
|
|
|
- notification_text = (
|
|
|
- f"🔄 <b>Position Auto-Synced</b>\n\n"
|
|
|
- f"Token: {token}\n"
|
|
|
- f"Lifecycle ID: {lifecycle_id[:8]}...\n"
|
|
|
- f"Direction: {position_side.upper()}\n"
|
|
|
- f"Size: {actual_contracts_size:.6f} {token}\n"
|
|
|
- f"Entry Price: ${final_entry_price:,.4f} {price_source_log}\n"
|
|
|
- f"{pnl_emoji} P&L (Unrealized): ${unrealized_pnl:,.2f}\n"
|
|
|
- f"Reason: Position found on exchange without bot record.\n"
|
|
|
- f"Time: {datetime.now(timezone.utc).strftime('%H:%M:%S')}\n\n"
|
|
|
- f"✅ Position now tracked. Use /sl or /tp if needed."
|
|
|
- )
|
|
|
- await self.notification_manager.send_generic_notification(notification_text)
|
|
|
- else:
|
|
|
- logger.error(f"❌ AUTO-SYNC: Failed to update lifecycle to 'position_opened' for {symbol} (Lifecycle: {lifecycle_id[:8]}).")
|
|
|
- else:
|
|
|
- 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 (Exchange had position, Bot did not).")
|
|
|
-
|
|
|
- bot_open_lifecycles = stats.get_trades_by_status('position_opened')
|
|
|
- if not bot_open_lifecycles:
|
|
|
- return
|
|
|
-
|
|
|
- current_exchange_positions_map = {}
|
|
|
- for ex_pos in (self.market_monitor_cache.cached_positions or []): # Use fresh cache from market_monitor_cache
|
|
|
- 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:
|
|
|
- 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"
|
|
|
-
|
|
|
- try:
|
|
|
- all_recent_fills = self.trading_engine.get_recent_fills()
|
|
|
- if all_recent_fills:
|
|
|
- symbol_specific_fills = [f for f in all_recent_fills if f.get('symbol') == symbol]
|
|
|
- if symbol_specific_fills:
|
|
|
- closing_side = 'sell' if position_side == 'long' else 'buy'
|
|
|
- relevant_fills = sorted(
|
|
|
- [f for f in symbol_specific_fills if f.get('side') == closing_side],
|
|
|
- key=lambda f: f.get('timestamp'), reverse=True
|
|
|
- )
|
|
|
- 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:
|
|
|
- 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:
|
|
|
- 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)
|
|
|
-
|
|
|
- unique_flat_id = str(uuid.uuid4())[:8]
|
|
|
- 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_{unique_flat_id}"
|
|
|
- )
|
|
|
-
|
|
|
- if success:
|
|
|
- closed_due_to_discrepancy += 1
|
|
|
- logger.info(f"✅ AUTO-SYNC (Discrepancy): Successfully closed bot lifecycle {lc_id} for {symbol}.")
|
|
|
- stats.migrate_trade_to_aggregated_stats(lc_id)
|
|
|
- if self.notification_manager:
|
|
|
- pnl_emoji = "🟢" if realized_pnl >= 0 else "🔴"
|
|
|
- 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"{pnl_emoji} Realized P&L for this closure: {formatter.format_price_with_symbol(realized_pnl)}\n"
|
|
|
- f"Time: {datetime.now(timezone.utc).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)
|
|
|
-
|
|
|
- async def _estimate_entry_price_for_orphaned_position(self, symbol: str, contracts: float, side: str) -> float:
|
|
|
- """Estimate entry price for an orphaned position by checking recent fills and market data."""
|
|
|
- try:
|
|
|
- entry_fill_side = 'buy' if side == 'long' else 'sell'
|
|
|
- formatter = get_formatter()
|
|
|
- token = symbol.split('/')[0] if '/' in symbol else symbol
|
|
|
- all_recent_fills = self.trading_engine.get_recent_fills()
|
|
|
- recent_fills = [f for f in all_recent_fills if f.get('symbol') == symbol]
|
|
|
-
|
|
|
- if recent_fills:
|
|
|
- symbol_side_fills = [
|
|
|
- fill for fill in recent_fills
|
|
|
- if fill.get('symbol') == symbol and fill.get('side') == entry_fill_side and float(fill.get('amount',0)) > 0
|
|
|
- ]
|
|
|
- if symbol_side_fills:
|
|
|
- symbol_side_fills.sort(key=lambda f: (
|
|
|
- datetime.fromtimestamp(f.get('timestamp') / 1000, tz=timezone.utc) if f.get('timestamp') else datetime.min.replace(tzinfo=timezone.utc),
|
|
|
- abs(float(f.get('amount',0)) - contracts)
|
|
|
- ), reverse=True)
|
|
|
-
|
|
|
- best_fill = symbol_side_fills[0]
|
|
|
- fill_price = float(best_fill.get('price', 0))
|
|
|
- fill_amount = float(best_fill.get('amount', 0))
|
|
|
- if fill_price > 0:
|
|
|
- logger.info(f"💡 AUTO-SYNC: Estimated entry for {side} {symbol} via recent {entry_fill_side} fill: {formatter.format_price_with_symbol(fill_price, token)} (Amount: {formatter.format_amount(fill_amount, token)})")
|
|
|
- return fill_price
|
|
|
-
|
|
|
- market_data = self.trading_engine.get_market_data(symbol)
|
|
|
- if market_data and market_data.get('ticker'):
|
|
|
- current_price = float(market_data['ticker'].get('last', 0))
|
|
|
- if current_price > 0:
|
|
|
- logger.warning(f"⚠️ AUTO-SYNC: Using current market price as entry estimate for {side} {symbol}: {formatter.format_price_with_symbol(current_price, token)}")
|
|
|
- return current_price
|
|
|
-
|
|
|
- if market_data and market_data.get('ticker'):
|
|
|
- bid = float(market_data['ticker'].get('bid', 0))
|
|
|
- ask = float(market_data['ticker'].get('ask', 0))
|
|
|
- if bid > 0 and ask > 0: return (bid + ask) / 2
|
|
|
-
|
|
|
- logger.warning(f"AUTO-SYNC: Could not estimate entry price for {side} {symbol} through any method.")
|
|
|
- return 0.0
|
|
|
- except Exception as e:
|
|
|
- logger.error(f"❌ Error estimating entry price for orphaned position {symbol}: {e}", exc_info=True)
|
|
|
- return 0.0
|
|
|
-
|
|
|
- async def _immediate_startup_auto_sync(self):
|
|
|
- """🆕 Immediately check for and sync orphaned positions on startup."""
|
|
|
- try:
|
|
|
- logger.info("🔍 STARTUP: Checking for orphaned positions...")
|
|
|
- stats = self.trading_engine.get_stats()
|
|
|
- if not stats:
|
|
|
- logger.warning("⚠️ STARTUP: TradingStats not available for auto-sync.")
|
|
|
- return
|
|
|
-
|
|
|
- formatter = get_formatter()
|
|
|
- exchange_positions = self.trading_engine.get_positions() or []
|
|
|
- if not exchange_positions:
|
|
|
- logger.info("✅ STARTUP: No positions found on exchange.")
|
|
|
- return
|
|
|
-
|
|
|
- synced_count = 0
|
|
|
- for exchange_pos in exchange_positions:
|
|
|
- symbol = exchange_pos.get('symbol')
|
|
|
- contracts_abs = abs(float(exchange_pos.get('contracts', 0)))
|
|
|
- token_for_log = symbol.split('/')[0] if symbol and '/' in symbol else symbol
|
|
|
-
|
|
|
- if not (symbol and contracts_abs > 1e-9): continue
|
|
|
-
|
|
|
- existing_trade_lc = stats.get_trade_by_symbol_and_status(symbol, 'position_opened')
|
|
|
- if not existing_trade_lc:
|
|
|
- position_side, order_side = '', ''
|
|
|
- ccxt_side = exchange_pos.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:
|
|
|
- raw_info = exchange_pos.get('info', {}).get('position', {})
|
|
|
- if isinstance(raw_info, dict):
|
|
|
- szi_str = raw_info.get('szi')
|
|
|
- if szi_str is not None:
|
|
|
- try: szi_val = float(szi_str)
|
|
|
- except ValueError: szi_val = 0
|
|
|
- if szi_val > 1e-9: position_side, order_side = 'long', 'buy'
|
|
|
- elif szi_val < -1e-9: position_side, order_side = 'short', 'sell'
|
|
|
-
|
|
|
- if not position_side:
|
|
|
- contracts_val = float(exchange_pos.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:
|
|
|
- logger.warning(f"AUTO-SYNC: Position size is effectively 0 for {symbol} after side checks, skipping sync. Data: {exchange_pos}")
|
|
|
- continue
|
|
|
-
|
|
|
- if not position_side:
|
|
|
- logger.error(f"AUTO-SYNC: CRITICAL - Could not determine position side for {symbol}. Data: {exchange_pos}. Skipping.")
|
|
|
- continue
|
|
|
-
|
|
|
- entry_price = float(exchange_pos.get('entryPrice', 0)) or float(exchange_pos.get('entryPx', 0))
|
|
|
- price_source_log = "(exchange data)"
|
|
|
- if not entry_price or entry_price <= 0:
|
|
|
- estimated_price = await self._estimate_entry_price_for_orphaned_position(symbol, contracts_abs, position_side)
|
|
|
- if estimated_price > 0:
|
|
|
- entry_price = estimated_price
|
|
|
- price_source_log = "(estimated)"
|
|
|
- else:
|
|
|
- logger.error(f"AUTO-SYNC: Could not determine/estimate entry price for {symbol}. Skipping sync.")
|
|
|
- continue
|
|
|
-
|
|
|
- logger.info(f"🔄 STARTUP: Auto-syncing orphaned position: {symbol} {position_side.upper()} {formatter.format_amount(contracts_abs, token_for_log)} @ {formatter.format_price_with_symbol(entry_price, token_for_log)} {price_source_log}")
|
|
|
-
|
|
|
- unique_id = str(uuid.uuid4())[:8]
|
|
|
- lifecycle_id = stats.create_trade_lifecycle(
|
|
|
- symbol=symbol, side=order_side,
|
|
|
- entry_order_id=f"startup_sync_{unique_id}",
|
|
|
- trade_type='external_startup_sync'
|
|
|
- )
|
|
|
-
|
|
|
- if lifecycle_id:
|
|
|
- success = stats.update_trade_position_opened(
|
|
|
- lifecycle_id, entry_price, contracts_abs,
|
|
|
- f"startup_fill_sync_{unique_id}"
|
|
|
- )
|
|
|
- if success:
|
|
|
- synced_count += 1
|
|
|
- logger.info(f"✅ STARTUP: Successfully synced orphaned position for {symbol} (Lifecycle: {lifecycle_id[:8]}).")
|
|
|
- await self._send_startup_auto_sync_notification(exchange_pos, symbol, position_side, contracts_abs, entry_price, lifecycle_id, price_source_log)
|
|
|
- else:
|
|
|
- logger.error(f"❌ STARTUP: Failed to update lifecycle for {symbol} (Lifecycle: {lifecycle_id[:8]}).")
|
|
|
- else:
|
|
|
- logger.error(f"❌ STARTUP: Failed to create lifecycle for {symbol}.")
|
|
|
-
|
|
|
- if synced_count == 0 and exchange_positions:
|
|
|
- logger.info("✅ STARTUP: All existing exchange positions are already tracked.")
|
|
|
- elif synced_count > 0:
|
|
|
- logger.info(f"🎉 STARTUP: Auto-synced {synced_count} orphaned position(s) (Exchange had pos, Bot did not).")
|
|
|
-
|
|
|
- logger.info("🔍 STARTUP: Checking for discrepancies (Bot has pos, Exchange does not)...")
|
|
|
- bot_open_lifecycles = stats.get_trades_by_status('position_opened')
|
|
|
-
|
|
|
- current_exchange_positions_map = {}
|
|
|
- for ex_pos in (exchange_positions or []):
|
|
|
- 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_startup = 0
|
|
|
- if bot_open_lifecycles:
|
|
|
- for lc in bot_open_lifecycles:
|
|
|
- symbol = lc.get('symbol')
|
|
|
- lc_id = lc.get('trade_lifecycle_id')
|
|
|
- token_for_log_discrepancy = symbol.split('/')[0] if symbol and '/' in symbol else symbol
|
|
|
-
|
|
|
- if symbol not in current_exchange_positions_map:
|
|
|
- logger.warning(f"🔄 STARTUP (Discrepancy): Bot lifecycle {lc_id} for {symbol} is 'position_opened', but NO position found on exchange. Closing lifecycle.")
|
|
|
-
|
|
|
- entry_price_lc = lc.get('entry_price', 0)
|
|
|
- position_side_lc = lc.get('position_side')
|
|
|
- position_size_for_pnl = lc.get('current_position_size', 0)
|
|
|
- exit_price_for_calc = 0
|
|
|
- price_source_info = "unknown"
|
|
|
-
|
|
|
- try:
|
|
|
- all_recent_fills_for_startup_sync = self.trading_engine.get_recent_fills()
|
|
|
- if all_recent_fills_for_startup_sync:
|
|
|
- symbol_specific_fills_startup = [f for f in all_recent_fills_for_startup_sync if f.get('symbol') == symbol]
|
|
|
- if symbol_specific_fills_startup:
|
|
|
- closing_side = 'sell' if position_side_lc == 'long' else 'buy'
|
|
|
- relevant_fills = sorted(
|
|
|
- [f for f in symbol_specific_fills_startup if f.get('side') == closing_side],
|
|
|
- key=lambda f: f.get('timestamp'), reverse=True
|
|
|
- )
|
|
|
- if relevant_fills:
|
|
|
- last_closing_fill = relevant_fills[0]
|
|
|
- exit_price_for_calc = float(last_closing_fill.get('price', 0))
|
|
|
- fill_ts_val = last_closing_fill.get('timestamp')
|
|
|
- fill_timestamp_str = datetime.fromtimestamp(fill_ts_val/1000, tz=timezone.utc).isoformat() if fill_ts_val else "N/A"
|
|
|
- price_source_info = f"(last exchange fill ({formatter.format_price(exit_price_for_calc, symbol)} @ {fill_timestamp_str}))"
|
|
|
- logger.info(f"STARTUP SYNC: Using exit price {price_source_info} for {symbol} lifecycle {lc_id}.")
|
|
|
- except Exception as e:
|
|
|
- logger.warning(f"STARTUP SYNC: Error fetching recent fills for {symbol} to determine exit price: {e}")
|
|
|
-
|
|
|
- if not exit_price_for_calc or exit_price_for_calc <= 0:
|
|
|
- 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"
|
|
|
- else:
|
|
|
- exit_price_for_calc = entry_price_lc
|
|
|
- price_source_info = "lifecycle entry_price (0 PNL)"
|
|
|
-
|
|
|
- realized_pnl = 0
|
|
|
- if position_side_lc == 'long':
|
|
|
- realized_pnl = position_size_for_pnl * (exit_price_for_calc - entry_price_lc)
|
|
|
- elif position_side_lc == 'short':
|
|
|
- realized_pnl = position_size_for_pnl * (entry_price_lc - exit_price_for_calc)
|
|
|
-
|
|
|
- unique_close_id = str(uuid.uuid4())[:8]
|
|
|
- success_close = stats.update_trade_position_closed(
|
|
|
- lifecycle_id=lc_id,
|
|
|
- exit_price=exit_price_for_calc,
|
|
|
- realized_pnl=realized_pnl,
|
|
|
- exchange_fill_id=f"startup_sync_flat_{unique_close_id}"
|
|
|
- )
|
|
|
-
|
|
|
- if success_close:
|
|
|
- closed_due_to_discrepancy_startup += 1
|
|
|
- logger.info(f"✅ STARTUP (Discrepancy): Successfully closed bot lifecycle {lc_id} for {symbol}.")
|
|
|
- stats.migrate_trade_to_aggregated_stats(lc_id)
|
|
|
- if self.notification_manager:
|
|
|
- pnl_emoji = "🟢" if realized_pnl >= 0 else "🔴"
|
|
|
- notification_text = (
|
|
|
- f"🔄 <b>Position Auto-Closed (Startup Sync)</b>\n\n"
|
|
|
- f"Token: {token_for_log_discrepancy}\n"
|
|
|
- f"Lifecycle ID: {lc_id[:8]}...\n"
|
|
|
- f"Reason: Bot startup - found open lifecycle, but no corresponding position on exchange.\n"
|
|
|
- f"Assumed Exit Price: {formatter.format_price(exit_price_for_calc, symbol)} (Source: {price_source_info})\n"
|
|
|
- f"{pnl_emoji} Realized P&L: {formatter.format_price_with_symbol(realized_pnl)}\n"
|
|
|
- f"Time: {datetime.now(timezone.utc).strftime('%H:%M:%S')}"
|
|
|
- )
|
|
|
- await self.notification_manager.send_generic_notification(notification_text)
|
|
|
- else:
|
|
|
- logger.error(f"❌ STARTUP (Discrepancy): Failed to close bot lifecycle {lc_id} for {symbol}.")
|
|
|
-
|
|
|
- if closed_due_to_discrepancy_startup > 0:
|
|
|
- logger.info(f"🎉 STARTUP: Auto-closed {closed_due_to_discrepancy_startup} lifecycle(s) due to discrepancy (Bot had pos, Exchange did not).")
|
|
|
- else:
|
|
|
- logger.info("✅ STARTUP: No discrepancies found where bot had position and exchange did not.")
|
|
|
-
|
|
|
- except Exception as e:
|
|
|
- logger.error(f"❌ Error in startup auto-sync: {e}", exc_info=True)
|
|
|
-
|
|
|
- async def _send_startup_auto_sync_notification(self, exchange_pos, symbol, position_side, contracts, entry_price, lifecycle_id, price_source_log):
|
|
|
- """Send notification for positions auto-synced on startup."""
|
|
|
- try:
|
|
|
- if not self.notification_manager: return
|
|
|
-
|
|
|
- formatter = get_formatter()
|
|
|
- token = symbol.split('/')[0] if '/' in symbol else symbol
|
|
|
- unrealized_pnl = float(exchange_pos.get('unrealizedPnl', 0))
|
|
|
- pnl_emoji = "🟢" if unrealized_pnl >= 0 else "🔴"
|
|
|
-
|
|
|
- size_str = formatter.format_amount(contracts, token)
|
|
|
- entry_price_str = formatter.format_price_with_symbol(entry_price, token)
|
|
|
- pnl_str = formatter.format_price_with_symbol(unrealized_pnl)
|
|
|
-
|
|
|
- notification_text_parts = [
|
|
|
- f"🚨 <b>Bot Startup: Position Auto-Synced</b>\n",
|
|
|
- f"Token: {token}",
|
|
|
- f"Lifecycle ID: {lifecycle_id[:8]}...",
|
|
|
- f"Direction: {position_side.upper()}",
|
|
|
- f"Size: {size_str} {token}",
|
|
|
- f"Entry Price: {entry_price_str} {price_source_log}",
|
|
|
- f"{pnl_emoji} P&L (Unrealized): {pnl_str}",
|
|
|
- f"Reason: Position found on exchange without bot record.",
|
|
|
- "\n✅ Position now tracked. Use /sl or /tp if needed."
|
|
|
- ]
|
|
|
-
|
|
|
- liq_price = float(exchange_pos.get('liquidationPrice', 0))
|
|
|
- if liq_price > 0:
|
|
|
- liq_price_str = formatter.format_price_with_symbol(liq_price, token)
|
|
|
- notification_text_parts.append(f"⚠️ Liquidation: {liq_price_str}")
|
|
|
-
|
|
|
- await self.notification_manager.send_generic_notification("\n".join(notification_text_parts))
|
|
|
- logger.info(f"📤 STARTUP: Sent auto-sync notification for {symbol} (Lifecycle: {lifecycle_id[:8]}).")
|
|
|
-
|
|
|
- except Exception as e:
|
|
|
- logger.error(f"❌ STARTUP: Failed to send auto-sync notification for {symbol}: {e}")
|