瀏覽代碼

Enhance Telegram bot trade tracking and command handling - Introduced a mechanism to track bot-generated trade IDs to prevent double processing. Updated command handling to improve user experience with a more comprehensive keyboard mapping and added a debug command for internal state inspection. Enhanced position direction validation using CCXT's side field for improved accuracy in trading operations.

Carles Sentis 5 天之前
父節點
當前提交
ebd1bf097d
共有 1 個文件被更改,包括 257 次插入50 次删除
  1. 257 50
      src/telegram_bot.py

+ 257 - 50
src/telegram_bot.py

@@ -60,6 +60,9 @@ class TelegramTradingBot:
         # Pending stop loss storage
         self.pending_stop_losses = {}  # Format: {order_id: {'token': str, 'stop_price': float, 'side': str, 'amount': float, 'order_type': str}}
         
+        # Track bot-generated trades to avoid double processing
+        self.bot_trade_ids = set()  # Track trade IDs generated by bot commands
+        
         # Load bot state first, then initialize stats
         self._load_bot_state()
         self._initialize_stats()
@@ -76,6 +79,9 @@ class TelegramTradingBot:
                 self.last_known_orders = set(state_data.get('last_known_orders', []))
                 self.last_known_positions = state_data.get('last_known_positions', {})
                 
+                # Restore bot trade IDs (prevent double processing)
+                self.bot_trade_ids = set(state_data.get('bot_trade_ids', []))
+                
                 # Restore timestamps (convert from ISO string if present)
                 last_trade_time = state_data.get('last_processed_trade_time')
                 if last_trade_time:
@@ -107,6 +113,7 @@ class TelegramTradingBot:
             self.pending_stop_losses = {}
             self.last_known_orders = set()
             self.last_known_positions = {}
+            self.bot_trade_ids = set()
             self.last_processed_trade_time = None
             self.last_deposit_withdrawal_check = None
     
@@ -134,6 +141,7 @@ class TelegramTradingBot:
                 'pending_stop_losses': self.pending_stop_losses,
                 'last_known_orders': list(self.last_known_orders),  # Convert set to list for JSON
                 'last_known_positions': self.last_known_positions,
+                'bot_trade_ids': list(self.bot_trade_ids),  # Track bot-generated trades
                 'last_processed_trade_time': safe_datetime_to_iso(self.last_processed_trade_time),
                 'last_deposit_withdrawal_check': safe_datetime_to_iso(self.last_deposit_withdrawal_check),
                 'last_updated': datetime.now().isoformat(),
@@ -651,7 +659,14 @@ Tap any button below for instant access to bot functions:
                     
                     # Extract token name for cleaner display
                     token = symbol.split('/')[0] if '/' in symbol else symbol
-                    position_type = "LONG" if contracts > 0 else "SHORT"
+                    
+                    # Use CCXT's side field if available, otherwise fall back to contracts sign
+                    side_field = position.get('side', '').lower()
+                    if side_field in ['long', 'short']:
+                        position_type = side_field.upper()
+                    else:
+                        # Fallback for exchanges that don't provide side field
+                        position_type = "LONG" if contracts > 0 else "SHORT"
                     
                     positions_text += f"📊 <b>{token}</b> ({position_type})\n"
                     positions_text += f"   📏 Size: {abs(contracts):.6f} {token}\n"
@@ -1058,6 +1073,12 @@ Tap any button below for instant access to bot functions:
                     actual_price = price
                 action_type = self.stats.record_trade_with_enhanced_tracking(symbol, 'buy', token_amount, actual_price, order_id, "bot")
                 
+                # Track this as a bot-generated trade to prevent double processing
+                if order_id and order_id != 'N/A':
+                    self.bot_trade_ids.add(order_id)
+                    self._save_bot_state()  # Save state to persist bot trade tracking
+                    logger.info(f"🤖 Tracked bot trade ID: {order_id}")
+                
                 # Save pending stop loss if provided
                 if stop_loss_price is not None:
                     self.pending_stop_losses[order_id] = {
@@ -1145,6 +1166,12 @@ Tap any button below for instant access to bot functions:
                     actual_price = price
                 action_type = self.stats.record_trade_with_enhanced_tracking(symbol, 'sell', token_amount, actual_price, order_id, "bot")
                 
+                # Track this as a bot-generated trade to prevent double processing
+                if order_id and order_id != 'N/A':
+                    self.bot_trade_ids.add(order_id)
+                    self._save_bot_state()  # Save state to persist bot trade tracking
+                    logger.info(f"🤖 Tracked bot trade ID: {order_id}")
+                
                 # Save pending stop loss if provided
                 if stop_loss_price is not None:
                     self.pending_stop_losses[order_id] = {
@@ -1213,6 +1240,12 @@ Tap any button below for instant access to bot functions:
                     actual_price = price
                 action_type = self.stats.record_trade_with_enhanced_tracking(symbol, exit_side, contracts, actual_price, order_id, "bot")
                 
+                # Track this as a bot-generated trade to prevent double processing
+                if order_id and order_id != 'N/A':
+                    self.bot_trade_ids.add(order_id)
+                    self._save_bot_state()  # Save state to persist bot trade tracking
+                    logger.info(f"🤖 Tracked bot trade ID: {order_id}")
+                
                 position_type = "LONG" if exit_side == "sell" else "SHORT"
                 
                 success_message = f"""
@@ -1426,29 +1459,49 @@ Tap any button below for instant access to bot functions:
             logger.error(f"Error setting take profit: {e}")
     
     async def handle_keyboard_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
-        """Handle messages from custom keyboard buttons (without /)."""
+        """Handle messages from custom keyboard buttons."""
         if not self.is_authorized(update.effective_chat.id):
+            await update.message.reply_text("❌ Unauthorized access.")
             return
         
         message_text = update.message.text.lower()
         
-        # Map clean button text to command handlers
-        command_handlers = {
-            'daily': self.daily_command,
-            'performance': self.performance_command,
-            'balance': self.balance_command,
-            'stats': self.stats_command,
-            'positions': self.positions_command,
-            'orders': self.orders_command,
-            'price': self.price_command,
-            'market': self.market_command,
-            'help': self.help_command,
-            'commands': self.commands_command
+        # Map keyboard button text to commands
+        command_map = {
+            'balance': '/balance',
+            'positions': '/positions',
+            'orders': '/orders',
+            'stats': '/stats',
+            'trades': '/trades',
+            'market': '/market',
+            'price': '/price',
+            'help': '/help',
+            'commands': '/commands',
+            'monitoring': '/monitoring',
+            'logs': '/logs',
+            'performance': '/performance',
+            'daily': '/daily',
+            'weekly': '/weekly',
+            'monthly': '/monthly',
+            'risk': '/risk',
+            'alarm': '/alarm',
+            'keyboard': '/keyboard'
         }
         
-        # Execute the corresponding command handler
-        if message_text in command_handlers:
-            await command_handlers[message_text](update, context)
+        # Check if the message matches any keyboard command
+        if message_text in command_map:
+            # Create a fake update object with the corresponding command
+            update.message.text = command_map[message_text]
+            # Get the handler for this command and call it
+            handlers = self.application.handlers[0]  # Get default group handlers
+            for handler in handlers:
+                if hasattr(handler, 'callback') and hasattr(handler, 'filters'):
+                    if await handler.check_update(update):
+                        await handler.callback(update, context)
+                        return
+        
+        # If no keyboard command matched, show a help message
+        await update.message.reply_text("❓ Unknown command. Use /help to see available commands.")
 
     async def unknown_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
         """Handle unknown commands."""
@@ -1494,6 +1547,7 @@ Tap any button below for instant access to bot functions:
         self.application.add_handler(CommandHandler("version", self.version_command))
         self.application.add_handler(CommandHandler("balance_adjustments", self.balance_adjustments_command))
         self.application.add_handler(CommandHandler("keyboard", self.keyboard_command))
+        self.application.add_handler(CommandHandler("debug", self.debug_command))
         
         # Callback query handler for inline keyboards
         self.application.add_handler(CallbackQueryHandler(self.button_callback))
@@ -2079,8 +2133,10 @@ This action cannot be undone.
             entry_price = float(current_position.get('entryPx', 0))
             unrealized_pnl = float(current_position.get('unrealizedPnl', 0))
             
-            # Determine position direction and validate stop loss price
-            if contracts > 0:
+            # Use CCXT's side field to determine position direction
+            side_field = current_position.get('side', '').lower()
+            
+            if side_field == 'long':
                 # Long position - stop loss should be below entry price
                 position_type = "LONG"
                 exit_side = "sell"
@@ -2096,22 +2152,32 @@ This action cannot be undone.
                         f"💡 Try a lower price like: /sl {token} {entry_price * 0.95:.0f}"
                     )
                     return
-            else:
+            elif side_field == 'short':
                 # Short position - stop loss should be above entry price
                 position_type = "SHORT"
                 exit_side = "buy"
                 exit_emoji = "🟢"
-                contracts_abs = abs(contracts)
+                contracts_abs = contracts  # Already positive from CCXT
+                
+                # Debug logging for short position validation
+                logger.info(f"🛑 Stop loss validation for SHORT: entry_price=${entry_price}, stop_price=${stop_price}, contracts={contracts}")
                 
                 if stop_price <= entry_price:
                     await update.message.reply_text(
                         f"⚠️ Stop loss price should be ABOVE entry price for short positions\n\n"
                         f"📊 Your {token} SHORT position:\n"
                         f"• Entry Price: ${entry_price:,.2f}\n"
-                        f"• Stop Price: ${stop_price:,.2f} ❌\n\n"
-                        f"💡 Try a higher price like: /sl {token} {entry_price * 1.05:.0f}"
+                        f"• Stop Price: ${stop_price:,.2f} ❌\n"
+                        f"• Contracts: {contracts} (side: {side_field})\n\n"
+                        f"💡 Try a higher price like: /sl {token} {entry_price * 1.05:.0f}\n\n"
+                        f"🔧 Debug: stop_price ({stop_price}) <= entry_price ({entry_price}) = {stop_price <= entry_price}"
                     )
                     return
+                else:
+                    logger.info(f"✅ Stop loss validation PASSED for SHORT: ${stop_price} > ${entry_price}")
+            else:
+                await update.message.reply_text(f"❌ Could not determine position direction for {token}. Side field: '{side_field}'")
+                return
             
             # Get current market price for reference
             market_data = self.client.get_market_data(symbol)
@@ -2120,9 +2186,9 @@ This action cannot be undone.
                 current_price = float(market_data['ticker'].get('last', 0))
             
             # Calculate estimated P&L at stop loss
-            if contracts > 0:  # Long position
+            if side_field == 'long':
                 pnl_at_stop = (stop_price - entry_price) * contracts_abs
-            else:  # Short position
+            else:  # short
                 pnl_at_stop = (entry_price - stop_price) * contracts_abs
             
             # Create confirmation message
@@ -2206,8 +2272,10 @@ This will place a limit {exit_side} order at ${stop_price:,.2f} to protect your
             entry_price = float(current_position.get('entryPx', 0))
             unrealized_pnl = float(current_position.get('unrealizedPnl', 0))
             
-            # Determine position direction and validate take profit price
-            if contracts > 0:
+            # Use CCXT's side field to determine position direction
+            side_field = current_position.get('side', '').lower()
+            
+            if side_field == 'long':
                 # Long position - take profit should be above entry price
                 position_type = "LONG"
                 exit_side = "sell"
@@ -2223,12 +2291,12 @@ This will place a limit {exit_side} order at ${stop_price:,.2f} to protect your
                         f"💡 Try a higher price like: /tp {token} {entry_price * 1.05:.0f}"
                     )
                     return
-            else:
+            elif side_field == 'short':
                 # Short position - take profit should be below entry price
                 position_type = "SHORT"
                 exit_side = "buy"
                 exit_emoji = "🟢"
-                contracts_abs = abs(contracts)
+                contracts_abs = contracts  # Already positive from CCXT
                 
                 if profit_price >= entry_price:
                     await update.message.reply_text(
@@ -2239,6 +2307,9 @@ This will place a limit {exit_side} order at ${stop_price:,.2f} to protect your
                         f"💡 Try a lower price like: /tp {token} {entry_price * 0.95:.0f}"
                     )
                     return
+            else:
+                await update.message.reply_text(f"❌ Could not determine position direction for {token}. Side field: '{side_field}'")
+                return
             
             # Get current market price for reference
             market_data = self.client.get_market_data(symbol)
@@ -2247,9 +2318,9 @@ This will place a limit {exit_side} order at ${stop_price:,.2f} to protect your
                 current_price = float(market_data['ticker'].get('last', 0))
             
             # Calculate estimated P&L at take profit
-            if contracts > 0:  # Long position
+            if side_field == 'long':
                 pnl_at_tp = (profit_price - entry_price) * contracts_abs
-            else:  # Short position
+            else:  # short
                 pnl_at_tp = (entry_price - profit_price) * contracts_abs
             
             # Create confirmation message
@@ -2370,12 +2441,26 @@ This will place a limit {exit_side} order at ${profit_price:,.2f} to capture pro
             # Find filled orders (orders that were in last_known_orders but not in current_orders)
             filled_order_ids = self.last_known_orders - current_order_ids
             
+            # Add some debugging for filled order detection
             if filled_order_ids:
-                logger.info(f"🎯 Detected {len(filled_order_ids)} filled orders")
+                logger.info(f"🎯 Detected {len(filled_order_ids)} filled orders: {list(filled_order_ids)}")
+                
+                # Log pending stop losses before processing
+                if self.pending_stop_losses:
+                    logger.info(f"📋 Current pending stop losses: {list(self.pending_stop_losses.keys())}")
+                    for order_id in filled_order_ids:
+                        if order_id in self.pending_stop_losses:
+                            stop_loss_info = self.pending_stop_losses[order_id]
+                            logger.info(f"🛑 Will process stop loss for filled order {order_id}: {stop_loss_info['token']} @ ${stop_loss_info['stop_price']}")
+                
                 await self._process_filled_orders(filled_order_ids, current_positions)
                 
                 # Process pending stop losses for filled orders
                 await self._process_pending_stop_losses(filled_order_ids)
+            else:
+                # Log if we have pending stop losses but no filled orders
+                if self.pending_stop_losses:
+                    logger.debug(f"📋 No filled orders detected, but {len(self.pending_stop_losses)} pending stop losses remain")
             
             # Update tracking data
             self.last_known_orders = current_order_ids
@@ -2677,6 +2762,15 @@ This will place a limit {exit_side} order at ${profit_price:,.2f} to capture pro
             
             # Process new trades
             for trade in new_trades:
+                # Log trade processing for debugging
+                trade_id = trade.get('id', 'external')
+                symbol = trade.get('symbol', 'Unknown')
+                side = trade.get('side', 'Unknown')
+                amount = trade.get('amount', 0)
+                price = trade.get('price', 0)
+                
+                logger.info(f"🔍 Processing trade: {trade_id} - {side} {amount} {symbol} @ ${price}")
+                
                 await self._process_external_trade(trade)
             
             # Update last processed time (keep as datetime object)
@@ -2850,6 +2944,11 @@ This will place a limit {exit_side} order at ${profit_price:,.2f} to capture pro
             if not all([symbol, side, amount, price]):
                 return
             
+            # Skip bot-generated trades to prevent double processing
+            if trade_id in self.bot_trade_ids:
+                logger.debug(f"🤖 Skipping bot-generated trade: {trade_id}")
+                return
+            
             # Record trade in stats and get action type using enhanced tracking
             action_type = self.stats.record_trade_with_enhanced_tracking(symbol, side, amount, price, trade_id, "external")
             
@@ -3979,7 +4078,13 @@ Will trigger when {token} price moves {alarm['direction']} ${target_price:,.2f}
                 })
                 
                 logger.info(f"📈 Position updated: {symbol} LONG {position['contracts']:.6f} @ avg ${position['avg_entry_price']:.2f}")
-                return 'long_opened' if old_contracts == 0 else 'long_increased'
+                
+                # Check if this is truly a position open vs increase
+                # For very small previous positions (< 0.001), consider it a new position open
+                if old_contracts < 0.001:
+                    return 'long_opened'
+                else:
+                    return 'long_increased'
             else:
                 # Reducing short position
                 reduction = min(amount, abs(position['contracts']))
@@ -4004,6 +4109,7 @@ Will trigger when {token} price moves {alarm['direction']} ${target_price:,.2f}
             # Adding to short position or reducing long position
             if position['contracts'] <= 0:
                 # Opening/adding to short position
+                old_contracts = abs(position['contracts'])
                 position['contracts'] -= amount
                 position['entry_count'] += 1
                 position['entry_history'].append({
@@ -4014,7 +4120,13 @@ Will trigger when {token} price moves {alarm['direction']} ${target_price:,.2f}
                 })
                 
                 logger.info(f"📉 Position updated: {symbol} SHORT {abs(position['contracts']):.6f} @ ${price:.2f}")
-                return 'short_opened' if position['contracts'] == -amount else 'short_increased'
+                
+                # Check if this is truly a position open vs increase
+                # For very small previous positions (< 0.001), consider it a new position open
+                if old_contracts < 0.001:
+                    return 'short_opened'
+                else:
+                    return 'short_increased'
             else:
                 # Reducing long position
                 reduction = min(amount, position['contracts'])
@@ -4347,29 +4459,124 @@ Will trigger when {token} price moves {alarm['direction']} ${target_price:,.2f}
             )
 
     async def handle_keyboard_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
-        """Handle messages from custom keyboard buttons (without /)."""
+        """Handle messages from custom keyboard buttons."""
         if not self.is_authorized(update.effective_chat.id):
+            await update.message.reply_text("❌ Unauthorized access.")
             return
         
         message_text = update.message.text.lower()
         
-        # Map clean button text to command handlers
-        command_handlers = {
-            'daily': self.daily_command,
-            'performance': self.performance_command,
-            'balance': self.balance_command,
-            'stats': self.stats_command,
-            'positions': self.positions_command,
-            'orders': self.orders_command,
-            'price': self.price_command,
-            'market': self.market_command,
-            'help': self.help_command,
-            'commands': self.commands_command
+        # Map keyboard button text to commands
+        command_map = {
+            'balance': '/balance',
+            'positions': '/positions',
+            'orders': '/orders',
+            'stats': '/stats',
+            'trades': '/trades',
+            'market': '/market',
+            'price': '/price',
+            'help': '/help',
+            'commands': '/commands',
+            'monitoring': '/monitoring',
+            'logs': '/logs',
+            'performance': '/performance',
+            'daily': '/daily',
+            'weekly': '/weekly',
+            'monthly': '/monthly',
+            'risk': '/risk',
+            'alarm': '/alarm',
+            'keyboard': '/keyboard'
         }
         
-        # Execute the corresponding command handler
-        if message_text in command_handlers:
-            await command_handlers[message_text](update, context)
+        # Check if the message matches any keyboard command
+        if message_text in command_map:
+            # Create a fake update object with the corresponding command
+            update.message.text = command_map[message_text]
+            # Get the handler for this command and call it
+            handlers = self.application.handlers[0]  # Get default group handlers
+            for handler in handlers:
+                if hasattr(handler, 'callback') and hasattr(handler, 'filters'):
+                    if await handler.check_update(update):
+                        await handler.callback(update, context)
+                        return
+        
+        # If no keyboard command matched, show a help message
+        await update.message.reply_text("❓ Unknown command. Use /help to see available commands.")
+
+    async def debug_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+        """Debug command to show internal bot state."""
+        if not self.is_authorized(update.effective_chat.id):
+            await update.message.reply_text("❌ Unauthorized access.")
+            return
+        
+        try:
+            # Get bot state information
+            debug_info = f"🔧 <b>Bot Debug Information</b>\\n\\n"
+            
+            # Bot version and status
+            debug_info += f"🤖 <b>Version:</b> {self.version}\\n"
+            debug_info += f"⚡ <b>Status:</b> Running\\n"
+            
+            # Position tracking information
+            debug_info += f"\\n📊 <b>Position State Tracking:</b>\\n"
+            if hasattr(self, '_internal_positions') and self._internal_positions:
+                for symbol, pos_state in self._internal_positions.items():
+                    token = symbol.split('/')[0] if '/' in symbol else symbol
+                    contracts = pos_state.get('contracts', 0)
+                    avg_price = pos_state.get('average_price', 0)
+                    debug_info += f"  • {token}: {contracts:.6f} @ ${avg_price:.2f}\\n"
+            else:
+                debug_info += f"  No internal position tracking data\\n"
+            
+            # Get raw Hyperliquid position data for comparison
+            debug_info += f"\\n🔍 <b>Raw Hyperliquid Position Data:</b>\\n"
+            raw_positions = self.client.get_positions()
+            if raw_positions:
+                for pos in raw_positions:
+                    if float(pos.get('contracts', 0)) != 0:
+                        symbol = pos.get('symbol', 'Unknown')
+                        contracts = pos.get('contracts', 0)
+                        side = pos.get('side', 'Unknown')
+                        entry_price = pos.get('entryPrice', 0)
+                        unrealized_pnl = pos.get('unrealizedPnl', 0)
+                        notional = pos.get('notional', 0)
+                        
+                        # Get raw Hyperliquid data from info field
+                        raw_info = pos.get('info', {})
+                        raw_position = raw_info.get('position', {}) if raw_info else {}
+                        raw_szi = raw_position.get('szi', 'N/A')
+                        
+                        token = symbol.split('/')[0] if '/' in symbol else symbol
+                        debug_info += f"  • <b>{token}:</b>\\n"
+                        debug_info += f"    - CCXT contracts: {contracts} (always positive)\\n"
+                        debug_info += f"    - CCXT side: {side}\\n"
+                        debug_info += f"    - Raw Hyperliquid szi: {raw_szi} (negative = short)\\n"
+                        debug_info += f"    - Entry price: ${entry_price}\\n"
+                        debug_info += f"    - Unrealized P&L: ${unrealized_pnl}\\n"
+                        debug_info += f"    - Notional: ${notional}\\n"
+                        
+                        # Show what the bot would interpret this as
+                        side_field = pos.get('side', '').lower()
+                        if side_field in ['long', 'short']:
+                            interpreted_direction = side_field.upper()
+                        else:
+                            interpreted_direction = "LONG" if float(contracts) > 0 else "SHORT"
+                        debug_info += f"    - Bot interprets as: {interpreted_direction} (using CCXT side field)\\n\\n"
+            else:
+                debug_info += f"  No positions found or API error\\n"
+            
+            # Show monitoring status
+            debug_info += f"\\n🔍 <b>Monitoring:</b> {'Active' if self.monitoring_active else 'Inactive'}\\n"
+            debug_info += f"📋 <b>Tracked Orders:</b> {len(self.last_known_orders)}\\n"
+            debug_info += f"🤖 <b>Bot Trade IDs:</b> {len(self.bot_trade_ids)}\\n"
+            if self.bot_trade_ids:
+                debug_info += "  Recent bot trades: " + ", ".join(list(self.bot_trade_ids)[-5:]) + "\\n"
+            
+            await update.message.reply_text(debug_info, parse_mode='HTML')
+            
+        except Exception as e:
+            logger.error(f"❌ Error in debug command: {e}")
+            await update.message.reply_text(f"❌ Debug error: {e}")
 
 
 async def main_async():