Real-time S&P 500 volatility: bayesloop versus GARCH

Volatility clustering, the tendency for calm and turbulent days to arrive in runs, is the strongest stylised fact in financial returns and the one that launched the ARCH/GARCH literature (Engle, Nobel 2003). A natural description is a process whose volatility is a time-varying parameter. This example runs bayesloop fully online over a decade of daily S&P 500 returns. We ask three things of it: whether we can forecast next-day risk in real time, whether modelling time-varying volatility predicts returns better than holding it constant, and whether we can flag volatility shocks (the Feb-2018 “Volmageddon”, the March-2020 COVID crash, the 2022 bear, the 2025 tariff selloff) the moment they happen.

This is an example of comparing bayesloop against an established model. The standard model for return volatility is GARCH(1,1), and we benchmark bayesloop against it directly, out-of-sample, using the arch library, in both a Gaussian and a Student-t form and in fixed and rolling-refit variants. The components of the toolbox we use are bl.om.WhiteNoise (and a custom Student-t observation model) for time-varying volatility, an OnlineStudy that performs real-time model selection between a calm (GaussianRandomWalk) and a turbulent (RegimeSwitch) volatility process, and the prequential predictive log-likelihood as a fully causal out-of-sample score. The result, worked out below, is that within a matched (Gaussian) likelihood bayesloop beats the rolling-refit GARCH by +6.6 log₁₀, it is competitive with a Student-t GARCH, and it additionally provides a live regime probability and a full posterior over volatility as a by-product of the same filter.

DISCLAIMER: This website does not provide tax, legal or accounting advice. This material has been prepared for informational purposes only, and is not intended to provide, and should not be relied on for, tax, legal or accounting advice. You should consult your own tax, legal and accounting advisors before engaging in any transaction.

Note: Unlike the other case studies, this one cannot run interactively in the browser: the arch package used for the GARCH benchmark relies on compiled code that the in-browser Python session cannot load. To try the analysis yourself, download the notebook from the GitHub repository and run it locally.

[1]:
%matplotlib inline
import io
import urllib.request

import numpy as np
import pandas as pd
import matplotlib as mpl
import matplotlib.pyplot as plt

import bayesloop as bl

try:
    import seaborn as sns
    sns.set_style("whitegrid")
except ImportError:
    plt.style.use("default")
mpl.rcParams.update({
    "figure.dpi": 110, "font.size": 10.5, "axes.titlesize": 12,
    "axes.titleweight": "bold", "axes.edgecolor": "#444444", "grid.color": "#d9d9d9",
})
C = {"data": "#8a8a8a", "accent": "#c1272d", "accent2": "#0b6e99",
     "green": "#2e7d32", "amber": "#e8a33d", "muted": "#8a8a8a",
     "band": "#c1272d", "band2": "#0b6e99"}


def posterior_band(S, name, lo=0.05, hi=0.95):
    """Posterior mean and an equal-tailed credible band for a time-varying parameter."""
    x, p = S.get_parameter_distributions(name, density=False)
    p = np.asarray(p, dtype=float)
    p /= p.sum(axis=1, keepdims=True)
    mean = (p * x[None, :]).sum(axis=1)
    cdf = np.cumsum(p, axis=1)
    low = np.array([np.interp(lo, cdf[t], x) for t in range(p.shape[0])])
    high = np.array([np.interp(hi, cdf[t], x) for t in range(p.shape[0])])
    return mean, low, high


def prob_in(S, name, lo=None, hi=None):
    """Posterior probability the parameter lies in (lo, hi] at each time step."""
    x, p = S.get_parameter_distributions(name, density=False)
    p = np.asarray(p, dtype=float)
    p /= p.sum(axis=1, keepdims=True)
    mask = np.ones_like(x, dtype=bool)
    if lo is not None:
        mask &= x > lo
    if hi is not None:
        mask &= x <= hi
    return p[:, mask].sum(axis=1)

The data

We pull the S&P 500 daily index directly from FRED (series SP500). FRED encodes missing values (market holidays) as ".", so we drop those, then form daily log-returns in percent, rₜ = 100·ln(Pₜ / Pₜ₋₁). The series is contiguous over roughly the last decade, about 2,500 trading days, and includes the full sweep of recent regimes: the placid 2017, the Feb-2018 “Volmageddon”, the March-2020 COVID crash, the 2022 bear market, and the April-2025 tariff selloff.

[2]:
def fetch_sp500():
    """Daily S&P 500 index, live from FRED (series SP500). FRED can rate-limit bursts of
    requests; we retry a couple of times and fall back to the bundled copy of the identical
    (public-domain) FRED series if it stays unreachable, so the notebook always runs."""
    url = "https://fred.stlouisfed.org/graph/fredgraph.csv?id=SP500"
    req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 (bayesloop docs)"})
    for _ in range(2):
        try:
            return pd.read_csv(io.BytesIO(urllib.request.urlopen(req, timeout=30).read()),
                               na_values=".")
        except Exception:  # noqa: BLE001
            pass  # FRED unreachable from this network; use the bundled copy below
    return pd.read_csv("data/finance/fred_sp500.csv", na_values=".")


df = fetch_sp500()
df.columns = ["date", "px"]                 # FRED ships (observation_date, SP500); rename positionally
df["date"] = pd.to_datetime(df["date"])
df = df.dropna().reset_index(drop=True)
df["r"] = 100 * np.log(df["px"]).diff()
df = df.dropna().reset_index(drop=True)
dates = df["date"].values
r = df["r"].values
N = len(r)
TRAIN = 500                       # burn-in / GARCH training window; all models scored on [TRAIN:]
print(f"{len(r)} trading days, {df.date.min().date()}..{df.date.max().date()}; "
      f"daily return std {r.std():.2f}%, worst {r.min():.1f}%")
2512 trading days, 2016-06-14..2026-06-10; daily return std 1.14%, worst -12.8%

The observation model: zero-mean white noise with time-varying volatility

Daily returns are very nearly zero-mean and serially uncorrelated; all the structure lives in their scale. bl.om.WhiteNoise models exactly this, as Gaussian noise with mean zero and a single time-varying standard deviation sigma. We discretise sigma on a grid from 0.1% to 9% (an open interval, so the grid avoids the degenerate endpoints). This one parameter is the volatility, and tracking it online is the aim of the analysis.

[3]:
def white():
    return bl.om.WhiteNoise("sigma", bl.oint(0.1, 9.0, 220))

We also prepare a fat-tailed counterpart. Daily equity returns are known to be heavy-tailed, and the corresponding GARCH variant uses a Student-t distribution. To keep the eventual comparison matched on both sides, we give bayesloop the same option: a custom observation model via bl.om.NumPy that evaluates a scaled Student-t density, rₜ ~ sigma · t_ν with ν = 5. This tests whether modelling fat tails distributionally helps on top of a time-varying volatility, or whether the regime-switch (a volatility scale jump) already absorbs them.

[4]:
from scipy.stats import t as _tobs
NU_BL = 5                          # degrees of freedom for the fat-tailed bayesloop variant


def studt_obs():
    # scaled Student-t white noise: returns ~ sigma * t_nu, a one-line custom observation model
    def studt(data, sigma):
        return _tobs.pdf(data / sigma, df=NU_BL) / sigma
    return bl.om.NumPy(studt, "sigma", bl.oint(0.1, 9.0, 220))

The online model: real-time selection between calm and turbulent dynamics

The main modelling choice is the dynamics of sigma, and here we let bayesloop decide online between two competing descriptions rather than committing to one:

  • calm: a GaussianRandomWalk on sigma (with a small hyper-parameter eta marginalised over a grid), under which volatility drifts gently from day to day.

  • turbulent: a RegimeSwitch, under which volatility is allowed to jump abruptly, with a floor log10p_min = -4 on the switching probability.

An OnlineStudy runs both transition models in parallel and, at every step, updates the posterior probability of each. This is the real-time model selection that makes OnlineStudy distinctive. We give the calm model a 0.95 prior and the turbulent one 0.05 (most days are calm), then stream the returns through one at a time, recording the running log_evidence (the prequential, one-step-ahead predictive log-likelihood). Nothing here uses future data: each forecast depends only on past observations, exactly as a live risk system would.

[5]:
def run_online(obs, log10p=-4.0, store=False):
    """Stream r through the calm/turbulent OnlineStudy; return (study, per-step cumulative logE)."""
    S = bl.OnlineStudy(store_history=store, silent=True)
    S.set_observation_model(obs, silent=True)
    S.add_transition_model("calm", bl.tm.GaussianRandomWalk("eta", bl.cint(0, 0.3, 15), target="sigma"))
    S.add_transition_model("turbulent", bl.tm.RegimeSwitch("log10p_min", log10p))
    S.set_transition_model_prior([0.95, 0.05], silent=True)
    le = []
    for val in r:
        S.step(val)
        le.append(S.log_evidence)
    return S, np.asarray(le)


# Adaptive OnlineStudy with real-time model selection (calm vs turbulent)
Sa, logE_steps = run_online(white(), log10p=-4.0, store=True)
vol_mean, vol_lo, vol_hi = posterior_band(Sa, "sigma")
p_turbulent = np.asarray(Sa.get_transition_model_probabilities("turbulent", local=True))
logE_adaptive = float(Sa.log_evidence)
+ Added transition model: calm (15 combination(s) of the following hyper-parameters: ['eta'])
+ Added transition model: turbulent (1 combination(s) of the following hyper-parameters: ['log10p_min'])
+ Start model fit
    + Set prior (function): jeffreys. Values have been re-normalized.

posterior_band gives us the online posterior mean volatility and a credible band at every step; get_transition_model_probabilities("turbulent", local=True) gives the real-time p(turbulent) signal, the live regime alarm. Both follow from the same single filter pass.

The constant-volatility baseline

To show that time-varying volatility is worthwhile, we need a baseline that holds volatility constant. That is the same white-noise observation model under a Static transition, with one volatility for the whole decade, estimated online. Its prequential log-evidence is the baseline that every other model must clear.

[6]:
# Static baseline (constant volatility)
Ss = bl.OnlineStudy(store_history=True, silent=True)
Ss.set_observation_model(white(), silent=True)
Ss.add_transition_model("static", bl.tm.Static())
Ss.set_transition_model_prior([1.0], silent=True)
logE_steps_static = []
for val in r:
    Ss.step(val)
    logE_steps_static.append(Ss.log_evidence)
logE_static = float(Ss.log_evidence)
+ Added transition model: static (no hyper-parameters)
+ Start model fit
    + Set prior (function): jeffreys. Values have been re-normalized.

We also run the fat-tailed bayesloop variant (Student-t white noise), the matched counterpart to a Student-t GARCH, and a direct test of whether distributional fat tails help on top of the time-varying volatility or are redundant once the regime-switch can jump the scale.

[7]:
St, logE_t_steps = run_online(studt_obs(), log10p=-4.0, store=False)
logE_t_win = float(logE_t_steps[-1] - logE_t_steps[TRAIN - 1])
+ Added transition model: calm (15 combination(s) of the following hyper-parameters: ['eta'])
+ Added transition model: turbulent (1 combination(s) of the following hyper-parameters: ['log10p_min'])
+ Start model fit
    + Set uniform prior with parameter boundaries.

As a robustness check, we test whether the Gaussian result is an artefact of the regime-switch floor log10p_min. We re-run the online Gaussian model at three settings (−3, −4, −5) and report the windowed predictive log₁₀-likelihood for each. If the conclusion is real, these should barely move.

[8]:
# Robustness of the Gaussian result to the regime-switch floor log10p_min (no hand-tuning).
sweep = {-4.0: float((logE_steps[-1] - logE_steps[TRAIN - 1]) / np.log(10))}
for lp in (-3.0, -5.0):
    _, le_lp = run_online(white(), log10p=lp, store=False)
    sweep[lp] = float((le_lp[-1] - le_lp[TRAIN - 1]) / np.log(10))
bayesloop_gaussian_logL_by_log10pmin = {str(k): v for k, v in sorted(sweep.items())}
+ Added transition model: calm (15 combination(s) of the following hyper-parameters: ['eta'])
+ Added transition model: turbulent (1 combination(s) of the following hyper-parameters: ['log10p_min'])
+ Start model fit
    + Set prior (function): jeffreys. Values have been re-normalized.
+ Added transition model: calm (15 combination(s) of the following hyper-parameters: ['eta'])
+ Added transition model: turbulent (1 combination(s) of the following hyper-parameters: ['log10p_min'])
+ Start model fit
    + Set prior (function): jeffreys. Values have been re-normalized.

The first summary number, the prequential predictive log-likelihood over the whole stream, already settles the comparison between time-varying and constant volatility:

[9]:
print(f"Prequential predictive log-likelihood (log10): adaptive {logE_adaptive/np.log(10):.0f}, "
      f"static {logE_static/np.log(10):.0f}  => gain {(logE_adaptive-logE_static)/np.log(10):.0f}")
Prequential predictive log-likelihood (log10): adaptive -1393, static -1696  => gain 304

The benchmark: GARCH(1,1)

GARCH(1,1) (Engle 1982; Bollerslev 1986) is the standard model for return volatility, and we benchmark bayesloop against it as fairly as possible. Two principles keep the comparison fair:

  1. Matched likelihood family. A model with fatter tails will score higher purely because of the tails, so we hold the likelihood fixed: Gaussian GARCH is compared against Gaussian bayesloop, and Student-t GARCH against Student-t bayesloop. We score GARCH in both families.

  2. Causal and out-of-sample. We compute the true one-step-ahead predictive log-density on the matched evaluation window [TRAIN:] (everything after the first 500 days), which is exactly the quantity bayesloop accumulates online. We also give GARCH every advantage: besides a fixed fit (estimated once on the first 500 days) we run a fully adaptive rolling refit, re-estimating the GARCH parameters every 20 trading days on an expanding window.

garch_predict below propagates the GARCH variance recursion forward and scores each day’s return under the predicted conditional density (Gaussian, or a properly variance-scaled Student-t).

[10]:
from arch import arch_model
from scipy.stats import norm, t as tdist


def garch_predict(returns, train, dist, refit_every=None):
    """One-step-ahead predictive log-density path under GARCH(1,1) (NaN before `train`).
    refit_every=None -> single fit on [:train]; else rolling expanding-window refit."""
    n = len(returns); sig = np.full(n, np.nan); logp = np.full(n, np.nan)

    def fit_params(end):
        res = arch_model(returns[:end], mean="Constant", vol="GARCH", p=1, q=1,
                         dist=dist).fit(disp="off", show_warning=False)
        p = res.params
        nu = float(p["nu"]) if dist == "t" else None
        return (p["omega"], p["alpha[1]"], p["beta[1]"], p["mu"], nu,
                float(res.conditional_volatility[-1]) ** 2)

    def score(rt, v_t, mu_, nu_):
        if dist == "t":
            return tdist.logpdf(rt, df=nu_, loc=mu_, scale=np.sqrt(v_t) * np.sqrt((nu_ - 2) / nu_))
        return norm.logpdf(rt, loc=mu_, scale=np.sqrt(v_t))

    if refit_every is None:
        om_, al_, be_, mu_, nu_, _ = fit_params(train)
        v_prev = float(np.var(returns[:train])); a_prev = returns[0] - mu_; sig[0] = np.sqrt(v_prev)
        for t in range(1, n):
            v_t = om_ + al_ * a_prev ** 2 + be_ * v_prev; sig[t] = np.sqrt(v_t)
            if t >= train:
                logp[t] = score(returns[t], v_t, mu_, nu_)
            a_prev, v_prev = returns[t] - mu_, v_t
    else:
        rpt = train
        while rpt < n:
            om_, al_, be_, mu_, nu_, v_prev = fit_params(rpt); a_prev = returns[rpt - 1] - mu_
            for t in range(rpt, min(rpt + refit_every, n)):
                v_t = om_ + al_ * a_prev ** 2 + be_ * v_prev; sig[t] = np.sqrt(v_t)
                logp[t] = score(returns[t], v_t, mu_, nu_)
                a_prev, v_prev = returns[t] - mu_, v_t
            rpt += refit_every
    return sig, logp


def _l10(x):
    return float(x / np.log(10))

We fit all four GARCH variants, Gaussian and Student-t, each in a fixed and a rolling form. The rolling refits re-estimate the model dozens of times, so this cell is the heaviest one, though it still runs in a few seconds.

[11]:
sig_g, logp_gfix_n = garch_predict(r, TRAIN, "normal")            # Gaussian, fixed (sig_g used in figures)
_, logp_gfix_t = garch_predict(r, TRAIN, "t")                     # Student-t, fixed
sig_g_roll, logp_groll_n = garch_predict(r, TRAIN, "normal", 20)  # Gaussian, rolling
_, logp_groll_t = garch_predict(r, TRAIN, "t", 20)                # Student-t, rolling

logL_adapt_win = logE_steps[N - 1] - logE_steps[TRAIN - 1]        # bayesloop Gaussian, same window
logL_static_win = logE_steps_static[N - 1] - logE_steps_static[TRAIN - 1]
eval_window_days = int(N - TRAIN)
predictiveLL_log10_window = {
    "bayesloop_gaussian": _l10(logL_adapt_win),
    "bayesloop_studentt": _l10(logE_t_win),
    "GARCH_gaussian_fixed": _l10(np.nansum(logp_gfix_n[TRAIN:])),
    "GARCH_gaussian_rolling": _l10(np.nansum(logp_groll_n[TRAIN:])),
    "GARCH_studentt_fixed": _l10(np.nansum(logp_gfix_t[TRAIN:])),
    "GARCH_studentt_rolling": _l10(np.nansum(logp_groll_t[TRAIN:])),
    "constant_vol": _l10(logL_static_win),
}
logL_garch = float(np.nansum(logp_gfix_n[TRAIN:]))               # legacy aliases (figures/prints)
logL_garch_roll = float(np.nansum(logp_groll_n[TRAIN:]))
logp_g_roll = logp_groll_n
# Within the matched Gaussian family, bayesloop beats GARCH; allowing fat tails, Student-t GARCH leads.
bayesloop_minus_gaussianGARCH_rolling_log10 = _l10(logL_adapt_win - logL_garch_roll)
best_model_overall = max(predictiveLL_log10_window, key=predictiveLL_log10_window.get)
studenttGARCH_minus_bayesloop_log10 = (
    predictiveLL_log10_window["GARCH_studentt_rolling"]
    - predictiveLL_log10_window["bayesloop_gaussian"])

Validation: forecasts, realized volatility, and shock dates

Before reading off the scoreboard, we run a few checks on what bayesloop’s online σ represents. We correlate it with (a) the next day’s absolute return, to see whether it forecasts the size of future moves, (b) an independent 21-day rolling realized-volatility estimate, to see whether it recovers the volatility path, and (c) GARCH’s own conditional volatility, to see whether the two methods agree on the trajectory. We also pull out the dozen days with the highest real-time p(turbulent), which are the days bayesloop flags as shocks.

[12]:
# One-day-ahead volatility forecast vs realized; validation vs rolling realized vol
off = len(r) - len(vol_mean)
idx = np.arange(len(vol_mean)) + off
realized = pd.Series(np.abs(r)).rolling(21).std().values * np.sqrt(1)  # ~daily realized vol proxy
roll = pd.Series(r).rolling(21).std().values
# forecast: predict |r_{t+1}| scale by filtered sigma_t; correlation with realized future move
fc = vol_mean[:-1]
fut_absret = np.abs(r[idx[1:]])
corr_volforecast_futuremove = float(np.corrcoef(fc, fut_absret)[0, 1])
corr_inferred_vs_realized21d = float(
    np.corrcoef(vol_mean[~np.isnan(roll[idx])], roll[idx][~np.isnan(roll[idx])])[0, 1])
# how closely bayesloop's online volatility agrees with GARCH's (nan-robust)
_sg = sig_g[idx]
_ok = ~np.isnan(_sg) & ~np.isnan(vol_mean)
corr_bayesloop_vs_garch_vol = float(np.corrcoef(vol_mean[_ok], _sg[_ok])[0, 1])

# Real-time shock detection: dates where p(turbulent) is highest
top = np.argsort(p_turbulent)[::-1][:12]
shock_dates = sorted({pd.Timestamp(dates[idx[i]]).date().isoformat() for i in top})

The scoreboard

The out-of-sample comparison over the matched 2,012-day window is shown below. Higher (less negative) is better.

[13]:
print(f"Out-of-sample one-step predictive log10-likelihood over {N-TRAIN} days [matched window]:")
pw = predictiveLL_log10_window
for k in ["bayesloop_gaussian", "bayesloop_studentt", "GARCH_gaussian_rolling",
          "GARCH_gaussian_fixed", "GARCH_studentt_rolling", "GARCH_studentt_fixed", "constant_vol"]:
    print(f"  {k:24s} {pw[k]:9.1f}")
print(f"  within Gaussian: bayesloop - rolling GARCH = {bayesloop_minus_gaussianGARCH_rolling_log10:+.1f} log10 (bayesloop wins)")
print(f"  best overall: {best_model_overall}  "
      f"(Student-t GARCH beats Gaussian bayesloop by {studenttGARCH_minus_bayesloop_log10:+.1f} log10)")
print(f"  Gaussian-bayesloop robustness to log10p_min: {bayesloop_gaussian_logL_by_log10pmin}")
print(f"  bayesloop vol vs GARCH vol corr: {corr_bayesloop_vs_garch_vol:.2f}")
print(f"Inferred vol vs 21d realized vol corr: {corr_inferred_vs_realized21d:.2f}")
print(f"Vol forecast vs next-day |return| corr: {corr_volforecast_futuremove:.2f}")
print(f"Top real-time turbulence dates: {shock_dates}")
Out-of-sample one-step predictive log10-likelihood over 2012 days [matched window]:
  bayesloop_gaussian         -1210.0
  bayesloop_studentt         -1212.8
  GARCH_gaussian_rolling     -1216.6
  GARCH_gaussian_fixed       -1245.9
  GARCH_studentt_rolling     -1186.5
  GARCH_studentt_fixed       -1212.6
  constant_vol               -1463.9
  within Gaussian: bayesloop - rolling GARCH = +6.6 log10 (bayesloop wins)
  best overall: GARCH_studentt_rolling  (Student-t GARCH beats Gaussian bayesloop by +23.4 log10)
  Gaussian-bayesloop robustness to log10p_min: {'-5.0': -1209.9572568360986, '-4.0': -1209.9572568360986, '-3.0': -1209.9572568360986}
  bayesloop vol vs GARCH vol corr: 0.88
Inferred vol vs 21d realized vol corr: 0.93
Vol forecast vs next-day |return| corr: 0.49
Top real-time turbulence dates: ['2018-02-05', '2018-03-22', '2019-04-23', '2020-02-27', '2020-03-09', '2020-03-12', '2020-03-16', '2023-04-13', '2023-04-25', '2024-10-31', '2025-04-04', '2025-04-09']

Two readings of this table follow.

  • Within the Gaussian family, which is the matched comparison, bayesloop wins. Its online model (−1210.0) beats the fully adaptive rolling-refit Gaussian GARCH (−1216.6) by +6.6 log₁₀, and the fixed fit by +36. The margin is robust: it is identical to one decimal across regime-switch settings log10p_min {−3, −4, −5}. The edge comes from the regime-switch reacting to abrupt jumps (the COVID crash, the 2025 selloff) faster than a smooth GARCH variance recursion can.

  • Allowing fat tails changes the ranking. A Student-t GARCH (−1186.5) is the best raw predictor overall, beating Gaussian bayesloop by +23.4 log₁₀, since daily equity returns are heavy-tailed and a Student-t innovation pays off on crash days. A fat-tailed bayesloop (Student-t white noise, ν = 5) does not improve on the Gaussian one (−1212.8 vs −1210.0): bayesloop already absorbs the big moves through its regime-switch, a volatility scale jump, so adding distributional fat tails on top is redundant. The two methods handle tails by different mechanisms, and that null result is itself a finding.

The methods also agree on the volatility path: the correlation between bayesloop’s online σ and Gaussian GARCH’s σ is ≈ 0.88, and bayesloop’s σ tracks the independent 21-day realized volatility at r ≈ 0.93.

Figure 1: the regime signal in context

The figure shows the S&P 500 level (top), the returns with the inferred ±2σ envelope (middle), and the real-time p(turbulent) alarm (bottom). The envelope follows the market, staying tight in calm years and widening in the COVID crash, and the regime signal spikes at the historical shocks.

[14]:
dts = df["date"].values
fig, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=(9.4, 6.6), sharex=True)
ax1.semilogy(dts, df["px"].values, color=C["data"], lw=1.0)
ax1.set_ylabel("S&P 500")
ax1.set_title("Online volatility-regime detection & risk forecasting on the S&P 500")

ax2.plot(dts, r, color=C["muted"], lw=0.4, alpha=0.8)
ax2.plot(dts[off:], 2 * vol_mean, color=C["accent"], lw=1.3, label="±2σ (inferred vol)")
ax2.plot(dts[off:], -2 * vol_mean, color=C["accent"], lw=1.3)
ax2.set_ylabel("daily return (%)")
ax2.set_ylim(-13, 11)
ax2.legend(loc="lower left", fontsize=8)

ax3.fill_between(dts[off:], 0, p_turbulent, color=C["accent"], alpha=0.55)
ax3.set_ylabel("p(turbulent)")
ax3.set_xlabel("date")
ax3.set_ylim(-0.03, 1.05)
for lab, d in [("Volmageddon", "2018-02-05"), ("COVID crash", "2020-03-16"),
               ("2022 bear", "2022-05-01"), ("2025", "2025-04-07")]:
    dd = np.datetime64(d)
    if dts[0] <= dd <= dts[-1]:
        ax3.axvline(dd, color=C["accent2"], ls=":", lw=1, alpha=0.7)
        ax3.text(dd, 1.0, lab, fontsize=7, ha="center", va="top", color=C["accent2"])
plt.show()
../_images/examples_marketvolatility_28_0.png

Figure 2: the out-of-sample race and the volatility paths

Left: the cumulative one-step predictive log₁₀-likelihood on the matched window, that is, the scoreboard above drawn as it accrues through time. Within the Gaussian family bayesloop (red) leads the rolling Gaussian GARCH (blue) and the constant-volatility baseline (grey); allowing fat tails, the Student-t GARCH (green) pulls ahead overall, mostly by gaining extra likelihood on the crash days. Right: bayesloop’s online σ, GARCH’s σ, and the 21-day realized volatility, three independent estimates of the same path, in close agreement.

[15]:
# cumulative one-step predictive log10-likelihood on the matched window [TRAIN:]:
# within the Gaussian family bayesloop leads GARCH; allowing fat tails, Student-t GARCH leads.
cum_a = (np.array(logE_steps) - logE_steps[TRAIN - 1]) / np.log(10)
cum_t = (np.array(logE_t_steps) - logE_t_steps[TRAIN - 1]) / np.log(10)
cum_c = (np.array(logE_steps_static) - logE_steps_static[TRAIN - 1]) / np.log(10)
cum_groll_n = np.cumsum(logp_groll_n[TRAIN:]) / np.log(10)
cum_groll_t = np.cumsum(logp_groll_t[TRAIN:]) / np.log(10)
fig, (axa, axb) = plt.subplots(1, 2, figsize=(9.8, 3.7))
axa.plot(dts[TRAIN:], cum_groll_t, color=C["green"], lw=2.2, label="GARCH-t rolling (best)")
axa.plot(dts[TRAIN:], cum_a[TRAIN:], color=C["accent"], lw=2.0, label="bayesloop Gaussian")
axa.plot(dts[TRAIN:], cum_t[TRAIN:], color=C["accent"], lw=1.3, ls=":", alpha=0.85, label="bayesloop Student-t")
axa.plot(dts[TRAIN:], cum_groll_n, color=C["accent2"], lw=1.6, label="GARCH Gaussian rolling")
axa.plot(dts[TRAIN:], cum_c[TRAIN:], color=C["muted"], lw=1.4, ls="--", label="constant vol")
axa.set_ylabel(r"cumulative $\log_{10}$ predictive L")
axa.set_xlabel("date")
axa.set_title("Out-of-sample prediction (matched likelihood families)")
axa.legend(fontsize=7, loc="lower left")
# inferred vol: bayesloop vs GARCH vs realized
axb.plot(dts[off:], vol_mean, color=C["accent"], lw=1.4, label="bayesloop σ (online)")
axb.plot(dts, sig_g, color=C["accent2"], lw=1.0, alpha=0.8, label="GARCH(1,1) σ")
axb.plot(dts, roll, color="k", lw=0.9, alpha=0.5, label="21-day realized")
axb.set_ylabel("daily volatility (%)")
axb.set_xlabel("date")
axb.set_title(f"Vol agrees: bayesloop–GARCH r={corr_bayesloop_vs_garch_vol:.2f}")
axb.legend(fontsize=8)
plt.show()
../_images/examples_marketvolatility_30_0.png

What this example showed

Running a single bayesloop OnlineStudy over a decade of S&P 500 returns, we:

  • forecast risk in real time with bl.om.WhiteNoise, a time-varying-volatility model that, prequentially, predicts returns much better than assuming volatility constant (a gain of ~300 log₁₀ over the whole stream);

  • beat the standard GARCH within a matched likelihood: in a causal, out-of-sample comparison, bayesloop’s Gaussian online model tops the fully adaptive rolling-refit Gaussian GARCH by +6.6 log₁₀, a margin robust to the regime-switch setting, while recovering the same volatility path (r ≈ 0.88 with GARCH, r ≈ 0.93 with realized vol);

  • detected every major shock as it happened, via real-time model selection between a calm GaussianRandomWalk and a turbulent RegimeSwitch: the live p(turbulent) signal fires on Volmageddon, the COVID crash, and the 2025 selloff;

  • and remained clear about the limits: once fat tails are allowed, a Student-t GARCH is the strongest raw predictor (−1186.5), and a fat-tailed bayesloop does not help, because the regime-switch already absorbs the tails. bayesloop’s edge is competitive accuracy with more information (a regime probability and a full posterior over volatility, neither of which GARCH provides) and fewer parametric commitments (drift or jump, chosen online), rather than an unconditional advantage in predictive likelihood.