
Regime Detection
- 262 installs
- 257 repo stars
- Updated June 24, 2026
- agiprolabs/claude-trading-skills
regime-detection is a Claude Code skill that classifies market regimes by volatility and trend to select the right trading strategy and sizing.
About
regime-detection is a Claude Code skill that identifies the current market regime. It classifies markets into a four-quadrant model of volatility and trend using ATR percentile, ADX, EMA slope, Bollinger width, and the Hurst exponent, and maps each regime to suitable strategies. A developer uses it to route signals, adjust position size, and avoid running trend logic in a range.
- Four-quadrant regime model across volatility and trend axes, refined by the Hurst exponent
- No-ML approaches (ATR percentile, ADX, EMA slope, Bollinger width) plus rolling Hurst
- Ships detect_regime.py and regime_backtest.py comparing adaptive vs static strategies
Regime Detection by the numbers
- 262 all-time installs (skills.sh)
- Ranked #352 of 1,106 Finance & Trading skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
regime-detection capabilities & compatibility
Free; runs on pandas and numpy with no API keys.
- Capabilities
- regime detection · volatility analysis · trend detection · strategy selection
- Use cases
- data analysis
- Pricing
- Free
What regime-detection says it does
Identify the current market regime so you can pick the right strategy, size positions correctly, and avoid deploying trend-following logic in a ranging market (or vice versa).
Two orthogonal axes define the four-quadrant regime model
npx skills add https://github.com/agiprolabs/claude-trading-skills --skill regime-detectionAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 262 |
|---|---|
| repo stars | ★ 257 |
| Last updated | June 24, 2026 |
| Repository | agiprolabs/claude-trading-skills ↗ |
What it does
Classify the current market regime by volatility and trend so the right strategy and position size are used.
Who is it for?
Deciding which strategy playbook and sizing fit the current market environment.
Skip if: Generating specific entry prices or executing orders.
When should I use this skill?
You need to know whether the market is trending or ranging and high- or low-volatility before deploying a strategy.
What you get
A regime classification that guides strategy selection, position sizing, and stop adaptation.
- Regime classification
- Volatility and trend scores
- Regime-adaptive strategy backtest comparison
By the numbers
- 4-quadrant regime model
- Hurst thresholds (<0.4 mean-reverting, >0.6 trending)
- ADX >25 trending / <20 ranging
Files
Regime Detection
Identify the current market regime so you can pick the right strategy, size positions correctly, and avoid deploying trend-following logic in a ranging market (or vice versa).
Why Regime Detection Matters
Every strategy has a "home regime." A momentum strategy prints money in a clean uptrend but bleeds in a choppy range. A mean-reversion grid thrives in low-volatility consolidation but gets steamrolled by a trending breakout. Regime detection tells you which playbook to use right now.
Key benefits:
- Strategy selection: Route signals to the right strategy for the current environment
- Position sizing: Reduce exposure in hostile regimes, increase in favorable ones
- Stop adaptation: Wider stops in high-vol regimes, tighter in low-vol trends
- Drawdown control: Sit out "danger zone" regimes (high vol + no trend)
Core Regime Dimensions
Two orthogonal axes define the four-quadrant regime model:
| Low Volatility | High Volatility | |
|---|---|---|
| Trending | Q1: Clean trend — best for trend following | Q2: Volatile trend — momentum with caution |
| Ranging | Q3: Quiet range — mean-reversion paradise | Q4: Choppy chaos — reduce or sit out |
A third dimension — mean-reversion tendency (Hurst exponent) — refines Q3 by telling you how reliably price reverts.
Simple Approaches (No ML Required)
1. ATR Volatility Percentile
Rank the current ATR against its own recent history to get a 0–100 percentile score.
import pandas as pd
import numpy as np
def atr_percentile(
high: pd.Series, low: pd.Series, close: pd.Series,
atr_period: int = 14, lookback: int = 100
) -> pd.Series:
"""ATR percentile rank over a rolling window."""
tr = pd.concat([
high - low,
(high - close.shift(1)).abs(),
(low - close.shift(1)).abs()
], axis=1).max(axis=1)
atr = tr.rolling(atr_period).mean()
return atr.rolling(lookback).apply(
lambda x: pd.Series(x).rank(pct=True).iloc[-1], raw=False
)- < 25th percentile → Low volatility regime
- 25th–75th → Normal volatility
- > 75th percentile → High volatility regime
2. ADX Trend Strength
ADX above 25 signals a trending market; below 20 signals a range.
def compute_adx(
high: pd.Series, low: pd.Series, close: pd.Series,
period: int = 14
) -> pd.Series:
"""Average Directional Index."""
plus_dm = high.diff().clip(lower=0)
minus_dm = (-low.diff()).clip(lower=0)
# Zero out when the other is larger
plus_dm[plus_dm < minus_dm] = 0
minus_dm[minus_dm < plus_dm] = 0
tr = pd.concat([
high - low,
(high - close.shift(1)).abs(),
(low - close.shift(1)).abs()
], axis=1).max(axis=1)
atr = tr.ewm(span=period, adjust=False).mean()
plus_di = 100 * plus_dm.ewm(span=period, adjust=False).mean() / atr
minus_di = 100 * minus_dm.ewm(span=period, adjust=False).mean() / atr
dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di)
return dx.ewm(span=period, adjust=False).mean()3. EMA Slope + Price Position
def trend_direction(close: pd.Series, period: int = 20) -> pd.Series:
"""Returns +1 (uptrend), -1 (downtrend), 0 (neutral)."""
ema = close.ewm(span=period, adjust=False).mean()
slope = ema.diff(5) # 5-bar slope
above = (close > ema).astype(int)
direction = pd.Series(0, index=close.index)
direction[(slope > 0) & (above == 1)] = 1
direction[(slope < 0) & (above == 0)] = -1
return direction4. Bollinger Band Width Percentile
BB width (upper - lower) / middle as a volatility proxy. A "squeeze" (low percentile) often precedes a breakout.
def bb_width_percentile(
close: pd.Series, period: int = 20,
std_dev: float = 2.0, lookback: int = 100
) -> pd.Series:
"""Bollinger Band width percentile."""
sma = close.rolling(period).mean()
std = close.rolling(period).std()
width = (2 * std_dev * std) / sma
return width.rolling(lookback).apply(
lambda x: pd.Series(x).rank(pct=True).iloc[-1], raw=False
)Statistical Approaches
Rolling Hurst Exponent
The Hurst exponent H classifies time series behavior:
- H < 0.4 → Mean-reverting (anti-persistent)
- 0.4 ≤ H ≤ 0.6 → Random walk (no exploitable structure)
- H > 0.6 → Trending (persistent)
Computed via the Rescaled Range (R/S) method. See references/methodology.md for the full derivation.
def hurst_exponent(series: pd.Series, max_lag: int = 50) -> float:
"""Estimate Hurst exponent using R/S method."""
lags = range(2, max_lag)
rs_values = []
for lag in lags:
chunks = [series.iloc[i:i+lag] for i in range(0, len(series) - lag, lag)]
rs_list = []
for chunk in chunks:
if len(chunk) < lag:
continue
mean_val = chunk.mean()
devs = chunk - mean_val
cumdev = devs.cumsum()
r = cumdev.max() - cumdev.min()
s = chunk.std(ddof=1)
if s > 0:
rs_list.append(r / s)
if rs_list:
rs_values.append(np.mean(rs_list))
else:
rs_values.append(np.nan)
valid = [(l, r) for l, r in zip(lags, rs_values) if not np.isnan(r)]
if len(valid) < 5:
return 0.5
log_lags = np.log([v[0] for v in valid])
log_rs = np.log([v[1] for v in valid])
coeffs = np.polyfit(log_lags, log_rs, 1)
return coeffs[0]Change-Point Detection (CUSUM)
Detects abrupt shifts in mean or variance of a return series.
def cusum_test(
returns: pd.Series, threshold: float = 2.0
) -> list[int]:
"""CUSUM change-point detection on returns.
Returns indices where regime changes are detected.
"""
mean_r = returns.mean()
std_r = returns.std()
if std_r == 0:
return []
s_pos, s_neg = 0.0, 0.0
changes = []
for i, r in enumerate(returns):
z = (r - mean_r) / std_r
s_pos = max(0, s_pos + z - 0.5)
s_neg = max(0, s_neg - z - 0.5)
if s_pos > threshold or s_neg > threshold:
changes.append(i)
s_pos, s_neg = 0.0, 0.0
return changesHidden Markov Models
For 2–3 state regime models using hmmlearn. This is optional — all core functionality works with numpy/pandas only.
# Optional: requires `uv pip install hmmlearn`
from hmmlearn import hmm
def fit_hmm_regimes(
returns: np.ndarray, n_states: int = 2, n_iter: int = 100
) -> tuple[np.ndarray, object]:
"""Fit a Gaussian HMM to return series."""
X = returns.reshape(-1, 1)
model = hmm.GaussianHMM(
n_components=n_states, covariance_type="full", n_iter=n_iter
)
model.fit(X)
states = model.predict(X)
return states, modelSee references/methodology.md for details on feature selection and state interpretation.
Crypto-Specific Considerations
Regime Speed
Crypto regimes change much faster than equities:
| Parameter | Equities | Crypto (large cap) | Crypto (micro cap / PumpFun) |
|---|---|---|---|
| ATR lookback | 100–200 bars | 50–100 bars | 20–50 bars |
| ADX period | 14–28 | 10–14 | 7–10 |
| Regime persistence | Weeks–months | Days–weeks | Hours–days |
| Hurst window | 200+ bars | 100 bars | 50 bars |
Volume as a Regime Signal
In crypto, volume confirms regime quality:
- High volume + trend → Strong conviction, ride it
- Low volume + trend → Drift, unreliable, reduce size
- High volume + range → Distribution or accumulation, watch for breakout
- Low volume + range → Dead market, skip
PumpFun Micro-Regimes
New token launches follow a stereotyped sequence: 1. Launch pump (minutes): Vertical move, extreme vol, no mean-reversion 2. First dump (minutes–hours): Profit-taking, high vol, trending down 3. Consolidation (hours–days): Low vol range, potential mean-reversion 4. Second wave or death: Either breaks out again (new trend) or fades to zero
Each micro-regime lasts minutes to hours. Use 1-minute bars with 20–50 bar windows.
Combined Regime Classification
def classify_regime(
vol_percentile: float, adx: float, hurst: float,
trend_dir: int
) -> dict[str, str]:
"""Classify into the 4-quadrant model."""
vol_regime = (
"low" if vol_percentile < 0.30
else "high" if vol_percentile > 0.70
else "normal"
)
trend_regime = (
"trending" if adx > 25
else "ranging" if adx < 20
else "transitional"
)
direction = (
"up" if trend_dir > 0
else "down" if trend_dir < 0
else "neutral"
)
mr_regime = (
"mean_reverting" if hurst < 0.4
else "trending" if hurst > 0.6
else "random"
)
return {
"volatility": vol_regime,
"trend": trend_regime,
"direction": direction,
"mean_reversion": mr_regime,
"quadrant": f"{vol_regime}_vol_{trend_regime}",
}Strategy Adaptation
See references/strategy_adaptation.md for the full regime-strategy matrix.
Quick reference:
| Current Regime | Action |
|---|---|
| Low vol + trending up | Full size trend-following, tight stops |
| High vol + trending | Half size momentum, wide stops |
| Low vol + ranging | Mean-reversion / grid strategies |
| High vol + ranging | Reduce to 25% size or sit out |
| Regime transition | Flatten or reduce to minimum size |
Integration with Other Skills
- `pandas-ta`: Compute ATR, ADX, Bollinger Bands, EMAs
- `volatility-modeling`: Advanced vol forecasting (GARCH, realized vol)
- `strategy-framework`: Route signals through regime filter before execution
- `position-sizing`: Scale position size by regime volatility
- `risk-management`: Adjust portfolio risk limits per regime
Files
References
references/methodology.md— Detailed math for Hurst exponent, HMM, change-point detection, and volatility estimation methodsreferences/strategy_adaptation.md— Full regime-strategy matrix with position sizing, stop adaptation, and PumpFun micro-regime playbook
Scripts
scripts/detect_regime.py— Compute regime indicators on live or demo data, classify into 4-quadrant modelscripts/regime_backtest.py— Compare regime-adaptive vs static strategy on synthetic data with clear regime transitions
Regime Detection — Methodology Reference
Detailed mathematical methods for identifying market regimes.
Volatility Regime Detection
Rolling Standard Deviation of Returns
The simplest volatility estimator. Compute log returns, then rolling standard deviation:
r_t = ln(close_t / close_{t-1})
σ_rolling = std(r_{t-N+1}, ..., r_t)- Window: 20 bars for crypto, 50–100 for equities
- Percentile rank: Rank current σ against the last 100 values → 0–100 score
- Annualization: Multiply by √(bars_per_year) if needed, but percentile rank doesn't require it
ATR Normalization
Raw ATR depends on price level. Normalize for cross-asset comparison:
NATR = (ATR / close) × 100NATR of 5% means average true range is 5% of price. Useful for comparing BTC (NATR ~3%) vs a micro-cap (NATR ~15%).
Parkinson Volatility
Uses high-low range instead of close-to-close. More efficient estimator:
σ_park = sqrt( (1 / (4N ln2)) × Σ (ln(H_i / L_i))² )Advantages:
- Uses intrabar information (high/low)
- ~5x more efficient than close-to-close estimator
- Less affected by overnight gaps (less relevant for 24/7 crypto)
Disadvantage:
- Does not capture gap risk (opening jumps)
Garman-Klass Volatility
Extends Parkinson by incorporating open and close:
σ_gk = sqrt( (1/N) × Σ [ 0.5 × (ln(H/L))² - (2ln2 - 1) × (ln(C/O))² ] )Most efficient estimator using OHLC data. Recommended for crypto where you have reliable OHLC.
Trend Detection
ADX Calculation
The Average Directional Index measures trend strength regardless of direction.
Step 1: Directional Movement
+DM = max(high_t - high_{t-1}, 0) if > |low_{t-1} - low_t|, else 0
-DM = max(low_{t-1} - low_t, 0) if > (high_t - high_{t-1}), else 0Step 2: Directional Indicators (smoothed over N periods)
+DI = 100 × smooth(+DM, N) / smooth(TR, N)
-DI = 100 × smooth(-DM, N) / smooth(TR, N)Step 3: ADX
DX = 100 × |+DI - -DI| / (+DI + -DI)
ADX = smooth(DX, N)Interpretation:
- ADX < 20: No trend (ranging)
- 20–25: Weak trend or emerging trend
- 25–40: Strong trend
- > 40: Very strong trend (often near exhaustion in crypto)
ADX does not indicate direction — only strength. Use +DI vs -DI or price vs EMA for direction.
Linear Regression Slope + R²
Fit a linear regression to the last N closing prices:
slope = Σ((i - ī)(p_i - p̄)) / Σ((i - ī)²)
R² = 1 - Σ(p_i - ŷ_i)² / Σ(p_i - p̄)²- slope > 0 + R² > 0.6: Clean uptrend
- slope < 0 + R² > 0.6: Clean downtrend
- R² < 0.3: No linear trend (ranging or chaotic)
R² is the key signal — high R² means price is "well-behaved" along the regression line, ideal for trend-following.
EMA Spread as Trend Strength
trend_strength = (EMA_fast - EMA_slow) / ATRNormalizing by ATR makes the spread comparable across assets and timeframes. Values > 1.5 indicate strong trend, < 0.5 indicates weak/no trend.
Hurst Exponent
Rescaled Range (R/S) Method
The Hurst exponent H characterizes time series persistence.
Algorithm: 1. For each lag τ from 2 to max_lag: a. Divide series into chunks of length τ b. For each chunk:
- Compute mean m
- Compute cumulative deviations: Y_t = Σ(x_i - m) for i=1..t
- R = max(Y) - min(Y) (range of cumulative deviations)
- S = std(chunk) (standard deviation)
- RS = R / S
c. Average RS across all chunks for this lag 2. Plot log(RS) vs log(τ) 3. H = slope of the best-fit line
Interpretation:
- H < 0.4: Anti-persistent (mean-reverting). Past up-moves predict future down-moves.
- 0.4 ≤ H ≤ 0.6: No significant memory. Approximately random walk.
- H > 0.6: Persistent (trending). Past up-moves predict future up-moves.
Practical notes:
- Need at least 100 data points for reliable estimation
- Rolling Hurst: compute H over a sliding window of 100 bars, step by 1
- Crypto assets frequently oscillate between H=0.3 and H=0.7 over days
- Hurst near 0.5 is uninformative — don't trade on it
Detrended Fluctuation Analysis (DFA)
An alternative to R/S that handles non-stationary trends better:
1. Compute cumulative sum of mean-centered returns 2. Divide into windows of size n 3. Fit a polynomial trend to each window 4. Compute RMS of residuals F(n) 5. H = slope of log(F(n)) vs log(n)
DFA is more robust than R/S for short series but computationally heavier.
Change-Point Detection
CUSUM (Cumulative Sum)
Detects shifts in the mean of a standardized series.
Algorithm:
S⁺_0 = S⁻_0 = 0
S⁺_t = max(0, S⁺_{t-1} + z_t - k)
S⁻_t = max(0, S⁻_{t-1} - z_t - k)Where z_t = (r_t - μ) / σ and k = 0.5 (slack parameter).
A change point is detected when S⁺ or S⁻ exceeds threshold h (typically 2–5). Higher h = fewer false alarms but slower detection.
Tuning for crypto:
- k = 0.5, h = 2.0 for fast detection (more false positives)
- k = 0.5, h = 4.0 for conservative detection
- Recompute μ and σ using the last 50–100 bars, not the full history
Pettitt Test
Non-parametric test for a single change point in the series. Tests whether the distribution before and after a candidate point differs significantly.
Good for confirming CUSUM-detected change points with a p-value.
Hidden Markov Models
Model Setup
A Gaussian HMM with N states models regime-switching:
- States: Unobserved regimes (e.g., bull, bear, neutral)
- Observations: Return features (returns, volatility, volume change)
- Transition matrix: Probability of switching between states
- Emission: Each state has a Gaussian distribution over observations
Feature Selection
Recommended feature vector for crypto: 1. Log returns (captures direction + magnitude) 2. Rolling volatility (captures vol regime) 3. Volume ratio: volume / rolling_mean_volume (captures participation)
Practical Limitations
- Labels are arbitrary: HMM assigns state 0, 1, 2 — you must interpret them by examining each state's mean return and volatility
- Non-stationary: Retrain periodically (every 200–500 bars)
- Sensitive to initialization: Run multiple times, pick best log-likelihood
- Overfitting with >3 states: Stick to 2–3 states for crypto
- Look-ahead risk: Use only past data for training when backtesting
State Interpretation
After fitting, examine each state:
for i in range(n_states):
mask = (states == i)
print(f"State {i}: mean_return={returns[mask].mean():.4f}, "
f"vol={returns[mask].std():.4f}, "
f"pct_time={mask.mean():.1%}")Label states by their characteristics:
- Highest mean return + moderate vol → "Bull"
- Lowest mean return + high vol → "Bear"
- Near-zero return + low vol → "Neutral/Range"
Regime Detection — Strategy Adaptation Reference
How to adapt trading strategies, position sizing, and risk parameters based on the detected market regime.
Regime-Strategy Matrix
| Regime | Best Strategies | Worst Strategies | Notes |
|---|---|---|---|
| Low vol + trend up | Trend following, breakout, momentum | Mean reversion, short selling | Cleanest edge. Full position size. Tight trailing stops. |
| Low vol + trend down | Trend following (short), hedging | Buying dips, grid trading | Less common in crypto. Often precedes capitulation. |
| High vol + trend up | Momentum with wide stops, scale-in | Tight stop trend following | Stops get hunted. Use half size, 2x normal stop width. |
| High vol + trend down | Short momentum, hedging, cash | Buying dips, mean reversion | Most dangerous. Drawdowns accelerate. Preserve capital. |
| Low vol + range | Mean reversion, grid, market making | Trend following, breakout | Bollinger band bounce, RSI mean reversion. High win rate. |
| High vol + range | Volatility strategies, wide grids | Tight stops, trend following | Danger zone. Whipsaws destroy both trend and MR strategies. |
| Transitional | Reduce size, wait for clarity | Any full-size strategy | Regime shifts cause the biggest losses. Wait for confirmation. |
Position Sizing by Regime
Volatility-Adjusted Sizing
Base position size on the inverse of current volatility:
def regime_adjusted_size(
base_size: float,
vol_percentile: float,
adx: float
) -> float:
"""Adjust position size based on regime.
Args:
base_size: Normal position size (e.g., 1.0 = 100%).
vol_percentile: Current ATR percentile (0-1).
adx: Current ADX value.
Returns:
Adjusted position size.
"""
# Volatility adjustment: scale inversely with vol
if vol_percentile > 0.80:
vol_mult = 0.25 # Very high vol: quarter size
elif vol_percentile > 0.60:
vol_mult = 0.50 # High vol: half size
elif vol_percentile < 0.20:
vol_mult = 1.25 # Very low vol: can go slightly over
else:
vol_mult = 1.0 # Normal vol: full size
# Trend clarity adjustment
if adx > 30:
trend_mult = 1.25 # Strong trend: increase size
elif adx < 15:
trend_mult = 0.50 # No trend: reduce size
else:
trend_mult = 1.0
return base_size * vol_mult * trend_multSizing Rules of Thumb
| Regime | Size Multiplier | Rationale |
|---|---|---|
| Low vol + strong trend | 1.25–1.50x | Best risk/reward, exploit it |
| Normal vol + trend | 1.0x | Standard sizing |
| High vol + trend | 0.50x | Same direction conviction, more noise |
| Low vol + range | 0.75x | Lower expected return per trade |
| High vol + range | 0.25x | Danger zone, minimal exposure |
| Any transition | 0.25–0.50x | Wait for regime to establish |
Stop Loss Adaptation
ATR-Based Stop Scaling
def regime_stop_distance(
atr: float,
vol_percentile: float,
regime: str
) -> float:
"""Compute stop distance adapted to current regime.
Returns distance in price units.
"""
if regime == "trending":
# Wider stops to avoid getting shaken out
if vol_percentile > 0.70:
return atr * 3.0 # High vol trend: 3x ATR
else:
return atr * 2.0 # Low vol trend: 2x ATR
else:
# Range: tighter stops, expect mean reversion
if vol_percentile > 0.70:
return atr * 2.5 # High vol range: wide but capped
else:
return atr * 1.5 # Low vol range: tightStop Adaptation Summary
| Regime | Stop Width (ATR multiples) | Stop Type |
|---|---|---|
| Low vol trend | 2.0x ATR | Trailing stop |
| High vol trend | 3.0x ATR | Trailing stop, wider |
| Low vol range | 1.5x ATR | Fixed stop at range edge |
| High vol range | 2.5x ATR | Fixed stop, or time-based exit |
Indicator Parameter Adaptation
Adjust indicator lookback periods based on regime speed:
| Parameter | Low Vol / Slow | High Vol / Fast |
|---|---|---|
| EMA fast period | 20 | 8–12 |
| EMA slow period | 50 | 20–30 |
| RSI period | 14 | 7–10 |
| ADX period | 14 | 7–10 |
| ATR period | 14 | 7–10 |
| Bollinger period | 20 | 10–14 |
| Hurst window | 100–200 | 50–100 |
Rationale: High-volatility regimes have faster information incorporation. Longer-period indicators lag too much and generate late signals.
Regime Transition Signals
Early warning that a regime is about to change:
Volatility Regime Transitions
- Low → High vol: Bollinger Band width expanding rapidly (BB width crosses above 75th percentile from below). ATR increasing 2+ consecutive bars.
- High → Low vol: BB squeeze forming. ATR decreasing. Volume declining.
Trend Regime Transitions
- Range → Trend: ADX crossing above 20 from below. Price breaking out of Bollinger Bands. Volume spike (>2x average).
- Trend → Range: ADX declining from above 30. DI+ and DI- converging. Hurst dropping toward 0.5.
Transition Playbook
1. Detect potential transition (2+ signals aligning) 2. Reduce position size to 50% until new regime confirms 3. Wait 3–5 bars for regime to establish (avoid whipsaws) 4. Switch strategy only after regime persists for 5+ bars 5. Never fully switch on a single bar signal
PumpFun Micro-Regime Playbook
For new token launches on Solana, regimes compress into minutes/hours.
Phase Detection
def detect_pump_phase(
close: pd.Series, volume: pd.Series,
bars_since_launch: int
) -> str:
"""Identify current micro-regime phase for a new token."""
recent_return = (close.iloc[-1] / close.iloc[-10] - 1) if len(close) > 10 else 0
vol_ratio = volume.iloc[-5:].mean() / volume.mean() if len(volume) > 20 else 1
if bars_since_launch < 20:
if recent_return > 0.5: # Up 50%+ in first bars
return "launch_pump"
return "launch_uncertain"
elif bars_since_launch < 60:
if recent_return < -0.3 and vol_ratio > 1.5:
return "first_dump"
elif abs(recent_return) < 0.1 and vol_ratio < 0.5:
return "consolidation"
else:
return "volatile_transition"
else:
if recent_return > 0.3 and vol_ratio > 1.0:
return "second_wave"
elif recent_return < -0.2:
return "fading"
else:
return "consolidation"Micro-Regime Actions
| Phase | Action | Position Size | Stop |
|---|---|---|---|
| Launch pump | Don't chase | 0% | — |
| First dump | Watch, don't catch knife | 0% | — |
| Consolidation | Assess holders, volume | Scout (10%) | Below range low |
| Second wave | Enter if volume confirms | 25–50% | Below consolidation low |
| Fading | Avoid | 0% | — |
Risk Regime Override
Regardless of regime classification, apply these hard overrides:
1. Drawdown override: If portfolio down > 5% today, reduce all sizes to 25% regardless of regime 2. Correlation spike: If BTC drops > 5% in 1 hour, all altcoin regimes reset to "danger" — everything correlates in crashes 3. Volume death: If volume drops below 20th percentile of its own history, treat any trend signal as unreliable 4. Spread blowout: If bid-ask spread exceeds 2%, the regime is "illiquid" — reduce size and widen stops regardless
#!/usr/bin/env python3
"""Detect the current market regime for a token using multiple indicators.
Computes volatility percentile (ATR), trend strength (ADX), Hurst exponent,
and Bollinger Band width to classify the market into a 4-quadrant regime model.
Usage:
python scripts/detect_regime.py --demo
python scripts/detect_regime.py --mint So11111111111111111111111111111111111111112
Dependencies:
uv pip install pandas numpy httpx
Environment Variables:
BIRDEYE_API_KEY: Your Birdeye API key (optional, only for live data)
TOKEN_MINT: Token mint address (optional, overridden by --mint)
"""
import argparse
import os
import sys
from typing import Optional
import numpy as np
import pandas as pd
# ── Configuration ───────────────────────────────────────────────────
BIRDEYE_API_KEY = os.getenv("BIRDEYE_API_KEY", "")
DEFAULT_MINT = os.getenv(
"TOKEN_MINT", "So11111111111111111111111111111111111111112"
)
# Regime detection parameters
ATR_PERIOD = 14
ADX_PERIOD = 14
BB_PERIOD = 20
BB_STD = 2.0
VOL_LOOKBACK = 100
HURST_MAX_LAG = 50
EMA_FAST = 20
EMA_SLOW = 50
# ── Data Generation (Demo Mode) ────────────────────────────────────
def generate_demo_data(n_bars: int = 300) -> pd.DataFrame:
"""Generate synthetic OHLCV data with clear regime transitions.
Creates four distinct regimes:
- Bars 0-74: Low volatility uptrend
- Bars 75-149: High volatility uptrend
- Bars 150-224: Low volatility range
- Bars 225-299: High volatility range (choppy)
Args:
n_bars: Total number of bars to generate.
Returns:
DataFrame with open, high, low, close, volume columns.
"""
np.random.seed(42)
prices = [100.0]
volumes = []
for i in range(1, n_bars):
if i < 75:
# Low vol uptrend
drift = 0.002
vol = 0.008
base_vol = 1000
elif i < 150:
# High vol uptrend
drift = 0.003
vol = 0.025
base_vol = 2000
elif i < 225:
# Low vol range
mean_price = prices[-1]
drift = (150 - prices[-1]) * 0.01 # Mean revert to 150
vol = 0.006
base_vol = 800
else:
# High vol range (choppy)
drift = (150 - prices[-1]) * 0.005
vol = 0.03
base_vol = 1500
ret = drift + vol * np.random.randn()
prices.append(prices[-1] * (1 + ret))
volumes.append(base_vol * (1 + 0.3 * abs(np.random.randn())))
volumes.append(volumes[-1]) # Pad to match length
prices_arr = np.array(prices)
# Generate OHLC from close prices
noise = np.abs(np.random.randn(n_bars)) * 0.005 + 0.002
highs = prices_arr * (1 + noise)
lows = prices_arr * (1 - noise)
opens = np.roll(prices_arr, 1)
opens[0] = prices_arr[0]
return pd.DataFrame({
"open": opens,
"high": highs,
"low": lows,
"close": prices_arr,
"volume": volumes,
})
# ── Data Fetching (Live Mode) ──────────────────────────────────────
def fetch_ohlcv(
mint: str, api_key: str, timeframe: str = "15m", limit: int = 300
) -> pd.DataFrame:
"""Fetch OHLCV data from Birdeye API.
Args:
mint: Token mint address.
api_key: Birdeye API key.
timeframe: Candle timeframe (1m, 5m, 15m, 1H, 4H, 1D).
limit: Number of candles to fetch.
Returns:
DataFrame with open, high, low, close, volume columns.
Raises:
SystemExit: If API call fails.
"""
try:
import httpx
except ImportError:
print("httpx required for live mode: uv pip install httpx")
sys.exit(1)
url = "https://public-api.birdeye.so/defi/ohlcv"
import time
time_to = int(time.time())
tf_seconds = {
"1m": 60, "5m": 300, "15m": 900,
"1H": 3600, "4H": 14400, "1D": 86400,
}
seconds = tf_seconds.get(timeframe, 900)
time_from = time_to - (limit * seconds)
params = {
"address": mint,
"type": timeframe,
"time_from": time_from,
"time_to": time_to,
}
headers = {"X-API-KEY": api_key}
try:
resp = httpx.get(url, params=params, headers=headers, timeout=30.0)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPStatusError as e:
print(f"API error: {e.response.status_code} — {e.response.text[:200]}")
sys.exit(1)
except httpx.RequestError as e:
print(f"Request failed: {e}")
sys.exit(1)
items = data.get("data", {}).get("items", [])
if not items:
print("No OHLCV data returned. Check mint address and API key.")
sys.exit(1)
df = pd.DataFrame(items)
df = df.rename(columns={
"o": "open", "h": "high", "l": "low", "c": "close", "v": "volume",
})
for col in ["open", "high", "low", "close", "volume"]:
df[col] = pd.to_numeric(df[col], errors="coerce")
df = df.dropna(subset=["close"])
df = df.sort_values("unixTime").reset_index(drop=True)
return df[["open", "high", "low", "close", "volume"]]
# ── Regime Indicators ──────────────────────────────────────────────
def compute_atr(
high: pd.Series, low: pd.Series, close: pd.Series, period: int = 14
) -> pd.Series:
"""Compute Average True Range.
Args:
high: High prices.
low: Low prices.
close: Close prices.
period: Smoothing period.
Returns:
ATR series.
"""
tr = pd.concat([
high - low,
(high - close.shift(1)).abs(),
(low - close.shift(1)).abs(),
], axis=1).max(axis=1)
return tr.rolling(period).mean()
def compute_atr_percentile(
high: pd.Series, low: pd.Series, close: pd.Series,
atr_period: int = 14, lookback: int = 100
) -> pd.Series:
"""ATR percentile rank over a rolling window.
Args:
high: High prices.
low: Low prices.
close: Close prices.
atr_period: ATR smoothing period.
lookback: Window for percentile ranking.
Returns:
Series of percentile values (0-1).
"""
atr = compute_atr(high, low, close, atr_period)
return atr.rolling(lookback).rank(pct=True)
def compute_adx(
high: pd.Series, low: pd.Series, close: pd.Series, period: int = 14
) -> pd.Series:
"""Compute Average Directional Index.
Args:
high: High prices.
low: Low prices.
close: Close prices.
period: Smoothing period.
Returns:
ADX series.
"""
plus_dm = high.diff().clip(lower=0)
minus_dm = (-low.diff()).clip(lower=0)
# Zero out the smaller DM
mask_plus = plus_dm < minus_dm
mask_minus = minus_dm < plus_dm
plus_dm = plus_dm.copy()
minus_dm = minus_dm.copy()
plus_dm[mask_plus] = 0
minus_dm[mask_minus] = 0
tr = pd.concat([
high - low,
(high - close.shift(1)).abs(),
(low - close.shift(1)).abs(),
], axis=1).max(axis=1)
atr = tr.ewm(span=period, adjust=False).mean()
plus_di = 100 * plus_dm.ewm(span=period, adjust=False).mean() / atr
minus_di = 100 * minus_dm.ewm(span=period, adjust=False).mean() / atr
dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di + 1e-10)
adx = dx.ewm(span=period, adjust=False).mean()
return adx
def compute_trend_direction(close: pd.Series, period: int = 20) -> pd.Series:
"""Detect trend direction via EMA slope and price position.
Args:
close: Close prices.
period: EMA period.
Returns:
Series: +1 (uptrend), -1 (downtrend), 0 (neutral).
"""
ema = close.ewm(span=period, adjust=False).mean()
slope = ema.diff(5)
above = close > ema
direction = pd.Series(0, index=close.index, dtype=int)
direction[(slope > 0) & above] = 1
direction[(slope < 0) & ~above] = -1
return direction
def compute_bb_width_percentile(
close: pd.Series, period: int = 20,
std_dev: float = 2.0, lookback: int = 100
) -> pd.Series:
"""Bollinger Band width percentile.
Args:
close: Close prices.
period: BB period.
std_dev: Standard deviation multiplier.
lookback: Window for percentile ranking.
Returns:
Series of BB width percentile values (0-1).
"""
sma = close.rolling(period).mean()
std = close.rolling(period).std()
width = (2 * std_dev * std) / sma
return width.rolling(lookback).rank(pct=True)
def compute_hurst(series: pd.Series, max_lag: int = 50) -> float:
"""Estimate Hurst exponent via Rescaled Range (R/S) method.
Args:
series: Price or return series.
max_lag: Maximum lag for R/S computation.
Returns:
Hurst exponent estimate.
"""
values = series.dropna().values
if len(values) < max_lag * 2:
return 0.5 # Not enough data
lags = range(2, min(max_lag, len(values) // 4))
rs_means = []
valid_lags = []
for lag in lags:
n_chunks = len(values) // lag
if n_chunks < 1:
continue
rs_list = []
for c in range(n_chunks):
chunk = values[c * lag:(c + 1) * lag]
if len(chunk) < lag:
continue
mean_c = np.mean(chunk)
devs = np.cumsum(chunk - mean_c)
r = np.max(devs) - np.min(devs)
s = np.std(chunk, ddof=1)
if s > 1e-10:
rs_list.append(r / s)
if rs_list:
rs_means.append(np.mean(rs_list))
valid_lags.append(lag)
if len(valid_lags) < 5:
return 0.5
log_lags = np.log(np.array(valid_lags, dtype=float))
log_rs = np.log(np.array(rs_means, dtype=float))
coeffs = np.polyfit(log_lags, log_rs, 1)
h = float(coeffs[0])
# Clamp to reasonable range
return max(0.0, min(1.0, h))
def compute_rolling_hurst(
close: pd.Series, window: int = 100, max_lag: int = 50
) -> pd.Series:
"""Compute rolling Hurst exponent.
Args:
close: Close prices.
window: Rolling window size.
max_lag: Max lag for R/S computation.
Returns:
Series of Hurst exponent values.
"""
log_returns = np.log(close / close.shift(1)).dropna()
hurst_values = pd.Series(np.nan, index=close.index)
for i in range(window, len(log_returns)):
segment = log_returns.iloc[i - window:i]
hurst_values.iloc[i + 1] = compute_hurst(segment, max_lag)
return hurst_values
# ── Regime Classification ──────────────────────────────────────────
def classify_regime(
vol_pct: float, adx_val: float, hurst_val: float, trend_dir: int
) -> dict[str, str]:
"""Classify into the 4-quadrant regime model.
Args:
vol_pct: ATR percentile (0-1).
adx_val: Current ADX value.
hurst_val: Current Hurst exponent.
trend_dir: +1 (up), -1 (down), 0 (neutral).
Returns:
Dict with volatility, trend, direction, hurst, and quadrant keys.
"""
vol_regime = (
"low" if vol_pct < 0.30
else "high" if vol_pct > 0.70
else "normal"
)
trend_regime = (
"trending" if adx_val > 25
else "ranging" if adx_val < 20
else "transitional"
)
direction = (
"up" if trend_dir > 0
else "down" if trend_dir < 0
else "neutral"
)
hurst_regime = (
"mean_reverting" if hurst_val < 0.4
else "trending" if hurst_val > 0.6
else "random_walk"
)
# Quadrant
if trend_regime == "trending" and vol_regime != "high":
quadrant = "quiet_trend"
elif trend_regime == "trending" and vol_regime == "high":
quadrant = "volatile_trend"
elif trend_regime != "trending" and vol_regime != "high":
quadrant = "quiet_range"
elif trend_regime != "trending" and vol_regime == "high":
quadrant = "volatile_range"
else:
quadrant = "uncertain"
return {
"volatility": vol_regime,
"trend": trend_regime,
"direction": direction,
"hurst": hurst_regime,
"quadrant": quadrant,
}
def get_strategy_recommendation(quadrant: str, direction: str) -> list[str]:
"""Return strategy recommendations for the current regime.
Args:
quadrant: Regime quadrant string.
direction: Trend direction string.
Returns:
List of strategy recommendation strings.
"""
recs: dict[str, list[str]] = {
"quiet_trend": [
"Trend following with full position size",
f"Direction: {direction} — align entries with trend",
"Use 2x ATR trailing stop",
"Standard indicator periods (20/50 EMA, 14 RSI)",
],
"volatile_trend": [
"Momentum strategy with REDUCED size (50%)",
f"Direction: {direction} — strong but noisy",
"Use 3x ATR trailing stop to avoid shakeouts",
"Shorter indicator periods (10/20 EMA, 7 RSI)",
],
"quiet_range": [
"Mean-reversion / grid strategy",
"RSI oversold/overbought at range boundaries",
"Use 1.5x ATR fixed stops at range edges",
"Bollinger Band bounce entries",
],
"volatile_range": [
"DANGER ZONE — reduce to 25% size or sit out",
"Both trend and mean-reversion strategies will whipsaw",
"If trading: use 3x ATR stops, expect frequent stops",
"Wait for regime to resolve before full-size entries",
],
}
return recs.get(quadrant, ["Regime unclear — reduce size and wait for clarity"])
# ── Display ─────────────────────────────────────────────────────────
def display_current_regime(
df: pd.DataFrame,
vol_pct: pd.Series,
adx: pd.Series,
trend_dir: pd.Series,
bb_pct: pd.Series,
hurst_series: pd.Series,
) -> None:
"""Print the current regime analysis.
Args:
df: OHLCV DataFrame.
vol_pct: ATR percentile series.
adx: ADX series.
trend_dir: Trend direction series.
bb_pct: BB width percentile series.
hurst_series: Rolling Hurst series.
"""
# Get latest valid values
latest_vol = vol_pct.dropna().iloc[-1] if not vol_pct.dropna().empty else 0.5
latest_adx = adx.dropna().iloc[-1] if not adx.dropna().empty else 20.0
latest_trend = int(trend_dir.dropna().iloc[-1]) if not trend_dir.dropna().empty else 0
latest_bb = bb_pct.dropna().iloc[-1] if not bb_pct.dropna().empty else 0.5
latest_hurst = hurst_series.dropna().iloc[-1] if not hurst_series.dropna().empty else 0.5
latest_close = df["close"].iloc[-1]
regime = classify_regime(latest_vol, latest_adx, latest_hurst, latest_trend)
print("\n" + "=" * 60)
print(" MARKET REGIME ANALYSIS")
print("=" * 60)
print(f"\n Price: {latest_close:.4f}")
print(f" ATR Percentile: {latest_vol:.2f} ({regime['volatility']} vol)")
print(f" BB Width Pct: {latest_bb:.2f}")
print(f" ADX: {latest_adx:.1f} ({regime['trend']})")
print(f" Trend Direction: {regime['direction']}")
print(f" Hurst Exponent: {latest_hurst:.3f} ({regime['hurst']})")
print(f"\n REGIME QUADRANT: {regime['quadrant'].upper()}")
print("-" * 60)
recs = get_strategy_recommendation(regime["quadrant"], regime["direction"])
print("\n Strategy Recommendations:")
for r in recs:
print(f" - {r}")
# Regime history for last 20 bars
print("\n" + "-" * 60)
print(" REGIME HISTORY (last 20 bars)")
print("-" * 60)
print(f" {'Bar':>5} {'Close':>10} {'Vol%':>6} {'ADX':>6} {'Dir':>5} {'Regime':<18}")
print(f" {'---':>5} {'-----':>10} {'----':>6} {'---':>6} {'---':>5} {'------':<18}")
start_idx = max(0, len(df) - 20)
for i in range(start_idx, len(df)):
vp = vol_pct.iloc[i] if not np.isnan(vol_pct.iloc[i]) else 0.5
ax = adx.iloc[i] if not np.isnan(adx.iloc[i]) else 20.0
td = int(trend_dir.iloc[i]) if not np.isnan(trend_dir.iloc[i]) else 0
hr = hurst_series.iloc[i] if not np.isnan(hurst_series.iloc[i]) else 0.5
r = classify_regime(vp, ax, hr, td)
dir_sym = "+" if td > 0 else "-" if td < 0 else "."
print(
f" {i:>5} {df['close'].iloc[i]:>10.4f} "
f"{vp:>6.2f} {ax:>6.1f} {dir_sym:>5} {r['quadrant']:<18}"
)
print("\n" + "=" * 60)
print(" This is analytical output, not a trading recommendation.")
print("=" * 60 + "\n")
# ── Main ────────────────────────────────────────────────────────────
def main() -> None:
"""Run regime detection analysis."""
parser = argparse.ArgumentParser(description="Market Regime Detection")
parser.add_argument("--demo", action="store_true", help="Use synthetic demo data")
parser.add_argument("--mint", type=str, default=None, help="Token mint address")
parser.add_argument("--timeframe", type=str, default="15m", help="Candle timeframe")
args = parser.parse_args()
if args.demo:
print("Running in DEMO mode with synthetic data...")
df = generate_demo_data(300)
else:
mint = args.mint or DEFAULT_MINT
if not BIRDEYE_API_KEY:
print("No BIRDEYE_API_KEY set. Use --demo for demo mode, or set the env var.")
sys.exit(1)
print(f"Fetching data for {mint[:8]}...{mint[-4:]} ({args.timeframe})...")
df = fetch_ohlcv(mint, BIRDEYE_API_KEY, args.timeframe)
print(f"Loaded {len(df)} bars. Computing regime indicators...")
# Compute all indicators
vol_pct = compute_atr_percentile(
df["high"], df["low"], df["close"], ATR_PERIOD, VOL_LOOKBACK
)
adx = compute_adx(df["high"], df["low"], df["close"], ADX_PERIOD)
trend_dir = compute_trend_direction(df["close"], EMA_FAST)
bb_pct = compute_bb_width_percentile(df["close"], BB_PERIOD, BB_STD, VOL_LOOKBACK)
hurst_series = compute_rolling_hurst(df["close"], window=100, max_lag=HURST_MAX_LAG)
display_current_regime(df, vol_pct, adx, trend_dir, bb_pct, hurst_series)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Backtest a regime-adaptive strategy vs a static strategy on synthetic data.
Generates OHLCV data with clear regime transitions, then compares:
- Static strategy: Always uses EMA crossover signals
- Adaptive strategy: Switches between EMA crossover (trending regime)
and RSI mean-reversion (ranging regime), with position sizing
adjusted by volatility regime.
Usage:
python scripts/regime_backtest.py
Dependencies:
uv pip install pandas numpy
Environment Variables:
None required (uses synthetic data only).
"""
import sys
from typing import Optional
import numpy as np
import pandas as pd
# ── Configuration ───────────────────────────────────────────────────
N_BARS = 500
INITIAL_CAPITAL = 10000.0
COMMISSION_BPS = 10 # 10 bps per trade (each way)
# Indicator parameters
ATR_PERIOD = 14
ADX_PERIOD = 14
EMA_FAST = 10
EMA_SLOW = 30
RSI_PERIOD = 14
RSI_OVERSOLD = 35
RSI_OVERBOUGHT = 65
VOL_LOOKBACK = 80
# ── Data Generation ────────────────────────────────────────────────
def generate_regime_data(n_bars: int = 500, seed: int = 123) -> pd.DataFrame:
"""Generate synthetic OHLCV data with multiple regime transitions.
Regime schedule:
- 0-79: Low vol uptrend
- 80-159: High vol uptrend
- 160-249: Low vol range (mean-reverting)
- 250-329: High vol range (choppy)
- 330-399: Low vol downtrend
- 400-499: Low vol uptrend (recovery)
Args:
n_bars: Number of bars.
seed: Random seed for reproducibility.
Returns:
DataFrame with open, high, low, close, volume columns and a
'true_regime' column for validation.
"""
rng = np.random.RandomState(seed)
prices = [100.0]
volumes = []
true_regimes = ["low_vol_uptrend"] # First bar regime
regime_schedule = [
(80, 0.002, 0.007, 1200, "low_vol_uptrend"),
(160, 0.003, 0.022, 2500, "high_vol_uptrend"),
(250, 0.0, 0.005, 900, "low_vol_range"),
(330, 0.0, 0.028, 1800, "high_vol_range"),
(400, -0.002, 0.008, 1100, "low_vol_downtrend"),
(500, 0.002, 0.007, 1300, "low_vol_uptrend"),
]
for i in range(1, n_bars):
# Find current regime
drift, vol, base_vol, regime_name = 0.0, 0.01, 1000, "unknown"
for end_bar, d, v, bv, name in regime_schedule:
if i < end_bar:
drift, vol, base_vol, regime_name = d, v, bv, name
break
# For range regimes, add mean-reversion force
if "range" in regime_name:
mean_target = 130.0 if i < 330 else 120.0
drift = (mean_target - prices[-1]) / mean_target * 0.05
ret = drift + vol * rng.randn()
prices.append(prices[-1] * (1 + ret))
volumes.append(base_vol * (1 + 0.4 * abs(rng.randn())))
true_regimes.append(regime_name)
volumes.append(volumes[-1])
prices_arr = np.array(prices)
# Generate OHLC
noise = np.abs(rng.randn(n_bars)) * 0.004 + 0.002
highs = prices_arr * (1 + noise)
lows = prices_arr * (1 - noise)
opens = np.roll(prices_arr, 1)
opens[0] = prices_arr[0]
return pd.DataFrame({
"open": opens,
"high": highs,
"low": lows,
"close": prices_arr,
"volume": volumes,
"true_regime": true_regimes,
})
# ── Indicators ──────────────────────────────────────────────────────
def compute_atr(
high: pd.Series, low: pd.Series, close: pd.Series, period: int = 14
) -> pd.Series:
"""Average True Range."""
tr = pd.concat([
high - low,
(high - close.shift(1)).abs(),
(low - close.shift(1)).abs(),
], axis=1).max(axis=1)
return tr.rolling(period).mean()
def compute_atr_percentile(
high: pd.Series, low: pd.Series, close: pd.Series,
atr_period: int = 14, lookback: int = 80
) -> pd.Series:
"""ATR percentile rank."""
atr = compute_atr(high, low, close, atr_period)
return atr.rolling(lookback).rank(pct=True)
def compute_adx(
high: pd.Series, low: pd.Series, close: pd.Series, period: int = 14
) -> pd.Series:
"""Average Directional Index."""
plus_dm = high.diff().clip(lower=0)
minus_dm = (-low.diff()).clip(lower=0)
plus_dm_copy = plus_dm.copy()
minus_dm_copy = minus_dm.copy()
plus_dm_copy[plus_dm < minus_dm] = 0
minus_dm_copy[minus_dm < plus_dm] = 0
tr = pd.concat([
high - low,
(high - close.shift(1)).abs(),
(low - close.shift(1)).abs(),
], axis=1).max(axis=1)
atr = tr.ewm(span=period, adjust=False).mean()
plus_di = 100 * plus_dm_copy.ewm(span=period, adjust=False).mean() / atr
minus_di = 100 * minus_dm_copy.ewm(span=period, adjust=False).mean() / atr
dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di + 1e-10)
return dx.ewm(span=period, adjust=False).mean()
def compute_rsi(close: pd.Series, period: int = 14) -> pd.Series:
"""Relative Strength Index.
Args:
close: Close prices.
period: RSI period.
Returns:
RSI series (0-100).
"""
delta = close.diff()
gain = delta.clip(lower=0)
loss = (-delta).clip(lower=0)
avg_gain = gain.ewm(span=period, adjust=False).mean()
avg_loss = loss.ewm(span=period, adjust=False).mean()
rs = avg_gain / (avg_loss + 1e-10)
return 100 - (100 / (1 + rs))
def detect_regime(vol_pct: float, adx_val: float) -> str:
"""Classify regime from volatility percentile and ADX.
Args:
vol_pct: ATR percentile (0-1).
adx_val: Current ADX value.
Returns:
Regime string.
"""
is_trending = adx_val > 22
is_high_vol = vol_pct > 0.65
if is_trending and not is_high_vol:
return "quiet_trend"
elif is_trending and is_high_vol:
return "volatile_trend"
elif not is_trending and not is_high_vol:
return "quiet_range"
else:
return "volatile_range"
# ── Strategy Logic ──────────────────────────────────────────────────
def run_static_strategy(df: pd.DataFrame) -> pd.DataFrame:
"""Run a static EMA crossover strategy (no regime adaptation).
Always uses EMA crossover signals with fixed position sizing.
Args:
df: OHLCV DataFrame.
Returns:
DataFrame with trade log columns.
"""
close = df["close"]
ema_fast = close.ewm(span=EMA_FAST, adjust=False).mean()
ema_slow = close.ewm(span=EMA_SLOW, adjust=False).mean()
position = 0 # 0 = flat, 1 = long
capital = INITIAL_CAPITAL
shares = 0.0
trades: list[dict] = []
for i in range(EMA_SLOW + 1, len(df)):
signal = 0
if ema_fast.iloc[i] > ema_slow.iloc[i] and ema_fast.iloc[i - 1] <= ema_slow.iloc[i - 1]:
signal = 1 # Buy
elif ema_fast.iloc[i] < ema_slow.iloc[i] and ema_fast.iloc[i - 1] >= ema_slow.iloc[i - 1]:
signal = -1 # Sell
price = close.iloc[i]
commission_rate = COMMISSION_BPS / 10000
if signal == 1 and position == 0:
# Buy with full capital
cost = capital * commission_rate
shares = (capital - cost) / price
capital = 0
position = 1
trades.append({"bar": i, "action": "BUY", "price": price,
"shares": shares, "strategy": "ema_xover"})
elif signal == -1 and position == 1:
# Sell all
proceeds = shares * price
cost = proceeds * commission_rate
capital = proceeds - cost
trades.append({"bar": i, "action": "SELL", "price": price,
"shares": shares, "pnl": capital - INITIAL_CAPITAL,
"strategy": "ema_xover"})
shares = 0
position = 0
# Close any open position at end
if position == 1:
price = close.iloc[-1]
proceeds = shares * price
cost = proceeds * (COMMISSION_BPS / 10000)
capital = proceeds - cost
trades.append({"bar": len(df) - 1, "action": "SELL (EOD)", "price": price,
"shares": shares, "pnl": capital - INITIAL_CAPITAL,
"strategy": "ema_xover"})
shares = 0
equity = capital if position == 0 else shares * close.iloc[-1]
return pd.DataFrame(trades) if trades else pd.DataFrame(), equity
def run_adaptive_strategy(
df: pd.DataFrame,
vol_pct: pd.Series,
adx: pd.Series,
rsi: pd.Series,
) -> tuple[pd.DataFrame, float]:
"""Run regime-adaptive strategy.
In trending regimes: use EMA crossover signals.
In ranging regimes: use RSI mean-reversion signals.
Position size adjusted by volatility regime.
Args:
df: OHLCV DataFrame.
vol_pct: ATR percentile series.
adx: ADX series.
rsi: RSI series.
Returns:
Tuple of (trade log DataFrame, final equity).
"""
close = df["close"]
ema_fast = close.ewm(span=EMA_FAST, adjust=False).mean()
ema_slow = close.ewm(span=EMA_SLOW, adjust=False).mean()
position = 0
capital = INITIAL_CAPITAL
shares = 0.0
trades: list[dict] = []
warmup = max(EMA_SLOW, VOL_LOOKBACK) + 5
for i in range(warmup, len(df)):
vp = vol_pct.iloc[i]
ax = adx.iloc[i]
rs = rsi.iloc[i]
if np.isnan(vp) or np.isnan(ax) or np.isnan(rs):
continue
regime = detect_regime(vp, ax)
price = close.iloc[i]
commission_rate = COMMISSION_BPS / 10000
# Position size multiplier based on regime
if regime == "quiet_trend":
size_mult = 1.0
elif regime == "volatile_trend":
size_mult = 0.50
elif regime == "quiet_range":
size_mult = 0.75
else: # volatile_range
size_mult = 0.25
signal = 0
if regime in ("quiet_trend", "volatile_trend"):
# Trend strategy: EMA crossover
if (ema_fast.iloc[i] > ema_slow.iloc[i]
and ema_fast.iloc[i - 1] <= ema_slow.iloc[i - 1]):
signal = 1
elif (ema_fast.iloc[i] < ema_slow.iloc[i]
and ema_fast.iloc[i - 1] >= ema_slow.iloc[i - 1]):
signal = -1
else:
# Range strategy: RSI mean-reversion
if rs < RSI_OVERSOLD:
signal = 1
elif rs > RSI_OVERBOUGHT:
signal = -1
if signal == 1 and position == 0:
invest = capital * size_mult
cost = invest * commission_rate
shares = (invest - cost) / price
capital -= invest
position = 1
trades.append({
"bar": i, "action": "BUY", "price": price,
"shares": shares, "regime": regime,
"size_mult": size_mult,
"strategy": "ema_xover" if "trend" in regime else "rsi_mr",
})
elif signal == -1 and position == 1:
proceeds = shares * price
cost = proceeds * commission_rate
capital += proceeds - cost
pnl = capital - INITIAL_CAPITAL
trades.append({
"bar": i, "action": "SELL", "price": price,
"shares": shares, "pnl": pnl, "regime": regime,
"strategy": "ema_xover" if "trend" in regime else "rsi_mr",
})
shares = 0
position = 0
# Close any open position
if position == 1:
price = close.iloc[-1]
proceeds = shares * price
cost = proceeds * (COMMISSION_BPS / 10000)
capital += proceeds - cost
trades.append({
"bar": len(df) - 1, "action": "SELL (EOD)", "price": price,
"shares": shares, "pnl": capital - INITIAL_CAPITAL,
"regime": "end", "strategy": "close",
})
shares = 0
equity = capital
trade_df = pd.DataFrame(trades) if trades else pd.DataFrame()
return trade_df, equity
# ── Performance Metrics ─────────────────────────────────────────────
def compute_metrics(
trades_df: pd.DataFrame, final_equity: float, label: str
) -> dict:
"""Compute performance metrics from a trade log.
Args:
trades_df: DataFrame with trade records.
final_equity: Final portfolio equity.
label: Strategy label.
Returns:
Dict of performance metrics.
"""
total_return = (final_equity / INITIAL_CAPITAL - 1) * 100
n_trades = len(trades_df[trades_df["action"].str.startswith("SELL")]) if len(trades_df) > 0 else 0
wins = 0
losses = 0
if len(trades_df) > 0 and "pnl" in trades_df.columns:
sell_trades = trades_df[trades_df["action"].str.startswith("SELL")].copy()
# Compute per-trade PnL from sequential buy/sell
pnls = []
buy_price = None
buy_shares = None
for _, row in trades_df.iterrows():
if row["action"] == "BUY":
buy_price = row["price"]
buy_shares = row["shares"]
elif row["action"].startswith("SELL") and buy_price is not None:
trade_pnl = (row["price"] - buy_price) * buy_shares
pnls.append(trade_pnl)
if trade_pnl > 0:
wins += 1
else:
losses += 1
buy_price = None
win_rate = wins / max(1, wins + losses) * 100
return {
"label": label,
"final_equity": final_equity,
"total_return_pct": total_return,
"n_trades": n_trades,
"wins": wins,
"losses": losses,
"win_rate_pct": win_rate,
}
# ── Display ─────────────────────────────────────────────────────────
def print_regime_timeline(df: pd.DataFrame, vol_pct: pd.Series, adx: pd.Series) -> None:
"""Print regime classification across the full history.
Args:
df: OHLCV DataFrame.
vol_pct: ATR percentile series.
adx: ADX series.
"""
print("\n" + "=" * 70)
print(" REGIME TIMELINE")
print("=" * 70)
prev_regime = ""
regime_start = 0
for i in range(len(df)):
vp = vol_pct.iloc[i] if not np.isnan(vol_pct.iloc[i]) else 0.5
ax = adx.iloc[i] if not np.isnan(adx.iloc[i]) else 20.0
regime = detect_regime(vp, ax)
if regime != prev_regime:
if prev_regime:
true_regime = df["true_regime"].iloc[regime_start]
print(
f" Bars {regime_start:>4}-{i-1:<4} "
f"Detected: {prev_regime:<18} "
f"Actual: {true_regime:<22} "
f"Close: {df['close'].iloc[i-1]:>8.2f}"
)
prev_regime = regime
regime_start = i
# Final segment
if prev_regime:
true_regime = df["true_regime"].iloc[regime_start]
print(
f" Bars {regime_start:>4}-{len(df)-1:<4} "
f"Detected: {prev_regime:<18} "
f"Actual: {true_regime:<22} "
f"Close: {df['close'].iloc[-1]:>8.2f}"
)
def print_comparison(
static_metrics: dict, adaptive_metrics: dict
) -> None:
"""Print side-by-side comparison of strategies.
Args:
static_metrics: Performance metrics for static strategy.
adaptive_metrics: Performance metrics for adaptive strategy.
"""
print("\n" + "=" * 70)
print(" PERFORMANCE COMPARISON")
print("=" * 70)
print(f"\n {'Metric':<25} {'Static (EMA only)':<20} {'Regime-Adaptive':<20}")
print(f" {'-'*25} {'-'*20} {'-'*20}")
rows = [
("Initial Capital", f"${INITIAL_CAPITAL:,.2f}", f"${INITIAL_CAPITAL:,.2f}"),
("Final Equity",
f"${static_metrics['final_equity']:,.2f}",
f"${adaptive_metrics['final_equity']:,.2f}"),
("Total Return",
f"{static_metrics['total_return_pct']:+.2f}%",
f"{adaptive_metrics['total_return_pct']:+.2f}%"),
("Num Trades",
f"{static_metrics['n_trades']}",
f"{adaptive_metrics['n_trades']}"),
("Win Rate",
f"{static_metrics['win_rate_pct']:.1f}%",
f"{adaptive_metrics['win_rate_pct']:.1f}%"),
("Wins / Losses",
f"{static_metrics['wins']} / {static_metrics['losses']}",
f"{adaptive_metrics['wins']} / {adaptive_metrics['losses']}"),
]
for label, static_val, adaptive_val in rows:
print(f" {label:<25} {static_val:<20} {adaptive_val:<20}")
diff = adaptive_metrics["total_return_pct"] - static_metrics["total_return_pct"]
print(f"\n Regime adaptation edge: {diff:+.2f}% return difference")
if diff > 0:
print(" The adaptive strategy outperformed by avoiding wrong-regime trades.")
else:
print(" The static strategy outperformed — regime detection may have filtered")
print(" profitable signals. Review regime thresholds and strategy pairing.")
print("\n This is a simulation for analytical purposes only.")
print(" Past synthetic performance does not predict real market results.")
# ── Main ────────────────────────────────────────────────────────────
def main() -> None:
"""Run the regime-adaptive backtest comparison."""
print("Generating synthetic data with 6 regime phases (500 bars)...")
df = generate_regime_data(N_BARS)
print("Computing indicators...")
vol_pct = compute_atr_percentile(
df["high"], df["low"], df["close"], ATR_PERIOD, VOL_LOOKBACK
)
adx = compute_adx(df["high"], df["low"], df["close"], ADX_PERIOD)
rsi = compute_rsi(df["close"], RSI_PERIOD)
# Show regime timeline
print_regime_timeline(df, vol_pct, adx)
# Run both strategies
print("\nRunning static EMA crossover strategy...")
static_trades, static_equity = run_static_strategy(df)
print("Running regime-adaptive strategy...")
adaptive_trades, adaptive_equity = run_adaptive_strategy(df, vol_pct, adx, rsi)
# Compute and display metrics
static_metrics = compute_metrics(static_trades, static_equity, "Static")
adaptive_metrics = compute_metrics(adaptive_trades, adaptive_equity, "Adaptive")
print_comparison(static_metrics, adaptive_metrics)
# Show adaptive strategy trade detail
if len(adaptive_trades) > 0 and "regime" in adaptive_trades.columns:
print("\n" + "-" * 70)
print(" ADAPTIVE STRATEGY — TRADE LOG")
print("-" * 70)
for _, row in adaptive_trades.iterrows():
regime = row.get("regime", "?")
strat = row.get("strategy", "?")
size_m = row.get("size_mult", 1.0)
action = row["action"]
print(
f" Bar {int(row['bar']):>4} {action:<12} "
f"@ {row['price']:>8.2f} "
f"Regime: {regime:<18} "
f"Strategy: {strat:<10} "
f"Size: {size_m:.0%}"
)
print()
if __name__ == "__main__":
main()
Related skills
FAQ
What defines the regime model?
Two orthogonal axes, volatility (low/high) and trend (trending/ranging), form a four-quadrant model, refined by mean-reversion tendency via the Hurst exponent.
Does it require machine learning?
No; it includes simple no-ML approaches like ATR percentile, ADX, EMA slope, and Bollinger width alongside statistical methods.