""" OSIRIS NEELY STRATEGY v1.0 — Neely Method (MEW) para Day Trade ================================================================ Base teórica: Glenn Neely, "Mastering Elliott Wave" (Windsor Books, 1990) DIFERENCIAL sobre OsirisElliottStrategy (Frost/Prechter básico): 1. Structure Labels (:3/:5) via Pre-Constructive Rules of Logic — classifica cada onda como impulsiva/corretiva ANTES de nomear 2. 0-2 Trendline Channeling — timing de entrada preciso — não entra até o preço confirmar acima da 0-2 line 3. Power Ratings (-3 a +3) baseados no TIPO da W2: Running (-50% retrace) → power +3 → target 261.8% de W1 IrrFail (>95% retrace) → power +2 → target 200% de W1 Zigzag (38-62%) → power +1 → target 161.8% de W1 Flat/Etc → power 0 → target 161.8% de W1 4. Terminal Impulse detection → trade de reversão SHORT 5. Stage 1 confirmation: 2-4 trendline quebrada ≤ tempo de W5 3 SETUPS: SETUP 1 — Wave 3 Onset (Long) Detecta: W0(L) → W1(H) → W2(L) → início W3 Entry: breakout > W1 + preço acima 0-2 trendline Stop: 0.5 ATR abaixo de W2 Target: W2_low + W1_length × mult (baseado em Power Rating) SETUP 2 — Wave 5 Entry (Long) Detecta: W0→W1→W2→W3→W4 completo Entry: recovery acima de W4 + 2-4 trendline intacta Stop: 0.5 ATR abaixo de W4 Target: W4_low + W1_length (igualdade) ou × 1.618 (extension) SETUP 3 — Terminal Reversal (Short — apenas se can_short=True) Detecta: Terminal Impulse (canal convergente, W4 overlap W1, todas :3) Entry: W5 completo, preço girando para baixo Stop: 0.5 ATR acima de W5 topo Target: início do Terminal Timeframe: 5m (day trade) Confirmação: 1h via informative pair (tendência macro) """ import logging import numpy as np import pandas as pd from pandas import DataFrame from typing import Optional from datetime import datetime, timedelta from freqtrade.strategy import IStrategy, merge_informative_pair from freqtrade.strategy import DecimalParameter, IntParameter, CategoricalParameter from freqtrade.persistence import Trade try: import talib.abstract as ta HAS_TALIB = True except ImportError: HAS_TALIB = False from neely_engine import NeelyEngine, fib_extension_levels logger = logging.getLogger(__name__) class OsirisNeelyStrategy(IStrategy): """ OSIRIS NEELY — Implementação do Neely Method para day trade no Freqtrade. Usa o NeelyEngine para calcular padrões, labels e trendlines. """ INTERFACE_VERSION = 3 can_short = True timeframe = "5m" # Safety ROI (custom exit cuida do lucro real) minimal_roi = {"0": 0.20} # Stoploss base (custom_stoploss vai sobrescrever) stoploss = -0.06 trailing_stop = False use_custom_stoploss = True # Candlestick count para aquecimento dos indicadores startup_candle_count = 200 process_only_new_candles = True # ═══════════════════════════════════════════════════════════════════ # HYPEROPT PARAMETERS # ═══════════════════════════════════════════════════════════════════ # Neely Engine pivot_period = IntParameter(7, 20, default=10, space="buy", optimize=True, load=True) min_wave_atr = DecimalParameter(1.5, 4.0, default=2.0, decimals=1, space="buy", optimize=True, load=True) # Score mínimo para entrar neely_score_min = DecimalParameter(3.5, 7.5, default=6.0, decimals=1, space="buy", optimize=True, load=True) # Filtro macro (1h) use_macro_filter = CategoricalParameter([True, False], default=True, space="buy", optimize=False, load=True) macro_ema_period = IntParameter(20, 100, default=50, space="buy", optimize=True, load=True) # Setups ativos use_setup1 = CategoricalParameter([True, False], default=True, space="buy", optimize=False, load=True) use_setup2 = CategoricalParameter([True, False], default=True, space="buy", optimize=False, load=True) use_setup3 = CategoricalParameter([True, False], default=False, space="buy", optimize=False, load=True) # Stop: ATR multiplier stop_atr_mult = DecimalParameter(0.3, 1.5, default=0.5, decimals=1, space="stoploss", optimize=True, load=True) # Target: R múltiplo mínimo (fechamento parcial em 1R, tudo em 2R) tp_r_partial = DecimalParameter(0.8, 1.5, default=1.0, decimals=1, space="sell", optimize=True, load=True) tp_r_full = DecimalParameter(1.5, 4.0, default=2.0, decimals=1, space="sell", optimize=True, load=True) # Power Rating mínimo para entrar (0 = aceita todos, 1 = apenas W2 forte) min_power_rating = IntParameter(-1, 2, default=0, space="buy", optimize=True, load=True) # ═══════════════════════════════════════════════════════════════════ # INFORMATIVE PAIR (1h) — filtro macro de tendência # ═══════════════════════════════════════════════════════════════════ def informative_pairs(self): pairs = self.dp.current_whitelist() return [(p, '1h') for p in pairs] # ═══════════════════════════════════════════════════════════════════ # INDICADORES # ═══════════════════════════════════════════════════════════════════ def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: # ── ATR (para stops + filtro de tamanho de onda) ──────────────── if HAS_TALIB: dataframe['atr'] = ta.ATR(dataframe, timeperiod=14) dataframe['rsi'] = ta.RSI(dataframe, timeperiod=14) macd, sig, hist = ta.MACD(dataframe, fastperiod=12, slowperiod=26, signalperiod=9) dataframe['macd_hist'] = hist dataframe['ema20'] = ta.EMA(dataframe, timeperiod=20) dataframe['ema50'] = ta.EMA(dataframe, timeperiod=50) dataframe['ema200'] = ta.EMA(dataframe, timeperiod=200) else: # Fallback puro pandas dataframe['atr'] = self._atr_pandas(dataframe, 14) dataframe['rsi'] = self._rsi_pandas(dataframe, 14) dataframe['macd_hist'] = 0.0 dataframe['ema20'] = dataframe['close'].ewm(span=20).mean() dataframe['ema50'] = dataframe['close'].ewm(span=50).mean() dataframe['ema200'] = dataframe['close'].ewm(span=200).mean() dataframe['volume_ma'] = dataframe['volume'].rolling(20).mean() # ── Neely Engine ────────────────────────────────────────────── engine = NeelyEngine( pivot_period=int(self.pivot_period.value), min_wave_atr=float(self.min_wave_atr.value), ) dataframe = engine.analyze(dataframe) # ── Informative 1h (macro trend) ───────────────────────────── if self.use_macro_filter.value: dataframe = self._merge_informative_1h(dataframe, metadata) return dataframe def _merge_informative_1h(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """Merge dados 1h para filtro macro.""" try: inf_df = self.dp.get_pair_dataframe(metadata['pair'], '1h') if inf_df is None or len(inf_df) == 0: dataframe['macro_bull'] = True return dataframe inf_df = inf_df.copy() ep = int(self.macro_ema_period.value) if HAS_TALIB: inf_df['ema_macro'] = ta.EMA(inf_df, timeperiod=ep) else: inf_df['ema_macro'] = inf_df['close'].ewm(span=ep).mean() inf_df['macro_bull'] = inf_df['close'] > inf_df['ema_macro'] # Merge por tempo (forward fill) merged = merge_informative_pair( dataframe, inf_df[['date', 'macro_bull']], self.timeframe, '1h', ffill=True, ) macro_bull = merged['macro_bull_1h'] dataframe['macro_bull'] = macro_bull.where(macro_bull.notna(), True).astype(bool) except Exception as e: logger.warning(f"NeelyStrategy: falha no informative 1h: {e}") dataframe['macro_bull'] = True return dataframe # ═══════════════════════════════════════════════════════════════════ # BUY SIGNAL # ═══════════════════════════════════════════════════════════════════ def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: dataframe['enter_long'] = 0 dataframe['enter_short'] = 0 score_min = float(self.neely_score_min.value) power_min = int(self.min_power_rating.value) macro_bool = self.use_macro_filter.value # Filtro macro (bullish acima da EMA macro) macro_bull = dataframe.get('macro_bull', pd.Series(True, index=dataframe.index)) # ── SETUP 1 — Wave 3 Onset (Long) ──────────────────────────── if self.use_setup1.value: cond_s1 = ( (dataframe['neely_pattern'] == 'wave3_onset') & (dataframe['neely_score'] >= score_min) & (dataframe['neely_power'] >= power_min) & (dataframe['neely_target'] > dataframe['close']) # target > preço & (dataframe['volume'] > dataframe['volume_ma'] * 0.5) # liquidez mínima ) if macro_bool: cond_s1 = cond_s1 & (macro_bull == True) dataframe.loc[cond_s1, 'enter_long'] = 1 # Tag do setup dataframe.loc[cond_s1, 'enter_tag'] = f'neely_w3_p{dataframe.loc[cond_s1, "neely_power"].astype(str)}' # ── SETUP 2 — Wave 5 Entry (Long) ──────────────────────────── if self.use_setup2.value: cond_s2 = ( (dataframe['neely_pattern'] == 'wave5_entry') & (dataframe['neely_score'] >= score_min + 0.5) # barra mais alta para W5 & (dataframe['neely_target'] > dataframe['close']) & (dataframe['volume'] > dataframe['volume_ma'] * 0.5) ) if macro_bool: cond_s2 = cond_s2 & (macro_bull == True) dataframe.loc[cond_s2, 'enter_long'] = 1 dataframe.loc[cond_s2, 'enter_tag'] = 'neely_w5' # ── SETUP 3 — Terminal Reversal (Short) ────────────────────── if self.use_setup3.value and self.can_short: cond_s3 = ( (dataframe['neely_pattern'] == 'terminal_rev') & (dataframe['neely_score'] >= score_min + 2.0) # threshold bem alto para short & (dataframe['neely_target'] < dataframe['close']) & (dataframe['volume'] > dataframe['volume_ma'] * 0.5) ) dataframe.loc[cond_s3, 'enter_short'] = 1 dataframe.loc[cond_s3, 'enter_tag'] = 'neely_terminal' return dataframe # ═══════════════════════════════════════════════════════════════════ # SELL SIGNAL (saída via custom_exit principalmente) # ═══════════════════════════════════════════════════════════════════ def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: dataframe['exit_long'] = 0 dataframe['exit_short'] = 0 return dataframe # ═══════════════════════════════════════════════════════════════════ # CUSTOM STOPLOSS — Neely: stop abaixo de W2/W4 # ═══════════════════════════════════════════════════════════════════ def custom_stoploss( self, pair: str, trade: Trade, current_time: datetime, current_rate: float, current_profit: float, after_fill: bool, **kwargs, ) -> float: """ Custom stoploss baseado no Neely Method. Lógica: 1. Na entrada: stop = neely_stop (abaixo de W2 ou W4) = distância fixa baseada no preço de entrada 2. Em profit > 0.5R: move stop para breakeven + 0.1% 3. Em profit > 1.0R: trailing stop sobe para preservar 0.5R O 'neely_stop' foi calculado no engine como: W2_price - ATR × stop_atr_mult """ try: # Busca candle de entrada para pegar neely_stop original dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) if dataframe is None or len(dataframe) == 0: return -1 # Usa stoploss padrão trade_open_date = pd.Timestamp(trade.open_date) if trade_open_date.tzinfo is None: trade_open_date = trade_open_date.tz_localize('UTC') else: trade_open_date = trade_open_date.tz_convert('UTC') # Pega o stop calculado no momento da entrada (Trade.open_date) candle_dates = pd.to_datetime(dataframe['date'], utc=True) entry_candle = dataframe[candle_dates <= trade_open_date] if len(entry_candle) == 0: return -1 entry_row = entry_candle.iloc[-1] neely_stop_price = entry_row.get('neely_stop', 0.0) open_rate = trade.open_rate atr_val = entry_row.get('atr', open_rate * 0.01) if neely_stop_price <= 0 or open_rate <= 0: return -1 # Stop inicial: distância do Neely stop if not trade.is_short: initial_sl_pct = (open_rate - neely_stop_price) / open_rate initial_sl_pct = max(initial_sl_pct, 0.005) # mínimo 0.5% # Calcula R (1R = distância inicial de stop) r_value = initial_sl_pct # pct por 1R # Fase 1: breakeven em 0.5R if current_profit >= r_value * 0.5: break_even_sl = -0.001 # +0.1% acima do preço de entrada if current_profit >= r_value * 1.0: # Fase 2: trailing a partir de 1R — preserva 0.5R preserved = r_value * 0.5 trailing_sl = -(current_profit - preserved) return max(trailing_sl, -initial_sl_pct) return break_even_sl # Antes do breakeven: usa stop fixo do Neely return -initial_sl_pct else: # Short: stop acima de W5 topo initial_sl_pct = (neely_stop_price - open_rate) / open_rate initial_sl_pct = max(initial_sl_pct, 0.005) r_value = initial_sl_pct if current_profit >= r_value * 0.5: if current_profit >= r_value * 1.0: preserved = r_value * 0.5 trailing_sl = -(current_profit - preserved) return max(trailing_sl, -initial_sl_pct) return -0.001 return -initial_sl_pct except Exception as e: logger.warning(f"NeelyStrategy: erro em custom_stoploss: {e}") return -1 # usa stoploss padrão # ═══════════════════════════════════════════════════════════════════ # CUSTOM EXIT — Target Fibonacci + Time limit # ═══════════════════════════════════════════════════════════════════ def custom_exit( self, pair: str, trade: Trade, current_time: datetime, current_rate: float, current_profit: float, **kwargs, ) -> Optional[str]: """ Saída customizada: 1. Neely Target Hit: neely_target atingido → sai 2. 2-4 Trendline break (Stage 1): sai se for W5 e 2-4 quebrou 3. Divergência bearish: RSI divergindo no topo → saída preventiva 4. Time limit: posição parada por > 4h sem lucro → corta Retorna string com motivo de saída ou None (mantém posição). """ try: dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) if dataframe is None or len(dataframe) == 0: return None last = dataframe.iloc[-1] open_rate = trade.open_rate atr_val = float(last.get('atr', open_rate * 0.01)) neely_tgt = float(last.get('neely_target', 0.0)) neely_tl24 = float(last.get('neely_tl_24', float('nan'))) hours_open = (current_time - trade.open_date_utc).total_seconds() / 3600 # ── TARGET HIT ────────────────────────────────────────── if not trade.is_short: if neely_tgt > 0 and current_rate >= neely_tgt: return 'neely_target_hit' else: if neely_tgt > 0 and current_rate <= neely_tgt: return 'neely_target_hit' # ── 2-4 TRENDLINE BREAK (Stage 1 — fim do W5 pattern) ── # Neely: após W5 completo, 2-4 trendline deve ser quebrada # em ≤ tempo de W5. Se a trendline foi quebrada downward = saída. if (not trade.is_short and not np.isnan(neely_tl24) and current_rate < neely_tl24 and current_profit > 0): return 'neely_2_4_trendline_break' # ── TIME LIMIT ─────────────────────────────────────────── tp_r_full = float(self.tp_r_full.value) if hours_open > 8 and current_profit < 0: # Está perdendo há mais de 8h → corta return 'neely_time_cut_loss' if hours_open > 24: # Após 24h — se não atingiu target, sai com o que tem return 'neely_time_limit_24h' # ── PARTIAL TARGET (1R): sinal para saída parcial ──────── # Freqtrade não faz saída parcial nativa, mas podemos sinalizar # através de custom_exit com lucro razoável if (current_profit >= 0.015 and hours_open > 2 and not trade.is_short): # Saiu do range normal de W3 — protege com saída se RSI alto rsi_now = float(last.get('rsi', 50.0)) if rsi_now > 75: return 'neely_rsi_overbought_exit' except Exception as e: logger.warning(f"NeelyStrategy: erro em custom_exit: {e}") return None # ═══════════════════════════════════════════════════════════════════ # CONFIRM ENTRY — Gate final antes de abrir trade # ═══════════════════════════════════════════════════════════════════ 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: """ Gate final: - Só opera em horário de alta liquidez BTC (UTC 08:00-22:00) """ # Horário de liquidez (UTC) hour = current_time.hour if not (8 <= hour <= 22): return False return True # ═══════════════════════════════════════════════════════════════════ # LEVERAGED POSITION (Futures) # ═══════════════════════════════════════════════════════════════════ def leverage(self, pair: str, current_time: datetime, current_rate: float, proposed_leverage: float, max_leverage: float, entry_tag: Optional[str], side: str, **kwargs) -> float: """ Alavancagem baseada no Power Rating do sinal. power +3 (Running) → 3x power +2 → 2x power +1/0 → 1x (sem alavancagem) """ try: dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) if dataframe is None or len(dataframe) == 0: return 1.0 last = dataframe.iloc[-1] power = int(last.get('neely_power', 0)) if power >= 3: return min(3.0, max_leverage) elif power >= 2: return min(2.0, max_leverage) else: return 1.0 except Exception: return 1.0 # ═══════════════════════════════════════════════════════════════════ # HELPERS — Pandas fallback (sem TA-Lib) # ═══════════════════════════════════════════════════════════════════ def _atr_pandas(self, df: DataFrame, period: int = 14) -> pd.Series: high = df['high'] low = df['low'] close = df['close'].shift(1) tr = pd.concat([ high - low, (high - close).abs(), (low - close).abs(), ], axis=1).max(axis=1) return tr.rolling(period).mean() def _rsi_pandas(self, df: DataFrame, period: int = 14) -> pd.Series: delta = df['close'].diff() gain = delta.clip(lower=0).rolling(period).mean() loss = (-delta.clip(upper=0)).rolling(period).mean() rs = gain / (loss + 1e-9) return 100 - (100 / (1 + rs))