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 plotly
Requirement 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_subplots

3. 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:

  1. Identify impulse bars: candles whose body exceeds impulse_factor × ATR.
  2. Look back lookback bars to find the last candle of the opposite colour preceding the impulse.
  3. That preceding candle's high–low range defines the Order Block zone.
  4. When price re-enters a bullish OB zone from above → Buy (+1).
  5. 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()
[ ]