# pragma pylint: disable=missing-docstring, invalid-name, pointless-string-statement # flake8: noqa: F401 """ Strategy 14: ATR Volatility Breakout ====================================== Author : Halal Algo Trading Suite Timeframe : 4h Asset class : Crypto SPOT only — no shorting, no margin, no futures Overview -------- The Average True Range (ATR) is the most widely used measure of market volatility. Rather than using a fixed percentage stoploss, this strategy dynamically sizes its risk exposure in proportion to the current market volatility — a technique championed by trend-following funds. Core Logic ---------- A breakout is defined as the close exceeding the highest high of the previous 20 bars. This "Donchian-style" breakout avoids minor intraday wicks and focuses on confirmed closing-price strength. Entry conditions (ALL must be true) ──────────────────────────────────── • Close > highest high of the prior 20 bars (confirmed breakout) • Volume >= 1.5× the 20-bar average (institutional participation) • RSI > 50 (momentum regime, not just mean reversion bounce) • Close > 50-period EMA (trend aligned — avoids counter-trend fades) ATR-Based Dynamic Stoploss -------------------------- The custom_stoploss() override replaces the static -10 % fallback with an ATR-proportional stop that adapts to live volatility: Phase Condition Stop distance ───────── ───────────────────── ────────────── Initial Any open trade ATR × 1.5 Profitable Profit > 0 ATR × 1.5 (trailing from current rate) Well in Profit > ATR×2 / entry ATR × 0.8 (tightened to protect gains) This means: • In a highly volatile market (large ATR), the stop is wider — reducing the chance of being shaken out by normal fluctuations. • In a calm market (small ATR), the stop is tighter — appropriate risk. • Once a trade is well in profit (> 2× ATR), the stop tightens significantly to lock in a larger portion of the gain. Position Sizing Note (for manual risk management) -------------------------------------------------- The ATR-based stop enables precise position sizing: Position Size (units) = Account Risk Amount / (ATR × multiplier) Example: $10 000 account, 1 % risk = $100 risk budget ATR = $500, multiplier = 1.5 → stop distance = $750 Position = $100 / $750 = 0.133 units of the asset This ensures consistent percentage-of-account risk regardless of volatility. Risk Parameters --------------- Stoploss (fallback) : -10 % (overridden by custom_stoploss in practice) use_custom_stoploss : True ROI schedule : 12 % immediately, 6 % after 8h, 3 % after 16h Halal Compliance ---------------- ✓ SPOT trading only ✓ No leverage or margin ✓ No short selling ✓ No interest-bearing instruments """ from datetime import datetime, timedelta from typing import Optional import numpy as np import pandas as pd import pandas_ta as ta from freqtrade.strategy import IStrategy, DecimalParameter, IntParameter from freqtrade.persistence import Trade # --------------------------------------------------------------------------- # Helper utilities # --------------------------------------------------------------------------- def crossed_above(s1: pd.Series, s2: pd.Series) -> pd.Series: """Return True on bars where s1 crosses above s2.""" return (s1 > s2) & (s1.shift(1) <= s2.shift(1)) def crossed_below(s1: pd.Series, s2: pd.Series) -> pd.Series: """Return True on bars where s1 crosses below s2.""" return (s1 < s2) & (s1.shift(1) >= s2.shift(1)) # --------------------------------------------------------------------------- # Strategy # --------------------------------------------------------------------------- class Strategy14_ATR_Breakout(IStrategy): """ ATR Volatility Breakout — Donchian breakout filtered by volume, RSI, and EMA, with ATR-proportional dynamic stoploss. See module docstring for full description. """ INTERFACE_VERSION = 3 # ------------------------------------------------------------------ # Metadata # ------------------------------------------------------------------ timeframe = "4h" can_short = False # SPOT only # ------------------------------------------------------------------ # ROI — trend trades need time; allow multi-day holds # ------------------------------------------------------------------ minimal_roi = { "0": 0.12, # 12 % immediately (large move target) "120": 0.06, # 6 % after 8h (2 candles × 4h) "240": 0.03, # 3 % after 16h (4 candles × 4h) } # ------------------------------------------------------------------ # Stoploss — large fallback; actual stop set by custom_stoploss # ------------------------------------------------------------------ stoploss = -0.10 trailing_stop = False use_custom_stoploss = True # ------------------------------------------------------------------ # Startup candle count — need 50+ bars for EMA and Donchian lookback # ------------------------------------------------------------------ startup_candle_count = 60 # ------------------------------------------------------------------ # Hyper-opt parameters # ------------------------------------------------------------------ # ATR period atr_period = IntParameter(10, 20, default=14, space="buy", optimize=True) # Donchian breakout lookback donchian_period = IntParameter(10, 30, default=20, space="buy", optimize=True) # Volume multiplier for entry confirmation vol_mult = DecimalParameter(1.2, 2.5, default=1.5, decimals=1, space="buy", optimize=True) # EMA length for trend filter ema_length = IntParameter(30, 100, default=50, space="buy", optimize=True) # ATR multiplier for initial stop atr_initial_mult = DecimalParameter(1.0, 3.0, default=1.5, decimals=1, space="sell", optimize=True) # ATR multiplier when trade is well in profit atr_tight_mult = DecimalParameter(0.5, 1.5, default=0.8, decimals=1, space="sell", optimize=True) # ------------------------------------------------------------------ # Indicator population # ------------------------------------------------------------------ def populate_indicators(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame: """ Compute all breakout and trend indicators. Columns added ------------- atr : 14-period Average True Range ema50 : 50-period EMA (trend filter) highest_high : Rolling max of high over last 20 bars (shifted by 1) vol_ma : 20-bar volume SMA vol_ratio : Current volume / vol_ma rsi : 14-period RSI breakout : True when close > prior 20-bar highest high """ atr_p = self.atr_period.value don_p = self.donchian_period.value # -- ATR ----------------------------------------------------------- dataframe["atr"] = ta.atr( dataframe["high"], dataframe["low"], dataframe["close"], length=atr_p ) # -- Trend filter -------------------------------------------------- dataframe["ema50"] = ta.ema(dataframe["close"], length=self.ema_length.value) # -- Donchian highest high (exclude current bar) ------------------- # .shift(1) ensures we use data UP TO (not including) the current bar, # preventing lookahead bias on the breakout condition. dataframe["highest_high"] = ( dataframe["high"].shift(1).rolling(window=don_p).max() ) # -- Volume analysis ----------------------------------------------- dataframe["vol_ma"] = dataframe["volume"].rolling(window=20).mean() dataframe["vol_ratio"] = dataframe["volume"] / dataframe["vol_ma"] # -- RSI ----------------------------------------------------------- dataframe["rsi"] = ta.rsi(dataframe["close"], length=14) # -- Breakout flag ------------------------------------------------- dataframe["breakout"] = dataframe["close"] > dataframe["highest_high"] # --- NaN Safety: convert any None to NaN so pandas comparisons work --- for col in dataframe.columns: if col not in ['open', 'high', 'low', 'close', 'volume', 'date']: try: dataframe[col] = pd.to_numeric(dataframe[col], errors='coerce') except (ValueError, TypeError): pass return dataframe # ------------------------------------------------------------------ # Entry signals # ------------------------------------------------------------------ def populate_entry_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame: """ Entry requires ALL four conditions simultaneously: 1. Confirmed close-above-high breakout (Donchian 20) 2. Volume >= vol_mult × 20-bar average (institutional participation) 3. RSI > 50 (momentum regime) 4. Close > 50-period EMA (trend aligned, avoid counter-trend) A single entry per breakout event is natural since the stoploss and ROI schedule manage the trade thereafter. """ dataframe.loc[ ( dataframe["breakout"] & (dataframe["vol_ratio"] >= self.vol_mult.value) & (dataframe["rsi"] > 50) & (dataframe["close"] > dataframe["ema50"]) & dataframe["atr"].notna() & dataframe["ema50"].notna() & dataframe["highest_high"].notna() ), "enter_long" ] = 1 return dataframe # ------------------------------------------------------------------ # Exit signals # ------------------------------------------------------------------ def populate_exit_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame: """ Signal-based exits complement the custom stoploss: • Price falls back below the 50 EMA — trend is broken. • RSI drops below 40 — momentum has reversed. • Price closes back inside the Donchian channel (false breakout). The custom_stoploss handles the primary ATR-based exit, and the ROI schedule captures profit targets. """ dataframe.loc[ ( # Trend filter broken crossed_below(dataframe["close"], dataframe["ema50"]) ) | ( # Momentum reversed (dataframe["rsi"] < 40) ) | ( # Breakout failed — price closed back below prior high (dataframe["close"] < dataframe["highest_high"]) & (~dataframe["breakout"]) ), "exit_long" ] = 1 return dataframe # ------------------------------------------------------------------ # Custom stoploss — ATR-proportional, adaptive to profit phase # ------------------------------------------------------------------ def custom_stoploss( self, pair: str, trade: Trade, current_time: datetime, current_rate: float, current_profit: float, **kwargs, ) -> float: """ Replace the static stoploss with an ATR-proportional stop. Logic ----- Phase 1 — Initial (any profit state): Stop distance = ATR × 1.5 / current_rate Phase 2 — Well profitable (profit > 2 × ATR / entry_rate): Stop distance = ATR × 0.8 / current_rate (tightened) The returned value is a negative fraction of current_rate. Freqtrade interprets it as a relative distance from current price. Returns the most conservative (widest) of: • ATR-based calculation • The strategy's static stoploss (-10 %) to prevent runaway losses if ATR data is unavailable. """ dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) if dataframe is None or dataframe.empty: return self.stoploss # fallback to static last_candle = dataframe.iloc[-1] atr = last_candle.get("atr", None) if atr is None or np.isnan(atr) or atr <= 0 or current_rate <= 0: return self.stoploss # fallback to static initial_mult = self.atr_initial_mult.value tight_mult = self.atr_tight_mult.value # Threshold: profit exceeds 2 ATR relative to entry profit_threshold = (atr * 2.0) / trade.open_rate if current_profit > profit_threshold: # Well in profit — tighten to protect gains sl_distance = atr * tight_mult / current_rate else: # Standard ATR stop sl_distance = atr * initial_mult / current_rate # Return negative (stoploss below current price) # Cap at the static fallback to avoid excessively wide stops return max(-sl_distance, self.stoploss)