Portfolio & Market Risk

Backtest a VaR Model Against Realised PnL

TeaVM Java simulates realised P&L from the forecast-implied volatility, applies the optional early stress window, and computes Kupiec unconditional coverage and Christoffersen independence and conditional coverage statistics.

VaR forecast
Realised market (how well the forecast held up)Optional
1.0 = model well-calibrated
days of extra-stressed vol, from day 0

The seeded series uses Java's Random generator; seed, forecast and stress settings are visible in the source.

Java source
VarBacktestRiskStudio.java

This exact source runs in TeaVM. Form changes update its Java literals and reset manual edits.

import java.util.Random;

public final class VarBacktestRiskStudio {
  public static void main(String[] args) {
    double forecast = 2000000, alpha = 99 / 100.0, volMult = 1, clusterMult = 2;
    int n = 500, stressDays = 0;
    long seed = 7L;
    double sigma = forecast / inverseNormal(alpha);
    Random rng = new Random(seed);
    boolean[] hit = new boolean[n];
    int x = 0;
    for (int t = 0; t < n; t++) {
      double scale = sigma * volMult * (t < stressDays ? clusterMult : 1.0);
      double pnl = scale * rng.nextGaussian();
      hit[t] = -pnl > forecast;
      if (hit[t]) x++;
    }
    double kupiec = pof(n, x, 1 - alpha), independence = independence(hit), cc = kupiec + independence;
    System.out.println("RESULT|" + n + "|" + x + "|" + (n * (1 - alpha)) + "|" + kupiec + "|" + chi1(kupiec)
        + "|" + independence + "|" + chi1(independence) + "|" + cc + "|" + Math.exp(-0.5 * cc));
  }
  static double pof(int n, int x, double p) {
    if (x == 0) return -2.0 * n * Math.log(1 - p);
    if (x == n) return -2.0 * n * Math.log(p);
    double q = (double) x / n;
    return -2 * ((n - x) * Math.log(1 - p) + x * Math.log(p) - (n - x) * Math.log(1 - q) - x * Math.log(q));
  }
  static double xl(int n, double p) {
    return n == 0 ? 0 : n * Math.log(p);
  }
  static double independence(boolean[] h) {
    int n00 = 0, n01 = 0, n10 = 0, n11 = 0;
    for (int t = 1; t < h.length; t++) {
      if (!h[t - 1] && !h[t]) n00++;
      else if (!h[t - 1]) n01++;
      else if (!h[t]) n10++;
      else n11++;
    }
    int n0 = n00 + n01, n1 = n10 + n11, total = n0 + n1, ones = n01 + n11;
    if (total == 0 || ones == 0 || ones == total) return 0;
    double pi = (double) ones / total, pi0 = n0 == 0 ? 0 : (double) n01 / n0, pi1 = n1 == 0 ? 0 : (double) n11 / n1;
    double pooled = xl(ones, pi) + xl(total - ones, 1 - pi), split = xl(n01, pi0) + xl(n00, 1 - pi0)
        + xl(n11, pi1) + xl(n10, 1 - pi1);
    return Math.max(0, -2 * (pooled - split));
  }
  static double chi1(double s) {
    return s <= 0 ? 1 : Math.max(0, Math.min(1, 2 * (1 - cdf(Math.sqrt(s)))));
  }
  static double cdf(double x) {
    double z = Math.abs(x), t = 1 / (1 + 0.2316419 * z), d = 0.3989422804014327 * Math.exp(-z * z / 2),
        p = d * t * (0.319381530 + t * (-0.356563782 + t * (1.781477937 + t * (-1.821255978 + t * 1.330274429))));
    return x >= 0 ? 1 - p : p;
  }
  static double inverseNormal(double p) {
    double[] a = {
      -39.69683028665376, 220.9460984245205, -275.9285104469687, 138.3577518672690, -30.66479806614716,
          2.506628277459239
    }, b = {
      -54.47609879822406, 161.5858368580409, -155.6989798598866, 66.80131188771972, -13.28068155288572
    }, c = {
      -0.007784894002430293, -0.3223964580411365, -2.400758277161838, -2.549732539343734, 4.374664141464968,
          2.938163982698783
    }, d = {
      0.007784695709041462, 0.3224671290700398, 2.445134137142996, 3.754408661907416
    };
    double q, r, x;
    if (p < 0.02425) {
      q = Math.sqrt(-2 * Math.log(p));
      x = (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ((((d[0] * q + d[1]) * q
          + d[2]) * q + d[3]) * q + 1);
    } else if (p <= 0.97575) {
      q = p - 0.5;
      r = q * q;
      x = (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q / (((((b[0] * r
          + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1);
    } else {
      q = Math.sqrt(-2 * Math.log(1 - p));
      x = -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ((((d[0] * q + d[1]) * q
          + d[2]) * q + d[3]) * q + 1);
    }
    return x;
  }
}
TeaVM compiles and runs the Java source above in this browser.
Implementation guide

Testing whether VaR exceptions behave as expected

Backtesting assesses whether realised P&amp;L breaches a VaR forecast at the expected frequency and without suspicious clustering.

Core mechanism

The Kupiec test focuses on unconditional coverage; the Christoffersen framework adds a test of independence. Together they distinguish too many exceptions from a pattern that suggests changing risk dynamics.

Practical workflow

Freeze the forecast and realised-P&amp;L definitions, maintain a complete exception record, test at a sufficient sample size, and investigate events rather than treating p-values as an automatic verdict.

Key details

A VaR backtest compares a forecast loss threshold with realised P&L over a sequence of dates. An exception occurs when the realised loss is worse than the forecast at the stated confidence level. The sign convention must be fixed first: this project reports VaR as a positive loss, so realised P&L is converted consistently before comparison.

The Kupiec proportion-of-failures test asks whether the observed exception rate is consistent with the nominal tail probability. It does not ask whether exceptions arrive independently. A model can have roughly the right number of exceptions while still failing when breaches cluster during changing volatility or correlation regimes.

The Christoffersen independence test uses the transition pattern between exception and non-exception days. Combining it with the coverage test produces a conditional-coverage diagnostic: both the frequency and time pattern must be plausible under the forecast model.

The result should be read with sample size in mind. At high confidence levels, exceptions are deliberately rare, so a short history has low statistical power. A p-value is evidence about a precisely defined null hypothesis, not an automatic model-approval decision.

The implementation exposes the exception count and the one- and two-degree-of-freedom chi-square survival probabilities used by these tests. A useful review also examines the individual exception dates, P&L attribution, data revisions and whether the VaR and realised-P&L horizons actually match.

Scope and review point

Statistical tests have low power on short samples. A validation process also needs P&amp;L attribution, data-quality controls and qualitative review.