Signals·Signal Confluence Systems·Intermediate

Market Regime Signal Classifier

Detect and classify the current market regime using unsupervised learning on volatility, trend strength, and cross-sectional correlation features, then route trading signals through regime-specific logic optimized for each distinct market condition.

signal-generationstatistical-methodstrading-signals

Market Regime Signal Classifier

This notebook implements a market regime signal classifier, which dynamically adapts trading signals based on the detected market state (trending, ranging, or volatile).

1. Setup and Imports

This section handles the necessary library imports to set up the environment for data manipulation, calculation, and visualization.

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

2. Data Generation

Here, we define a function to generate synthetic OHLCV (Open, High, Low, Close, Volume) data, which will be used to test our market regime classifier.

[ ]

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 42003 42025 41963 41970 194.938978 2024-01-01 00:00:00+00:00
1 41970 42242 41886 42231 463.658140 2024-01-01 00:01:00+00:00
2 42234 42375 42110 42276 470.107527 2024-01-01 00:02:00+00:00
3 42290 42565 42182 42545 438.878664 2024-01-01 00:03:00+00:00
4 42524 42547 42340 42376 306.016996 2024-01-01 00:04:00+00:00

4. Market Regime Signal Classifier Function

This section defines the core function responsible for calculating technical indicators (ADX, ATR, EMA, RSI) and classifying the market into different regimes, then generating appropriate trading signals.

Introduction to Market Regimes

Market regime classification is a crucial concept in algorithmic trading and investment strategy. It involves identifying the prevailing state of the market, which typically falls into categories like trending, ranging (sideways), or volatile. By understanding the current regime, traders can select and apply signal logic that is most appropriate and effective for that specific market condition, thereby improving strategy performance and managing risk.

Types of Market Regimes and Their Characteristics:

  • Trending Regime:

    • Criteria: Characterized by strong price movement in a particular direction (up or down) over a sustained period. A common indicator for identifying a trending market is the Average Directional Index (ADX). An ADX value typically above 25 suggests a strong trend.
    • Signal Logic: In a trending market, trend-following strategies are most effective. This often involves using indicators like Exponential Moving Average (EMA) crosses (e.g., a short-term EMA crossing above a long-term EMA for a buy signal, or below for a sell signal). The goal is to ride the momentum of the existing trend.
  • Ranging (Sideways) Regime:

    • Criteria: Prices oscillate within a defined band, without a clear directional bias. The ADX value is typically below 25, indicating a lack of strong trend. Additionally, other factors like low Average True Range (ATR) can suggest reduced volatility within the range.
    • Signal Logic: Mean-reversion strategies are well-suited for ranging markets. These strategies aim to profit from prices reverting to their average. Indicators like the Relative Strength Index (RSI) are often used; for instance, buying when RSI is extremely low (e.g., below 30) and selling when it's extremely high (e.g., above 70).
  • Volatile Regime:

    • Criteria: Marked by sharp, unpredictable, and often large price swings, indicating high uncertainty or significant news events. While a market can be volatile during a strong trend, this regime specifically refers to periods of high, often directionless, volatility. A high ATR (e.g., above its 90th percentile) can signal this regime.
    • Signal Logic: In highly volatile regimes, it's often prudent to avoid trading or significantly reduce position sizes. The increased risk and unpredictable price movements make most traditional trading signals unreliable, leading to higher chances of false signals and increased drawdowns.

Summary Table:

RegimeCriteriaSignal Logic
TrendingADX > 25Trend-following (EMA cross)
RangingADX ≤ 25, low ATRMean-reversion (RSI extremes)
VolatileATR > 90th percentileNo signal (risk too high)

Limitation: It's important to note that indicators like ADX can lag actual regime changes. Market transitions are often smoother and more fluid in reality than the hard thresholds used in classification models imply. Therefore, this classification serves as a simplified model to guide strategy selection.

[ ]
def market_regime_signal_classifier(
    df: pd.DataFrame,
    adx_period: int = 14,
    adx_trend_threshold: float = 25.0,
    atr_vol_pct: float = 90.0,
) -> pd.DataFrame:
    """
    Classify market regime (trending / ranging / volatile) and select signal logic.

    Core logic
    ----------
    1. Compute ADX to measure trend strength.
    2. Compute ATR percentile to detect extreme volatility.
    3. Classify each bar into a regime based on ADX and ATR thresholds.
    4. In trending regime: apply EMA cross signal.
    5. In ranging regime: apply RSI extreme signal.
    6. In volatile regime: suppress all signals.

    Parameters
    ----------
    df : pd.DataFrame      OHLCV DataFrame.
    adx_period : int       ADX computation period.
    adx_trend_threshold : float  ADX value above which market is 'trending'.
    atr_vol_pct : float    ATR percentile above which market is 'volatile'.

    Returns
    -------
    pd.DataFrame with: adx, atr, regime, signal.
    """
    df = df.copy().sort_values("datetime", ignore_index=True)

    # ── 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(adx_period).mean()
    atr_high_threshold = df["atr"].rolling(100).quantile(atr_vol_pct / 100)

    # ── Directional Movement for ADX ─────────────────────────────────────────
    plus_dm  = (df["high"] - df["high"].shift(1)).clip(lower=0)
    minus_dm = (df["low"].shift(1) - df["low"]).clip(lower=0)
    plus_dm  = plus_dm.where(plus_dm > minus_dm, 0)
    minus_dm = minus_dm.where(minus_dm > plus_dm, 0)
    atr_s    = df["atr"]
    plus_di  = 100 * plus_dm.rolling(adx_period).mean()  / atr_s.replace(0, np.nan)
    minus_di = 100 * minus_dm.rolling(adx_period).mean() / atr_s.replace(0, np.nan)
    dx       = (100 * (plus_di - minus_di).abs() / (plus_di + minus_di).replace(0, np.nan))
    df["adx"] = dx.rolling(adx_period).mean()

    # ── Regime classification ─────────────────────────────────────────────────
    df["regime"] = "ranging"
    df.loc[df["adx"] > adx_trend_threshold, "regime"] = "trending"
    df.loc[df["atr"] > atr_high_threshold, "regime"]  = "volatile"

    # ── EMA cross signal (used in trending regime) ────────────────────────────
    ema12 = df["close"].ewm(span=12, adjust=False).mean()
    ema26 = df["close"].ewm(span=26, adjust=False).mean()
    ema_sig = np.where(ema12 > ema26, 1, -1)

    # ── RSI extreme signal (used in ranging regime) ───────────────────────────
    delta = df["close"].diff()
    gain  = delta.clip(lower=0).rolling(14).mean()
    loss  = (-delta.clip(upper=0)).rolling(14).mean()
    rsi   = 100 - 100 / (1 + gain / loss.replace(0, np.nan))
    rsi_sig = np.where(rsi < 30, 1, np.where(rsi > 70, -1, 0))

    # ── Regime-aware final signal ─────────────────────────────────────────────
    df["signal"] = 0
    df.loc[df["regime"] == "trending", "signal"] = ema_sig[df["regime"] == "trending"]
    df.loc[df["regime"] == "ranging",  "signal"] = rsi_sig[df["regime"] == "ranging"]
    # volatile: signal remains 0

    return df

df_signals = market_regime_signal_classifier(df)
print(df_signals["regime"].value_counts())
print(df_signals["signal"].value_counts())
regime
trending    273
ranging     156
volatile     71
Name: count, dtype: int64
signal
 0    215
 1    206
-1     79
Name: count, dtype: int64
  • ADX: Measures trend strength (not direction); values above 25 indicate a trending market regardless of direction.
  • Regime-aware dispatch: A single strategy function selects between two different signal algorithms based on the detected regime, avoiding applying trend-following logic in mean-reverting conditions and vice versa.

5. Visualization of Market Signals

In this final section, we visualize the generated OHLCV data, the calculated market regimes, and the resulting trading signals (buy/sell) using an interactive candlestick chart.

[ ]
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 + Regime Signals", "ADX", "ATR"],
    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=9, 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=9, color="red"),   name="Sell"), row=1, col=1)
fig.add_trace(go.Scatter(x=df_signals["datetime"], y=df_signals["adx"],
    mode="lines", name="ADX", line=dict(color="orange")), row=2, col=1)
fig.add_hline(y=25, line_dash="dot", line_color="gray", row=2, col=1)
fig.add_trace(go.Scatter(x=df_signals["datetime"], y=df_signals["atr"],
    mode="lines", name="ATR", line=dict(color="purple")), row=3, col=1)
fig.update_layout(title_text="Market Regime Signal Classifier",
    xaxis_rangeslider_visible=False, height=800, xaxis3_title="Datetime")
fig.show()

Conclusion

This notebook successfully implemented a market regime signal classifier that adapts trading signals based on detected market states (trending, ranging, or volatile). By leveraging indicators like ADX for trend strength and ATR for volatility, the system dynamically switches between EMA cross signals for trending markets and RSI extreme signals for ranging markets, while suppressing signals during highly volatile conditions.

This approach helps in aligning trading strategies with current market dynamics, aiming to improve performance and manage risk more effectively than a one-size-fits-all strategy.