Timeframe
4h
Direction
Long & Short
Stoploss
-3.0%
Trailing Stop
Yes
ROI
0m: 12.0%, 1440m: 8.0%, 4320m: 4.0%, 8640m: 0.0%
Interface Version
3
Startup Candles
200
Indicators
6
freqtrade/freqtrade-strategies
Strategy 003 author@: Gerald Lonlas github@: https://github.com/freqtrade/freqtrade-strategies
"""
ICT/SMC Strategy — 4h timeframe optimized for crypto
Key adaptations for 4h:
- 3-bar swing detection (12h window)
- 10-bar OB/FVG retest window (40h)
- 30-bar fib lookback (5 days)
- Wider stops for 4h volatility
- Multi-condition confirmation stacking
"""
from pandas import DataFrame
import pandas as pd
import talib.abstract as ta
import numpy as np
from freqtrade.strategy import IStrategy
class ICT_SMC_4H(IStrategy):
INTERFACE_VERSION = 3
timeframe = '4h'
can_short = True
# Wider stops for 4h — each candle is 4 hours
stoploss = -0.03
trailing_stop = True
trailing_stop_positive = 0.01
trailing_stop_positive_offset = 0.025
trailing_only_offset_is_reached = True
# ROI for 4h — longer holding periods
minimal_roi = {"0": 0.12, "1440": 0.08, "4320": 0.04, "8640": 0}
max_open_trades = 4
startup_candle_count = 200
process_only_new_candles = True
use_exit_signal = False
def populate_indicators(self, d: DataFrame, m: dict) -> DataFrame:
# ============================================================
# 1. SWING HIGHS/LOWS — 3-bar window for 4h (12h range)
# ============================================================
d['swing_high'] = (
(d['high'] > d['high'].shift(1)) &
(d['high'] > d['high'].shift(2)) &
(d['high'] >= d['high'].shift(-1)) &
(d['high'] >= d['high'].shift(-2))
)
d['swing_low'] = (
(d['low'] < d['low'].shift(1)) &
(d['low'] < d['low'].shift(2)) &
(d['low'] <= d['low'].shift(-1)) &
(d['low'] <= d['low'].shift(-2))
)
# Forward-fill swing values
d['last_sh'] = d['high'].where(d['swing_high']).ffill()
d['last_sl'] = d['low'].where(d['swing_low']).ffill()
# ============================================================
# 2. BREAK OF STRUCTURE (BOS) & CHoCH
# ============================================================
d['bos_bull'] = d['close'] > d['last_sh'].shift(1)
d['bos_bear'] = d['close'] < d['last_sl'].shift(1)
# CHoCH: trend reversal after sustained BOS sequence
d['bull_bos_6'] = d['bos_bull'].rolling(6).sum()
d['bear_bos_6'] = d['bos_bear'].rolling(6).sum()
d['choch_bear'] = d['bos_bear'] & (d['bull_bos_6'].shift(1) >= 2)
d['choch_bull'] = d['bos_bull'] & (d['bear_bos_6'].shift(1) >= 2)
# ============================================================
# 3. LIQUIDITY SWEEPS — wider range for 4h
# ============================================================
d['hh_20'] = d['high'].rolling(20).max()
d['ll_20'] = d['low'].rolling(20).min()
d['hh_10'] = d['high'].rolling(10).max()
d['ll_10'] = d['low'].rolling(10).min()
# Sweep: wicks through a level then closes back
d['sweep_high'] = (d['high'] >= d['hh_20'].shift(1) * 0.997) & (d['close'] < d['hh_20'].shift(1) * 0.995)
d['sweep_low'] = (d['low'] <= d['ll_20'].shift(1) * 1.003) & (d['close'] > d['ll_20'].shift(1) * 1.005)
# Double sweep: sweeps both sides of a range (stronger signal)
d['sweep_both'] = d['sweep_high'] & d['sweep_low'].shift(1)
# ============================================================
# 4. ORDER BLOCKS — adapted for 4h
# ============================================================
d['body'] = (d['close'] - d['open']).abs()
d['avg_body'] = d['body'].rolling(20).mean()
d['range'] = d['high'] - d['low']
# Displacement: body > 1.5x avg AND range > avg range (significant move)
d['displacement_up'] = (d['close'] > d['open']) & (d['body'] > d['avg_body'] * 1.5) & (d['range'] > d['range'].rolling(20).mean() * 1.2)
d['displacement_down'] = (d['close'] < d['open']) & (d['body'] > d['avg_body'] * 1.5) & (d['range'] > d['range'].rolling(20).mean() * 1.2)
d['displacement'] = d['displacement_up'] | d['displacement_down']
# Bull OB: bear candle before impulsive move up
d['ob_bull'] = d['displacement_up'] & (d['close'].shift(1) < d['open'].shift(1))
# Bear OB: bull candle before impulsive move down
d['ob_bear'] = d['displacement_down'] & (d['close'].shift(1) > d['open'].shift(1))
# OB retest — check up to 10 bars (40h) after formation
d['ob_bull_high'] = np.where(d['ob_bull'], d['high'].shift(1), np.nan)
d['ob_bull_low'] = np.where(d['ob_bull'], d['low'].shift(1), np.nan)
d['ob_bear_high'] = np.where(d['ob_bear'], d['high'].shift(1), np.nan)
d['ob_bear_low'] = np.where(d['ob_bear'], d['low'].shift(1), np.nan)
d['ob_bull_retest'] = False
d['ob_bear_retest'] = False
for lb in range(1, 11):
d['ob_bull_retest'] = d['ob_bull_retest'] | (
d['ob_bull'].shift(lb) &
(d['low'] <= d['ob_bull_high'].shift(lb)) &
(d['close'] >= d['ob_bull_low'].shift(lb))
)
d['ob_bear_retest'] = d['ob_bear_retest'] | (
d['ob_bear'].shift(lb) &
(d['high'] >= d['ob_bear_low'].shift(lb)) &
(d['close'] <= d['ob_bear_high'].shift(lb))
)
# ============================================================
# 5. FAIR VALUE GAP (FVG) — 4h gap detection
# ============================================================
d['fvg_bull'] = d['low'].shift(2) > d['high'].shift(0) * 1.003
d['fvg_bear'] = d['high'].shift(2) < d['low'].shift(0) * 0.997
d['fvg_bull_retest'] = False
d['fvg_bear_retest'] = False
for lb in range(1, 11):
d['fvg_bull_retest'] = d['fvg_bull_retest'] | (
d['fvg_bull'].shift(lb) & (d['low'] <= d['high'].shift(lb - 2))
)
d['fvg_bear_retest'] = d['fvg_bear_retest'] | (
d['fvg_bear'].shift(lb) & (d['high'] >= d['low'].shift(lb - 2))
)
# ============================================================
# 6. FIBONACCI OTE ZONE — 30-bar swing range (5 days)
# ============================================================
d['swing_high_30'] = d['high'].rolling(30).max()
d['swing_low_30'] = d['low'].rolling(30).min()
swing_range = d['swing_high_30'] - d['swing_low_30']
d['fib_062'] = d['swing_high_30'] - swing_range * 0.618
d['fib_079'] = d['swing_high_30'] - swing_range * 0.786
d['fib_050'] = d['swing_high_30'] - swing_range * 0.50
d['fib_062_s'] = d['swing_low_30'] + swing_range * 0.618
d['fib_079_s'] = d['swing_low_30'] + swing_range * 0.786
d['in_ote_long'] = (d['close'] >= d['fib_079']) & (d['close'] <= d['fib_062'])
d['in_ote_short'] = (d['close'] >= d['fib_062_s']) & (d['close'] <= d['fib_079_s'])
# Premium/discount zones
d['in_discount'] = d['close'] <= d['fib_050'] # below 50% = discount for longs
d['in_premium'] = d['close'] >= d['fib_050'] # above 50% = premium for shorts
# ============================================================
# 7. TREND CONFIRMATION
# ============================================================
d['e20'] = ta.EMA(d, timeperiod=20)
d['e50'] = ta.EMA(d, timeperiod=50)
d['e200'] = ta.EMA(d, timeperiod=200)
d['rsi'] = ta.RSI(d, timeperiod=14)
d['adx'] = ta.ADX(d, timeperiod=14)
d['di_plus'] = ta.PLUS_DI(d, timeperiod=14)
d['di_minus'] = ta.MINUS_DI(d, timeperiod=14)
d['atr'] = ta.ATR(d, timeperiod=14)
d['vr'] = d['volume'] / ta.SMA(d['volume'], timeperiod=10)
# MACD for momentum
macd = ta.MACD(d, fastperiod=12, slowperiod=26, signalperiod=9)
d['macd'] = macd['macd']
d['macd_signal'] = macd['macdsignal']
# ============================================================
# 8. PRECEDING TREND BIAS — what happened before the setup
# ============================================================
# Bias score: recent returns + EMA alignment + DMI
d['ret_5d'] = d['close'].pct_change(5) * 100
d['ema_aligned_bull'] = (d['e20'] > d['e50']) & (d['e50'] > d['e200'])
d['ema_aligned_bear'] = (d['e20'] < d['e50']) & (d['e50'] < d['e200'])
d['dmi_bull'] = d['di_plus'] > d['di_minus']
d['dmi_bear'] = d['di_minus'] > d['di_plus']
return d
def populate_entry_trend(self, d: DataFrame, m: dict) -> DataFrame:
# ================================================================
# LONG: 5-confirmation ICT stacking
# ================================================================
# 1. Structure: BOS bull or CHoCH bull (trend reversal up)
structure_long = d['bos_bull'] | d['choch_bull'] | (
d['close'] > d['e50']
)
# 2. Sweep: liquidity taken below (stop hunt) OR in discount
sweep_long = d['sweep_low'] | d['in_discount']
# 3. Entry zone: OB retest or FVG retest or OTE zone
entry_long = d['ob_bull_retest'] | d['fvg_bull_retest'] | d['in_ote_long']
# 4. Trend bias: EMA aligned or DMI bullish or ADX strong
bias_long = d['ema_aligned_bull'] | d['dmi_bull'] | (d['adx'] > 20)
# 5. Momentum: displacement up OR MACD crossing up
momentum_long = d['displacement_up'] | (
(d['macd'] > d['macd_signal']) & (d['macd'].shift(1) <= d['macd_signal'].shift(1))
)
# Confirmation: RSI not extreme, volume present
confirm_long = d['rsi'].between(30, 65) & (d['vr'] > 0.5)
# STACK: at least 3 of 5 conditions + confirmation
conditions_long = (
structure_long.astype(int) +
sweep_long.astype(int) +
entry_long.astype(int) +
bias_long.astype(int) +
momentum_long.astype(int)
)
d.loc[
(conditions_long >= 3) & confirm_long & (d['volume'] > 0),
['enter_long', 'enter_tag']
] = (1, 'ICT4_L')
# ================================================================
# SHORT: 5-confirmation ICT stacking
# ================================================================
structure_short = d['bos_bear'] | d['choch_bear'] | (
d['close'] < d['e50']
)
sweep_short = d['sweep_high'] | d['in_premium']
entry_short = d['ob_bear_retest'] | d['fvg_bear_retest'] | d['in_ote_short']
bias_short = d['ema_aligned_bear'] | d['dmi_bear'] | (d['adx'] > 20)
momentum_short = d['displacement_down'] | (
(d['macd'] < d['macd_signal']) & (d['macd'].shift(1) >= d['macd_signal'].shift(1))
)
confirm_short = d['rsi'].between(35, 70) & (d['vr'] > 0.5)
conditions_short = (
structure_short.astype(int) +
sweep_short.astype(int) +
entry_short.astype(int) +
bias_short.astype(int) +
momentum_short.astype(int)
)
d.loc[
(conditions_short >= 3) & confirm_short & (d['volume'] > 0),
['enter_short', 'enter_tag']
] = (1, 'ICT4_S')
return d
def populate_exit_trend(self, d: DataFrame, m: dict) -> DataFrame:
return d