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.
Calculate exception counts and p-values for Kupiec coverage, Christoffersen independence and joint conditional coverage using reproducible simulated profit and loss (P&L).
Set a positive one-day Value at Risk (VaR) forecast, confidence level, sample length and seed. An exception occurs when the simulated loss exceeds the forecast. Increase realised volatility or add an initial stress window to explore a misspecified forecast.
The results show whether the exception rate matches the forecast (Kupiec), whether exceptions depend on the previous day (Christoffersen independence), and whether those two conditions hold jointly (conditional coverage). See the worked example and p-value interpretation.
The controls generate a zero-mean Gaussian P&L simulation; they do not upload historical P&L or a daily forecast series. The default results are precomputed. After changing inputs, choose “Run the backtest” to calculate new results.
The seeded series uses Java's Random generator; seed, forecast and stress settings are visible in the source.
This exact source runs in TeaVM. Form changes update its Java literals and reset manual edits.
import com.nablatensor.quant.analytic.Normal;
import java.util.Random;
public final class VarBacktestRiskStudio {
// Java demo using nablatensor normal-distribution helpers: simulate P&L, then test exception frequency and timing.
public static void main(String[] args) {
// VaR is a positive loss threshold; alpha is its confidence as a probability.
double forecast = 2000000, alpha = 99 / 100.0;
// Multipliers control realised volatility and the extra volatility in the initial stress window.
double volMult = 1, clusterMult = 2;
int n = 500, stressDays = 0;
// A fixed seed makes this constructed simulation reproducible.
long seed = 7L;
// For zero-mean Gaussian P&L, VaR = sigma * Phi^-1(alpha).
// Normal.inverseCdf(0.99) is about 2.32635: the standard-normal 99th percentile.
double sigma = forecast / Normal.inverseCdf(alpha);
Random rng = new Random(seed);
// hit[t] is true when day t's loss exceeds the forecast; x counts these exceptions.
boolean[] hit = new boolean[n];
int x = 0;
for (int t = 0; t < n; t++) {
// Stress affects the first stressDays observations; afterwards use ordinary realised volatility.
double scale = sigma * volMult * (t < stressDays ? clusterMult : 1.0);
// nextGaussian draws a standard normal; positive P&L is a gain, negative P&L a loss.
double pnl = scale * rng.nextGaussian();
hit[t] = -pnl > forecast;
if (hit[t]) x++;
// Return this run's daily loss, forecast, exception flag and expected running count for the graphs.
// Display days from 1; array indices still start at 0.
System.out.println("DAY|" + (t + 1) + "|" + (-pnl) + "|" + forecast + "|" + (hit[t] ? 1 : 0)
+ "|" + ((t + 1) * (1 - alpha)));
}
// Kupiec checks the exception rate against the forecast probability 1-alpha.
double kupiec = pof(n, x, 1 - alpha);
// Christoffersen checks whether exception probability depends on the previous day's outcome.
double independence = independence(hit);
// Conditional coverage combines the frequency and independence likelihood-ratio statistics.
double cc = kupiec + independence;
// Output: days, exceptions, expected exceptions, then each statistic and its p-value.
// The combined test uses two chi-square degrees of freedom: survival = exp(-cc/2).
System.out.println("RESULT|" + n + "|" + x + "|" + (n * (1 - alpha)) + "|" + kupiec + "|" + chi1(kupiec)
+ "|" + independence + "|" + chi1(independence) + "|" + cc + "|" + Math.exp(-0.5 * cc));
}
// Proportion-of-failures test: compare the forecast rate p with the fitted observed rate q.
static double pof(int n, int x, double p) {
// Boundary cases avoid evaluating log(0) multiplied by zero.
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;
// -2 * (forecast log-likelihood - fitted log-likelihood).
return -2 * ((n - x) * Math.log(1 - p) + x * Math.log(p) - (n - x) * Math.log(1 - q) - x * Math.log(q));
}
// A count-weighted log probability; an unobserved event contributes zero even if p=0.
static double xl(int n, double p) {
return n == 0 ? 0 : n * Math.log(p);
}
// First-order independence test over adjacent days, using 0=no exception and 1=exception.
static double independence(boolean[] h) {
// nij counts transitions from yesterday's state i to today's state j.
int n00 = 0, n01 = 0, n10 = 0, n11 = 0;
// Start at day 1 because day 0 has no preceding observation in this sample.
for (int t = 1; t < h.length; t++) {
// 0 -> 0: two consecutive days without an exception.
if (!h[t - 1] && !h[t]) n00++;
// 0 -> 1: an exception following a day without one.
else if (!h[t - 1]) n01++;
// 1 -> 0: a non-exception following an exception.
else if (!h[t]) n10++;
// 1 -> 1: consecutive exceptions (one indication of clustering).
else n11++;
}
// Row totals: how many transitions began in state 0 and state 1.
int n0 = n00 + n01;
int n1 = n10 + n11;
// There are h.length-1 transitions; ones counts exceptions at their destinations.
int total = n0 + n1;
int ones = n01 + n11;
// No transitions, or constant destinations: both fitted likelihoods agree, so LR=0.
// This convention does not establish independence; a sparse sample offers little evidence.
if (total == 0 || ones == 0 || ones == total) return 0;
// Null model: one common exception probability, independent of yesterday's state.
double pi = (double) ones / total;
// Alternative model: separate exception probabilities after state 0 and state 1.
// An empty row uses 0 as a placeholder; xl makes all its contributions zero.
double pi0 = n0 == 0 ? 0 : (double) n01 / n0;
double pi1 = n1 == 0 ? 0 : (double) n11 / n1;
// Null log-likelihood: all destination exceptions/non-exceptions share the pooled rate.
double pooled = xl(ones, pi) + xl(total - ones, 1 - pi);
// Alternative log-likelihood: each transition uses its row's fitted probability.
double split = xl(n01, pi0) + xl(n00, 1 - pi0) + xl(n11, pi1) + xl(n10, 1 - pi1);
// Larger LR means stronger evidence against independence; clamp round-off below zero.
return Math.max(0, -2 * (pooled - split));
}
// Approximate asymptotic p-value for a chi-square statistic with one degree of freedom.
// If Z is standard normal, Z^2 is chi-square(1): P(Z^2>s)=2*(1-Phi(sqrt(s))).
// Small samples and rare exceptions can make this asymptotic calibration unreliable.
// Normal.cdf(x) returns P(Z <= x); nablatensor owns the numerical approximation.
static double chi1(double s) {
return s <= 0 ? 1 : Math.max(0, Math.min(1, 2 * (1 - Normal.cdf(Math.sqrt(s)))));
}
}
Results from a precomputed Java run.
Simulated losses in the forecast’s monetary units; negative losses are gains. Larger dots mark exceptions above the dashed VaR line. Hover over a dot for its day and loss.
Solid steps count observed exceptions; the dashed line shows the expected count under the forecast confidence. This is a reference expectation, not a confidence band or a separate test.
End of window: 4 observed / 5.00 expected.
Use a one-day VaR forecast of 2,000,000, confidence 99%, backtest window 500 days, seed 7, realised volatility multiplier 1, stress window 0 days and stress multiplier 2 (inactive with no stress days). These are the default inputs with the unedited Java source. The seed fixes this constructed simulation; these are not observed market losses.
The expected count is 500 × (1 − 0.99) = 5 exceptions. The default Java run produces 4, or 0.8% of observations.
| Test | Statistic | p-value |
|---|---|---|
| Kupiec coverage | 0.217 | 0.641 |
| Christoffersen independence | 0.065 | 0.799 |
| Conditional coverage | 0.282 | 0.869 |
At a 5% significance threshold, a p-value below 0.05 rejects the corresponding null hypothesis. None of these three tests rejects in this example. Kupiec checks frequency, independence checks the adjacent-day pattern, and conditional coverage adds their statistics, using a chi-square reference distribution with two degrees of freedom.
A large p-value does not prove the model is correct. With only four exceptions, evidence about clustering is sparse and the asymptotic p-values need caution. These tests do not implement Basel traffic-light zones or replace validation against independent market data.
For the mechanics, read VaR backtesting with Kupiec and Christoffersen tests. For two sequences with the same exception count but different timing, see why Kupiec can pass while Christoffersen rejects. To explore the forecast itself, use the VaR and Expected Shortfall calculator.
Backtesting assesses whether realised P&L breaches a VaR forecast at the expected frequency and without suspicious clustering.
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.
Freeze the forecast and realised-P&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.
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.
Statistical tests have low power on short samples. A validation process also needs P&L attribution, data-quality controls and qualitative review.