SigmaField
Mathematical Foundations for Quant Finance
0 of 256 lessons
      1. 5.3.1 Expectation and LOTUSConcept · 30 min
      2. 5.3.2 Variance, and linearity of expectationConcept · 35 min
      3. 5.3.3 Skewness and kurtosisConcept · 30 min
      4. 5.3.4 Practice set: expectation and momentsPractice set · 20 min
      5. 5.3.5 Case study: return moments and value at riskCase study · 35 min

Level 5, Probability and statistics 5.3 Expectation and moments Lesson 5 of 5

Case studyLesson 5.3.5 in Level 5, Probability and statistics. About 35 minutes.

Case study: return moments and value at risk

The data

The series is 1,000 daily log returns rt=ln⁡(Pt/Pt−1)r_t = \ln(P_t / P_{t-1}), 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 tt distribution with 4 degrees of freedom (lesson 5.4.4), which produces occasional very large days. Scaling by 0.012\frac{0.01}{\sqrt{2}} sets the volatility near 1%, because that tt distribution has variance 2.

A position worth VV at the start of a day is worth VerVe^{r} at the end, so it loses V(1−er)V(1 - e^{r}). That is VV 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, −5.47%-5.47\%, loses $53,248 on $1,000,000. The best day is +6.87%+6.87\%.

⌘/Ctrl + Enter runs the cell

Four moments of a sample

Treat each of the n=1,000n = 1{,}000 days as one equally likely outcome, with probability 1n\frac{1}{n}. Then the sample is a distribution, and its moments are the expectations of lessons 5.3.1 to 5.3.3.

Rule: Sample moments

rˉ=1n∑trts2=1n∑t(rt−rˉ)2zt=rt−rˉsskew=1n∑tzt3kurt=1n∑tzt4\begin{gathered} \bar{r} = \frac{1}{n}\sum_t r_t \\[4pt] s^2 = \frac{1}{n}\sum_t (r_t - \bar{r})^2 \\[4pt] z_t = \frac{r_t - \bar{r}}{s} \\[4pt] \text{skew} = \frac{1}{n}\sum_t z_t^3 \\[4pt] \text{kurt} = \frac{1}{n}\sum_t z_t^4 \end{gathered}

rˉ\bar{r} is the mean, ss the volatility, and ztz_t counts volatilities from the mean (lesson 5.3.2). These are SciPy's defaults (lesson 5.3.3); its kurtosis reports kurt −3- 3, the excess.

momentsamplea normal
mean−0.0061%-0.0061\%(matched)
volatility1.0073%(matched)
skewness−0.3085-0.30850
excess kurtosis5.28990

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 nn, as the rule does.

Dividing by n−1n - 1 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.

⌘/Ctrl + Enter runs the cell

The mean is the least certain number

The recipe's mean is +0.04%+0.04\% a day, yet the sample's is −0.0061%-0.0061\%. 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 1n(r1+⋯+rn)\frac{1}{n}(r_1 + \cdots + r_n). If the days are independent, the variances of the nn terms add, and the factor 1n\frac{1}{n} is squared. So the variance of the mean is σ2n\frac{\sigma^2}{n}.

With the recipe's volatility σ=1%\sigma = 1\% and n=1,000n = 1{,}000, the mean's standard deviation is 1%1000≈0.0316%\frac{1\%}{\sqrt{1000}} \approx 0.0316\%. 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%.

⌘/Ctrl + Enter runs the cell

What the kurtosis says

Excess kurtosis of 5.29 is easier to read as counts. Standardise each day to ztz_t and count the days far from the mean. A normal distribution has P(∣Z∣>3)=2N(−3)P(|Z| > 3) = 2N(-3), with NN the area of lesson 2.6.3, so it expects 1000×2N(−3)≈2.71000 \times 2N(-3) \approx 2.7 such days.

days with ∣zt∣\lvert z_t \rvert above345
in the data1364
a normal expects2.70.0630.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 1−N(k)1 - N(k), the normal's chance of landing above kk; doubling it counts both tails.

The last lines show how few days carry the kurtosis: the 10 largest zt4z_t^4 make up 72% of the sum. Without its single largest day, +6.83+6.83 volatilities, the series has an excess kurtosis of 3.70.

⌘/Ctrl + Enter runs the cell

That fragility is lesson 5.3.3's warning made concrete. Worse, the recipe's Student tt 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, q0.01q_{0.01}: the return with 1% of days below it.

Rule: One-day value at risk

P(r<q0.01)=0.01VaR99%=V (1−e q0.01)\begin{gathered} P(r < q_{0.01}) = 0.01 \\[4pt] \text{VaR}_{99\%} = V\,\big(1 - e^{\,q_{0.01}}\big) \end{gathered}

VV 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 −5.47%-5.47\%, −5.37%-5.37\%, −5.06%-5.06\%, −4.65%-4.65\%, −3.82%-3.82\%, −3.70%-3.70\%, −3.66%-3.66\%, −3.57%-3.57\%, −3.08%-3.08\% and −2.99%-2.99\%; the eleventh is −2.65%-2.65\%. The 1% quantile lies between the tenth and the eleventh.

NumPy's np.quantile(r, 0.01) gives −2.6562%-2.6562\%. The historical VaR is then 1,000,000×(1−e−0.026562)1{,}000{,}000 \times (1 - e^{-0.026562}), which is $26,212.

From a normal model

A normal model keeps only the mean rˉ\bar{r} and the volatility ss. If returns were normal, the chance of a day below rˉ+zs\bar{r} + zs would be N(z)N(z) (lesson 2.6.3; lesson 5.4.3 develops this). So the 1% quantile is rˉ+zs\bar{r} + zs with N(z)=0.01N(z) = 0.01.

That zz is −2.3263-2.3263. SciPy finds it as norm.ppf(0.01), the inverse of norm.cdf. The quantile is rˉ−2.3263s=−2.3495%\bar{r} - 2.3263s = -2.3495\%, 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 eq−1e^q - 1 accurately for small qq, 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.

⌘/Ctrl + Enter runs the cell

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:

level90%95%97.5%99%99.5%99.9%
historical11,15615,50320,44926,21236,28952,310
normal12,88716,49319,60923,22125,67330,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.

⌘/Ctrl + Enter runs the cell
Check yourself
At the 95% level, which VaR is larger for these returns, and why?

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.

⌘/Ctrl + Enter runs the cell

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.

⌘/Ctrl + Enter runs the cell
Check yourself
Another tool reports a kurtosis of 8.29 for these same returns, where SciPy said 5.29. What explains it?
Exercise

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.

  1. historical_var(r, level, value): take qq = np.quantile(r, 1 - level), NumPy's default, and return V(1−eq)V(1 - e^{q}).
  2. normal_var(r, level, value): the same, with q=rˉ+zsq = \bar{r} + z s, where zz = norm.ppf(1 - level) and ss = r.std(), dividing by nn.
  3. expected_shortfall(r, level, value): the average of V(1−ert)V(1 - e^{r_t}) over the days with rt≤qr_t \le q, using the historical qq.

All three raise ValueError if level is not strictly between 0 and 1. They also raise it if r has fewer than 11−level\frac{1}{1 - \text{level}} 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: −0.0061%-0.0061\%, 1.0073%, −0.3085-0.3085 and 5.2899.
  • The mean of nn independent days has standard deviation σn\frac{\sigma}{\sqrt{n}}, 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: V(1−eq0.01)V(1 - e^{q_{0.01}}). 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.

Free further reading

Optional. This lesson covers what you need; these add more examples and practice. All are free to read.