Explorar o código

Refactor management commands and enhance order synchronization features

- Updated ManagementCommands to inherit from InfoCommandsBase, improving code structure.
- Refined the /sync command to streamline synchronization with exchange orders, including enhanced error handling and user feedback.
- Introduced a new method for formatting synchronization results, providing clearer messages to users.
- Enhanced OrdersCommands to adopt an exchange-first approach for retrieving orders, improving accuracy and efficiency.
- Implemented cleanup of stale pending stop loss orders based on current positions and limit orders, ensuring better resource management.
Carles Sentis hai 1 semana
pai
achega
f7c6748ebc

+ 294 - 69
src/commands/info/orders.py

@@ -10,7 +10,7 @@ class OrdersCommands(InfoCommandsBase):
     """Handles all order-related commands."""
     """Handles all order-related commands."""
 
 
     async def orders_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
     async def orders_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
-        """Handle the /orders command."""
+        """Handle the /orders command with exchange-first approach."""
         try:
         try:
             if not self._is_authorized(update):
             if not self._is_authorized(update):
                 await self._reply(update, "❌ Unauthorized access.")
                 await self._reply(update, "❌ Unauthorized access.")
@@ -21,82 +21,307 @@ class OrdersCommands(InfoCommandsBase):
                 await self._reply(update, "❌ Trading stats not available.")
                 await self._reply(update, "❌ Trading stats not available.")
                 return
                 return
 
 
-            # Get open orders from database (much faster than exchange query)
-            db_orders = stats.get_orders_by_status('open', limit=50)
-            db_orders.extend(stats.get_orders_by_status('submitted', limit=50))
-            db_orders.extend(stats.get_orders_by_status('pending_trigger', limit=50))
-
-            # Get pending stop loss orders from the database
-            pending_sl_lifecycles = stats.get_pending_stop_losses_from_lifecycles()
+            # Get exchange orders (primary source of truth)
+            exchange_orders = self.trading_engine.get_orders() or []
+            
+            # Get pending stop loss orders (bot-internal only)
+            pending_sl_orders = stats.get_pending_stop_losses_from_lifecycles()
             
             
-            # Combine both lists
-            all_orders = db_orders + pending_sl_lifecycles
+            # Clean up stale pending SL orders
+            await self._cleanup_stale_pending_sl_orders(stats, exchange_orders)
+            
+            if not exchange_orders and not pending_sl_orders:
+                await self._reply(update, "📭 No open orders found")
+                return
+
+            # Format exchange-first orders message
+            orders_text = await self._format_exchange_first_orders_message(exchange_orders, pending_sl_orders)
+            await self._reply(update, orders_text.strip())
+
+        except Exception as e:
+            logger.error(f"Error in orders command: {e}", exc_info=True)
+            await self._reply(update, "❌ Error retrieving order information.")
 
 
-            if not all_orders:
-                await self._reply(update, "📭 No open or pending orders")
+    async def _cleanup_stale_pending_sl_orders(self, stats, exchange_orders: List[Dict]):
+        """Clean up pending SL orders when no more limit orders or positions exist for that token."""
+        try:
+            pending_sl_orders = stats.get_pending_stop_losses_from_lifecycles()
+            if not pending_sl_orders:
                 return
                 return
+            
+            # Get current positions
+            positions = self.trading_engine.get_positions() or []
+            position_tokens = {pos.get('symbol', '').split('/')[0] for pos in positions if pos.get('symbol')}
+            
+            # Get tokens with open limit orders on exchange
+            limit_order_tokens = set()
+            for order in exchange_orders:
+                if order.get('type') == 'limit' and not order.get('reduceOnly', False):
+                    symbol = order.get('symbol', '')
+                    token = symbol.split('/')[0] if symbol else ''
+                    if token:
+                        limit_order_tokens.add(token)
+            
+            # Find stale pending SL orders
+            stale_lifecycles = []
+            for pending_sl in pending_sl_orders:
+                symbol = pending_sl.get('symbol', '')
+                token = symbol.split('/')[0] if symbol else ''
+                
+                # If no position and no limit orders for this token, mark as stale
+                if token and token not in position_tokens and token not in limit_order_tokens:
+                    lifecycle_id = pending_sl.get('trade_lifecycle_id')
+                    if lifecycle_id:
+                        stale_lifecycles.append(lifecycle_id)
+                        logger.info(f"🧹 Marking pending SL for {token} as stale (no position/limit orders)")
+            
+            # Clean up stale pending SL orders
+            for lifecycle_id in stale_lifecycles:
+                stats.cancel_pending_stop_loss(lifecycle_id)
+                logger.info(f"✅ Cleaned up stale pending SL: {lifecycle_id}")
+                
+        except Exception as e:
+            logger.error(f"Error cleaning up stale pending SL orders: {e}")
 
 
-            # Format orders text
-            orders_text = "📋 <b>Open & Pending Orders</b>\n\n"
+    def _categorize_exchange_orders(self, exchange_orders: List[Dict]) -> Dict[str, List[Dict]]:
+        """Categorize exchange orders by type for better display."""
+        categories = {
+            'stop_loss': [],        # Stop loss orders (reduce-only with trigger)
+            'take_profit': [],      # Take profit orders  
+            'limit_orders': [],     # Regular limit orders
+            'market_orders': [],    # Market orders
+            'other_exit': [],       # Other reduce-only orders
+            'zero_size': []         # Zero-size position placeholders
+        }
+        
+        for order in exchange_orders:
+            amount = float(order.get('amount', 0))
+            order_type = order.get('type', '').lower()
+            is_reduce_only = order.get('reduceOnly', False) or order.get('info', {}).get('reduceOnly', False)
+            order_info = order.get('info', {})
+            has_trigger = 'triggerPrice' in order_info or order_info.get('isTrigger', False)
+            tpsl_type = order_info.get('tpsl')  # 'tp' or 'sl'
+            is_position_tpsl = order_info.get('isPositionTpsl', False)
+            
+            if amount == 0:
+                categories['zero_size'].append(order)
+            elif is_reduce_only and has_trigger and (tpsl_type == 'sl' or is_position_tpsl):
+                categories['stop_loss'].append(order)
+            elif is_reduce_only and has_trigger and tpsl_type == 'tp':
+                categories['take_profit'].append(order)
+            elif is_reduce_only:
+                categories['other_exit'].append(order)
+            elif order_type == 'limit':
+                categories['limit_orders'].append(order)
+            elif order_type == 'market':
+                categories['market_orders'].append(order)
+            else:
+                categories['other_exit'].append(order)  # Fallback
+        
+        return categories
 
 
-            for order in all_orders:
-                try:
-                    is_pending_sl = 'trade_lifecycle_id' in order and order.get('stop_loss_price') is not None
-                    
-                    symbol = order.get('symbol', 'unknown')
-                    base_asset = symbol.split('/')[0] if '/' in symbol else symbol.split(':')[0]
-                    
-                    if is_pending_sl:
-                        # This is a pending SL from a trade lifecycle
-                        entry_side = order.get('side', 'unknown').upper()
-                        order_type = "STOP (PENDING)"
-                        side = "SELL" if entry_side == "BUY" else "BUY"
-                        price = float(order.get('stop_loss_price') or 0)
-                        amount = float(order.get('amount') or 0) # Amount from the entry order
-                        status = f"Awaiting {order.get('status', '').upper()} Entry" # e.g. Awaiting PENDING Entry
-                        order_id = order.get('trade_lifecycle_id', 'unknown')
-                    else:
-                        # This is a regular database order
-                        order_type = order.get('type', 'unknown').upper()
-                        side = order.get('side', 'unknown').upper()
-                        price = float(order.get('price') or 0)
-                        amount_requested = float(order.get('amount_requested') or 0)
-                        amount_filled = float(order.get('amount_filled') or 0)
-                        amount = amount_requested - amount_filled # Show remaining amount
-                        status = order.get('status', 'unknown').upper()
-                        order_id = order.get('exchange_order_id') or order.get('bot_order_ref_id', 'unknown')
+    async def _format_exchange_first_orders_message(self, exchange_orders: List[Dict], pending_sl_orders: List[Dict]) -> str:
+        """Format orders message with exchange orders as primary."""
+        
+        formatter = self._get_formatter()
+        message_parts = []
+        
+        # Header
+        total_exchange = len(exchange_orders)
+        total_pending = len(pending_sl_orders)
+        message_parts.append(f"📋 <b>All Orders ({total_exchange + total_pending} total)</b>\n")
+        
+        if exchange_orders:
+            # Categorize exchange orders
+            categories = self._categorize_exchange_orders(exchange_orders)
+            
+            # Regular Limit Orders
+            if categories['limit_orders']:
+                message_parts.append("📈 <b>Limit Orders:</b>")
+                for order in categories['limit_orders']:
+                    order_line = await self._format_exchange_order(order, formatter, show_type=False)
+                    message_parts.append(order_line)
+                message_parts.append("")
+            
+            # Market Orders
+            if categories['market_orders']:
+                message_parts.append("⚡ <b>Market Orders:</b>")
+                for order in categories['market_orders']:
+                    order_line = await self._format_exchange_order(order, formatter, show_type=False)
+                    message_parts.append(order_line)
+                message_parts.append("")
+            
+            # Stop Loss Orders
+            if categories['stop_loss']:
+                message_parts.append("🛑 <b>Stop Loss Orders:</b>")
+                for order in categories['stop_loss']:
+                    order_line = await self._format_stop_loss_order(order, formatter)
+                    message_parts.append(order_line)
+                message_parts.append("")
+            
+            # Take Profit Orders
+            if categories['take_profit']:
+                message_parts.append("🎯 <b>Take Profit Orders:</b>")
+                for order in categories['take_profit']:
+                    order_line = await self._format_take_profit_order(order, formatter)
+                    message_parts.append(order_line)
+                message_parts.append("")
+            
+            # Other Exit Orders
+            if categories['other_exit']:
+                message_parts.append("🔄 <b>Other Exit Orders:</b>")
+                for order in categories['other_exit']:
+                    order_line = await self._format_exchange_order(order, formatter, show_type=True)
+                    message_parts.append(order_line)
+                message_parts.append("")
+            
+            # Zero-size orders (position placeholders)
+            if categories['zero_size']:
+                message_parts.append("⚪ <b>Position Placeholders (Zero Size):</b>")
+                for order in categories['zero_size']:
+                    order_line = await self._format_zero_size_order(order, formatter)
+                    message_parts.append(order_line)
+                message_parts.append("")
+        
+        # Pending Stop Loss Orders (bot-internal only)
+        if pending_sl_orders:
+            message_parts.append("⏳ <b>Pending Stop Loss Orders (Bot Internal):</b>")
+            message_parts.append("   <i>Will be placed when entry orders fill</i>")
+            for order in pending_sl_orders:
+                order_line = await self._format_pending_sl_order(order, formatter)
+                if order_line:
+                    message_parts.append(order_line)
+            message_parts.append("")
+        
+        # Footer
+        from src.config.config import Config
+        message_parts.append("💡 <b>About Orders:</b>")
+        message_parts.append("• All orders above are live on the exchange")
+        message_parts.append("• Pending SL orders are bot-internal (not yet on exchange)")
+        message_parts.append("• Use /coo [token] to cancel all orders for a token")
+        message_parts.append(f"• Data refreshed every {Config.BOT_HEARTBEAT_SECONDS}s")
+        
+        return "\n".join(message_parts)
 
 
-                    # Skip fully filled orders
-                    if amount <= 0 and not is_pending_sl:
-                        continue
-                    
-                    # Format order details
-                    formatter = self._get_formatter()
-                    price_str = await formatter.format_price_with_symbol(price, base_asset)
-                    amount_str = await formatter.format_amount(amount, base_asset) if amount > 0 else "N/A"
+    async def _format_exchange_order(self, order: Dict, formatter, show_type: bool = True) -> str:
+        """Format a regular exchange order."""
+        try:
+            symbol = order.get('symbol', 'unknown')
+            base_asset = symbol.split('/')[0] if '/' in symbol else symbol
+            side = order.get('side', '').upper()
+            amount = float(order.get('amount', 0))
+            price = float(order.get('price', 0)) if order.get('price') else 0
+            order_type = order.get('type', '').upper()
+            
+            price_str = await formatter.format_price_with_symbol(price, base_asset) if price else "MARKET"
+            amount_str = await formatter.format_amount(amount, base_asset)
+            
+            side_emoji = "🟢" if side == "BUY" else "🔴"
+            
+            if show_type:
+                return f"   {side_emoji} {base_asset} {side} {order_type} - {amount_str} @ {price_str}"
+            else:
+                return f"   {side_emoji} {base_asset} {side} - {amount_str} @ {price_str}"
+            
+        except Exception as e:
+            logger.error(f"Error formatting exchange order: {e}")
+            return f"   ❌ Error formatting order: {order.get('symbol', 'unknown')}"
 
 
-                    # Order header
-                    side_emoji = "🟢" if side == "BUY" else "🔴"
-                    orders_text += f"{side_emoji} <b>{base_asset} {side} {order_type}</b>\n"
-                    orders_text += f"   Status: {status.replace('_', ' ')}\n"
-                    orders_text += f"   📏 Amount: {amount_str}\n"
-                    orders_text += f"   💰 {'Trigger Price' if 'STOP' in order_type else 'Price'}: {price_str}\n"
-                    
-                    # Add order ID
-                    id_label = "Lifecycle ID" if is_pending_sl else "Order ID"
-                    orders_text += f"   🆔 {id_label}: {str(order_id)[:12]}\n\n"
+    async def _format_stop_loss_order(self, order: Dict, formatter) -> str:
+        """Format a stop loss order with trigger information."""
+        try:
+            symbol = order.get('symbol', 'unknown')
+            base_asset = symbol.split('/')[0] if '/' in symbol else symbol
+            side = order.get('side', '').upper()
+            
+            # Get trigger price and amount info
+            order_info = order.get('info', {})
+            trigger_price = order_info.get('triggerPx', order_info.get('triggerPrice'))
+            trigger_condition = order_info.get('triggerCondition', '')
+            amount = float(order.get('amount', 0))
+            
+            trigger_price_str = await formatter.format_price_with_symbol(float(trigger_price), base_asset) if trigger_price else "N/A"
+            
+            # For zero-size position stop losses
+            if amount == 0:
+                amount_str = "Full Position"
+            else:
+                amount_str = await formatter.format_amount(amount, base_asset)
+            
+            side_emoji = "🟢" if side == "BUY" else "🔴"
+            
+            # Add trigger condition if available
+            condition_text = f" ({trigger_condition})" if trigger_condition and trigger_condition != "N/A" else ""
+            
+            return f"   {side_emoji} {base_asset} {side} {amount_str} - Trigger @ {trigger_price_str}{condition_text}"
+            
+        except Exception as e:
+            logger.error(f"Error formatting stop loss order: {e}")
+            return f"   ❌ Error formatting SL order: {order.get('symbol', 'unknown')}"
 
 
-                except Exception as e:
-                    logger.error(f"Error processing order {order.get('symbol', 'unknown')}: {e}", exc_info=True)
-                    continue
-            
-            # Add footer
-            orders_text += "💡 Pending SL orders are activated when the entry order fills.\n"
-            from src.config.config import Config
-            orders_text += f"🔄 Data updated every {Config.BOT_HEARTBEAT_SECONDS}s via monitoring system"
+    async def _format_take_profit_order(self, order: Dict, formatter) -> str:
+        """Format a take profit order."""
+        try:
+            symbol = order.get('symbol', 'unknown')
+            base_asset = symbol.split('/')[0] if '/' in symbol else symbol
+            side = order.get('side', '').upper()
+            amount = float(order.get('amount', 0))
+            
+            order_info = order.get('info', {})
+            trigger_price = order_info.get('triggerPx', order_info.get('triggerPrice'))
+            trigger_price_str = await formatter.format_price_with_symbol(float(trigger_price), base_asset) if trigger_price else "N/A"
+            amount_str = await formatter.format_amount(amount, base_asset)
+            
+            side_emoji = "🟢" if side == "BUY" else "🔴"
+            
+            return f"   {side_emoji} {base_asset} {side} {amount_str} - Target @ {trigger_price_str}"
+            
+        except Exception as e:
+            logger.error(f"Error formatting take profit order: {e}")
+            return f"   ❌ Error formatting TP order: {order.get('symbol', 'unknown')}"
 
 
-            await self._reply(update, orders_text.strip())
+    async def _format_zero_size_order(self, order: Dict, formatter) -> str:
+        """Format a zero-size order (position placeholder)."""
+        try:
+            symbol = order.get('symbol', 'unknown')
+            base_asset = symbol.split('/')[0] if '/' in symbol else symbol
+            side = order.get('side', '').upper()
+            order_info = order.get('info', {})
+            
+            # These are usually stop losses for positions
+            if order_info.get('isPositionTpsl', False):
+                trigger_price = order_info.get('triggerPx')
+                trigger_condition = order_info.get('triggerCondition', '')
+                trigger_price_str = await formatter.format_price_with_symbol(float(trigger_price), base_asset) if trigger_price else "N/A"
+                
+                side_emoji = "🛑" if side == "BUY" else "🛑"  # Red for stop loss
+                return f"   {side_emoji} {base_asset} Position Stop Loss - Trigger @ {trigger_price_str}"
+            else:
+                side_emoji = "⚪"
+                return f"   {side_emoji} {base_asset} {side} - Zero size placeholder"
+            
+        except Exception as e:
+            logger.error(f"Error formatting zero size order: {e}")
+            return f"   ❌ Error formatting zero-size order: {order.get('symbol', 'unknown')}"
 
 
+    async def _format_pending_sl_order(self, order: Dict, formatter) -> Optional[str]:
+        """Format a pending stop loss order (bot-internal)."""
+        try:
+            symbol = order.get('symbol', 'unknown')
+            base_asset = symbol.split('/')[0] if '/' in symbol else symbol.split(':')[0]
+            
+            entry_side = order.get('side', 'unknown').upper()
+            exit_side = "SELL" if entry_side == "BUY" else "BUY"
+            price = float(order.get('stop_loss_price') or 0)
+            amount = float(order.get('amount') or 0)
+            status = order.get('status', '').upper()
+            
+            price_str = await formatter.format_price_with_symbol(price, base_asset)
+            amount_str = await formatter.format_amount(amount, base_asset)
+            
+            side_emoji = "⏳"
+            return f"   {side_emoji} {base_asset} {exit_side} {amount_str} @ {price_str} (Entry: {status})"
+            
         except Exception as e:
         except Exception as e:
-            logger.error(f"Error in orders command: {e}", exc_info=True)
-            await self._reply(update, "❌ Error retrieving order information.")
+            logger.error(f"Error formatting pending SL order: {e}")
+            return None

+ 70 - 249
src/commands/management_commands.py

@@ -7,16 +7,18 @@ import logging
 import os
 import os
 import platform
 import platform
 import sys
 import sys
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
 from telegram import Update, ReplyKeyboardMarkup, KeyboardButton, InlineKeyboardMarkup
 from telegram import Update, ReplyKeyboardMarkup, KeyboardButton, InlineKeyboardMarkup
 from telegram.ext import ContextTypes
 from telegram.ext import ContextTypes
 import json
 import json
+from typing import Dict, Any, List, Optional
 
 
 from src.config.config import Config
 from src.config.config import Config
 from src.monitoring.alarm_manager import AlarmManager
 from src.monitoring.alarm_manager import AlarmManager
 from src.utils.token_display_formatter import get_formatter
 from src.utils.token_display_formatter import get_formatter
 from src.stats import TradingStats
 from src.stats import TradingStats
 from src.config.logging_config import LoggingManager
 from src.config.logging_config import LoggingManager
+from .info.base import InfoCommandsBase
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -31,11 +33,12 @@ def _normalize_token_case(token: str) -> str:
     else:
     else:
         return token.upper()  # Convert to uppercase for all-lowercase input
         return token.upper()  # Convert to uppercase for all-lowercase input
 
 
-class ManagementCommands:
+class ManagementCommands(InfoCommandsBase):
     """Handles all management-related Telegram commands."""
     """Handles all management-related Telegram commands."""
     
     
     def __init__(self, trading_engine, monitoring_coordinator):
     def __init__(self, trading_engine, monitoring_coordinator):
         """Initialize with trading engine and monitoring coordinator."""
         """Initialize with trading engine and monitoring coordinator."""
+        super().__init__(trading_engine)
         self.trading_engine = trading_engine
         self.trading_engine = trading_engine
         self.monitoring_coordinator = monitoring_coordinator
         self.monitoring_coordinator = monitoring_coordinator
         self.alarm_manager = AlarmManager()
         self.alarm_manager = AlarmManager()
@@ -544,263 +547,81 @@ Will trigger when {token} price moves {alarm['direction']} {target_price_str}
         )
         )
 
 
     async def sync_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
     async def sync_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
-        """Handle the /sync command to synchronize bot state 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
-
+        """Handle the /sync command to synchronize exchange orders with database."""
         try:
         try:
-            # Get confirmation from user unless force flag is provided
-            force_sync = len(context.args) > 0 and context.args[0].lower() == "force"
-            
-            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'
-                )
+            if not self._is_authorized(update):
+                await self._reply(update, "❌ Unauthorized access.")
                 return
                 return
 
 
-            # 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'
-            )
+            # Get force parameter
+            force = False
+            if context.args and context.args[0].lower() == 'force':
+                force = True
 
 
-            # 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'
-            )
+            await self._reply(update, "🔄 Starting order synchronization...")
 
 
-            # 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'
-            )
+            # Get exchange order sync from monitoring coordinator
+            monitoring_coordinator = getattr(self.trading_engine, 'monitoring_coordinator', None)
+            if not monitoring_coordinator or not monitoring_coordinator.exchange_order_sync:
+                await self._reply(update, "❌ Order synchronization not available. Please restart the bot.")
+                return
 
 
-            # Step 4: Recreate position tracking for open positions
-            positions_synced = 0
-            orders_synced = 0
+            # Run synchronization
+            sync_results = monitoring_coordinator.exchange_order_sync.sync_exchange_orders_to_database()
             
             
-            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
-                                # Use the side field from exchange (more reliable than contracts sign)
-                                exchange_side = position.get('side', '').lower()
-                                if exchange_side == 'long':
-                                    side = 'buy'
-                                    position_side = 'long'
-                                elif exchange_side == 'short':
-                                    side = 'sell' 
-                                    position_side = 'short'
-                                else:
-                                    # Fallback to contracts sign if side field missing
-                                    side = 'buy' if size > 0 else 'sell'
-                                    position_side = 'long' if size > 0 else 'short'
-                                    logger.warning(f"Using contracts sign fallback for {token}: size={size}, exchange_side='{exchange_side}'")
-                                
-                                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))
-                                        # Handle None markPrice safely
-                                        mark_price_raw = position.get('markPrice')
-                                        mark_price = float(mark_price_raw) if mark_price_raw is not None else 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, unrealized_pnl=0.0, 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")
+            # Format results message
+            message = await self._format_sync_results(sync_results, force)
+            await self._reply(update, message)
 
 
         except Exception as e:
         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')
-            
             logger.error(f"Error in sync command: {e}", exc_info=True)
             logger.error(f"Error in sync command: {e}", exc_info=True)
+            await self._reply(update, "❌ Error during synchronization.")
+
+    async def _format_sync_results(self, sync_results: Dict[str, Any], force: bool) -> str:
+        """Format synchronization results for display."""
+        
+        if 'error' in sync_results:
+            return f"❌ Synchronization failed: {sync_results['error']}"
+        
+        new_orders = sync_results.get('new_orders_added', 0)
+        updated_orders = sync_results.get('orders_updated', 0)
+        cancelled_orders = sync_results.get('orphaned_orders_cancelled', 0)
+        errors = sync_results.get('errors', [])
+        
+        message_parts = ["✅ <b>Order Synchronization Complete</b>\n"]
+        
+        # Results summary
+        message_parts.append("📊 <b>Sync Results:</b>")
+        message_parts.append(f"   • {new_orders} new orders added to database")
+        message_parts.append(f"   • {updated_orders} existing orders updated")
+        message_parts.append(f"   • {cancelled_orders} orphaned orders cancelled")
+        
+        if errors:
+            message_parts.append(f"   • {len(errors)} errors encountered")
+            message_parts.append("")
+            message_parts.append("⚠️ <b>Errors:</b>")
+            for error in errors[:3]:  # Show first 3 errors
+                message_parts.append(f"   • {error}")
+            if len(errors) > 3:
+                message_parts.append(f"   • ... and {len(errors) - 3} more errors")
+        
+        message_parts.append("")
+        
+        # Status messages
+        if new_orders == 0 and updated_orders == 0 and cancelled_orders == 0:
+            message_parts.append("✅ All orders are already synchronized")
+        else:
+            message_parts.append("🔄 Database now matches exchange state")
+        
+        # Help text
+        message_parts.append("")
+        message_parts.append("💡 <b>About Sync:</b>")
+        message_parts.append("• Orders placed directly on exchange are now tracked")
+        message_parts.append("• Bot-placed orders remain tracked as before")
+        message_parts.append("• Sync runs automatically every 30 seconds")
+        message_parts.append("• Use /orders to see all synchronized orders")
+        
+        return "\n".join(message_parts)
 
 
     async def deposit_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
     async def deposit_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
         """Handle the /deposit command to record a deposit."""
         """Handle the /deposit command to record a deposit."""

+ 226 - 0
src/monitoring/exchange_order_sync.py

@@ -0,0 +1,226 @@
+#!/usr/bin/env python3
+"""
+Exchange Order Synchronization Manager
+Syncs orders from the exchange to the database for tracking
+"""
+
+import logging
+from typing import Dict, List, Optional, Any
+from datetime import datetime, timezone
+import uuid
+
+logger = logging.getLogger(__name__)
+
+class ExchangeOrderSync:
+    """Manages synchronization of exchange orders with database"""
+    
+    def __init__(self, hl_client, trading_stats):
+        self.hl_client = hl_client
+        self.trading_stats = trading_stats
+        
+    def sync_exchange_orders_to_database(self) -> Dict[str, Any]:
+        """Sync all exchange orders to database for tracking"""
+        try:
+            # Get current exchange orders
+            exchange_orders = self.hl_client.get_open_orders() or []
+            
+            # Get current database orders
+            db_orders = []
+            db_orders.extend(self.trading_stats.get_orders_by_status('open', limit=100))
+            db_orders.extend(self.trading_stats.get_orders_by_status('submitted', limit=100))
+            db_orders.extend(self.trading_stats.get_orders_by_status('pending_trigger', limit=100))
+            
+            # Track what we sync
+            sync_results = {
+                'new_orders_added': 0,
+                'orders_updated': 0,
+                'orphaned_orders_cancelled': 0,
+                'errors': []
+            }
+            
+            # Sync exchange orders to database
+            self._sync_exchange_to_db(exchange_orders, db_orders, sync_results)
+            
+            # Clean up orphaned database orders
+            self._cleanup_orphaned_db_orders(exchange_orders, db_orders, sync_results)
+            
+            logger.info(f"Order sync complete: {sync_results['new_orders_added']} added, "
+                       f"{sync_results['orders_updated']} updated, "
+                       f"{sync_results['orphaned_orders_cancelled']} orphaned")
+            
+            return sync_results
+            
+        except Exception as e:
+            logger.error(f"Error syncing exchange orders: {e}")
+            return {'error': str(e)}
+    
+    def _sync_exchange_to_db(self, exchange_orders: List[Dict], db_orders: List[Dict], sync_results: Dict):
+        """Sync exchange orders to database"""
+        
+        # Create lookup of existing database orders by exchange ID
+        db_orders_by_exchange_id = {
+            order.get('exchange_order_id'): order 
+            for order in db_orders 
+            if order.get('exchange_order_id')
+        }
+        
+        for exchange_order in exchange_orders:
+            try:
+                exchange_order_id = exchange_order.get('id')
+                if not exchange_order_id:
+                    continue
+                
+                # Check if order already exists in database
+                if exchange_order_id in db_orders_by_exchange_id:
+                    # Update existing order
+                    self._update_existing_order(exchange_order, db_orders_by_exchange_id[exchange_order_id], sync_results)
+                else:
+                    # Add new order to database
+                    self._add_new_order_to_database(exchange_order, sync_results)
+                    
+            except Exception as e:
+                sync_results['errors'].append(f"Error syncing order {exchange_order.get('id', 'unknown')}: {e}")
+                logger.error(f"Error syncing exchange order: {e}")
+    
+    def _add_new_order_to_database(self, exchange_order: Dict, sync_results: Dict):
+        """Add a new exchange order to the database"""
+        try:
+            symbol = exchange_order.get('symbol', '')
+            side = exchange_order.get('side', '')
+            order_type = exchange_order.get('type', '')
+            amount = float(exchange_order.get('amount', 0))
+            price = float(exchange_order.get('price', 0)) if exchange_order.get('price') else None
+            exchange_order_id = exchange_order.get('id', '')
+            
+            # Generate bot order ref ID for tracking
+            bot_order_ref_id = f"sync_{uuid.uuid4().hex[:8]}"
+            
+            # Determine order status
+            status = self._map_exchange_status_to_db_status(exchange_order)
+            
+            # Check if it's a reduce-only order
+            is_reduce_only = exchange_order.get('reduceOnly', False) or exchange_order.get('info', {}).get('reduceOnly', False)
+            
+            # Skip zero-size orders (position placeholders)
+            if amount == 0:
+                logger.debug(f"Skipping zero-size order {exchange_order_id} (position placeholder)")
+                return
+            
+            # Insert into database
+            insert_query = """
+                INSERT INTO orders (
+                    bot_order_ref_id, exchange_order_id, symbol, side, type, 
+                    amount_requested, amount_filled, price, status, 
+                    timestamp_created, timestamp_updated
+                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+            """
+            
+            timestamp = datetime.now(timezone.utc).isoformat()
+            amount_filled = float(exchange_order.get('filled', 0))
+            
+            params = (
+                bot_order_ref_id, exchange_order_id, symbol, side, order_type,
+                amount, amount_filled, price, status,
+                timestamp, timestamp
+            )
+            
+            self.trading_stats.db_manager._execute_query(insert_query, params)
+            sync_results['new_orders_added'] += 1
+            
+            logger.info(f"✅ Added exchange order to database: {symbol} {side} {order_type} (ID: {exchange_order_id})")
+            
+        except Exception as e:
+            logger.error(f"Error adding exchange order to database: {e}")
+            sync_results['errors'].append(f"Error adding order {exchange_order.get('id', 'unknown')}: {e}")
+    
+    def _update_existing_order(self, exchange_order: Dict, db_order: Dict, sync_results: Dict):
+        """Update existing database order with exchange data"""
+        try:
+            exchange_order_id = exchange_order.get('id', '')
+            new_status = self._map_exchange_status_to_db_status(exchange_order)
+            new_amount_filled = float(exchange_order.get('filled', 0))
+            
+            # Check if status or filled amount changed
+            old_status = db_order.get('status', '')
+            old_amount_filled = float(db_order.get('amount_filled', 0))
+            
+            if new_status != old_status or abs(new_amount_filled - old_amount_filled) > 0.0001:
+                # Update the order
+                update_query = """
+                    UPDATE orders 
+                    SET status = ?, amount_filled = ?, timestamp_updated = ?
+                    WHERE exchange_order_id = ?
+                """
+                
+                timestamp = datetime.now(timezone.utc).isoformat()
+                params = (new_status, new_amount_filled, timestamp, exchange_order_id)
+                
+                self.trading_stats.db_manager._execute_query(update_query, params)
+                sync_results['orders_updated'] += 1
+                
+                logger.debug(f"Updated order {exchange_order_id}: status {old_status} -> {new_status}, "
+                           f"filled {old_amount_filled} -> {new_amount_filled}")
+            
+        except Exception as e:
+            logger.error(f"Error updating exchange order in database: {e}")
+            sync_results['errors'].append(f"Error updating order {exchange_order.get('id', 'unknown')}: {e}")
+    
+    def _cleanup_orphaned_db_orders(self, exchange_orders: List[Dict], db_orders: List[Dict], sync_results: Dict):
+        """Clean up database orders that no longer exist on exchange"""
+        
+        # Get exchange order IDs
+        exchange_order_ids = {order.get('id') for order in exchange_orders if order.get('id')}
+        
+        for db_order in db_orders:
+            try:
+                exchange_order_id = db_order.get('exchange_order_id')
+                if not exchange_order_id:
+                    continue  # Skip orders without exchange ID
+                
+                # If database order not found on exchange, mark as cancelled
+                if exchange_order_id not in exchange_order_ids:
+                    self._mark_order_as_cancelled(db_order, sync_results)
+                    
+            except Exception as e:
+                logger.error(f"Error checking orphaned order: {e}")
+                sync_results['errors'].append(f"Error checking orphaned order: {e}")
+    
+    def _mark_order_as_cancelled(self, db_order: Dict, sync_results: Dict):
+        """Mark a database order as cancelled (no longer on exchange)"""
+        try:
+            order_id = db_order.get('id')
+            exchange_order_id = db_order.get('exchange_order_id', '')
+            symbol = db_order.get('symbol', '')
+            
+            update_query = """
+                UPDATE orders 
+                SET status = 'cancelled', timestamp_updated = ?
+                WHERE id = ?
+            """
+            
+            timestamp = datetime.now(timezone.utc).isoformat()
+            params = (timestamp, order_id)
+            
+            self.trading_stats.db_manager._execute_query(update_query, params)
+            sync_results['orphaned_orders_cancelled'] += 1
+            
+            logger.info(f"🗑️ Marked orphaned order as cancelled: {symbol} (Exchange ID: {exchange_order_id})")
+            
+        except Exception as e:
+            logger.error(f"Error marking order as cancelled: {e}")
+    
+    def _map_exchange_status_to_db_status(self, exchange_order: Dict) -> str:
+        """Map exchange order status to database status"""
+        exchange_status = exchange_order.get('status', '').lower()
+        
+        status_mapping = {
+            'open': 'open',
+            'pending': 'submitted',
+            'closed': 'filled',
+            'canceled': 'cancelled',
+            'cancelled': 'cancelled',
+            'expired': 'expired',
+            'rejected': 'failed'
+        }
+        
+        return status_mapping.get(exchange_status, 'unknown') 

+ 55 - 0
src/monitoring/monitoring_coordinator.py

@@ -10,6 +10,7 @@ from .position_tracker import PositionTracker
 from .pending_orders_manager import PendingOrdersManager
 from .pending_orders_manager import PendingOrdersManager
 from .risk_manager import RiskManager
 from .risk_manager import RiskManager
 from .alarm_manager import AlarmManager
 from .alarm_manager import AlarmManager
+from .exchange_order_sync import ExchangeOrderSync
 # DrawdownMonitor and RsiMonitor will be lazy-loaded to avoid circular imports
 # DrawdownMonitor and RsiMonitor will be lazy-loaded to avoid circular imports
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -32,6 +33,9 @@ class MonitoringCoordinator:
         self.risk_manager = RiskManager(hl_client, notification_manager, config)
         self.risk_manager = RiskManager(hl_client, notification_manager, config)
         self.alarm_manager = AlarmManager()  # AlarmManager only needs alarms_file (defaults to data/price_alarms.json)
         self.alarm_manager = AlarmManager()  # AlarmManager only needs alarms_file (defaults to data/price_alarms.json)
         
         
+        # Exchange order synchronization (will be initialized with trading stats)
+        self.exchange_order_sync = None
+        
         # DrawdownMonitor and RSIMonitor will be lazy-loaded to avoid circular imports
         # DrawdownMonitor and RSIMonitor will be lazy-loaded to avoid circular imports
         self.drawdown_monitor = None
         self.drawdown_monitor = None
         self.rsi_monitor = None
         self.rsi_monitor = None
@@ -54,6 +58,9 @@ class MonitoringCoordinator:
             await self.risk_manager.start()
             await self.risk_manager.start()
             # AlarmManager doesn't have start() method - it's always ready
             # AlarmManager doesn't have start() method - it's always ready
             
             
+            # Initialize exchange order sync with trading stats
+            self._init_exchange_order_sync()
+            
             # Lazy-load optional monitors to avoid circular imports
             # Lazy-load optional monitors to avoid circular imports
             self._init_optional_monitors()
             self._init_optional_monitors()
             
             
@@ -63,6 +70,9 @@ class MonitoringCoordinator:
             if hasattr(self.rsi_monitor, 'start'):
             if hasattr(self.rsi_monitor, 'start'):
                 await self.rsi_monitor.start()
                 await self.rsi_monitor.start()
             
             
+            # Start order synchronization loop
+            asyncio.create_task(self._order_sync_loop())
+            
             logger.info("All monitoring components started successfully")
             logger.info("All monitoring components started successfully")
             
             
         except Exception as e:
         except Exception as e:
@@ -92,6 +102,51 @@ class MonitoringCoordinator:
         
         
         logger.info("Monitoring system stopped")
         logger.info("Monitoring system stopped")
         
         
+    def _init_exchange_order_sync(self):
+        """Initialize exchange order synchronization"""
+        try:
+            # Get trading stats from position tracker
+            if hasattr(self.position_tracker, 'trading_stats') and self.position_tracker.trading_stats:
+                self.exchange_order_sync = ExchangeOrderSync(
+                    self.hl_client, 
+                    self.position_tracker.trading_stats
+                )
+                logger.info("✅ Exchange order sync initialized")
+            else:
+                logger.warning("⚠️ Trading stats not available, exchange order sync disabled")
+        except Exception as e:
+            logger.error(f"Error initializing exchange order sync: {e}")
+    
+    async def _order_sync_loop(self):
+        """Periodic order synchronization loop"""
+        logger.info("🔄 Starting exchange order synchronization loop")
+        sync_interval = 30  # Sync every 30 seconds
+        loop_count = 0
+        
+        while self.is_running:
+            try:
+                loop_count += 1
+                
+                # Run sync every 30 seconds (6 cycles with 5s heartbeat)
+                if loop_count % 6 == 0 and self.exchange_order_sync:
+                    logger.debug(f"🔄 Running exchange order sync (loop #{loop_count})")
+                    sync_results = self.exchange_order_sync.sync_exchange_orders_to_database()
+                    
+                    # Log results if there were changes
+                    if sync_results.get('new_orders_added', 0) > 0 or sync_results.get('orders_updated', 0) > 0:
+                        logger.info(f"📊 Order sync: +{sync_results.get('new_orders_added', 0)} new, "
+                                  f"~{sync_results.get('orders_updated', 0)} updated, "
+                                  f"-{sync_results.get('orphaned_orders_cancelled', 0)} cancelled")
+                
+                # Wait for next cycle
+                await asyncio.sleep(Config.BOT_HEARTBEAT_SECONDS)
+                
+            except Exception as e:
+                logger.error(f"Error in order sync loop: {e}")
+                await asyncio.sleep(Config.BOT_HEARTBEAT_SECONDS)
+        
+        logger.info("🛑 Exchange order sync loop stopped")
+    
     def _init_optional_monitors(self):
     def _init_optional_monitors(self):
         """Initialize optional monitors with lazy loading to avoid circular imports"""
         """Initialize optional monitors with lazy loading to avoid circular imports"""
         try:
         try:

+ 61 - 7
src/monitoring/position_tracker.py

@@ -25,6 +25,12 @@ class PositionTracker:
         self.current_positions: Dict[str, Dict] = {}
         self.current_positions: Dict[str, Dict] = {}
         self.is_running = False
         self.is_running = False
         
         
+        # Market data cache to prevent overfetching
+        # Previously: Every 5s cycle fetched market data 2-4 times per symbol
+        # Now: Market data is cached for 30s, reducing API calls by ~75%
+        self._market_data_cache: Dict[str, Dict] = {}  # symbol -> {data, timestamp}
+        self._cache_ttl_seconds = 30  # Cache market data for 30 seconds
+        
     async def start(self):
     async def start(self):
         """Start position tracking"""
         """Start position tracking"""
         if self.is_running:
         if self.is_running:
@@ -65,9 +71,10 @@ class PositionTracker:
                 logger.debug(f"📊 Position tracker loop #{loop_count} - checking for position changes...")
                 logger.debug(f"📊 Position tracker loop #{loop_count} - checking for position changes...")
                 await self._check_position_changes()
                 await self._check_position_changes()
                 
                 
-                # Log periodically to show it's alive
-                if loop_count % 12 == 0:  # Every 12 loops (60 seconds with 5s heartbeat)
-                    logger.info(f"📊 Position tracker alive - loop #{loop_count}, {len(self.current_positions)} positions tracked")
+                # Clean stale cache entries every 12 loops (60 seconds with 5s heartbeat)
+                if loop_count % 12 == 0:
+                    self._clear_stale_cache_entries()
+                    logger.info(f"📊 Position tracker alive - loop #{loop_count}, {len(self.current_positions)} positions tracked, {len(self._market_data_cache)} cached symbols")
                 
                 
                 await asyncio.sleep(Config.BOT_HEARTBEAT_SECONDS)  # Use config heartbeat
                 await asyncio.sleep(Config.BOT_HEARTBEAT_SECONDS)  # Use config heartbeat
             except Exception as e:
             except Exception as e:
@@ -215,7 +222,7 @@ class PositionTracker:
                         # Get current market price for mark price and position value calculation
                         # Get current market price for mark price and position value calculation
                         current_mark_price = 0.0
                         current_mark_price = 0.0
                         try:
                         try:
-                            market_data = self.hl_client.get_market_data(symbol)
+                            market_data = self._get_cached_market_data(symbol)
                             if market_data and market_data.get('ticker'):
                             if market_data and market_data.get('ticker'):
                                 current_mark_price = float(market_data['ticker'].get('last', 0))
                                 current_mark_price = float(market_data['ticker'].get('last', 0))
                         except Exception as e:
                         except Exception as e:
@@ -402,7 +409,7 @@ class PositionTracker:
             side = "Long" if position['size'] > 0 else "Short"
             side = "Long" if position['size'] > 0 else "Short"
             
             
             # Get current market price for exit calculation
             # Get current market price for exit calculation
-            market_data = self.hl_client.get_market_data(full_symbol)
+            market_data = self._get_cached_market_data(full_symbol)
             if not market_data:
             if not market_data:
                 logger.error(f"Could not get market data for {full_symbol}")
                 logger.error(f"Could not get market data for {full_symbol}")
                 return
                 return
@@ -486,7 +493,7 @@ class PositionTracker:
             # Get current market price for more accurate value calculation
             # Get current market price for more accurate value calculation
             try:
             try:
                 full_symbol = f"{symbol}/USDC:USDC"
                 full_symbol = f"{symbol}/USDC:USDC"
-                market_data = self.hl_client.get_market_data(full_symbol)
+                market_data = self._get_cached_market_data(full_symbol)
                 current_market_price = float(market_data.get('ticker', {}).get('last', current['entry_px'])) if market_data else current['entry_px']
                 current_market_price = float(market_data.get('ticker', {}).get('last', current['entry_px'])) if market_data else current['entry_px']
             except Exception:
             except Exception:
                 current_market_price = current['entry_px']  # Fallback to entry price
                 current_market_price = current['entry_px']  # Fallback to entry price
@@ -591,4 +598,51 @@ class PositionTracker:
             logger.info(f"Saved stats for {symbol}: PnL ${pnl:.3f}, lifecycle_id: {lifecycle_id}")
             logger.info(f"Saved stats for {symbol}: PnL ${pnl:.3f}, lifecycle_id: {lifecycle_id}")
             
             
         except Exception as e:
         except Exception as e:
-            logger.error(f"Error saving position stats for {symbol}: {e}") 
+            logger.error(f"Error saving position stats for {symbol}: {e}")
+
+    def _get_cached_market_data(self, symbol: str) -> Optional[Dict[str, Any]]:
+        """Get market data from cache if fresh, otherwise fetch and cache it."""
+        now = datetime.now(timezone.utc)
+        
+        # Check if we have cached data for this symbol
+        if symbol in self._market_data_cache:
+            cached_entry = self._market_data_cache[symbol]
+            cache_age = (now - cached_entry['timestamp']).total_seconds()
+            
+            # Return cached data if it's still fresh
+            if cache_age < self._cache_ttl_seconds:
+                logger.debug(f"📋 Using cached market data for {symbol} (age: {cache_age:.1f}s)")
+                return cached_entry['data']
+            else:
+                logger.debug(f"🗑️ Cached data for {symbol} is stale (age: {cache_age:.1f}s), will fetch fresh data")
+        
+        # Fetch fresh market data
+        logger.debug(f"🔄 Fetching fresh market data for {symbol}")
+        market_data = self.hl_client.get_market_data(symbol)
+        
+        if market_data:
+            # Cache the fresh data
+            self._market_data_cache[symbol] = {
+                'data': market_data,
+                'timestamp': now
+            }
+            logger.debug(f"💾 Cached market data for {symbol}")
+        
+        return market_data
+        
+    def _clear_stale_cache_entries(self):
+        """Remove stale entries from market data cache."""
+        now = datetime.now(timezone.utc)
+        stale_symbols = []
+        
+        for symbol, cached_entry in self._market_data_cache.items():
+            cache_age = (now - cached_entry['timestamp']).total_seconds()
+            if cache_age >= self._cache_ttl_seconds:
+                stale_symbols.append(symbol)
+        
+        for symbol in stale_symbols:
+            del self._market_data_cache[symbol]
+            logger.debug(f"🗑️ Removed stale cache entry for {symbol}")
+        
+        if stale_symbols:
+            logger.debug(f"🧹 Cleaned {len(stale_symbols)} stale cache entries") 

+ 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.309"
+BOT_VERSION = "2.6.310"
 
 
 # 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"))