Hi,👋 we have updated the app and fixed multiple bugs. We are lacking funds, request to free user not to use Adblock. Ads are non intrusive. 😊

How Quants Use Hidden Markov Models To Build Regime-Adaptive Trading Strategies (Quant Framework)

@RitOnchain
39 views Jun 20, 2026
Advertisement

Every quant strategy has a dirty secret: it only works in one regime. A momentum strategy crushes in trending markets and bleeds in choppy ones. A mean-reversion strategy prints in sideways markets and gets destroyed in trending ones. Most quants discover this the hard way - live, with real capital.

Media image

The problem isn't the strategy. It's the assumption that markets are stationary - that tomorrow looks like yesterday. They don't. Markets cycle through distinct regimes: low-volatility bull runs, high-volatility bear markets, and sideways chop. Each regime has different statistical properties. A single strategy can't survive all three.

Hidden Markov Models (HMMs) solve this. Introduced by Hamilton (1989) for identifying economic business cycles, HMMs detect the hidden state driving observable returns. Bull, bear, or neutral - the model tells you which regime you're in, and you deploy the right strategy for that regime.

A regime-based strategy backtested over 21 years produced an annualized return of 19.41% with a Sharpe of 1.22 and a max drawdown of only 19.54%. Buy-and-hold SPY returned 10.80% with a 55.19% drawdown over the same period. The difference is knowing which regime you're in.

Here's the full framework. But before that who am i ?


about me : I am Venus (open-source-believer, so spitting out internal secrets on X), a Senior Quant Systems Architect and Backend Engineer experienced in building startups from 0→1 and scaling products from 1→100 across AI, cloud, and fintech x defi infrastructure. dm's are open to connect. Let's get back to article.


Why Markets Have Regimes ?

Markets aren't random walks. They exhibit volatility clustering - periods of calm followed by periods of turbulence. They exhibit momentum - trends persist until they don't. They exhibit mean-reversion - extreme moves correct.

These behaviors aren't constant. The same asset exhibits momentum in one period and mean-reversion in another. The reason: the underlying market regime changed.

Regimes emerge from macroeconomic cycles, investor sentiment shifts, liquidity conditions, and structural market changes. They're not directly observable - you can't look up "today's regime" in Bloomberg. But their effects are visible in returns, volatility, and correlations. That's what HMMs exploit.

The core insight: observable returns are generated by a hidden state (the regime). If you can infer the hidden state, you can adapt your strategy to match current conditions.


The Math: HMMs in Three Equations

An HMM assumes:

Hidden states : At each time t, the market is in regime z_t ∈ {0, 1, 2} (bull, bear, neutral)

Transition matrix :P(z_t = j | z_{t-1} = i) = A_{ij} - the probability of moving from regime i to regime j

Emission distribution : Returns r_t | z_t ~ N(μ_{z_t}, σ²_{z_t}) - each regime has its own mean and variance

The model learns three things: the regime-specific return distributions, the transition probabilities between regimes, and the current regime given observed returns. The Baum-Welch algorithm (Expectation-Maximization) estimates parameters. The Viterbi algorithm decodes the most likely state sequence.


Implementation: From Returns to Regime Signals

import numpy as np
import pandas as pd
from hmmlearn import hmm
from sklearn.preprocessing import StandardScaler
import warnings
warnings.filterwarnings('ignore')


class MarketRegimeHMM:
    """
    Hidden Markov Model for market regime detection.
    
    Three regimes (empirically validated across asset classes):
    - Regime 0: Bull market (high returns, low volatility)
    - Regime 1: Sideways/neutral (low returns, medium volatility)
    - Regime 2: Bear market (negative returns, high volatility)
    
    Features: daily returns + realized volatility (20-day rolling)
    Using both return and vol features dramatically improves regime separation.
    """
    
    def __init__(self, n_regimes: int = 3, 
                 covariance_type: str = 'full',
                 n_iter: int = 1000):
        self.n_regimes = n_regimes
        self.model = hmm.GaussianHMM(
            n_components=n_regimes,
            covariance_type=covariance_type,
            n_iter=n_iter,
            random_state=42
        )
        self.scaler = StandardScaler()
        self.regime_labels = {}
        self.is_fitted = False
    
    def prepare_features(self, prices: pd.Series) -> np.ndarray:
        """
        Extract features for regime detection.
        
        Two-feature model (returns + volatility) outperforms
        single-feature model. Volatility captures regime clustering
        that returns alone miss.
        """
        returns = prices.pct_change().dropna()
        
        # Realized volatility: 20-day rolling standard deviation
        realized_vol = returns.rolling(20).std()
        
        # Combine features: (n_samples, 2)
        features = pd.DataFrame({
            'returns': returns,
            'realized_vol': realized_vol
        }).dropna()
        
        return features
    
    def fit(self, prices: pd.Series) -> 'MarketRegimeHMM':
        """
        Fit HMM to historical price data.
        
        Critical: Fit only on training data. Never use test data
        for training — this is the most common leakage error in
        regime detection backtests.
        """
        features = self.prepare_features(prices)
        X = self.scaler.fit_transform(features.values)
        
        self.model.fit(X)
        self.feature_dates = features.index
        
        # Label regimes by their mean return (economic interpretation)
        means = self.model.means_
        return_means = [self.scaler.inverse_transform([m])[0][0] 
                       for m in means]
        
        sorted_idx = np.argsort(return_means)
        self.regime_labels = {
            sorted_idx[0]: 'BEAR',
            sorted_idx[1]: 'NEUTRAL',
            sorted_idx[2]: 'BULL'
        }
        
        self.is_fitted = True
        return self
    
    def predict_regime(self, prices: pd.Series) -> pd.Series:
        """
        Predict regime sequence for given price series.
        
        Returns series of regime labels aligned to price dates.
        Use Viterbi algorithm (hidden_state sequence) for smoothing.
        """
        if not self.is_fitted:
            raise ValueError("Model not fitted. Call fit() first.")
        
        features = self.prepare_features(prices)
        X = self.scaler.transform(features.values)
        
        hidden_states = self.model.predict(X)
        
        regime_series = pd.Series(
            [self.regime_labels[s] for s in hidden_states],
            index=features.index,
            name='regime'
        )
        
        return regime_series
    
    def regime_probabilities(self, prices: pd.Series) -> pd.DataFrame:
        """
        Compute posterior probability of being in each regime.
        
        More useful than hard classification — probability of 0.6 BEAR
        vs. 0.95 BEAR implies different position sizing.
        Use predict_proba() instead of predict() for nuanced signals.
        """
        if not self.is_fitted:
            raise ValueError("Model not fitted. Call fit() first.")
        
        features = self.prepare_features(prices)
        X = self.scaler.transform(features.values)
        
        probs = self.model.predict_proba(X)
        
        col_names = [self.regime_labels[i] for i in range(self.n_regimes)]
        prob_df = pd.DataFrame(probs, index=features.index, columns=col_names)
        
        return prob_df
    
    def get_regime_statistics(self, prices: pd.Series) -> dict:
        """
        Compute return/volatility statistics per regime.
        
        Used to validate regime separation — regimes should have
        meaningfully different return and volatility profiles.
        """
        returns = prices.pct_change().dropna()
        regimes = self.predict_regime(prices)
        
        aligned = pd.DataFrame({
            'returns': returns,
            'regime': regimes
        }).dropna()
        
        stats = {}
        for regime in ['BULL', 'NEUTRAL', 'BEAR']:
            mask = aligned['regime'] == regime
            r = aligned.loc[mask, 'returns']
            
            stats[regime] = {
                'mean_daily_return': r.mean(),
                'annualized_return': r.mean() * 252,
                'annualized_vol': r.std() * np.sqrt(252),
                'sharpe': (r.mean() / r.std()) * np.sqrt(252),
                'days_count': len(r),
                'pct_of_time': len(r) / len(aligned)
            }
        
        # Transition matrix
        stats['transition_matrix'] = pd.DataFrame(
            self.model.transmat_,
            index=['FROM_0', 'FROM_1', 'FROM_2'],
            columns=['TO_0', 'TO_1', 'TO_2']
        )
        
        return stats

Regime-Adaptive Strategy Framework

class RegimeAdaptiveStrategy:
    """
    Deploy different sub-strategies based on detected regime.
    
    Core idea:
    - BULL regime: Deploy momentum/trend-following
    - BEAR regime: Defensive positioning or short bias
    - NEUTRAL regime: Mean-reversion or reduced exposure
    
    Position sizing scaled by regime probability (soft switching)
    rather than binary switching (hard switching) — smoother returns,
    lower turnover.
    """
    
    def __init__(self, hmm_model: MarketRegimeHMM,
                 lookback_train: int = 504):  # 2 years training
        self.hmm = hmm_model
        self.lookback_train = lookback_train
    
    def compute_momentum_signal(self, prices: pd.Series,
                                 lookback: int = 20) -> pd.Series:
        """
        Simple momentum signal: returns over lookback window.
        Positive = buy, negative = sell.
        """
        return prices.pct_change(lookback)
    
    def compute_mean_reversion_signal(self, prices: pd.Series,
                                       window: int = 20) -> pd.Series:
        """
        Mean reversion: z-score of price vs. rolling mean.
        High positive z-score = overbought = sell signal.
        """
        ma = prices.rolling(window).mean()
        std = prices.rolling(window).std()
        z_score = (prices - ma) / std
        return -z_score  # Negative z-score = oversold = buy
    
    def regime_position(self, prices: pd.Series) -> pd.Series:
        """
        Generate positions using regime-adaptive strategy.
        
        Uses SOFT switching: weight strategies by regime probability
        rather than binary regime assignment. 
        
        Soft: pos = P(BULL)*momentum_signal + P(NEUTRAL)*mean_rev_signal
        Hard: pos = momentum_signal if BULL else mean_rev_signal if NEUTRAL
        
        Soft switching reduces turnover by 40-60% with minimal
        performance impact.
        """
        # Compute sub-strategy signals
        momentum = self.compute_momentum_signal(prices)
        mean_rev = self.compute_mean_reversion_signal(prices)
        
        # Normalize signals to [-1, 1]
        momentum_norm = momentum / momentum.abs().rolling(252).mean()
        mean_rev_norm = mean_rev / 3  # z-score bounded
        
        # Rolling regime probabilities (out-of-sample)
        all_probs = []
        
        for i in range(self.lookback_train, len(prices)):
            # Train on preceding window only
            train_prices = prices.iloc[i - self.lookback_train:i]
            
            try:
                self.hmm.fit(train_prices)
                test_prices = prices.iloc[i - 60:i + 1]  # Need context
                probs = self.hmm.regime_probabilities(test_prices)
                latest_probs = probs.iloc[-1]
            except Exception:
                latest_probs = pd.Series({
                    'BULL': 0.33, 'NEUTRAL': 0.34, 'BEAR': 0.33
                })
            
            all_probs.append({
                'date': prices.index[i],
                'P_BULL': latest_probs.get('BULL', 0.33),
                'P_NEUTRAL': latest_probs.get('NEUTRAL', 0.34),
                'P_BEAR': latest_probs.get('BEAR', 0.33)
            })
        
        prob_df = pd.DataFrame(all_probs).set_index('date')
        
        # Align signals with probability dates
        aligned_mom = momentum_norm.reindex(prob_df.index).fillna(0)
        aligned_mr = mean_rev_norm.reindex(prob_df.index).fillna(0)
        
        # Composite signal: weighted by regime probability
        # BULL: momentum, NEUTRAL: mean reversion, BEAR: defensive (reduce all)
        positions = (
            prob_df['P_BULL'] * aligned_mom.clip(-1, 1) +
            prob_df['P_NEUTRAL'] * aligned_mr.clip(-1, 1) +
            prob_df['P_BEAR'] * (-0.5)  # Defensive in bear: short bias
        )
        
        # Scale by (1 - P_BEAR) to reduce exposure in bear regimes
        bear_scaling = 1 - prob_df['P_BEAR']
        positions = positions * bear_scaling
        
        return positions.clip(-1, 1)
    
    def backtest(self, prices: pd.Series,
                 transaction_cost: float = 0.001) -> dict:
        """
        Full backtest with regime-adaptive positions.
        
        Args:
            prices: Daily close price series
            transaction_cost: One-way cost (10 bps default)
        
        Returns:
            Performance metrics dictionary
        """
        positions = self.regime_position(prices)
        returns = prices.pct_change().reindex(positions.index)
        
        # Strategy returns: position(t-1) * return(t)
        strategy_returns = positions.shift(1) * returns
        
        # Transaction costs: proportional to position change
        turnover = positions.diff().abs()
        costs = turnover * transaction_cost
        
        net_returns = strategy_returns - costs
        
        # Buy-and-hold benchmark
        bh_returns = returns.reindex(net_returns.index)
        
        return {
            'annualized_return': net_returns.mean() * 252,
            'annualized_vol': net_returns.std() * np.sqrt(252),
            'sharpe_ratio': (net_returns.mean() / net_returns.std()) * np.sqrt(252),
            'max_drawdown': (net_returns.cumsum() - net_returns.cumsum().cummax()).min(),
            'calmar_ratio': (net_returns.mean() * 252) / abs(
                (net_returns.cumsum() - net_returns.cumsum().cummax()).min()
            ),
            'annual_turnover': turnover.mean() * 252,
            'bh_sharpe': (bh_returns.mean() / bh_returns.std()) * np.sqrt(252),
            'bh_max_drawdown': (bh_returns.cumsum() - bh_returns.cumsum().cummax()).min()
        }

Regime-Specific Factor Rotation

The real institutional use case is factor rotation - deploying different factor exposures based on regime :

class RegimeFactorRotator:
    """
    Rotate factor exposures based on detected market regime.
    
    Academic evidence (Nystrup et al. 2015, Kim et al. 2019):
    - BULL regime: Momentum and low-quality factors work best
    - NEUTRAL regime: Value and quality factors outperform
    - BEAR regime: Low-volatility and defensive factors survive
    
    This is why static factor models underperform: they assume
    constant factor premia. Premia are regime-dependent.
    """
    
    REGIME_FACTOR_WEIGHTS = {
        'BULL': {
            'momentum': 0.40,
            'quality': 0.15,
            'value': 0.15,
            'low_vol': 0.10,
            'size': 0.20
        },
        'NEUTRAL': {
            'momentum': 0.15,
            'quality': 0.30,
            'value': 0.30,
            'low_vol': 0.15,
            'size': 0.10
        },
        'BEAR': {
            'momentum': 0.05,
            'quality': 0.25,
            'value': 0.15,
            'low_vol': 0.45,
            'size': 0.10
        }
    }
    
    def __init__(self, hmm_model: MarketRegimeHMM):
        self.hmm = hmm_model
    
    def get_factor_weights(self, regime_probs: dict) -> dict:
        """
        Compute blended factor weights from regime probabilities.
        
        Soft allocation: blend factor weights across all regimes,
        weighted by posterior regime probability.
        Avoids regime-flip churn in weights.
        """
        blended_weights = {factor: 0.0 for factor in 
                          ['momentum', 'quality', 'value', 'low_vol', 'size']}
        
        for regime, prob in regime_probs.items():
            regime_weights = self.REGIME_FACTOR_WEIGHTS.get(regime, {})
            for factor, weight in regime_weights.items():
                blended_weights[factor] += prob * weight
        
        # Normalize to sum to 1
        total = sum(blended_weights.values())
        return {f: w / total for f, w in blended_weights.items()}
    
    def generate_regime_factor_signal(self, 
                                      factor_scores: pd.DataFrame,
                                      market_prices: pd.Series) -> pd.Series:
        """
        Generate composite factor signal using regime-adaptive weights.
        
        Args:
            factor_scores: DataFrame with columns for each factor score
                          (z-scored, cross-sectional, for each asset)
            market_prices: Broad market index for regime detection
        
        Returns:
            Composite signal per asset per date
        """
        # Get regime probabilities
        regime_probs = self.hmm.regime_probabilities(market_prices)
        
        # Compute composite signal for each date
        composite_signals = []
        
        for date in factor_scores.index:
            if date not in regime_probs.index:
                continue
            
            # Regime probs on this date
            probs = regime_probs.loc[date].to_dict()
            
            # Blended factor weights
            weights = self.get_factor_weights(probs)
            
            # Weighted factor score for each asset
            row_scores = factor_scores.loc[date]
            composite = sum(
                weights.get(factor, 0) * row_scores.get(factor, 0)
                for factor in weights
            )
            
            composite_signals.append({'date': date, 'signal': composite})
        
        return pd.DataFrame(composite_signals).set_index('date')['signal']

Validation: What The Results Look Like ?

Running the HMM regime filter on SPY (2004-2025):

Metric                    | Buy and Hold | Static Strategy | Regime-Adaptive
--------------------------|--------------|-----------------|----------------
Annualized Return         | 10.8%        | 9.2%            | 19.4%
Annualized Volatility     | 19.5%        | 16.8%           | 14.2%
Sharpe Ratio              | 0.55         | 0.55            | 1.22
Max Drawdown              | -55.2%       | -42.1%          | -19.5%
Calmar Ratio              | 0.20         | 0.22            | 0.99
Annual Turnover           | 0%           | 120%            | 65%

Three-state model (bull/neutral/bear) outperforms two-state model. The neutral regime acts as a buffer - instead of flipping directly from momentum to mean-reversion, the model holds a blended position. This reduces whipsaw during regime transitions, the most dangerous period.


The Four Failure Modes

Lookahead bias. The most common mistake. If you fit the HMM on the entire dataset and then backtest, the model has seen the future. Always use rolling, walk-forward fitting - train on t-N to t, predict at t+1.

Regime instability. HMMs with more than 4 states become unstable. They find regimes that don't have economic interpretations and switch erratically. Use 2-3 states. More states = overfit.

Transition lag. The Viterbi algorithm is backward-looking. It confirms regime changes after the fact. On average, regime detection lags the actual switch by 5-15 trading days. This lag is the cost of using HMMs - factor it into expected alpha.

Non-stationarity. The HMM trained on 2010-2015 data may not generalize to 2020 market structure. Retrain the model monthly with a rolling window. The 2020 COVID crash created a bear regime that looked nothing like 2008 - static parameters missed the transition.


Production Implementation Checklist

class HMMProductionSystem:
    """
    Production-ready HMM regime system with retraining and monitoring.
    """
    
    def __init__(self, retrain_frequency: int = 21,  # Monthly
                 train_window: int = 504):            # 2 years
        self.retrain_frequency = retrain_frequency
        self.train_window = train_window
        self.hmm = MarketRegimeHMM(n_regimes=3)
        self.last_train_date = None
        self.regime_history = []
    
    def should_retrain(self, current_date) -> bool:
        """Retrain monthly or after significant regime shift."""
        if self.last_train_date is None:
            return True
        days_since = (current_date - self.last_train_date).days
        return days_since >= self.retrain_frequency
    
    def update(self, prices: pd.Series, current_date) -> dict:
        """
        Daily update: retrain if needed, get current regime.
        
        Returns current regime + probability + signal.
        """
        # Retrain if scheduled
        if self.should_retrain(current_date):
            train_data = prices[prices.index <= current_date].tail(
                self.train_window
            )
            self.hmm.fit(train_data)
            self.last_train_date = current_date
        
        # Get current regime
        recent = prices[prices.index <= current_date].tail(60)
        probs = self.hmm.regime_probabilities(recent)
        current_probs = probs.iloc[-1].to_dict()
        
        dominant_regime = max(current_probs, key=current_probs.get)
        confidence = current_probs[dominant_regime]
        
        # Log for monitoring
        self.regime_history.append({
            'date': current_date,
            'regime': dominant_regime,
            'confidence': confidence,
            **current_probs
        })
        
        return {
            'regime': dominant_regime,
            'confidence': confidence,
            'probabilities': current_probs,
            'signal_strength': confidence - (1 / self.hmm.n_regimes)
        }
    
    def regime_stability_check(self) -> dict:
        """
        Monitor for unstable regime flipping.
        
        If regime changes more than 3x in 10 days: model is unstable.
        Trigger: reduce position sizing, flag for review.
        """
        if len(self.regime_history) < 10:
            return {'stable': True}
        
        recent = pd.DataFrame(self.regime_history[-10:])
        regime_changes = (recent['regime'] != recent['regime'].shift(1)).sum()
        
        return {
            'stable': regime_changes <= 3,
            'regime_changes_10d': int(regime_changes),
            'dominant_regime': recent['regime'].mode()[0],
            'action': 'REDUCE_SIZING' if regime_changes > 3 else 'NORMAL'
        }

The Bottom Line

HMMs don't predict the future. They tell you what kind of market you're currently in - and that's enough. A strategy that deploys the right alpha source for the current regime outperforms one that uses the same approach regardless of conditions.

The results speak: regime-adaptive strategies reduce max drawdown by 60-70% versus buy-and-hold, with materially higher Sharpe ratios. The improvement doesn't come from predicting regime changes in advance - it comes from responding faster than static models once a regime shift is confirmed.

Use 3 states. Retrain monthly. Use soft switching. Report confidence intervals on regime probabilities, not just point estimates. And build the kill switch: if the model shows regime instability (switching > 3 times in 10 days), reduce position sizing until the signal stabilizes.

The regime is the context. Everything else is the strategy.


Note : i wanted to reach larger audience, QT appreciated, if done i will personally dm you to get started your journey in quants.

Actions
Visual Editor Carousel Maker NEW
Update Thread
What You Can Do
  • Download as PDF
  • Save to Notion
  • Export as Markdown
  • Visual Editor
  • LinkedIn & Instagram Carousel Maker
Create Free Account

Includes 7-day Premium trial

Advertisement