trade-copilot / order_blocks.py
utkarshpathak48's picture
feat: price action analysis — candlestick patterns, chart patterns, OBs, FVGs, confluence grading
cb145d1
Raw
History Blame Contribute Delete
17.4 kB
"""ICT / Smart Money Concepts: Order Blocks, Fair Value Gaps, Breaker Blocks.
Concepts:
Fair Value Gap (FVG):
A 3-candle imbalance where price moved so fast it left a gap in the order book.
Bullish FVG: candle[i-1].high < candle[i+1].low (gap above prior high)
Bearish FVG: candle[i-1].low > candle[i+1].high (gap below prior low)
Price tends to retrace into FVGs to "fill" them.
Order Block (OB):
The last opposing candle before a strong impulse move.
Represents institutional order flow — where smart money placed big orders.
Bullish OB: last bearish candle before a strong bullish impulse
Bearish OB: last bullish candle before a strong bearish impulse
Breaker Block:
A failed order block. When price breaks through an OB and then reverses,
the OB flips to a "breaker" — now acting in the opposite direction.
All detection uses closed candles only.
All returns are JSON-serialisable.
"""
from __future__ import annotations
import pandas as pd
# ─────────────────────────────────────────────────────────────────────────────
# Constants
# ─────────────────────────────────────────────────────────────────────────────
# Impulse threshold: single candle body ≥ this × ATR qualifies as impulse
IMPULSE_ATR_MULT = 1.5
# Or: 3+ consecutive same-direction candles = structural impulse
IMPULSE_CONSECUTIVE = 3
# Max lookback in candles for OB detection
OB_LOOKBACK = 50
# Max lookback for FVG detection
FVG_LOOKBACK = 30
# An OB is "fresh" if price has NOT traded back into it since it formed
# An OB is "tested" if price touched it once but held
# An OB is "breaker" if price closed THROUGH it
# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────
def _is_impulse(df: pd.DataFrame, start_idx: int, direction: str,
atr: float) -> bool:
"""True if a strong impulse move starts at start_idx in given direction."""
body_threshold = atr * IMPULSE_ATR_MULT
n = len(df)
# Single-candle impulse
if start_idx < n:
row = df.iloc[start_idx]
body = abs(row["close"] - row["open"])
if direction == "bull" and row["close"] > row["open"] and body >= body_threshold:
return True
if direction == "bear" and row["close"] < row["open"] and body >= body_threshold:
return True
# Multi-candle impulse: IMPULSE_CONSECUTIVE consecutive same-direction candles
if start_idx + IMPULSE_CONSECUTIVE <= n:
segment = df.iloc[start_idx: start_idx + IMPULSE_CONSECUTIVE]
if direction == "bull" and all(segment["close"].values > segment["open"].values):
return True
if direction == "bear" and all(segment["close"].values < segment["open"].values):
return True
return False
def _zone_status(zone_low: float, zone_high: float,
df: pd.DataFrame, formed_idx: int) -> str:
"""Assess whether an OB zone is fresh, tested, or a breaker."""
subsequent = df.iloc[formed_idx + 1:]
if len(subsequent) == 0:
return "fresh"
closes = subsequent["close"].values
lows = subsequent["low"].values
highs = subsequent["high"].values
# Check if any close went inside or through the zone
inside = any(zone_low <= c <= zone_high for c in closes)
through_bull = any(c > zone_high for c in closes)
through_bear = any(c < zone_low for c in closes)
if through_bull or through_bear:
return "breaker"
if inside:
return "tested"
return "fresh"
# ─────────────────────────────────────────────────────────────────────────────
# Fair Value Gap detection
# ─────────────────────────────────────────────────────────────────────────────
def _detect_fvgs(df: pd.DataFrame) -> tuple[list[dict], list[dict]]:
"""Return (bullish_fvgs, bearish_fvgs).
Bullish FVG: candle[i-1].high < candle[i+1].low
Bearish FVG: candle[i-1].low > candle[i+1].high
Only returns unfilled FVGs (gap still open vs current price).
"""
bullish, bearish = [], []
n = len(df)
close_now = float(df["close"].iloc[-1])
# Look back FVG_LOOKBACK candles
start = max(1, n - FVG_LOOKBACK - 1)
for i in range(start, n - 1):
c_prev = df.iloc[i - 1]
c_curr = df.iloc[i]
c_next = df.iloc[i + 1]
# Bullish FVG
gap_low = float(c_prev["high"])
gap_high = float(c_next["low"])
if gap_high > gap_low:
# Check if still unfilled (price hasn't traded into the gap)
filled = any(
float(df.iloc[j]["low"]) <= gap_high and
float(df.iloc[j]["high"]) >= gap_low
for j in range(i + 2, n)
)
if not filled:
bullish.append({
"type": "bullish_fvg",
"gap_low": round(gap_low, 6),
"gap_high": round(gap_high, 6),
"formed_at": i,
"gap_pct": round((gap_high - gap_low) / gap_low * 100, 2),
"above_price": gap_low > close_now, # is the FVG above current price?
"below_price": gap_high < close_now,
})
# Bearish FVG
gap_high2 = float(c_prev["low"])
gap_low2 = float(c_next["high"])
if gap_high2 > gap_low2:
filled = any(
float(df.iloc[j]["low"]) <= gap_high2 and
float(df.iloc[j]["high"]) >= gap_low2
for j in range(i + 2, n)
)
if not filled:
bearish.append({
"type": "bearish_fvg",
"gap_low": round(gap_low2, 6),
"gap_high": round(gap_high2, 6),
"formed_at": i,
"gap_pct": round((gap_high2 - gap_low2) / gap_high2 * 100, 2),
"above_price": gap_low2 > close_now,
"below_price": gap_high2 < close_now,
})
return bullish, bearish
# ─────────────────────────────────────────────────────────────────────────────
# Order Block detection
# ─────────────────────────────────────────────────────────────────────────────
def _detect_obs(df: pd.DataFrame, atr: float) -> tuple[list[dict], list[dict]]:
"""Return (bullish_obs, bearish_obs).
Bullish OB: last bearish candle before a bullish impulse.
Bearish OB: last bullish candle before a bearish impulse.
"""
bullish_obs, bearish_obs = [], []
n = len(df)
start = max(0, n - OB_LOOKBACK)
for i in range(start, n - 2):
row = df.iloc[i]
o, h, l, c = float(row["open"]), float(row["high"]), float(row["low"]), float(row["close"])
# Bullish OB candidate: this candle is bearish
if c < o:
if _is_impulse(df, i + 1, "bull", atr):
status = _zone_status(l, o, df, i)
bullish_obs.append({
"type": "bullish_ob",
"zone_low": round(l, 6),
"zone_high": round(o, 6), # OB = low to open (body bottom to top of prior bear)
"formed_at": i,
"status": status, # fresh | tested | breaker
"body_pct": round(abs(c - o) / o * 100, 2) if o > 0 else 0,
})
# Bearish OB candidate: this candle is bullish
elif c > o:
if _is_impulse(df, i + 1, "bear", atr):
status = _zone_status(c, h, df, i)
bearish_obs.append({
"type": "bearish_ob",
"zone_low": round(c, 6), # OB = close to high (body top to wick top)
"zone_high": round(h, 6),
"formed_at": i,
"status": status,
"body_pct": round(abs(c - o) / o * 100, 2) if o > 0 else 0,
})
# Sort by recency (most recent first) — prefer recent OBs
bullish_obs.sort(key=lambda x: x["formed_at"], reverse=True)
bearish_obs.sort(key=lambda x: x["formed_at"], reverse=True)
return bullish_obs, bearish_obs
# ─────────────────────────────────────────────────────────────────────────────
# Nearest zone finders
# ─────────────────────────────────────────────────────────────────────────────
def _nearest_ob_above(obs: list[dict], price: float) -> dict | None:
"""Nearest OB zone with zone_low ABOVE current price."""
candidates = [ob for ob in obs if ob["zone_low"] > price]
if not candidates:
return None
return min(candidates, key=lambda ob: ob["zone_low"] - price)
def _nearest_ob_below(obs: list[dict], price: float) -> dict | None:
"""Nearest OB zone with zone_high BELOW current price."""
candidates = [ob for ob in obs if ob["zone_high"] < price]
if not candidates:
return None
return min(candidates, key=lambda ob: price - ob["zone_high"])
def _nearest_fvg_above(fvgs: list[dict], price: float) -> dict | None:
candidates = [f for f in fvgs if f["gap_low"] > price]
if not candidates:
return None
return min(candidates, key=lambda f: f["gap_low"] - price)
def _nearest_fvg_below(fvgs: list[dict], price: float) -> dict | None:
candidates = [f for f in fvgs if f["gap_high"] < price]
if not candidates:
return None
return min(candidates, key=lambda f: price - f["gap_high"])
# ─────────────────────────────────────────────────────────────────────────────
# Summary builder
# ─────────────────────────────────────────────────────────────────────────────
def _build_summary(ob_below: dict | None, ob_above: dict | None,
fvg_below: dict | None, fvg_above: dict | None,
price: float) -> str:
"""One-line summary of the most relevant OB/FVG context."""
parts = []
if ob_below and ob_below["status"] in ("fresh", "tested"):
z = ob_below
parts.append(
f"Bullish OB {z['zone_low']:.4g}{z['zone_high']:.4g} below "
f"({'fresh' if z['status']=='fresh' else 'tested'} support)"
)
if ob_above and ob_above["status"] in ("fresh", "tested"):
z = ob_above
parts.append(
f"Bearish OB {z['zone_low']:.4g}{z['zone_high']:.4g} above "
f"({'fresh' if z['status']=='fresh' else 'tested'} resistance)"
)
if fvg_below and not parts:
f = fvg_below
parts.append(f"Unfilled bullish FVG {f['gap_low']:.4g}{f['gap_high']:.4g} below (magnet zone)")
if fvg_above and not any("OB" in p for p in parts):
f = fvg_above
parts.append(f"Unfilled bearish FVG {f['gap_low']:.4g}{f['gap_high']:.4g} above (resistance)")
return " · ".join(parts) if parts else "No significant OB or FVG in range"
# ─────────────────────────────────────────────────────────────────────────────
# Public API
# ─────────────────────────────────────────────────────────────────────────────
def detect_order_blocks(df: pd.DataFrame, atr: float = 0.0) -> dict:
"""Detect all OBs, FVGs, and breaker blocks in df.
Args:
df: OHLCV DataFrame, forming bar dropped.
atr: 14-period ATR for impulse thresholds. Falls back to
rough estimate (1% of close) if not provided.
Returns dict with:
bullish_obs — all detected bullish order blocks
bearish_obs — all detected bearish order blocks
bullish_fvgs — unfilled bullish fair value gaps
bearish_fvgs — unfilled bearish fair value gaps
nearest_ob_above — closest bearish OB above price
nearest_ob_below — closest bullish OB below price
nearest_fvg_above — closest bearish FVG above price
nearest_fvg_below — closest bullish FVG below price
summary — one-line human-readable summary
"""
if len(df) < 10:
return {
"bullish_obs": [], "bearish_obs": [],
"bullish_fvgs": [], "bearish_fvgs": [],
"nearest_ob_above": None, "nearest_ob_below": None,
"nearest_fvg_above": None, "nearest_fvg_below": None,
"summary": "Insufficient data for OB/FVG analysis",
}
price = float(df["close"].iloc[-1])
if atr <= 0:
atr = price * 0.01 # fallback: 1% of price
bullish_obs, bearish_obs = _detect_obs(df, atr)
bullish_fvgs, bearish_fvgs = _detect_fvgs(df)
ob_above = _nearest_ob_above(bearish_obs, price)
ob_below = _nearest_ob_below(bullish_obs, price)
fvg_above = _nearest_fvg_above(bearish_fvgs, price)
fvg_below = _nearest_fvg_below(bullish_fvgs, price)
summary = _build_summary(ob_below, ob_above, fvg_below, fvg_above, price)
return {
"bullish_obs": bullish_obs[:5], # cap for serialisation
"bearish_obs": bearish_obs[:5],
"bullish_fvgs": bullish_fvgs[:5],
"bearish_fvgs": bearish_fvgs[:5],
"nearest_ob_above": ob_above,
"nearest_ob_below": ob_below,
"nearest_fvg_above": fvg_above,
"nearest_fvg_below": fvg_below,
"summary": summary,
}
def ob_fvg_score(result: dict, direction: str) -> float:
"""Score the OB/FVG context for a trade in the given direction (0–10).
Base: 5.0 (neutral)
+ Fresh bullish OB below price + long → +2.5
+ Tested bullish OB below price + long → +1.5
+ Fresh bearish OB above price + short → +2.5
+ Tested bearish OB above + short → +1.5
+ Unfilled FVG in direction of trade → +1.0
- OB breaker in direction of trade → -1.5
"""
score = 5.0
ob_below = result.get("nearest_ob_below")
ob_above = result.get("nearest_ob_above")
fvg_below = result.get("nearest_fvg_below")
fvg_above = result.get("nearest_fvg_above")
if direction == "long":
if ob_below:
if ob_below["status"] == "fresh":
score += 2.5
elif ob_below["status"] == "tested":
score += 1.5
elif ob_below["status"] == "breaker":
score -= 1.5 # support became resistance — bad for longs
if fvg_below:
score += 1.0 # unfilled gap below = magnet that may pull price down first
# (slightly penalise — price may fill it before going up)
score -= 0.5
if fvg_above:
score += 0.5 # unfilled gap above = air pocket price can fill = target
if ob_above and ob_above["status"] == "fresh":
score -= 0.5 # fresh resistance above
elif direction == "short":
if ob_above:
if ob_above["status"] == "fresh":
score += 2.5
elif ob_above["status"] == "tested":
score += 1.5
elif ob_above["status"] == "breaker":
score -= 1.5
if fvg_above:
score += 1.0
score -= 0.5
if fvg_below:
score += 0.5
if ob_below and ob_below["status"] == "fresh":
score -= 0.5
return round(max(0.0, min(10.0, score)), 2)