Medium-frequency trend-following system with breakout confirmation and risk-first trade management.
Timeframe
15m
Direction
Long Only
Stoploss
-18.0%
Trailing Stop
No
ROI
0m: 10.0%, 240m: 5.0%, 720m: 2.0%, 1440m: 0.0%
Interface Version
3
Startup Candles
2400
Indicators
6
freqtrade/freqtrade-strategies
Strategy 003 author@: Gerald Lonlas github@: https://github.com/freqtrade/freqtrade-strategies
from __future__ import annotations
from datetime import datetime
from typing import Any
import numpy as np
import pandas as pd
import talib.abstract as ta
from pandas import DataFrame, Series
from freqtrade.persistence import Trade
from freqtrade.strategy import (
BooleanParameter,
DecimalParameter,
IStrategy,
IntParameter,
merge_informative_pair,
stoploss_from_absolute,
stoploss_from_open,
timeframe_to_minutes,
timeframe_to_prev_date,
)
class SystemTrendBreakoutV1(IStrategy):
"""
Medium-frequency trend-following system with breakout confirmation and risk-first trade management.
Design goals:
- Spot-first, long-only by default.
- Multi-timeframe market regime filter using 15m / 1h / 4h.
- Clear vectorized indicators for backtest / hyperopt / lookahead / recursive checks.
- Minimal heavy work in callbacks to keep live execution efficient.
"""
INTERFACE_VERSION = 3
timeframe = "15m"
informative_timeframe_1h = "1h"
informative_timeframe_4h = "4h"
can_short = False
process_only_new_candles = True
startup_candle_count = 2400
use_exit_signal = True
exit_profit_only = False
ignore_roi_if_entry_signal = False
use_custom_stoploss = True
stoploss = -0.18
trailing_stop = False
minimal_roi = {
"0": 0.10,
"240": 0.05,
"720": 0.02,
"1440": 0.0,
}
order_types = {
"entry": "limit",
"exit": "limit",
"emergency_exit": "market",
"force_entry": "market",
"force_exit": "market",
"stoploss": "market",
"stoploss_on_exchange": False,
"stoploss_on_exchange_interval": 60,
}
order_time_in_force = {
"entry": "GTC",
"exit": "GTC",
}
max_open_trades = 4
breakout_lookback = IntParameter(20, 96, default=48, space="buy", optimize=True)
breakout_buffer = DecimalParameter(0.000, 0.006, default=0.001, decimals=3, space="buy", optimize=True)
rsi_lower = IntParameter(48, 58, default=52, space="buy", optimize=True)
rsi_upper = IntParameter(62, 75, default=68, space="buy", optimize=True)
adx_threshold = IntParameter(18, 28, default=22, space="buy", optimize=True)
atr_ratio_min = DecimalParameter(0.002, 0.012, default=0.004, decimals=3, space="buy", optimize=True)
atr_ratio_max = DecimalParameter(0.015, 0.080, default=0.040, decimals=3, space="buy", optimize=True)
volume_window = IntParameter(12, 48, default=24, space="buy", optimize=False)
volume_factor = DecimalParameter(1.0, 2.5, default=1.2, decimals=2, space="buy", optimize=True)
ema_slope_window = IntParameter(6, 48, default=24, space="buy", optimize=True)
chase_atr_limit = DecimalParameter(0.4, 2.0, default=1.0, decimals=2, space="buy", optimize=True)
bb_extension_limit = DecimalParameter(0.001, 0.03, default=0.01, decimals=3, space="buy", optimize=True)
pullback_tolerance = DecimalParameter(0.002, 0.02, default=0.008, decimals=3, space="buy", optimize=True)
pullback_structure_window = IntParameter(6, 24, default=12, space="buy", optimize=False)
breakout_memory = IntParameter(4, 24, default=12, space="buy", optimize=False)
enable_pullback_module = BooleanParameter(default=True, space="buy", optimize=True)
risk_per_trade = DecimalParameter(0.002, 0.010, default=0.005, decimals=3, space="stake", optimize=False)
min_position_size_pct = DecimalParameter(0.01, 0.10, default=0.02, decimals=3, space="stake", optimize=False)
max_position_size_pct = DecimalParameter(0.05, 0.35, default=0.20, decimals=3, space="stake", optimize=False)
max_pair_exposure_pct = DecimalParameter(0.05, 0.35, default=0.20, decimals=3, space="stake", optimize=False)
initial_atr_stop_mult = DecimalParameter(1.5, 4.0, default=2.4, decimals=2, space="stoploss", optimize=True)
breakeven_r_multiple = DecimalParameter(0.8, 1.8, default=1.0, decimals=2, space="stoploss", optimize=True)
profit_lock_r_multiple = DecimalParameter(1.2, 3.0, default=1.8, decimals=2, space="stoploss", optimize=True)
atr_trail_mult = DecimalParameter(1.0, 3.5, default=1.8, decimals=2, space="stoploss", optimize=True)
swing_window = IntParameter(6, 30, default=12, space="stoploss", optimize=False)
momentum_exit_rsi = IntParameter(38, 55, default=45, space="sell", optimize=True)
timeout_bars = IntParameter(24, 192, default=96, space="sell", optimize=True)
timeout_min_profit = DecimalParameter(-0.01, 0.03, default=0.005, decimals=3, space="sell", optimize=True)
volatility_exit_enabled = BooleanParameter(default=True, space="sell", optimize=True)
volatility_exit_wick_ratio = DecimalParameter(0.35, 0.75, default=0.50, decimals=2, space="sell", optimize=True)
volatility_exit_volume_mult = DecimalParameter(1.5, 4.0, default=2.2, decimals=2, space="sell", optimize=True)
cooldown_candles = IntParameter(2, 16, default=4, space="protection", optimize=True)
stoploss_guard_lookback = IntParameter(24, 144, default=72, space="protection", optimize=True)
stoploss_guard_trade_limit = IntParameter(2, 8, default=4, space="protection", optimize=True)
stoploss_guard_duration = IntParameter(4, 48, default=12, space="protection", optimize=True)
max_drawdown_lookback = IntParameter(48, 288, default=144, space="protection", optimize=True)
max_drawdown_trade_limit = IntParameter(6, 30, default=12, space="protection", optimize=True)
max_drawdown_stop = IntParameter(12, 96, default=24, space="protection", optimize=True)
max_drawdown_allowed = DecimalParameter(0.08, 0.30, default=0.16, decimals=2, space="protection", optimize=True)
low_profit_lookback = IntParameter(24, 192, default=72, space="protection", optimize=True)
low_profit_trade_limit = IntParameter(2, 12, default=4, space="protection", optimize=True)
low_profit_stop = IntParameter(4, 48, default=12, space="protection", optimize=True)
low_profit_required = DecimalParameter(-0.02, 0.03, default=0.0, decimals=3, space="protection", optimize=True)
@property
def protections(self) -> list[dict[str, Any]]:
"""
Return strategy-local protections.
"""
return [
{
"method": "CooldownPeriod",
"stop_duration_candles": int(self.cooldown_candles.value),
},
{
"method": "StoplossGuard",
"lookback_period_candles": int(self.stoploss_guard_lookback.value),
"trade_limit": int(self.stoploss_guard_trade_limit.value),
"stop_duration_candles": int(self.stoploss_guard_duration.value),
"only_per_pair": False,
},
{
"method": "MaxDrawdown",
"lookback_period_candles": int(self.max_drawdown_lookback.value),
"trade_limit": int(self.max_drawdown_trade_limit.value),
"stop_duration_candles": int(self.max_drawdown_stop.value),
"max_allowed_drawdown": float(self.max_drawdown_allowed.value),
},
{
"method": "LowProfitPairs",
"lookback_period_candles": int(self.low_profit_lookback.value),
"trade_limit": int(self.low_profit_trade_limit.value),
"stop_duration_candles": int(self.low_profit_stop.value),
"required_profit": float(self.low_profit_required.value),
},
]
def informative_pairs(self) -> list[tuple[str, str]]:
"""
Provide 1h and 4h informative pairs for every pair in the current whitelist.
"""
if not self.dp:
return []
pairs = self.dp.current_whitelist()
informative: list[tuple[str, str]] = []
for pair in pairs:
informative.append((pair, self.informative_timeframe_1h))
informative.append((pair, self.informative_timeframe_4h))
return informative
def _safe_series(self, series: Series, fill_value: float = 0.0) -> Series:
"""
Normalize NaN / inf values for indicator columns.
"""
return series.replace([np.inf, -np.inf], np.nan).fillna(fill_value)
def _add_main_indicators(self, dataframe: DataFrame) -> DataFrame:
"""
Calculate all 15m indicators used by entry, exit, and risk logic.
"""
dataframe["ema_20"] = ta.EMA(dataframe, timeperiod=20)
dataframe["ema_50"] = ta.EMA(dataframe, timeperiod=50)
dataframe["ema_200"] = ta.EMA(dataframe, timeperiod=200)
dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14)
dataframe["adx"] = ta.ADX(dataframe, timeperiod=14)
dataframe["atr"] = ta.ATR(dataframe, timeperiod=14)
dataframe["atr_ratio"] = self._safe_series(dataframe["atr"] / dataframe["close"])
bbands = ta.BBANDS(dataframe, timeperiod=20, nbdevup=2.0, nbdevdn=2.0)
dataframe["bb_upper"] = bbands["upperband"]
dataframe["bb_middle"] = bbands["middleband"]
dataframe["bb_lower"] = bbands["lowerband"]
volume_window = int(self.volume_window.value)
dataframe["volume_mean"] = dataframe["volume"].rolling(volume_window, min_periods=volume_window).mean()
dataframe["volume_ratio"] = self._safe_series(dataframe["volume"] / dataframe["volume_mean"])
lookback = int(self.breakout_lookback.value)
dataframe["donchian_high"] = dataframe["high"].shift(1).rolling(lookback, min_periods=lookback).max()
dataframe["donchian_low"] = dataframe["low"].shift(1).rolling(lookback, min_periods=lookback).min()
dataframe["donchian_mid"] = (dataframe["donchian_high"] + dataframe["donchian_low"]) / 2.0
structure_window = int(self.pullback_structure_window.value)
dataframe["structure_low"] = dataframe["low"].shift(1).rolling(
structure_window, min_periods=structure_window
).min()
dataframe["swing_low"] = dataframe["low"].shift(1).rolling(
int(self.swing_window.value), min_periods=int(self.swing_window.value)
).min()
slope_window = int(self.ema_slope_window.value)
dataframe["ema200_slope_15m"] = dataframe["ema_200"] - dataframe["ema_200"].shift(slope_window)
dataframe["distance_over_ema20"] = self._safe_series((dataframe["close"] - dataframe["ema_20"]) / dataframe["close"])
dataframe["distance_to_bb_upper"] = self._safe_series((dataframe["bb_upper"] - dataframe["close"]) / dataframe["close"])
breakout_buffer = float(self.breakout_buffer.value)
dataframe["breakout_trigger"] = (
(dataframe["close"] > dataframe["donchian_high"] * (1.0 + breakout_buffer))
& (dataframe["close"].shift(1) <= dataframe["donchian_high"].shift(1) * (1.0 + breakout_buffer))
).astype(int)
dataframe["recent_breakout"] = (
dataframe["breakout_trigger"]
.shift(1)
.rolling(int(self.breakout_memory.value), min_periods=1)
.max()
.fillna(0)
.astype(int)
)
dataframe["upper_wick_ratio"] = self._safe_series(
(dataframe["high"] - dataframe[["open", "close"]].max(axis=1))
/ (dataframe["high"] - dataframe["low"]).replace(0, np.nan)
)
dataframe["is_green"] = (dataframe["close"] > dataframe["open"]).astype(int)
dataframe["trend_stack"] = (
(dataframe["ema_20"] > dataframe["ema_50"]) & (dataframe["ema_50"] > dataframe["ema_200"])
).astype(int)
return dataframe
def _add_informative_indicators(self, dataframe: DataFrame, pair: str) -> DataFrame:
"""
Merge and derive indicators from 1h and 4h informative dataframes.
"""
if not self.dp:
return dataframe
informative_1h = self.dp.get_pair_dataframe(pair=pair, timeframe=self.informative_timeframe_1h)
informative_1h["adx"] = ta.ADX(informative_1h, timeperiod=14)
informative_1h["ema_50"] = ta.EMA(informative_1h, timeperiod=50)
informative_1h["ema_200"] = ta.EMA(informative_1h, timeperiod=200)
informative_4h = self.dp.get_pair_dataframe(pair=pair, timeframe=self.informative_timeframe_4h)
informative_4h["ema_200"] = ta.EMA(informative_4h, timeperiod=200)
informative_4h["atr"] = ta.ATR(informative_4h, timeperiod=14)
informative_4h["atr_ratio"] = self._safe_series(informative_4h["atr"] / informative_4h["close"])
informative_4h["ema200_slope"] = informative_4h["ema_200"] - informative_4h["ema_200"].shift(
int(self.ema_slope_window.value)
)
dataframe = merge_informative_pair(
dataframe,
informative_1h,
self.timeframe,
self.informative_timeframe_1h,
ffill=True,
)
dataframe = merge_informative_pair(
dataframe,
informative_4h,
self.timeframe,
self.informative_timeframe_4h,
ffill=True,
)
return dataframe
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
"""
Build all indicators and debug columns.
Debug columns are intentionally kept in the dataframe so exported signals
can be analyzed after backtesting.
"""
dataframe = self._add_main_indicators(dataframe)
dataframe = self._add_informative_indicators(dataframe, metadata["pair"])
slope_window = int(self.ema_slope_window.value)
dataframe["regime_4h_bull"] = (
(dataframe["close_4h"] > dataframe["ema_200_4h"])
& (dataframe["ema_200_4h"] > dataframe["ema_200_4h"].shift(slope_window))
).astype(int)
dataframe["trend_strength_1h"] = (dataframe["adx_1h"] > int(self.adx_threshold.value)).astype(int)
dataframe["volatility_ok"] = (
(dataframe["atr_ratio"] >= float(self.atr_ratio_min.value))
& (dataframe["atr_ratio"] <= float(self.atr_ratio_max.value))
).astype(int)
dataframe["volume_ok"] = (
dataframe["volume"] > dataframe["volume_mean"] * float(self.volume_factor.value)
).astype(int)
dataframe["market_regime_ok"] = (
(dataframe["regime_4h_bull"] == 1)
& (dataframe["trend_strength_1h"] == 1)
& (dataframe["volatility_ok"] == 1)
& (dataframe["volume_ok"] == 1)
).astype(int)
return dataframe
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
"""
Create vectorized long entry signals for breakout and pullback modules.
"""
dataframe["enter_long"] = 0
dataframe["enter_tag"] = None
regime_ok = dataframe["market_regime_ok"] == 1
trend_stack = dataframe["trend_stack"] == 1
rsi_ok = dataframe["rsi"].between(int(self.rsi_lower.value), int(self.rsi_upper.value))
not_too_extended = (
(dataframe["distance_over_ema20"] <= dataframe["atr_ratio"] * float(self.chase_atr_limit.value))
& (dataframe["distance_to_bb_upper"] >= -float(self.bb_extension_limit.value))
)
breakout_entry = (
regime_ok
& trend_stack
& rsi_ok
& not_too_extended
& (dataframe["breakout_trigger"] == 1)
)
tolerance = float(self.pullback_tolerance.value)
near_pullback_zone = (
self._safe_series((dataframe["close"] - dataframe["ema_20"]).abs() / dataframe["close"]) <= tolerance
) | (
self._safe_series((dataframe["close"] - dataframe["donchian_mid"]).abs() / dataframe["close"]) <= tolerance
)
pullback_reclaim = (
(dataframe["close"] > dataframe["ema_20"])
& (dataframe["close"] > dataframe["open"])
& (dataframe["volume"] > dataframe["volume_mean"] * float(self.volume_factor.value))
& (dataframe["close"] > dataframe["close"].shift(1))
)
structure_intact = dataframe["low"] > dataframe["structure_low"] * (1.0 - tolerance)
pullback_entry = (
regime_ok
& trend_stack
& (dataframe["recent_breakout"] == 1)
& near_pullback_zone
& structure_intact
& pullback_reclaim
)
if not bool(self.enable_pullback_module.value):
pullback_entry = pd.Series(False, index=dataframe.index)
dataframe.loc[breakout_entry, "enter_long"] = 1
dataframe.loc[breakout_entry, "enter_tag"] = "breakout"
pullback_only = pullback_entry & ~breakout_entry
dataframe.loc[pullback_only, "enter_long"] = 1
dataframe.loc[pullback_only, "enter_tag"] = "pullback"
return dataframe
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
"""
Keep vectorized exits light and reserve stateful logic for custom_exit.
"""
dataframe["exit_long"] = 0
dataframe["exit_tag"] = None
base_exit = (dataframe["close"] < dataframe["ema_50"]) & (dataframe["rsi"] < int(self.momentum_exit_rsi.value))
dataframe.loc[base_exit, "exit_long"] = 1
dataframe.loc[base_exit, "exit_tag"] = "signal_trend_fail"
return dataframe
def _get_analyzed_candle(self, pair: str, current_time: datetime) -> Series | None:
"""
Fetch the latest analyzed candle aligned to the current trade callback time.
"""
if not self.dp:
return None
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if dataframe.empty:
return None
candle_time = timeframe_to_prev_date(self.timeframe, current_time)
candle_df = dataframe.loc[dataframe["date"] == candle_time]
if candle_df.empty:
return dataframe.iloc[-1]
return candle_df.iloc[-1]
def _get_trade_open_candle(self, pair: str, trade: Trade) -> Series | None:
"""
Fetch the candle corresponding to the trade open time for stable per-trade references.
"""
if not self.dp:
return None
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if dataframe.empty:
return None
trade_date = timeframe_to_prev_date(self.timeframe, trade.open_date_utc)
candle_df = dataframe.loc[dataframe["date"] == trade_date]
if candle_df.empty:
earlier = dataframe.loc[dataframe["date"] <= trade_date]
if earlier.empty:
return None
return earlier.iloc[-1]
return candle_df.iloc[-1]
def _stake_balance(self) -> float:
"""
Return current total stake-currency equity with safe fallbacks.
"""
stake_currency = self.config.get("stake_currency", "USDT")
try:
if self.wallets:
total = float(self.wallets.get_total(stake_currency))
if total > 0:
return total
except Exception:
pass
dry_run_wallet = self.config.get("dry_run_wallet", 10000)
if isinstance(dry_run_wallet, dict):
total = float(dry_run_wallet.get(stake_currency, 10000))
if total > 0:
return total
try:
return float(dry_run_wallet)
except Exception:
return 10000.0
def custom_stake_amount(
self,
pair: str,
current_time: datetime,
current_rate: float,
proposed_stake: float,
min_stake: float | None,
max_stake: float,
leverage: float,
entry_tag: str | None,
side: str,
**kwargs: Any,
) -> float:
"""
Size positions from risk budget and ATR-based initial stop distance.
"""
if side != "long":
return 0.0
candle = self._get_analyzed_candle(pair, current_time)
if candle is None:
return proposed_stake
atr = float(candle.get("atr", 0.0))
if atr <= 0 or current_rate <= 0:
return proposed_stake
equity = self._stake_balance()
risk_budget = equity * float(self.risk_per_trade.value)
stop_distance_ratio = (atr * float(self.initial_atr_stop_mult.value)) / current_rate
if stop_distance_ratio <= 0:
return proposed_stake
raw_stake = risk_budget / stop_distance_ratio
exposure_cap = equity * float(self.max_pair_exposure_pct.value)
min_cap = equity * float(self.min_position_size_pct.value)
max_cap = equity * float(self.max_position_size_pct.value)
stake = min(raw_stake, exposure_cap, max_cap, max_stake)
stake = max(stake, min_cap)
if min_stake is not None:
stake = max(stake, float(min_stake))
stake = min(stake, max_stake)
if not np.isfinite(stake) or stake <= 0:
return proposed_stake
return float(stake)
def custom_stoploss(
self,
pair: str,
trade: Trade,
current_time: datetime,
current_rate: float,
current_profit: float,
after_fill: bool,
**kwargs: Any,
) -> float | None:
"""
Manage stoploss in layers with ATR and swing-low logic.
"""
candle = self._get_analyzed_candle(pair, current_time)
open_candle = self._get_trade_open_candle(pair, trade)
if candle is None or open_candle is None or current_rate <= 0:
return None
entry_atr = float(open_candle.get("atr", 0.0))
live_atr = float(candle.get("atr", 0.0))
swing_low = float(candle.get("swing_low", np.nan))
if entry_atr <= 0:
return None
initial_abs_stop = trade.open_rate - entry_atr * float(self.initial_atr_stop_mult.value)
initial_risk = max(trade.open_rate - initial_abs_stop, trade.open_rate * 0.002)
one_r_profit = initial_risk / trade.open_rate
if after_fill:
return stoploss_from_absolute(
initial_abs_stop,
current_rate=current_rate,
is_short=trade.is_short,
leverage=trade.leverage,
)
if current_profit < float(self.breakeven_r_multiple.value) * one_r_profit:
return stoploss_from_absolute(
initial_abs_stop,
current_rate=current_rate,
is_short=trade.is_short,
leverage=trade.leverage,
)
breakeven_buffer = max(entry_atr * 0.15, trade.open_rate * 0.001)
breakeven_stop = trade.open_rate + breakeven_buffer
desired_stop = breakeven_stop
if current_profit >= float(self.profit_lock_r_multiple.value) * one_r_profit and live_atr > 0:
atr_trail_stop = current_rate - live_atr * float(self.atr_trail_mult.value)
desired_stop = max(desired_stop, atr_trail_stop)
if np.isfinite(swing_low) and swing_low > 0:
desired_stop = max(desired_stop, swing_low - live_atr * 0.25)
desired_stop = min(desired_stop, current_rate * 0.995)
if desired_stop <= 0:
return None
if desired_stop <= trade.open_rate and current_profit > 0:
return stoploss_from_open(
0.0,
current_profit=current_profit,
is_short=trade.is_short,
leverage=trade.leverage,
)
return stoploss_from_absolute(
desired_stop,
current_rate=current_rate,
is_short=trade.is_short,
leverage=trade.leverage,
)
def custom_exit(
self,
pair: str,
trade: Trade,
current_time: datetime,
current_rate: float,
current_profit: float,
**kwargs: Any,
) -> str | None:
"""
Use state-aware exits for momentum decay, time stop, and optional volatility climax.
"""
candle = self._get_analyzed_candle(pair, current_time)
if candle is None:
return None
timeframe_minutes = timeframe_to_minutes(self.timeframe)
bars_held = max(int((current_time - trade.open_date_utc).total_seconds() // 60 // timeframe_minutes), 0)
rsi = float(candle.get("rsi", np.nan))
close_price = float(candle.get("close", current_rate))
ema20 = float(candle.get("ema_20", np.nan))
volume_ratio = float(candle.get("volume_ratio", 0.0))
upper_wick_ratio = float(candle.get("upper_wick_ratio", 0.0))
if np.isfinite(rsi) and np.isfinite(ema20):
if rsi < int(self.momentum_exit_rsi.value) and close_price < ema20 and current_profit > -0.01:
return "momentum_exit"
if bars_held >= int(self.timeout_bars.value) and current_profit < float(self.timeout_min_profit.value):
return "timeout_exit"
if bool(self.volatility_exit_enabled.value):
if (
current_profit > 0.01
and upper_wick_ratio >= float(self.volatility_exit_wick_ratio.value)
and volume_ratio >= float(self.volatility_exit_volume_mult.value)
and close_price < float(candle.get("high", current_rate))
):
return "volatility_exit"
regime_4h_bull = int(candle.get("regime_4h_bull", 1))
if regime_4h_bull == 0 and current_profit > 0:
return "regime_exit"
return None