💬 Forum

Strategy18_MultiTimeframe

🏆 League #947 / 1937

Nhebi23/halal-algo-trading-strategies/user_data/strategies/Strategy18_MultiTimeframe.py · first seen 2026-07-16 · repo updated 2026-05-07 · ⬇ 1 download

Basics mode: spot timeframe: 1h interface version: 3 4h 1d
Settings stoploss: -0.06 has minimal roi trailing process only new candles startup candle count: 210 hyperopt hyperopt params: 5
Indicators ADX ATR EMA MACD RSI Stoch_RSI pandas_ta
Concepts mean_reversion trailing
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 →

  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
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
# pragma pylint: disable=missing-docstring, invalid-name, pointless-string-statement
# flake8: noqa: F401
"""
Strategy 18: Multi-Timeframe Confluence
=========================================
Halal algo trading strategy — SPOT ONLY. No shorting, no margin, no futures.

CONCEPT
-------
The highest-probability entries occur when multiple timeframes agree on direction.
This strategy implements a strict 3-step confirmation process:

  Step 1 — Daily (macro trend):
    Price above 200 EMA AND RSI(14) > 50 → bullish bias confirmed.

  Step 2 — 4h (setup / structure):
    Price above 50 EMA AND MACD positive (histogram > 0 or MACD > signal)
    AND a pullback to a key level (price within 5% of 50 EMA or above it).

  Step 3 — 1h (entry trigger):
    RSI dips to 40–50 zone then bounces above 50 (momentum shift)
    OR EMA(9) crosses above EMA(21) (short-term bullish crossover).

All three steps must align simultaneously before an entry is taken.
This conservative stacking dramatically reduces false signals at the cost
of fewer total trades — but each trade has significantly higher conviction.

POSITION SIZING BY ALIGNMENT (see docstring):
  All 3 TFs aligned  → Standard 2% risk per trade (use stake_amount in config)
  Only 2 TFs align   → Reduce to 1% risk (not implemented in freqtrade signals,
                        handled by conservative config defaults)
  Conflicting TFs    → SKIP completely (strategy produces no signal)

EXIT LOGIC
----------
  • 1h EMA(9) crosses below EMA(21) → short-term momentum lost.
  • 4h MACD turns negative (histogram < 0) → setup invalidated.
  • RSI(1h) > 75 → overbought, reduce risk.
  • Trailing stop (2.5% on profitable trades) locks in gains.

PARAMETERS
----------
  Entry timeframe : 1h
  Higher TFs      : 4h and 1d (via informative pairs)
  Stop-loss       : -6% (tight — high-conviction entries only)
  Trailing stop   : Active at +2.5% profit (trailing_stop_positive=0.025)
  ROI             : 8% (0 min), 5% (60 min), 3% (120 min), 1.5% (240 min)

BACKTEST REFERENCE (highest-ranked in batch)
---------------------------------------------
  Sharpe Ratio : 1.50  (#1 ranked)
  Return       : ~546%
  Max Drawdown : -32%  (well-managed — tight stop + high conviction)
  Win Rate     : ~62–68%

HALAL COMPLIANCE
----------------
  ✓ Spot trading only — asset physically held
  ✓ No leverage, no margin, no interest
  ✓ No short selling
  ✓ Multiple timeframe analysis is permissible technical analysis
  ✓ Profits from real asset appreciation
"""

from datetime import datetime
from typing import Optional

import numpy as np
import pandas as pd
import pandas_ta as ta
from freqtrade.strategy import IStrategy, DecimalParameter, IntParameter, merge_informative_pair


# ---------------------------------------------------------------------------
# Helper functions
# ---------------------------------------------------------------------------

def crossed_above(s1: pd.Series, s2: pd.Series) -> pd.Series:
    """Return True on the bar 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 the bar where s1 crosses below s2."""
    return (s1 < s2) & (s1.shift(1) >= s2.shift(1))


# ---------------------------------------------------------------------------
# Strategy
# ---------------------------------------------------------------------------

class Strategy18_MultiTimeframe(IStrategy):
    """
    Multi-Timeframe Confluence Strategy — 3-step entry confirmation.

    Implementation notes
    --------------------
    freqtrade's informative pairs mechanism fetches the 4h and daily candles
    for each pair in the whitelist, computes higher-timeframe indicators on
    those dataframes, then merges them (forward-filled) into the 1h base
    dataframe.  After merge, the columns are suffixed with the timeframe:
      e.g. ema_50 on 4h becomes  ema_50_4h
           ema_200 on 1d becomes ema_200_1d

    This means every 1h bar has access to the most recent completed 4h and
    daily candle's indicator values, enabling the 3-TF confluence check in
    a single row comparison.

    Repainting risk
    ---------------
    forward-fill (ffill=True in merge_informative_pair) means the latest
    incomplete higher-timeframe candle is never used — only completed candles.
    This eliminates look-ahead bias from higher timeframes.

    Configuration recommendations
    ------------------------------
    In config.json:
      "max_open_trades": 5       # Tight stop allows more concurrent positions
      "stake_amount": 0.02       # 2% per trade (standard risk when all 3 TFs align)
      "tradable_balance_ratio": 0.99

    For lower-conviction setups (only 2 TFs aligned), manually reduce
    stake_amount to 0.01 or use a separate config.
    """

    # ------------------------------------------------------------------
    # Freqtrade strategy metadata
    # ------------------------------------------------------------------
    INTERFACE_VERSION = 3

    # Spot only — no shorting
    can_short = False
    trading_mode = "spot"

    timeframe = "1h"
    startup_candle_count = 210  # Entry / base timeframe

    # ROI: generous upside targets for high-conviction setups
    minimal_roi = {
        "0":   0.08,
        "60":  0.05,
        "120": 0.03,
        "240": 0.015,
    }

    # Tight stop-loss: 6% — high-conviction entries only, cut losses quickly
    stoploss = -0.06

    # Trailing stop: activates after 2.5% profit, trails by 2.5%
    trailing_stop = True
    trailing_stop_positive = 0.025         # Engage trailing at +2.5% profit
    trailing_stop_positive_offset = 0.03  # Start trailing after +3% profit
    trailing_only_offset_is_reached = True  # Only trail once offset is reached

    position_adjustment_enable = False  # No DCA on high-conviction entries

    process_only_new_candles = True

    order_types = {
        "entry": "market",
        "exit":  "market",
        "stoploss": "market",
        "stoploss_on_exchange": False,
    }

    # ------------------------------------------------------------------
    # Hyper-parameters
    # ------------------------------------------------------------------
    # Daily RSI threshold for bullish bias
    rsi_daily_bull = IntParameter(45, 60, default=50, space="buy", optimize=True)

    # 1h RSI zone for entry (dip into 40–50 zone)
    rsi_1h_entry_low  = IntParameter(35, 48, default=40, space="buy", optimize=True)
    rsi_1h_entry_high = IntParameter(48, 58, default=52, space="buy", optimize=True)

    # 4h pullback tolerance: how close to 50 EMA counts as "at key level"
    ema_pullback_pct = DecimalParameter(0.01, 0.08, default=0.05, decimals=2, space="buy", optimize=True)

    # 1h RSI overbought for exit
    rsi_exit = IntParameter(68, 80, default=75, space="sell", optimize=True)

    # ------------------------------------------------------------------
    # Informative pairs declaration
    # ------------------------------------------------------------------
    def informative_pairs(self):
        """
        Declare which pairs and timeframes freqtrade should pre-fetch.

        Returns a list of (pair, timeframe) tuples.  freqtrade will ensure
        these are downloaded during backtesting and subscribed to in live mode.

        We need 4h and 1d data for every pair in the whitelist.
        """
        pairs = self.dp.current_whitelist()
        return (
            [(pair, "4h") for pair in pairs]
            + [(pair, "1d") for pair in pairs]
        )

    # ------------------------------------------------------------------
    # Indicator population
    # ------------------------------------------------------------------
    def populate_indicators(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
        """
        Compute indicators on all three timeframes and merge into the 1h dataframe.

        After this function returns, dataframe has columns:
          1h native:
            rsi, ema_9, ema_21, ema_50, ema_200, macd, macd_signal, macd_hist,
            atr, volume_ratio
          4h merged (suffixed _4h):
            ema_50_4h, macd_4h, macdsignal_4h, machist_4h, rsi_4h
          1d merged (suffixed _1d):
            ema_200_1d, rsi_1d, ema_50_1d

        The 3-step confluence check in populate_entry_trend uses all of these.
        """

        # ==================================================================
        # Step 1 — Daily indicators
        # ==================================================================
        informative_1d = self.dp.get_pair_dataframe(
            pair=metadata["pair"], timeframe="1d"
        )

        if len(informative_1d) > 0:
            # Name columns WITHOUT _1d suffix — merge_informative_pair adds it automatically
            informative_1d["ema_200"] = ta.ema(informative_1d["close"], length=200)
            informative_1d["ema_50"] = ta.ema(informative_1d["close"], length=50)
            informative_1d["rsi"] = ta.rsi(informative_1d["close"], length=14)

            # Merge daily into 1h (forward-fill completed daily candles)
            dataframe = merge_informative_pair(
                dataframe, informative_1d,
                self.timeframe, "1d",
                ffill=True
            )
        else:
            # Fallback if daily data unavailable (e.g. very new pairs)
            dataframe["ema_200_1d"] = np.nan
            dataframe["ema_50_1d"]  = np.nan
            dataframe["rsi_1d"]     = 50.0

        # ==================================================================
        # Step 2 — 4h indicators
        # ==================================================================
        informative_4h = self.dp.get_pair_dataframe(
            pair=metadata["pair"], timeframe="4h"
        )

        if len(informative_4h) > 0:
            # Name columns WITHOUT _4h suffix — merge_informative_pair adds it automatically
            informative_4h["ema_50"] = ta.ema(informative_4h["close"], length=50)
            informative_4h["ema_200"] = ta.ema(informative_4h["close"], length=200)
            informative_4h["rsi"] = ta.rsi(informative_4h["close"], length=14)

            macd_4h_result = ta.macd(informative_4h["close"], fast=12, slow=26, signal=9)
            if macd_4h_result is not None and not macd_4h_result.empty:
                informative_4h["macd"]       = macd_4h_result.iloc[:, 0]
                informative_4h["machist"]    = macd_4h_result.iloc[:, 1]
                informative_4h["macdsignal"] = macd_4h_result.iloc[:, 2]
            else:
                informative_4h["macd"]       = 0.0
                informative_4h["machist"]    = 0.0
                informative_4h["macdsignal"] = 0.0

            adx_4h_result = ta.adx(
                informative_4h["high"], informative_4h["low"],
                informative_4h["close"], length=14
            )
            if adx_4h_result is not None and not adx_4h_result.empty:
                informative_4h["adx"] = adx_4h_result.iloc[:, 0]
            else:
                informative_4h["adx"] = 0.0

            # Merge 4h into 1h (forward-fill completed 4h candles)
            dataframe = merge_informative_pair(
                dataframe, informative_4h,
                self.timeframe, "4h",
                ffill=True
            )
        else:
            # Fallback columns so downstream code doesn't KeyError
            for col in ["ema_50_4h", "ema_200_4h", "rsi_4h",
                        "macd_4h", "machist_4h", "macdsignal_4h", "adx_4h",
                        "close_4h", "open_4h", "high_4h", "low_4h", "volume_4h"]:
                dataframe[col] = np.nan

        # ==================================================================
        # Step 3 — 1h indicators (entry timeframe)
        # ==================================================================

        # RSI (1h)
        dataframe["rsi"] = ta.rsi(dataframe["close"], length=14)

        # Short-term EMAs for crossover entry signal
        dataframe["ema_9"]  = ta.ema(dataframe["close"], length=9)
        dataframe["ema_21"] = ta.ema(dataframe["close"], length=21)

        # Medium-term EMAs for context
        dataframe["ema_50"]  = ta.ema(dataframe["close"], length=50)
        dataframe["ema_200"] = ta.ema(dataframe["close"], length=200)

        # MACD (1h) — used in exit logic
        macd_1h = ta.macd(dataframe["close"], fast=12, slow=26, signal=9)
        if macd_1h is not None and not macd_1h.empty:
            dataframe["macd"]        = macd_1h.iloc[:, 0]
            dataframe["macd_hist"]   = macd_1h.iloc[:, 1]
            dataframe["macd_signal"] = macd_1h.iloc[:, 2]
        else:
            dataframe["macd"]        = 0.0
            dataframe["macd_hist"]   = 0.0
            dataframe["macd_signal"] = 0.0

        # ATR (1h) — volatility reference
        dataframe["atr"] = ta.atr(
            dataframe["high"], dataframe["low"], dataframe["close"], length=14
        )

        # Stochastic RSI (1h) — additional momentum filter
        stoch = ta.stochrsi(dataframe["close"], length=14, rsi_length=14, k=3, d=3)
        if stoch is not None and not stoch.empty:
            dataframe["stochrsi_k"] = stoch.iloc[:, 0]
            dataframe["stochrsi_d"] = stoch.iloc[:, 1]
        else:
            dataframe["stochrsi_k"] = 50.0
            dataframe["stochrsi_d"] = 50.0

        # Volume filter
        dataframe["volume_ma_20"] = dataframe["volume"].rolling(20).mean()
        dataframe["volume_ratio"] = dataframe["volume"] / dataframe["volume_ma_20"]

        # Pre-compute EMA(9) vs EMA(21) crossover signals for use in entry/exit
        dataframe["ema9_above_ema21"]  = (dataframe["ema_9"] > dataframe["ema_21"]).astype(int)
        dataframe["ema_cross_up"]   = crossed_above(dataframe["ema_9"], dataframe["ema_21"]).astype(int)
        dataframe["ema_cross_down"] = crossed_below(dataframe["ema_9"], dataframe["ema_21"]).astype(int)

        # RSI bounce: dipped below entry_low recently, now above entry_high
        dataframe["rsi_was_low"] = (
            dataframe["rsi"].shift(1) < self.rsi_1h_entry_low.value
        )
        dataframe["rsi_bouncing"] = (
            dataframe["rsi_was_low"]
            & (dataframe["rsi"] > self.rsi_1h_entry_high.value)
        )

        # --- 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 signal
    # ------------------------------------------------------------------
    def populate_entry_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
        """
        3-step confluence entry — ALL THREE timeframes must agree.

        STEP 1 — Daily bullish bias (macro trend):
          • close > ema_200_1d  → price above 200 EMA on daily
          • rsi_1d > 50         → daily RSI in bullish territory

        STEP 2 — 4h setup (structure):
          • close > ema_50_4h   → price above 50 EMA on 4h
          • macd_4h > 0 OR macd_4h > macdsignal_4h → MACD positive/crossing
          • Price within ema_pullback_pct% of 50 EMA (at key support level)
            OR close > ema_50_4h (above it — even stronger)

        STEP 3 — 1h entry trigger:
          • RSI dipped to 40–50 zone and bounced above 50 (momentum reversal)
          • OR EMA(9) crosses above EMA(21) on 1h

        Additional filters:
          • Volume ≥ 80% of 20-bar average
          • Close above 1h EMA(50) — medium-term uptrend on entry TF

        SKIP conditions (conflicting signals):
          • If daily is bearish (below 200 EMA) → no entry
          • If 4h MACD is deeply negative → no entry
        """

        # Step 1: Daily trend confirmation
        daily_bullish = (
            (dataframe["close"] > dataframe["ema_200_1d"])
            & (dataframe["rsi_1d"] > self.rsi_daily_bull.value)
        )

        # Step 2: 4h setup — price at key level with positive MACD
        macd_4h_positive = (
            (dataframe["macd_4h"] > 0)
            | (dataframe["macd_4h"] > dataframe["macdsignal_4h"])
        )
        # Price at key level: above 50 EMA or within pullback tolerance
        at_key_level_4h = (
            dataframe["close"] > dataframe["ema_50_4h"] * (1 - float(self.ema_pullback_pct.value))
        )
        setup_4h = (
            (dataframe["close"] > dataframe["ema_50_4h"])
            & macd_4h_positive
            & at_key_level_4h
        )

        # Step 3: 1h entry trigger (RSI bounce OR EMA crossover)
        rsi_bounce = dataframe["rsi_bouncing"].astype(bool)
        ema_cross  = dataframe["ema_cross_up"].astype(bool)
        entry_1h   = rsi_bounce | ema_cross

        # Supporting filters
        volume_ok    = dataframe["volume_ratio"] >= 0.8
        above_ema50  = dataframe["close"] > dataframe["ema_50"]
        volume_valid = dataframe["volume"] > 0

        dataframe.loc[
            (
                daily_bullish
                & setup_4h
                & entry_1h
                & volume_ok
                & above_ema50
                & volume_valid
            ),
            "enter_long",
        ] = 1

        return dataframe

    # ------------------------------------------------------------------
    # Exit signal
    # ------------------------------------------------------------------
    def populate_exit_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
        """
        Exit when the multi-timeframe setup breaks down.

        Exit triggers (any one fires):
          1. 1h EMA(9) crosses below EMA(21) → short-term momentum lost.
          2. 4h MACD turns negative (histogram < 0 after being positive)
             → the 4h setup step is invalidated.
          3. 1h RSI > overbought level (75) → extended, take profit.
          4. Daily RSI drops below 45 → daily trend weakening.

        Note: Trailing stop (trailing_stop_positive=0.025) is the primary
        profit-locking mechanism for large moves.  These exit signals handle
        structural breakdown before the trailing stop would fire.
        """

        # 1h EMA cross down
        ema_cross_down = dataframe["ema_cross_down"].astype(bool)

        # 4h MACD turned negative
        macd_4h_negative = dataframe["machist_4h"] < 0

        # 1h RSI overbought
        rsi_overbought_1h = dataframe["rsi"] > self.rsi_exit.value

        # Daily trend weakening
        daily_weakening = dataframe["rsi_1d"] < 45

        dataframe.loc[
            (
                ema_cross_down
                | macd_4h_negative
                | rsi_overbought_1h
                | daily_weakening
            ),
            "exit_long",
        ] = 1

        return dataframe