# --- # 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 2–6 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.