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:
- Market Structure — Break of Structure (BOS) and Change of Character (CHoCH)
- Fair Value Gaps (FVG) — Price imbalances that act as magnets
- Liquidity Sweeps — Stops being hunted before the real move
- 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) tuplesswing_lows = []
# Inside main loop:check_idx = idx - SWING_LOOKBACK // 2if 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 pointsswing_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 NoneTracking 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 20active_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}") breakConcept 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 NoneConcept 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 Zone | UTC Time | Character |
|---|---|---|
| London Open | 07:00-10:00 | Often sets the daily high or low |
| NY Open | 13:30-16:00 | Highest volume, sharpest moves |
| NY Close | 19:00-20:00 | End-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 FalseTrading 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:
- Market structure is bullish (higher highs, higher lows)
- Either: price retraces into a bullish FVG, OR a bullish liquidity sweep occurs
- We are inside a kill zone
- No risk limits have been hit
Bearish entry requires:
- Market structure is bearish (lower highs, lower lows)
- Either: price retraces into a bearish FVG, OR a bearish liquidity sweep occurs
- We are inside a kill zone
- 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 PlaygroundMarket 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 settingsTIMEFRAME_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 = Noneentry_price = 0.0balance_before_trade = 50000trade_count = 0wins = 0current_day = ""daily_pnl = 0.0daily_trades = 0daily_locked = Falsedaily_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:
| Parameter | Effect of Increasing |
|---|---|
FVG_MIN_SIZE | Fewer but higher-quality FVG signals |
SWING_LOOKBACK | Finds more significant swing points, fewer total |
SL_TICKS | Wider stop = fewer stopped out, but larger losses per trade |
TP_RATIO | Higher target = fewer winners, but larger wins |
MAX_LOSS_STREAK | More tolerance for losing streaks before stopping |
Kill Zone Customization
If you trade London session instruments or want different hours:
# London sessionKZ_START = 7KZ_END = 10
# NY + London overlap (most volatile)KZ_START = 13KZ_END = 16
# Full NY session (default)KZ_START = 13KZ_END = 20How the Concepts Work Together
Here is a typical ICT trade sequence:
- Structure establishes: Market makes higher highs and higher lows = bullish structure
- FVG forms: An aggressive bullish candle leaves a gap between candle 1 and candle 3
- Price retraces: Price pulls back into the FVG zone during the kill zone
- Entry triggers: Buy order placed with bracket SL/TP
- Target hit: Price resumes the bullish trend and hits the take profit
Or with a liquidity sweep:
- Structure is bullish: Higher highs, higher lows
- Sweep occurs: Price dips below a recent swing low (taking out stops), then closes back above it
- Entry triggers: The sweep signals institutional buying — enter long
- Target hit: Price rallies to the take profit
What’s Next
- Bracket Orders (SL/TP) — deep dive on the bracket order system used in this strategy
- Parameter Optimization — systematically test different ICT parameter combinations
- From TestMax to Live Trading — port this strategy to the real TopStep API