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.
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_subplots2. 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:
| Regime | Criteria | Signal Logic |
|---|---|---|
| Trending | ADX > 25 | Trend-following (EMA cross) |
| Ranging | ADX ≤ 25, low ATR | Mean-reversion (RSI extremes) |
| Volatile | ATR > 90th percentile | No 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.