Skip to content

Market Regime Analysis

Regime analysis is the practice of classifying market conditions (trending, ranging, volatile) so you can activate or deactivate strategies accordingly. This guide shows how to build a simple regime classifier using HypQuant features.


1. Define regimes

We will use three features to classify each hourly candle into one of four regimes:

Regime RSI ATR (normalized) Bollinger %B
Trending Up > 55 > 0.8
Trending Down < 45 < 0.2
Volatile > 75th pct
Ranging 45–55 < 50th pct 0.2–0.8

2. Fetch features

from hypquant import MarketData
import polars as pl

SYMBOL   = "BTC-USDC"
EXCHANGE = "hyperliquid"
START    = "2024-01-01"
END      = "2024-06-01"

with MarketData(api_key="qp_...") as md:
    feat = md.features(
        SYMBOL,
        exchange=EXCHANGE,
        timeframe="1h",
        features=["rsi_14", "atr_14", "bb_pct", "bb_width"],
        start=START,
        end=END,
    )
    ohlcv = md.ohlcv(SYMBOL, exchange=EXCHANGE, timeframe="1h",
                     start=START, end=END, limit=10000)

df = ohlcv.join(feat, on="time", how="left").drop_nulls()
print(f"Clean rows: {len(df)}")

3. Compute regime thresholds

import numpy as np

atr_p25 = df["atr_14"].quantile(0.25)
atr_p75 = df["atr_14"].quantile(0.75)
atr_p50 = df["atr_14"].quantile(0.50)

print(f"ATR p25={atr_p25:.2f}, p50={atr_p50:.2f}, p75={atr_p75:.2f}")

4. Classify regimes

df = df.with_columns([
    pl.when(
        (pl.col("rsi_14") > 55) & (pl.col("bb_pct") > 0.8)
    ).then(pl.lit("trending_up"))
    .when(
        (pl.col("rsi_14") < 45) & (pl.col("bb_pct") < 0.2)
    ).then(pl.lit("trending_down"))
    .when(pl.col("atr_14") > atr_p75)
    .then(pl.lit("volatile"))
    .otherwise(pl.lit("ranging"))
    .alias("regime")
])

# Distribution
print(df.group_by("regime").len().sort("len", descending=True))
# ┌───────────────┬───────┐
# │ regime        ┆ len   │
# ╞═══════════════╪═══════╡
# │ ranging       ┆ 2341  │
# │ volatile      ┆  889  │
# │ trending_up   ┆  410  │
# │ trending_down ┆  320  │
# └───────────────┴───────┘

5. Compute returns per regime

df = df.with_columns(
    ((pl.col("close") - pl.col("open")) / pl.col("open")).alias("candle_return")
)

regime_stats = (
    df.group_by("regime")
    .agg([
        pl.col("candle_return").mean().alias("avg_return"),
        pl.col("candle_return").std().alias("std_return"),
        pl.col("candle_return").count().alias("n"),
    ])
    .with_columns(
        (pl.col("avg_return") / pl.col("std_return") * (8760 ** 0.5)).alias("annualized_sharpe")
    )
    .sort("annualized_sharpe", descending=True)
)

print(regime_stats)

6. Conditional strategy

Only run the RSI mean-reversion strategy (from the backtest guide) during the ranging regime:

with MarketData(api_key="qp_...") as md:
    all_feat = md.features(
        SYMBOL,
        exchange=EXCHANGE,
        timeframe="1h",
        features=["rsi_14", "atr_14", "bb_pct", "bb_width"],
        start=START,
        end=END,
    )
    full_ohlcv = md.ohlcv(SYMBOL, exchange=EXCHANGE, timeframe="1h",
                          start=START, end=END, limit=10000)

full_df = full_ohlcv.join(all_feat, on="time", how="left")

# Classify regime
full_df = full_df.with_columns([
    pl.when(
        (pl.col("rsi_14") > 55) & (pl.col("bb_pct") > 0.8)
    ).then(pl.lit("trending_up"))
    .when(
        (pl.col("rsi_14") < 45) & (pl.col("bb_pct") < 0.2)
    ).then(pl.lit("trending_down"))
    .when(pl.col("atr_14") > atr_p75)
    .then(pl.lit("volatile"))
    .otherwise(pl.lit("ranging"))
    .alias("regime")
])

# RSI signal only in ranging regime
full_df = full_df.with_columns([
    pl.when(
        (pl.col("regime") == "ranging") & (pl.col("rsi_14") < 30)
    ).then(pl.lit(1))
    .when(
        (pl.col("regime") == "ranging") & (pl.col("rsi_14") > 55)
    ).then(pl.lit(0))
    .otherwise(None)
    .alias("raw_signal")
]).with_columns(
    pl.col("raw_signal").forward_fill().fill_null(0).alias("position")
)

full_df = full_df.with_columns(
    ((pl.col("close") - pl.col("open")) / pl.col("open") * pl.col("position"))
    .alias("strategy_return")
)

total_ret = (1 + full_df["strategy_return"].fill_null(0)).cum_prod().to_numpy()[-1] - 1
print(f"Regime-conditioned strategy return: {total_ret*100:.1f}%")

7. Regime persistence

Regime classification is noisy if computed bar-by-bar. Requiring N consecutive candles in the same regime before committing reduces whipsaws:

# Require 3 consecutive candles in the same regime before committing.
# A candle is "stable" only if it matches the previous two.
df = df.with_columns(
    pl.when(
        (pl.col("regime") == pl.col("regime").shift(1)) &
        (pl.col("regime") == pl.col("regime").shift(2))
    )
    .then(pl.col("regime"))
    .otherwise(None)
    .forward_fill()
    .alias("stable_regime")
)

Next steps