Signals·Patterns·Intermediate
Order Block Detection
Identify institutional order blocks — the last up/down candle before a strong impulse move — for SMC-based entry zones.
order blocksSMCinstitutional
Strategy — Order Block Detection (Basic)
1. Dependency Installation
[1]
!pip install pandas numpy plotlyRequirement already satisfied: pandas in /usr/local/lib/python3.12/dist-packages (2.2.2) Requirement already satisfied: numpy in /usr/local/lib/python3.12/dist-packages (2.0.2) Requirement already satisfied: plotly in /usr/local/lib/python3.12/dist-packages (5.24.1) Requirement already satisfied: python-dateutil>=2.8.2 in /usr/local/lib/python3.12/dist-packages (from pandas) (2.9.0.post0) Requirement already satisfied: pytz>=2020.1 in /usr/local/lib/python3.12/dist-packages (from pandas) (2025.2) Requirement already satisfied: tzdata>=2022.7 in /usr/local/lib/python3.12/dist-packages (from pandas) (2026.1) Requirement already satisfied: tenacity>=6.2.0 in /usr/local/lib/python3.12/dist-packages (from plotly) (9.1.4) Requirement already satisfied: packaging in /usr/local/lib/python3.12/dist-packages (from plotly) (26.1) Requirement already satisfied: six>=1.5 in /usr/local/lib/python3.12/dist-packages (from python-dateutil>=2.8.2->pandas) (1.17.0)
2. Library Imports
[2]
import warnings; warnings.filterwarnings("ignore")
import pandas as pd
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots3. Strategy Overview
Order Blocks (OB) are price zones that represent areas of significant institutional order placement. In Smart Money Concept (SMC) theory, the last bearish candle before a sharp bullish impulse is a Bullish OB; the last bullish candle before a sharp bearish impulse is a Bearish OB.
Detection logic:
- Identify impulse bars: candles whose body exceeds
impulse_factor× ATR. - Look back
lookbackbars to find the last candle of the opposite colour preceding the impulse. - That preceding candle's high–low range defines the Order Block zone.
- When price re-enters a bullish OB zone from above → Buy (+1).
- When price re-enters a bearish OB zone from below → Sell (−1).
Limitation: Basic OB detection does not account for zone mitigation (a zone is invalidated when price closes beyond it); mitigation logic should be added for production use.
4. Data Generation
[3]
def generate_data(periods: int) -> pd.DataFrame:
"""
Generate synthetic OHLCV price data using a geometric random walk.
Parameters
----------
periods : int
Number of 1-minute bars to generate.
Returns
-------
pd.DataFrame
DataFrame with columns: open, high, low, close, volume, datetime.
"""
start_date = pd.to_datetime("2024-01-01 00:00:00+00:00")
datetime_index = pd.date_range(start_date, periods=periods, freq="1min", tz="UTC")
price_data = []
last_close = 42000
for i in range(periods):
open_price = last_close + np.random.normal(0, last_close * 0.0005)
close_price = open_price + np.random.normal(0, last_close * 0.005)
body_high = max(open_price, close_price)
body_low = min(open_price, close_price)
high_price = max(body_high + abs(np.random.normal(0, last_close * 0.002)), open_price, close_price)
low_price = min(body_low - abs(np.random.normal(0, last_close * 0.002)), open_price, close_price)
if high_price < low_price:
high_price, low_price = low_price, high_price
price_data.append({
"open": max(1, int(open_price)),
"high": max(1, int(high_price)),
"low": max(1, int(low_price)),
"close": max(1, int(close_price)),
})
last_close = close_price
df = pd.DataFrame(price_data, index=datetime_index)
df.index.name = "datetime"
df["volume"] = np.random.uniform(100.0, 500.0, periods)
df["datetime"] = df.index.to_series()
return df.reset_index(drop=True)
df = generate_data(500)
display(df.head())| open | high | low | close | volume | datetime | |
|---|---|---|---|---|---|---|
| 0 | 41995 | 42004 | 41898 | 42000 | 247.672567 | 2024-01-01 00:00:00+00:00 |
| 1 | 41986 | 42315 | 41915 | 42304 | 185.397632 | 2024-01-01 00:01:00+00:00 |
| 2 | 42294 | 42751 | 42232 | 42749 | 421.093823 | 2024-01-01 00:02:00+00:00 |
| 3 | 42723 | 42728 | 42573 | 42618 | 375.386252 | 2024-01-01 00:03:00+00:00 |
| 4 | 42623 | 42785 | 42578 | 42644 | 161.539705 | 2024-01-01 00:04:00+00:00 |
5. Strategy Function
[4]
def orderblock_detection_basic(
df: pd.DataFrame,
atr_window: int = 14,
impulse_factor: float = 1.5,
lookback: int = 5,
) -> pd.DataFrame:
"""
Detect basic bullish and bearish order blocks and generate re-entry signals.
Core logic
----------
1. Compute ATR to measure average candle size.
2. For each bar: if the body exceeds impulse_factor × ATR (impulse candle),
scan the prior lookback bars for the last candle of the opposite direction.
3. Record the OB zone (high, low of the qualifying candle).
4. For each bar: if price enters a live OB zone, emit the corresponding signal.
Parameters
----------
df : pd.DataFrame
OHLCV DataFrame with columns: open, high, low, close, volume, datetime.
atr_window : int
Rolling window for ATR calculation.
impulse_factor : float
Multiplier; candle body must exceed this multiple of ATR to be an impulse.
lookback : int
Maximum number of bars to look back for the preceding OB candle.
Returns
-------
pd.DataFrame
Original DataFrame extended with: atr, ob_type, ob_high, ob_low, signal.
"""
df = df.copy().sort_values("datetime", ignore_index=True)
df["signal"] = 0
df["ob_type"] = "none"
df["ob_high"] = np.nan
df["ob_low"] = np.nan
# ── ATR ──────────────────────────────────────────────────────────────────
tr = pd.concat([
df["high"] - df["low"],
(df["high"] - df["close"].shift(1)).abs(),
(df["low"] - df["close"].shift(1)).abs(),
], axis=1).max(axis=1)
df["atr"] = tr.rolling(atr_window).mean()
df["body"] = (df["close"] - df["open"]).abs()
# ── Track active OB zones ─────────────────────────────────────────────────
bullish_obs = [] # list of (ob_high, ob_low)
bearish_obs = []
for i in range(atr_window + lookback, len(df)):
atr_val = df["atr"].iloc[i]
if np.isnan(atr_val):
continue
body_i = df["body"].iloc[i]
is_bull = df["close"].iloc[i] > df["open"].iloc[i] # current candle direction
# ── Impulse detection ─────────────────────────────────────────────────
if body_i > impulse_factor * atr_val:
# Search lookback bars for the last opposite-direction candle
for j in range(i - 1, max(i - lookback - 1, 0), -1):
prev_bull = df["close"].iloc[j] > df["open"].iloc[j]
if is_bull and not prev_bull:
# Last bearish candle before bullish impulse → Bullish OB
bullish_obs.append((df["high"].iloc[j], df["low"].iloc[j]))
break
elif not is_bull and prev_bull:
# Last bullish candle before bearish impulse → Bearish OB
bearish_obs.append((df["high"].iloc[j], df["low"].iloc[j]))
break
# ── Signal: price re-enters an OB zone ───────────────────────────────
c = df["close"].iloc[i]
for ob_h, ob_l in bullish_obs:
if ob_l <= c <= ob_h:
df.at[df.index[i], "signal"] = 1
df.at[df.index[i], "ob_type"] = "bullish_ob"
df.at[df.index[i], "ob_high"] = ob_h
df.at[df.index[i], "ob_low"] = ob_l
break
if df["signal"].iloc[i] == 0:
for ob_h, ob_l in bearish_obs:
if ob_l <= c <= ob_h:
df.at[df.index[i], "signal"] = -1
df.at[df.index[i], "ob_type"] = "bearish_ob"
df.at[df.index[i], "ob_high"] = ob_h
df.at[df.index[i], "ob_low"] = ob_l
break
return df
df_signals = orderblock_detection_basic(df, atr_window=14, impulse_factor=1.5)
print("--- OB Type Distribution ---")
print(df_signals["ob_type"].value_counts())
print("\n--- Signal Distribution ---")
print(df_signals["signal"].value_counts())--- OB Type Distribution --- ob_type none 417 bearish_ob 49 bullish_ob 34 Name: count, dtype: int64 --- Signal Distribution --- signal 0 417 -1 49 1 34 Name: count, dtype: int64
Explanation:
impulse_factor × ATR: Normalises the impulse threshold to current volatility, ensuring only significant candles qualify as OB originators.bullish_obs / bearish_obs: Lists of active OB zones; in production code, zones should be invalidated (removed) when price closes decisively beyond them.
6. Visualization
[5]
buy_signals = df_signals[df_signals["signal"] == 1]
sell_signals = df_signals[df_signals["signal"] == -1]
fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
subplot_titles=["Price + Order Block Signals", "ATR"],
row_heights=[0.65, 0.35])
fig.add_trace(go.Candlestick(
x=df_signals["datetime"],
open=df_signals["open"], high=df_signals["high"],
low=df_signals["low"], close=df_signals["close"],
name="Price"), row=1, col=1)
fig.add_trace(go.Scatter(
x=buy_signals["datetime"], y=buy_signals["low"] * 0.999,
mode="markers", marker=dict(symbol="triangle-up", size=10, color="green"),
name="Bullish OB (+1)"), row=1, col=1)
fig.add_trace(go.Scatter(
x=sell_signals["datetime"], y=sell_signals["high"] * 1.001,
mode="markers", marker=dict(symbol="triangle-down", size=10, color="red"),
name="Bearish OB (-1)"), row=1, col=1)
fig.add_trace(go.Scatter(
x=df_signals["datetime"], y=df_signals["atr"],
mode="lines", name="ATR", line=dict(color="orange", width=1)),
row=2, col=1)
fig.update_layout(
title_text="Order Block Detection (Basic)",
xaxis_rangeslider_visible=False,
height=700, xaxis2_title="Datetime",
)
fig.show()[ ]