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

642 lines
23 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]
# # Uncertainty Features
#
# **Docker image**: `ml4t`
#
# This notebook demonstrates Bayesian and frequentist approaches to extracting
# **uncertainty features** — posterior distributions and prediction intervals
# become ML inputs, not just diagnostics.
#
# **Learning Objectives**:
# - Understand the uncertainty-as-feature principle
# - Implement a walk-forward stochastic volatility (SV) model with filtered posteriors
# - Extract `vol_posterior_std`, `vol_ci_width`, `vol_of_vol`, and `vol_persistence`
# - Compute ARIMA forecast uncertainty on log-volatility with proper back-transform
#
# **Book Reference**: Chapter 9, Section 9.4 (Uncertainty Features)
#
# **Prerequisites**: `08_garch_volatility` for frequentist volatility modeling
# (GARCH provides point estimates; this notebook adds uncertainty quantification).
# `09_har_rough_volatility` for realized volatility estimators.
# %%
"""Uncertainty Features — walk-forward SV posteriors and ARIMA forecast intervals."""
from datetime import datetime
import arviz as az
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import polars as pl
import pymc as pm
from IPython.display import display
from ml4t.diagnostic.evaluation.autocorrelation import analyze_autocorrelation
from ml4t.diagnostic.evaluation.stationarity import analyze_stationarity
from ml4t.engineer.features.volatility import garman_klass_volatility
from statsforecast import StatsForecast
from statsforecast.models import AutoARIMA
from data import load_etfs
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_DRAWS = 1000
N_TUNE = 2000
N_CHAINS = 2
REFIT_INTERVAL = 63 # Quarterly (~252/4 trading days)
ARIMA_WINDOW = 252
TRAIN_DAYS = 252
SEED = 42
# %%
set_global_seeds(SEED)
# %% [markdown]
# ## Load Data
# %%
etfs = load_etfs(symbols=["SPY"])
sp500 = etfs.select(["timestamp", "open", "high", "low", "close"]).sort("timestamp")
start_date = datetime(2015, 1, 1)
end_date = datetime(2024, 12, 31)
sp500 = sp500.filter((pl.col("timestamp") >= start_date) & (pl.col("timestamp") <= end_date))
sp500 = sp500.with_columns(
returns=pl.col("close").pct_change() * 100,
).drop_nulls()
returns = sp500.to_pandas().set_index("timestamp")["returns"]
print(f"Returns: {len(returns)} observations")
print(f"Date range: {returns.index.min()} to {returns.index.max()}")
print(f"Sample mean: {returns.mean():.4f}%, Sample std: {returns.std():.4f}%")
# %% [markdown]
# ## Garman-Klass Realized Volatility
#
# ARIMA forecast uncertainty requires a volatility target series. We use the
# Garman-Klass estimator (Garman and Klass, 1980) rather than close-to-close
# rolling standard deviation. GK incorporates open, high, low, and close prices,
# producing a more efficient estimator — see `09_har_rough_volatility` for the
# full comparison of range-based estimators.
# %%
sp500_gk = sp500.with_columns(
rv_gk_21=garman_klass_volatility("open", "high", "low", "close", period=21),
).drop_nulls(subset=["rv_gk_21"])
rv_gk = sp500_gk.select(["timestamp", "rv_gk_21"]).to_pandas().set_index("timestamp")["rv_gk_21"]
print(f"Garman-Klass RV (21-day): {len(rv_gk)} observations")
print(f"Mean: {rv_gk.mean():.4f}, Std: {rv_gk.std():.4f}")
# %%
fig, ax = plt.subplots(figsize=(14, 4))
ax.plot(rv_gk.index, rv_gk.values, linewidth=0.5, alpha=0.8)
ax.set_title("21-Day Garman-Klass Realized Volatility (SPY)")
ax.set_ylabel("Annualized Volatility")
ax.set_xlabel("Date")
plt.tight_layout()
plt.show()
# %% [markdown]
# ## The Uncertainty-as-Feature Principle
#
# Any point estimate becomes a distributional feature under Bayesian inference.
# A brief example: the Sharpe ratio. The frequentist estimate is a single number;
# the Bayesian posterior reveals how uncertain that number is. The same principle
# applies to volatility, hedge ratios, and any model parameter — the posterior
# width is itself a feature. Chapter 17 develops Bayesian Sharpe estimation fully;
# here we apply the principle to volatility and forecasts.
# %%
freq_sharpe = (returns.mean() / returns.std()) * np.sqrt(252)
print(f"Frequentist Sharpe (annualized): {freq_sharpe:.3f}")
print("A single number with no uncertainty quantification.")
print("The Bayesian version (Chapter 17) produces a full posterior distribution,")
print("yielding credible intervals and probability statements like P(SR > 0).")
# %% [markdown]
# ## Stochastic Volatility Model — Walk-Forward
#
# The canonical SV model specifies an AR(1) process in log-volatility:
#
# $$\log(\sigma_t) = \mu_h + \phi \cdot (\log(\sigma_{t-1}) - \mu_h) + \sigma_\eta \cdot \eta_t$$
#
# We implement this using `pm.AR` with the centered parameterization:
# $\tilde{h}_t = \phi \cdot \tilde{h}_{t-1} + \sigma_\eta \cdot \eta_t$
# where $\tilde{h}_t = h_t - \mu_h$. The observation model uses Student-t
# rather than Gaussian to handle fat tails in financial returns.
#
# ### Walk-Forward Protocol
#
# To maintain point-in-time integrity, we refit the SV model at quarterly
# boundaries on a trailing training window. At each refit, we extract the
# **filtered final state** — the posterior at the last time step, conditioned
# only on data up to that point. Between refits, features carry forward the
# most recent posterior state. This mirrors the GARCH walk-forward in
# `08_garch_volatility` but with quarterly cadence due to MCMC cost.
# %% [markdown]
# ### SV Model Specification
#
# Define the model as a function for reuse across walk-forward folds.
# %%
def fit_sv_model(returns_array, n_draws, n_tune, n_chains):
"""Fit AR(1) stochastic volatility with Student-t observations.
Returns the MCMC trace (InferenceData object).
"""
with pm.Model() as sv_model:
mu_h = pm.Normal("mu_h", mu=0, sigma=5)
phi = pm.Uniform("phi", lower=0, upper=1)
sigma_eta = pm.Exponential("sigma_eta", lam=2)
nu = pm.Deterministic("nu", pm.Gamma("nu_minus2", alpha=2, beta=0.1) + 2)
h_centered = pm.AR(
"h_centered",
rho=[phi],
sigma=sigma_eta,
init_dist=pm.Normal.dist(0, 1),
shape=len(returns_array),
)
h = pm.Deterministic("h", h_centered + mu_h)
volatility = pm.Deterministic("volatility", pm.math.exp(h / 2))
pm.StudentT("obs", nu=nu, mu=0, sigma=volatility, observed=returns_array)
with sv_model:
trace = pm.sample(
n_draws,
tune=n_tune,
chains=n_chains,
cores=1,
progressbar=False,
random_seed=SEED,
target_accept=0.99,
)
return trace
# %% [markdown]
# ### Walk-Forward Execution
#
# Refit every `REFIT_INTERVAL` trading days (~quarterly). Each refit trains
# on the preceding `TRAIN_DAYS` observations. Between refits, we carry forward
# the filtered posterior from the training window's final state.
# %%
forecast_start = len(returns) - TRAIN_DAYS
refit_points = list(range(forecast_start, len(returns), REFIT_INTERVAL))
n_refits = len(refit_points)
print(f"Walk-forward: {n_refits} refits, interval={REFIT_INTERVAL} days")
print(f"Training window: {TRAIN_DAYS} days, forecast horizon: {len(returns) - forecast_start} days")
sv_features_list = []
last_trace = None
# %%
for refit_idx, t in enumerate(refit_points):
train_start = max(0, t - TRAIN_DAYS)
train_data = returns.iloc[train_start:t].values
carry_end = min(t + REFIT_INTERVAL, len(returns))
carry_dates = returns.index[t:carry_end]
print(
f" Refit {refit_idx + 1}/{n_refits}: train [{train_start}:{t}] ({len(train_data)} obs), "
f"carry [{t}:{carry_end}] ({len(carry_dates)} days)"
)
trace = fit_sv_model(train_data, N_DRAWS, N_TUNE, N_CHAINS)
last_trace = trace
# Extract FILTERED final state — posterior at the last training time step
vol_final = trace.posterior["volatility"].values[:, :, -1].flatten()
phi_post = trace.posterior["phi"].values.flatten()
sigma_eta_post = trace.posterior["sigma_eta"].values.flatten()
nu_post = trace.posterior["nu"].values.flatten()
features = {
"vol_posterior_mean": vol_final.mean(),
"vol_posterior_std": vol_final.std(),
"vol_ci_width": np.percentile(vol_final, 97.5) - np.percentile(vol_final, 2.5),
"vol_of_vol": sigma_eta_post.mean(),
"vol_persistence": phi_post.mean(),
}
# Carry forward to each day until next refit
for dt in carry_dates:
row = {"timestamp": dt, **features}
sv_features_list.append(row)
# Per-refit diagnostics
n_div = int(trace.sample_stats["diverging"].values.sum())
print(
f" phi={phi_post.mean():.3f}, sigma_eta={sigma_eta_post.mean():.3f}, "
f"nu={nu_post.mean():.1f}, divergences={n_div}"
)
sv_features_df = pd.DataFrame(sv_features_list).set_index("timestamp")
print(f"\nSV features: {len(sv_features_df)} daily observations ({n_refits} refit points)")
# %% [markdown]
# ### MCMC Diagnostics
#
# Beyond divergence counts, we check $\hat{R}$ (convergence across chains) and
# effective sample size (ESS). $\hat{R} > 1.05$ or ESS below 100 indicate the
# sampler has not explored the posterior adequately.
#
# SV models are notoriously difficult for NUTS because the latent AR(1) path
# creates a funnel geometry between $\phi$, $\sigma_\eta$, and the 252 latent
# states. Expect lower ESS for these parameters than for $\mu_h$ and $\nu$,
# which are better identified. This is a genuine operational consideration:
# production SV implementations often use particle MCMC or sequential Monte
# Carlo rather than NUTS to handle this geometry.
# %%
# Diagnostics from the last refit
diag_vars = ["phi", "sigma_eta", "mu_h", "nu"]
summary = az.summary(last_trace, var_names=diag_vars)
display(summary)
n_divergences = int(last_trace.sample_stats["diverging"].values.sum())
print(f"\nDivergences: {n_divergences}")
# %% [markdown]
# ### Walk-Forward SV Features
#
# The plot shows piecewise-constant features that update at each quarterly
# refit. This honestly reflects the production protocol: features carry the
# last filtered state forward between re-estimations.
# %%
fig, axes = plt.subplots(2, 1, figsize=(14, 8), sharex=True)
ax = axes[0]
wf_dates = sv_features_df.index
ax.plot(
returns.loc[wf_dates].index,
returns.loc[wf_dates].values,
linewidth=0.4,
alpha=0.6,
label="Returns",
)
ax.fill_between(
wf_dates,
-2 * sv_features_df["vol_posterior_mean"],
2 * sv_features_df["vol_posterior_mean"],
alpha=0.3,
color="red",
label="±2σ (filtered)",
)
ax.set_title("Returns with Walk-Forward Volatility Bands")
ax.set_ylabel("Return (%)")
ax.legend()
ax = axes[1]
ax.plot(
wf_dates,
sv_features_df["vol_posterior_mean"],
linewidth=1.5,
label="Posterior mean (filtered)",
drawstyle="steps-post",
)
ax.fill_between(
wf_dates,
sv_features_df["vol_posterior_mean"] - sv_features_df["vol_ci_width"] / 2,
sv_features_df["vol_posterior_mean"] + sv_features_df["vol_ci_width"] / 2,
alpha=0.3,
label="95% CI width",
)
for rp in refit_points:
if rp < len(returns):
ax.axvline(returns.index[rp], color="gray", linestyle=":", linewidth=0.5)
ax.set_title("Filtered Posterior Volatility (Step-Function at Refit Points)")
ax.set_ylabel("Volatility (%)")
ax.set_xlabel("Date")
ax.legend()
plt.tight_layout()
plt.show()
# %% [markdown]
# **Interpretation**: The step-function updates show how SV features evolve at
# quarterly refits. Between refits, features are constant — reflecting the
# production reality that MCMC is too expensive to run daily. The CI width
# varies across refits, widening during uncertain periods.
# %% [markdown]
# ### Parameter Posteriors (Last Refit)
# %%
phi_samples = last_trace.posterior["phi"].values.flatten()
sigma_eta_samples = last_trace.posterior["sigma_eta"].values.flatten()
mu_h_samples = last_trace.posterior["mu_h"].values.flatten()
nu_samples = last_trace.posterior["nu"].values.flatten()
print("=== SV Parameter Posteriors (Last Refit) ===")
print(f"phi (persistence): mean={phi_samples.mean():.3f}, std={phi_samples.std():.3f}")
print(
f"sigma_eta (vol-of-vol): mean={sigma_eta_samples.mean():.3f}, std={sigma_eta_samples.std():.3f}"
)
print(f"mu_h (mean log-vol): mean={mu_h_samples.mean():.3f}, std={mu_h_samples.std():.3f}")
print(f"nu (Student-t df): mean={nu_samples.mean():.1f}, std={nu_samples.std():.1f}")
fig, axes = plt.subplots(1, 4, figsize=(16, 4))
for ax, samples, label, title in zip(
axes,
[phi_samples, sigma_eta_samples, mu_h_samples, nu_samples],
["φ (persistence)", "σ_η (vol-of-vol)", "μ_h (mean log-vol)", "ν (Student-t df)"],
["Volatility Persistence", "Volatility of Volatility", "Mean Log-Volatility", "Tail Thickness"],
strict=False,
):
ax.hist(samples, bins=30, density=True, alpha=0.7, edgecolor="white")
ax.axvline(samples.mean(), color="red", linestyle="--", label=f"Mean: {samples.mean():.2f}")
ax.set_xlabel(label)
ax.set_title(title)
ax.legend(fontsize=8)
plt.tight_layout()
plt.show()
# %% [markdown]
# **Interpretation**: The $\phi$ posterior captures volatility persistence — values
# near 1 mean shocks decay slowly (clustering), lower values indicate faster
# mean-reversion. The $\nu$ posterior indicates tail thickness: values near 46
# confirm that Student-t is substantially better than Gaussian ($\nu \to \infty$);
# values above 30 suggest Gaussian is adequate. Both $\sigma_\eta$ (vol-of-vol)
# and $\phi$ are re-estimated quarterly and serve as slowly varying features.
# %% [markdown]
# ### Scalability
#
# Full MCMC-based SV is practical for small universes (tens of assets). For
# large panels (1000+ assets), pragmatic alternatives include:
# - **Sector aggregates**: Run SV on sector ETFs, broadcast uncertainty features
# - **Variational inference**: `pm.fit()` gives faster but less accurate posteriors
# - **GARCH baseline with SV overlay**: Reserve SV for portfolio-level risk
#
# See the chapter text (§9.4) for universe-size guidance.
# %% [markdown]
# ## ARIMA Forecast Uncertainty on Log-Volatility
#
# ARIMA produces prediction intervals alongside point forecasts. We apply it
# to **log-volatility** rather than raw volatility for two reasons:
#
# 1. Log-transform ensures forecasts stay positive (volatility cannot be negative)
# 2. ARIMA residuals on log-vol are more Gaussian, improving interval coverage
#
# The back-transform uses the log-normal property: $\exp(\mu_f)$ gives the
# **median** forecast on the original scale (not the mean, which is
# $\exp(\mu_f + \sigma_f^2/2)$). The prediction interval is
# $[\exp(\mu_f - z \cdot \sigma_f),\; \exp(\mu_f + z \cdot \sigma_f)]$.
# Both the median and the interval are exact, not approximations.
# %% [markdown]
# ### Pre-ARIMA Diagnostics
#
# Before fitting, verify that log-volatility is stationary (confirming $d=0$)
# and examine autocorrelation structure.
# %%
log_rv = np.log(rv_gk.clip(lower=1e-8))
stat_check = analyze_stationarity(log_rv.dropna().values)
acf_analysis = analyze_autocorrelation(log_rv.dropna().values)
print("=== Pre-ARIMA Diagnostics (log Garman-Klass RV) ===")
print(f"Stationarity: {stat_check.consensus} (agreement: {stat_check.agreement_score:.2f})")
print(f"Suggested ARIMA order: {acf_analysis.suggested_arima_order}")
# %% [markdown]
# ### Order Selection and Rolling Forecast via Nixtla
#
# We use Nixtla's `AutoARIMA` (compiled C, AIC-based order selection) on the
# first training window to choose (p, d, q), then roll forward with that fixed
# order using Nixtla's `ARIMA` for speed. The model operates on
# $\log(\text{RV}_{GK})$; we back-transform to the original scale afterward.
#
# $\exp(\hat{\mu}_f)$ gives the **median** forecast on the original scale
# (not the mean $\exp(\hat{\mu}_f + \hat{\sigma}_f^2/2)$), which is more
# robust for position sizing.
# %%
from statsforecast.models import ARIMA as NixtlaARIMA
log_rv_clean = log_rv.dropna()
# Step 1: AutoARIMA order selection on first training window
first_window_df = pd.DataFrame(
{
"unique_id": "SPY",
"ds": log_rv_clean.index[:ARIMA_WINDOW],
"y": log_rv_clean.values[:ARIMA_WINDOW],
}
)
sf_auto = StatsForecast(models=[AutoARIMA(season_length=1)], freq="B", n_jobs=1)
sf_auto.fit(first_window_df)
selected_order = sf_auto.fitted_[0, 0].model_["arma"]
p, q, P, Q, s, d, D = selected_order
print(f"AutoARIMA selected: ARIMA({p},{d},{q})")
# %%
# Step 2: Rolling forecast with fixed order (fast)
n_total = len(log_rv_clean)
n_windows = n_total - ARIMA_WINDOW
arima_input = pd.DataFrame(
{
"unique_id": "SPY",
"ds": log_rv_clean.index,
"y": log_rv_clean.values,
}
)
sf = StatsForecast(
models=[NixtlaARIMA(order=(p, d, q), season_length=1)],
freq="B",
n_jobs=1,
)
cv_result = sf.cross_validation(
df=arima_input,
h=1,
step_size=1,
n_windows=n_windows,
level=[95],
)
# Back-transform from log scale to original scale
col = "ARIMA"
arima_df = pd.DataFrame(
{
"timestamp": cv_result["ds"].values,
"arima_forecast": np.exp(cv_result[col].values),
"arima_lo_95": np.exp(cv_result[f"{col}-lo-95"].values),
"arima_hi_95": np.exp(cv_result[f"{col}-hi-95"].values),
"actual": np.exp(cv_result["y"].values),
}
)
arima_df["arima_ci_width"] = arima_df["arima_hi_95"] - arima_df["arima_lo_95"]
arima_df["forecast_uncertainty_ratio"] = arima_df["arima_ci_width"] / arima_df["arima_forecast"]
# Forecast std from the CI width on original scale (robust to negative log-CIs)
arima_df["arima_forecast_std"] = arima_df["arima_ci_width"] / (2 * 1.96)
arima_df = arima_df.set_index("timestamp")
print(f"ARIMA({p},{d},{q}) forecast features: {len(arima_df)} observations")
# %%
fig, axes = plt.subplots(3, 1, figsize=(14, 10), sharex=True)
ax = axes[0]
ax.plot(
arima_df.index,
arima_df["arima_forecast"],
linewidth=0.8,
label=f"ARIMA({p},{d},{q}) forecast (median)",
)
ax.plot(arima_df.index, arima_df["actual"], linewidth=0.5, alpha=0.5, label="Actual GK RV")
ax.fill_between(
arima_df.index,
arima_df["arima_forecast"] - 1.96 * arima_df["arima_forecast_std"],
arima_df["arima_forecast"] + 1.96 * arima_df["arima_forecast_std"],
alpha=0.2,
label="95% PI",
)
ax.set_title(f"ARIMA({p},{d},{q}) Forecast on Garman-Klass RV (Log-Normal Back-Transform)")
ax.set_ylabel("Annualized Volatility")
ax.legend()
ax = axes[1]
ax.plot(arima_df.index, arima_df["arima_forecast_std"], linewidth=0.8, color="orange")
ax.set_title("Forecast Standard Error on Log Scale (arima_forecast_std)")
ax.set_ylabel("Log-Scale Std Error")
ax = axes[2]
ax.plot(arima_df.index, arima_df["forecast_uncertainty_ratio"], linewidth=0.8, color="purple")
ax.axhline(
arima_df["forecast_uncertainty_ratio"].median(),
color="red",
linestyle="--",
linewidth=0.5,
label=f"Median: {arima_df['forecast_uncertainty_ratio'].median():.2f}",
)
ax.set_title("Forecast Uncertainty Ratio (CI Width / Forecast Level)")
ax.set_ylabel("Ratio")
ax.set_xlabel("Date")
ax.legend()
plt.tight_layout()
plt.show()
# %% [markdown]
# **Interpretation**: The forecast standard error spikes during volatile periods
# (COVID, 2022 rate hikes) — precisely when the model is least confident.
# The log-normal back-transform produces asymmetric prediction intervals on the
# original scale: wider on the upside, reflecting that volatility spikes are
# unbounded while the floor is zero. The order was selected by AutoARIMA on
# the first window; periodic reselection would guard against structural change.
# %% [markdown]
# ## Feature Summary
# %%
print("SV Uncertainty Features (Walk-Forward):")
display(sv_features_df.describe().round(4))
print("ARIMA Uncertainty Features (AutoARIMA on Log-Vol):")
display(
arima_df[["arima_forecast_std", "arima_ci_width", "forecast_uncertainty_ratio"]]
.describe()
.round(4)
)
# %% [markdown]
# ### Production Notes
#
# The walk-forward protocol implemented above is the correct production pattern:
# SV features are re-estimated at periodic boundaries and carried forward between
# refits. ARIMA features update daily with negligible cost (refit on a rolling
# window and call `get_forecast()`).
#
# For production hardening beyond this notebook:
# - **Particle filtering** provides online posterior updates without full MCMC
# re-estimation — useful for daily SV updates between quarterly refits
# - AutoARIMA reselects the order at each window automatically; for even
# faster rolling forecasts, fix the order after an initial selection pass
# %% [markdown]
# ## Save Uncertainty Features
# %%
MODEL_DIR = get_case_study_dir("etfs") / "models" / "time_series"
MODEL_DIR.mkdir(parents=True, exist_ok=True)
arima_save_cols = [
"arima_forecast",
"arima_forecast_std",
"arima_ci_width",
"forecast_uncertainty_ratio",
]
arima_output = pl.from_pandas(arima_df[arima_save_cols].reset_index())
arima_path = MODEL_DIR / "arima_uncertainty.parquet"
arima_output.write_parquet(arima_path)
print(f"Saved ARIMA uncertainty features to {arima_path}")
print(f" Shape: {arima_output.shape}")
sv_output = pl.from_pandas(sv_features_df.reset_index())
sv_path = MODEL_DIR / "sv_uncertainty.parquet"
sv_output.write_parquet(sv_path)
print(f"Saved SV uncertainty features to {sv_path}")
print(f" Shape: {sv_output.shape}")
# %% [markdown]
# ## Key Takeaways
#
# 1. **Walk-forward SV preserves point-in-time integrity** — the filtered final
# state from each training window produces features conditioned only on past
# data, unlike full-sample smoothed posteriors
# 2. **Student-t observation model handles fat tails** — the estimated $\nu$
# posterior confirms whether Gaussian is adequate; values near 46 show
# Student-t materially improves the fit
# 3. **Vol persistence ($\phi$) and vol-of-vol ($\sigma_\eta$)** are complementary:
# $\phi$ near 1 means shocks decay slowly; high $\sigma_\eta$ means the
# volatility process itself is unstable
# 4. **Log-vol ARIMA ensures positive forecasts** — the log-normal back-transform
# produces exact, asymmetric prediction intervals on the original scale
# 5. **MCMC diagnostics go beyond divergences** — $\hat{R}$, ESS, and the $\nu$
# posterior all provide information about posterior reliability
# 6. **Computational cost scales with universe** — full MCMC is practical for
# tens of assets; large panels require variational inference or sector aggregates
#
# **Previous**: `09_har_rough_volatility` for multi-horizon volatility decomposition.
# **Next**: `11_hmm_regimes` for regime detection via Hidden Markov Models.