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

1038 lines
34 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ---
# 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]
# # Factor-Based Regime Detection
#
# **Chapter 1 · §1.4 Market Regimes: Change Is the Constant**
#
# **Docker image**: `ml4t`
#
# ## Purpose
#
# Demonstrates unsupervised learning for market regime detection using Gaussian Mixture Models (GMM)
# on factor returns from the AQR Century of Factor Premia dataset.
#
# ## Learning Objectives
#
# - Apply GMM clustering to factor return time series.
# - Evaluate cluster count with BIC, AIC, and silhouette scores.
# - Visualize regime timelines with historical event annotations.
# - Compare factor performance across regimes.
#
# ## Book Reference
#
# Section 1.4 of Chapter 1, "Market Regimes: Change Is the Constant" — style-regime view that
# precedes the macro-regime view in `macro_regimes.py`.
#
# ## Prerequisites
#
# - Familiarity with monthly return series and StandardScaler-style preprocessing.
# - Conceptual exposure to mixture models and information criteria.
# - AQR Century of Factor Premia parquet at `data/aqr_factors/` (download via
# `uv run python data/factors/aqr_download.py` if missing).
#
# ## Background
#
# Inspired by [Two Sigma's 2021 paper](https://www.twosigma.com/articles/a-machine-learning-approach-to-regime-modeling/)
# which uses GMM on an 18-factor "Factor Lens" to identify four market regimes. We work with
# AQR's longer history (1927+) over Value, Momentum, Carry, and Defensive across multiple asset
# classes. There is no objectively "correct" number of regimes; we sweep 26 clusters to show
# how granularity shapes the regime map.
#
# ### Scope: descriptive, not predictive
#
# This notebook fits the GMM and the StandardScaler on the **entire factor-return history**
# and then assigns regime labels back over that same history. The result is an *ex-post*
# characterization of how the factor space partitions, useful for narrative and for
# visualizing where macro events fall within the partitioning. It is **not** a regime
# classifier that can be used for prediction: using these labels as features in a trading
# strategy would constitute look-ahead because the partition itself was estimated with
# future data. Lookahead-safe label construction (walk-forward fitting, point-in-time
# information sets, embargoed cross-validation) is introduced from Chapter 6 onward, and
# the case-study chapters (Ch16-20) demonstrate how predictive regime features are
# constructed and evaluated.
# %% [markdown]
# ## Imports
# %%
"""Factor-Based Regime Detection — unsupervised regime detection using GMM on AQR factor returns."""
from __future__ import annotations
import warnings
from collections.abc import Iterable
from dataclasses import dataclass
warnings.filterwarnings("ignore")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import polars as pl
from matplotlib.axes import Axes
from matplotlib.colors import ListedColormap
from ml4t.data.providers import AQRFactorProvider
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
from sklearn.mixture import GaussianMixture
from sklearn.preprocessing import StandardScaler
from utils.paths import get_output_dir
from utils.reproducibility import set_global_seeds
# %% tags=["parameters"]
# Production defaults (Papermill overrides for testing)
SEED = 42
# %% [markdown]
# ## Configuration
# %%
OUTPUT_DIR = get_output_dir(1, "factor_regimes")
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
set_global_seeds(SEED)
# %% [markdown]
# ## Helper Functions
#
# A small container for GMM fit diagnostics, and a grid-search function
# that fits multiple cluster counts and reports BIC, AIC, and silhouette.
# %%
@dataclass(frozen=True)
class GmmFitResult:
"""Results from fitting a Gaussian Mixture Model."""
model: GaussianMixture
labels: np.ndarray
probabilities: np.ndarray
bic: float
aic: float
silhouette: float
# %% [markdown]
# ### Grid Search for Optimal Cluster Count
# Fit GMM across multiple cluster counts and evaluate with BIC, AIC, and silhouette.
# %%
def fit_gmm_grid(
x: np.ndarray,
n_components_list: Iterable[int],
random_state: int = SEED,
) -> dict[int, GmmFitResult]:
"""
Fit a grid of GaussianMixture models and report diagnostics.
BIC and AIC are more appropriate for mixture models than silhouette,
but silhouette can still be helpful as a coarse separation check.
"""
results: dict[int, GmmFitResult] = {}
for n in n_components_list:
model = GaussianMixture(
n_components=n,
covariance_type="full",
random_state=random_state,
n_init=10,
reg_covar=1e-6,
)
model.fit(x)
labels = model.predict(x)
probs = model.predict_proba(x)
bic = float(model.bic(x))
aic = float(model.aic(x))
sil = float(silhouette_score(x, labels)) if n >= 2 else float("nan")
results[n] = GmmFitResult(
model=model,
labels=labels,
probabilities=probs,
bic=bic,
aic=aic,
silhouette=sil,
)
return results
# %% [markdown]
# ## Load Factor Data
# %%
aqr_raw_pl = AQRFactorProvider().fetch("century_premia")
print(f"AQR Century of Factor Premia: {aqr_raw_pl.height} months, {aqr_raw_pl.width - 1} factors")
date_range = aqr_raw_pl.select(
pl.col("timestamp").min().alias("min"), pl.col("timestamp").max().alias("max")
)
print(f"Date range: {date_range['min'][0]} to {date_range['max'][0]}")
# %% [markdown]
# ## Understanding the Data
#
# The AQR "Century of Factor Premia" dataset contains monthly returns for various factor
# strategies across asset classes. Key columns we use:
#
# | Factor | Description |
# |--------|-------------|
# | **Equity indices Market** | Global developed equity market return (value-weighted) |
# | **All asset classes Value** | Cross-asset value factor (long cheap, short expensive) |
# | **All asset classes Momentum** | Cross-asset momentum (long top-quantile past returns, short bottom-quantile) |
# | **All asset classes Carry** | Cross-asset carry (long high yield, short low yield) |
# | **All asset classes Defensive** | Cross-asset low-risk anomaly |
#
# The "Equity indices Market" is what you'd earn investing in a global equity index -
# essentially the aggregate return from holding developed market stocks.
# %% [markdown]
# ## Select Factors
# %%
factor_cols = [
"All asset classes Value",
"All asset classes Momentum",
"All asset classes Carry",
"All asset classes Defensive",
"US Stock Selection Value",
"US Stock Selection Momentum",
"Equity indices Market",
"Fixed income Market",
"Commodities Market",
]
available_cols = [c for c in factor_cols if c in aqr_raw_pl.columns]
print(f"Selected {len(available_cols)} factors for regime detection:")
for col in available_cols:
na_count = aqr_raw_pl.select(pl.col(col).is_null().sum()).item()
na_pct = na_count / aqr_raw_pl.height * 100
print(f" {col}: {na_pct:.1f}% missing")
# %% [markdown]
# ## Prepare Data for Clustering
# %%
factors_pl = (
aqr_raw_pl.select(["timestamp"] + available_cols)
.sort("timestamp")
.fill_null(strategy="forward")
.drop_nulls()
)
factors_df = factors_pl.select(available_cols).to_pandas()
factors_df.index = factors_pl["timestamp"].to_pandas()
scaler = StandardScaler()
factors_scaled = scaler.fit_transform(factors_df)
print(
f"Factor data: {len(factors_df)} months ({factors_df.index.min().year} to {factors_df.index.max().year})"
)
# %% [markdown]
# ## Regime Detection with GMM
# %%
n_regimes_list = [2, 3, 4, 5, 6]
# Fit GMM grid
gmm_grid = fit_gmm_grid(factors_scaled, n_regimes_list)
# Also fit K-Means for comparison
kmeans_results = {}
for n in n_regimes_list:
kmeans = KMeans(n_clusters=n, random_state=SEED, n_init=10)
labels = kmeans.fit_predict(factors_scaled)
kmeans_results[n] = (kmeans, labels)
# Report model selection criteria as a DataFrame (lower BIC/AIC is better; higher silhouette is better).
selection_df = pd.DataFrame(
{
"n": n_regimes_list,
"BIC": [gmm_grid[n].bic for n in n_regimes_list],
"AIC": [gmm_grid[n].aic for n in n_regimes_list],
"Silhouette (GMM)": [gmm_grid[n].silhouette for n in n_regimes_list],
"Silhouette (K-Means)": [
silhouette_score(factors_scaled, kmeans_results[n][1]) for n in n_regimes_list
],
}
).set_index("n")
selection_df.style.format(
{
"BIC": "{:.1f}",
"AIC": "{:.1f}",
"Silhouette (GMM)": "{:.3f}",
"Silhouette (K-Means)": "{:.3f}",
}
)
# %% [markdown]
# **Interpretation**: BIC is minimised at K=2 (26,170) and silhouette is maximised
# at K=2 (0.27); AIC keeps falling out to K=6 (24,930). BIC penalises model complexity
# more heavily than AIC, making it the standard choice when the goal is interpretability
# rather than maximum likelihood. We proceed with K=2 — a Risk-On / Risk-Off split that
# both BIC and silhouette select on this 1927-2024 panel.
#
# The silhouette scores turn negative for K≥4 (0.02 at K=4, near-zero for K=5 and K=6),
# indicating that higher cluster counts produce overlapping, poorly separated regimes.
# %% [markdown]
# ## Model Selection Visualization
#
# The charts below show BIC, AIC, and silhouette scores for different cluster counts.
# Note the y-axis is scaled to highlight differences between models.
# %%
metrics = {
"BIC": [gmm_grid[n].bic for n in n_regimes_list],
"AIC": [gmm_grid[n].aic for n in n_regimes_list],
"Silhouette": [gmm_grid[n].silhouette for n in n_regimes_list],
}
# %%
fig, axes = plt.subplots(1, 3, figsize=(14, 4))
# BIC (lower is better) - zoomed y-axis
ax = axes[0]
bars = ax.bar(n_regimes_list, metrics["BIC"], color="steelblue", edgecolor="black")
best_idx = np.argmin(metrics["BIC"])
bars[best_idx].set_color("#D4A84B")
ax.set_xlabel("Number of Clusters", fontsize=11)
ax.set_ylabel("BIC (lower is better)", fontsize=11)
ax.set_title("Bayesian Information Criterion", fontsize=12)
ax.set_xticks(n_regimes_list)
# Zoom y-axis to show differences more clearly
bic_min, bic_max = min(metrics["BIC"]), max(metrics["BIC"])
bic_range = bic_max - bic_min
ax.set_ylim(bic_min - 0.1 * bic_range, bic_max + 0.1 * bic_range)
# AIC (lower is better) - zoomed y-axis
ax = axes[1]
bars = ax.bar(n_regimes_list, metrics["AIC"], color="steelblue", edgecolor="black")
best_idx = np.argmin(metrics["AIC"])
bars[best_idx].set_color("#D4A84B")
ax.set_xlabel("Number of Clusters", fontsize=11)
ax.set_ylabel("AIC (lower is better)", fontsize=11)
ax.set_title("Akaike Information Criterion", fontsize=12)
ax.set_xticks(n_regimes_list)
# Zoom y-axis to show differences more clearly
aic_min, aic_max = min(metrics["AIC"]), max(metrics["AIC"])
aic_range = aic_max - aic_min
ax.set_ylim(aic_min - 0.1 * aic_range, aic_max + 0.1 * aic_range)
# Silhouette (higher is better)
ax = axes[2]
bars = ax.bar(n_regimes_list, metrics["Silhouette"], color="steelblue", edgecolor="black")
best_idx = np.argmax(metrics["Silhouette"])
bars[best_idx].set_color("#D4A84B")
ax.set_xlabel("Number of Clusters", fontsize=11)
ax.set_ylabel("Silhouette (higher is better)", fontsize=11)
ax.set_title("Silhouette Score", fontsize=12)
ax.set_xticks(n_regimes_list)
fig.suptitle(
"GMM Model Selection: BIC and Silhouette Agree on K=2; AIC Prefers K=6",
fontsize=14,
y=1.02,
)
plt.show()
# %% [markdown]
# ## Regime Timeline Visualization
# %%
def plot_regime_timeline(
labels: np.ndarray,
dates: pd.Index,
n_regimes: int,
title: str,
ax: Axes | None = None,
) -> Axes:
"""Plot regime assignments as a timeline heatmap (one row per regime)."""
if ax is None:
fig, ax = plt.subplots(figsize=(16, 0.8 * n_regimes + 1))
years = [pd.Timestamp(ts).year for ts in dates]
# Create binary matrix: (n_regimes x n_months)
regime_matrix = np.zeros((n_regimes, len(labels)))
for i, label in enumerate(labels):
regime_matrix[label, i] = 1
# Plot each regime as a swim lane. origin='lower' so data row k anchors
# at y=k (matching the "Regime k+1" yticklabel below).
ax.imshow(
regime_matrix,
aspect="auto",
cmap="Blues",
vmin=0,
vmax=1,
extent=(0, len(labels), -0.5, n_regimes - 0.5),
interpolation="nearest",
origin="lower",
)
# Y-axis: regime labels
ax.set_yticks(range(n_regimes))
ax.set_yticklabels([f"Regime {i + 1}" for i in range(n_regimes)], fontsize=10)
# X-axis: decade markers
year_ticks = []
year_labels_list = []
for j, year in enumerate(years):
if j == 0 or years[j - 1] != year:
if year % 10 == 0:
year_ticks.append(j)
year_labels_list.append(str(year))
ax.set_xticks(year_ticks)
ax.set_xticklabels(year_labels_list, fontsize=9)
ax.set_xlabel("Year", fontsize=11)
ax.set_title(title, fontsize=12)
# Add horizontal grid lines between regimes
for i in range(1, n_regimes):
ax.axhline(y=i - 0.5, color="white", linewidth=2)
return ax
# %%
# Create swim lane figures for each cluster count
start_year = factors_df.index.min().year
end_year = factors_df.index.max().year
for n in n_regimes_list:
fig, ax = plt.subplots(figsize=(16, 0.8 * n + 1.5))
labels = gmm_grid[n].labels
plot_regime_timeline(
labels=labels,
dates=factors_df.index,
n_regimes=n,
title=f"GMM Factor Regimes (n={n}) - {start_year} to {end_year}",
ax=ax,
)
# Add metrics annotation
bic = gmm_grid[n].bic
aic = gmm_grid[n].aic
sil = gmm_grid[n].silhouette
ax.text(
0.02,
0.98,
f"BIC: {bic:.0f} | AIC: {aic:.0f} | Silhouette: {sil:.3f}",
transform=ax.transAxes,
fontsize=9,
verticalalignment="top",
bbox=dict(boxstyle="round", facecolor="white", alpha=0.8),
)
plt.show()
# %% [markdown]
# ## Two-Regime View: Risk-On vs Risk-Off
#
# The two-regime split separates periods of broad factor outperformance from
# periods of broad underperformance. It is the model that BIC and silhouette
# select above; the analysis below characterises the two regimes empirically.
# %%
# Historical events for annotation
historical_events = {
1929: ("Great Crash", "top"),
1937: ("Recession", "bottom"),
1973: ("Oil Crisis", "top"),
1987: ("Black Monday", "bottom"),
2000: ("Dot-com", "top"),
2008: ("GFC", "bottom"),
2020: ("COVID", "top"),
}
# Use n=2 for cleaner, more stable regime identification
n_regimes_book = 2
labels_2 = gmm_grid[n_regimes_book].labels
# Ex-post labeling for interpretability (NOT predictive)
regime_equity_returns = factors_df["Equity indices Market"].groupby(labels_2).mean()
good_regime = regime_equity_returns.idxmax()
bad_regime = 1 - good_regime
# %% [markdown]
# ### Risk-On vs Risk-Off Regime Timeline
# Swim lane panel showing regime assignment alongside cumulative equity returns.
# %%
def plot_two_regime_view():
"""Create swim lane + cumulative return figure for the two-regime model."""
# Create figure with swim lane panel + cumulative returns
fig, axes = plt.subplots(2, 1, figsize=(14, 5.5), height_ratios=[1, 2], sharex=True)
# Panel 1: Regime swim lanes
ax1 = axes[0]
# Create binary matrix: row 0 = Risk-Off, row 1 = Risk-On
regime_matrix = np.zeros((2, len(labels_2)))
for i, label in enumerate(labels_2):
if label == good_regime:
regime_matrix[1, i] = 1 # Risk-On row
else:
regime_matrix[0, i] = 1 # Risk-Off row
# Use grayscale-friendly colors for swim lanes. origin='lower' so data
# row 0 (Risk-Off) anchors at the bottom of the axis and row 1 (Risk-On)
# at the top, matching the yticklabels below.
cmap_swim = ListedColormap(["white", "#2d2d2d"])
ax1.imshow(
regime_matrix,
aspect="auto",
cmap=cmap_swim,
vmin=0,
vmax=1,
extent=[0, len(labels_2), -0.5, 1.5],
interpolation="nearest",
origin="lower",
)
# Y-axis: regime labels
ax1.set_yticks([0, 1])
ax1.set_yticklabels(["Risk-Off", "Risk-On"], fontsize=10)
ax1.axhline(y=0.5, color="gray", linewidth=1.5)
# X-axis setup
years = [pd.Timestamp(ts).year for ts in factors_df.index]
decade_ticks = []
decade_labels = []
for j, year in enumerate(years):
if j == 0 or years[j - 1] != year:
if year % 10 == 0:
decade_ticks.append(j)
decade_labels.append(str(year))
# Add historical event markers (BLACK labels, not red)
for event_year, (event_label, pos) in historical_events.items():
try:
idx = next(j for j, y in enumerate(years) if y == event_year)
ax1.axvline(x=idx, color="black", linestyle="-", alpha=0.6, linewidth=1.5)
y_pos = 1.7 if pos == "top" else -0.7
va = "bottom" if pos == "top" else "top"
ax1.annotate(
event_label,
xy=(idx, y_pos),
xycoords=("data", "data"),
ha="center",
va=va,
fontsize=8,
color="black",
fontweight="bold",
)
except StopIteration:
pass
ax1.set_xlim(0, len(labels_2))
ax1.set_ylim(-0.5, 1.5)
# Panel 2: Cumulative equity returns colored by regime
ax2 = axes[1]
equity_returns = factors_df["Equity indices Market"]
cum_returns = (1 + equity_returns).cumprod()
# Plot with regime coloring
for i in range(len(cum_returns) - 1):
color = "#d0d0d0" if labels_2[i] == good_regime else "#2d2d2d"
ax2.plot(
[i, i + 1], [cum_returns.iloc[i], cum_returns.iloc[i + 1]], color=color, linewidth=1.2
)
ax2.set_yscale("log")
ax2.set_ylabel("Cumulative Return (log scale)", fontsize=10)
ax2.set_xlabel("Year", fontsize=10)
ax2.set_xticks(decade_ticks)
ax2.set_xticklabels(decade_labels, fontsize=9)
ax2.grid(True, alpha=0.3)
# Annotations
ax2.annotate(
f"${cum_returns.iloc[-1]:.0f}",
xy=(len(cum_returns) - 1, cum_returns.iloc[-1]),
xytext=(10, 0),
textcoords="offset points",
fontsize=10,
fontweight="bold",
)
ax2.annotate(
"$1 invested in 1927",
xy=(0, 1),
xytext=(10, 10),
textcoords="offset points",
fontsize=9,
style="italic",
)
fig.suptitle(
f"Market Regimes: Risk-On vs Risk-Off ({start_year}-{end_year})",
fontsize=12,
fontweight="bold",
y=1.02,
)
plt.show()
plot_two_regime_view()
# %% [markdown]
# ## Volatility by Regime
#
# A key question: does volatility differ meaningfully between regimes?
# If Risk-Off periods have higher volatility, this validates using regime
# detection for risk management.
# %%
# Calculate rolling volatility (12-month window, annualized)
equity_returns = factors_df["Equity indices Market"]
rolling_vol = equity_returns.rolling(12).std() * np.sqrt(12)
# Create regime indicator series
regime_indicator = pd.Series(labels_2, index=factors_df.index)
# Calculate volatility statistics by regime
vol_by_regime = []
for regime, name in [(good_regime, "Risk-On"), (bad_regime, "Risk-Off")]:
mask = regime_indicator == regime
vol_in_regime = rolling_vol[mask].dropna()
vol_by_regime.append(
{
"Regime": name,
"Mean Vol": vol_in_regime.mean(),
"Median Vol": vol_in_regime.median(),
"Max Vol": vol_in_regime.max(),
}
)
vol_df = pd.DataFrame(vol_by_regime).set_index("Regime")
# %% [markdown]
# ### Volatility Regime Chart
# Regime bands with rolling volatility, showing how risk environments differ.
# %%
def plot_volatility_by_regime():
"""Plot regime bands and rolling volatility with comparison statistics."""
# Create figure with regime bands panel + volatility
fig, axes = plt.subplots(2, 1, figsize=(14, 5), height_ratios=[1, 3], sharex=True)
# Panel 1: Regime bands (same as the cumulative returns chart)
ax1 = axes[0]
regime_matrix = np.zeros((2, len(labels_2)))
for i, label in enumerate(labels_2):
if label == good_regime:
regime_matrix[1, i] = 1 # Risk-On row
else:
regime_matrix[0, i] = 1 # Risk-Off row
cmap_regime = ListedColormap(["white", "#2d2d2d"])
ax1.imshow(
regime_matrix,
aspect="auto",
cmap=cmap_regime,
vmin=0,
vmax=1,
extent=[0, len(labels_2), -0.5, 1.5],
interpolation="nearest",
origin="lower",
)
ax1.set_yticks([0, 1])
ax1.set_yticklabels(["Risk-Off", "Risk-On"], fontsize=9)
ax1.axhline(y=0.5, color="gray", linewidth=1)
ax1.set_title("Regime Classification", fontsize=10)
# Panel 2: Volatility time series
ax2 = axes[1]
# Plot rolling volatility colored by regime
for i in range(len(rolling_vol) - 1):
if pd.notna(rolling_vol.iloc[i]):
color = "#d0d0d0" if labels_2[i] == good_regime else "#2d2d2d"
ax2.plot(
[i, i + 1],
[rolling_vol.iloc[i] * 100, rolling_vol.iloc[i + 1] * 100],
color=color,
linewidth=1.2,
)
# Add horizontal lines for regime means
ax2.axhline(
vol_df.loc["Risk-On", "Mean Vol"] * 100,
color="#4a7c59",
linestyle="--",
linewidth=2,
label=f"Risk-On avg: {vol_df.loc['Risk-On', 'Mean Vol'] * 100:.1f}%",
)
ax2.axhline(
vol_df.loc["Risk-Off", "Mean Vol"] * 100,
color="#8b4513",
linestyle="--",
linewidth=2,
label=f"Risk-Off avg: {vol_df.loc['Risk-Off', 'Mean Vol'] * 100:.1f}%",
)
# X-axis setup
years = [pd.Timestamp(ts).year for ts in factors_df.index]
decade_ticks = []
decade_labels = []
for j, year in enumerate(years):
if j == 0 or years[j - 1] != year:
if year % 10 == 0:
decade_ticks.append(j)
decade_labels.append(str(year))
ax2.set_xticks(decade_ticks)
ax2.set_xticklabels(decade_labels, fontsize=9)
ax2.set_xlabel("Year", fontsize=10)
ax2.set_ylabel("Rolling 12-Month Volatility (%)", fontsize=10)
ax2.legend(loc="upper right", fontsize=10)
ax2.grid(True, alpha=0.3)
fig.suptitle(
f"Equity Market Volatility by Regime ({start_year}-{end_year})",
fontsize=12,
fontweight="bold",
y=1.02,
)
plt.show()
vol_ratio = vol_df.loc["Risk-Off", "Mean Vol"] / vol_df.loc["Risk-On", "Mean Vol"]
print(
f"Risk-Off / Risk-On rolling-volatility ratio: {vol_ratio:.2f}x "
f"({(vol_ratio - 1) * 100:.0f}% higher volatility in Risk-Off)."
)
return (
(vol_df * 100)
.round(1)
.rename(columns={"Mean Vol": "Mean (%)", "Median Vol": "Median (%)", "Max Vol": "Max (%)"})
)
vol_summary = plot_volatility_by_regime()
vol_summary
# %% [markdown]
# ### Persist Figure 1.5 inputs
#
# The publication-quality version of Figure 1.5 is rendered by
# `book/01_process_is_edge/figures/scripts/generate_figure_1_5_factor_regimes_volatility.py`.
# That script reads the arrays persisted below so the book build does not
# re-fit the GMM.
# %%
ARTIFACT_DIR = OUTPUT_DIR / "figure_1_5"
ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
np.savez(
ARTIFACT_DIR / "inputs.npz",
dates=factors_df.index.astype("datetime64[ns]").astype("int64"),
labels_2=np.asarray(labels_2, dtype=np.int64),
good_regime=np.int64(good_regime),
rolling_vol=rolling_vol.to_numpy(dtype=float),
risk_on_mean_vol=float(vol_df.loc["Risk-On", "Mean Vol"]),
risk_off_mean_vol=float(vol_df.loc["Risk-Off", "Mean Vol"]),
start_year=np.int64(start_year),
end_year=np.int64(end_year),
)
# %% [markdown]
# ## Factor Behavior by Regime
# %%
def plot_factor_returns_by_regime():
"""Compare annualized factor returns between Risk-On and Risk-Off regimes."""
# Calculate mean factor returns by regime
regime_labels_2 = pd.Series(labels_2, index=factors_df.index, name="regime")
factor_by_regime = factors_df.copy()
factor_by_regime["regime"] = regime_labels_2
# Mean returns by regime (annualized)
regime_means = factor_by_regime.groupby("regime").mean() * 12
regime_means.index = ["Risk-Off" if i == bad_regime else "Risk-On" for i in regime_means.index]
# Rename columns for readability
col_renames = {
"All asset classes Value": "Value",
"All asset classes Momentum": "Momentum",
"All asset classes Carry": "Carry",
"All asset classes Defensive": "Defensive",
"US Stock Selection Value": "US Value",
"US Stock Selection Momentum": "US Mom",
"Equity indices Market": "Equity",
"Fixed income Market": "Bonds",
"Commodities Market": "Cmdty",
}
regime_means_display = regime_means.rename(columns=col_renames)
# Create bar chart comparing regimes
fig, ax = plt.subplots(figsize=(12, 5))
x = np.arange(len(regime_means_display.columns))
width = 0.35
bars1 = ax.bar(
x - width / 2,
regime_means_display.loc["Risk-On"].values * 100,
width,
label="Risk-On",
color="#4a7c59",
edgecolor="black",
linewidth=0.5,
)
bars2 = ax.bar(
x + width / 2,
regime_means_display.loc["Risk-Off"].values * 100,
width,
label="Risk-Off",
color="#8b4513",
edgecolor="black",
linewidth=0.5,
)
# Add value labels
for bar, val in zip(bars1, regime_means_display.loc["Risk-On"].values, strict=False):
height = bar.get_height()
ax.annotate(
f"{val * 100:.1f}%",
xy=(bar.get_x() + bar.get_width() / 2, height),
xytext=(0, 3 if height >= 0 else -10),
textcoords="offset points",
ha="center",
va="bottom" if height >= 0 else "top",
fontsize=8,
fontweight="bold",
)
for bar, val in zip(bars2, regime_means_display.loc["Risk-Off"].values, strict=False):
height = bar.get_height()
ax.annotate(
f"{val * 100:.1f}%",
xy=(bar.get_x() + bar.get_width() / 2, height),
xytext=(0, 3 if height >= 0 else -10),
textcoords="offset points",
ha="center",
va="bottom" if height >= 0 else "top",
fontsize=8,
)
ax.set_ylabel("Annualized Return (%)", fontsize=11)
ax.set_xlabel("Factor", fontsize=11)
ax.set_xticks(x)
ax.set_xticklabels(regime_means_display.columns, fontsize=10)
ax.axhline(y=0, color="black", linestyle="-", linewidth=0.5)
ax.legend(loc="upper right", fontsize=10)
ax.grid(axis="y", alpha=0.3)
n_on = (labels_2 == good_regime).sum()
n_off = (labels_2 == bad_regime).sum()
ax.set_title(
f"Factor Returns by Market Regime\nRisk-On: {n_on} months ({n_on / len(labels_2) * 100:.0f}%) | Risk-Off: {n_off} months ({n_off / len(labels_2) * 100:.0f}%)",
fontsize=12,
fontweight="bold",
)
plt.show()
# Display per-factor returns as a DataFrame for easy comparison.
period_label = (
f"{factors_df.index.min().year}-{factors_df.index.max().year} ({len(factors_df)} months)"
)
print(f"Period: {period_label}")
print(
f"Regime distribution: Risk-On {n_on} ({n_on / len(labels_2) * 100:.1f}%), "
f"Risk-Off {n_off} ({n_off / len(labels_2) * 100:.1f}%)"
)
key_factors = [
c
for c in ["Value", "Momentum", "Equity", "Bonds", "Carry", "Defensive"]
if c in regime_means_display.columns
]
factor_summary = (regime_means_display[key_factors].T * 100).round(1)
factor_summary.columns = [f"{c} (%)" for c in factor_summary.columns]
return factor_summary
factor_summary = plot_factor_returns_by_regime()
factor_summary
# %% [markdown]
# **Interpretation**: The regime split reveals distinct factor behavior:
#
# - **Value is countercyclical**: Returns *increase* during Risk-Off (+5.3% vs +1.6%),
# consistent with the value premium's tendency to compensate for distress risk.
# - **Momentum is procyclical**: Much weaker during Risk-Off (+0.4% vs +4.5%),
# reflecting momentum's vulnerability to sharp reversals.
# - **Carry and Defensive turn negative** in Risk-Off (0.6% and 0.5%) — these
# strategies that appear safe in calm markets lose money when conditions deteriorate.
# - **Bonds outperform in Risk-Off** (+4.2% vs +0.8%), consistent with the classic
# flight-to-quality effect.
#
# For portfolio construction, this suggests Value and Bonds provide genuine
# diversification during stress, while Carry and Defensive do not.
# %% [markdown]
# ## Regime Statistics
#
# Compare key metrics between Risk-On and Risk-Off periods.
# %%
def compute_regime_statistics():
"""Calculate and display comprehensive regime statistics."""
# Calculate comprehensive regime statistics
equity_returns = factors_df["Equity indices Market"]
regime_series = pd.Series(labels_2, index=factors_df.index)
regime_stats_list = []
for regime, name in [(good_regime, "Risk-On"), (bad_regime, "Risk-Off")]:
mask = regime_series == regime
returns = equity_returns[mask]
# Basic counts
n_months = mask.sum()
pct_time = n_months / len(mask) * 100
# Return statistics (annualized)
mean_ret = returns.mean() * 12
vol = returns.std() * np.sqrt(12)
sharpe = mean_ret / vol if vol > 0 else 0
# Drawdown
cum_ret = (1 + returns).cumprod()
peak = cum_ret.cummax()
drawdown = (cum_ret - peak) / peak
max_dd = drawdown.min()
regime_stats_list.append(
{
"Regime": name,
"Months": n_months,
"% of Time": pct_time,
"Ann. Return": mean_ret,
"Ann. Volatility": vol,
"Sharpe Ratio": sharpe,
"Max Drawdown": max_dd,
}
)
regime_stats_df = pd.DataFrame(regime_stats_list).set_index("Regime")
# Volatility ratio (printed; the table itself displays below).
vol_on = regime_stats_df.loc["Risk-On", "Ann. Volatility"]
vol_off = regime_stats_df.loc["Risk-Off", "Ann. Volatility"]
print(f"Volatility ratio (Risk-Off / Risk-On): {vol_off / vol_on:.2f}x")
return regime_stats_df.style.format(
{
"% of Time": "{:.1f}%",
"Ann. Return": "{:+.1%}",
"Ann. Volatility": "{:.1%}",
"Sharpe Ratio": "{:.2f}",
"Max Drawdown": "{:.1%}",
}
)
compute_regime_statistics()
# %% [markdown]
# **Interpretation**: The regime split is stark. Risk-On delivers a Sharpe of 1.11
# while Risk-Off drops to 0.12 — equity exposure during Risk-Off is essentially
# uncompensated risk. The 76.9% max drawdown in Risk-Off reflects the Great
# Depression and underscores that these are not mild corrections.
#
# The direct volatility ratio here (2.2x) is much larger than the rolling-window
# average reported earlier (1.3x). The rolling measure smooths over extreme months,
# while this calculation captures the full variance within each regime. The direct
# measure better reflects what a portfolio actually experiences during Risk-Off.
# %% [markdown]
# ## Regime Duration Analysis
# %%
# Calculate average duration
durations = {}
for regime, name in [(bad_regime, "Risk-Off"), (good_regime, "Risk-On")]:
regime_mask = labels_2 == regime
runs = []
current_run = 0
for val in regime_mask:
if val:
current_run += 1
elif current_run > 0:
runs.append(current_run)
current_run = 0
if current_run > 0:
runs.append(current_run)
durations[name] = (np.mean(runs), np.max(runs), len(runs))
transitions = int(np.sum(np.diff(labels_2) != 0))
duration_df = pd.DataFrame(
[
{
"Regime": name,
"Avg duration (months)": durations[name][0],
"Max duration (months)": durations[name][1],
"Episodes": durations[name][2],
}
for name in ["Risk-On", "Risk-Off"]
]
).set_index("Regime")
print(
f"Total regime transitions: {transitions} "
f"(avg {len(labels_2) / transitions:.1f} months between switches)"
)
duration_df.style.format({"Avg duration (months)": "{:.1f}"})
# %% [markdown]
# **Interpretation**: With 267 transitions over 98 years, the model switches regime
# roughly every 4 months on average. Risk-Off episodes are short (avg ~2 months)
# — they capture acute stress periods rather than prolonged bear markets. This
# choppiness has practical implications: a strategy that reallocates based on these
# signals would trade frequently, incurring transaction costs that may erode the
# regime-timing benefit. Regime models are more useful for *understanding* market
# dynamics than for high-frequency tactical allocation.
# %% [markdown]
# ## Key Takeaways
#
# - **BIC favors two regimes**: K=2 has the lowest BIC and highest silhouette score,
# though AIC prefers more clusters. The two-state model offers the most interpretable
# and stable regime split.
# - **Regimes capture distinct risk environments**: Risk-Off has 2.2x higher volatility,
# a Sharpe of 0.12 (vs 1.11), and a max drawdown of 77%.
# - **Value is countercyclical**: +5.3% annualized during Risk-Off vs +1.6% during Risk-On.
# Carry and Defensive, despite their names, turn negative in Risk-Off.
# - **Momentum is procyclical**: +4.5% during Risk-On vs +0.4% during Risk-Off — weakest
# factor during market stress.
# - **Regime signals are noisy**: With transitions every ~4 months on average, this model
# is better suited for understanding factor dynamics than for tactical allocation.
#
# **Next**: `macro_regimes.py` switches from style returns to macro indicators (UNRATE,
# DFF, T10Y2Y, CPIAUCSL) and validates the resulting clusters against S&P 500 volatility
# and drawdowns. See Chapter 1 §1.4 for the workflow context.