The data
The series is 1,000 daily log returns , as in case study 1.4.7. They are invented for illustration, generated with a fixed seed so that every cell gets the same numbers. Case studies 5.4.6 and 5.9.4 return to the same series.
The recipe gives them a mean of 0.04% a day, the mean daily log return that case study 1.4.7 annualised. It draws from a Student distribution with 4 degrees of freedom (lesson 5.4.4), which produces occasional very large days. Scaling by sets the volatility near 1%, because that distribution has variance 2.
A position worth at the start of a day is worth at the end, so it loses . That is times the simple return with its sign flipped. Risk totals are built from such amounts, as case study 1.4.7 advised, not from log returns.
Try it
The first lines generate the series exactly as the later case studies do. Then the size of the sample, its first five days, and the worst and best days.
The worst day, , loses $53,248 on $1,000,000. The best day is .
Four moments of a sample
Treat each of the days as one equally likely outcome, with probability . Then the sample is a distribution, and its moments are the expectations of lessons 5.3.1 to 5.3.3.
Rule: Sample moments
is the mean, the volatility, and counts volatilities from the mean (lesson 5.3.2). These are SciPy's defaults (lesson 5.3.3); its kurtosis reports kurt , the excess.
| moment | sample | a normal |
|---|---|---|
| mean | (matched) | |
| volatility | 1.0073% | (matched) |
| skewness | 0 | |
| excess kurtosis | 5.2899 | 0 |
The volatility is close to the 1% of the recipe. The skewness is mildly negative, and the excess kurtosis is far from a normal's 0: the tails are fat.
Try it
NumPy gives the mean and the volatility, scipy.stats the skewness and the excess kurtosis. np.std divides by , as the rule does.
Dividing by instead, with ddof=1, changes the volatility only in the fourth figure, to 1.0078%. Lesson 5.5.2 defines that version, and module 5.8 explains why it exists. The plain kurtosis is 8.2899.
The mean is the least certain number
The recipe's mean is a day, yet the sample's is . That is not an error: 1,000 days cannot pin a mean down closely.
Lesson 5.3.2's rules say how closely. The sample mean is . If the days are independent, the variances of the terms add, and the factor is squared. So the variance of the mean is .
With the recipe's volatility and , the mean's standard deviation is . The sample mean sits 1.46 of these below 0.04%, an ordinary miss. Lessons 5.7.2 and 5.8.2 build on this.
Try it
The standard deviation of a 1,000-day mean, in percent, then how many of them separate the sample mean from the recipe's 0.04%.
What the kurtosis says
Excess kurtosis of 5.29 is easier to read as counts. Standardise each day to and count the days far from the mean. A normal distribution has , with the area of lesson 2.6.3, so it expects such days.
| days with above | 3 | 4 | 5 |
|---|---|---|---|
| in the data | 13 | 6 | 4 |
| a normal expects | 2.7 | 0.063 | 0.00057 |
The middle is crowded too: 76.2% of days lie within one volatility of the mean, against 68.3% for a normal. Fat tails and a crowded middle come together, with fewer days in the shoulders between.
Try it
norm.sf(k) is , the normal's chance of landing above ; doubling it counts both tails.
The last lines show how few days carry the kurtosis: the 10 largest make up 72% of the sum. Without its single largest day, volatilities, the series has an excess kurtosis of 3.70.
That fragility is lesson 5.3.3's warning made concrete. Worse, the recipe's Student with 4 degrees of freedom has an infinite kurtosis. So 5.29 does not estimate any true value, and a longer sample would give a different number.
Treat a sample kurtosis as a flag for fat tails, not as a precise input.
Value at risk as a quantile
Value at risk (VaR) at the 99% level over one day is the loss that is exceeded on only 1% of days. In return terms it starts from the 1% quantile of the daily returns, : the return with 1% of days below it.
Rule: One-day value at risk
is the position's value. VaR is quoted as a positive number, a loss. Its units are dollars, so it is often given per $1,000,000 of position.
From the data
Sort the 1,000 returns. The worst ten are , , , , , , , , and ; the eleventh is . The 1% quantile lies between the tenth and the eleventh.
NumPy's np.quantile(r, 0.01) gives . The historical VaR is then , which is $26,212.
From a normal model
A normal model keeps only the mean and the volatility . If returns were normal, the chance of a day below would be (lesson 2.6.3; lesson 5.4.3 develops this). So the 1% quantile is with .
That is . SciPy finds it as norm.ppf(0.01), the inverse of norm.cdf. The quantile is , and the normal VaR is $23,221.
The normal figure is 11.4% below the historical one. It also fails its own test: it should be exceeded on about 10 of 1,000 days, and it was exceeded on 18.
Try it
The sorted returns, the two quantiles and the two VaRs per $1,000,000. np.expm1(q) computes accurately for small , like log1p in case study 1.4.7.
The last line counts the days below each quantile: 10 for the historical one, by its construction, and 18 for the normal one.
Why kurtosis makes the normal VaR too small
The normal model matches the mean and the volatility exactly. What it cannot match is the shape. The data puts more weight far out in the tails and in the middle, and less in the shoulders.
VaR at 99% sits out in the tail, where the normal has too little weight.
So the error depends on the level. The table compares the two VaRs per $1,000,000 at six confidence levels:
| level | 90% | 95% | 97.5% | 99% | 99.5% | 99.9% |
|---|---|---|---|---|---|---|
| historical | 11,156 | 15,503 | 20,449 | 26,212 | 36,289 | 52,310 |
| normal | 12,887 | 16,493 | 19,609 | 23,221 | 25,673 | 30,709 |
At 90% and 95% the normal VaR is the larger, because the shoulders it imagines are emptier in the data. The two cross near 96%. Beyond that the normal is too small, and the gap widens: at 99.9% the data says 70% more.
The returns against a normal with the same mean and volatility, and the VaR each implies. The data has a crowded middle and far tails, so its VaR is lower at 90% and higher beyond about 96%.
A model checked only at 95% can look cautious there and still understate the 99% loss. That is why fat tails matter most for exactly the numbers risk managers report.
Try it
The loop prints the table, one confidence level per line: the historical VaR, the normal VaR and their ratio.
The last lines scan levels from 90% to 99.9% and print the first and last level where the two cross. The historical curve wiggles, so it crosses a few times close together.
Expected shortfall
VaR says where the worst 1% of days begins, not how bad those days are. The expected shortfall (ES) at 99% is the average loss on the days beyond the VaR: here the 10 worst days.
For this series the 99% ES is $40,490, against a VaR of $26,212. A normal model with the same mean and volatility puts it near $26,573 (from a seeded simulation), far below the data. The Basel rules for banks' market risk use a 97.5% expected shortfall in place of 99% VaR, partly because it looks into the tail.
Try it
The historical ES averages the dollar losses on the days at or below the 1% quantile; there are 10 of them.
For the normal model, the cell draws 2,000,000 normal days with the same mean and volatility and averages the worst 1%. The result is from one seeded run.
Try it
Each line checks one desk note: the log-as-simple error, the 10th-worst convention, the first 250 days, and the normal VaR with a zero mean.
Value at risk and expected shortfall
Write three functions. Each takes a NumPy array r of daily log returns, a confidence level (default 0.99) and a position value in dollars (default 1,000,000). Each returns a positive loss in dollars.
historical_var(r, level, value): take =np.quantile(r, 1 - level), NumPy's default, and return .normal_var(r, level, value): the same, with , where =norm.ppf(1 - level)and =r.std(), dividing by .expected_shortfall(r, level, value): the average of over the days with , using the historical .
All three raise ValueError if level is not strictly between 0 and 1. They also raise it if r has fewer than days, so that no day lies in the tail.
See the tests
import numpy as np
rng = np.random.default_rng(50)
R = 0.0004 + 0.01 * rng.standard_t(4, size=1000) / np.sqrt(2)
SMALL = np.array([0.02, -0.1, 0.04, -0.05, 0.0, 0.1, -0.08, 0.01, -0.02, 0.06])
def _raises(f, *args):
try:
f(*args)
except ValueError:
return True
return False
def test_historical_var():
"""The shared returns give a 99% historical VaR of about $26,212 per $1,000,000"""
got = historical_var(R)
if got is not None and abs(got - 26561.89) < 1:
raise AssertionError("got 26,562: that treats the log return as a simple one. The loss is V(1 - e^q), np.expm1 helps")
expect_close(got, 26212.2246829505, what="historical_var(r)", rel=1e-9)
def test_normal_var():
"""A normal with the same mean and volatility gives about $23,221"""
expect_close(normal_var(R), 23221.176628094614, what="normal_var(r)", rel=1e-9)
def test_expected_shortfall():
"""The 99% expected shortfall, the average loss on the 10 worst days, is about $40,490"""
expect_close(expected_shortfall(R), 40489.60362640658, what="expected_shortfall(r)", rel=1e-9)
def test_lower_level():
"""At 95% the normal VaR, about $16,493, is above the historical one, about $15,503"""
expect_close(historical_var(R, 0.95), 15503.09856173464, what="historical_var(r, 0.95)", rel=1e-9)
expect_close(normal_var(R, 0.95), 16492.55296317398, what="normal_var(r, 0.95)", rel=1e-9)
def test_small_sample():
"""Ten returns at the 80% level: VaR about $54,461, shortfall about $86,023 from the two worst days"""
expect_close(historical_var(SMALL, 0.8), 54460.864109603746, what="historical_var(small, 0.8)", rel=1e-9)
expect_close(expected_shortfall(SMALL, 0.8), 86023.11778870232, what="expected_shortfall(small, 0.8)", rel=1e-9)
expect_close(normal_var(SMALL, 0.8), 50445.66311356407, what="normal_var(small, 0.8)", rel=1e-9)
def test_position_size():
"""A $2,500,000 position has 2.5 times the VaR: about $65,531"""
expect_close(historical_var(R, 0.99, 2_500_000), 65530.56170737624, what="historical_var(r, 0.99, 2_500_000)", rel=1e-9)
def test_bad_level():
"""A level of 0, 1 or 1.5 raises ValueError in all three functions"""
for f in (historical_var, normal_var, expected_shortfall):
for level in (0, 1, 1.5):
assert _raises(f, R, level), f"{f.__name__}(r, {level}) should raise ValueError"
def test_too_few_days():
"""50 days are too few for a 99% level: ValueError"""
for f in (historical_var, normal_var, expected_shortfall):
assert _raises(f, R[:50], 0.99), f"{f.__name__} with 50 days at 99% should raise ValueError"
Summary card
- Treat each day as equally likely and the sample moments are expectations: mean, volatility, skewness and excess kurtosis. For the shared returns: , 1.0073%, and 5.2899.
- The mean of independent days has standard deviation , about 0.03% for 1,000 days: means are the least certain moment.
- Sample kurtosis rests on a handful of days; read it as a flag for fat tails. Here 13 days lie beyond 3 volatilities, where a normal expects 2.7.
- One-day VaR at 99% is the loss beyond the 1% quantile: . Historical $26,212 against normal $23,221 per $1,000,000.
- Fat tails make the normal VaR too small at high levels, here beyond about 96%, and too large below. Expected shortfall, the average loss beyond VaR, is $40,490 here.