Challenges · C07 · ★★★★☆

Stochastic volatility: a Value-at-Risk number you can defend

Difficulty
★★★★☆
Time
4-5 hours
Data
S&P 500 daily closes and log-returns, May 2008 - December 2013
Skills
Designing posterior predictive checks with your own test statistics · heavy-tailed likelihoods · a latent state per observation (random-walk log-volatility) · centred vs non-centred time series · reading low ESS and divergences instead of ignoring them · one-step-ahead forecasts from a state-space model · sequential updating without refitting · back-testing a risk number

The brief

You have joined the market-risk desk of a small asset manager. Every evening the desk publishes a one-day 99% Value-at-Risk (VaR) for its S&P 500 book: the loss that tomorrow's return should exceed on only 1 day in 100. Capital is held against that number, and the regulator back-tests it the simple way - by counting the days on which the loss was worse than the VaR.

The desk's current model is one line: daily returns are Normal with a constant mean and a constant volatility, estimated from history. The head of risk suspects it is wrong in two ways at once - "too relaxed the week everything falls apart, and far too scared for years afterwards" - and asks you for a replacement, plus the evidence that it is better.

You will build the model on data up to 30 June 2011 and be judged on the 2.5 years that follow, which the model never sees during fitting.

How this notebook works

  • Each task states what to deliver, not how. Write your code in the YOUR CODE HERE cells.
  • Stuck? h.hint("task3") reveals hints one level at a time: nudge → approach → code skeleton. Try to get by on nudges.
  • h.check("task3", vol_of_vol=...) compares your numbers with the reference solution.
  • A full worked solution lives in notebooks/solutions/. Open it only when you are done (or truly stuck).
import logging

import arviz as az
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import pymc as pm
import pytensor.tensor as pt
from scipy import stats

from pymc_challenges import Hints, data

# PyTensor 3.3 logs a long but harmless "Rewrite failure" traceback when it compiles
# pm.GaussianRandomWalk with more than 127 steps. The model is unaffected; keep the log quiet.
logging.getLogger("pytensor.graph.rewriting.basic").setLevel(logging.CRITICAL)

RANDOM_SEED = 2008
rng = np.random.default_rng(RANDOM_SEED)
az.style.use("arviz-variat")

h = Hints("C07")
h.tasks()

C07 - Stochastic volatility: a Value-at-Risk number you can defend

  • task0 - What do returns look like? (3 hints, 2 check(s))
  • task1 - The desk's model and checks that expose it (3 hints, 2 check(s))
  • task2 - Fat tails (3 hints, 1 check(s))
  • task3 - Latent log-volatility and a sampler in trouble (3 hints, 2 check(s))
  • task4 - Does the volatility path make sense? (3 hints, 2 check(s))
  • task5 - Tomorrow's VaR (3 hints, 2 check(s))
  • task6 - Back-test and recommend (3 hints, 2 check(s))

The data

change is the daily log-return of the index. Returns are easier to read (and to put priors on) in percent, so that is the unit used everywhere below, including in the checks: ret = 100 * change.

The file runs to 2019. We use only the first 5.5 years: a latent volatility per trading day makes the model grow with the data, and 800 training days keep each fit to about a minute. train is all you may fit on; test is for Tasks 5-6 only.

data.describe("sp500")
sp500 = data.load("sp500")
sp500["ret"] = 100 * sp500["change"]

train = sp500.loc[:"2011-06-30"]
test = sp500.loc["2011-07-01":"2013-12-31"]
print(f"train: {train.index[0].date()} to {train.index[-1].date()}, {len(train)} days")
print(f"test:  {test.index[0].date()} to {test.index[-1].date()}, {len(test)} days")
train.head()
sp500: Daily close and log-return ('change').
  source: S&P 500 index daily closes (2008-2019), via pymc-examples.
  url:    https://raw.githubusercontent.com/pymc-devs/pymc-examples/main/examples/data/SP500.csv
train: 2008-05-02 to 2011-06-30, 798 days
test:  2011-07-01 to 2013-12-31, 629 days
Close change ret
Date
2008-05-02 1413.900024 0.003230 0.323038
2008-05-05 1407.489990 -0.004544 -0.454389
2008-05-06 1418.260010 0.007623 0.762281
2008-05-07 1392.569946 -0.018280 -1.827985
2008-05-08 1397.680054 0.003663 0.366284

Task 0 · What do returns look like?

Deliver (on train) the evidence for or against each half of the desk's assumption:

  1. Normal: compare the distribution of returns with a Normal of the same mean and standard deviation, in a way that shows the tails. Report the excess kurtosis.
  2. Constant volatility: plot the autocorrelation function of the returns and of their absolute values, side by side. What does each say?
Your turn Work this task in the notebook.

Task 1 · The desk's model, and checks that expose it

Fit the desk's model: $r_t \sim \text{Normal}(\mu, \sigma)$, both constant.

Deliver

  1. The fit, with priors that make sense for daily returns in percent.
  2. A posterior predictive check built on test statistics you choose yourself - at least three, each aimed at something a VaR number depends on. For each one: the distribution of the statistic over replicated datasets, the observed value, and the posterior predictive p-value $P(T(y^\text{rep}) \ge T(y))$.
  3. One sentence per statistic: what does the model get wrong, and would a density overlay (az.plot_ppc_dist) have shown it?
Your turn Work this task in the notebook.

Task 2 · Fat tails

The textbook fix for fat tails is a Student-t likelihood. Keep $\mu$ and the scale constant.

Deliver the fit, the same predictive checks as in Task 1, and a verdict: which failures are fixed, which are not, and is any statistic now wrong in the other direction? Explain what the posterior of the degrees of freedom $\nu$ is telling you.

Your turn Work this task in the notebook.

Task 3 · Let volatility move

Model the log-volatility as a latent random walk: each day has its own volatility, tomorrow's is today's plus a small shock, and returns are Student-t around $\mu$ with that day's scale. That is one latent variable per trading day - about 800 parameters.

Deliver

  1. The model, with a prior on the random walk's innovation scale (the "vol of vol") that you can defend: how much can volatility plausibly change in one day?
  2. Your first fit and an honest reading of its diagnostics. Do not stop at "no divergences": check r_hat and ESS of every parameter, and find which one is in trouble.
  3. A diagnosis (why is this posterior hard to sample?), a fix, and a final fit with clean diagnostics. If a fix trades one problem for another, show that too, and show that your final answer did not change with the sampler settings.

Budget: a fit takes about a minute. If you need more than three or four, think rather than wait.

Your turn Work this task in the notebook.

Task 4 · Does the volatility path make sense?

Deliver

  1. A plot of the posterior volatility path $e^{v_t}$ (mean and a credible band) over the training period, on top of the absolute returns, with the dates you would expect to matter marked - at least the Lehman bankruptcy (15 Sep 2008) and the "flash crash" (6 May 2010). Report the posterior mean of $e^{v_t}$ on its peak day and on the last training day.
  2. The predictive checks of Task 1 for this model. Then think about what that check does and does not prove here, given that the replications reuse the fitted path, and add a check that does not have this weakness.
Your turn Work this task in the notebook.

Task 5 · Tomorrow's number

It is the evening of 30 June 2011, the last training day.

Deliver the one-day-ahead 99% VaR for 1 July 2011 - the 1% quantile of the posterior predictive distribution of tomorrow's return, in percent (a negative number) - under the desk's model and under yours. Tomorrow's volatility is not in your posterior: get it there. Propagate all the uncertainty (parameters, today's volatility, tomorrow's shock).

Your turn Work this task in the notebook.

Task 6 · Back-test, and recommend

Now the verdict. For every day in test you need the VaR that would have been published the evening before, using only returns up to that evening.

Deliver

  1. The daily VaR series of your model and of the desk's model over the test period, in one plot with the realised returns, exceedances marked.
  2. The number of exceedances of each against the expected 1%, when they happened, and the average VaR (a proxy for the capital the desk must hold).
  3. A recommendation to the head of risk in a short paragraph - including what your model still gets wrong.

Refitting an 800-parameter model 629 times is not an option, and fitting once on train + test would let each day's volatility estimate peek at the future. Find a way to update your belief about today's volatility day by day that is cheap and does not cheat, and state clearly which shortcut you are taking.

Your turn Work this task in the notebook.

Going further

  • Leverage. Let the shock to tomorrow's volatility be negatively correlated with today's return, or give the returns a skewed distribution. Does the lower-tail coverage in the back-test improve, and what happens to the upper tail?
  • Mean reversion. Replace the random walk by an AR(1) log-volatility, $v_t = \bar v + \phi\,(v_{t-1} - \bar v) + \sigma_v \epsilon_t$. How well is $\phi$ identified, and does it change the 10-day-ahead VaR more than the 1-day-ahead one?
  • Check the shortcut. Refit the model on all data up to the end of 2012 and compare its volatility on the last day, and its parameters, with what the particle filter carried forward from June 2011.
  • Expected shortfall. Regulators have moved from VaR to the expected loss beyond the VaR. You have the predictive draws: compute it, and think about how you would back-test it.
h.progress()

C07: 0/21 hints used, 13/13 checks passed.

Reference solution

A complete, executed solution with commentary on every task — including the ways the obvious model fails. Try the hint ladder first.

Open the worked solution →