ATR Volatility Breakout — Donchian breakout filtered by volume, RSI, and EMA, with ATR-proportional dynamic stoploss.
Timeframe
4h
Direction
Long Only
Stoploss
-10.0%
Trailing Stop
No
ROI
0m: 12.0%, 120m: 6.0%, 240m: 3.0%
Interface Version
3
Startup Candles
60
Indicators
5
freqtrade/freqtrade-strategies
Strategy 003 author@: Gerald Lonlas github@: https://github.com/freqtrade/freqtrade-strategies
# 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)