💬 Forum

TrendRider

bobarudragos94-wq/trading/user_data/strategies/TrendRider.py · first seen 2026-07-16 · repo updated 2026-07-03 · ⬇ 1 download

Basics mode: spot 4h
Settings custom stoploss protections hyperopt hyperopt params: 5
Indicators ADX ATR EMA
Concepts breakout risk_management trend_following
Other shared
15 related strategies ( identical code, similar name)

Each tile is a different kind of check — from an instant code lint to full sandboxed backtests and forward tests on recent data. Not sure what a check actually proves? See the FAQ →

Source

Download Raw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
"""TrendRider — trend-following breakout strategy (for the `trend` regime).

Entry (long only, S1):
- 1h close breaks above the Donchian(20) high of the previous 20 candles
- 4h EMA50 > 4h EMA200 (higher-timeframe uptrend filter)
- 1h ADX(14) > 20 (trend strength filter)

Risk / exit:
- initial stop: 2 x ATR(14) below entry (hard outer stop -10%, S5)
- after price moves +1R in our favour: trail at 1.5 x ATR below price
- exit signal: 1h close below EMA20

Position sizing and the entry gate live in SantinelaBase (riskguard).
Every hyperopt-tunable parameter carries an explicit bounded space.
"""

from __future__ import annotations

from datetime import datetime

from pandas import DataFrame

from freqtrade.strategy import DecimalParameter, IntParameter, informative
from freqtrade.strategy import stoploss_from_absolute

from shared import indicators as ind
from shared.santinela_base import SantinelaBase


class TrendRider(SantinelaBase):
    # --- hyperopt spaces (bounded by design; see §6.2) ---
    donchian_len = IntParameter(15, 40, default=20, space="buy", optimize=True)
    adx_min = IntParameter(15, 35, default=20, space="buy", optimize=True)
    stop_atr = DecimalParameter(1.5, 3.0, default=2.0, decimals=1,
                                space="sell", optimize=True)
    trail_atr = DecimalParameter(1.0, 2.5, default=1.5, decimals=1,
                                 space="sell", optimize=True)
    ema_exit_len = IntParameter(10, 30, default=20, space="sell", optimize=True)

    @property
    def stop_atr_mult(self) -> float:  # used by SantinelaBase sizing
        return float(self.stop_atr.value)

    # Defense-in-depth mirrors of S6-S8 at the freqtrade level.
    # RiskGuard remains the authority; these just add a second net.
    @property
    def protections(self):
        return [
            {"method": "CooldownPeriod", "stop_duration_candles": 2},
            {
                "method": "StoplossGuard",  # ~S8 mirror
                "lookback_period_candles": 48,
                "trade_limit": 6,
                "stop_duration_candles": 12,
                "only_per_pair": False,
            },
            {
                "method": "MaxDrawdown",  # ~S6/S7 mirror
                "lookback_period_candles": 24,
                "trade_limit": 4,
                "max_allowed_drawdown": 0.05,
                "stop_duration_candles": 24,
            },
        ]

    @informative("4h")
    def populate_indicators_4h(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        dataframe["ema50"] = ind.ema(dataframe, 50)
        dataframe["ema200"] = ind.ema(dataframe, 200)
        return dataframe

    def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        dataframe["atr"] = ind.atr(dataframe, 14)
        dataframe["adx"] = ind.adx(dataframe, 14)
        dataframe["donchian_high"] = ind.donchian_high(
            dataframe, self.donchian_len.value
        )
        dataframe["ema_exit"] = ind.ema(dataframe, self.ema_exit_len.value)
        return dataframe

    def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        dataframe.loc[
            (dataframe["close"] > dataframe["donchian_high"])
            & (dataframe["ema50_4h"] > dataframe["ema200_4h"])
            & (dataframe["adx"] > self.adx_min.value)
            & (dataframe["volume"] > 0),
            ["enter_long", "enter_tag"],
        ] = (1, "donchian_breakout")
        return dataframe

    def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        dataframe.loc[
            (dataframe["close"] < dataframe["ema_exit"])
            & (dataframe["volume"] > 0),
            ["exit_long", "exit_tag"],
        ] = (1, "close_below_ema")
        return dataframe

    def custom_stoploss(
        self,
        pair: str,
        trade,
        current_time: datetime,
        current_rate: float,
        current_profit: float,
        after_fill: bool,
        **kwargs,
    ) -> float | None:
        entry_atr = self._atr_at(pair, trade.open_date_utc)
        if entry_atr <= 0:
            return None  # keep hard outer stop (S5)
        initial_risk = self.stop_atr.value * entry_atr / trade.open_rate

        if current_profit >= initial_risk:  # +1R reached -> ATR trail
            current_atr = self._atr_at(pair, current_time) or entry_atr
            stop_price = current_rate - self.trail_atr.value * current_atr
        else:
            stop_price = trade.open_rate - self.stop_atr.value * entry_atr
        # freqtrade only ever tightens the stop; returning a looser value
        # than the current stop is ignored (S5: tighten, never remove).
        return stoploss_from_absolute(
            stop_price, current_rate, is_short=trade.is_short
        )