Files
2026-07-13 13:26:28 +08:00

936 lines
31 KiB
Python

# ---
# jupyter:
# jupytext:
# cell_metadata_filter: tags,-all
# text_representation:
# extension: .py
# format_name: percent
# format_version: '1.3'
# jupytext_version: 1.19.3
# kernelspec:
# display_name: Python 3 (ipykernel)
# language: python
# name: python3
# ---
# %% [markdown]
# # Regime Detection with Hidden Markov Models
#
# **Docker image**: `ml4t`
#
# This notebook provides a thorough introduction to HMMs for financial regime
# detection, from first principles through production considerations.
#
# **Learning Objectives**:
# - Understand observable threshold baselines (VIX, moving average cross)
# - Implement the forward algorithm on a toy example
# - Compare filtered vs smoothed probabilities and identify look-ahead bias
# - Address EM estimation pitfalls: local optima, initialization, model selection
# - Prevent label switching across estimation windows
# - Fit Markov-Switching AR and compare with HMM
#
# **Book Reference**: Chapter 9, Section 9.5 (Regime Features)
#
# **Prerequisites**: `01_visual_diagnostics` for stationarity concepts;
# basic probability and matrix operations.
# %%
"""Regime Detection with Hidden Markov Models — thorough tutorial."""
import warnings
warnings.filterwarnings("ignore")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import polars as pl
from hmmlearn.hmm import GaussianHMM
from ml4t.engineer.features.regime import (
choppiness_index,
fractal_efficiency,
hurst_exponent,
market_regime_classifier,
trend_intensity_index,
)
from scipy import stats
from sklearn.cluster import KMeans
from statsmodels.tsa.regime_switching.markov_autoregression import MarkovAutoregression
from data import load_etfs, load_macro
from utils.paths import get_case_study_dir
from utils.reproducibility import set_global_seeds
# %% tags=["parameters"]
# Production defaults — Papermill injects overrides for CI
N_INITS = 10
N_ITER = 200
SEED = 42
# %%
set_global_seeds(SEED)
# %% [markdown]
# ## Load Data
# %%
CASE_DIR = get_case_study_dir("etfs")
etfs = load_etfs(symbols=["SPY"])
spy = (
etfs.select(["timestamp", "open", "high", "low", "close", "volume"])
.sort("timestamp")
.with_columns(
returns=pl.col("close").log().diff() * 100,
volatility=pl.col("close").log().diff().rolling_std(window_size=21) * 100 * np.sqrt(252),
)
.drop_nulls()
)
spy_pd = spy.to_pandas().set_index("timestamp")
spy_pd.index = pd.DatetimeIndex(spy_pd.index)
print(
f"SPY: {len(spy_pd):,} observations ({spy_pd.index.min().date()} to {spy_pd.index.max().date()})"
)
# %% [markdown]
# # Part 1 — Observable Threshold Baselines
#
# Before fitting latent-variable models, establish simple baselines using
# observable quantities. These are widely used in practice because they are
# transparent, deterministic, and free from estimation risk.
# %% [markdown]
# ### Baseline 1: VIX Threshold
#
# VIX > 20 is the classic "fear threshold." It requires no estimation and is
# available in real time from CBOE.
# %%
macro = load_macro()
vix = macro.select(["timestamp", "vixcls"]).drop_nulls().rename({"vixcls": "vix"}).sort("timestamp")
# Merge VIX into SPY data
vix_pd = vix.to_pandas().set_index("timestamp")
vix_pd.index = pd.DatetimeIndex(vix_pd.index)
spy_pd = spy_pd.join(vix_pd, how="left").ffill()
spy_pd["vix_regime"] = np.where(spy_pd["vix"] > 20, 1, 0)
pct_high = spy_pd["vix_regime"].mean()
print(f"VIX > 20: {pct_high:.1%} of trading days classified as high-vol")
# %% [markdown]
# ### Baseline 2: 200-Day Moving Average Cross
#
# Price above 200-day MA = uptrend; below = downtrend. A trend/momentum
# regime indicator used by CTAs and systematic macro funds.
# %%
spy_pd["ma_200"] = spy_pd["close"].rolling(200).mean()
spy_pd["trend_regime"] = np.where(spy_pd["close"] > spy_pd["ma_200"], 0, 1)
pct_downtrend = spy_pd["trend_regime"].dropna().mean()
print(f"Below 200-day MA: {pct_downtrend:.1%} of trading days classified as downtrend")
# %%
fig, axes = plt.subplots(3, 1, figsize=(14, 10), sharex=True)
ax = axes[0]
ax.plot(spy_pd.index, spy_pd["close"], linewidth=0.8)
ax.plot(spy_pd.index, spy_pd["ma_200"], linewidth=1, color="orange", label="200-day MA")
ax.fill_between(
spy_pd.index,
spy_pd["close"].min(),
spy_pd["close"].max(),
where=spy_pd["trend_regime"] == 1,
alpha=0.1,
color="red",
label="Downtrend",
)
ax.set_title("SPY with 200-Day MA Regime")
ax.set_ylabel("Price")
ax.legend(loc="upper left")
ax = axes[1]
ax.fill_between(spy_pd.index, 0, spy_pd["vix"], alpha=0.3, color="purple")
ax.axhline(20, color="red", linestyle="--", linewidth=0.5, label="VIX = 20")
ax.set_title("VIX with Threshold Regime")
ax.set_ylabel("VIX")
ax.legend()
ax = axes[2]
combined = spy_pd["vix_regime"] + spy_pd["trend_regime"]
ax.fill_between(spy_pd.index, 0, combined.fillna(0), alpha=0.5, color="red")
ax.set_title("Combined Stress (VIX>20 + Below 200-MA)")
ax.set_ylabel("Stress Count (0-2)")
ax.set_yticks([0, 1, 2])
plt.tight_layout()
plt.show()
# %% [markdown]
# **Takeaway**: Observable baselines are transparent and require zero estimation.
# They serve as the benchmark that any statistical model should beat.
# %% [markdown]
# # Part 2 — HMM Tutorial: Forward Algorithm
#
# Before fitting HMMs to financial data, we work through the mechanics on a
# small toy example. This builds intuition for what the model is actually
# computing.
# %% [markdown]
# ### HMM Setup
#
# An HMM has:
# - $K$ hidden states (e.g., bull/bear)
# - Transition matrix $A$ where $A_{ij} = P(\text{state}_t = j \mid \text{state}_{t-1} = i)$
# - Emission parameters: $P(\text{observation} \mid \text{state})$
# - Initial state distribution $\pi$
#
# The **forward algorithm** computes $P(\text{state}_t \mid \text{observations}_{1:t})$
# — the filtered probability given *only past and current* observations.
# %%
# Toy HMM: 2 states, 10 observations
# State 0: "Calm" (low vol, positive mean)
# State 1: "Stressed" (high vol, negative mean)
K = 2
T_toy = 10
# Transition matrix: states are sticky
A = np.array(
[
[0.95, 0.05], # Calm → Calm: 95%, Calm → Stressed: 5%
[0.10, 0.90],
]
) # Stressed → Calm: 10%, Stressed → Stressed: 90%
# Emission parameters (Gaussian)
means = np.array([0.05, -0.10]) # Calm: +5 bps, Stressed: -10 bps
stds = np.array([0.01, 0.03]) # Calm: low vol, Stressed: high vol
# Initial distribution
pi = np.array([0.8, 0.2])
# Generate observations from the true HMM (seeded globally in the preamble)
true_states = np.zeros(T_toy, dtype=int)
observations = np.zeros(T_toy)
true_states[0] = np.random.choice(K, p=pi)
observations[0] = np.random.normal(means[true_states[0]], stds[true_states[0]])
for t in range(1, T_toy):
true_states[t] = np.random.choice(K, p=A[true_states[t - 1]])
observations[t] = np.random.normal(means[true_states[t]], stds[true_states[t]])
print("True states: ", true_states)
print("Observations:", np.round(observations, 4))
# %% [markdown]
# ### Forward Algorithm Implementation
#
# The forward variable $\alpha_t(k) = P(\text{obs}_{1:t}, \text{state}_t = k)$
# is computed recursively:
#
# $$\alpha_1(k) = \pi_k \cdot b_k(o_1)$$
# $$\alpha_t(k) = b_k(o_t) \sum_{j=1}^{K} \alpha_{t-1}(j) \cdot A_{jk}$$
#
# where $b_k(o_t) = P(o_t \mid \text{state} = k)$ is the emission probability.
# %%
def forward_algorithm(
obs: np.ndarray, A: np.ndarray, means: np.ndarray, stds: np.ndarray, pi: np.ndarray
) -> np.ndarray:
"""Compute forward probabilities (filtered) for a Gaussian HMM.
Returns alpha[t, k] = P(obs_{1:t}, state_t = k).
"""
T = len(obs)
K = len(pi)
alpha = np.zeros((T, K))
# Initialization
for k in range(K):
alpha[0, k] = pi[k] * stats.norm.pdf(obs[0], means[k], stds[k])
# Recursion
for t in range(1, T):
for k in range(K):
emission = stats.norm.pdf(obs[t], means[k], stds[k])
alpha[t, k] = emission * np.sum(alpha[t - 1, :] * A[:, k])
return alpha
alpha = forward_algorithm(observations, A, means, stds, pi)
# Normalize to get filtered probabilities P(state_t | obs_{1:t})
filtered_probs = alpha / alpha.sum(axis=1, keepdims=True)
print("=== Forward Algorithm Results ===")
print(f"{'t':>3} {'Obs':>8} {'True':>5} {'P(Calm)':>10} {'P(Stress)':>10} {'Pred':>5}")
print("-" * 45)
for t in range(T_toy):
pred = np.argmax(filtered_probs[t])
print(
f"{t:>3} {observations[t]:>8.4f} {true_states[t]:>5} "
f"{filtered_probs[t, 0]:>10.4f} {filtered_probs[t, 1]:>10.4f} {pred:>5}"
)
accuracy = (np.argmax(filtered_probs, axis=1) == true_states).mean()
print(f"\nFiltered accuracy: {accuracy:.1%}")
# %% [markdown]
# ### Filtered vs Smoothed Probabilities
#
# The **filtered** probability $P(\text{state}_t \mid \text{obs}_{1:t})$ uses only
# past and current data — it is **causal** and safe for trading.
#
# The **smoothed** probability $P(\text{state}_t \mid \text{obs}_{1:T})$ uses the
# entire sample including *future* data — it has **look-ahead bias**.
#
# Using smoothed probabilities as features in a backtest is a common mistake
# that inflates performance.
# %%
# Compute smoothed probabilities using hmmlearn for comparison
toy_hmm = GaussianHMM(n_components=2, covariance_type="diag", n_iter=1)
# Set parameters manually to match our toy model
toy_hmm.startprob_ = pi
toy_hmm.transmat_ = A
toy_hmm.means_ = means.reshape(-1, 1)
toy_hmm.covars_ = (stds**2).reshape(-1, 1)
# hmmlearn's predict_proba returns smoothed by default
obs_2d = observations.reshape(-1, 1)
smoothed_probs = toy_hmm.predict_proba(obs_2d)
fig, axes = plt.subplots(2, 1, figsize=(12, 6), sharex=True)
t_range = np.arange(T_toy)
ax = axes[0]
ax.plot(t_range, filtered_probs[:, 0], "b-o", label="Filtered P(Calm)", markersize=6)
ax.plot(t_range, smoothed_probs[:, 0], "r--s", label="Smoothed P(Calm)", markersize=6)
ax.set_title("Filtered vs Smoothed Probabilities — Toy Example")
ax.set_ylabel("P(Calm)")
# Pin to [0, 1] and turn off the scientific-notation offset that matplotlib
# auto-applies when both series saturate near 1.0 — the +1 offset makes the
# probability differences look like ~1e-5 noise.
ax.set_ylim(-0.05, 1.05)
ax.ticklabel_format(useOffset=False, axis="y")
ax.legend()
# Highlight look-ahead bias
ax = axes[1]
bias = smoothed_probs[:, 0] - filtered_probs[:, 0]
ax.bar(t_range, bias, color=["red" if b > 0.01 else "green" if b < -0.01 else "gray" for b in bias])
ax.axhline(0, color="black", linewidth=0.5)
ax.set_title("Look-Ahead Bias (Smoothed - Filtered)")
ax.set_ylabel("Probability Difference")
ax.set_xlabel("Time Step")
plt.tight_layout()
plt.show()
print(f"Mean absolute bias: {np.mean(np.abs(bias)):.4f}")
print("Smoothed probabilities use future data → look-ahead bias in backtests!")
# %% [markdown]
# # Part 3 — HMM on Financial Data
#
# Now we apply HMMs to SPY returns, addressing practical challenges:
# initialization, model selection, and label switching.
# %% [markdown]
# ### Multiple Random Initializations
#
# EM is sensitive to initialization. Running multiple starts and selecting
# the best log-likelihood reduces the risk of local optima.
# %%
X = spy_pd[["returns", "volatility"]].dropna().values
n_inits = N_INITS
best_ll = -np.inf
best_model = None
log_likelihoods = []
for seed in range(n_inits):
model = GaussianHMM(
n_components=2,
covariance_type="full",
n_iter=N_ITER,
random_state=seed,
tol=1e-4,
)
model.fit(X)
ll = model.score(X)
log_likelihoods.append(ll)
if ll > best_ll:
best_ll = ll
best_model = model
print(f"=== EM Initialization: {n_inits} Random Starts ===")
print(f"Log-likelihoods: {[f'{ll:.1f}' for ll in log_likelihoods]}")
print(f"Range: {max(log_likelihoods) - min(log_likelihoods):.1f}")
print(f"Best: {best_ll:.1f} (seed {np.argmax(log_likelihoods)})")
# %% [markdown]
# ### K-Means Seeded Initialization
#
# Instead of random initialization, use k-means clustering to provide
# better starting values for the emission parameters.
# %%
def fit_hmm_kmeans_init(X: np.ndarray, n_states: int, random_state: int = 42) -> GaussianHMM:
"""Fit HMM with k-means-seeded initialization."""
# K-means for initial emission parameters
kmeans = KMeans(n_clusters=n_states, random_state=random_state, n_init=10)
kmeans.fit(X)
model = GaussianHMM(
n_components=n_states,
covariance_type="full",
n_iter=N_ITER,
random_state=random_state,
init_params="st", # Only init startprob and transmat
)
# Set emission parameters from k-means
model.means_ = kmeans.cluster_centers_
model.covars_ = np.array(
[np.cov(X[kmeans.labels_ == k].T) + np.eye(X.shape[1]) * 1e-6 for k in range(n_states)]
)
model.fit(X)
return model
hmm_kmeans = fit_hmm_kmeans_init(X, n_states=2)
ll_kmeans = hmm_kmeans.score(X)
print(f"K-means init log-likelihood: {ll_kmeans:.1f}")
print(f"Best random init log-likelihood: {best_ll:.1f}")
print(f"K-means {'better' if ll_kmeans > best_ll else 'worse'} by {abs(ll_kmeans - best_ll):.1f}")
# Use the better model
hmm2 = hmm_kmeans if ll_kmeans > best_ll else best_model
assert hmm2 is not None
# %% [markdown]
# ### BIC for Number of States
#
# Compare 2, 3, and 4 states using BIC (Bayesian Information Criterion).
# More states always improve log-likelihood but may overfit.
# %%
bic_results = []
models = {}
for k in [2, 3, 4]:
model = fit_hmm_kmeans_init(X, n_states=k)
ll = model.score(X)
# BIC = -2*LL + n_params * log(n_obs)
# model.score(X) returns the TOTAL log-likelihood, not per-sample
n_obs = len(X)
d = X.shape[1]
# Parameters: (k-1) initial + k*(k-1) transition + k*d means + k*d*(d+1)/2 covariances
n_params = (k - 1) + k * (k - 1) + k * d + k * d * (d + 1) // 2
bic = -2 * ll + n_params * np.log(n_obs)
bic_results.append({"K": k, "LL": ll, "n_params": n_params, "BIC": bic})
models[k] = model
print(f"K={k}: LL={ll:.1f}, params={n_params}, BIC={bic:.0f}")
# Select best by BIC
best_k = min(bic_results, key=lambda x: x["BIC"])["K"]
print(f"\nBest K by BIC: {best_k}")
# %% [markdown]
# BIC decreases monotonically through K=4, suggesting the data supports more
# than two states in-sample. However, BIC is known to over-select states for
# HMMs on long financial series — more states always capture more volatility
# clustering patterns, but these additional regimes often fail to persist
# out-of-sample. We proceed with **K=2** (calm/stressed) for interpretability
# and robustness, consistent with the common finding that equity returns are
# well-described by two volatility regimes.
# %% [markdown]
# ### Label Switching Prevention
#
# HMM states are unordered — "State 0" might be high-vol in one estimation
# and low-vol in another. Sort states by a consistent property (variance)
# to prevent label switching.
# %%
def sort_states_by_variance(model: GaussianHMM) -> np.ndarray:
"""Sort HMM states by variance (ascending) for consistent labeling.
Returns (sorted_means, sorted_covars, state_order).
"""
# Compute total variance for each state
variances = np.array([np.trace(model.covars_[k]) for k in range(model.n_components)])
order = np.argsort(variances) # Low vol first
return order
# %%
def relabel_states(states: np.ndarray, probs: np.ndarray, order: np.ndarray) -> tuple:
"""Relabel states according to the given order."""
inv_order = np.argsort(order)
new_states = inv_order[states]
new_probs = probs[:, order]
return new_states, new_probs
# Apply to 2-state model
order_2 = sort_states_by_variance(hmm2)
states_2 = hmm2.predict(X)
probs_2 = hmm2.predict_proba(X)
states_sorted, probs_sorted = relabel_states(states_2, probs_2, order_2)
# Verify: State 0 should have lower volatility
for k in range(2):
mask = states_sorted == k
label = "Low-Vol" if k == 0 else "High-Vol"
mean_vol = spy_pd.loc[spy_pd.index[: len(mask)][mask], "volatility"].mean()
mean_ret = spy_pd.loc[spy_pd.index[: len(mask)][mask], "returns"].mean()
pct = mask.mean()
print(f"State {k} ({label}): {pct:.1%} of time, mean ret={mean_ret:.3f}%, vol={mean_vol:.1f}%")
# %% [markdown]
# ### Filtered Probabilities for Production
#
# In production, use **filtered** (not smoothed) probabilities to avoid
# look-ahead bias. hmmlearn's `predict_proba` returns smoothed by default.
# We implement the forward algorithm directly using hmmlearn's internal
# `_compute_log_likelihood` for per-observation emission probabilities.
# This is a private API — if it changes between hmmlearn versions, the
# forward pass logic itself (below) remains correct with any emission source.
# %%
def compute_filtered_probs(model: GaussianHMM, X: np.ndarray) -> np.ndarray:
"""Compute filtered probabilities P(state_t | obs_{1:t}).
Uses the forward algorithm internally, then normalizes.
"""
# hmmlearn provides _compute_log_likelihood for emissions
framelogprob = model._compute_log_likelihood(X)
n_samples = X.shape[0]
n_components = model.n_components
log_startprob = np.log(model.startprob_ + 1e-300)
log_transmat = np.log(model.transmat_ + 1e-300)
# Forward pass (log-domain for numerical stability)
fwdlattice = np.zeros((n_samples, n_components))
# Initialization
fwdlattice[0] = log_startprob + framelogprob[0]
# Recursion
for t in range(1, n_samples):
for j in range(n_components):
fwdlattice[t, j] = framelogprob[t, j] + np.logaddexp.reduce(
fwdlattice[t - 1] + log_transmat[:, j]
)
# Normalize to get probabilities
log_normalizer = np.logaddexp.reduce(fwdlattice, axis=1, keepdims=True)
filtered = np.exp(fwdlattice - log_normalizer)
return filtered
filtered = compute_filtered_probs(hmm2, X)
smoothed = hmm2.predict_proba(X)
# Apply label sorting
filtered_sorted = filtered[:, order_2]
smoothed_sorted = smoothed[:, order_2]
# %% [markdown]
# ### Filtered vs Smoothed on Real Data
#
# The difference is most visible around regime transitions — smoothed
# probabilities "know" the transition is coming before it happens.
# %%
fig, axes = plt.subplots(3, 1, figsize=(14, 10), sharex=True)
dates = spy_pd.index[: len(filtered)]
ax = axes[0]
ax.plot(dates, spy_pd["close"].values[: len(filtered)], linewidth=0.5)
ax.set_title("SPY Price")
ax.set_ylabel("Price")
ax = axes[1]
ax.plot(dates, filtered_sorted[:, 1], linewidth=0.8, label="Filtered P(High-Vol)", alpha=0.7)
ax.plot(dates, smoothed_sorted[:, 1], linewidth=0.8, label="Smoothed P(High-Vol)", alpha=0.7)
ax.set_title("High-Vol State Probability: Filtered vs Smoothed")
ax.set_ylabel("Probability")
ax.legend()
# Show bias in a focused window around COVID
ax = axes[2]
covid_mask = (dates >= "2019-06-01") & (dates <= "2020-12-31")
if covid_mask.sum() > 0:
ax.plot(
dates[covid_mask], filtered_sorted[covid_mask, 1], linewidth=1.5, label="Filtered (causal)"
)
ax.plot(
dates[covid_mask],
smoothed_sorted[covid_mask, 1],
linewidth=1.5,
label="Smoothed (look-ahead)",
)
ax.set_title("COVID Period: Smoothed Anticipates Transition")
ax.set_ylabel("P(High-Vol)")
ax.legend()
plt.tight_layout()
plt.show()
# %% [markdown]
# # Part 4 — Regime Features
#
# Extract features from the fitted HMM for downstream ML models.
# %%
spy_pd["hmm_state"] = states_sorted[: len(spy_pd)]
spy_pd["regime_prob_high"] = filtered_sorted[: len(spy_pd), 1]
spy_pd["regime_prob_low"] = filtered_sorted[: len(spy_pd), 0]
# Transition probabilities as features
transmat = hmm2.transmat_[order_2][:, order_2]
spy_pd["transition_low_to_high"] = transmat[0, 1]
spy_pd["transition_high_to_low"] = transmat[1, 0]
# Expected duration in current state
# E[duration in state k] = 1 / (1 - A_{kk})
spy_pd["expected_duration"] = np.where(
spy_pd["hmm_state"] == 0,
1 / (1 - transmat[0, 0]),
1 / (1 - transmat[1, 1]),
)
# Regime entropy: uncertainty about current state
# High entropy = uncertain, low entropy = confident
eps = 1e-10
p = filtered_sorted[: len(spy_pd)]
spy_pd["regime_entropy"] = -np.sum(p * np.log(p + eps), axis=1)
print("=== Regime Feature Summary ===")
print("Transition matrix (sorted):")
print(f" P(Low→Low): {transmat[0, 0]:.4f} P(Low→High): {transmat[0, 1]:.4f}")
print(f" P(High→Low): {transmat[1, 0]:.4f} P(High→High): {transmat[1, 1]:.4f}")
print("\nExpected durations:")
print(f" Low-vol regime: {1 / (1 - transmat[0, 0]):.0f} days")
print(f" High-vol regime: {1 / (1 - transmat[1, 1]):.0f} days")
# %% [markdown]
# # Part 5 — Markov-Switching AR (Hamilton)
#
# MS-AR provides a complementary approach with different advantages:
# - Explicitly models AR dynamics within each regime
# - Provides maximum likelihood with analytical gradients
# - Includes regime-specific AR coefficients and variances
# %%
returns_clean = spy_pd["returns"].dropna()
msar = MarkovAutoregression(
returns_clean,
k_regimes=2,
order=1,
switching_ar=False,
switching_variance=True,
)
msar_result = msar.fit(disp=False)
print("=== MS-AR(1) Model ===")
print(f"Regime 0 variance: {msar_result.params['sigma2[0]']:.4f}")
print(f"Regime 1 variance: {msar_result.params['sigma2[1]']:.4f}")
# Extract both filtered and smoothed probabilities
# Filtered: point-in-time correct (production-safe)
# Smoothed: uses full sample (for diagnostics/comparison only)
msar_filtered_0 = msar_result.filtered_marginal_probabilities[0]
msar_filtered_1 = msar_result.filtered_marginal_probabilities[1]
msar_smoothed_0 = msar_result.smoothed_marginal_probabilities[0]
msar_smoothed_1 = msar_result.smoothed_marginal_probabilities[1]
# Align indices — MS-AR drops observations due to AR lag
spy_pd["msar_filtered_0"] = np.nan
spy_pd["msar_filtered_1"] = np.nan
spy_pd["msar_smoothed_0"] = np.nan
spy_pd["msar_smoothed_1"] = np.nan
common_idx = spy_pd.index.intersection(msar_filtered_0.index)
spy_pd.loc[common_idx, "msar_filtered_0"] = msar_filtered_0.loc[common_idx].values
spy_pd.loc[common_idx, "msar_filtered_1"] = msar_filtered_1.loc[common_idx].values
spy_pd.loc[common_idx, "msar_smoothed_0"] = msar_smoothed_0.loc[common_idx].values
spy_pd.loc[common_idx, "msar_smoothed_1"] = msar_smoothed_1.loc[common_idx].values
# Identify which MS-AR regime is high-vol
var_0 = msar_result.params["sigma2[0]"]
var_1 = msar_result.params["sigma2[1]"]
high_vol_regime = 0 if var_0 > var_1 else 1
print(f"High-vol regime: {high_vol_regime} (σ²={max(var_0, var_1):.4f})")
# %% [markdown]
# **Filtered vs smoothed MS-AR**: Like HMMs, MS-AR provides both filtered and
# smoothed marginal probabilities. The filtered probabilities use only past and
# current observations — these are the production-safe features. Smoothed
# probabilities incorporate the full sample and are useful only for historical
# analysis.
# %% [markdown]
# ### Method Comparison
# %%
fig, axes = plt.subplots(4, 1, figsize=(14, 12), sharex=True)
ax = axes[0]
ax.plot(spy_pd.index, spy_pd["close"], linewidth=0.5, color="black")
ax.set_title("SPY Price")
ax.set_ylabel("Price")
ax = axes[1]
ax.fill_between(spy_pd.index, 0, spy_pd["vix_regime"], alpha=0.5, color="orange", label="VIX > 20")
ax.set_title("Baseline: VIX Threshold")
ax.set_ylabel("Regime")
ax.legend()
ax = axes[2]
ax.fill_between(spy_pd.index, 0, spy_pd["regime_prob_high"], alpha=0.7, color="red")
ax.set_title("HMM: Filtered P(High-Vol)")
ax.set_ylabel("Probability")
ax = axes[3]
msar_high = spy_pd[f"msar_filtered_{high_vol_regime}"].fillna(0)
ax.fill_between(spy_pd.index, 0, msar_high, alpha=0.7, color="purple")
ax.set_title("MS-AR: Filtered P(High-Vol)")
ax.set_ylabel("Probability")
plt.tight_layout()
plt.show()
# %%
# Agreement analysis
valid = spy_pd[["vix_regime", "regime_prob_high"]].dropna()
hmm_high = (valid["regime_prob_high"] > 0.5).astype(int)
vix_high = valid["vix_regime"]
agreement = (hmm_high == vix_high).mean()
print(f"HMM vs VIX agreement: {agreement:.1%}")
# Confusion analysis
tp = ((hmm_high == 1) & (vix_high == 1)).sum()
fp = ((hmm_high == 1) & (vix_high == 0)).sum()
fn = ((hmm_high == 0) & (vix_high == 1)).sum()
tn = ((hmm_high == 0) & (vix_high == 0)).sum()
print("\nHMM vs VIX Confusion (VIX as 'truth'):")
print(f" True Positive: {tp:>5} False Positive: {fp:>5}")
print(f" False Negative: {fn:>5} True Negative: {tn:>5}")
# %% [markdown]
# **Interpretation**: HMM and VIX-based regimes agree on most days but diverge
# meaningfully at transitions. False positives (HMM detects stress that VIX
# misses) often correspond to elevated realized volatility before VIX catches up.
# False negatives (VIX elevated but HMM calm) may reflect VIX overshooting
# during event-driven spikes. Neither is "correct" — they measure different
# aspects of the volatility regime.
# %% [markdown]
# # Part 6 — Indicator-Based Regime Detection (ml4t-engineer)
#
# HMMs are probabilistic and data-driven — they learn regime structure from
# the data. An alternative approach uses **deterministic indicators** from
# technical analysis that classify regime based on fixed rules applied to
# price dynamics.
#
# `ml4t-engineer` provides five regime indicators as Polars expressions,
# plus a composite `market_regime_classifier` that combines them.
# %%
spy_indicators = spy.with_columns(
chop=choppiness_index("high", "low", "close", period=14),
hurst=hurst_exponent("close", period=100),
fractal_eff=fractal_efficiency("close", period=20),
trend_intensity=trend_intensity_index("close", period=30),
regime=market_regime_classifier("high", "low", "close", "volume"),
).drop_nulls(subset=["chop"])
print("=== Indicator-Based Regime Features ===")
for col in ["chop", "hurst", "fractal_eff", "trend_intensity", "regime"]:
vals = spy_indicators[col].drop_nulls()
print(f" {col:<18}: mean={vals.mean():.3f}, std={vals.std():.3f}")
# %% [markdown]
# ### HMM vs Indicator Comparison
#
# We compare the HMM high-volatility state with indicator-based regimes.
# These capture **different concepts**: the HMM identifies volatility regimes
# (calm vs stressed) from return dynamics, while `market_regime_classifier`
# produces trend regimes (bearish/range-bound = -1, neutral = 0, bullish = 1)
# from price structure. High volatility and bearish trend often co-occur
# during sell-offs but can diverge — recovery rallies are high-vol + bullish,
# and slow grinds can be low-vol + bearish. The comparison measures overlap,
# not equivalence.
# %%
# Align indicator regimes with HMM period
ind_pd = (
spy_indicators.select(
["timestamp", "chop", "hurst", "fractal_eff", "trend_intensity", "regime"]
)
.to_pandas()
.set_index("timestamp")
)
ind_pd.index = pd.DatetimeIndex(ind_pd.index)
# Align to common dates
common = spy_pd.index.intersection(ind_pd.index)
hmm_high_vol = (spy_pd.loc[common, "regime_prob_high"] > 0.5).astype(int)
ind_bearish = (ind_pd.loc[common, "regime"] == -1).astype(int) # -1 = bearish/range-bound
agreement = (hmm_high_vol == ind_bearish).mean()
print(f"HMM high-vol vs Indicator bearish overlap: {agreement:.1%}")
print("(These are different regime definitions — overlap, not equivalence)")
# %%
fig, axes = plt.subplots(3, 1, figsize=(14, 8), sharex=True)
ax = axes[0]
ax.plot(common, spy_pd.loc[common, "close"], linewidth=0.5, color="black")
ax.set_title("SPY Price")
ax.set_ylabel("Price")
ax = axes[1]
ax.fill_between(common, 0, spy_pd.loc[common, "regime_prob_high"], alpha=0.7, color="red")
ax.set_title("HMM: Filtered P(High-Vol) — Probabilistic")
ax.set_ylabel("Probability")
ax = axes[2]
ax.fill_between(
common,
0,
ind_pd.loc[common, "chop"] / 100,
alpha=0.5,
color="blue",
label="Choppiness Index / 100",
)
ax.plot(common, ind_pd.loc[common, "hurst"], linewidth=0.8, color="green", label="Hurst")
ax.axhline(0.5, color="red", linestyle="--", linewidth=0.5)
ax.set_title("Indicator Regime Features — Deterministic")
ax.set_ylabel("Value")
ax.legend(loc="upper right")
plt.tight_layout()
plt.show()
# %% [markdown]
# **Comparison**: HMM captures regime switches through latent-state inference —
# it adapts to the data but requires estimation and is sensitive to initialization.
# Indicator-based regimes are transparent and deterministic but use fixed rules
# that may not adapt to structural changes. In practice, combining both (e.g.,
# using indicators as additional HMM features) often works best.
# %% [markdown]
# ## Save Regime Features
#
# Save regime states for downstream chapters (Ch12 GBM, Ch18 portfolio).
# The cross-join below broadcasts SPY-derived regime states to all symbols
# in the case study universe — a simplification appropriate for market-level
# regime features. Per-asset regime models would require individual HMM fits.
# %%
MODEL_DIR = CASE_DIR / "models" / "time_series"
MODEL_DIR.mkdir(parents=True, exist_ok=True)
# 3-state HMM for richer regime information
hmm3 = fit_hmm_kmeans_init(X, n_states=3)
order_3 = sort_states_by_variance(hmm3)
states_3 = hmm3.predict(X)
probs_3 = hmm3.predict_proba(X)
states_3_sorted, probs_3_sorted = relabel_states(states_3, probs_3, order_3)
# Compute filtered probabilities for 3-state model
filtered_3 = compute_filtered_probs(hmm3, X)
filtered_3_sorted = filtered_3[:, order_3]
# %%
# Build output with all symbols
all_symbols = load_etfs().select("symbol").unique().sort("symbol").get_column("symbol")
vol_regime_map = {0: "low", 1: "normal", 2: "high"}
base_df = pl.DataFrame(
{
"timestamp": spy_pd.index[: len(states_3_sorted)].values,
"regime_hmm": states_3_sorted,
"regime_prob_0": filtered_3_sorted[:, 0],
"regime_prob_1": filtered_3_sorted[:, 1],
"regime_prob_2": filtered_3_sorted[:, 2],
"vol_regime": [vol_regime_map[s] for s in states_3_sorted],
"trend_regime": spy_pd["trend_regime"].values[: len(states_3_sorted)],
}
)
symbols_df = pl.DataFrame({"symbol": all_symbols})
regime_df = base_df.join(symbols_df, how="cross")
regime_df = regime_df.select(
[
"timestamp",
"symbol",
"regime_hmm",
"regime_prob_0",
"regime_prob_1",
"regime_prob_2",
"vol_regime",
"trend_regime",
]
)
output_path = MODEL_DIR / "regime_states.parquet"
regime_df.write_parquet(output_path)
print(f"Saved regime states: {regime_df.shape}")
# %% [markdown]
# ## Key Takeaways
#
# 1. **Observable baselines** (VIX > 20, 200-day MA) are transparent and
# require no estimation — they set the bar any statistical model must clear
# 2. **The forward algorithm** computes filtered probabilities using only past
# data — essential for avoiding look-ahead bias in trading
# 3. **Smoothed probabilities use future data** — never use them as features
# in a backtest or live trading system
# 4. **EM is sensitive to initialization** — use multiple random starts or
# k-means seeding to find better optima
# 5. **BIC selects the number of states** — more states always improve fit
# but risk overfitting; 2-3 states usually suffice for financial data
# 6. **Sort states by variance** to prevent label switching across
# estimation windows
# 7. **Regime probabilities are better features than hard classifications** —
# they preserve uncertainty and degrade gracefully
# 8. **Indicator-based regime detection** (choppiness, Hurst, fractal efficiency)
# via ml4t-engineer provides transparent, deterministic alternatives to HMM —
# combine both approaches for robust regime features
#
# **Next**: See `12_wasserstein_regimes` for distribution-based clustering
# and `13_regime_as_feature` for integrating regime features into ML pipelines.