Browse Source

Add price alarm system and logging enhancements - Introduce a comprehensive price alarm manager with functionality for creating, triggering, and managing alarms. Implement logging configuration with rotation and cleanup support, and integrate alarm checks into the Telegram bot for real-time notifications. Add tests for alarm management and ensure persistence across sessions.

Carles Sentis 5 days ago
parent
commit
01b6eed4d8
9 changed files with 1559 additions and 76 deletions
  1. 28 1
      config/env.example
  2. 240 0
      src/alarm_manager.py
  3. 54 14
      src/config.py
  4. 69 2
      src/hyperliquid_client.py
  5. 241 0
      src/logging_config.py
  6. 508 59
      src/telegram_bot.py
  7. 58 0
      test_heartbeat_config.py
  8. 104 0
      test_logging_system.py
  9. 257 0
      tests/test_alarm_system.py

+ 28 - 1
config/env.example

@@ -38,8 +38,35 @@ TELEGRAM_CHAT_ID=your_chat_id_here
 # Enable/disable Telegram integration
 TELEGRAM_ENABLED=true
 
+# ========================================
+# Bot Monitoring Configuration
+# ========================================
+# Heartbeat interval for monitoring orders, positions, and price alarms (in seconds)
+# Default: 30 seconds - good balance between responsiveness and API usage
+# Minimum recommended: 10 seconds (to avoid rate limiting)
+# Maximum recommended: 300 seconds (5 minutes)
+BOT_HEARTBEAT_SECONDS=30
+
 # ========================================
 # Logging
 # ========================================
 # Options: DEBUG, INFO, WARNING, ERROR
-LOG_LEVEL=INFO 
+LOG_LEVEL=INFO
+
+# Enable/disable logging to file (true/false)
+LOG_TO_FILE=true
+
+# Log file path (relative to project root)
+LOG_FILE_PATH=logs/trading_bot.log
+
+# Log rotation type: 'size' (rotate when file gets too big) or 'time' (rotate daily/hourly)
+LOG_ROTATION_TYPE=size
+
+# For size-based rotation: max file size in MB before rotation
+LOG_MAX_SIZE_MB=10
+
+# For time-based rotation: when to rotate ('midnight', 'H' for hourly, 'D' for daily)
+LOG_ROTATION_INTERVAL=midnight
+
+# Number of backup log files to keep (older files are automatically deleted)
+LOG_BACKUP_COUNT=5 

+ 240 - 0
src/alarm_manager.py

@@ -0,0 +1,240 @@
+#!/usr/bin/env python3
+"""
+Price Alarm Manager
+
+Manages price alarms for tokens with persistence and monitoring integration.
+"""
+
+import json
+import os
+import logging
+from datetime import datetime
+from typing import Dict, List, Any, Optional, Tuple
+from config import Config
+
+logger = logging.getLogger(__name__)
+
+class AlarmManager:
+    """Manages price alarms with persistence and monitoring."""
+    
+    def __init__(self, alarms_file: str = "price_alarms.json"):
+        """Initialize the alarm manager."""
+        self.alarms_file = alarms_file
+        self.data = self._load_alarms()
+        
+        # Initialize if first run
+        if not self.data:
+            self._initialize_alarms()
+    
+    def _load_alarms(self) -> Dict[str, Any]:
+        """Load alarms from file."""
+        try:
+            if os.path.exists(self.alarms_file):
+                with open(self.alarms_file, 'r') as f:
+                    return json.load(f)
+        except Exception as e:
+            logger.error(f"Error loading alarms: {e}")
+        return {}
+    
+    def _save_alarms(self):
+        """Save alarms to file."""
+        try:
+            with open(self.alarms_file, 'w') as f:
+                json.dump(self.data, f, indent=2, default=str)
+        except Exception as e:
+            logger.error(f"Error saving alarms: {e}")
+    
+    def _initialize_alarms(self):
+        """Initialize alarms structure."""
+        self.data = {
+            'next_id': 1,
+            'alarms': [],
+            'triggered_alarms': [],
+            'last_update': datetime.now().isoformat()
+        }
+        self._save_alarms()
+    
+    def create_alarm(self, token: str, target_price: float, current_price: float) -> Dict[str, Any]:
+        """Create a new price alarm."""
+        # Determine direction based on current vs target price
+        direction = "above" if target_price > current_price else "below"
+        
+        alarm = {
+            'id': self.data['next_id'],
+            'token': token.upper(),
+            'target_price': target_price,
+            'current_price_when_set': current_price,
+            'direction': direction,
+            'status': 'active',
+            'created_at': datetime.now().isoformat(),
+            'triggered_at': None
+        }
+        
+        self.data['alarms'].append(alarm)
+        self.data['next_id'] += 1
+        self.data['last_update'] = datetime.now().isoformat()
+        self._save_alarms()
+        
+        logger.info(f"Created alarm {alarm['id']}: {token} @ ${target_price} ({direction})")
+        return alarm
+    
+    def get_alarm_by_id(self, alarm_id: int) -> Optional[Dict[str, Any]]:
+        """Get alarm by ID."""
+        for alarm in self.data['alarms']:
+            if alarm['id'] == alarm_id:
+                return alarm
+        return None
+    
+    def get_alarms_by_token(self, token: str) -> List[Dict[str, Any]]:
+        """Get all active alarms for a specific token."""
+        token = token.upper()
+        return [alarm for alarm in self.data['alarms'] if alarm['token'] == token and alarm['status'] == 'active']
+    
+    def get_all_active_alarms(self) -> List[Dict[str, Any]]:
+        """Get all active alarms."""
+        return [alarm for alarm in self.data['alarms'] if alarm['status'] == 'active']
+    
+    def get_recent_triggered_alarms(self, limit: int = 10) -> List[Dict[str, Any]]:
+        """Get recently triggered alarms."""
+        return self.data['triggered_alarms'][-limit:] if self.data['triggered_alarms'] else []
+    
+    def remove_alarm(self, alarm_id: int) -> bool:
+        """Remove an alarm by ID."""
+        for i, alarm in enumerate(self.data['alarms']):
+            if alarm['id'] == alarm_id:
+                removed_alarm = self.data['alarms'].pop(i)
+                self.data['last_update'] = datetime.now().isoformat()
+                self._save_alarms()
+                logger.info(f"Removed alarm {alarm_id}: {removed_alarm['token']} @ ${removed_alarm['target_price']}")
+                return True
+        return False
+    
+    def trigger_alarm(self, alarm_id: int, triggered_price: float) -> Optional[Dict[str, Any]]:
+        """Mark an alarm as triggered."""
+        for alarm in self.data['alarms']:
+            if alarm['id'] == alarm_id and alarm['status'] == 'active':
+                alarm['status'] = 'triggered'
+                alarm['triggered_at'] = datetime.now().isoformat()
+                alarm['triggered_price'] = triggered_price
+                
+                # Move to triggered alarms list
+                self.data['triggered_alarms'].append(alarm.copy())
+                
+                # Keep only last 50 triggered alarms
+                if len(self.data['triggered_alarms']) > 50:
+                    self.data['triggered_alarms'] = self.data['triggered_alarms'][-50:]
+                
+                self.data['last_update'] = datetime.now().isoformat()
+                self._save_alarms()
+                
+                logger.info(f"Triggered alarm {alarm_id}: {alarm['token']} @ ${triggered_price}")
+                return alarm
+        return None
+    
+    def check_alarms(self, price_data: Dict[str, float]) -> List[Dict[str, Any]]:
+        """Check all active alarms against current prices."""
+        triggered_alarms = []
+        
+        for alarm in self.get_all_active_alarms():
+            token = alarm['token']
+            target_price = alarm['target_price']
+            direction = alarm['direction']
+            
+            # Get current price for this token
+            current_price = price_data.get(token)
+            if current_price is None:
+                continue
+            
+            # Check if alarm should trigger
+            should_trigger = False
+            
+            if direction == "above" and current_price >= target_price:
+                should_trigger = True
+            elif direction == "below" and current_price <= target_price:
+                should_trigger = True
+            
+            if should_trigger:
+                triggered_alarm = self.trigger_alarm(alarm['id'], current_price)
+                if triggered_alarm:
+                    triggered_alarms.append(triggered_alarm)
+        
+        return triggered_alarms
+    
+    def format_alarm_list(self, alarms: List[Dict[str, Any]], title: str = "Price Alarms") -> str:
+        """Format a list of alarms for display."""
+        if not alarms:
+            return f"📢 <b>{title}</b>\n\n📭 No alarms found."
+        
+        message = f"📢 <b>{title}</b>\n\n"
+        
+        for alarm in alarms:
+            status_emoji = "✅" if alarm['status'] == 'active' else "🔔"
+            direction_emoji = "📈" if alarm['direction'] == 'above' else "📉"
+            
+            message += f"{status_emoji} <b>ID {alarm['id']}</b> - {alarm['token']}\n"
+            message += f"   {direction_emoji} Alert {alarm['direction']} ${alarm['target_price']:,.2f}\n"
+            
+            if alarm['status'] == 'active':
+                created_date = datetime.fromisoformat(alarm['created_at']).strftime('%m/%d %H:%M')
+                message += f"   📅 Created: {created_date}\n"
+            else:
+                triggered_date = datetime.fromisoformat(alarm['triggered_at']).strftime('%m/%d %H:%M')
+                message += f"   🔔 Triggered: {triggered_date} @ ${alarm['triggered_price']:,.2f}\n"
+            
+            message += "\n"
+        
+        if title == "Price Alarms":
+            message += f"💡 <b>Commands:</b>\n"
+            message += f"• <code>/alarm BTC 50000</code> - Set new alarm\n"
+            message += f"• <code>/alarm BTC</code> - Show BTC alarms\n"
+            message += f"• <code>/alarm 3</code> - Remove alarm ID 3"
+        
+        return message.strip()
+    
+    def format_triggered_alarm(self, alarm: Dict[str, Any]) -> str:
+        """Format a triggered alarm notification."""
+        direction_emoji = "📈" if alarm['direction'] == 'above' else "📉"
+        price_change = alarm['triggered_price'] - alarm['current_price_when_set']
+        change_percent = (price_change / alarm['current_price_when_set']) * 100
+        
+        message = f"""
+🔔 <b>Price Alert Triggered!</b>
+
+📊 <b>Alarm Details:</b>
+• Token: {alarm['token']}
+• Alert ID: {alarm['id']}
+• Target Price: ${alarm['target_price']:,.2f}
+• Direction: {alarm['direction'].upper()}
+
+💰 <b>Price Info:</b>
+• Current Price: ${alarm['triggered_price']:,.2f}
+• Price When Set: ${alarm['current_price_when_set']:,.2f}
+• Change: ${price_change:,.2f} ({change_percent:+.2f}%)
+
+{direction_emoji} <b>Alert Condition:</b> Price moved {alarm['direction']} ${alarm['target_price']:,.2f}
+
+⏰ <b>Triggered:</b> {datetime.now().strftime('%H:%M:%S')}
+
+✅ <b>Status:</b> Alarm completed and removed from active list
+        """
+        
+        return message.strip()
+    
+    def get_statistics(self) -> Dict[str, Any]:
+        """Get alarm statistics."""
+        active_alarms = self.get_all_active_alarms()
+        triggered_alarms = self.data.get('triggered_alarms', [])
+        
+        # Count by token
+        token_counts = {}
+        for alarm in active_alarms:
+            token = alarm['token']
+            token_counts[token] = token_counts.get(token, 0) + 1
+        
+        return {
+            'total_active': len(active_alarms),
+            'total_triggered': len(triggered_alarms),
+            'tokens_tracked': len(token_counts),
+            'token_breakdown': token_counts,
+            'next_id': self.data['next_id']
+        } 

+ 54 - 14
src/config.py

@@ -1,10 +1,13 @@
 import os
 from dotenv import load_dotenv
 from typing import Optional
+import logging
 
 # Load environment variables from .env file
 load_dotenv()
 
+logger = logging.getLogger(__name__)
+
 class Config:
     """Configuration class for the Hyperliquid trading bot."""
     
@@ -24,31 +27,68 @@ class Config:
     # Telegram Bot Configuration
     TELEGRAM_BOT_TOKEN: Optional[str] = os.getenv('TELEGRAM_BOT_TOKEN')
     TELEGRAM_CHAT_ID: Optional[str] = os.getenv('TELEGRAM_CHAT_ID')
-    TELEGRAM_ENABLED: bool = os.getenv('TELEGRAM_ENABLED', 'false').lower() == 'true'
+    TELEGRAM_ENABLED: bool = os.getenv('TELEGRAM_ENABLED', 'true').lower() == 'true'
+    
+    # Bot monitoring configuration
+    BOT_HEARTBEAT_SECONDS = int(os.getenv('BOT_HEARTBEAT_SECONDS', '30'))
     
     # Logging
     LOG_LEVEL: str = os.getenv('LOG_LEVEL', 'INFO')
     
+    # Log file configuration
+    LOG_TO_FILE: bool = os.getenv('LOG_TO_FILE', 'true').lower() == 'true'
+    LOG_FILE_PATH: str = os.getenv('LOG_FILE_PATH', 'logs/trading_bot.log')
+    LOG_MAX_SIZE_MB: int = int(os.getenv('LOG_MAX_SIZE_MB', '10'))
+    LOG_BACKUP_COUNT: int = int(os.getenv('LOG_BACKUP_COUNT', '5'))
+    LOG_ROTATION_TYPE: str = os.getenv('LOG_ROTATION_TYPE', 'size')  # 'size' or 'time'
+    LOG_ROTATION_INTERVAL: str = os.getenv('LOG_ROTATION_INTERVAL', 'midnight')  # for time rotation
+    
     @classmethod
     def validate(cls) -> bool:
-        """Validate that required configuration is present."""
+        """Validate all configuration settings."""
+        is_valid = True
+        
+        # Validate Hyperliquid settings
         if not cls.HYPERLIQUID_PRIVATE_KEY:
-            print("❌ HYPERLIQUID_PRIVATE_KEY is required")
-            return False
+            logger.error("❌ HYPERLIQUID_PRIVATE_KEY is required")
+            is_valid = False
+        
+        # Validate Telegram settings (if enabled)
+        if cls.TELEGRAM_ENABLED:
+            if not cls.TELEGRAM_BOT_TOKEN:
+                logger.error("❌ TELEGRAM_BOT_TOKEN is required when Telegram is enabled")
+                is_valid = False
+            
+            if not cls.TELEGRAM_CHAT_ID:
+                logger.error("❌ TELEGRAM_CHAT_ID is required when Telegram is enabled")
+                is_valid = False
+        
+        # Validate heartbeat interval
+        if cls.BOT_HEARTBEAT_SECONDS < 5:
+            logger.error("❌ BOT_HEARTBEAT_SECONDS must be at least 5 seconds to avoid rate limiting")
+            is_valid = False
+        elif cls.BOT_HEARTBEAT_SECONDS > 600:
+            logger.warning("⚠️ BOT_HEARTBEAT_SECONDS is very high (>10 minutes), monitoring may be slow")
+        
+        # Validate trading settings
+        if cls.DEFAULT_TRADE_AMOUNT <= 0:
+            logger.error("❌ DEFAULT_TRADE_AMOUNT must be positive")
+            is_valid = False
         
-        # Note: SECRET_KEY might be optional depending on Hyperliquid implementation
-        # Some CCXT exchanges require both apiKey and secret, others only one
+        # Validate logging settings
+        if cls.LOG_MAX_SIZE_MB <= 0:
+            logger.error("❌ LOG_MAX_SIZE_MB must be positive")
+            is_valid = False
         
-        if cls.TELEGRAM_ENABLED and not cls.TELEGRAM_BOT_TOKEN:
-            print("❌ TELEGRAM_BOT_TOKEN is required when TELEGRAM_ENABLED=true")
-            return False
+        if cls.LOG_BACKUP_COUNT < 0:
+            logger.error("❌ LOG_BACKUP_COUNT must be non-negative")
+            is_valid = False
         
-        if cls.TELEGRAM_ENABLED and not cls.TELEGRAM_CHAT_ID:
-            print("❌ TELEGRAM_CHAT_ID is required when TELEGRAM_ENABLED=true")
-            return False
+        if cls.LOG_ROTATION_TYPE not in ['size', 'time']:
+            logger.error("❌ LOG_ROTATION_TYPE must be 'size' or 'time'")
+            is_valid = False
         
-        print("✅ Configuration is valid")
-        return True
+        return is_valid
     
     @classmethod
     def get_hyperliquid_config(cls) -> dict:

+ 69 - 2
src/hyperliquid_client.py

@@ -4,8 +4,7 @@ from typing import Optional, Dict, Any, List
 from hyperliquid import HyperliquidSync, HyperliquidAsync
 from config import Config
 
-# Set up logging
-logging.basicConfig(level=getattr(logging, Config.LOG_LEVEL))
+# Use existing logger setup (will be configured by main application)
 logger = logging.getLogger(__name__)
 
 class HyperliquidClient:
@@ -425,4 +424,72 @@ class HyperliquidClient:
             return order
         except Exception as e:
             logger.error(f"❌ Error placing take profit order: {e}")
+            return None
+
+    def get_recent_fills(self, limit: int = 100) -> Optional[List[Dict[str, Any]]]:
+        """
+        Get recent fills/trades for the account.
+        
+        Args:
+            limit: Maximum number of fills to return
+            
+        Returns:
+            List of recent fills/trades or None if error
+        """
+        try:
+            if not self.sync_client:
+                logger.error("❌ Client not initialized")
+                return None
+            
+            # Add user parameter for Hyperliquid CCXT compatibility
+            params = {}
+            if Config.HYPERLIQUID_PRIVATE_KEY:
+                wallet_address = Config.HYPERLIQUID_PRIVATE_KEY
+                params['user'] = f"0x{wallet_address}" if not wallet_address.startswith('0x') else wallet_address
+                
+            # Fetch recent trades/fills for the account
+            # Use fetch_my_trades to get account-specific trades
+            logger.debug(f"🔍 Fetching recent fills with params: {params}")
+            
+            # Get recent fills across all symbols
+            # We'll fetch trades for all symbols and merge them
+            try:
+                # Option 1: Try fetch_my_trades if available
+                fills = self.sync_client.fetch_my_trades(None, limit=limit, params=params)
+                logger.info(f"✅ Successfully fetched {len(fills)} recent fills")
+                return fills
+            except AttributeError:
+                # Option 2: If fetch_my_trades not available, try alternative approach
+                logger.debug("fetch_my_trades not available, trying alternative approach")
+                
+                # Get positions to determine active symbols
+                positions = self.get_positions()
+                if not positions:
+                    logger.info("No positions found, no recent fills to fetch")
+                    return []
+                
+                # Get symbols from positions
+                symbols = list(set([pos.get('symbol') for pos in positions if pos.get('symbol')]))
+                
+                all_fills = []
+                for symbol in symbols[:5]:  # Limit to 5 symbols to avoid too many requests
+                    try:
+                        symbol_trades = self.sync_client.fetch_my_trades(symbol, limit=limit//len(symbols), params=params)
+                        if symbol_trades:
+                            all_fills.extend(symbol_trades)
+                    except Exception as e:
+                        logger.warning(f"Could not fetch trades for {symbol}: {e}")
+                        continue
+                
+                # Sort by timestamp (newest first)
+                all_fills.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
+                
+                # Return only the requested limit
+                result = all_fills[:limit]
+                logger.info(f"✅ Successfully fetched {len(result)} recent fills from {len(symbols)} symbols")
+                return result
+                
+        except Exception as e:
+            logger.error(f"❌ Error fetching recent fills: {e}")
+            logger.debug(f"💡 Attempted with params: {params}")
             return None 

+ 241 - 0
src/logging_config.py

@@ -0,0 +1,241 @@
+#!/usr/bin/env python3
+"""
+Logging configuration module with rotation and cleanup support.
+"""
+
+import logging
+import logging.handlers
+import os
+from pathlib import Path
+from datetime import datetime, timedelta
+from typing import Optional
+from config import Config
+
+
+class LoggingManager:
+    """Manages logging configuration with rotation and cleanup."""
+    
+    def __init__(self):
+        """Initialize the logging manager."""
+        self.logger = None
+        self.file_handler = None
+        self.console_handler = None
+        
+    def setup_logging(self) -> logging.Logger:
+        """
+        Set up comprehensive logging with rotation and cleanup.
+        
+        Returns:
+            Configured logger instance
+        """
+        # Create logs directory if it doesn't exist
+        if Config.LOG_TO_FILE:
+            log_dir = Path(Config.LOG_FILE_PATH).parent
+            log_dir.mkdir(parents=True, exist_ok=True)
+        
+        # Create root logger
+        logger = logging.getLogger()
+        logger.setLevel(getattr(logging, Config.LOG_LEVEL.upper()))
+        
+        # Clear any existing handlers
+        logger.handlers.clear()
+        
+        # Create formatter
+        formatter = logging.Formatter(
+            fmt='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
+            datefmt='%Y-%m-%d %H:%M:%S'
+        )
+        
+        # Set up console handler
+        self.console_handler = logging.StreamHandler()
+        self.console_handler.setLevel(getattr(logging, Config.LOG_LEVEL.upper()))
+        self.console_handler.setFormatter(formatter)
+        logger.addHandler(self.console_handler)
+        
+        # Set up file handler with rotation (if enabled)
+        if Config.LOG_TO_FILE:
+            if Config.LOG_ROTATION_TYPE == 'size':
+                self.file_handler = logging.handlers.RotatingFileHandler(
+                    filename=Config.LOG_FILE_PATH,
+                    maxBytes=Config.LOG_MAX_SIZE_MB * 1024 * 1024,  # Convert MB to bytes
+                    backupCount=Config.LOG_BACKUP_COUNT,
+                    encoding='utf-8'
+                )
+            else:  # time-based rotation
+                self.file_handler = logging.handlers.TimedRotatingFileHandler(
+                    filename=Config.LOG_FILE_PATH,
+                    when=Config.LOG_ROTATION_INTERVAL,
+                    backupCount=Config.LOG_BACKUP_COUNT,
+                    encoding='utf-8'
+                )
+            
+            self.file_handler.setLevel(getattr(logging, Config.LOG_LEVEL.upper()))
+            self.file_handler.setFormatter(formatter)
+            logger.addHandler(self.file_handler)
+        
+        # Store reference
+        self.logger = logger
+        
+        # Log the configuration
+        logger.info("🔧 Logging system initialized")
+        logger.info(f"📊 Log Level: {Config.LOG_LEVEL}")
+        logger.info(f"📁 Log to File: {Config.LOG_TO_FILE}")
+        
+        if Config.LOG_TO_FILE:
+            logger.info(f"📂 Log File: {Config.LOG_FILE_PATH}")
+            logger.info(f"🔄 Rotation Type: {Config.LOG_ROTATION_TYPE}")
+            logger.info(f"📏 Max Size: {Config.LOG_MAX_SIZE_MB}MB (size rotation)")
+            logger.info(f"🗃️ Backup Count: {Config.LOG_BACKUP_COUNT}")
+            logger.info(f"⏰ Rotation Interval: {Config.LOG_ROTATION_INTERVAL} (time rotation)")
+        
+        return logger
+    
+    def cleanup_old_logs(self, days_to_keep: int = 30) -> None:
+        """
+        Clean up old log files beyond the backup count.
+        
+        Args:
+            days_to_keep: Number of days worth of logs to keep
+        """
+        if not Config.LOG_TO_FILE:
+            return
+        
+        try:
+            log_dir = Path(Config.LOG_FILE_PATH).parent
+            log_base_name = Path(Config.LOG_FILE_PATH).stem
+            
+            # Find all log files
+            log_files = []
+            for file_path in log_dir.glob(f"{log_base_name}*"):
+                if file_path.is_file() and file_path.suffix in ['.log', '.log.1', '.log.2']:
+                    log_files.append(file_path)
+            
+            # Sort by modification time (oldest first)
+            log_files.sort(key=lambda x: x.stat().st_mtime)
+            
+            # Calculate cutoff date
+            cutoff_date = datetime.now() - timedelta(days=days_to_keep)
+            
+            # Remove old files
+            removed_count = 0
+            for log_file in log_files:
+                file_date = datetime.fromtimestamp(log_file.stat().st_mtime)
+                
+                if file_date < cutoff_date:
+                    try:
+                        log_file.unlink()
+                        removed_count += 1
+                        if self.logger:
+                            self.logger.info(f"🗑️ Removed old log file: {log_file.name}")
+                    except OSError as e:
+                        if self.logger:
+                            self.logger.warning(f"⚠️ Could not remove {log_file.name}: {e}")
+            
+            if removed_count > 0 and self.logger:
+                self.logger.info(f"🧹 Cleanup complete: removed {removed_count} old log files")
+            
+        except Exception as e:
+            if self.logger:
+                self.logger.error(f"❌ Error during log cleanup: {e}")
+    
+    def get_log_stats(self) -> dict:
+        """
+        Get statistics about current log files.
+        
+        Returns:
+            Dictionary with log file statistics
+        """
+        stats = {
+            'log_to_file': Config.LOG_TO_FILE,
+            'log_file_path': Config.LOG_FILE_PATH,
+            'current_size_mb': 0,
+            'backup_files': 0,
+            'total_size_mb': 0
+        }
+        
+        if not Config.LOG_TO_FILE:
+            return stats
+        
+        try:
+            log_dir = Path(Config.LOG_FILE_PATH).parent
+            log_base_name = Path(Config.LOG_FILE_PATH).stem
+            
+            # Main log file
+            main_log = Path(Config.LOG_FILE_PATH)
+            if main_log.exists():
+                stats['current_size_mb'] = main_log.stat().st_size / (1024 * 1024)
+                stats['total_size_mb'] = stats['current_size_mb']
+            
+            # Backup files
+            backup_files = list(log_dir.glob(f"{log_base_name}*.log.*"))
+            stats['backup_files'] = len(backup_files)
+            
+            # Total size
+            for backup_file in backup_files:
+                if backup_file.exists():
+                    stats['total_size_mb'] += backup_file.stat().st_size / (1024 * 1024)
+            
+        except Exception as e:
+            if self.logger:
+                self.logger.error(f"❌ Error getting log stats: {e}")
+        
+        return stats
+    
+    def format_log_stats(self, stats: Optional[dict] = None) -> str:
+        """
+        Format log statistics for display.
+        
+        Args:
+            stats: Log statistics dictionary (will fetch if None)
+            
+        Returns:
+            Formatted string with log statistics
+        """
+        if stats is None:
+            stats = self.get_log_stats()
+        
+        if not stats['log_to_file']:
+            return "📊 <b>Logging:</b> Console only"
+        
+        return f"""📊 <b>Log File Statistics:</b>
+• File: {Path(stats['log_file_path']).name}
+• Current Size: {stats['current_size_mb']:.2f} MB
+• Backup Files: {stats['backup_files']}
+• Total Size: {stats['total_size_mb']:.2f} MB
+• Max Size: {Config.LOG_MAX_SIZE_MB} MB
+• Rotation: {Config.LOG_ROTATION_TYPE}
+• Backups Kept: {Config.LOG_BACKUP_COUNT}"""
+
+
+# Global logging manager instance
+logging_manager = LoggingManager()
+
+
+def setup_logging() -> logging.Logger:
+    """
+    Set up logging configuration.
+    
+    Returns:
+        Configured logger instance
+    """
+    return logging_manager.setup_logging()
+
+
+def cleanup_logs(days_to_keep: int = 30) -> None:
+    """
+    Clean up old log files.
+    
+    Args:
+        days_to_keep: Number of days worth of logs to keep
+    """
+    logging_manager.cleanup_old_logs(days_to_keep)
+
+
+def get_log_stats() -> dict:
+    """Get log file statistics."""
+    return logging_manager.get_log_stats()
+
+
+def format_log_stats(stats: Optional[dict] = None) -> str:
+    """Format log statistics for display."""
+    return logging_manager.format_log_stats(stats) 

+ 508 - 59
src/telegram_bot.py

@@ -9,20 +9,18 @@ with comprehensive statistics tracking and phone-friendly controls.
 import logging
 import asyncio
 import re
-from datetime import datetime
+from datetime import datetime, timedelta
 from typing import Optional, Dict, Any
 from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
 from telegram.ext import Application, CommandHandler, CallbackQueryHandler, ContextTypes, MessageHandler, filters
 from hyperliquid_client import HyperliquidClient
 from trading_stats import TradingStats
 from config import Config
+from alarm_manager import AlarmManager
+from logging_config import setup_logging, cleanup_logs, format_log_stats
 
-# Set up logging
-logging.basicConfig(
-    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
-    level=getattr(logging, Config.LOG_LEVEL)
-)
-logger = logging.getLogger(__name__)
+# Set up logging using the new configuration system
+logger = setup_logging().getChild(__name__)
 
 class TelegramTradingBot:
     """Telegram bot for manual trading with comprehensive statistics."""
@@ -31,6 +29,7 @@ class TelegramTradingBot:
         """Initialize the Telegram trading bot."""
         self.client = HyperliquidClient(use_testnet=Config.HYPERLIQUID_TESTNET)
         self.stats = TradingStats()
+        self.alarm_manager = AlarmManager()
         self.authorized_chat_id = Config.TELEGRAM_CHAT_ID
         self.application = None
         
@@ -39,6 +38,9 @@ class TelegramTradingBot:
         self.last_known_orders = set()  # Track order IDs we've seen
         self.last_known_positions = {}  # Track position sizes for P&L calculation
         
+        # External trade monitoring
+        self.last_processed_trade_time = None  # Track last processed external trade
+        
         # Initialize stats with current balance
         self._initialize_stats()
         
@@ -90,8 +92,10 @@ Tap the buttons below for instant access to key functions.
 /stats - Trading statistics
 
 <b>📊 Market Commands:</b>
-/market - Market data
-/price - Current price
+/market - Market data (default token)
+/market SOL - Market data for SOL
+/price - Current price (default token)
+/price BTC - Price for BTC
 
 <b>🚀 Perps Trading:</b>
 • /long BTC 100 - Long BTC with $100 USDC (Market Order)
@@ -114,16 +118,32 @@ Tap the buttons below for instant access to key functions.
 /performance - Performance metrics
 /risk - Risk analysis
 
-<b>🔄 Automatic Notifications:</b>
+<b>🔔 Price Alerts:</b>
+• /alarm - List all alarms
+• /alarm BTC 50000 - Set alarm for BTC at $50,000
+• /alarm BTC - Show BTC alarms only
+• /alarm 3 - Remove alarm ID 3
+
+<b>🔄 Automatic Monitoring:</b>
 • Real-time order fill alerts
 • Position opened/closed notifications  
 • P&L calculations on trade closure
-• 30-second monitoring interval
+• Price alarm triggers
+• External trade detection & sync
+• Auto stats synchronization
+• {Config.BOT_HEARTBEAT_SECONDS}-second monitoring interval
+
+<b>📊 Universal Trade Tracking:</b>
+• Bot trades: Full logging & notifications
+• Platform trades: Auto-detected & synced
+• Mobile app trades: Monitored & recorded
+• API trades: Tracked & included in stats
 
 Type /help for detailed command information.
 
 <b>🔄 Order Monitoring:</b>
 • /monitoring - View monitoring status
+• /logs - View log file statistics and cleanup
 
 <b>⚙️ Configuration:</b>
 • Symbol: {symbol}
@@ -186,8 +206,10 @@ For support, contact your bot administrator.
 • /orders - Show open orders
 
 <b>📊 Market Data:</b>
-• /market - Detailed market data
-• /price - Quick price check
+• /market - Detailed market data (default token)
+• /market BTC - Market data for specific token
+• /price - Quick price check (default token)
+• /price SOL - Price for specific token
 
 <b>🚀 Perps Trading:</b>
 • /long BTC 100 - Long BTC with $100 USDC (Market Order)
@@ -211,8 +233,15 @@ For support, contact your bot administrator.
 • /risk - Sharpe ratio, drawdown, VaR
 • /trades - Recent trade history
 
+<b>🔔 Price Alerts:</b>
+• /alarm - List all active alarms
+• /alarm BTC 50000 - Set alarm for BTC at $50,000
+• /alarm BTC - Show all BTC alarms
+• /alarm 3 - Remove alarm ID 3
+
 <b>🔄 Order Monitoring:</b>
 • /monitoring - View monitoring status
+• /logs - View log file statistics and cleanup
 
 <b>⚙️ Configuration:</b>
 • Symbol: {symbol}
@@ -457,35 +486,83 @@ For support, contact your bot administrator.
             await update.message.reply_text("❌ Unauthorized access.")
             return
         
-        symbol = Config.DEFAULT_TRADING_SYMBOL
+        # Check if token is provided as argument
+        if context.args and len(context.args) >= 1:
+            symbol = context.args[0].upper()
+        else:
+            symbol = Config.DEFAULT_TRADING_SYMBOL
+        
         market_data = self.client.get_market_data(symbol)
         
-        if market_data:
-            ticker = market_data['ticker']
-            orderbook = market_data['orderbook']
-            
-            # Calculate 24h change
-            current_price = float(ticker.get('last', 0))
-            high_24h = float(ticker.get('high', 0))
-            low_24h = float(ticker.get('low', 0))
-            
-            market_text = f"📊 <b>Market Data - {symbol}</b>\n\n"
-            market_text += f"💵 <b>Current Price:</b> ${current_price:,.2f}\n"
-            market_text += f"📈 <b>24h High:</b> ${high_24h:,.2f}\n"
-            market_text += f"📉 <b>24h Low:</b> ${low_24h:,.2f}\n"
-            market_text += f"📊 <b>24h Volume:</b> {ticker.get('baseVolume', 'N/A')}\n\n"
-            
-            if orderbook.get('bids') and orderbook.get('asks'):
-                best_bid = float(orderbook['bids'][0][0]) if orderbook['bids'] else 0
-                best_ask = float(orderbook['asks'][0][0]) if orderbook['asks'] else 0
-                spread = best_ask - best_bid
-                spread_percent = (spread / best_ask * 100) if best_ask > 0 else 0
+        if market_data and market_data.get('ticker'):
+            try:
+                ticker = market_data['ticker']
+                orderbook = market_data.get('orderbook', {})
+                
+                # Safely extract ticker data with fallbacks
+                current_price = float(ticker.get('last') or 0)
+                high_24h = float(ticker.get('high') or 0)
+                low_24h = float(ticker.get('low') or 0)
+                volume_24h = ticker.get('baseVolume') or ticker.get('volume') or 'N/A'
                 
-                market_text += f"🟢 <b>Best Bid:</b> ${best_bid:,.2f}\n"
-                market_text += f"🔴 <b>Best Ask:</b> ${best_ask:,.2f}\n"
-                market_text += f"📏 <b>Spread:</b> ${spread:.2f} ({spread_percent:.3f}%)\n"
+                market_text = f"📊 <b>Market Data - {symbol}</b>\n\n"
+                
+                if current_price > 0:
+                    market_text += f"💵 <b>Current Price:</b> ${current_price:,.2f}\n"
+                else:
+                    market_text += f"💵 <b>Current Price:</b> N/A\n"
+                
+                if high_24h > 0:
+                    market_text += f"📈 <b>24h High:</b> ${high_24h:,.2f}\n"
+                else:
+                    market_text += f"📈 <b>24h High:</b> N/A\n"
+                
+                if low_24h > 0:
+                    market_text += f"📉 <b>24h Low:</b> ${low_24h:,.2f}\n"
+                else:
+                    market_text += f"📉 <b>24h Low:</b> N/A\n"
+                
+                market_text += f"📊 <b>24h Volume:</b> {volume_24h}\n\n"
+                
+                # Handle orderbook data safely
+                if orderbook and orderbook.get('bids') and orderbook.get('asks'):
+                    try:
+                        bids = orderbook.get('bids', [])
+                        asks = orderbook.get('asks', [])
+                        
+                        if bids and asks and len(bids) > 0 and len(asks) > 0:
+                            best_bid = float(bids[0][0]) if bids[0][0] else 0
+                            best_ask = float(asks[0][0]) if asks[0][0] else 0
+                            
+                            if best_bid > 0 and best_ask > 0:
+                                spread = best_ask - best_bid
+                                spread_percent = (spread / best_ask * 100) if best_ask > 0 else 0
+                                
+                                market_text += f"🟢 <b>Best Bid:</b> ${best_bid:,.2f}\n"
+                                market_text += f"🔴 <b>Best Ask:</b> ${best_ask:,.2f}\n"
+                                market_text += f"📏 <b>Spread:</b> ${spread:.2f} ({spread_percent:.3f}%)\n"
+                            else:
+                                market_text += f"📋 <b>Orderbook:</b> Data unavailable\n"
+                        else:
+                            market_text += f"📋 <b>Orderbook:</b> No orders available\n"
+                    except (IndexError, ValueError, TypeError) as e:
+                        market_text += f"📋 <b>Orderbook:</b> Error parsing data\n"
+                else:
+                    market_text += f"📋 <b>Orderbook:</b> Not available\n"
+                
+                # Add usage hint
+                market_text += f"\n💡 <b>Usage:</b> <code>/market {symbol}</code> or <code>/market</code> for default"
+                
+            except (ValueError, TypeError) as e:
+                market_text = f"❌ <b>Error parsing market data</b>\n\n"
+                market_text += f"🔧 Raw data received but couldn't parse values.\n"
+                market_text += f"📞 Please try again or contact support if this persists."
         else:
-            market_text = "❌ Could not fetch market data"
+            market_text = f"❌ <b>Could not fetch market data for {symbol}</b>\n\n"
+            market_text += f"🔄 Please try again in a moment.\n"
+            market_text += f"🌐 Check your network connection.\n"
+            market_text += f"📡 API may be temporarily unavailable.\n\n"
+            market_text += f"💡 <b>Usage:</b> <code>/market BTC</code>, <code>/market ETH</code>, <code>/market SOL</code>, etc."
         
         await update.message.reply_text(market_text, parse_mode='HTML')
     
@@ -495,18 +572,37 @@ For support, contact your bot administrator.
             await update.message.reply_text("❌ Unauthorized access.")
             return
         
-        symbol = Config.DEFAULT_TRADING_SYMBOL
+        # Check if token is provided as argument
+        if context.args and len(context.args) >= 1:
+            symbol = context.args[0].upper()
+        else:
+            symbol = Config.DEFAULT_TRADING_SYMBOL
+        
         market_data = self.client.get_market_data(symbol)
         
-        if market_data:
-            price = float(market_data['ticker'].get('last', 0))
-            price_text = f"💵 <b>{symbol}</b>: ${price:,.2f}"
-            
-            # Add timestamp
-            timestamp = datetime.now().strftime('%H:%M:%S')
-            price_text += f"\n⏰ <i>Updated: {timestamp}</i>"
+        if market_data and market_data.get('ticker'):
+            try:
+                ticker = market_data['ticker']
+                price_value = ticker.get('last')
+                
+                if price_value is not None:
+                    price = float(price_value)
+                    price_text = f"💵 <b>{symbol}</b>: ${price:,.2f}"
+                    
+                    # Add timestamp
+                    timestamp = datetime.now().strftime('%H:%M:%S')
+                    price_text += f"\n⏰ <i>Updated: {timestamp}</i>"
+                    
+                    # Add usage hint
+                    price_text += f"\n💡 <i>Usage: </i><code>/price {symbol}</code><i> or </i><code>/price</code><i> for default</i>"
+                else:
+                    price_text = f"💵 <b>{symbol}</b>: Price not available\n⚠️ <i>Data temporarily unavailable</i>"
+                    
+            except (ValueError, TypeError) as e:
+                price_text = f"❌ <b>Error parsing price for {symbol}</b>\n🔧 <i>Please try again</i>"
         else:
-            price_text = f"❌ Could not fetch price for {symbol}"
+            price_text = f"❌ <b>Could not fetch price for {symbol}</b>\n🔄 <i>Please try again in a moment</i>\n\n"
+            price_text += f"💡 <b>Usage:</b> <code>/price BTC</code>, <code>/price ETH</code>, <code>/price SOL</code>, etc."
         
         await update.message.reply_text(price_text, parse_mode='HTML')
     
@@ -953,6 +1049,8 @@ For support, contact your bot administrator.
         self.application.add_handler(CommandHandler("sl", self.sl_command))
         self.application.add_handler(CommandHandler("tp", self.tp_command))
         self.application.add_handler(CommandHandler("monitoring", self.monitoring_command))
+        self.application.add_handler(CommandHandler("alarm", self.alarm_command))
+        self.application.add_handler(CommandHandler("logs", self.logs_command))
         
         # Callback query handler for inline keyboards
         self.application.add_handler(CallbackQueryHandler(self.button_callback))
@@ -988,11 +1086,22 @@ For support, contact your bot administrator.
                 f"✅ Connected to Hyperliquid {'Testnet' if Config.HYPERLIQUID_TESTNET else 'Mainnet'}\n"
                 f"📊 Default Symbol: {Config.DEFAULT_TRADING_SYMBOL}\n"
                 f"📱 Manual trading ready!\n"
-                f"🔄 Order monitoring: Active\n"
+                f"🔄 Order monitoring: Active ({Config.BOT_HEARTBEAT_SECONDS}s interval)\n"
+                f"🔄 External trade monitoring: Active\n"
+                f"🔔 Price alarms: Active\n"
+                f"📊 Auto stats sync: Enabled\n"
+                f"📝 Logging: {'File + Console' if Config.LOG_TO_FILE else 'Console only'}\n"
                 f"⏰ Started at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
                 "Use /start for quick actions or /help for all commands."
             )
             
+            # Perform initial log cleanup
+            try:
+                cleanup_logs(days_to_keep=30)
+                logger.info("🧹 Initial log cleanup completed")
+            except Exception as e:
+                logger.warning(f"⚠️ Initial log cleanup failed: {e}")
+            
             # Start the application
             await self.application.start()
             
@@ -1696,17 +1805,17 @@ This will place a limit {exit_side} order at ${profit_price:,.2f} to capture pro
             logger.error(f"❌ Error initializing order tracking: {e}")
     
     async def _order_monitoring_loop(self):
-        """Main monitoring loop that runs every 30 seconds."""
+        """Main monitoring loop that runs every Config.BOT_HEARTBEAT_SECONDS seconds."""
         while self.monitoring_active:
             try:
                 await self._check_order_fills()
-                await asyncio.sleep(30)  # Wait 30 seconds
+                await asyncio.sleep(Config.BOT_HEARTBEAT_SECONDS)  # Use configurable interval
             except asyncio.CancelledError:
                 logger.info("🛑 Order monitoring cancelled")
                 break
             except Exception as e:
                 logger.error(f"❌ Error in order monitoring loop: {e}")
-                await asyncio.sleep(30)  # Continue monitoring even if there's an error
+                await asyncio.sleep(Config.BOT_HEARTBEAT_SECONDS)  # Continue monitoring even if there's an error
     
     async def _check_order_fills(self):
         """Check for filled orders and send notifications."""
@@ -1729,9 +1838,168 @@ This will place a limit {exit_side} order at ${profit_price:,.2f} to capture pro
             self.last_known_orders = current_order_ids
             await self._update_position_tracking(current_positions)
             
+            # Check price alarms
+            await self._check_price_alarms()
+            
+            # Check external trades (trades made outside the bot)
+            await self._check_external_trades()
+            
         except Exception as e:
             logger.error(f"❌ Error checking order fills: {e}")
-    
+
+    async def _check_price_alarms(self):
+        """Check all active price alarms."""
+        try:
+            # Get all active alarms
+            active_alarms = self.alarm_manager.get_all_active_alarms()
+            if not active_alarms:
+                return
+            
+            # Get unique tokens from alarms
+            tokens_to_check = list(set(alarm['token'] for alarm in active_alarms))
+            
+            # Fetch current prices for all tokens
+            price_data = {}
+            for token in tokens_to_check:
+                symbol = f"{token}/USDC:USDC"
+                market_data = self.client.get_market_data(symbol)
+                
+                if market_data and market_data.get('ticker'):
+                    current_price = market_data['ticker'].get('last')
+                    if current_price is not None:
+                        price_data[token] = float(current_price)
+            
+            # Check alarms against current prices
+            triggered_alarms = self.alarm_manager.check_alarms(price_data)
+            
+            # Send notifications for triggered alarms
+            for alarm in triggered_alarms:
+                await self._send_alarm_notification(alarm)
+                
+        except Exception as e:
+            logger.error(f"❌ Error checking price alarms: {e}")
+
+    async def _send_alarm_notification(self, alarm: Dict[str, Any]):
+        """Send notification for triggered alarm."""
+        try:
+            message = self.alarm_manager.format_triggered_alarm(alarm)
+            await self.send_message(message)
+            logger.info(f"📢 Sent alarm notification: {alarm['token']} ID {alarm['id']} @ ${alarm['triggered_price']}")
+        except Exception as e:
+            logger.error(f"❌ Error sending alarm notification: {e}")
+
+    async def _check_external_trades(self):
+        """Check for trades made outside the Telegram bot and update stats."""
+        try:
+            # Get recent fills from Hyperliquid
+            recent_fills = self.client.get_recent_fills()
+            
+            if not recent_fills:
+                return
+            
+            # Initialize last processed time if first run
+            if self.last_processed_trade_time is None:
+                # Set to current time minus 1 hour to catch recent activity
+                self.last_processed_trade_time = (datetime.now() - timedelta(hours=1)).isoformat()
+            
+            # Filter for new trades since last check
+            new_trades = []
+            latest_trade_time = self.last_processed_trade_time
+            
+            for fill in recent_fills:
+                fill_time = fill.get('timestamp')
+                if fill_time and fill_time > self.last_processed_trade_time:
+                    new_trades.append(fill)
+                    if fill_time > latest_trade_time:
+                        latest_trade_time = fill_time
+            
+            if not new_trades:
+                return
+            
+            # Process new trades
+            for trade in new_trades:
+                await self._process_external_trade(trade)
+            
+            # Update last processed time
+            self.last_processed_trade_time = latest_trade_time
+            
+            if new_trades:
+                logger.info(f"📊 Processed {len(new_trades)} external trades")
+                
+        except Exception as e:
+            logger.error(f"❌ Error checking external trades: {e}")
+
+    async def _process_external_trade(self, trade: Dict[str, Any]):
+        """Process an individual external trade."""
+        try:
+            # Extract trade information
+            symbol = trade.get('symbol', '')
+            side = trade.get('side', '')
+            amount = float(trade.get('amount', 0))
+            price = float(trade.get('price', 0))
+            trade_id = trade.get('id', 'external')
+            timestamp = trade.get('timestamp', '')
+            
+            if not all([symbol, side, amount, price]):
+                return
+            
+            # Record trade in stats
+            self.stats.record_trade(symbol, side, amount, price, trade_id)
+            
+            # Send notification for significant trades
+            await self._send_external_trade_notification(trade)
+            
+            logger.info(f"📋 Processed external trade: {side} {amount} {symbol} @ ${price}")
+            
+        except Exception as e:
+            logger.error(f"❌ Error processing external trade: {e}")
+
+    async def _send_external_trade_notification(self, trade: Dict[str, Any]):
+        """Send notification for external trades."""
+        try:
+            symbol = trade.get('symbol', '')
+            side = trade.get('side', '')
+            amount = float(trade.get('amount', 0))
+            price = float(trade.get('price', 0))
+            timestamp = trade.get('timestamp', '')
+            
+            # Extract token from symbol
+            token = symbol.split('/')[0] if '/' in symbol else symbol
+            
+            # Format timestamp
+            try:
+                trade_time = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
+                time_str = trade_time.strftime('%H:%M:%S')
+            except:
+                time_str = "Unknown"
+            
+            # Determine trade type and emoji
+            side_emoji = "🟢" if side.lower() == 'buy' else "🔴"
+            trade_value = amount * price
+            
+            message = f"""
+🔄 <b>External Trade Detected</b>
+
+📊 <b>Trade Details:</b>
+• Token: {token}
+• Side: {side.upper()}
+• Amount: {amount} {token}
+• Price: ${price:,.2f}
+• Value: ${trade_value:,.2f}
+
+{side_emoji} <b>Source:</b> Direct Platform Trade
+⏰ <b>Time:</b> {time_str}
+
+📈 <b>Note:</b> This trade was executed outside the Telegram bot
+📊 Stats have been automatically updated
+            """
+            
+            await self.send_message(message.strip())
+            logger.info(f"📢 Sent external trade notification: {side} {amount} {token}")
+            
+        except Exception as e:
+            logger.error(f"❌ Error sending external trade notification: {e}")
+
     async def _process_filled_orders(self, filled_order_ids: set, current_positions: list):
         """Process filled orders and determine if they opened or closed positions."""
         try:
@@ -1928,20 +2196,35 @@ This will place a limit {exit_side} order at ${profit_price:,.2f} to capture pro
             await update.message.reply_text("❌ Unauthorized access.")
             return
         
+        # Get alarm statistics
+        alarm_stats = self.alarm_manager.get_statistics()
+        
         status_text = f"""
-🔄 <b>Order Monitoring Status</b>
+🔄 <b>System Monitoring Status</b>
 
-📊 <b>Current Status:</b>
+📊 <b>Order Monitoring:</b>
 • Active: {'✅ Yes' if self.monitoring_active else '❌ No'}
-• Check Interval: 30 seconds
+• Check Interval: {Config.BOT_HEARTBEAT_SECONDS} seconds
 • Orders Tracked: {len(self.last_known_orders)}
 • Positions Tracked: {len(self.last_known_positions)}
 
+🔔 <b>Price Alarms:</b>
+• Active Alarms: {alarm_stats['total_active']}
+• Triggered Today: {alarm_stats['total_triggered']}
+• Tokens Monitored: {alarm_stats['tokens_tracked']}
+• Next Alarm ID: {alarm_stats['next_id']}
+
+🔄 <b>External Trade Monitoring:</b>
+• Last Check: {self.last_processed_trade_time or 'Not started'}
+• Auto Stats Update: ✅ Enabled
+• External Notifications: ✅ Enabled
+
 📈 <b>Notifications:</b>
-• 🚀 Position Opened
-• 📈 Position Increased
-• 📉 Position Partially Closed
-• 🎯 Position Closed (with P&L)
+• 🚀 Position Opened/Increased
+• 📉 Position Partially/Fully Closed
+• 🎯 P&L Calculations
+• 🔔 Price Alarm Triggers
+• 🔄 External Trade Detection
 
 ⏰ <b>Last Check:</b> {datetime.now().strftime('%H:%M:%S')}
 
@@ -1949,11 +2232,177 @@ This will place a limit {exit_side} order at ${profit_price:,.2f} to capture pro
 • Real-time order fill detection
 • Automatic P&L calculation
 • Position change tracking
+• Price alarm monitoring
+• External trade monitoring
+• Auto stats synchronization
 • Instant Telegram notifications
         """
         
+        if alarm_stats['token_breakdown']:
+            status_text += f"\n\n📋 <b>Active Alarms by Token:</b>\n"
+            for token, count in alarm_stats['token_breakdown'].items():
+                status_text += f"• {token}: {count} alarm{'s' if count != 1 else ''}\n"
+        
         await update.message.reply_text(status_text.strip(), parse_mode='HTML')
 
+    async def alarm_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+        """Handle the /alarm command for price alerts."""
+        if not self.is_authorized(update.effective_chat.id):
+            await update.message.reply_text("❌ Unauthorized access.")
+            return
+        
+        try:
+            if not context.args or len(context.args) == 0:
+                # No arguments - list all alarms
+                alarms = self.alarm_manager.get_all_active_alarms()
+                message = self.alarm_manager.format_alarm_list(alarms)
+                await update.message.reply_text(message, parse_mode='HTML')
+                return
+            
+            elif len(context.args) == 1:
+                arg = context.args[0]
+                
+                # Check if argument is a number (alarm ID to remove)
+                try:
+                    alarm_id = int(arg)
+                    # Remove alarm by ID
+                    if self.alarm_manager.remove_alarm(alarm_id):
+                        await update.message.reply_text(f"✅ Alarm ID {alarm_id} has been removed.")
+                    else:
+                        await update.message.reply_text(f"❌ Alarm ID {alarm_id} not found.")
+                    return
+                except ValueError:
+                    # Not a number, treat as token
+                    token = arg.upper()
+                    alarms = self.alarm_manager.get_alarms_by_token(token)
+                    message = self.alarm_manager.format_alarm_list(alarms, f"{token} Price Alarms")
+                    await update.message.reply_text(message, parse_mode='HTML')
+                    return
+            
+            elif len(context.args) == 2:
+                # Set new alarm: /alarm TOKEN PRICE
+                token = context.args[0].upper()
+                target_price = float(context.args[1])
+                
+                # Get current market price
+                symbol = f"{token}/USDC:USDC"
+                market_data = self.client.get_market_data(symbol)
+                
+                if not market_data or not market_data.get('ticker'):
+                    await update.message.reply_text(f"❌ Could not fetch current price for {token}")
+                    return
+                
+                current_price = float(market_data['ticker'].get('last', 0))
+                if current_price <= 0:
+                    await update.message.reply_text(f"❌ Invalid current price for {token}")
+                    return
+                
+                # Create the alarm
+                alarm = self.alarm_manager.create_alarm(token, target_price, current_price)
+                
+                # Format confirmation message
+                direction_emoji = "📈" if alarm['direction'] == 'above' else "📉"
+                price_diff = abs(target_price - current_price)
+                price_diff_percent = (price_diff / current_price) * 100
+                
+                message = f"""
+✅ <b>Price Alarm Created</b>
+
+📊 <b>Alarm Details:</b>
+• Alarm ID: {alarm['id']}
+• Token: {token}
+• Target Price: ${target_price:,.2f}
+• Current Price: ${current_price:,.2f}
+• Direction: {alarm['direction'].upper()}
+
+{direction_emoji} <b>Alert Condition:</b>
+Will trigger when {token} price moves {alarm['direction']} ${target_price:,.2f}
+
+💰 <b>Price Difference:</b>
+• Distance: ${price_diff:,.2f} ({price_diff_percent:.2f}%)
+• Status: ACTIVE ✅
+
+⏰ <b>Created:</b> {datetime.now().strftime('%H:%M:%S')}
+
+💡 The alarm will be checked every {Config.BOT_HEARTBEAT_SECONDS} seconds and you'll receive a notification when triggered.
+                """
+                
+                await update.message.reply_text(message.strip(), parse_mode='HTML')
+                
+            else:
+                # Too many arguments
+                await update.message.reply_text(
+                    "❌ Invalid usage. Examples:\n\n"
+                    "• <code>/alarm</code> - List all alarms\n"
+                    "• <code>/alarm BTC</code> - List BTC alarms\n"
+                    "• <code>/alarm BTC 50000</code> - Set alarm for BTC at $50,000\n"
+                    "• <code>/alarm 3</code> - Remove alarm ID 3",
+                    parse_mode='HTML'
+                )
+                
+        except ValueError:
+            await update.message.reply_text("❌ Invalid price format. Please use numbers only.")
+        except Exception as e:
+            error_message = f"❌ Error processing alarm command: {str(e)}"
+            await update.message.reply_text(error_message)
+            logger.error(f"Error in alarm command: {e}")
+
+    async def logs_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+        """Handle the /logs command to show log file statistics and cleanup options."""
+        if not self.is_authorized(update.effective_chat.id):
+            await update.message.reply_text("❌ Unauthorized access.")
+            return
+        
+        try:
+            # Check for cleanup argument
+            if context.args and len(context.args) >= 1:
+                if context.args[0].lower() == 'cleanup':
+                    # Get days parameter (default 30)
+                    days_to_keep = 30
+                    if len(context.args) >= 2:
+                        try:
+                            days_to_keep = int(context.args[1])
+                        except ValueError:
+                            await update.message.reply_text("❌ Invalid number of days. Using default (30).")
+                    
+                    # Perform cleanup
+                    await update.message.reply_text(f"🧹 Cleaning up log files older than {days_to_keep} days...")
+                    cleanup_logs(days_to_keep)
+                    await update.message.reply_text(f"✅ Log cleanup completed!")
+                    return
+            
+            # Show log statistics
+            log_stats_text = format_log_stats()
+            
+            # Add additional info
+            status_text = f"""
+📊 <b>System Logging Status</b>
+
+{log_stats_text}
+
+📈 <b>Log Configuration:</b>
+• Log Level: {Config.LOG_LEVEL}
+• Heartbeat Interval: {Config.BOT_HEARTBEAT_SECONDS}s
+• Bot Uptime: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
+
+💡 <b>Log Management:</b>
+• <code>/logs cleanup</code> - Clean old logs (30 days)
+• <code>/logs cleanup 7</code> - Clean logs older than 7 days
+• Log rotation happens automatically
+• Old backups are removed automatically
+
+🔧 <b>Configuration:</b>
+• Rotation Type: {Config.LOG_ROTATION_TYPE}
+• Max Size: {Config.LOG_MAX_SIZE_MB}MB (size rotation)
+• Backup Count: {Config.LOG_BACKUP_COUNT}
+            """
+            
+            await update.message.reply_text(status_text.strip(), parse_mode='HTML')
+            
+        except Exception as e:
+            error_message = f"❌ Error processing logs command: {str(e)}"
+            await update.message.reply_text(error_message)
+            logger.error(f"Error in logs command: {e}")
 
 async def main_async():
     """Async main entry point for the Telegram bot."""

+ 58 - 0
test_heartbeat_config.py

@@ -0,0 +1,58 @@
+#!/usr/bin/env python3
+"""
+Test script to demonstrate configurable heartbeat interval
+"""
+
+import os
+import sys
+from pathlib import Path
+
+# Add the src directory to the path
+project_root = Path(__file__).parent
+sys.path.insert(0, str(project_root / 'src'))
+
+def test_heartbeat_config():
+    """Test the configurable heartbeat interval."""
+    print("🧪 Testing Configurable Bot Heartbeat")
+    print("=" * 50)
+    
+    # Test with different values
+    test_values = [
+        ("10", "Fast monitoring (10 seconds)"),
+        ("30", "Default monitoring (30 seconds)"), 
+        ("60", "Slow monitoring (60 seconds)"),
+        ("300", "Very slow monitoring (5 minutes)"),
+        ("3", "Too fast (should warn)"),
+        ("700", "Too slow (should warn)")
+    ]
+    
+    for value, description in test_values:
+        print(f"\n🔍 Testing: {description}")
+        
+        # Set environment variable
+        os.environ['BOT_HEARTBEAT_SECONDS'] = value
+        
+        # Reload config (simulate fresh import)
+        if 'config' in sys.modules:
+            del sys.modules['config']
+        
+        try:
+            from config import Config
+            
+            print(f"   📊 BOT_HEARTBEAT_SECONDS: {Config.BOT_HEARTBEAT_SECONDS}")
+            
+            # Test validation
+            is_valid = Config.validate()
+            print(f"   ✅ Validation: {'PASSED' if is_valid else 'FAILED'}")
+            
+            # Show what the monitoring message would say
+            print(f"   📱 Monitoring message: 'Order monitoring: Active ({Config.BOT_HEARTBEAT_SECONDS}s interval)'")
+            
+        except Exception as e:
+            print(f"   ❌ Error: {e}")
+    
+    print(f"\n🎉 Heartbeat configuration test complete!")
+    print(f"💡 Set BOT_HEARTBEAT_SECONDS in your .env file to customize the monitoring interval")
+
+if __name__ == "__main__":
+    test_heartbeat_config() 

+ 104 - 0
test_logging_system.py

@@ -0,0 +1,104 @@
+#!/usr/bin/env python3
+"""
+Test script to demonstrate the logging system with rotation and cleanup
+"""
+
+import os
+import sys
+import time
+from pathlib import Path
+
+# Add the src directory to the path
+project_root = Path(__file__).parent
+sys.path.insert(0, str(project_root / 'src'))
+
+def test_logging_system():
+    """Test the logging system with rotation and cleanup."""
+    print("🧪 Testing Advanced Logging System")
+    print("=" * 60)
+    
+    # Set up test environment
+    os.environ['LOG_TO_FILE'] = 'true'
+    os.environ['LOG_FILE_PATH'] = 'test_logs/bot_test.log'
+    os.environ['LOG_MAX_SIZE_MB'] = '1'  # Small size for testing
+    os.environ['LOG_BACKUP_COUNT'] = '3'
+    os.environ['LOG_ROTATION_TYPE'] = 'size'
+    os.environ['LOG_LEVEL'] = 'INFO'
+    
+    # Import after setting environment
+    from logging_config import setup_logging, cleanup_logs, get_log_stats, format_log_stats
+    
+    print("📊 Test Configuration:")
+    print(f"   Log File: test_logs/bot_test.log")
+    print(f"   Max Size: 1 MB")
+    print(f"   Backup Count: 3")
+    print(f"   Rotation Type: size")
+    print()
+    
+    # Initialize logging
+    print("🔧 Initializing logging system...")
+    logger = setup_logging()
+    print("✅ Logging system initialized")
+    print()
+    
+    # Generate lots of log entries to trigger rotation
+    print("📝 Generating log entries to test rotation...")
+    for i in range(1000):
+        logger.info(f"Test log message {i:04d} - This is a sample trading bot log entry with some details about market data, orders, and trading activity.")
+        
+        if i % 100 == 0:
+            print(f"   Generated {i} log entries...")
+    
+    print("✅ Log generation complete")
+    print()
+    
+    # Check log statistics
+    print("📊 Log File Statistics:")
+    stats = get_log_stats()
+    for key, value in stats.items():
+        if key == 'current_size_mb' or key == 'total_size_mb':
+            print(f"   {key}: {value:.2f} MB")
+        else:
+            print(f"   {key}: {value}")
+    print()
+    
+    # Show formatted stats
+    print("📋 Formatted Log Status:")
+    formatted_stats = format_log_stats()
+    print(formatted_stats)
+    print()
+    
+    # Test cleanup function
+    print("🧹 Testing log cleanup (keeping files from last 1 day)...")
+    cleanup_logs(days_to_keep=1)
+    print("✅ Cleanup test complete")
+    print()
+    
+    # Final statistics
+    print("📊 Final Statistics:")
+    final_stats = get_log_stats()
+    print(format_log_stats(final_stats))
+    print()
+    
+    # Show actual files created
+    test_log_dir = Path('test_logs')
+    if test_log_dir.exists():
+        print("📁 Created Log Files:")
+        for log_file in sorted(test_log_dir.glob('*.log*')):
+            size_mb = log_file.stat().st_size / (1024 * 1024)
+            print(f"   📄 {log_file.name} ({size_mb:.2f} MB)")
+    
+    print()
+    print("🎉 Logging system test complete!")
+    print()
+    print("💡 Key Features Demonstrated:")
+    print("   ✅ Automatic log rotation by file size")
+    print("   ✅ Backup file management") 
+    print("   ✅ Log cleanup functionality")
+    print("   ✅ Statistics and monitoring")
+    print("   ✅ Configurable via environment variables")
+    print()
+    print("🚀 Ready for production use!")
+
+if __name__ == "__main__":
+    test_logging_system() 

+ 257 - 0
tests/test_alarm_system.py

@@ -0,0 +1,257 @@
+#!/usr/bin/env python3
+"""
+Test suite for the Price Alarm System
+
+Tests alarm creation, management, triggering, and integration.
+"""
+
+import unittest
+import tempfile
+import os
+from datetime import datetime
+from alarm_manager import AlarmManager
+
+
+class TestAlarmManager(unittest.TestCase):
+    """Test the AlarmManager class."""
+    
+    def setUp(self):
+        """Set up test fixtures."""
+        # Use temporary file for testing
+        self.temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.json')
+        self.temp_file.close()
+        self.alarm_manager = AlarmManager(self.temp_file.name)
+    
+    def tearDown(self):
+        """Clean up after tests."""
+        # Remove temporary file
+        if os.path.exists(self.temp_file.name):
+            os.unlink(self.temp_file.name)
+    
+    def test_create_alarm_above(self):
+        """Test creating an alarm for price going above current price."""
+        # Current price $50, target $55 (above)
+        alarm = self.alarm_manager.create_alarm("BTC", 55.0, 50.0)
+        
+        self.assertEqual(alarm['id'], 1)
+        self.assertEqual(alarm['token'], 'BTC')
+        self.assertEqual(alarm['target_price'], 55.0)
+        self.assertEqual(alarm['current_price_when_set'], 50.0)
+        self.assertEqual(alarm['direction'], 'above')
+        self.assertEqual(alarm['status'], 'active')
+    
+    def test_create_alarm_below(self):
+        """Test creating an alarm for price going below current price."""
+        # Current price $50, target $45 (below)
+        alarm = self.alarm_manager.create_alarm("ETH", 45.0, 50.0)
+        
+        self.assertEqual(alarm['id'], 1)
+        self.assertEqual(alarm['token'], 'ETH')
+        self.assertEqual(alarm['target_price'], 45.0)
+        self.assertEqual(alarm['current_price_when_set'], 50.0)
+        self.assertEqual(alarm['direction'], 'below')
+        self.assertEqual(alarm['status'], 'active')
+    
+    def test_multiple_alarms_increment_id(self):
+        """Test that multiple alarms get incrementing IDs."""
+        alarm1 = self.alarm_manager.create_alarm("BTC", 55.0, 50.0)
+        alarm2 = self.alarm_manager.create_alarm("ETH", 3500.0, 3000.0)
+        alarm3 = self.alarm_manager.create_alarm("BTC", 45.0, 50.0)
+        
+        self.assertEqual(alarm1['id'], 1)
+        self.assertEqual(alarm2['id'], 2)
+        self.assertEqual(alarm3['id'], 3)
+    
+    def test_get_alarms_by_token(self):
+        """Test filtering alarms by token."""
+        self.alarm_manager.create_alarm("BTC", 55.0, 50.0)
+        self.alarm_manager.create_alarm("ETH", 3500.0, 3000.0)
+        self.alarm_manager.create_alarm("BTC", 45.0, 50.0)
+        
+        btc_alarms = self.alarm_manager.get_alarms_by_token("BTC")
+        eth_alarms = self.alarm_manager.get_alarms_by_token("ETH")
+        
+        self.assertEqual(len(btc_alarms), 2)
+        self.assertEqual(len(eth_alarms), 1)
+        self.assertEqual(btc_alarms[0]['token'], 'BTC')
+        self.assertEqual(eth_alarms[0]['token'], 'ETH')
+    
+    def test_remove_alarm(self):
+        """Test removing an alarm by ID."""
+        alarm = self.alarm_manager.create_alarm("BTC", 55.0, 50.0)
+        alarm_id = alarm['id']
+        
+        # Verify alarm exists
+        self.assertIsNotNone(self.alarm_manager.get_alarm_by_id(alarm_id))
+        
+        # Remove alarm
+        result = self.alarm_manager.remove_alarm(alarm_id)
+        self.assertTrue(result)
+        
+        # Verify alarm is gone
+        self.assertIsNone(self.alarm_manager.get_alarm_by_id(alarm_id))
+    
+    def test_remove_nonexistent_alarm(self):
+        """Test removing an alarm that doesn't exist."""
+        result = self.alarm_manager.remove_alarm(999)
+        self.assertFalse(result)
+    
+    def test_check_alarms_trigger_above(self):
+        """Test triggering an alarm when price goes above target."""
+        # Create alarm: trigger when BTC goes above $55
+        self.alarm_manager.create_alarm("BTC", 55.0, 50.0)
+        
+        # Price data: BTC is now $56 (above $55)
+        price_data = {"BTC": 56.0}
+        triggered = self.alarm_manager.check_alarms(price_data)
+        
+        self.assertEqual(len(triggered), 1)
+        self.assertEqual(triggered[0]['token'], 'BTC')
+        self.assertEqual(triggered[0]['triggered_price'], 56.0)
+        self.assertEqual(triggered[0]['status'], 'triggered')
+    
+    def test_check_alarms_trigger_below(self):
+        """Test triggering an alarm when price goes below target."""
+        # Create alarm: trigger when ETH goes below $45
+        self.alarm_manager.create_alarm("ETH", 45.0, 50.0)
+        
+        # Price data: ETH is now $44 (below $45)
+        price_data = {"ETH": 44.0}
+        triggered = self.alarm_manager.check_alarms(price_data)
+        
+        self.assertEqual(len(triggered), 1)
+        self.assertEqual(triggered[0]['token'], 'ETH')
+        self.assertEqual(triggered[0]['triggered_price'], 44.0)
+        self.assertEqual(triggered[0]['status'], 'triggered')
+    
+    def test_check_alarms_no_trigger(self):
+        """Test that alarms don't trigger when conditions aren't met."""
+        # Create alarm: trigger when BTC goes above $55
+        self.alarm_manager.create_alarm("BTC", 55.0, 50.0)
+        
+        # Price data: BTC is now $54 (still below $55)
+        price_data = {"BTC": 54.0}
+        triggered = self.alarm_manager.check_alarms(price_data)
+        
+        self.assertEqual(len(triggered), 0)
+        
+        # Verify alarm is still active
+        active_alarms = self.alarm_manager.get_all_active_alarms()
+        self.assertEqual(len(active_alarms), 1)
+        self.assertEqual(active_alarms[0]['status'], 'active')
+    
+    def test_check_alarms_exact_price(self):
+        """Test that alarms trigger at exact target price."""
+        # Create alarm: trigger when BTC goes above $55
+        self.alarm_manager.create_alarm("BTC", 55.0, 50.0)
+        
+        # Price data: BTC is exactly $55
+        price_data = {"BTC": 55.0}
+        triggered = self.alarm_manager.check_alarms(price_data)
+        
+        self.assertEqual(len(triggered), 1)
+        self.assertEqual(triggered[0]['triggered_price'], 55.0)
+    
+    def test_alarm_only_triggers_once(self):
+        """Test that triggered alarms don't trigger again."""
+        # Create alarm
+        self.alarm_manager.create_alarm("BTC", 55.0, 50.0)
+        
+        # Trigger alarm
+        price_data = {"BTC": 56.0}
+        triggered1 = self.alarm_manager.check_alarms(price_data)
+        self.assertEqual(len(triggered1), 1)
+        
+        # Try to trigger again with even higher price
+        price_data = {"BTC": 60.0}
+        triggered2 = self.alarm_manager.check_alarms(price_data)
+        self.assertEqual(len(triggered2), 0)  # Should not trigger again
+    
+    def test_multiple_alarms_selective_trigger(self):
+        """Test that only applicable alarms trigger."""
+        # Create multiple alarms
+        self.alarm_manager.create_alarm("BTC", 55.0, 50.0)  # Above $55
+        self.alarm_manager.create_alarm("ETH", 45.0, 50.0)  # Below $45
+        self.alarm_manager.create_alarm("BTC", 40.0, 50.0)  # Below $40
+        
+        # Price data: BTC $56 (triggers first), ETH $46 (doesn't trigger)
+        price_data = {"BTC": 56.0, "ETH": 46.0}
+        triggered = self.alarm_manager.check_alarms(price_data)
+        
+        self.assertEqual(len(triggered), 1)
+        self.assertEqual(triggered[0]['token'], 'BTC')
+        self.assertEqual(triggered[0]['target_price'], 55.0)
+    
+    def test_persistence(self):
+        """Test that alarms persist across manager instances."""
+        # Create alarm
+        alarm = self.alarm_manager.create_alarm("BTC", 55.0, 50.0)
+        alarm_id = alarm['id']
+        
+        # Create new manager instance with same file
+        new_manager = AlarmManager(self.temp_file.name)
+        
+        # Check that alarm persists
+        restored_alarm = new_manager.get_alarm_by_id(alarm_id)
+        self.assertIsNotNone(restored_alarm)
+        self.assertEqual(restored_alarm['token'], 'BTC')
+        self.assertEqual(restored_alarm['target_price'], 55.0)
+        self.assertEqual(restored_alarm['status'], 'active')
+    
+    def test_format_alarm_list_empty(self):
+        """Test formatting empty alarm list."""
+        message = self.alarm_manager.format_alarm_list([])
+        self.assertIn("No alarms found", message)
+    
+    def test_format_alarm_list_with_alarms(self):
+        """Test formatting alarm list with alarms."""
+        self.alarm_manager.create_alarm("BTC", 55.0, 50.0)
+        self.alarm_manager.create_alarm("ETH", 45.0, 50.0)
+        
+        alarms = self.alarm_manager.get_all_active_alarms()
+        message = self.alarm_manager.format_alarm_list(alarms)
+        
+        self.assertIn("BTC", message)
+        self.assertIn("ETH", message)
+        self.assertIn("ID 1", message)
+        self.assertIn("ID 2", message)
+        self.assertIn("55.00", message)
+        self.assertIn("45.00", message)
+    
+    def test_format_triggered_alarm(self):
+        """Test formatting triggered alarm notification."""
+        # Create and trigger alarm
+        self.alarm_manager.create_alarm("BTC", 55.0, 50.0)
+        price_data = {"BTC": 56.0}
+        triggered = self.alarm_manager.check_alarms(price_data)
+        
+        message = self.alarm_manager.format_triggered_alarm(triggered[0])
+        
+        self.assertIn("Price Alert Triggered", message)
+        self.assertIn("BTC", message)
+        self.assertIn("56.00", message)
+        self.assertIn("55.00", message)
+        self.assertIn("50.00", message)
+    
+    def test_statistics(self):
+        """Test alarm statistics."""
+        # Create some alarms
+        self.alarm_manager.create_alarm("BTC", 55.0, 50.0)
+        self.alarm_manager.create_alarm("BTC", 45.0, 50.0)
+        self.alarm_manager.create_alarm("ETH", 3500.0, 3000.0)
+        
+        # Trigger one alarm
+        price_data = {"BTC": 56.0}
+        self.alarm_manager.check_alarms(price_data)
+        
+        stats = self.alarm_manager.get_statistics()
+        
+        self.assertEqual(stats['total_active'], 2)  # 2 still active
+        self.assertEqual(stats['total_triggered'], 1)  # 1 triggered
+        self.assertEqual(stats['tokens_tracked'], 2)  # BTC and ETH
+        self.assertEqual(stats['token_breakdown']['BTC'], 1)  # 1 BTC alarm left
+        self.assertEqual(stats['token_breakdown']['ETH'], 1)  # 1 ETH alarm
+
+
+if __name__ == '__main__':
+    unittest.main()