Browse Source

Add copy trading configuration and validation

- Introduced new copy trading settings in the environment configuration and the Config class, allowing users to enable copy trading, set target addresses, and define portfolio allocation.
- Implemented validation for copy trading parameters to ensure correct configurations, including checks for target address format, portfolio percentage range, trading mode, leverage limits, and position size.
- Updated the monitoring coordinator to integrate the copy trading monitor, enabling its functionality based on user settings.
Carles Sentis 5 days ago
parent
commit
6caee961ec

+ 38 - 1
config/env.example

@@ -32,6 +32,43 @@ RISK_MANAGEMENT_ENABLED=true
 # Set to 100.0 to disable (would require -100% ROE = total loss)
 STOP_LOSS_PERCENTAGE=10.0
 
+# ========================================
+# Copy Trading Configuration
+# ========================================
+# Enable/disable copy trading functionality
+COPY_TRADING_ENABLED=true
+
+# Trader address to follow (from our analysis)
+COPY_TRADING_TARGET_ADDRESS=0x59f5371933249060bbe97462b297c840abc5c36e
+
+# Portfolio allocation percentage for copy trading (0.0 to 1.0)
+# 0.1 = 10% of account, 0.5 = 50% of account, 1.0 = 100% of account
+COPY_TRADING_PORTFOLIO_PERCENTAGE=0.1
+
+# Copy trading mode: PROPORTIONAL or FIXED
+# PROPORTIONAL: Scale position size based on target trader's portfolio percentage
+# FIXED: Use fixed percentage of your account regardless of their position size
+COPY_TRADING_MODE=PROPORTIONAL
+
+# Maximum leverage to use when copying (safety limit)
+# Will use target trader's leverage but cap it at this value
+COPY_TRADING_MAX_LEVERAGE=10
+
+# Minimum position size in USD (to avoid dust trades)
+COPY_TRADING_MIN_POSITION_SIZE=10.0
+
+# Delay between trade detection and execution (seconds)
+# Small delay to avoid API rate limits and ensure data consistency
+COPY_TRADING_EXECUTION_DELAY=5
+
+# Enable copy trading notifications via Telegram
+COPY_TRADING_NOTIFICATIONS=true
+
+# Only copy NEW trades (skip existing positions on startup)
+# true = Only copy trades that happen after bot starts (recommended)
+# false = Copy all positions immediately on startup (legacy behavior)
+COPY_TRADING_ONLY_NEW_TRADES=true
+
 # ========================================
 # RSI Notification Configuration
 # ========================================
@@ -68,7 +105,7 @@ TELEGRAM_CUSTOM_KEYBOARD_ENABLED=true
 # Custom keyboard layout - comma-separated commands per row, pipe-separated rows
 # Format: "cmd1,cmd2,cmd3|cmd4,cmd5|cmd6,cmd7,cmd8,cmd9"
 # Example: "/daily,/performance,/balance|/stats,/positions,/orders|/price,/market,/help,/commands"
-TELEGRAM_CUSTOM_KEYBOARD_LAYOUT="/daily,/performance,/balance|/stats,/positions,/orders|/price,/market,/help,/commands"
+TELEGRAM_CUSTOM_KEYBOARD_LAYOUT="/daily,/performance,/balance|/stats,/positions,/orders|/copy_status,/copy_start,/copy_stop|/price,/market,/help,/commands"
 
 # ========================================
 # Bot Monitoring Configuration

+ 49 - 0
src/config/config.py

@@ -32,6 +32,17 @@ class Config:
     RSI_PERIOD: int = int(os.getenv('RSI_PERIOD', '14'))
     RSI_SMA_PERIOD: int = int(os.getenv('RSI_SMA_PERIOD', '5'))
     
+    # Copy Trading Configuration
+    COPY_TRADING_ENABLED: bool = get_bool_env('COPY_TRADING_ENABLED', 'false')
+    COPY_TRADING_TARGET_ADDRESS: Optional[str] = os.getenv('COPY_TRADING_TARGET_ADDRESS')
+    COPY_TRADING_PORTFOLIO_PERCENTAGE: float = float(os.getenv('COPY_TRADING_PORTFOLIO_PERCENTAGE', '0.1'))
+    COPY_TRADING_MODE: str = os.getenv('COPY_TRADING_MODE', 'PROPORTIONAL').upper()
+    COPY_TRADING_MAX_LEVERAGE: float = float(os.getenv('COPY_TRADING_MAX_LEVERAGE', '10.0'))
+    COPY_TRADING_MIN_POSITION_SIZE: float = float(os.getenv('COPY_TRADING_MIN_POSITION_SIZE', '10.0'))
+    COPY_TRADING_EXECUTION_DELAY: float = float(os.getenv('COPY_TRADING_EXECUTION_DELAY', '5.0'))
+    COPY_TRADING_NOTIFICATIONS: bool = get_bool_env('COPY_TRADING_NOTIFICATIONS', 'true')
+    COPY_TRADING_ONLY_NEW_TRADES: bool = get_bool_env('COPY_TRADING_ONLY_NEW_TRADES', 'true')
+    
     # Telegram Bot Configuration
     TELEGRAM_BOT_TOKEN: Optional[str] = os.getenv('TELEGRAM_BOT_TOKEN')
     TELEGRAM_CHAT_ID: Optional[str] = os.getenv('TELEGRAM_CHAT_ID')
@@ -69,6 +80,7 @@ class Config:
             cls._validate_telegram,
             cls._validate_bot_settings,
             cls._validate_rsi,
+            cls._validate_copy_trading,
             cls._validate_logging
         ]
         return all(validator() for validator in validators)
@@ -135,6 +147,38 @@ class Config:
         
         return True
 
+    @classmethod
+    def _validate_copy_trading(cls) -> bool:
+        """Validate copy trading configuration."""
+        if cls.COPY_TRADING_ENABLED:
+            if not cls.COPY_TRADING_TARGET_ADDRESS:
+                logger.warning("⚠️ Copy trading enabled but no target address set")
+            elif not cls.COPY_TRADING_TARGET_ADDRESS.startswith('0x') or len(cls.COPY_TRADING_TARGET_ADDRESS) != 42:
+                logger.error("❌ COPY_TRADING_TARGET_ADDRESS must be a valid Ethereum address (0x...)")
+                return False
+        
+        if not (0.01 <= cls.COPY_TRADING_PORTFOLIO_PERCENTAGE <= 1.0):
+            logger.error("❌ COPY_TRADING_PORTFOLIO_PERCENTAGE must be between 0.01 (1%) and 1.0 (100%)")
+            return False
+        
+        if cls.COPY_TRADING_MODE not in ['PROPORTIONAL', 'FIXED']:
+            logger.error("❌ COPY_TRADING_MODE must be 'PROPORTIONAL' or 'FIXED'")
+            return False
+        
+        if not (1.0 <= cls.COPY_TRADING_MAX_LEVERAGE <= 50.0):
+            logger.error("❌ COPY_TRADING_MAX_LEVERAGE must be between 1.0 and 50.0")
+            return False
+        
+        if cls.COPY_TRADING_MIN_POSITION_SIZE < 1.0:
+            logger.error("❌ COPY_TRADING_MIN_POSITION_SIZE must be at least $1.0")
+            return False
+        
+        if cls.COPY_TRADING_EXECUTION_DELAY < 0:
+            logger.error("❌ COPY_TRADING_EXECUTION_DELAY must be non-negative")
+            return False
+        
+        return True
+
     @classmethod
     def _validate_logging(cls) -> bool:
         """Validate logging settings."""
@@ -190,6 +234,11 @@ class Config:
         logger.info(f"  ⏰ RSI_TIMEFRAME: {cls.RSI_TIMEFRAME}")
         logger.info(f"  🔢 RSI_PERIOD: {cls.RSI_PERIOD}")
         logger.info(f"  📈 RSI_SMA_PERIOD: {cls.RSI_SMA_PERIOD}")
+        logger.info(f"  🔄 COPY_TRADING_ENABLED: {cls.COPY_TRADING_ENABLED}")
+        logger.info(f"  🎯 COPY_TRADING_TARGET: {'✅ Set' if cls.COPY_TRADING_TARGET_ADDRESS else '❌ Not Set'}")
+        logger.info(f"  💰 COPY_TRADING_PORTFOLIO: {cls.COPY_TRADING_PORTFOLIO_PERCENTAGE:.1%}")
+        logger.info(f"  📊 COPY_TRADING_MODE: {cls.COPY_TRADING_MODE}")
+        logger.info(f"  ⚡ COPY_TRADING_MAX_LEVERAGE: {cls.COPY_TRADING_MAX_LEVERAGE}x")
         logger.info(f"  🤖 TELEGRAM_BOT_TOKEN: {'✅ Set' if cls.TELEGRAM_BOT_TOKEN else '❌ Not Set'}")
         logger.info(f"  💬 TELEGRAM_CHAT_ID: {'✅ Set' if cls.TELEGRAM_CHAT_ID else '❌ Not Set'}")
         logger.info(f"  ⌨️ CUSTOM_KEYBOARD: {'✅ Enabled' if cls.TELEGRAM_CUSTOM_KEYBOARD_ENABLED else '❌ Disabled'}")

+ 564 - 0
src/monitoring/copy_trading_monitor.py

@@ -0,0 +1,564 @@
+"""
+Copy Trading Monitor - Tracks and copies trades from a target trader on Hyperliquid
+"""
+
+import logging
+import time
+import asyncio
+from datetime import datetime, timedelta
+from typing import Dict, List, Optional, Any
+from dataclasses import dataclass
+import requests
+import json
+from decimal import Decimal, ROUND_DOWN
+
+from ..config.config import Config
+from ..clients.hyperliquid_client import HyperliquidClient
+from ..notifications.notification_manager import NotificationManager
+from .copy_trading_state import CopyTradingStateManager
+
+
+@dataclass
+class TraderPosition:
+    """Represents a position held by the target trader"""
+    coin: str
+    size: float
+    side: str  # 'long' or 'short'
+    entry_price: float
+    leverage: float
+    position_value: float
+    unrealized_pnl: float
+    margin_used: float
+    timestamp: int
+
+
+@dataclass
+class CopyTrade:
+    """Represents a trade to be copied"""
+    coin: str
+    action: str  # 'open_long', 'open_short', 'close_long', 'close_short'
+    size: float
+    leverage: float
+    original_trade_hash: str
+    target_trader_address: str
+    timestamp: int
+
+
+class CopyTradingMonitor:
+    """Monitor and copy trades from a target trader"""
+    
+    def __init__(self, client: HyperliquidClient, notification_manager: NotificationManager):
+        self.client = client
+        self.notification_manager = notification_manager
+        self.config = Config()
+        self.logger = logging.getLogger(__name__)
+        
+        # Configuration
+        self.enabled = self.config.COPY_TRADING_ENABLED
+        self.target_address = self.config.COPY_TRADING_TARGET_ADDRESS
+        self.portfolio_percentage = self.config.COPY_TRADING_PORTFOLIO_PERCENTAGE
+        self.copy_mode = self.config.COPY_TRADING_MODE
+        self.max_leverage = self.config.COPY_TRADING_MAX_LEVERAGE
+        self.min_position_size = self.config.COPY_TRADING_MIN_POSITION_SIZE
+        self.execution_delay = self.config.COPY_TRADING_EXECUTION_DELAY
+        self.notifications_enabled = self.config.COPY_TRADING_NOTIFICATIONS
+        
+        # State management for persistence and tracking
+        self.state_manager = CopyTradingStateManager()
+        
+        # Override enabled status from state if different from config
+        if self.state_manager.is_enabled() and self.target_address:
+            self.enabled = True
+        
+        # State tracking (legacy, kept for compatibility)
+        self.target_positions: Dict[str, TraderPosition] = {}
+        self.our_positions: Dict[str, Any] = {}
+        self.last_check_time = 0
+        self.pending_trades: List[CopyTrade] = []
+        
+        # API endpoints
+        self.info_url = "https://api.hyperliquid.xyz/info"
+        
+        self.logger.info(f"Copy Trading Monitor initialized - Target: {self.target_address}")
+        
+        # Load previous session info if available
+        session_info = self.state_manager.get_session_info()
+        if session_info['start_time']:
+            self.logger.info(f"📅 Previous session started: {session_info['start_time']}")
+            self.logger.info(f"📊 Tracked positions: {session_info['tracked_positions_count']}")
+            self.logger.info(f"🔄 Copied trades: {session_info['copied_trades_count']}")
+        
+    async def start_monitoring(self):
+        """Start the copy trading monitoring loop"""
+        if not self.enabled:
+            self.logger.info("Copy trading is disabled")
+            return
+            
+        if not self.target_address:
+            self.logger.error("No target trader address configured")
+            return
+            
+        self.logger.info(f"Starting copy trading monitor for {self.target_address}")
+        
+        # Start state tracking
+        self.state_manager.start_copy_trading(self.target_address)
+        
+        # Get current target positions for initialization
+        current_positions = await self.get_target_positions()
+        if current_positions:
+            # Check if this is a fresh start or resuming
+            if not self.state_manager.get_tracked_positions():
+                # Fresh start - initialize tracking but don't copy existing positions
+                self.logger.info("🆕 Fresh start - initializing with existing positions (won't copy)")
+                self.state_manager.initialize_tracked_positions(current_positions)
+                
+                startup_message = (
+                    f"🔄 Copy Trading Started (Fresh)\n"
+                    f"Target: {self.target_address[:10]}...\n"
+                    f"Portfolio Allocation: {self.portfolio_percentage:.1%}\n"
+                    f"Mode: {self.copy_mode}\n"
+                    f"Max Leverage: {self.max_leverage}x\n\n"
+                    f"📊 Found {len(current_positions)} existing positions\n"
+                    f"⚠️ Will only copy NEW trades from now on"
+                )
+            else:
+                # Resuming - continue from where we left off
+                tracked_count = len(self.state_manager.get_tracked_positions())
+                self.logger.info(f"▶️ Resuming session - {tracked_count} positions tracked")
+                
+                startup_message = (
+                    f"▶️ Copy Trading Resumed\n"
+                    f"Target: {self.target_address[:10]}...\n"
+                    f"Portfolio Allocation: {self.portfolio_percentage:.1%}\n"
+                    f"Mode: {self.copy_mode}\n"
+                    f"Max Leverage: {self.max_leverage}x\n\n"
+                    f"📊 Resuming with {tracked_count} tracked positions"
+                )
+        else:
+            startup_message = (
+                f"🔄 Copy Trading Started\n"
+                f"Target: {self.target_address[:10]}...\n"
+                f"Portfolio Allocation: {self.portfolio_percentage:.1%}\n"
+                f"Mode: {self.copy_mode}\n"
+                f"Max Leverage: {self.max_leverage}x\n\n"
+                f"⚠️ Could not access target trader positions"
+            )
+        
+        # Send startup notification
+        if self.notifications_enabled:
+            await self.notification_manager.send_message(startup_message)
+        
+        # Initial sync
+        await self.sync_positions()
+        
+        # Start monitoring loop
+        while self.enabled and self.state_manager.is_enabled():
+            try:
+                await self.monitor_cycle()
+                await asyncio.sleep(30)  # Check every 30 seconds
+            except Exception as e:
+                self.logger.error(f"Error in copy trading monitor cycle: {e}")
+                await asyncio.sleep(60)  # Wait longer on error
+    
+    async def monitor_cycle(self):
+        """Single monitoring cycle"""
+        try:
+            # Get target trader's current positions
+            new_positions = await self.get_target_positions()
+            
+            if new_positions is None:
+                return
+                
+            # Compare with previous positions to detect changes
+            position_changes = self.detect_position_changes(new_positions)
+            
+            # Execute any detected trades
+            for trade in position_changes:
+                await self.execute_copy_trade(trade)
+                
+            # Update our tracking
+            self.target_positions = new_positions
+            
+        except Exception as e:
+            self.logger.error(f"Error in monitor cycle: {e}")
+    
+    async def get_target_positions(self) -> Optional[Dict[str, TraderPosition]]:
+        """Get current positions of target trader"""
+        try:
+            payload = {
+                "type": "clearinghouseState",
+                "user": self.target_address
+            }
+            
+            response = requests.post(self.info_url, json=payload)
+            
+            if response.status_code != 200:
+                self.logger.error(f"Failed to get target positions: {response.status_code}")
+                return None
+                
+            data = response.json()
+            positions = {}
+            
+            # Parse asset positions
+            for asset_pos in data.get('assetPositions', []):
+                if asset_pos.get('type') == 'oneWay':
+                    pos = asset_pos['position']
+                    coin = pos['coin']
+                    size = float(pos['szi'])
+                    
+                    if abs(size) < 0.001:  # Skip dust positions
+                        continue
+                        
+                    side = 'long' if size > 0 else 'short'
+                    
+                    positions[coin] = TraderPosition(
+                        coin=coin,
+                        size=abs(size),
+                        side=side,
+                        entry_price=float(pos['entryPx']),
+                        leverage=float(pos['leverage']['value']),
+                        position_value=float(pos['positionValue']),
+                        unrealized_pnl=float(pos['unrealizedPnl']),
+                        margin_used=float(pos['marginUsed']),
+                        timestamp=int(time.time() * 1000)
+                    )
+            
+            return positions
+            
+        except Exception as e:
+            self.logger.error(f"Error getting target positions: {e}")
+            return None
+    
+    def detect_position_changes(self, new_positions: Dict[str, TraderPosition]) -> List[CopyTrade]:
+        """Detect changes in target trader's positions using state manager"""
+        trades = []
+        
+        # Check for new positions and position increases
+        for coin, new_pos in new_positions.items():
+            position_data = {
+                'size': new_pos.size,
+                'side': new_pos.side,
+                'entry_price': new_pos.entry_price,
+                'leverage': new_pos.leverage
+            }
+            
+            # Check if this is a new position we should copy
+            if self.state_manager.should_copy_position(coin, position_data):
+                tracked_pos = self.state_manager.get_tracked_positions().get(coin)
+                
+                if tracked_pos is None:
+                    # Completely new position
+                    action = f"open_{new_pos.side}"
+                    copy_size = new_pos.size
+                    self.logger.info(f"🆕 Detected NEW position: {action} {copy_size} {coin} at {new_pos.leverage}x")
+                else:
+                    # Position increase
+                    size_increase = new_pos.size - tracked_pos['size']
+                    action = f"add_{new_pos.side}"
+                    copy_size = size_increase
+                    self.logger.info(f"📈 Detected position increase: {action} {size_increase} {coin}")
+                
+                # Create trade to copy
+                trade_id = f"{coin}_{action}_{new_pos.timestamp}"
+                if not self.state_manager.has_copied_trade(trade_id):
+                    trades.append(CopyTrade(
+                        coin=coin,
+                        action=action,
+                        size=copy_size,
+                        leverage=new_pos.leverage,
+                        original_trade_hash=trade_id,
+                        target_trader_address=self.target_address,
+                        timestamp=new_pos.timestamp
+                    ))
+                    
+            # Check for position reductions
+            elif self.state_manager.is_position_reduction(coin, position_data):
+                tracked_pos = self.state_manager.get_tracked_positions()[coin]
+                size_decrease = tracked_pos['size'] - new_pos.size
+                action = f"reduce_{new_pos.side}"
+                
+                trade_id = f"{coin}_{action}_{new_pos.timestamp}"
+                if not self.state_manager.has_copied_trade(trade_id):
+                    trades.append(CopyTrade(
+                        coin=coin,
+                        action=action,
+                        size=size_decrease,
+                        leverage=new_pos.leverage,
+                        original_trade_hash=trade_id,
+                        target_trader_address=self.target_address,
+                        timestamp=new_pos.timestamp
+                    ))
+                    self.logger.info(f"📉 Detected position decrease: {action} {size_decrease} {coin}")
+            
+            # Update tracking regardless
+            self.state_manager.update_tracked_position(coin, position_data)
+        
+        # Check for closed positions (exits)
+        tracked_positions = self.state_manager.get_tracked_positions()
+        for coin in list(tracked_positions.keys()):
+            if coin not in new_positions:
+                # Position fully closed
+                tracked_pos = tracked_positions[coin]
+                action = f"close_{tracked_pos['side']}"
+                
+                trade_id = f"{coin}_{action}_{int(time.time() * 1000)}"
+                if not self.state_manager.has_copied_trade(trade_id):
+                    trades.append(CopyTrade(
+                        coin=coin,
+                        action=action,
+                        size=tracked_pos['size'],
+                        leverage=tracked_pos['leverage'],
+                        original_trade_hash=trade_id,
+                        target_trader_address=self.target_address,
+                        timestamp=int(time.time() * 1000)
+                    ))
+                    self.logger.info(f"❌ Detected position closure: {action} {tracked_pos['size']} {coin}")
+                
+                # Remove from tracking
+                self.state_manager.remove_tracked_position(coin)
+        
+        # Update last check time
+        self.state_manager.update_last_check()
+        
+        return trades
+    
+    async def execute_copy_trade(self, trade: CopyTrade):
+        """Execute a copy trade"""
+        try:
+            # Check if we've already copied this trade
+            if self.state_manager.has_copied_trade(trade.original_trade_hash):
+                self.logger.debug(f"Skipping already copied trade: {trade.original_trade_hash}")
+                return
+            
+            # Calculate our position size
+            our_size = await self.calculate_position_size(trade)
+            
+            if our_size < self.min_position_size:
+                self.logger.info(f"Skipping {trade.coin} trade - size too small: ${our_size:.2f}")
+                # Still mark as copied to avoid retrying
+                self.state_manager.add_copied_trade(trade.original_trade_hash)
+                return
+            
+            # Apply leverage limit
+            leverage = min(trade.leverage, self.max_leverage)
+            
+            # Add execution delay
+            await asyncio.sleep(self.execution_delay)
+            
+            # Execute the trade
+            success = await self._execute_hyperliquid_trade(trade, our_size, leverage)
+            
+            # Mark trade as copied (whether successful or not to avoid retrying)
+            self.state_manager.add_copied_trade(trade.original_trade_hash)
+            
+            # Send notification
+            if self.notifications_enabled:
+                status = "✅ SUCCESS" if success else "❌ FAILED"
+                await self.notification_manager.send_message(
+                    f"🔄 Copy Trade {status}\n"
+                    f"Action: {trade.action}\n"
+                    f"Asset: {trade.coin}\n"
+                    f"Size: ${our_size:.2f}\n"
+                    f"Leverage: {leverage}x\n"
+                    f"Target: {trade.target_trader_address[:10]}...\n"
+                    f"Trade ID: {trade.original_trade_hash[:16]}..."
+                )
+                
+        except Exception as e:
+            self.logger.error(f"Error executing copy trade for {trade.coin}: {e}")
+            
+            # Mark as copied even on error to avoid infinite retries
+            self.state_manager.add_copied_trade(trade.original_trade_hash)
+            
+            if self.notifications_enabled:
+                await self.notification_manager.send_message(
+                    f"❌ Copy Trade Error\n"
+                    f"Asset: {trade.coin}\n"
+                    f"Action: {trade.action}\n"
+                    f"Error: {str(e)[:100]}\n"
+                    f"Trade ID: {trade.original_trade_hash[:16]}..."
+                )
+    
+    async def calculate_position_size(self, trade: CopyTrade) -> float:
+        """Calculate our position size based on the copy trading mode"""
+        try:
+            # Get our current account balance
+            our_balance = await self.get_our_account_balance()
+            
+            if self.copy_mode == 'FIXED':
+                # Fixed percentage of our account
+                return our_balance * self.portfolio_percentage
+                
+            elif self.copy_mode == 'PROPORTIONAL':
+                # Get target trader's account balance
+                target_balance = await self.get_target_account_balance()
+                
+                if target_balance <= 0:
+                    return our_balance * self.portfolio_percentage
+                
+                # Calculate target trader's position percentage
+                target_pos = self.target_positions.get(trade.coin)
+                if not target_pos:
+                    return our_balance * self.portfolio_percentage
+                
+                target_position_percentage = target_pos.margin_used / target_balance
+                
+                # Apply same percentage to our account
+                our_position_size = our_balance * target_position_percentage
+                
+                # Cap at our portfolio percentage limit
+                max_size = our_balance * self.portfolio_percentage
+                return min(our_position_size, max_size)
+                
+            else:
+                return our_balance * self.portfolio_percentage
+                
+        except Exception as e:
+            self.logger.error(f"Error calculating position size: {e}")
+            # Fallback to fixed percentage
+            our_balance = await self.get_our_account_balance()
+            return our_balance * self.portfolio_percentage
+    
+    async def get_our_account_balance(self) -> float:
+        """Get our account balance"""
+        try:
+            balance_info = self.client.get_balance()
+            if balance_info:
+                return float(balance_info.get('accountValue', 0))
+            else:
+                return 0.0
+        except Exception as e:
+            self.logger.error(f"Error getting our account balance: {e}")
+            return 0.0
+    
+    async def get_target_account_balance(self) -> float:
+        """Get target trader's account balance"""
+        try:
+            payload = {
+                "type": "clearinghouseState", 
+                "user": self.target_address
+            }
+            
+            response = requests.post(self.info_url, json=payload)
+            
+            if response.status_code == 200:
+                data = response.json()
+                return float(data.get('marginSummary', {}).get('accountValue', 0))
+            else:
+                return 0.0
+                
+        except Exception as e:
+            self.logger.error(f"Error getting target account balance: {e}")
+            return 0.0
+    
+    async def _execute_hyperliquid_trade(self, trade: CopyTrade, size: float, leverage: float) -> bool:
+        """Execute trade on Hyperliquid"""
+        try:
+            # Determine if this is a buy or sell order
+            is_buy = 'long' in trade.action or ('close' in trade.action and 'short' in trade.action)
+            
+            # For position opening/closing
+            if 'open' in trade.action:
+                # Open new position
+                result = await self.client.place_order(
+                    symbol=trade.coin,
+                    side='buy' if is_buy else 'sell',
+                    size=size,
+                    leverage=leverage,
+                    order_type='market'
+                )
+            elif 'close' in trade.action:
+                # Close existing position
+                result = await self.client.close_position(
+                    symbol=trade.coin,
+                    size=size
+                )
+            elif 'add' in trade.action:
+                # Add to existing position
+                result = await self.client.place_order(
+                    symbol=trade.coin,
+                    side='buy' if is_buy else 'sell',
+                    size=size,
+                    leverage=leverage,
+                    order_type='market'
+                )
+            elif 'reduce' in trade.action:
+                # Reduce existing position
+                result = await self.client.place_order(
+                    symbol=trade.coin,
+                    side='sell' if 'long' in trade.action else 'buy',
+                    size=size,
+                    order_type='market'
+                )
+            else:
+                self.logger.error(f"Unknown trade action: {trade.action}")
+                return False
+            
+            if result and result.get('success'):
+                self.logger.info(f"Successfully executed copy trade: {trade.action} {size} {trade.coin}")
+                return True
+            else:
+                self.logger.error(f"Failed to execute copy trade: {result}")
+                return False
+                
+        except Exception as e:
+            self.logger.error(f"Error executing Hyperliquid trade: {e}")
+            return False
+    
+    async def sync_positions(self):
+        """Sync our current positions with tracking"""
+        try:
+            # Get our current positions
+            positions = self.client.get_positions()
+            if positions:
+                self.our_positions = {pos['symbol']: pos for pos in positions}
+            else:
+                self.our_positions = {}
+            
+            # Get target positions for initial sync
+            self.target_positions = await self.get_target_positions() or {}
+            
+            self.logger.info(f"Synced positions - Target: {len(self.target_positions)}, Ours: {len(self.our_positions)}")
+            
+        except Exception as e:
+            self.logger.error(f"Error syncing positions: {e}")
+    
+    async def stop_monitoring(self):
+        """Stop copy trading monitoring"""
+        self.enabled = False
+        self.state_manager.stop_copy_trading()
+        self.logger.info("Copy trading monitor stopped")
+        
+        if self.notifications_enabled:
+            session_info = self.state_manager.get_session_info()
+            duration_str = ""
+            if session_info['session_duration_seconds']:
+                duration_hours = session_info['session_duration_seconds'] / 3600
+                duration_str = f"\nSession duration: {duration_hours:.1f} hours"
+            
+            await self.notification_manager.send_message(
+                f"🛑 Copy Trading Stopped\n"
+                f"📊 Tracked positions: {session_info['tracked_positions_count']}\n"
+                f"🔄 Copied trades: {session_info['copied_trades_count']}"
+                + duration_str +
+                f"\n\n💾 State saved - can resume later"
+            )
+    
+    def get_status(self) -> Dict[str, Any]:
+        """Get current copy trading status"""
+        session_info = self.state_manager.get_session_info()
+        
+        return {
+            'enabled': self.enabled and self.state_manager.is_enabled(),
+            'target_address': self.target_address,
+            'portfolio_percentage': self.portfolio_percentage,
+            'copy_mode': self.copy_mode,
+            'max_leverage': self.max_leverage,
+            'target_positions': len(self.target_positions),
+            'our_positions': len(self.our_positions),
+            'tracked_positions': session_info['tracked_positions_count'],
+            'copied_trades': session_info['copied_trades_count'],
+            'session_start_time': session_info['start_time'],
+            'session_duration_hours': session_info['session_duration_seconds'] / 3600 if session_info['session_duration_seconds'] else None,
+            'last_check': session_info['last_check_time']
+        } 

+ 269 - 0
src/monitoring/copy_trading_state.py

@@ -0,0 +1,269 @@
+"""
+Copy Trading State Management - Handles persistence and tracking of copy trading state
+"""
+
+import json
+import logging
+import time
+from datetime import datetime
+from pathlib import Path
+from typing import Dict, Set, Optional, Any
+from dataclasses import dataclass, asdict
+from threading import Lock
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class CopyTradingState:
+    """Represents the current state of copy trading"""
+    enabled: bool = False
+    target_address: Optional[str] = None
+    start_timestamp: Optional[int] = None  # When copy trading was first started
+    last_check_timestamp: Optional[int] = None  # Last time we checked positions
+    tracked_positions: Dict[str, Dict] = None  # Positions we're already tracking
+    copied_trades: Set[str] = None  # Trade IDs we've already copied
+    session_start_time: Optional[int] = None  # When current session started
+    
+    def __post_init__(self):
+        if self.tracked_positions is None:
+            self.tracked_positions = {}
+        if self.copied_trades is None:
+            self.copied_trades = set()
+
+
+class CopyTradingStateManager:
+    """Manages persistence and state tracking for copy trading"""
+    
+    def __init__(self, state_file: str = "data/copy_trading_state.json"):
+        self.state_file = Path(state_file)
+        self.state = CopyTradingState()
+        self._lock = Lock()
+        
+        # Ensure data directory exists
+        self.state_file.parent.mkdir(exist_ok=True)
+        
+        # Load existing state
+        self.load_state()
+        
+        logger.info(f"Copy trading state manager initialized - State file: {self.state_file}")
+    
+    def load_state(self) -> None:
+        """Load state from file"""
+        try:
+            if self.state_file.exists():
+                with open(self.state_file, 'r') as f:
+                    data = json.load(f)
+                
+                # Convert copied_trades back to set
+                if 'copied_trades' in data and isinstance(data['copied_trades'], list):
+                    data['copied_trades'] = set(data['copied_trades'])
+                
+                # Update state with loaded data
+                for key, value in data.items():
+                    if hasattr(self.state, key):
+                        setattr(self.state, key, value)
+                
+                logger.info(f"✅ Loaded copy trading state from {self.state_file}")
+                logger.info(f"   Target: {self.state.target_address}")
+                logger.info(f"   Enabled: {self.state.enabled}")
+                logger.info(f"   Tracked positions: {len(self.state.tracked_positions)}")
+                logger.info(f"   Copied trades: {len(self.state.copied_trades)}")
+                
+            else:
+                logger.info(f"No existing state file found, starting fresh")
+                
+        except Exception as e:
+            logger.error(f"Error loading copy trading state: {e}")
+            logger.info("Starting with fresh state")
+            self.state = CopyTradingState()
+    
+    def save_state(self) -> None:
+        """Save current state to file"""
+        try:
+            with self._lock:
+                # Convert state to dict
+                state_dict = asdict(self.state)
+                
+                # Convert set to list for JSON serialization
+                if 'copied_trades' in state_dict:
+                    state_dict['copied_trades'] = list(state_dict['copied_trades'])
+                
+                # Write to temporary file first, then rename (atomic operation)
+                temp_file = self.state_file.with_suffix('.tmp')
+                with open(temp_file, 'w') as f:
+                    json.dump(state_dict, f, indent=2)
+                
+                # Atomic rename
+                temp_file.rename(self.state_file)
+                
+                logger.debug(f"💾 Saved copy trading state to {self.state_file}")
+                
+        except Exception as e:
+            logger.error(f"Error saving copy trading state: {e}")
+    
+    def start_copy_trading(self, target_address: str) -> None:
+        """Start copy trading for a target address"""
+        with self._lock:
+            current_time = int(time.time() * 1000)
+            
+            # If starting fresh or changing target, reset state
+            if (not self.state.enabled or 
+                self.state.target_address != target_address or
+                self.state.start_timestamp is None):
+                
+                logger.info(f"🚀 Starting fresh copy trading session for {target_address}")
+                self.state = CopyTradingState(
+                    enabled=True,
+                    target_address=target_address,
+                    start_timestamp=current_time,
+                    session_start_time=current_time,
+                    tracked_positions={},
+                    copied_trades=set()
+                )
+            else:
+                # Resuming existing session
+                logger.info(f"▶️ Resuming copy trading session for {target_address}")
+                self.state.enabled = True
+                self.state.session_start_time = current_time
+            
+            self.state.last_check_timestamp = current_time
+            self.save_state()
+    
+    def stop_copy_trading(self) -> None:
+        """Stop copy trading but preserve state"""
+        with self._lock:
+            self.state.enabled = False
+            self.state.last_check_timestamp = int(time.time() * 1000)
+            self.save_state()
+            logger.info("⏹️ Copy trading stopped (state preserved)")
+    
+    def should_copy_position(self, coin: str, position_data: Dict) -> bool:
+        """Determine if we should copy a position"""
+        with self._lock:
+            # If we haven't seen this position before, it's new
+            if coin not in self.state.tracked_positions:
+                return True
+            
+            # If we've seen it but it's larger than before, copy the increase
+            previous_size = self.state.tracked_positions[coin].get('size', 0)
+            current_size = position_data.get('size', 0)
+            
+            return current_size > previous_size
+    
+    def is_position_reduction(self, coin: str, position_data: Dict) -> bool:
+        """Check if this is a position reduction"""
+        with self._lock:
+            if coin not in self.state.tracked_positions:
+                return False
+            
+            previous_size = self.state.tracked_positions[coin].get('size', 0)
+            current_size = position_data.get('size', 0)
+            
+            return current_size < previous_size
+    
+    def is_position_closure(self, coin: str) -> bool:
+        """Check if a tracked position has been closed"""
+        with self._lock:
+            return coin in self.state.tracked_positions
+    
+    def update_tracked_position(self, coin: str, position_data: Dict) -> None:
+        """Update our tracking of a position"""
+        with self._lock:
+            self.state.tracked_positions[coin] = {
+                'size': position_data.get('size', 0),
+                'side': position_data.get('side'),
+                'entry_price': position_data.get('entry_price', 0),
+                'leverage': position_data.get('leverage', 1),
+                'last_updated': int(time.time() * 1000)
+            }
+            self.save_state()
+    
+    def remove_tracked_position(self, coin: str) -> None:
+        """Remove a position from tracking (when closed)"""
+        with self._lock:
+            if coin in self.state.tracked_positions:
+                del self.state.tracked_positions[coin]
+                self.save_state()
+                logger.info(f"🗑️ Removed {coin} from tracked positions")
+    
+    def add_copied_trade(self, trade_id: str) -> None:
+        """Mark a trade as copied"""
+        with self._lock:
+            self.state.copied_trades.add(trade_id)
+            # Keep only last 1000 trade IDs to prevent unbounded growth
+            if len(self.state.copied_trades) > 1000:
+                oldest_trades = sorted(self.state.copied_trades)[:500]
+                for trade_id in oldest_trades:
+                    self.state.copied_trades.discard(trade_id)
+            self.save_state()
+    
+    def has_copied_trade(self, trade_id: str) -> bool:
+        """Check if we've already copied a trade"""
+        with self._lock:
+            return trade_id in self.state.copied_trades
+    
+    def update_last_check(self) -> None:
+        """Update the last check timestamp"""
+        with self._lock:
+            self.state.last_check_timestamp = int(time.time() * 1000)
+            self.save_state()
+    
+    def initialize_tracked_positions(self, current_positions: Dict) -> None:
+        """Initialize tracking with current target positions (on first start)"""
+        with self._lock:
+            logger.info(f"🔄 Initializing position tracking with {len(current_positions)} existing positions")
+            
+            for coin, position in current_positions.items():
+                self.state.tracked_positions[coin] = {
+                    'size': position.size,
+                    'side': position.side,
+                    'entry_price': position.entry_price,
+                    'leverage': position.leverage,
+                    'last_updated': int(time.time() * 1000)
+                }
+                logger.info(f"   📊 Tracking existing {position.side.upper()} {coin}: {position.size}")
+            
+            self.save_state()
+    
+    def get_session_info(self) -> Dict[str, Any]:
+        """Get information about current session"""
+        with self._lock:
+            start_time = None
+            session_duration = None
+            
+            if self.state.start_timestamp:
+                start_time = datetime.fromtimestamp(self.state.start_timestamp / 1000)
+            
+            if self.state.session_start_time:
+                session_duration = (int(time.time() * 1000) - self.state.session_start_time) / 1000
+            
+            return {
+                'enabled': self.state.enabled,
+                'target_address': self.state.target_address,
+                'start_time': start_time,
+                'session_duration_seconds': session_duration,
+                'tracked_positions_count': len(self.state.tracked_positions),
+                'copied_trades_count': len(self.state.copied_trades),
+                'last_check_time': datetime.fromtimestamp(self.state.last_check_timestamp / 1000) if self.state.last_check_timestamp else None
+            }
+    
+    def reset_state(self) -> None:
+        """Reset all state (useful for testing or manual reset)"""
+        with self._lock:
+            self.state = CopyTradingState()
+            self.save_state()
+            logger.info("🔄 Copy trading state reset")
+    
+    def get_tracked_positions(self) -> Dict[str, Dict]:
+        """Get current tracked positions"""
+        with self._lock:
+            return self.state.tracked_positions.copy()
+    
+    def is_enabled(self) -> bool:
+        """Check if copy trading is enabled"""
+        return self.state.enabled
+    
+    def get_target_address(self) -> Optional[str]:
+        """Get current target address"""
+        return self.state.target_address 

+ 11 - 0
src/monitoring/monitoring_coordinator.py

@@ -11,6 +11,7 @@ from .pending_orders_manager import PendingOrdersManager
 from .risk_manager import RiskManager
 from .alarm_manager import AlarmManager
 from .exchange_order_sync import ExchangeOrderSync
+from .copy_trading_monitor import CopyTradingMonitor
 # DrawdownMonitor and RsiMonitor will be lazy-loaded to avoid circular imports
 
 logger = logging.getLogger(__name__)
@@ -32,6 +33,7 @@ class MonitoringCoordinator:
         self.pending_orders_manager = PendingOrdersManager(hl_client, notification_manager)
         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.copy_trading_monitor = CopyTradingMonitor(hl_client, notification_manager)
         
         # Exchange order synchronization (will be initialized with trading stats)
         self.exchange_order_sync = None
@@ -58,6 +60,10 @@ class MonitoringCoordinator:
             await self.risk_manager.start()
             # AlarmManager doesn't have start() method - it's always ready
             
+            # Start copy trading monitor if enabled
+            if self.copy_trading_monitor.enabled:
+                asyncio.create_task(self.copy_trading_monitor.start_monitoring())
+            
             # Initialize exchange order sync with trading stats
             self._init_exchange_order_sync()
             
@@ -94,6 +100,10 @@ class MonitoringCoordinator:
         await self.risk_manager.stop()
         # AlarmManager doesn't have stop() method - nothing to stop
         
+        # Stop copy trading monitor
+        if self.copy_trading_monitor:
+            await self.copy_trading_monitor.stop_monitoring()
+        
         # Stop optional monitors if they exist and have stop methods
         if self.drawdown_monitor and hasattr(self.drawdown_monitor, 'stop'):
             await self.drawdown_monitor.stop()
@@ -218,6 +228,7 @@ class MonitoringCoordinator:
                     'pending_orders_manager': self.pending_orders_manager.is_running,
                     'risk_manager': self.risk_manager.is_running,
                     'alarm_manager': self.alarm_manager.is_running if hasattr(self.alarm_manager, 'is_running') else True,
+                    'copy_trading_monitor': self.copy_trading_monitor.enabled,
                     'drawdown_monitor': self.drawdown_monitor.is_running if hasattr(self.drawdown_monitor, 'is_running') else True,
                     'rsi_monitor': self.rsi_monitor.is_running if hasattr(self.rsi_monitor, 'is_running') else True
                 },

+ 1 - 1
trading_bot.py

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