#!/usr/bin/env python3 """ Paper Trading Deployer ====================== Automated deployment of qualified candidates to dry-run/paper trading. Features: - Per-candidate isolated database - Docker container management (start/stop/monitor) - Health checks and auto-restart - State tracking and logging - Integration with ranking system Usage: from execution.deployers import PaperDeployer, DeployConfig config = DeployConfig( candidate_id="run_001", strategy_name="BreakoutV1", params={"stoploss": -0.05}, pairs=["BTC/USDT", "ETH/USDT"], dry_run=True ) deployer = PaperDeployer(config) deployment = deployer.deploy() if deployment.state == DeploymentState.RUNNING: print(f"โœ… Deployed: {deployment.container_id}") Safety: - dry_run=True enforced (no live trading) - Isolated DB per candidate - Resource limits (CPU, RAM) - Automatic cleanup on failure """ import json import logging import re import subprocess import time import hashlib from dataclasses import dataclass, field, asdict from datetime import datetime from enum import Enum from pathlib import Path from typing import Dict, Any, List, Optional, Tuple class DeploymentState(Enum): """Deployment lifecycle states.""" PENDING = "pending" CREATING = "creating" STARTING = "starting" RUNNING = "running" HEALTHY = "healthy" DEGRADED = "degraded" STOPPING = "stopping" STOPPED = "stopped" FAILED = "failed" CLEANED = "cleaned" @dataclass class DeployConfig: """ Configuration for paper trading deployment. All candidates MUST have dry_run=True (enforced in code). """ # Identification candidate_id: str strategy_name: str run_id: Optional[str] = None # Strategy config params: Dict[str, Any] = field(default_factory=dict) pairs: List[str] = field(default_factory=lambda: ["BTC/USDT", "ETH/USDT"]) timeframe: str = "5m" # Deployment settings dry_run: bool = True # ALWAYS True, enforced in code stake_amount: float = 100.0 max_open_trades: int = 5 wallet_balance: float = 1000.0 # Resource limits memory_limit: str = "512m" cpu_limit: float = 1.0 # Paths (auto-generated if not provided) base_dir: Optional[Path] = None data_dir: Path = Path("/opt/docker/freqtrade/shared_data/user_data/data") # Docker settings image: str = "freqtradeorg/freqtrade:stable" docker_host: str = "tcp://socket-proxy:2375" # Monitoring health_check_interval: int = 60 auto_restart: bool = True max_restarts: int = 3 def __post_init__(self): """Ensure safety constraints.""" # FORCE dry_run - never allow live trading if not self.dry_run: raise ValueError("dry_run must be True - live trading not allowed") # Generate base_dir if not provided if self.base_dir is None: self.base_dir = Path(f"/opt/docker/freqtrade/paper_deployments/{self.candidate_id}") else: self.base_dir = Path(self.base_dir) # Generate run_id if not provided if self.run_id is None: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") hash_suffix = hashlib.md5(self.candidate_id.encode()).hexdigest()[:6] self.run_id = f"paper_{timestamp}_{hash_suffix}" # Validate pairs if not self.pairs: self.pairs = ["BTC/USDT", "ETH/USDT"] @dataclass class DeploymentStatus: """Current deployment status.""" state: DeploymentState container_id: Optional[str] = None container_name: Optional[str] = None started_at: Optional[str] = None last_check: Optional[str] = None health_checks_passed: int = 0 health_checks_failed: int = 0 restarts: int = 0 error_message: Optional[str] = None # Performance metrics (if available) current_profit: Optional[float] = None trade_count: Optional[int] = None open_trades: Optional[int] = None def is_active(self) -> bool: """Check if deployment is actively running.""" return self.state in [DeploymentState.RUNNING, DeploymentState.HEALTHY] def to_dict(self) -> Dict[str, Any]: """Convert to dictionary.""" return asdict(self) class PaperDeployer: """ Deploys qualified candidates to dry-run trading. Builds on existing setup_paper_trading.py patterns but with: - Better isolation (per-candidate DB) - State management - Health monitoring - Clean start/stop lifecycle """ def __init__(self, config: DeployConfig): """ Initialize deployer. Args: config: Deployment configuration """ self.config = config self.status = DeploymentStatus(state=DeploymentState.PENDING) # Setup logging self.logger = logging.getLogger(f"paper_deployer.{config.candidate_id}") # Directories self.user_data_dir = self.config.base_dir / "user_data" self.configs_dir = self.user_data_dir / "configs" self.strategies_dir = self.user_data_dir / "strategies" self.logs_dir = self.user_data_dir / "logs" self.db_dir = self.user_data_dir / "db" # Container name (deterministic) self.container_name = f"bob-paper-{self.config.candidate_id[:20]}" # Tracked for cleanup self._created_files: List[Path] = [] self._created_dirs: List[Path] = [] def _run_docker(self, command: List[str], **kwargs) -> Tuple[int, str, str]: """ Execute Docker command. Args: command: Docker command and arguments **kwargs: Additional subprocess args Returns: Tuple of (exit_code, stdout, stderr) """ env = {"DOCKER_HOST": self.config.docker_host} try: result = subprocess.run( command, capture_output=True, text=True, env=env, timeout=kwargs.get('timeout', 300) ) return result.returncode, result.stdout, result.stderr except subprocess.TimeoutExpired: return -1, "", "Docker command timeout" except Exception as e: return -2, "", str(e) def _create_directories(self) -> bool: """Create isolated directory structure.""" try: self.logger.info(f"๐Ÿ“ Creating directories in {self.config.base_dir}") dirs = [ self.user_data_dir, self.configs_dir, self.strategies_dir, self.logs_dir, self.db_dir ] for d in dirs: d.mkdir(parents=True, exist_ok=True) self._created_dirs.append(d) self.logger.debug(f" Created: {d}") return True except Exception as e: self.logger.error(f"โŒ Failed to create directories: {e}") self.status.error_message = str(e) return False def _generate_config(self) -> bool: """Generate freqtrade configuration.""" try: self.logger.info(f"โš™๏ธ Generating config") config = { "max_open_trades": self.config.max_open_trades, "stake_currency": "USDT", "stake_amount": self.config.stake_amount, "dry_run": True, # ENFORCED "dry_run_wallet": self.config.wallet_balance, "timeframe": self.config.timeframe, "fee": 0.001, "trading_mode": "spot", "exchange": { "name": "binance", "pair_whitelist": self.config.pairs, "ccxt_config": {"enableRateLimit": True} }, "pairlists": [ {"method": "StaticPairList"}, {"method": "AgeFilter", "min_days_listed": 30} ], "entry_pricing": { "price_side": "other", "use_order_book": True, "order_book_top": 1 }, "exit_pricing": { "price_side": "other", "use_order_book": True, "order_book_top": 1 }, "telegram": {"enabled": False}, # Disable for paper "api_server": { "enabled": False # Disable API for safety }, "bot_name": self.container_name, "initial_state": "running", "force_entry_enable": False, "strategy": self.config.strategy_name } # Add stoploss/trailing from params if 'stoploss' in self.config.params: config['stoploss'] = self.config.params['stoploss'] if 'trailing' in self.config.params: config['trailing_stop'] = True config['trailing_stop_positive'] = self.config.params.get('trailing', 0.015) # Database path (isolated per candidate) db_path = self.db_dir / f"{self.config.candidate_id}.sqlite" config['db_url'] = f"sqlite:///{db_path}" config_file = self.configs_dir / "config.json" with open(config_file, 'w') as f: json.dump(config, f, indent=2) self._created_files.append(config_file) self.logger.info(f" Config: {config_file}") self.logger.info(f" Database: {db_path}") return True except Exception as e: self.logger.error(f"โŒ Failed to generate config: {e}") self.status.error_message = str(e) return False def _generate_strategy(self) -> bool: """Generate strategy file from stored code or create placeholder.""" try: self.logger.info(f"๐Ÿ“„ Generating strategy: {self.config.strategy_name}") # Check if strategy file exists elsewhere strategy_source = self._find_strategy_source() if strategy_source: # Copy existing strategy import shutil dest = self.strategies_dir / f"{self.config.strategy_name}.py" shutil.copy(strategy_source, dest) self._created_files.append(dest) self.logger.info(f" Copied from: {strategy_source}") else: # Create placeholder (user must provide strategy) strategy_code = self._generate_placeholder_strategy() dest = self.strategies_dir / f"{self.config.strategy_name}.py" with open(dest, 'w') as f: f.write(strategy_code) self._created_files.append(dest) self.logger.info(f" Created placeholder (needs real strategy)") return True except Exception as e: self.logger.error(f"โŒ Failed to generate strategy: {e}") self.status.error_message = str(e) return False def _find_strategy_source(self) -> Optional[Path]: """Find strategy file in common locations.""" search_paths = [ Path("/opt/docker/freqtrade/user_data/strategies"), Path("/opt/docker/freqtrade/shared_data/user_data/strategies"), Path(__file__).parent.parent.parent / "strategies" ] for base in search_paths: candidate = base / f"{self.config.strategy_name}.py" if candidate.exists(): return candidate return None def _generate_placeholder_strategy(self) -> str: """Generate placeholder strategy template.""" params_str = json.dumps(self.config.params, indent=4) return f'''"""Placeholder strategy - replace with real implementation""" from freqtrade.strategy import IStrategy from pandas import DataFrame class {self.config.strategy_name}(IStrategy): timeframe = '{self.config.timeframe}' stoploss = {self.config.params.get('stoploss', -0.10)} trailing_stop = {str('trailing' in self.config.params).lower()} minimal_roi = {{"0": 0.05}} def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: raise NotImplementedError("Replace with real strategy") def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: raise NotImplementedError("Replace with real strategy") def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: raise NotImplementedError("Replace with real strategy") # Config params: {params_str} ''' def _stop_existing(self) -> bool: """Stop and remove existing container with same name.""" try: self.logger.info(f"๐Ÿ›‘ Checking for existing container") # Check if container exists code, stdout, _ = self._run_docker( ["docker", "ps", "-a", "-q", "-f", f"name={self.container_name}"] ) if stdout.strip(): self.logger.info(f" Stopping existing container...") self._run_docker(["docker", "stop", self.container_name], timeout=30) self._run_docker(["docker", "rm", self.container_name], timeout=30) self.logger.info(f" โœ… Cleaned up") else: self.logger.info(f" No existing container") return True except Exception as e: self.logger.warning(f" Warning: Cleanup issue: {e}") return True # Continue anyway def _start_container(self) -> bool: """Start Docker container.""" try: self.logger.info(f"๐Ÿš€ Starting container: {self.container_name}") self.status.state = DeploymentState.STARTING # Build command cmd = [ "docker", "run", "-d", "--name", self.container_name, "--network", "host", "--memory", self.config.memory_limit, "--cpus", str(self.config.cpu_limit), "--restart", "unless-stopped" if self.config.auto_restart else "no", "-e", f"DOCKER_HOST={self.config.docker_host}", "-e", "PYTHONHASHSEED=0", "-v", f"{self.user_data_dir}:/freqtrade/user_data:rw", "-v", f"{self.config.data_dir}:/freqtrade/user_data/data:ro", self.config.image, "trade", "--strategy", self.config.strategy_name, "--config", "/freqtrade/user_data/configs/config.json", "--datadir", "/freqtrade/user_data/data/binance", "--db-url", f"sqlite:////freqtrade/user_data/db/{self.config.candidate_id}.sqlite" ] self.logger.debug(f" Command: {' '.join(cmd[:20])}...") code, stdout, stderr = self._run_docker(cmd, timeout=60) if code != 0: self.status.state = DeploymentState.FAILED self.status.error_message = f"Docker failed: {stderr}" self.logger.error(f"โŒ Container failed: {stderr}") return False container_id = stdout.strip() self.status.container_id = container_id[:12] self.status.container_name = self.container_name self.status.state = DeploymentState.RUNNING self.status.started_at = datetime.now().isoformat() self.logger.info(f"โœ… Container started: {container_id[:12]}") # Wait a moment and verify time.sleep(2) return self._verify_container() except Exception as e: self.status.state = DeploymentState.FAILED self.status.error_message = str(e) self.logger.error(f"โŒ Exception during start: {e}") return False def _verify_container(self) -> bool: """Verify container is actually running.""" code, stdout, _ = self._run_docker( ["docker", "ps", "-q", "-f", f"name={self.container_name}"] ) if stdout.strip(): self.status.state = DeploymentState.RUNNING self.logger.info(f"โœ… Container verified running") return True else: self.status.state = DeploymentState.FAILED self.status.error_message = "Container not found in docker ps" self.logger.error(f"โŒ Container not running") return False def deploy(self) -> DeploymentStatus: """ Execute full deployment. Returns: Current deployment status """ self.logger.info(f"๐ŸŽฏ Starting deployment for {self.config.candidate_id}") self.status.state = DeploymentState.CREATING # 1. Create directories if not self._create_directories(): self.status.state = DeploymentState.FAILED return self.status # 2. Generate config if not self._generate_config(): self._cleanup() self.status.state = DeploymentState.FAILED return self.status # 3. Generate strategy if not self._generate_strategy(): self._cleanup() self.status.state = DeploymentState.FAILED return self.status # 4. Stop existing self._stop_existing() # 5. Start container if not self._start_container(): self._cleanup() return self.status self.logger.info(f"โœ… Deployment complete: {self.status.container_id}") return self.status def get_status(self) -> DeploymentStatus: """Get current deployment status.""" if self.status.container_id: code, stdout, _ = self._run_docker( ["docker", "ps", "-q", "-f", f"name={self.container_name}"] ) if not stdout.strip(): # Container stopped if self.status.state not in [DeploymentState.STOPPING, DeploymentState.STOPPED]: self.status.state = DeploymentState.FAILED self.status.error_message = "Container stopped unexpectedly" self.status.last_check = datetime.now().isoformat() return self.status def stop(self) -> bool: """Stop deployment.""" self.logger.info(f"๐Ÿ›‘ Stopping deployment") self.status.state = DeploymentState.STOPPING try: self._run_docker(["docker", "stop", self.container_name], timeout=30) self._run_docker(["docker", "rm", self.container_name], timeout=30) self.status.state = DeploymentState.STOPPED self.logger.info(f"โœ… Deployment stopped") return True except Exception as e: self.logger.error(f"โŒ Error stopping: {e}") return False def logs(self, tail: int = 100) -> str: """Get container logs.""" if not self.status.container_id: return "Container not running" code, stdout, _ = self._run_docker( ["docker", "logs", "--tail", str(tail), self.container_name] ) return stdout if code == 0 else f"Error: {stdout}" def _cleanup(self): """Clean up on failure.""" self.logger.info(f"๐Ÿงน Cleaning up") try: # Stop container if running self._run_docker(["docker", "stop", self.container_name], timeout=10) self._run_docker(["docker", "rm", self.container_name], timeout=10) # Remove created files for f in self._created_files: if f.exists(): f.unlink() self.logger.debug(f" Removed: {f}") except Exception as e: self.logger.warning(f" Cleanup warning: {e}") def demo(): """Demonstrate deployer usage.""" logging.basicConfig(level=logging.INFO) print("=" * 70) print("PAPER DEPLOYER DEMO") print("=" * 70) config = DeployConfig( candidate_id="demo_candidate_001", strategy_name="EXPLORATION_TEST", params={"stoploss": -0.05, "trailing": 0.02}, pairs=["BTC/USDT", "ETH/USDT"], dry_run=True # Enforced ) print(f"\nCandidate: {config.candidate_id}") print(f"Strategy: {config.strategy_name}") print(f"DB Path: {config.base_dir}/db/{config.candidate_id}.sqlite") print(f"Container: bob-paper-{config.candidate_id[:20]}") deployer = PaperDeployer(config) print("\n" + "-" * 70) print("Deployment would create:") print("-" * 70) print(f" Directory: {config.base_dir}") print(f" Config: {config.base_dir}/user_data/configs/config.json") print(f" Strategy: {config.base_dir}/user_data/strategies/{config.strategy_name}.py") print(f" Database: {config.base_dir}/user_data/db/{config.candidate_id}.sqlite") print(f" Container: bob-paper-{config.candidate_id[:20]}") print("\n" + "-" * 70) print("Status tracking:") print("-" * 70) print(f" state: {deployer.status.state.value}") print(f" container: {deployer.status.container_name}") print("\n" + "-" * 70) print("Note: This was a dry-run demo (no actual deployment)") print("-" * 70) if __name__ == "__main__": demo()