Financial markets alternate between different statistical environments, often referred to as regimes. A common pattern is a shift between low-volatility periods (stable or bullish conditions) and high-volatility periods (turbulent or bearish conditions). Identifying these regimes allows traders, risk managers, and portfolio allocators to adapt strategies to changing risk profiles.
A statistical framework well suited for this purpose is the Markov Switching Model (MSM).
Let \(y_t\) denote the return at time \(t\), and \(S_t\) be an unobserved state variable taking one of \(k\) discrete values. In a two-state specification (\(k=2\)):
\[ S_t \in \{0, 1\} \]
\[ y_t = \mu_{S_t} + \varepsilon_t, \quad \varepsilon_t \sim N(0, \sigma_{S_t}^2) \]
where:
The state process \(\{S_t\}\) follows a first-order Markov chain:
\[ P_{ij} = \Pr(S_t = j \mid S_{t-1} = i), \quad i,j \in \{0, 1\} \]
The transition matrix is:
\[ P = \begin{bmatrix} P_{00} & 1 - P_{00} \\ 1 - P_{11} & P_{11} \end{bmatrix} \]
Estimation provides:
The script below downloads daily prices from Yahoo Finance, fits a two-state MSM on log returns, labels high- and low-volatility regimes, and produces three key outputs: returns with high-volatility shading, smoothed probability of high-volatility, and an equity curve comparison between buy-and-hold and a probability-filtered strategy.
import warnings
"ignore")
warnings.filterwarnings(
import os
import numpy as np
import pandas as pd
import yfinance as yf
import matplotlib.pyplot as plt
from statsmodels.tsa.regime_switching.markov_regression import MarkovRegression
# Configuration
= "SPY" # Change to QQQ, AAPL, BTC-USD, etc.
TICKER = "2005-01-01"
START = 0.60
PROB_THRESHOLD = "msm_output"
OUT_DIR =True)
os.makedirs(OUT_DIR, exist_ok
# Data loader
def load_data(ticker, start):
= yf.download(ticker, start=start, progress=False, auto_adjust=True)
df = df["Close"]
px = np.log(px).diff().dropna()
rets = "ret"
rets.name return px, rets
# Model fitting
def fit_msm(returns):
= MarkovRegression(
model =returns,
endog=2,
k_regimes="c",
trend=True
switching_variance
)= model.fit(em_iter=10, search_reps=20, disp=False)
res return res
# Regime labeling
def label_regimes(returns, smoothed_probs):
= smoothed_probs.idxmax(axis=1)
hard_state = returns.groupby(hard_state).agg(["mean", "std"])
stats = stats["std"].idxmax()
high_vol = 1 - high_vol
low_vol return hard_state, stats, low_vol, high_vol
# Strategy
def regime_strategy(returns, smoothed_probs, low_vol_regime, threshold):
= smoothed_probs[low_vol_regime]
p_low = (p_low >= threshold).astype(int)
signal = returns * signal
strat_rets return strat_rets
# Plot returns with regimes
def plot_returns_with_regimes(returns, hard_state, high_vol):
= plt.subplots(figsize=(12,5))
fig, ax =0.7)
ax.plot(returns.index, returns.values, lwf"{TICKER} Returns with High-Volatility Regimes Shaded")
ax.set_title(True, alpha=0.3)
ax.grid(= (hard_state == high_vol)
in_high = [], None
blocks, start for t, flag in in_high.items():
if flag and start is None:
= t
start elif not flag and start is not None:
blocks.append((start, t))= None
start if start is not None:
-1]))
blocks.append((start, in_high.index[for s, e in blocks:
=0.15)
ax.axvspan(s, e, alpha"returns_regimes.png"), dpi=150)
fig.savefig(os.path.join(OUT_DIR,
# Plot smoothed probabilities
def plot_probs(smoothed_probs, high_vol):
= plt.subplots(figsize=(12,4))
fig, ax =1.0)
ax.plot(smoothed_probs.index, smoothed_probs[high_vol], lw"Probability of High-Volatility Regime")
ax.set_title(-0.02, 1.02)
ax.set_ylim(True, alpha=0.3)
ax.grid("prob_highvol.png"), dpi=150)
fig.savefig(os.path.join(OUT_DIR,
# Plot equity curves
def plot_equity_curves(returns, strat_returns):
= (1 + returns).cumprod()
bh = (1 + strat_returns).cumprod()
st = plt.subplots(figsize=(12,5))
fig, ax ="Buy & Hold")
ax.plot(bh.index, bh.values, label="Regime-Filtered")
ax.plot(st.index, st.values, label"Equity Curves")
ax.set_title(True, alpha=0.3)
ax.grid(
ax.legend()"equity_curves.png"), dpi=150)
fig.savefig(os.path.join(OUT_DIR,
# Main execution
def main():
= load_data(TICKER, START)
px, rets = fit_msm(rets)
res print(res.summary())
= res.smoothed_marginal_probabilities
smoothed_probs = [int(c) for c in smoothed_probs.columns]
smoothed_probs.columns
= label_regimes(rets, smoothed_probs)
hard_state, stats, low_vol, high_vol print("\nRegime statistics (mean, std):\n", stats)
plot_returns_with_regimes(rets, hard_state, high_vol)
plot_probs(smoothed_probs, high_vol)
= regime_strategy(rets, smoothed_probs, low_vol, PROB_THRESHOLD)
strat_rets
plot_equity_curves(rets, strat_rets)
= pd.DataFrame({
summary "Total Return": [(1 + rets).prod() - 1, (1 + strat_rets).prod() - 1],
"Annualized Volatility": [rets.std() * np.sqrt(252), strat_rets.std() * np.sqrt(252)]
=["Buy & Hold", "Regime-Filtered"])
}, indexprint("\nPerformance Summary:\n", summary)
if __name__ == "__main__":
main()
Markov Switching Model Results
==============================================================================
Dep. Variable: ret No. Observations: 5182
Model: MarkovRegression Log Likelihood 16777.492
Date: Sat, 09 Aug 2025 AIC -33542.985
Time: 02:34:34 BIC -33503.667
Sample: 0 HQIC -33529.229
- 5182
Covariance Type: approx
Regime 0 parameters
==============================================================================
coef std err z P>|z| [0.025 0.975]
------------------------------------------------------------------------------
const -0.0013 0.001 -2.238 0.025 -0.002 -0.000
sigma2 0.0004 2.19e-05 19.488 0.000 0.000 0.000
Regime 1 parameters
==============================================================================
coef std err z P>|z| [0.025 0.975]
------------------------------------------------------------------------------
const 0.0010 0.000 8.573 0.000 0.001 0.001
sigma2 4.489e-05 1.71e-06 26.189 0.000 4.15e-05 4.82e-05
Regime transition parameters
==============================================================================
coef std err z P>|z| [0.025 0.975]
------------------------------------------------------------------------------
p[0->0] 0.9558 0.008 117.083 0.000 0.940 0.972
p[1->0] 0.0156 0.003 5.283 0.000 0.010 0.021
==============================================================================
Regime stats (realized on hard assignments):
mean std count
state
0 -0.001445 0.021012 1313
1 0.001020 0.006665 3869
Low-vol regime: 1 | High-vol regime: 0
Performance summary (no costs, illustrative):
Total Return Ann. Vol Min Drawdown
Buy & Hold 4.3090 0.1919 -0.5958
Regime-Filtered 36.6696 0.0897 -0.1015
Returns with high-volatility shading The shaded periods correspond to the state with the highest estimated variance, highlighting market stress phases.
Smoothed probability plot This shows the model’s inferred probability of being in the high-volatility regime. Values close to 1 indicate strong evidence of elevated volatility.
Equity curve comparison The regime-filtered strategy invests only when the probability of the low-volatility regime exceeds the threshold. This approach can help manage drawdowns but may reduce returns if regime shifts are misclassified.
The two-state MSM is a practical tool for uncovering hidden market conditions from historical returns. It provides both classification of past regimes and estimates of regime persistence through transition probabilities. These probabilities can serve as inputs to risk controls or dynamic allocation rules. Proper validation, including out-of-sample testing and accounting for transaction costs, is essential before operational use.