""" Freqtrade strategy exporter. Generates deployable .py strategy files from winning signal combos. """ import json import re from pathlib import Path from strategy_lab.signals import EXIT_PROFILES def export_strategy(combo, rank: int, output_dir: Path) -> Path: """Export a signal combo as a Freqtrade strategy .py file.""" class_name = f"LabStrategy_{rank:03d}" exit_params = EXIT_PROFILES[combo.exit_profile] # Parse entry description to determine which indicators/signals are needed entry_desc = combo.entry_desc gate_desc = combo.gate_desc indicators = _detect_indicators(entry_desc) btc_indicators = _detect_btc_indicators(gate_desc) entry_conditions = _build_entry_conditions(entry_desc) gate_conditions = _build_gate_conditions(gate_desc) roi_str = json.dumps(exit_params["minimal_roi"], indent=8) # Convert to Python dict format roi_str = roi_str.replace('"', "'") strategy_code = f'''import logging from freqtrade.strategy import IStrategy, informative from pandas import DataFrame import numpy as np import pandas as pd logger = logging.getLogger(__name__) class {class_name}(IStrategy): """ Auto-generated by Strategy Lab. Combo: {combo.label} """ INTERFACE_VERSION: int = 3 minimal_roi = {roi_str} stoploss = {exit_params["stoploss"]} trailing_stop = True trailing_stop_positive = {exit_params["trailing_stop_positive"]} trailing_stop_positive_offset = {exit_params["trailing_stop_positive_offset"]} trailing_only_offset_is_reached = True timeframe = '1h' process_only_new_candles = True use_exit_signal = False exit_profit_only = {exit_params["exit_profit_only"]} exit_profit_offset = {exit_params["exit_profit_offset"]} startup_candle_count = 200 @informative('1h', 'BTC/{{stake}}') def populate_indicators_btc_1h(self, dataframe: DataFrame, metadata: dict) -> DataFrame: {_generate_btc_indicators(btc_indicators)} return dataframe def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: {_generate_pair_indicators(indicators)} {_generate_btc_gate_columns(gate_desc)} return dataframe def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: dataframe.loc[ ( {entry_conditions} {gate_conditions} (dataframe['volume'] > 0) ), 'enter_long'] = 1 return dataframe def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: # Exits handled by ROI, stoploss, and trailing stop return dataframe {_generate_helpers(indicators)} ''' output_path = output_dir / f"{class_name}.py" with open(output_path, "w") as f: f.write(strategy_code) return output_path def _detect_indicators(entry_desc: str) -> set: """Detect which indicators are needed from the entry description.""" indicators = set() if "st(" in entry_desc or "st_all(" in entry_desc: indicators.add("supertrend") # Extract params for match in re.finditer(r'st(?:_all)?\(([^)]+)\)', entry_desc): indicators.add(f"supertrend_params:{match.group(1)}") if "ema(" in entry_desc: indicators.add("ema") match = re.search(r'ema\((\d+),(\d+)\)', entry_desc) if match: indicators.add(f"ema_params:{match.group(1)},{match.group(2)}") if "rsi(" in entry_desc: indicators.add("rsi") if "macd" in entry_desc: indicators.add("macd") if "bb(" in entry_desc: indicators.add("bollinger") match = re.search(r'bb\((\d+),([\d.]+)\)', entry_desc) if match: indicators.add(f"bb_params:{match.group(1)},{match.group(2)}") if "vol(" in entry_desc: indicators.add("volume_sma") if "adx(" in entry_desc: indicators.add("adx") if "stoch(" in entry_desc: indicators.add("stochastic") return indicators def _detect_btc_indicators(gate_desc: str) -> set: indicators = set() if "sma50" in gate_desc: indicators.add("sma50") if "sma200" in gate_desc: indicators.add("sma200") if "rsi" in gate_desc: indicators.add("rsi") if "nc" in gate_desc: indicators.add("close") # Need close for crash detection return indicators def _generate_btc_indicators(indicators: set) -> str: lines = [] if "sma50" in indicators: lines.append(" dataframe['sma50'] = dataframe['close'].rolling(50).mean()") if "sma200" in indicators: lines.append(" dataframe['sma200'] = dataframe['close'].rolling(200).mean()") if "rsi" in indicators: lines.append(" dataframe['rsi'] = _rsi(dataframe['close'], 14)") if not lines: lines.append(" pass") return "\n".join(lines) def _generate_pair_indicators(indicators: set) -> str: lines = [] for ind in indicators: if ind.startswith("supertrend_params:"): params = ind.split(":")[1] if "+" in params: # Multiple supertrend bands: st_all(4,8+1,8) for pair_str in params.split("+"): m, p = pair_str.strip().split(",") lines.append(f" dataframe = self._supertrend(dataframe, {m}, {p})") else: parts = params.split(",") if len(parts) == 2: lines.append(f" dataframe = self._supertrend(dataframe, {parts[0]}, {parts[1]})") for ind in indicators: if ind.startswith("ema_params:"): fast, slow = ind.split(":")[1].split(",") lines.append(f" dataframe['ema_{fast}'] = dataframe['close'].ewm(span={fast}, adjust=False).mean()") lines.append(f" dataframe['ema_{slow}'] = dataframe['close'].ewm(span={slow}, adjust=False).mean()") if "rsi" in indicators: lines.append(" dataframe['rsi'] = _rsi(dataframe['close'], 14)") if "macd" in indicators: lines.append(" ema12 = dataframe['close'].ewm(span=12, adjust=False).mean()") lines.append(" ema26 = dataframe['close'].ewm(span=26, adjust=False).mean()") lines.append(" dataframe['macd'] = ema12 - ema26") lines.append(" dataframe['macdsignal'] = dataframe['macd'].ewm(span=9, adjust=False).mean()") for ind in indicators: if ind.startswith("bb_params:"): period, std = ind.split(":")[1].split(",") lines.append(f" bb_sma = dataframe['close'].rolling({period}).mean()") lines.append(f" bb_std = dataframe['close'].rolling({period}).std()") lines.append(f" dataframe['bb_lower'] = bb_sma - {std} * bb_std") if "volume_sma" in indicators: lines.append(" dataframe['vol_sma20'] = dataframe['volume'].rolling(20).mean()") if "adx" in indicators: lines.append(" dataframe['adx'] = _adx(dataframe, 14)") if "stochastic" in indicators: lines.append(" low_min = dataframe['low'].rolling(14).min()") lines.append(" high_max = dataframe['high'].rolling(14).max()") lines.append(" dataframe['stoch_k'] = 100 * (dataframe['close'] - low_min) / (high_max - low_min + 1e-10)") if not lines: lines.append(" pass") return "\n".join(lines) def _generate_btc_gate_columns(gate_desc: str) -> str: lines = [] conditions = [] if "sma50" in gate_desc: conditions.append("(dataframe['btc_usdt_close_1h'] > dataframe['btc_usdt_sma50_1h'])") if "sma200" in gate_desc: conditions.append("(dataframe['btc_usdt_close_1h'] > dataframe['btc_usdt_sma200_1h'])") if "rsi35" in gate_desc: conditions.append("(dataframe['btc_usdt_rsi_1h'] > 35)") if "nc24" in gate_desc: conditions.append("(dataframe['btc_usdt_close_1h'] >= dataframe['btc_usdt_close_1h'].shift(24) * 0.97)") if conditions: cond_str = " & ".join(conditions) lines.append(f" dataframe['btc_gate'] = ({cond_str}).astype(int)") else: lines.append(" dataframe['btc_gate'] = 1") return "\n".join(lines) def _build_entry_conditions(entry_desc: str) -> str: """Build Freqtrade entry conditions from signal description.""" conditions = [] # Supertrend for match in re.finditer(r'st\((\d+),(\d+)\)', entry_desc): m, p = match.groups() conditions.append(f" (dataframe['st_{m}_{p}'] == 'up') &") # EMA crossover match = re.search(r'ema\((\d+),(\d+)\)', entry_desc) if match: fast, slow = match.groups() conditions.append(f" (dataframe['ema_{fast}'] > dataframe['ema_{slow}']) &") conditions.append(f" (dataframe['ema_{fast}'].shift(1) <= dataframe['ema_{slow}'].shift(1)) &") # MACD if "macd" in entry_desc and "ema" not in entry_desc: conditions.append(" (dataframe['macd'] > dataframe['macdsignal']) &") conditions.append(" (dataframe['macd'].shift(1) <= dataframe['macdsignal'].shift(1)) &") # Bollinger bounce match = re.search(r'bb\((\d+),([\d.]+)\)', entry_desc) if match: conditions.append(" (dataframe['close'] > dataframe['bb_lower']) &") conditions.append(" (dataframe['close'].shift(1) <= dataframe['bb_lower'].shift(1)) &") # RSI range match = re.search(r'rsi\((\d+),(\d+)\)', entry_desc) if match: low, high = match.groups() conditions.append(f" (dataframe['rsi'] >= {low}) &") conditions.append(f" (dataframe['rsi'] <= {high}) &") # Volume spike match = re.search(r'vol\(([\d.]+)\)', entry_desc) if match: mult = match.group(1) conditions.append(f" (dataframe['volume'] > {mult} * dataframe['vol_sma20']) &") # ADX trending match = re.search(r'adx\((\d+)\)', entry_desc) if match: threshold = match.group(1) conditions.append(f" (dataframe['adx'] > {threshold}) &") # Stochastic match = re.search(r'stoch\((\d+)\)', entry_desc) if match: threshold = match.group(1) conditions.append(f" (dataframe['stoch_k'] > {threshold}) &") conditions.append(f" (dataframe['stoch_k'].shift(1) <= {threshold}) &") return "\n".join(conditions) def _build_gate_conditions(gate_desc: str) -> str: return " (dataframe['btc_gate'] == 1) &\n" def _generate_helpers(indicators: set) -> str: """Generate helper functions needed by the strategy.""" parts = [] if "supertrend" in indicators: parts.append(''' def _supertrend(self, df, multiplier, period): import talib.abstract as ta high = df['high'].values low = df['low'].values close = df['close'].values length = len(df) tr = pd.concat([df['high']-df['low'], (df['high']-df['close'].shift(1)).abs(), (df['low']-df['close'].shift(1)).abs()], axis=1).max(axis=1) atr = tr.rolling(period).mean().values basic_ub = (high + low) / 2 + multiplier * atr basic_lb = (high + low) / 2 - multiplier * atr final_ub = np.zeros(length) final_lb = np.zeros(length) for i in range(period, length): final_ub[i] = basic_ub[i] if basic_ub[i] < final_ub[i-1] or close[i-1] > final_ub[i-1] else final_ub[i-1] final_lb[i] = basic_lb[i] if basic_lb[i] > final_lb[i-1] or close[i-1] < final_lb[i-1] else final_lb[i-1] st = np.zeros(length) for i in range(period, length): if st[i-1] == final_ub[i-1]: st[i] = final_ub[i] if close[i] <= final_ub[i] else final_lb[i] elif st[i-1] == final_lb[i-1]: st[i] = final_lb[i] if close[i] >= final_lb[i] else final_ub[i] col = f"st_{multiplier}_{period}" df[col] = np.where(st > 0, np.where(close < st, 'down', 'up'), 'none') return df''') # Always include _rsi and _adx as module-level functions since they may be needed parts.append(''' def _rsi(close, period=14): delta = close.diff() gain = delta.clip(lower=0) loss = (-delta).clip(lower=0) avg_gain = gain.ewm(alpha=1/period, min_periods=period).mean() avg_loss = loss.ewm(alpha=1/period, min_periods=period).mean() rs = avg_gain / (avg_loss + 1e-10) return 100 - (100 / (1 + rs)) def _adx(df, period=14): high, low, close = df['high'], df['low'], df['close'] plus_dm = high.diff() minus_dm = -low.diff() plus_dm = plus_dm.where((plus_dm > minus_dm) & (plus_dm > 0), 0.0) minus_dm = minus_dm.where((minus_dm > plus_dm) & (minus_dm > 0), 0.0) tr = pd.concat([high-low, (high-close.shift(1)).abs(), (low-close.shift(1)).abs()], axis=1).max(axis=1) atr = tr.rolling(period).mean() plus_di = 100 * (plus_dm.ewm(alpha=1/period, min_periods=period).mean() / (atr + 1e-10)) minus_di = 100 * (minus_dm.ewm(alpha=1/period, min_periods=period).mean() / (atr + 1e-10)) dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di + 1e-10) return dx.ewm(alpha=1/period, min_periods=period).mean()''') return "\n".join(parts) # Legacy function name kept for compatibility _generate_supertrend_helper = _generate_helpers _dummy = ''' def _supertrend(self, df, multiplier, period): import talib.abstract as ta high = df['high'].values low = df['low'].values close = df['close'].values length = len(df) tr = pd.concat([df['high']-df['low'], (df['high']-df['close'].shift(1)).abs(), (df['low']-df['close'].shift(1)).abs()], axis=1).max(axis=1) atr = tr.rolling(period).mean().values basic_ub = (high + low) / 2 + multiplier * atr basic_lb = (high + low) / 2 - multiplier * atr final_ub = np.zeros(length) final_lb = np.zeros(length) for i in range(period, length): final_ub[i] = basic_ub[i] if basic_ub[i] < final_ub[i-1] or close[i-1] > final_ub[i-1] else final_ub[i-1] final_lb[i] = basic_lb[i] if basic_lb[i] > final_lb[i-1] or close[i-1] < final_lb[i-1] else final_lb[i-1] st = np.zeros(length) for i in range(period, length): if st[i-1] == final_ub[i-1]: st[i] = final_ub[i] if close[i] <= final_ub[i] else final_lb[i] elif st[i-1] == final_lb[i-1]: st[i] = final_lb[i] if close[i] >= final_lb[i] else final_ub[i] col = f"st_{multiplier}_{period}" df[col] = np.where(st > 0, np.where(close < st, 'down', 'up'), 'none') return df def _rsi(close, period=14): delta = close.diff() gain = delta.clip(lower=0) loss = (-delta).clip(lower=0) avg_gain = gain.ewm(alpha=1/period, min_periods=period).mean() avg_loss = loss.ewm(alpha=1/period, min_periods=period).mean() rs = avg_gain / (avg_loss + 1e-10) return 100 - (100 / (1 + rs)) def _adx(df, period=14): high, low, close = df['high'], df['low'], df['close'] plus_dm = high.diff() minus_dm = -low.diff() plus_dm = plus_dm.where((plus_dm > minus_dm) & (plus_dm > 0), 0.0) minus_dm = minus_dm.where((minus_dm > plus_dm) & (minus_dm > 0), 0.0) tr = pd.concat([high-low, (high-close.shift(1)).abs(), (low-close.shift(1)).abs()], axis=1).max(axis=1) atr = tr.rolling(period).mean() plus_di = 100 * (plus_dm.ewm(alpha=1/period, min_periods=period).mean() / (atr + 1e-10)) minus_di = 100 * (minus_dm.ewm(alpha=1/period, min_periods=period).mean() / (atr + 1e-10)) dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di + 1e-10) return dx.ewm(alpha=1/period, min_periods=period).mean() '''