# ───────────────────────────────────────────────────────────────────────────── # SMC_ML_Strategy.py — v8 — Futures + momentum_failure exit (bugs corregidos) # Exchange: Bybit | Par: BTC/USDT | Timeframe: 15m # ───────────────────────────────────────────────────────────────────────────── import numpy as np import pandas as pd from pandas import DataFrame from freqtrade.strategy import IStrategy, informative import ta import joblib import os class SMC_ML_Strategy(IStrategy): INTERFACE_VERSION = 3 timeframe = '15m' can_short = True # futuros permiten short minimal_roi = { "0": 0.06, "60": 0.04, "180": 0.02, "360": 0.01 } stoploss = -0.02 trailing_stop = True trailing_stop_positive = 0.01 trailing_stop_positive_offset = 0.015 trailing_only_offset_is_reached = True use_exit_signal = True # ── Momentum failure exit — valores corregidos ──────────────────────────── # Bug en v7: MFE_CANDLES_WAIT=92 (23h) → el SL cerraba antes de evaluar # MFE_MIN_THRESHOLD=0.005 (0.5%) → umbral demasiado laxo # Correcto: 8 velas = 2h | 0.008 = 0.8% (margen bajo el 1.0% observado) # # Evidencia: 14/14 stops → MFE < 1.0% (avg 0.519%) # 0/44 winners → MFE < 1.0% (avg 1.560%) MFE_CANDLES_WAIT = 12 # velas 15m antes de evaluar = 2 horas MFE_MIN_THRESHOLD = 0.005 # 0.8% mínimo de excursión favorable startup_candle_count = 200 process_only_new_candles = True ml_score_threshold = 0.62 ml_model = None ml_scaler = None ml_model_path = os.path.join(os.path.dirname(__file__), '..', 'ml_models', 'smc_ml_model.pkl') ml_scaler_path = os.path.join(os.path.dirname(__file__), '..', 'ml_models', 'smc_ml_scaler.pkl') FEATURE_COLS = [ 'htf_bias_1h', 'htf_ema20_50_dist_1h', 'htf_ema50_200_dist_1h', 'htf_price_ema200_dist_1h', 'dist_to_ob', 'ob_size', 'ob_age', 'dist_to_fvg', 'fvg_size', 'dist_to_swing_high', 'dist_to_swing_low', 'volume_ratio', 'volume_zscore', 'atr_norm', 'atr_ratio', 'bb_width', 'rsi', 'macd_hist', 'in_kill_zone', 'bos_bullish', ] def bot_start(self, **kwargs): model_ok = os.path.exists(self.ml_model_path) scaler_ok = os.path.exists(self.ml_scaler_path) if model_ok and scaler_ok: self.ml_model = joblib.load(self.ml_model_path) self.ml_scaler = joblib.load(self.ml_scaler_path) print(f"✅ Modelo ML cargado") print(f"✅ Scaler ML cargado") else: print("⚠️ Modelo o scaler no encontrado. Operando sin filtro ML.") def leverage(self, pair: str, current_time, current_rate: float, proposed_leverage: float, max_leverage: float, entry_tag: str, side: str) -> float: """ Leverage fijo conservador. Con DD histórico de 1.45% en spot, en 3x el DD esperado es ~4.3%. En futuros el DD real puede diferir por funding rates y liquidaciones. Empezar conservador — ajustar después de validar en paper trading. """ return 3.0 @informative('1h') def populate_indicators_1h(self, dataframe: DataFrame, metadata: dict) -> DataFrame: dataframe['ema_20_1h'] = ta.trend.ema_indicator(dataframe['close'], window=20) dataframe['ema_50_1h'] = ta.trend.ema_indicator(dataframe['close'], window=50) dataframe['ema_200_1h'] = ta.trend.ema_indicator(dataframe['close'], window=200) dataframe['htf_bias'] = 0 dataframe.loc[ (dataframe['ema_20_1h'] > dataframe['ema_50_1h']) & (dataframe['ema_50_1h'] > dataframe['ema_200_1h']), 'htf_bias' ] = 1 dataframe.loc[ (dataframe['ema_20_1h'] < dataframe['ema_50_1h']) & (dataframe['ema_50_1h'] < dataframe['ema_200_1h']), 'htf_bias' ] = -1 dataframe['htf_ema20_50_dist'] = ( (dataframe['ema_20_1h'] - dataframe['ema_50_1h']) / dataframe['ema_50_1h'] ) dataframe['htf_ema50_200_dist'] = ( (dataframe['ema_50_1h'] - dataframe['ema_200_1h']) / dataframe['ema_200_1h'] ) dataframe['htf_price_ema200_dist'] = ( (dataframe['close'] - dataframe['ema_200_1h']) / dataframe['ema_200_1h'] ) dataframe['atr_1h'] = ta.volatility.average_true_range( dataframe['high'], dataframe['low'], dataframe['close'], window=14 ) return dataframe def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: dataframe['ema_9'] = ta.trend.ema_indicator(dataframe['close'], window=9) dataframe['ema_21'] = ta.trend.ema_indicator(dataframe['close'], window=21) dataframe['ema_50'] = ta.trend.ema_indicator(dataframe['close'], window=50) dataframe['ema_200'] = ta.trend.ema_indicator(dataframe['close'], window=200) dataframe['rsi'] = ta.momentum.rsi(dataframe['close'], window=14) macd = ta.trend.MACD(dataframe['close']) dataframe['macd'] = macd.macd() dataframe['macd_signal'] = macd.macd_signal() dataframe['macd_hist'] = macd.macd_diff() dataframe['atr'] = ta.volatility.average_true_range( dataframe['high'], dataframe['low'], dataframe['close'], window=14 ) bb = ta.volatility.BollingerBands(dataframe['close'], window=20, window_dev=2) dataframe['bb_upper'] = bb.bollinger_hband() dataframe['bb_lower'] = bb.bollinger_lband() dataframe['bb_mid'] = bb.bollinger_mavg() dataframe['bb_width'] = (dataframe['bb_upper'] - dataframe['bb_lower']) / dataframe['bb_mid'] dataframe['volume_ma_20'] = dataframe['volume'].rolling(20).mean() dataframe['volume_ratio'] = dataframe['volume'] / dataframe['volume_ma_20'] vol_mean = dataframe['volume'].rolling(50).mean() vol_std = dataframe['volume'].rolling(50).std() dataframe['volume_zscore'] = (dataframe['volume'] - vol_mean) / vol_std dataframe = self._detect_swing_points(dataframe) dataframe = self._detect_order_blocks(dataframe) dataframe = self._detect_fvg(dataframe) dataframe = self._detect_liquidity(dataframe) dataframe = self._detect_kill_zone(dataframe) dataframe = self._detect_bos(dataframe) dataframe = self._compute_smc_features(dataframe) dataframe = self._compute_ml_score(dataframe) return dataframe def _detect_swing_points(self, df: DataFrame, lookback: int = 11) -> DataFrame: df['swing_high'] = ( df['high'] == df['high'].rolling(lookback).max() ).astype(int) df['swing_low'] = ( df['low'] == df['low'].rolling(lookback).min() ).astype(int) df['last_swing_high'] = df['high'].where(df['swing_high'] == 1).ffill() df['last_swing_low'] = df['low'].where(df['swing_low'] == 1).ffill() return df def _detect_bos(self, df: DataFrame) -> DataFrame: df['bos_bullish'] = ( (df['close'] > df['last_swing_high'].shift(1)) & (df['close'].shift(1) <= df['last_swing_high'].shift(1)) ).astype(int) df['bos_bearish'] = ( (df['close'] < df['last_swing_low'].shift(1)) & (df['close'].shift(1) >= df['last_swing_low'].shift(1)) ).astype(int) return df def _detect_order_blocks(self, df: DataFrame) -> DataFrame: body = abs(df['close'] - df['open']) avg_body = body.rolling(20).mean() impulse = body > avg_body * 1.5 df['ob_bullish'] = ( (df['close'].shift(1) < df['open'].shift(1)) & impulse & (df['close'] > df['open']) ).astype(int) df['ob_bull_top'] = df['open'].shift(1).where(df['ob_bullish'] == 1).ffill() df['ob_bull_bottom'] = df['close'].shift(1).where(df['ob_bullish'] == 1).ffill() df['ob_bearish'] = ( (df['close'].shift(1) > df['open'].shift(1)) & impulse & (df['close'] < df['open']) ).astype(int) df['ob_bear_top'] = df['close'].shift(1).where(df['ob_bearish'] == 1).ffill() df['ob_bear_bottom'] = df['open'].shift(1).where(df['ob_bearish'] == 1).ffill() return df def _detect_fvg(self, df: DataFrame) -> DataFrame: df['fvg_bullish'] = (df['high'].shift(2) < df['low']).astype(int) df['fvg_bull_bottom'] = df['high'].shift(2).where(df['fvg_bullish'] == 1).ffill() df['fvg_bull_top'] = df['low'].where(df['fvg_bullish'] == 1).ffill() df['fvg_bearish'] = (df['low'].shift(2) > df['high']).astype(int) return df def _detect_liquidity(self, df: DataFrame, tolerance: float = 0.001) -> DataFrame: df['liquidity_high'] = ( (df['high'].rolling(20).max() - df['high']).abs() / df['high'] < tolerance ).astype(int) df['liquidity_low'] = ( (df['low'].rolling(20).min() - df['low']).abs() / df['low'] < tolerance ).astype(int) return df def _detect_kill_zone(self, df: DataFrame) -> DataFrame: hour = df['date'].dt.hour df['in_kill_zone'] = ( ((hour >= 7) & (hour < 10)) | ((hour >= 12) & (hour < 15)) ).astype(int) df['london_open'] = ((hour >= 7) & (hour < 10)).astype(int) df['ny_open'] = ((hour >= 12) & (hour < 15)).astype(int) return df def _compute_smc_features(self, df: DataFrame) -> DataFrame: df['dist_to_ob'] = ( (df['close'] - df['ob_bull_top']) / df['close'] ).fillna(0) df['ob_size'] = ( (df['ob_bull_top'] - df['ob_bull_bottom']) / df['close'] ).fillna(0) ob_age, last_ob = [], np.nan for i in range(len(df)): if df['ob_bullish'].iloc[i] == 1: last_ob = i ob_age.append(i - last_ob if not np.isnan(last_ob) else 999) df['ob_age'] = ob_age df['dist_to_fvg'] = ( (df['close'] - df['fvg_bull_top']) / df['close'] ).fillna(0) df['fvg_size'] = ( (df['fvg_bull_top'] - df['fvg_bull_bottom']) / df['close'] ).fillna(0) df['dist_to_swing_high'] = ( (df['last_swing_high'] - df['close']) / df['close'] ).fillna(0) df['dist_to_swing_low'] = ( (df['close'] - df['last_swing_low']) / df['close'] ).fillna(0) df['atr_norm'] = df['atr'] / df['close'] df['atr_ratio'] = df['atr'] / df['atr_1h_1h'].replace(0, np.nan).ffill() return df def _compute_ml_score(self, df: DataFrame) -> DataFrame: df['ml_score'] = 0.5 if self.ml_model is None or self.ml_scaler is None: return df try: features = df[self.FEATURE_COLS].fillna(0) features_scaled = self.ml_scaler.transform(features) df['ml_score'] = self.ml_model.predict_proba(features_scaled)[:, 1] except Exception as e: print(f"⚠️ Error en ML score: {e}") return df def custom_exit(self, pair: str, trade, current_time, current_rate: float, current_profit: float, **kwargs): """ Momentum failure exit — salida temprana por falta de follow-through. Evidencia estadística (14 stops vs 44 winners del backtest v6): - 14/14 stops → MFE < 1.0% | promedio MFE = 0.519% - 0/44 winners → MFE < 1.0% | promedio MFE = 1.560% - Separación perfecta sin excepciones → discriminador robusto Lógica: Después de MFE_CANDLES_WAIT velas (2h), si el precio nunca llegó a MFE_MIN_THRESHOLD (0.8%) de excursión favorable → momentum failure. Salir con ~-0.5% en lugar de esperar SL a -2%. Efecto esperado: -$104 USDT en stops → ~-$25 USDT = +$79 USDT recuperados. Bugs corregidos respecto a v7: - MFE_CANDLES_WAIT: 92 → 8 (92 velas = 23h, SL actúa antes) - MFE_MIN_THRESHOLD: 0.005 → 0.008 (0.5% demasiado laxo) """ if trade.max_rate is None or trade.open_rate is None: return None candles_since_entry = int( (current_time - trade.open_date_utc).total_seconds() / (15 * 60) ) if candles_since_entry < self.MFE_CANDLES_WAIT: return None mfe = (trade.max_rate - trade.open_rate) / trade.open_rate if mfe < self.MFE_MIN_THRESHOLD: return "momentum_failure" return None def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: if self.ml_model is not None and self.ml_scaler is not None: ml_filter = dataframe['ml_score'] >= self.ml_score_threshold else: ml_filter = False ema21_vs_50 = ( (dataframe['ema_21'] - dataframe['ema_50']) / dataframe['ema_50'] ).abs() not_extended = ( (dataframe['atr_norm'] < 0.0041) & (ema21_vs_50 < 0.0037) ) # ── Long entries ───────────────────────────────────────────────────── dataframe.loc[ ( (dataframe['htf_bias_1h'] == 1) & (dataframe['ema_9'] > dataframe['ema_21']) & (dataframe['rsi'] > 30) & (dataframe['rsi'] < 70) & ( (dataframe['close'] <= dataframe['ob_bull_top']) | (dataframe['close'] <= dataframe['fvg_bull_top']) | (dataframe['close'] <= dataframe['ema_21'] * 1.005) ) & (dataframe['volume_ratio'] > 0.8) & (dataframe['close'] > dataframe['open']) & (dataframe['volume'] > 0) & not_extended & ml_filter ), 'enter_long' ] = 1 # ── Short deshabilitado — solo long por ahora ───────────────────── # Razón: el modelo ML fue entrenado únicamente sobre setups largos. # Añadir shorts sin reentrenar introduciría sesgo. Habilitar después # de validar el sistema long en paper trading y reentrenar con labels # de short (exit_reason == "roi" en trades bajistas). dataframe['enter_short'] = 0 return dataframe def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: dataframe.loc[ ( (dataframe['bos_bearish'] == 1) & (dataframe['htf_bias_1h'] == -1) ), 'exit_long' ] = 1 dataframe['exit_short'] = 0 return dataframe