Skip to content
Back to App

ICT Smart Money Strategy

The ICT (Inner Circle Trader) Smart Money Concepts framework is an institutional-style approach to trading that focuses on how large players manipulate price. Instead of relying on lagging indicators, it reads the footprint of institutional order flow directly from price action.

This tutorial builds a complete ICT strategy from scratch, covering four core concepts:

  1. Market Structure — Break of Structure (BOS) and Change of Character (CHoCH)
  2. Fair Value Gaps (FVG) — Price imbalances that act as magnets
  3. Liquidity Sweeps — Stops being hunted before the real move
  4. Kill Zones — Trading only during high-probability sessions

Concept 1: Market Structure

Market structure is the foundation of ICT trading. It defines the trend by tracking swing highs and swing lows.

Swing Highs and Swing Lows

A swing high is a bar whose high is the highest of the surrounding N bars. A swing low is a bar whose low is the lowest of the surrounding N bars.

def find_swing_high(highs, idx, lookback=10):
"""Check if the bar at `idx` is a swing high (highest in its window)."""
if idx < lookback or idx >= len(highs):
return False
window = highs[idx - lookback:idx + 1]
return highs[idx] == max(window)
def find_swing_low(lows, idx, lookback=10):
"""Check if the bar at `idx` is a swing low (lowest in its window)."""
if idx < lookback or idx >= len(lows):
return False
window = lows[idx - lookback:idx + 1]
return lows[idx] == min(window)

Detecting Swing Points in Real Time

Since we are processing bars left to right, we check slightly behind the current bar to allow for confirmation:

swing_highs = [] # List of (index, price) tuples
swing_lows = []
# Inside main loop:
check_idx = idx - SWING_LOOKBACK // 2
if check_idx > 0:
if find_swing_high(highs, check_idx):
swing_highs.append((check_idx, highs[check_idx]))
if find_swing_low(lows, check_idx):
swing_lows.append((check_idx, lows[check_idx]))
# Keep only the most recent 20 swing points
swing_highs = swing_highs[-20:]
swing_lows = swing_lows[-20:]

Determining Market Structure

With swing points identified, the structure is determined by comparing consecutive swing highs and swing lows:

  • Bullish structure: Higher highs AND higher lows
  • Bearish structure: Lower highs AND lower lows
  • Neutral: Mixed — no clear trend
def get_market_structure(highs, lows, swing_highs, swing_lows):
"""Determine current market structure from swing points."""
if len(swing_highs) < 2 or len(swing_lows) < 2:
return 'neutral'
last_sh = swing_highs[-1][1] # Most recent swing high price
prev_sh = swing_highs[-2][1] # Previous swing high price
last_sl = swing_lows[-1][1] # Most recent swing low price
prev_sl = swing_lows[-2][1] # Previous swing low price
if last_sh > prev_sh and last_sl > prev_sl:
return 'bullish' # Higher highs + higher lows
elif last_sh < prev_sh and last_sl < prev_sl:
return 'bearish' # Lower highs + lower lows
return 'neutral'

Break of Structure (BOS) vs. Change of Character (CHoCH)

  • BOS (Break of Structure): Price breaks a swing point in the direction of the trend. In a bullish trend, price makes a new higher high. This confirms the trend continues.
  • CHoCH (Change of Character): Price breaks a swing point against the trend. In a bullish trend, price breaks below the most recent swing low. This signals a potential reversal.

The get_market_structure function captures both implicitly: when the structure shifts from bullish to bearish, a CHoCH has occurred.


Concept 2: Fair Value Gaps (FVG)

A Fair Value Gap is a three-candle pattern where the middle candle moves so aggressively that it leaves a gap between candle 1 and candle 3. This imbalance suggests price will likely return to fill the gap.

Bullish FVG

Candle 3’s low is above candle 1’s high. The gap between them is the FVG. Price is expected to retrace down into this gap before continuing higher.

Bearish FVG

Candle 3’s high is below candle 1’s low. Price is expected to retrace up into this gap before continuing lower.

def detect_fvg(opens, highs, lows, closes, idx):
"""Detect Fair Value Gap at the current bar (looking back 3 bars).
Returns: ('bullish'|'bearish', gap_low, gap_high) or None
"""
if idx < 2:
return None
# Bullish FVG: candle 3 low > candle 1 high (gap up)
if lows[idx] > highs[idx - 2]:
gap_size = lows[idx] - highs[idx - 2]
if gap_size >= FVG_MIN_SIZE:
return ('bullish', highs[idx - 2], lows[idx])
# Bearish FVG: candle 3 high < candle 1 low (gap down)
if highs[idx] < lows[idx - 2]:
gap_size = lows[idx - 2] - highs[idx]
if gap_size >= FVG_MIN_SIZE:
return ('bearish', highs[idx], lows[idx - 2])
return None

Tracking Active FVGs

FVGs are not permanent. They expire if they are not visited within a reasonable number of bars:

active_fvgs = [] # List of (type, low, high, bar_index) tuples
# After detecting an FVG:
fvg = detect_fvg(opens, highs, lows, closes, idx)
if fvg:
active_fvgs.append((fvg[0], fvg[1], fvg[2], idx))
# Clean up: remove FVGs older than 50 bars, keep max 20
active_fvgs = [(t, l, h, bi) for t, l, h, bi in active_fvgs if idx - bi < 50][-20:]

Using FVGs for Entry

The entry trigger: price retraces into an active FVG in the direction of the market structure:

# Check if current price is inside a bullish FVG (during bullish structure)
if structure == 'bullish':
for fvg_type, fvg_low, fvg_high, fvg_idx in active_fvgs:
if fvg_type == 'bullish' and fvg_low <= price <= fvg_high:
# Price is retracing into a bullish FVG — BUY signal
print(f"[SIGNAL] Price in bullish FVG: {fvg_low:.2f} - {fvg_high:.2f}")
break

Concept 3: Liquidity Sweeps

Institutions need liquidity to fill large orders. They get it by running price into clusters of stop losses — typically placed just beyond recent swing highs and lows. A liquidity sweep happens when price takes out a swing level and then immediately reverses.

def detect_liquidity_sweep(highs, lows, closes, idx, swing_highs, swing_lows):
"""Detect if the current bar swept liquidity at a swing point.
Returns: 'bullish_sweep' | 'bearish_sweep' | None
"""
if idx < 2:
return None
# Bullish sweep: price dips below a swing low (taking out stops)
# then closes back above it (rejection = reversal)
for sh_idx, sh_price in swing_lows[-5:]:
if lows[idx] < sh_price and closes[idx] > sh_price:
return 'bullish_sweep'
# Bearish sweep: price spikes above a swing high (taking out stops)
# then closes back below it (rejection = reversal)
for sh_idx, sh_price in swing_highs[-5:]:
if highs[idx] > sh_price and closes[idx] < sh_price:
return 'bearish_sweep'
return None

Concept 4: Kill Zones

Not all hours are created equal. ICT trading focuses on specific high-probability time windows called Kill Zones, when institutional order flow is most active:

Kill ZoneUTC TimeCharacter
London Open07:00-10:00Often sets the daily high or low
NY Open13:30-16:00Highest volume, sharpest moves
NY Close19:00-20:00End-of-day rebalancing
def is_in_kill_zone(bar_time_str, kz_start=13, kz_end=20):
"""Check if the bar falls within the NY session kill zone."""
try:
hour = int(bar_time_str[11:13])
return kz_start <= hour < kz_end
except:
return False

Trading only during kill zones dramatically reduces noise trades and false signals.


Timeframe Configuration

ICT parameters must adapt to the candle timeframe. What constitutes a “large” FVG on 1-second bars is tiny on 5-minute bars:

TIMEFRAME_CONFIGS = {
"1s": {
"fvg_min_size": 1.0, # Points — smaller gaps on 1s
"swing_lookback": 30, # 30 seconds of swings
"structure_lookback": 120, # 2 minutes of structure
"sl_ticks": 8, # 2 points SL (tight for scalping)
"tp_ratio": 1.5, # 1.5:1 R:R
"max_daily_loss": 500,
"max_trades": 20,
"max_loss_streak": 3,
},
"1m": {
"fvg_min_size": 2.0, # Standard FVG size
"swing_lookback": 10, # 10 candles = 10 minutes
"structure_lookback": 30, # 30 candles = 30 minutes
"sl_ticks": 20, # 5 points SL
"tp_ratio": 2.0, # 2:1 R:R
"max_daily_loss": 1000,
"max_trades": 10,
"max_loss_streak": 3,
},
"5m": {
"fvg_min_size": 5.0, # Bigger gaps on 5m
"swing_lookback": 8, # 8 candles = 40 minutes
"structure_lookback": 20, # 20 candles = 100 minutes
"sl_ticks": 40, # 10 points SL
"tp_ratio": 2.5, # 2.5:1 R:R
"max_daily_loss": 1500,
"max_trades": 5,
"max_loss_streak": 2,
},
"15m": {
"fvg_min_size": 10.0,
"swing_lookback": 6,
"structure_lookback": 16,
"sl_ticks": 80, # 20 points SL
"tp_ratio": 3.0, # 3:1 R:R
"max_daily_loss": 2000,
"max_trades": 3,
"max_loss_streak": 2,
},
"1h": {
"fvg_min_size": 20.0,
"swing_lookback": 5,
"structure_lookback": 12,
"sl_ticks": 160, # 40 points SL
"tp_ratio": 3.0,
"max_daily_loss": 2000,
"max_trades": 2,
"max_loss_streak": 2,
},
}

The Entry Logic

The strategy enters when multiple ICT concepts align:

Bullish entry requires:

  1. Market structure is bullish (higher highs, higher lows)
  2. Either: price retraces into a bullish FVG, OR a bullish liquidity sweep occurs
  3. We are inside a kill zone
  4. No risk limits have been hit

Bearish entry requires:

  1. Market structure is bearish (lower highs, lower lows)
  2. Either: price retraces into a bearish FVG, OR a bearish liquidity sweep occurs
  3. We are inside a kill zone
  4. No risk limits have been hit
# Entry logic (simplified)
if position is not None or not in_kz or daily_locked:
continue # Skip — not eligible
if structure == 'bullish':
# Check for bullish FVG entry
bullish_fvg_entry = None
for fvg_type, fvg_low, fvg_high, fvg_idx in active_fvgs:
if fvg_type == 'bullish' and fvg_low <= price <= fvg_high:
bullish_fvg_entry = (fvg_low, fvg_high)
break
if bullish_fvg_entry or sweep == 'bullish_sweep':
tp_ticks = int(SL_TICKS * TP_RATIO)
result = place_order(ACCOUNT_ID, CONTRACT_ID, 0, 1,
sl_ticks=SL_TICKS, tp_ticks=tp_ticks)
if result.get("success") or result.get("orderId"):
position = 'LONG'
entry_price = price
reason = "FVG retracement" if bullish_fvg_entry else "Liquidity sweep"
print(f"[BUY] @ {price:.2f} | {reason} | {structure}")

Complete ICT Strategy Implementation

Here is the full, runnable strategy. This is the same code used in TestMax’s built-in ICT template:

#!/usr/bin/env python3
"""
ICT Smart Money Strategy — TestMax Algo Playground
Market Structure + Fair Value Gaps + Liquidity Sweeps + Kill Zones
"""
import os, sys, time
# --- CONFIG ---
ACCOUNT_ID = int(os.environ.get("ACCOUNT_ID", "0"))
CONTRACT_ID = os.environ.get("CONTRACT_ID", "")
TOTAL_BARS = int(os.environ.get("TOTAL_BARS", "5000"))
STEP_DELAY = float(os.environ.get("STEP_DELAY", "0.02"))
SPEED_FILE = os.environ.get("SPEED_FILE", "")
TIMEFRAME = os.environ.get("TIMEFRAME", "1m")
# Timeframe-specific settings
TIMEFRAME_CONFIGS = {
"1s": {"fvg_min_size": 1.0, "swing_lookback": 30, "structure_lookback": 120,
"sl_ticks": 8, "tp_ratio": 1.5, "max_daily_loss": 500, "max_trades": 20, "max_loss_streak": 3},
"1m": {"fvg_min_size": 2.0, "swing_lookback": 10, "structure_lookback": 30,
"sl_ticks": 20, "tp_ratio": 2.0, "max_daily_loss": 1000, "max_trades": 10, "max_loss_streak": 3},
"5m": {"fvg_min_size": 5.0, "swing_lookback": 8, "structure_lookback": 20,
"sl_ticks": 40, "tp_ratio": 2.5, "max_daily_loss": 1500, "max_trades": 5, "max_loss_streak": 2},
"15m": {"fvg_min_size": 10.0, "swing_lookback": 6, "structure_lookback": 16,
"sl_ticks": 80, "tp_ratio": 3.0, "max_daily_loss": 2000, "max_trades": 3, "max_loss_streak": 2},
"1h": {"fvg_min_size": 20.0, "swing_lookback": 5, "structure_lookback": 12,
"sl_ticks": 160, "tp_ratio": 3.0, "max_daily_loss": 2000, "max_trades": 2, "max_loss_streak": 2},
}
_cfg = TIMEFRAME_CONFIGS.get(TIMEFRAME, TIMEFRAME_CONFIGS["1m"])
FVG_MIN_SIZE = _cfg["fvg_min_size"]
SWING_LOOKBACK = _cfg["swing_lookback"]
STRUCTURE_LOOKBACK = _cfg["structure_lookback"]
SL_TICKS = _cfg["sl_ticks"]
TP_RATIO = _cfg["tp_ratio"]
MAX_DAILY_LOSS = _cfg["max_daily_loss"]
MAX_TRADES_PER_DAY = _cfg["max_trades"]
MAX_LOSS_STREAK = _cfg["max_loss_streak"]
KZ_START = 13 # NY session start (UTC)
KZ_END = 20 # NY session end (UTC)
print(f"[INFO] Strategy: ICT Smart Money | TF: {TIMEFRAME}")
print(f"[INFO] FVG>{FVG_MIN_SIZE}pts | Swing={SWING_LOOKBACK} | SL={SL_TICKS}t | TP={TP_RATIO}:1")
print(f"[INFO] Kill Zone: {KZ_START}:00-{KZ_END}:00 UTC")
print("-" * 60)
# --- ICT FUNCTIONS ---
def find_swing_high(highs, idx, lookback=SWING_LOOKBACK):
if idx < lookback or idx >= len(highs):
return False
return highs[idx] == max(highs[idx - lookback:idx + 1])
def find_swing_low(lows, idx, lookback=SWING_LOOKBACK):
if idx < lookback or idx >= len(lows):
return False
return lows[idx] == min(lows[idx - lookback:idx + 1])
def detect_fvg(opens, highs, lows, closes, idx):
if idx < 2:
return None
if lows[idx] > highs[idx - 2]:
if lows[idx] - highs[idx - 2] >= FVG_MIN_SIZE:
return ('bullish', highs[idx - 2], lows[idx])
if highs[idx] < lows[idx - 2]:
if lows[idx - 2] - highs[idx] >= FVG_MIN_SIZE:
return ('bearish', highs[idx], lows[idx - 2])
return None
def detect_liquidity_sweep(highs, lows, closes, idx, swing_highs, swing_lows):
if idx < 2:
return None
for _, sh_price in swing_lows[-5:]:
if lows[idx] < sh_price and closes[idx] > sh_price:
return 'bullish_sweep'
for _, sh_price in swing_highs[-5:]:
if highs[idx] > sh_price and closes[idx] < sh_price:
return 'bearish_sweep'
return None
def get_market_structure(swing_highs, swing_lows):
if len(swing_highs) < 2 or len(swing_lows) < 2:
return 'neutral'
if swing_highs[-1][1] > swing_highs[-2][1] and swing_lows[-1][1] > swing_lows[-2][1]:
return 'bullish'
elif swing_highs[-1][1] < swing_highs[-2][1] and swing_lows[-1][1] < swing_lows[-2][1]:
return 'bearish'
return 'neutral'
def is_in_kill_zone(t):
try:
return KZ_START <= int(t[11:13]) < KZ_END
except:
return False
# --- STATE ---
opens, highs, lows, closes, times = [], [], [], [], []
swing_highs, swing_lows = [], []
active_fvgs = []
position = None
entry_price = 0.0
balance_before_trade = 50000
trade_count = 0
wins = 0
current_day = ""
daily_pnl = 0.0
daily_trades = 0
daily_locked = False
daily_loss_streak = 0
# --- MAIN LOOP ---
try:
for i in range(TOTAL_BARS):
bar = get_next_bar()
if bar is None:
print("[INFO] No more bars")
break
read_speed()
if STEP_DELAY > 0:
time.sleep(STEP_DELAY)
opens.append(bar["o"])
highs.append(bar["h"])
lows.append(bar["l"])
closes.append(bar["c"])
times.append(bar.get("t", ""))
idx = len(closes) - 1
price = closes[-1]
# Daily reset
bar_day = bar.get("t", "")[:10]
if bar_day != current_day:
if current_day:
print(f"[INFO] Day {current_day} | P&L: ${daily_pnl:+,.2f} | Trades: {daily_trades}")
current_day = bar_day
daily_pnl = 0.0
daily_trades = 0
daily_locked = False
daily_loss_streak = 0
if idx < STRUCTURE_LOOKBACK:
continue
# --- Identify Swing Points ---
check_idx = idx - SWING_LOOKBACK // 2
if check_idx > 0:
if find_swing_high(highs, check_idx):
swing_highs.append((check_idx, highs[check_idx]))
if find_swing_low(lows, check_idx):
swing_lows.append((check_idx, lows[check_idx]))
swing_highs = swing_highs[-20:]
swing_lows = swing_lows[-20:]
# --- Market Structure ---
structure = get_market_structure(swing_highs, swing_lows)
# --- Fair Value Gaps ---
fvg = detect_fvg(opens, highs, lows, closes, idx)
if fvg:
active_fvgs.append((fvg[0], fvg[1], fvg[2], idx))
active_fvgs = [(t, l, h, bi) for t, l, h, bi in active_fvgs if idx - bi < 50][-20:]
# --- Liquidity Sweeps ---
sweep = detect_liquidity_sweep(highs, lows, closes, idx, swing_highs, swing_lows)
# --- Kill Zone ---
in_kz = is_in_kill_zone(times[-1])
# --- Check if engine closed our position (bracket SL/TP) ---
if position is not None and idx % 5 == 0:
positions = get_positions(ACCOUNT_ID)
if len(positions) == 0:
acct = get_account(ACCOUNT_ID)
current_balance = acct["balance"] if acct else balance_before_trade
pnl_usd = current_balance - balance_before_trade
daily_pnl += pnl_usd
trade_count += 1
daily_trades += 1
if pnl_usd >= 0:
wins += 1
daily_loss_streak = 0
print(f"[TP] Entry: {entry_price:.2f} | P&L: ${pnl_usd:+,.0f} | Bal: ${current_balance:,.0f}")
else:
daily_loss_streak += 1
print(f"[SL] Entry: {entry_price:.2f} | P&L: ${pnl_usd:+,.0f} | Streak: {daily_loss_streak}L")
position = None
# --- Risk Checks ---
if daily_pnl <= -MAX_DAILY_LOSS and not daily_locked:
daily_locked = True
if position:
close_all_positions(ACCOUNT_ID, CONTRACT_ID)
position = None
print(f"[RISK] Daily loss limit: ${daily_pnl:,.2f}")
if daily_trades >= MAX_TRADES_PER_DAY and not daily_locked:
daily_locked = True
if daily_loss_streak >= MAX_LOSS_STREAK and not daily_locked:
daily_locked = True
if position:
close_all_positions(ACCOUNT_ID, CONTRACT_ID)
position = None
print(f"[RISK] Loss streak: {daily_loss_streak}")
# --- Entry ---
if position is not None or not in_kz or daily_locked:
continue
tp_ticks = int(SL_TICKS * TP_RATIO)
if structure == 'bullish':
bullish_fvg_entry = None
for fvg_type, fvg_low, fvg_high, fvg_idx in active_fvgs:
if fvg_type == 'bullish' and fvg_low <= price <= fvg_high:
bullish_fvg_entry = True
break
if bullish_fvg_entry or sweep == 'bullish_sweep':
result = place_order(ACCOUNT_ID, CONTRACT_ID, 0, 1,
sl_ticks=SL_TICKS, tp_ticks=tp_ticks)
if result.get("success") or result.get("orderId"):
position = 'LONG'
entry_price = price
acct = get_account(ACCOUNT_ID)
balance_before_trade = acct["balance"] if acct else balance_before_trade
reason = "FVG" if bullish_fvg_entry else "Sweep"
sl = price - SL_TICKS * 0.25
tp = price + tp_ticks * 0.25
print(f"[BUY] @ {price:.2f} | {reason} | SL: {sl:.2f} TP: {tp:.2f}")
elif structure == 'bearish':
bearish_fvg_entry = None
for fvg_type, fvg_low, fvg_high, fvg_idx in active_fvgs:
if fvg_type == 'bearish' and fvg_low <= price <= fvg_high:
bearish_fvg_entry = True
break
if bearish_fvg_entry or sweep == 'bearish_sweep':
result = place_order(ACCOUNT_ID, CONTRACT_ID, 1, 1,
sl_ticks=SL_TICKS, tp_ticks=tp_ticks)
if result.get("success") or result.get("orderId"):
position = 'SHORT'
entry_price = price
acct = get_account(ACCOUNT_ID)
balance_before_trade = acct["balance"] if acct else balance_before_trade
reason = "FVG" if bearish_fvg_entry else "Sweep"
sl = price + SL_TICKS * 0.25
tp = price - tp_ticks * 0.25
print(f"[SELL] @ {price:.2f} | {reason} | SL: {sl:.2f} TP: {tp:.2f}")
finally:
try:
close_all_positions(ACCOUNT_ID, CONTRACT_ID)
except:
pass
account = get_account(ACCOUNT_ID)
balance = account["balance"] if account else 50000
pnl = balance - 50000
if current_day:
print(f"[INFO] Day {current_day} | P&L: ${daily_pnl:+,.2f} | Trades: {daily_trades}")
print("-" * 60)
print(f"[DONE] Final Balance: ${balance:,.2f}")
print(f"[DONE] Net P&L: ${pnl:+,.2f}")
print(f"[DONE] Total Trades: {trade_count}")
if trade_count > 0:
print(f"[DONE] Win Rate: {wins / trade_count * 100:.1f}%")

Tuning the Strategy

By Timeframe

The TIMEFRAME_CONFIGS dictionary automatically adjusts all parameters. The key relationships:

  • Shorter timeframe = smaller FVG threshold, tighter stops, more trades allowed
  • Longer timeframe = larger FVG threshold, wider stops, fewer trades, higher R:R

Sensitivity Analysis

The most impactful parameters to tune:

ParameterEffect of Increasing
FVG_MIN_SIZEFewer but higher-quality FVG signals
SWING_LOOKBACKFinds more significant swing points, fewer total
SL_TICKSWider stop = fewer stopped out, but larger losses per trade
TP_RATIOHigher target = fewer winners, but larger wins
MAX_LOSS_STREAKMore tolerance for losing streaks before stopping

Kill Zone Customization

If you trade London session instruments or want different hours:

# London session
KZ_START = 7
KZ_END = 10
# NY + London overlap (most volatile)
KZ_START = 13
KZ_END = 16
# Full NY session (default)
KZ_START = 13
KZ_END = 20

How the Concepts Work Together

Here is a typical ICT trade sequence:

  1. Structure establishes: Market makes higher highs and higher lows = bullish structure
  2. FVG forms: An aggressive bullish candle leaves a gap between candle 1 and candle 3
  3. Price retraces: Price pulls back into the FVG zone during the kill zone
  4. Entry triggers: Buy order placed with bracket SL/TP
  5. Target hit: Price resumes the bullish trend and hits the take profit

Or with a liquidity sweep:

  1. Structure is bullish: Higher highs, higher lows
  2. Sweep occurs: Price dips below a recent swing low (taking out stops), then closes back above it
  3. Entry triggers: The sweep signals institutional buying — enter long
  4. Target hit: Price rallies to the take profit

What’s Next