Signals·Signal Confluence Systems·Intermediate

Liquidity Volume Signal Confirmation

Confirm all trading signals using real-time volume profile analysis and order book liquidity assessment, requiring sufficient market depth and participation before execution to avoid excessive slippage in thin or illiquid market conditions.

signal-generationtrading-signals

Liquidity and Volume Signal Confirmation

This notebook outlines a methodology for validating directional trading signals by incorporating volume and price spread characteristics. The objective is to confirm signals only when supported by significant market activity and price movement.

Setup

[ ]
import pandas as pd
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots

Data Preparation

A sample OHLCV (Open, High, Low, Close, Volume) DataFrame is generated for demonstration purposes. This DataFrame simulates typical financial time series data necessary for the signal confirmation model.

[ ]
# Generate synthetic OHLCV data for demonstration
dates = pd.to_datetime(pd.date_range(start='2023-01-01', periods=100, freq='h'))
np.random.seed(42)

open_prices = 100 + np.cumsum(np.random.randn(100))
high_prices = open_prices + np.random.rand(100) * 2
low_prices = open_prices - np.random.rand(100) * 2
close_prices = open_prices + (np.random.rand(100) - 0.5) * 3
volume = np.random.randint(1000, 10000, 100)

df = pd.DataFrame({
    'datetime': dates,
    'open': open_prices,
    'high': high_prices,
    'low': low_prices,
    'close': close_prices,
    'volume': volume
})
display(df.head())
datetime open high low close volume
0 2023-01-01 00:00:00 100.496714 101.331536 98.907092 100.064632 7731
1 2023-01-01 01:00:00 100.358450 100.802665 99.353176 101.131988 8241
2 2023-01-01 02:00:00 101.006138 101.245869 99.852331 99.549319 1953
3 2023-01-01 03:00:00 102.529168 103.204399 101.544133 101.377386 3539
4 2023-01-01 04:00:00 102.295015 104.180834 101.904529 100.933023 8056

Signal Confirmation Model

The liquidity_volume_signal_confirmation function confirms EMA cross signals using three independent criteria: volume ratio, bar range relative to Average True Range (ATR), and On-Balance Volume (OBV) trend. A base EMA cross signal is confirmed only when all three criteria are met, ensuring robustness.

[ ]
def liquidity_volume_signal_confirmation(
    df: pd.DataFrame,
    ema_fast: int = 12,
    ema_slow: int = 26,
    vol_window: int = 20,
    vol_factor: float = 1.3,
    atr_period: int = 14,
    range_factor: float = 1.0,
) -> pd.DataFrame:
    """
    Confirms EMA cross signals using volume, ATR-normalised range, and OBV trend.

    Core Logic:
    1. Computes an EMA cross base signal.
    2. Computes three independent confirmation metrics:
       - Volume ratio against a rolling average.
       - Bar range against Average True Range (ATR).
       - On-Balance Volume (OBV) trend against its Simple Moving Average (SMA).
    3. Emits a final signal exclusively when all three confirmation criteria align
       with the EMA cross direction.

    Parameters:
    -----------
    df : pd.DataFrame
        OHLCV DataFrame containing 'datetime', 'open', 'high', 'low', 'close', and 'volume'.
    ema_fast : int
        Period for the fast Exponential Moving Average.
    ema_slow : int
        Period for the slow Exponential Moving Average.
    vol_window : int
        Rolling window for the volume average calculation.
    vol_factor : float
        Minimum volume ratio required for confirmation.
    atr_period : int
        Period for the Average True Range (ATR) calculation.
    range_factor : float
        Minimum bar range / ATR ratio required for confirmation.

    Returns:
    --------
    pd.DataFrame
        Original DataFrame with added columns: `ema_fast`, `ema_slow`,
        `ema_signal`, `avg_vol`, `vol_ratio`, `vol_confirm`, `atr`,
        `bar_range`, `range_confirm`, `obv`, `obv_sma`, `obv_confirm`,
        and `signal`.
    """
    df = df.copy().sort_values("datetime", ignore_index=True)

    # ── EMA Cross Signal ──────────────────────────────────────────────────────
    df["ema_fast"] = df["close"].ewm(span=ema_fast, adjust=False).mean()
    df["ema_slow"] = df["close"].ewm(span=ema_slow, adjust=False).mean()
    df["ema_signal"] = np.where(df["ema_fast"] > df["ema_slow"], 1, -1)

    # ── Volume Confirmation ───────────────────────────────────────────────────
    df["avg_vol"] = df["volume"].rolling(vol_window).mean()
    df["vol_ratio"] = df["volume"] / df["avg_vol"].replace(0, np.nan)
    df["vol_confirm"] = df["vol_ratio"] > vol_factor

    # ── ATR and Range Confirmation ────────────────────────────────────────────
    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_period).mean()
    df["bar_range"] = df["high"] - df["low"]
    df["range_confirm"] = df["bar_range"] > range_factor * df["atr"]

    # ── OBV Trend Confirmation ────────────────────────────────────────────────
    obv_direction = np.where(df["close"] > df["close"].shift(1), 1, -1)
    df["obv"] = (obv_direction * df["volume"]).cumsum()
    df["obv_sma"] = df["obv"].rolling(20).mean()
    df["obv_confirm"] = np.where(
        (df["ema_signal"] == 1) & (df["obv"] > df["obv_sma"]), True,
        np.where((df["ema_signal"] == -1) & (df["obv"] < df["obv_sma"]), True, False)
    )

    # ── Final Confirmed Signal ────────────────────────────────────────────────
    df["signal"] = np.where(
        df["vol_confirm"] & df["range_confirm"] & df["obv_confirm"],
        df["ema_signal"], 0
    )

    return df

Signal Generation

The liquidity_volume_signal_confirmation function is applied to the prepared DataFrame to generate confirmed buy (1) or sell (-1) signals, or no signal (0).

[ ]
df_signals = liquidity_volume_signal_confirmation(df)
print("Signal Count Distribution:")
print(df_signals["signal"].value_counts())
Signal Count Distribution:
signal
 0    91
-1     8
 1     1
Name: count, dtype: int64

Key Model Components

  • OBV (On-Balance Volume): A cumulative sum of signed volume. A rising OBV indicates accumulation (bullish sentiment), while a falling OBV suggests distribution (bearish sentiment).
  • All-Three Requirement: The logical AND condition (vol_confirm & range_confirm & obv_confirm) ensures that only high-quality signals, supported by significant volume, favorable spread characteristics, and a confirmed OBV trend, are generated. This filtering mechanism aims to capture robust, institutionally-backed price movements.

Limitation: The specified spread and volume thresholds (vol_factor and range_factor) may inadvertently filter out valid signals in instruments characterized by low liquidity. Consequently, these thresholds require instrument-specific calibration to maintain signal efficacy.

Signal Visualization

The generated signals are visualized alongside price action, volume, and OBV to provide a comprehensive view of the model's output. Confirmed buy and sell signals are overlaid on the candlestick chart, while volume and OBV trends are displayed in separate subplots.

[ ]
buy_signals = df_signals[df_signals["signal"] == 1]
sell_signals = df_signals[df_signals["signal"] == -1]

fig = make_subplots(rows=3, cols=1, shared_xaxes=True,
    subplot_titles=["Price + Confirmed Signals", "Volume", "OBV"],
    row_heights=[0.5, 0.25, 0.25])

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="Buy"), 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="Sell"), row=1, col=1)

fig.add_trace(go.Bar(x=df_signals["datetime"], y=df_signals["volume"], name="Volume",
    marker_color=["green" if c else "gray" for c in df_signals["vol_confirm"]]), row=2, col=1)

fig.add_trace(go.Scatter(x=df_signals["datetime"], y=df_signals["obv"],
    mode="lines", name="OBV", line=dict(color="blue")), row=3, col=1)

fig.add_trace(go.Scatter(x=df_signals["datetime"], y=df_signals["obv_sma"],
    mode="lines", name="OBV SMA", line=dict(color="orange", dash="dash")), row=3, col=1)

fig.update_layout(title_text="Liquidity + Volume Signal Confirmation",
    xaxis_rangeslider_visible=False, height=800, xaxis3_title="Datetime")

fig.show()

Conclusion

This notebook presented a methodology for confirming directional trading signals by integrating liquidity and volume characteristics. The liquidity_volume_signal_confirmation function was developed to confirm EMA cross signals only when supported by three independent criteria:

  1. Volume Ratio: Current volume significantly exceeds its rolling average, indicating strong market participation.
  2. Bar Range vs. ATR: The current bar's range is substantial relative to the Average True Range (ATR), suggesting meaningful price movement.
  3. OBV Trend: On-Balance Volume (OBV) trend confirms the direction of the EMA cross, indicating accumulation for bullish signals and distribution for bearish signals.

This "all-three" confirmation approach aims to filter out weak signals and focus on high-conviction trading opportunities, potentially backed by institutional activity. While effective in theory, it's crucial to acknowledge the limitation that volume and spread thresholds (vol_factor and range_factor) require instrument-specific calibration to maintain signal efficacy, especially in low-liquidity markets. The visualization further demonstrated how these confirmed signals align with price action, volume, and OBV trends.