This project backtests Value at Risk (VaR) models for a five-asset portfolio built from the earlier CFA practical projects.
The portfolio uses daily returns from AAPL, MSFT, JPM, GLD, and SPY. The baseline portfolio value is GBP 100,000. The engine compares each one-day VaR forecast with the next realized portfolio return, counts VaR exceptions, calculates the Kupiec proportion of failures test, and assigns a Basel traffic-light classification.
I started with a historical simulation VaR backtesting engine and then tested whether model changes improved calibration:
- Baseline historical VaR with a 250-trading-day lookback at 95% confidence
- Student's t VaR with fatter tails at 95% confidence
- Historical VaR with a longer 500-trading-day lookback
- Historical VaR with a 365-trading-day lookback tested at 90%, 95%, 97.5%, and 99% confidence
The goal was to see which model produced exception counts closest to the expected number of exceptions, while also checking the Basel traffic-light result.
.
├── data/
│ └── README.md
├── outputs/
│ ├── model_comparison_summary.csv
│ └── var_backtest_summary.csv
├── src/
│ ├── backtesting.py
│ ├── data_loader.py
│ ├── main.py
│ ├── model_comparison.py
│ └── var_model.py
└── tests/
└── test_backtesting.py
The source code is dependency-free and uses only the Python standard library.
Add a data/portfolio_returns.csv file with this structure:
Date,Portfolio Return
2021-01-05,0.00609736854985427
2021-01-06,-0.006077101687306279Run the baseline model:
python3 src/main.pyRun the model comparison sweep:
python3 src/model_comparison.pyRun the tests:
python3 -m unittest discover -s testsFor each model, the engine:
- Loads daily portfolio returns from CSV.
- Uses a rolling lookback window to estimate the next one-day VaR.
- Compares the next realized return with the VaR estimate.
- Flags an exception when the actual return is worse than the VaR forecast.
- Summarizes exception counts, expected exceptions, exception rate, Kupiec p-value, average VaR loss, average expected shortfall loss, and Basel traffic-light status.
The Kupiec test is used to judge statistical calibration. A higher p-value means the observed exception count is closer to what the model predicted.
| Model | Lookback | Confidence | Exceptions | Expected Exceptions | Exception Rate | Kupiec p-value | Basel |
|---|---|---|---|---|---|---|---|
| Historical VaR | 250 | 95% | 54 | 50.20 | 5.38% | 0.5865 | Red |
| Student's t VaR | 250 | 95% | 58 | 50.20 | 5.78% | 0.2698 | Red |
| Historical VaR | 500 | 95% | 20 | 37.70 | 2.65% | 0.0012 | Yellow |
| Historical VaR | 365 | 95% | 32 | 44.45 | 3.60% | 0.0442 | Yellow |
The best statistical fit at the standard 95% confidence level was the 250-day historical VaR model. Its exception count of 54 was closest to the expected 50.20 exceptions, and it had the strongest Kupiec p-value.
The Student's t model was designed to add fatter tails, but on this dataset it did not improve performance. It produced more exceptions than the baseline and a lower Kupiec p-value.
The 500-day lookback and 365-day lookback at 95% were both more conservative, but they produced too few exceptions relative to expectation. This made their Kupiec p-values weaker.
| Confidence | Exceptions | Expected Exceptions | Exception Rate | Kupiec p-value | Basel |
|---|---|---|---|---|---|
| 90% | 72 | 88.90 | 8.10% | 0.0515 | Red |
| 95% | 32 | 44.45 | 3.60% | 0.0442 | Yellow |
| 97.5% | 19 | 22.23 | 2.14% | 0.4776 | Yellow |
| 99% | 12 | 8.89 | 1.35% | 0.3196 | Green |
Within the 365-day confidence sweep, the 97.5% model had the strongest Kupiec p-value. The 99% model achieved the best Basel traffic-light result because it landed in the green zone, but it was also more conservative and produced larger average VaR losses.
The best overall model depends on the criterion:
- Best statistical fit at 95% VaR: baseline 250-day historical VaR
- Best alternate calibration tested: 365-day historical VaR at 97.5%
- Best Basel traffic-light result: 365-day historical VaR at 99%
My main conclusion is that the baseline 250-day historical VaR is the best fit for a standard 95% VaR backtest, because its observed exceptions are closest to the expected exception count. However, if the goal is to optimize Basel traffic-light treatment, the 365-day 99% model is more favorable because it achieves a green result.
This creates a useful risk-management trade-off: the statistically best-fitting model is not necessarily the model with the best regulatory traffic-light classification.