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¶
- Funding Arb Strategy — combine regime with funding z-score
- Features Catalogue — add MACD trend confirmation
- Data Cleaning Decisions — understand warm-up periods for features