Browse Source

Add /sync command for resynchronizing bot with exchange

- Introduced the /sync command to clear local data and resynchronize with the exchange.
- Implemented safety features to prevent accidental usage, requiring a "force" parameter for execution.
- Detailed the command's functionality, including data clearing, fetching exchange state, and preserving historical performance data.
- Updated documentation to include /sync force command and its usage examples in commands.md.
- Enhanced error handling and logging for the synchronization process, ensuring better traceability and user feedback.
Carles Sentis 18 hours ago
parent
commit
058e9bc471
4 changed files with 349 additions and 35 deletions
  1. 62 0
      docs/commands.md
  2. 3 0
      src/bot/core.py
  3. 283 34
      src/commands/management_commands.py
  4. 1 1
      trading_bot.py

+ 62 - 0
docs/commands.md

@@ -19,6 +19,7 @@ The most important commands to get started:
 /alarm BTC 50000 # Set price alert for BTC at $50,000
 /alarm BTC 50000 # Set price alert for BTC at $50,000
 /risk        # Advanced risk metrics
 /risk        # Advanced risk metrics
 /version     # Bot version and system info
 /version     # Bot version and system info
+/sync force  # Resync bot with exchange (if out of sync)
 /help        # Full command reference
 /help        # Full command reference
 ```
 ```
 
 
@@ -1221,6 +1222,66 @@ Show bot version and system information.
 ⏰ Current Time: 2024-12-15 14:25:30
 ⏰ Current Time: 2024-12-15 14:25:30
 ```
 ```
 
 
+### `/sync force`
+Clear all local bot data and resynchronize with the exchange. This is useful if the bot gets out of sync with your exchange positions/orders.
+
+**Example:**
+```
+/sync force
+```
+
+**What it does:**
+1. **Clears local data:** Removes bot's internal tracking of trades, orders, and pending stop losses
+2. **Fetches exchange state:** Gets current positions and orders from the exchange
+3. **Recreates tracking:** Rebuilds internal position and order tracking
+4. **Preserves history:** Keeps completed trade statistics and performance data
+
+**Safety Features:**
+- Requires "force" parameter to prevent accidental usage
+- Shows warning message without "force" parameter
+- Preserves historical performance data
+- Maintains balance adjustment history
+
+**Use Cases:**
+- Bot tracking gets out of sync with exchange
+- After manual trading outside the bot
+- Recovery from system errors
+- Fresh start while preserving statistics
+
+**What gets cleared:**
+- Active position tracking
+- Pending order monitoring
+- Internal stop loss database
+- Order status tracking
+
+**What gets preserved:**
+- Completed trade history
+- Performance statistics
+- Balance adjustment records
+- Historical P&L data
+
+**Response:**
+```
+✅ Synchronization Complete!
+
+📊 Sync Results:
+• Positions Recreated: 2
+• Orders Linked: 3
+• Current Balance: $1,250.00
+
+🔄 What was reset:
+• Local trade tracking
+• Pending orders database
+• Order monitoring state
+
+🎯 What was preserved:
+• Historical performance data
+• Balance adjustment history
+• Completed trade statistics
+
+💡 The bot is now synchronized with your exchange state and ready for trading!
+```
+
 ### `/balance_adjustments`
 ### `/balance_adjustments`
 Show deposit and withdrawal history for accurate P&L tracking.
 Show deposit and withdrawal history for accurate P&L tracking.
 
 
@@ -1307,6 +1368,7 @@ Show deposit and withdrawal history for accurate P&L tracking.
 /monitoring       # Check system status
 /monitoring       # Check system status
 /orders           # All open orders
 /orders           # All open orders
 /logs             # System health
 /logs             # System health
+/sync force       # Resync with exchange
 ```
 ```
 
 
 ## ⚠️ Important Notes
 ## ⚠️ Important Notes

+ 3 - 0
src/bot/core.py

@@ -159,6 +159,7 @@ class TelegramTradingBot:
         self.application.add_handler(CommandHandler("debug", self.management_commands.debug_command))
         self.application.add_handler(CommandHandler("debug", self.management_commands.debug_command))
         self.application.add_handler(CommandHandler("version", self.management_commands.version_command))
         self.application.add_handler(CommandHandler("version", self.management_commands.version_command))
         self.application.add_handler(CommandHandler("keyboard", self.management_commands.keyboard_command))
         self.application.add_handler(CommandHandler("keyboard", self.management_commands.keyboard_command))
+        self.application.add_handler(CommandHandler("sync", self.management_commands.sync_command))
         
         
         # Callback and message handlers
         # Callback and message handlers
         self.application.add_handler(CallbackQueryHandler(self.trading_commands.button_callback))
         self.application.add_handler(CallbackQueryHandler(self.trading_commands.button_callback))
@@ -304,6 +305,7 @@ Type /help for detailed command information.
 <b>🔄 Order Monitoring:</b>
 <b>🔄 Order Monitoring:</b>
 • /monitoring - View monitoring status
 • /monitoring - View monitoring status
 • /logs - View log file statistics and cleanup
 • /logs - View log file statistics and cleanup
+• /sync force - Resync bot with exchange (clears local data)
 
 
 <b>⚙️ Configuration:</b>
 <b>⚙️ Configuration:</b>
 • Default Token: {Config.DEFAULT_TRADING_TOKEN}
 • Default Token: {Config.DEFAULT_TRADING_TOKEN}
@@ -375,6 +377,7 @@ For support, contact your bot administrator.
 • /alarm [token] [price] - Set price alerts
 • /alarm [token] [price] - Set price alerts
 • /logs - View recent bot logs
 • /logs - View recent bot logs
 • /debug - Bot internal state (troubleshooting)
 • /debug - Bot internal state (troubleshooting)
+• /sync force - Clear local data and resync with exchange
 
 
 For support or issues, check the logs or contact the administrator.
 For support or issues, check the logs or contact the administrator.
         """
         """

+ 283 - 34
src/commands/management_commands.py

@@ -494,47 +494,296 @@ Will trigger when {token} price moves {alarm['direction']} {target_price_str}
             logger.error(f"Error in version command: {e}")
             logger.error(f"Error in version command: {e}")
     
     
     async def keyboard_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
     async def keyboard_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
-        """Handle the /keyboard command to enable/show custom keyboard."""
+        """Handle the /keyboard command to show the main keyboard."""
         chat_id = update.effective_chat.id
         chat_id = update.effective_chat.id
         if not self._is_authorized(chat_id):
         if not self._is_authorized(chat_id):
             await context.bot.send_message(chat_id=chat_id, text="❌ Unauthorized access.")
             await context.bot.send_message(chat_id=chat_id, text="❌ Unauthorized access.")
             return
             return
+
+        # Define default keyboard layout
+        default_keyboard = [
+            [KeyboardButton("LONG"), KeyboardButton("SHORT"), KeyboardButton("EXIT")],
+            [KeyboardButton("BALANCE"), KeyboardButton("POSITIONS"), KeyboardButton("ORDERS")],
+            [KeyboardButton("STATS"), KeyboardButton("MARKET"), KeyboardButton("PERFORMANCE")],
+            [KeyboardButton("DAILY"), KeyboardButton("WEEKLY"), KeyboardButton("MONTHLY")],
+            [KeyboardButton("RISK"), KeyboardButton("ALARM"), KeyboardButton("MONITORING")],
+            [KeyboardButton("LOGS"), KeyboardButton("DEBUG"), KeyboardButton("VERSION")],
+            [KeyboardButton("COMMANDS"), KeyboardButton("KEYBOARD"), KeyboardButton("COO")]
+        ]
+
+        # Try to use custom keyboard from config if enabled
+        if Config.TELEGRAM_CUSTOM_KEYBOARD_ENABLED and Config.TELEGRAM_CUSTOM_KEYBOARD_LAYOUT:
+            try:
+                # Parse layout from config: "cmd1,cmd2|cmd3,cmd4|cmd5,cmd6"
+                rows = Config.TELEGRAM_CUSTOM_KEYBOARD_LAYOUT.split('|')
+                keyboard = []
+                for row in rows:
+                    buttons = []
+                    for cmd in row.split(','):
+                        cmd = cmd.strip()
+                        # Remove leading slash if present and convert to button text
+                        if cmd.startswith('/'):
+                            cmd = cmd[1:]
+                        buttons.append(KeyboardButton(cmd.upper()))
+                    if buttons:  # Only add non-empty rows
+                        keyboard.append(buttons)
+            except Exception as e:
+                logger.warning(f"Error parsing custom keyboard layout: {e}, falling back to default")
+                keyboard = default_keyboard
+        else:
+            # Use default keyboard when custom keyboard is disabled
+            keyboard = default_keyboard
+        
+        reply_markup = ReplyKeyboardMarkup(keyboard, resize_keyboard=True)
         
         
-        keyboard_enabled = getattr(Config, 'TELEGRAM_CUSTOM_KEYBOARD_ENABLED', False)
-        keyboard_layout_str = getattr(Config, 'TELEGRAM_CUSTOM_KEYBOARD_LAYOUT', '')
-
-        if keyboard_enabled and keyboard_layout_str:
-            rows_str = keyboard_layout_str.split('|')
-            keyboard = []
-            button_details_message = ""
-            for row_str in rows_str:
-                button_commands = row_str.split(',')
-                row_buttons = []
-                for cmd_text in button_commands:
-                    cmd_text = cmd_text.strip()
-                    if cmd_text: # Ensure not empty after strip
-                        row_buttons.append(KeyboardButton(cmd_text))
-                        # For the message, show the command and a brief description if possible
-                        # This part would require a mapping from command to description
-                        # For now, just list the command text.
-                        button_details_message += f"• {cmd_text}\n"
-                if row_buttons:
-                    keyboard.append(row_buttons)
+        await context.bot.send_message(
+            chat_id=chat_id, 
+            text="🎹 <b>Trading Bot Keyboard</b>\n\nUse the buttons below for quick access to commands:", 
+            reply_markup=reply_markup, 
+            parse_mode='HTML'
+        )
+
+    async def sync_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+        """Handle the /sync command to clear local data and resync with exchange."""
+        chat_id = update.effective_chat.id
+        if not self._is_authorized(chat_id):
+            await context.bot.send_message(chat_id=chat_id, text="❌ Unauthorized access.")
+            return
+
+        try:
+            # Get confirmation from user unless force flag is provided
+            force_sync = len(context.args) > 0 and context.args[0].lower() == "force"
             
             
-            if not keyboard:
-                await context.bot.send_message(chat_id=chat_id, text="⚠️ Custom keyboard layout is empty or invalid in config.", parse_mode='HTML')
+            if not force_sync:
+                # Send confirmation message
+                await context.bot.send_message(
+                    chat_id=chat_id,
+                    text=(
+                        "⚠️ <b>Data Synchronization Warning</b>\n\n"
+                        "This will:\n"
+                        "• Clear ALL local trades, orders, and pending stop losses\n"
+                        "• Reset bot tracking state\n"
+                        "• Sync with current exchange positions/orders\n"
+                        "• Preserve balance and performance history\n\n"
+                        "🔄 Use <code>/sync force</code> to proceed\n"
+                        "❌ Use any other command to cancel"
+                    ),
+                    parse_mode='HTML'
+                )
                 return
                 return
 
 
-            reply_markup = ReplyKeyboardMarkup(
-                keyboard,
-                resize_keyboard=True,
-                one_time_keyboard=False, # Persistent keyboard
-                selective=True
+            # Send processing message
+            processing_msg = await context.bot.send_message(
+                chat_id=chat_id,
+                text="🔄 <b>Synchronizing with Exchange...</b>\n\n⏳ Step 1/5: Clearing local data...",
+                parse_mode='HTML'
+            )
+
+            # Step 1: Clear local trading data
+            stats = self.trading_engine.get_stats()
+            if stats:
+                # Clear trades table (keep only position_closed for history)
+                stats.db_manager._execute_query(
+                    "DELETE FROM trades WHERE status IN ('pending', 'executed', 'position_opened', 'cancelled')"
+                )
+                
+                # Clear orders table (keep only filled/cancelled for history)
+                stats.db_manager._execute_query(
+                    "DELETE FROM orders WHERE status IN ('pending_submission', 'open', 'submitted', 'pending_trigger')"
+                )
+                
+                logger.info("🧹 Cleared local trading state from database")
+
+            # Update status
+            await context.bot.edit_message_text(
+                chat_id=chat_id,
+                message_id=processing_msg.message_id,
+                text="🔄 <b>Synchronizing with Exchange...</b>\n\n✅ Step 1/5: Local data cleared\n⏳ Step 2/5: Clearing pending stop losses...",
+                parse_mode='HTML'
+            )
+
+            # Step 2: Clear pending stop loss orders
+            try:
+                if hasattr(self.monitoring_coordinator, 'pending_orders_manager'):
+                    pending_manager = self.monitoring_coordinator.pending_orders_manager
+                    if pending_manager and hasattr(pending_manager, 'db_path'):
+                        import sqlite3
+                        with sqlite3.connect(pending_manager.db_path) as conn:
+                            conn.execute("DELETE FROM pending_stop_loss WHERE status IN ('pending', 'placed')")
+                            conn.commit()
+                        logger.info("🧹 Cleared pending stop loss orders")
+            except Exception as e:
+                logger.warning(f"Could not clear pending orders: {e}")
+
+            # Update status
+            await context.bot.edit_message_text(
+                chat_id=chat_id,
+                message_id=processing_msg.message_id,
+                text="🔄 <b>Synchronizing with Exchange...</b>\n\n✅ Step 1/5: Local data cleared\n✅ Step 2/5: Pending stop losses cleared\n⏳ Step 3/5: Fetching exchange state...",
+                parse_mode='HTML'
+            )
+
+            # Step 3: Fetch current exchange state
+            exchange_positions = self.trading_engine.get_positions() or []
+            exchange_orders = self.trading_engine.get_orders() or []
+            
+            # Update status
+            await context.bot.edit_message_text(
+                chat_id=chat_id,
+                message_id=processing_msg.message_id,
+                text="🔄 <b>Synchronizing with Exchange...</b>\n\n✅ Step 1/5: Local data cleared\n✅ Step 2/5: Pending stop losses cleared\n✅ Step 3/5: Exchange state fetched\n⏳ Step 4/5: Recreating position tracking...",
+                parse_mode='HTML'
             )
             )
+
+            # Step 4: Recreate position tracking for open positions
+            positions_synced = 0
+            orders_synced = 0
+            
+            if stats:
+                # Create trade lifecycles for open positions
+                for position in exchange_positions:
+                    try:
+                        size = float(position.get('contracts', 0))
+                        if abs(size) > 1e-8:  # Position exists
+                            symbol = position.get('symbol', '')
+                            if symbol:
+                                # Extract token from symbol (e.g., "BTC/USDC:USDC" -> "BTC")
+                                token = symbol.split('/')[0] if '/' in symbol else symbol.split(':')[0]
+                                
+                                # Create a new trade lifecycle for this existing position
+                                side = 'buy' if size > 0 else 'sell'
+                                position_side = 'long' if size > 0 else 'short'
+                                entry_price = float(position.get('entryPrice', 0))
+                                
+                                if entry_price > 0:  # Valid position data
+                                    # Create the lifecycle entry (this generates and returns lifecycle_id)
+                                    lifecycle_id = stats.create_trade_lifecycle(
+                                        symbol=symbol,
+                                        side=side,
+                                        stop_loss_price=None,  # Will be detected from orders
+                                        trade_type='sync'
+                                    )
+                                    
+                                    if lifecycle_id:
+                                        # Update to position opened status
+                                        await stats.update_trade_position_opened(
+                                            lifecycle_id=lifecycle_id,
+                                            entry_price=entry_price,
+                                            entry_amount=abs(size),
+                                            exchange_fill_id=f"sync_{lifecycle_id[:8]}"
+                                        )
+                                        
+                                        # Update with current market data
+                                        unrealized_pnl = float(position.get('unrealizedPnl', 0))
+                                        mark_price = float(position.get('markPrice', entry_price))
+                                        
+                                        stats.update_trade_market_data(
+                                            trade_lifecycle_id=lifecycle_id,
+                                            current_position_size=abs(size),
+                                            unrealized_pnl=unrealized_pnl,
+                                            mark_price=mark_price,
+                                            position_value=abs(size) * mark_price
+                                        )
+                                        
+                                        positions_synced += 1
+                                        logger.info(f"🔄 Recreated position tracking for {token}: {position_side} {abs(size)} @ {entry_price}")
+                    
+                    except Exception as e:
+                        logger.error(f"Error recreating position for {position}: {e}")
+
+                # Link existing orders to positions
+                for order in exchange_orders:
+                    try:
+                        order_symbol = order.get('symbol', '')
+                        order_id = order.get('id', '')
+                        order_type = order.get('type', '').lower()
+                        is_reduce_only = order.get('reduceOnly', False)
+                        
+                        if order_symbol and order_id and is_reduce_only:
+                            # This might be a stop loss or take profit order
+                            # Find the corresponding position
+                            matching_trade = stats.get_trade_by_symbol_and_status(order_symbol, 'position_opened')
+                            if matching_trade:
+                                lifecycle_id = matching_trade.get('trade_lifecycle_id')
+                                if lifecycle_id:
+                                    order_price = float(order.get('price', 0))
+                                    stop_price = float(order.get('stopPrice', 0)) or order_price
+                                    
+                                    if 'stop' in order_type and stop_price > 0:
+                                        # Link as stop loss
+                                        await stats.link_stop_loss_to_trade(lifecycle_id, order_id, stop_price)
+                                        orders_synced += 1
+                                        logger.info(f"🔗 Linked stop loss order {order_id} to position {lifecycle_id[:8]}")
+                                    elif order_type in ['limit', 'take_profit'] and order_price > 0:
+                                        # Link as take profit
+                                        await stats.link_take_profit_to_trade(lifecycle_id, order_id, order_price)
+                                        orders_synced += 1
+                                        logger.info(f"🔗 Linked take profit order {order_id} to position {lifecycle_id[:8]}")
+                    
+                    except Exception as e:
+                        logger.error(f"Error linking order {order}: {e}")
+
+            # Update status
+            await context.bot.edit_message_text(
+                chat_id=chat_id,
+                message_id=processing_msg.message_id,
+                text="🔄 <b>Synchronizing with Exchange...</b>\n\n✅ Step 1/5: Local data cleared\n✅ Step 2/5: Pending stop losses cleared\n✅ Step 3/5: Exchange state fetched\n✅ Step 4/5: Position tracking recreated\n⏳ Step 5/5: Updating balance...",
+                parse_mode='HTML'
+            )
+
+            # Step 5: Update current balance
+            current_balance = 0.0
+            try:
+                balance_data = self.trading_engine.get_balance()
+                if balance_data and balance_data.get('total'):
+                    current_balance = float(balance_data['total'].get('USDC', 0))
+                    if stats:
+                        await stats.record_balance_snapshot(current_balance, notes="Post-sync balance")
+            except Exception as e:
+                logger.warning(f"Could not update balance after sync: {e}")
+
+            # Final success message
+            formatter = get_formatter()
+            success_message = f"""
+✅ <b>Synchronization Complete!</b>
+
+📊 <b>Sync Results:</b>
+• Positions Recreated: {positions_synced}
+• Orders Linked: {orders_synced}
+• Current Balance: {await formatter.format_price_with_symbol(current_balance)}
+
+🔄 <b>What was reset:</b>
+• Local trade tracking
+• Pending orders database
+• Order monitoring state
+
+🎯 <b>What was preserved:</b>
+• Historical performance data
+• Balance adjustment history
+• Completed trade statistics
+
+💡 The bot is now synchronized with your exchange state and ready for trading!
+            """
+
+            await context.bot.edit_message_text(
+                chat_id=chat_id,
+                message_id=processing_msg.message_id,
+                text=success_message.strip(),
+                parse_mode='HTML'
+            )
+
+            logger.info(f"🎉 Sync completed: {positions_synced} positions, {orders_synced} orders linked")
+
+        except Exception as e:
+            error_message = f"❌ <b>Sync Failed</b>\n\nError: {str(e)}\n\n💡 Check logs for details."
+            try:
+                await context.bot.edit_message_text(
+                    chat_id=chat_id,
+                    message_id=processing_msg.message_id,
+                    text=error_message,
+                    parse_mode='HTML'
+                )
+            except:
+                await context.bot.send_message(chat_id=chat_id, text=error_message, parse_mode='HTML')
             
             
-            message_text = f"⌨️ <b>Custom Keyboard Activated!</b>\n\n🎯 <b>Your quick buttons based on config are ready:</b>\n{button_details_message}\n💡 <b>How to use:</b>\nTap any button below to send the command instantly!\n\n🔧 These buttons will stay at the bottom of your chat."
-            await context.bot.send_message(chat_id=chat_id, text=message_text, reply_markup=reply_markup, parse_mode='HTML')
-        elif not keyboard_enabled:
-            await context.bot.send_message(chat_id=chat_id, text="❌ <b>Custom Keyboard Disabled</b>\n\n🔧 <b>To enable:</b>\n• Set TELEGRAM_CUSTOM_KEYBOARD_ENABLED=true in your .env file\n• Restart the bot\n• Run /keyboard again\n\n📋 <b>Current config:</b>\n• Enabled: {keyboard_enabled}", parse_mode='HTML')
-        else: # keyboard_enabled is true, but layout_str is empty
-            await context.bot.send_message(chat_id=chat_id, text="⚠️ Custom keyboard is enabled but the layout string (TELEGRAM_CUSTOM_KEYBOARD_LAYOUT) is empty in your configuration. Please define a layout.", parse_mode='HTML') 
+            logger.error(f"Error in sync command: {e}", exc_info=True) 

+ 1 - 1
trading_bot.py

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