""" Strategy File Service Handles safe file operations for user strategy files. Security: - All paths resolved relative to user's strategies directory - No path traversal allowed - Only .py files - Syntax validation before save - Name validation (Python identifier rules) """ from __future__ import annotations import ast import keyword import os import re import tempfile from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional from sqlalchemy.orm import Session from ..db.models import StrategyComponent, StrategyParam # Maximum file size: 1MB MAX_STRATEGY_FILE_SIZE = 1024 * 1024 # Valid strategy name pattern STRATEGY_NAME_PATTERN = re.compile(r'^[A-Za-z][A-Za-z0-9_]*$') # Valid timeframes VALID_TIMEFRAMES = ["1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"] # Valid template types VALID_TEMPLATE_TYPES = ["basic", "scalper", "trend", "mean_reversion"] STRATEGY_PARAMETER_NAMES = [ "minimal_roi", "timeframe", "stoploss", "max_open_trades", "trailing_stop", "trailing_stop_positive", "trailing_stop_positive_offset", "trailing_only_offset_is_reached", "use_custom_stoploss", "process_only_new_candles", "order_types", "order_time_in_force", "unfilledtimeout", "disable_dataframe_checks", "use_exit_signal", "exit_profit_only", "exit_profit_offset", "ignore_roi_if_entry_signal", "ignore_buying_expired_candle_after", "position_adjustment_enable", "max_entry_position_adjustment", ] class StrategyValidationError(Exception): """Raised when strategy validation fails""" pass class StrategySecurityError(Exception): """Raised when a security violation is detected""" pass def _extract_strategy_parameters(code: str, strategy_name: str) -> Dict[str, bool]: """Extract whether known strategy params are explicitly declared.""" is_set: Dict[str, bool] = {k: False for k in STRATEGY_PARAMETER_NAMES} try: tree = ast.parse(code) except Exception: return is_set class_nodes = [n for n in tree.body if isinstance(n, ast.ClassDef)] if not class_nodes: return is_set target_class = next((c for c in class_nodes if c.name == strategy_name), class_nodes[0]) for node in target_class.body: name = None value_node = None if isinstance(node, ast.Assign): if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name): name = node.targets[0].id value_node = node.value elif isinstance(node, ast.AnnAssign): if isinstance(node.target, ast.Name): name = node.target.id value_node = node.value if name in is_set: is_set[name] = True return is_set def _upsert_strategy_component_metadata( db: Session, user_id: int, workspace_root: str, strategy_name: str, code: str, description: Optional[str] = None, ) -> None: """Upsert strategy metadata and parameter profile. This is best-effort and must not break strategy file operations. """ target_path = _safe_resolve_strategy_path(workspace_root, strategy_name) is_set = _extract_strategy_parameters(code, strategy_name) component = ( db.query(StrategyComponent) .filter(StrategyComponent.user_id == user_id, StrategyComponent.strategy_path == str(target_path)) .first() ) if not component: component = StrategyComponent( user_id=user_id, strategy_name=strategy_name, strategy_path=str(target_path), ) db.add(component) db.flush() component.strategy_name = strategy_name params_row = db.query(StrategyParam).filter(StrategyParam.strategy_id == component.id).first() if not params_row: params_row = StrategyParam(strategy_id=component.id) db.add(params_row) for name in STRATEGY_PARAMETER_NAMES: setattr(params_row, name, bool(is_set.get(name, False))) if description is not None: component.description = description component.last_modified = datetime.now(timezone.utc) db.commit() def validate_strategy_name(name: str) -> None: """ Validate strategy name follows Python identifier rules. Args: name: Strategy name to validate Raises: StrategyValidationError: If name is invalid """ if not name: raise StrategyValidationError("Strategy name cannot be empty") if len(name) > 100: raise StrategyValidationError("Strategy name too long (max 100 characters)") if not STRATEGY_NAME_PATTERN.match(name): raise StrategyValidationError( "Strategy name must start with a letter and contain only letters, numbers, and underscores" ) if keyword.iskeyword(name): raise StrategyValidationError(f"'{name}' is a Python reserved keyword") def _get_strategies_dir(workspace_root: str) -> Path: """Get the strategies directory for a workspace.""" return Path(workspace_root) / "strategies" def _make_readable(path: Path) -> None: """Make a strategy file readable by the Freqtrade container user.""" try: os.chmod(path, 0o644) except Exception: pass def _safe_resolve_strategy_path(workspace_root: str, strategy_name: str) -> Path: """ Safely resolve strategy file path with security checks. Args: workspace_root: User's workspace root directory strategy_name: Name of the strategy (without .py extension) Returns: Path: Resolved and validated path Raises: StrategySecurityError: If path traversal detected StrategyValidationError: If name is invalid """ validate_strategy_name(strategy_name) strategies_dir = _get_strategies_dir(workspace_root).resolve() target_path = (strategies_dir / f"{strategy_name}.py").resolve() # Security check: ensure target is within strategies directory try: target_path.relative_to(strategies_dir) except ValueError: raise StrategySecurityError( f"Path traversal detected: {strategy_name}" ) return target_path def list_strategies(workspace_root: str) -> List[Dict[str, any]]: """ List all strategy files in user's strategies directory. Args: workspace_root: User's workspace root directory Returns: List of strategy info dicts with name, filename, modifiedAt, size """ strategies_dir = _get_strategies_dir(workspace_root) if not strategies_dir.exists(): return [] strategies = [] for py_file in strategies_dir.glob("*.py"): if py_file.name.startswith("_"): continue try: stat = py_file.stat() name = py_file.stem # filename without .py strategies.append({ "name": name, "filename": py_file.name, "modifiedAt": int(stat.st_mtime), "size": stat.st_size }) except Exception: continue # Sort by name strategies.sort(key=lambda s: s["name"]) return strategies def read_strategy(workspace_root: str, strategy_name: str) -> Dict[str, str]: """ Read strategy file content. Args: workspace_root: User's workspace root directory strategy_name: Name of the strategy (without .py extension) Returns: Dict with name, filename, and code Raises: FileNotFoundError: If strategy file doesn't exist StrategyValidationError: If file is too large """ target_path = _safe_resolve_strategy_path(workspace_root, strategy_name) if not target_path.exists(): raise FileNotFoundError(f"Strategy '{strategy_name}' not found") # Check file size if target_path.stat().st_size > MAX_STRATEGY_FILE_SIZE: raise StrategyValidationError( f"Strategy file too large (max {MAX_STRATEGY_FILE_SIZE // 1024}KB)" ) # Read file try: code = target_path.read_text(encoding="utf-8") except UnicodeDecodeError: raise StrategyValidationError("Strategy file must be valid UTF-8") return { "name": strategy_name, "filename": f"{strategy_name}.py", "code": code } def validate_strategy_code(code: str) -> tuple[bool, Optional[str]]: """ Validate Python syntax without executing code. Args: code: Python code to validate Returns: Tuple of (is_valid, error_message) """ if not code or not code.strip(): return False, "Strategy code cannot be empty" if len(code) > MAX_STRATEGY_FILE_SIZE: return False, f"Strategy code too large (max {MAX_STRATEGY_FILE_SIZE // 1024}KB)" try: ast.parse(code) return True, None except SyntaxError as e: return False, f"Syntax error at line {e.lineno}: {e.msg}" except Exception as e: return False, f"Validation error: {str(e)}" def update_strategy( workspace_root: str, strategy_name: str, code: str, db: Optional[Session] = None, user_id: Optional[int] = None, description: Optional[str] = None, ) -> Dict[str, str]: """ Update strategy file content with validation. Args: workspace_root: User's workspace root directory strategy_name: Name of the strategy (without .py extension) code: New Python code Returns: Dict with success info Raises: FileNotFoundError: If strategy doesn't exist StrategyValidationError: If code is invalid """ target_path = _safe_resolve_strategy_path(workspace_root, strategy_name) if not target_path.exists(): raise FileNotFoundError(f"Strategy '{strategy_name}' not found") # Validate syntax is_valid, error = validate_strategy_code(code) if not is_valid: raise StrategyValidationError(error or "Invalid Python syntax") # Write to temp file first, then atomic replace strategies_dir = _get_strategies_dir(workspace_root) with tempfile.NamedTemporaryFile( mode='w', encoding='utf-8', dir=str(strategies_dir), delete=False, suffix='.tmp' ) as tmp_file: tmp_file.write(code) tmp_path = tmp_file.name try: # Atomic replace os.replace(tmp_path, str(target_path)) _make_readable(target_path) except Exception as e: # Cleanup temp file on error try: os.unlink(tmp_path) except Exception: pass raise StrategyValidationError(f"Failed to save strategy: {str(e)}") if db is not None and user_id is not None: try: _upsert_strategy_component_metadata( db=db, user_id=int(user_id), workspace_root=workspace_root, strategy_name=strategy_name, code=code, description=description, ) except Exception: # Strategy save must succeed even if metadata sync fails. pass return { "ok": True, "message": "Strategy saved successfully", "name": strategy_name } def _generate_template( class_name: str, timeframe: str, can_short: bool, template_type: str ) -> str: """Generate strategy template based on type.""" # Base template if template_type == "scalper": comments = """ # Scalper strategy template # Focus on quick entries/exits with tight stops # Recommended: Use lower timeframes (1m, 3m, 5m) """ elif template_type == "trend": comments = """ # Trend following strategy template # Focus on riding established trends # Recommended: Use higher timeframes (15m, 1h, 4h) """ elif template_type == "mean_reversion": comments = """ # Mean reversion strategy template # Focus on price returning to average # Recommended: Use medium timeframes (5m, 15m, 30m) """ else: # basic comments = """ # Basic strategy template # Customize as needed """ short_methods = "" if can_short: short_methods = """ def populate_entry_trend(self, df: DataFrame, metadata: dict | None = None): \"\"\"Define entry signals for long and short positions.\"\"\" df['enter_long'] = 0 df['enter_short'] = 0 # TODO: Add entry conditions # Example long entry: # long_mask = (df['close'] > df['ema_20']) & (df['rsi'] < 30) # df.loc[long_mask, 'enter_long'] = 1 # Example short entry: # short_mask = (df['close'] < df['ema_20']) & (df['rsi'] > 70) # df.loc[short_mask, 'enter_short'] = 1 return df def populate_exit_trend(self, df: DataFrame, metadata: dict | None = None): \"\"\"Define exit signals for long and short positions.\"\"\" df['exit_long'] = 0 df['exit_short'] = 0 # TODO: Add exit conditions return df """ else: short_methods = """ def populate_entry_trend(self, df: DataFrame, metadata: dict | None = None): \"\"\"Define entry signals for long positions.\"\"\" df['enter_long'] = 0 # TODO: Add entry conditions # Example: # long_mask = (df['close'] > df['ema_20']) & (df['rsi'] < 30) # df.loc[long_mask, 'enter_long'] = 1 return df def populate_exit_trend(self, df: DataFrame, metadata: dict | None = None): \"\"\"Define exit signals for long positions.\"\"\" df['exit_long'] = 0 # TODO: Add exit conditions return df """ template = f'''from __future__ import annotations """ {class_name} Strategy {comments} Generated strategy template - customize as needed. """ from pandas import DataFrame # Uncomment if using CoreBaseStrategy: # from backend.app.trading_core.base_strategy import CoreBaseStrategy # For standard Freqtrade strategies, use: from freqtrade.strategy import IStrategy class {class_name}(IStrategy): """ {class_name} - Generated strategy template """ # Strategy interface version INTERFACE_VERSION = 3 # Strategy parameters timeframe = "{timeframe}" # Minimal ROI minimal_roi = {{ "0": 0.10, # 10% at any time "30": 0.05, # 5% after 30 minutes "60": 0.02, # 2% after 1 hour }} # Stoploss stoploss = -0.10 # Trailing stop (optional) trailing_stop = False trailing_stop_positive = 0.01 trailing_stop_positive_offset = 0.02 trailing_only_offset_is_reached = True # Short trading (futures/margin) can_short = {str(can_short)} # Optional: Leverage settings (for futures) # leverage = {{ # "long": 1, # "short": 1, # }} # Optional: Order types # order_types = {{ # "entry": "limit", # "exit": "limit", # "stoploss": "market", # "stoploss_on_exchange": True # }} # Optional: Order time in force # order_time_in_force = {{ # "entry": "GTC", # "exit": "GTC" # }} def populate_indicators(self, df: DataFrame, metadata: dict | None = None): \"\"\" Add indicators to the dataframe. Common indicators: - EMA/SMA: df['ema_20'] = ta.EMA(df['close'], timeperiod=20) - RSI: df['rsi'] = ta.RSI(df['close'], timeperiod=14) - MACD: df['macd'], df['macdsignal'], df['macdhist'] = ta.MACD(df['close']) - Bollinger Bands: df['bb_upper'], df['bb_middle'], df['bb_lower'] = ta.BBANDS(df['close']) \"\"\" # TODO: Add your indicators here # Example: # import talib.abstract as ta # df['ema_20'] = ta.EMA(df['close'], timeperiod=20) # df['rsi'] = ta.RSI(df['close'], timeperiod=14) return df {short_methods} # Optional: Custom stoploss # def custom_stoploss(self, pair: str, trade: Trade, current_time: datetime, # current_rate: float, current_profit: float, **kwargs) -> float: # \"\"\" # Custom stoploss logic. # Return a negative value for stoploss, or None to keep current stoploss. # \"\"\" # return self.stoploss # Optional: Custom exit # def custom_exit(self, pair: str, trade: Trade, current_time: datetime, # current_rate: float, current_profit: float, **kwargs) -> Optional[str]: # \"\"\" # Custom exit logic. # Return string exit reason or None. # \"\"\" # return None # Optional: Confirm trade entry # def confirm_trade_entry(self, pair: str, order_type: str, amount: float, # rate: float, time_in_force: str, current_time: datetime, # entry_tag: Optional[str], side: str, **kwargs) -> bool: # \"\"\" # Confirm trade entry (last chance to cancel). # Return True to proceed, False to cancel. # \"\"\" # return True # Optional: Confirm trade exit # def confirm_trade_exit(self, pair: str, trade: Trade, order_type: str, # amount: float, rate: float, time_in_force: str, # exit_reason: str, current_time: datetime, **kwargs) -> bool: # \"\"\" # Confirm trade exit (last chance to cancel). # Return True to proceed, False to cancel. # \"\"\" # return True ''' return template def create_strategy( workspace_root: str, strategy_name: str, timeframe: str = "5m", can_short: bool = False, template_type: str = "basic", overwrite: bool = False, db: Optional[Session] = None, user_id: Optional[int] = None, description: Optional[str] = None, ) -> Dict[str, str]: """ Create a new strategy file from template. Args: workspace_root: User's workspace root directory strategy_name: Name for the new strategy timeframe: Timeframe for the strategy can_short: Whether strategy can open short positions template_type: Type of template (basic, scalper, trend, mean_reversion) overwrite: Whether to overwrite existing file Returns: Dict with success info and path Raises: StrategyValidationError: If parameters are invalid FileExistsError: If file exists and overwrite=False """ # Validate inputs validate_strategy_name(strategy_name) if timeframe not in VALID_TIMEFRAMES: raise StrategyValidationError( f"Invalid timeframe. Must be one of: {', '.join(VALID_TIMEFRAMES)}" ) if template_type not in VALID_TEMPLATE_TYPES: raise StrategyValidationError( f"Invalid template type. Must be one of: {', '.join(VALID_TEMPLATE_TYPES)}" ) # Resolve path target_path = _safe_resolve_strategy_path(workspace_root, strategy_name) # Check if exists if target_path.exists() and not overwrite: raise FileExistsError(f"Strategy '{strategy_name}' already exists") # Ensure strategies directory exists strategies_dir = _get_strategies_dir(workspace_root) strategies_dir.mkdir(parents=True, exist_ok=True) # Create __init__.py if needed init_path = strategies_dir / "__init__.py" if not init_path.exists(): init_path.write_text("", encoding="utf-8") _make_readable(init_path) # Generate template code = _generate_template( class_name=strategy_name, timeframe=timeframe, can_short=can_short, template_type=template_type ) # Validate generated code is_valid, error = validate_strategy_code(code) if not is_valid: raise StrategyValidationError(f"Generated template is invalid: {error}") # Write file target_path.write_text(code, encoding="utf-8") _make_readable(target_path) if db is not None and user_id is not None: try: _upsert_strategy_component_metadata( db=db, user_id=int(user_id), workspace_root=workspace_root, strategy_name=strategy_name, code=code, description=description, ) except Exception: # Strategy creation must succeed even if metadata sync fails. pass return { "ok": True, "message": f"Strategy '{strategy_name}' created successfully", "name": strategy_name, "filename": f"{strategy_name}.py" }