# MomentumBreakout.py — freqtrade strategy # Translated from momentum_breakout_strategy.pine (Qullamaggie · Martin Luk · Ariel) # Paper trading via: https://github.com/freqtrade/freqtrade # # Logic: # Entry : close > N-bar consolidation high + volume surge + above EMA50 + ADR ok # Stop : low of entry bar (never rises to original; EMA20 trail takes over) # Trail : max(entry_stop, EMA20) — rises but never falls # Target : entry + (entry - stop) * R_target (default 2R) from freqtrade.strategy import IStrategy, IntParameter, DecimalParameter from pandas import DataFrame import pandas_ta as ta import numpy as np class MomentumBreakout(IStrategy): # ── Strategy metadata ───────────────────────────────────────────────── INTERFACE_VERSION = 3 timeframe = "4h" can_short = False # ── Risk / sizing (mirrors Pine defaults) ───────────────────────────── # Position sizing is handled by freqtrade config (stake_amount) stoploss = -0.08 # fallback hard stop; custom_stoploss overrides trailing_stop = False # we do custom trailing via EMA20 use_custom_stoploss = True startup_candle_count = 200 # ── Hyperopt parameters (mirror Pine inputs) ─────────────────────────── lookback = IntParameter(10, 40, default=20, space="buy") vol_mult = DecimalParameter(1.2, 3.0, default=1.5, decimals=1, space="buy") vol_len = IntParameter(10, 40, default=20, space="buy") adr_min = DecimalParameter(1.0, 10.0, default=3.0, decimals=1, space="buy") ema_fast = IntParameter(5, 20, default=10, space="buy") ema_slow = IntParameter(10, 40, default=20, space="buy", optimize=False) ema_trend = IntParameter(30, 100, default=50, space="buy", optimize=False) r_target = DecimalParameter(1.0, 5.0, default=2.0, decimals=1, space="sell") # ── Custom stoploss state ────────────────────────────────────────────── # Stores {pair: {"entry_stop": float, "entry_px": float}} _custom_info: dict = {} # ── Indicators ──────────────────────────────────────────────────────── def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: lb = self.lookback.value vl = self.vol_len.value ef = self.ema_fast.value es = self.ema_slow.value et = self.ema_trend.value dataframe["ema_fast"] = ta.ema(dataframe["close"], length=ef) dataframe["ema_slow"] = ta.ema(dataframe["close"], length=es) dataframe["ema_trend"] = ta.ema(dataframe["close"], length=et) dataframe["vol_avg"] = ta.sma(dataframe["volume"], length=vl) # ADR: 14-bar SMA of (high-low) / close × 100 dataframe["adr"] = ( ta.sma(dataframe["high"] - dataframe["low"], length=14) / ta.sma(dataframe["close"], length=14) * 100 ) # Consolidation high: highest high of previous N bars (shifted by 1) dataframe["prev_high"] = ( dataframe["high"].rolling(lb).max().shift(1) ) return dataframe # ── Entry signal ────────────────────────────────────────────────────── def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: vm = self.vol_mult.value adm = self.adr_min.value breakout = dataframe["close"] > dataframe["prev_high"] high_vol = dataframe["volume"] > dataframe["vol_avg"] * vm in_uptrend = dataframe["close"] > dataframe["ema_trend"] adr_ok = dataframe["adr"] >= adm dataframe.loc[ breakout & high_vol & in_uptrend & adr_ok, ["enter_long", "enter_tag"] ] = [1, "momentum_breakout"] return dataframe # ── Exit signal (R-target; EMA20 trail handled by custom_stoploss) ──── def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: # Exits are handled by custom_stoploss (EMA20 trail) and R-target below dataframe["exit_long"] = 0 return dataframe # ── Custom stoploss: entry-bar-low → EMA20 trailing ────────────────── def custom_stoploss( self, pair: str, trade, current_time, current_rate: float, current_profit: float, **kwargs, ) -> float: dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) if dataframe is None or dataframe.empty: return self.stoploss last = dataframe.iloc[-1] info = self._custom_info.get(pair) # First call for this trade: record entry stop = low of entry bar if info is None or info.get("trade_id") != trade.id: entry_candle = dataframe[dataframe["date"] <= trade.open_date_utc] if entry_candle.empty: return self.stoploss entry_bar = entry_candle.iloc[-1] self._custom_info[pair] = { "trade_id": trade.id, "entry_stop": entry_bar["low"], "entry_px": trade.open_rate, } info = self._custom_info[pair] # Trail: max(original entry_stop, current EMA20) trail_stop = max(info["entry_stop"], last["ema_slow"]) # Convert absolute price to relative stoploss fraction freqtrade expects stoploss_value = (trail_stop / current_rate) - 1.0 return max(stoploss_value, -0.99) # never exceed -99% # ── Custom exit: take profit at R-target ────────────────────────────── def custom_exit( self, pair: str, trade, current_time, current_rate: float, current_profit: float, **kwargs, ): info = self._custom_info.get(pair) if info is None: return None entry_px = info["entry_px"] entry_stop = info["entry_stop"] risk_per_r = entry_px - entry_stop target = entry_px + risk_per_r * self.r_target.value if current_rate >= target: return f"r_target_{self.r_target.value}R" return None