σμimplied vol · KKmax(S−K,0)udβρᵢⱼfat tailsPnL · drawdownyield · TdSₜ = μSₜ dt + σSₜ dWₜC = S·N(d₁) − K·e⁻ʳᵀ·N(d₂)σₚ² = wᵀΣwd₁ = [ln(S/K) + (r + σ²⁄2)T] ⁄ σ√T∂V/∂t + ½σ²S²·∂²V/∂S² + rS·∂V/∂S − rV = 0Sharpe = (Rₚ − R_f) ⁄ σₚVaR₉₉ = μ − 2.33σρ = Cov(X,Y) ⁄ σₓσᵧE[Rₚ] = Σ wᵢ·E[Rᵢ]φ(x) = e^(−x²/2) ⁄ √2πE[Rᵢ] = R_f + βᵢ(E[Rₘ] − R_f)dXₜ = θ(μ − Xₜ)dt + σ dWₜσ²ₜ = ω + α·ε²ₜ₋₁ + β·σ²ₜ₋₁C − P = S − K·e⁻ʳᵀΔ = ∂V/∂S Γ = ∂²V/∂S²p = (e^(rΔt) − d) ⁄ (u − d)rₜ = ln(Pₜ ⁄ Pₜ₋₁)E[Xₜ₊₁ | ℱₜ] = Xₜf* = (bp − q) ⁄ bz = (x − μ) ⁄ σP(N=k) = λᵏe^(−λ) ⁄ k!Θ = −∂V/∂Tν = S·φ(d₁)·√Tmin wᵀΣw s.t. wᵀμ = μ*∫Σ∂∞λ
σμimplied vol · KKmax(S−K,0)udβρᵢⱼfat tailsPnL · drawdownyield · TdSₜ = μSₜ dt + σSₜ dWₜC = S·N(d₁) − K·e⁻ʳᵀ·N(d₂)σₚ² = wᵀΣwd₁ = [ln(S/K) + (r + σ²⁄2)T] ⁄ σ√T∂V/∂t + ½σ²S²·∂²V/∂S² + rS·∂V/∂S − rV = 0Sharpe = (Rₚ − R_f) ⁄ σₚVaR₉₉ = μ − 2.33σρ = Cov(X,Y) ⁄ σₓσᵧE[Rₚ] = Σ wᵢ·E[Rᵢ]φ(x) = e^(−x²/2) ⁄ √2πE[Rᵢ] = R_f + βᵢ(E[Rₘ] − R_f)dXₜ = θ(μ − Xₜ)dt + σ dWₜσ²ₜ = ω + α·ε²ₜ₋₁ + β·σ²ₜ₋₁C − P = S − K·e⁻ʳᵀΔ = ∂V/∂S Γ = ∂²V/∂S²p = (e^(rΔt) − d) ⁄ (u − d)rₜ = ln(Pₜ ⁄ Pₜ₋₁)E[Xₜ₊₁ | ℱₜ] = Xₜf* = (bp − q) ⁄ bz = (x − μ) ⁄ σP(N=k) = λᵏe^(−λ) ⁄ k!Θ = −∂V/∂Tν = S·φ(d₁)·√Tmin wᵀΣw s.t. wᵀμ = μ*∫Σ∂∞λ
uantCraft AcademySkills
Ctrl K
AO
+1.2k Learners
LoginJoin ↗
Skills
AO
+1.2k Learners
Login
  • English
  • Français
  • Español
  • العربية
  • Português
  • Deutsch
  • 日本語
  • 中文
Python skill›Module 3 · Probability & Monte Carlo simulation›Topic 3.3·~45 minIntermediate

Fat tails and normality tests

Skill positionModule 3 of 11 · Topic 3 of 12
3.3 Fat tails and normality tests
—Lab score— / 100Total XP—Streak—

What the bell curve promises

Many risk models assume returns follow a normal distribution, because it makes everything computable with a mean and a standard deviation. The normal distribution is very precise about extremes. The probability of being more than k standard deviations away from the mean, in either direction, is erfc(k / sqrt(2)): 31.7% for 1, 4.55% for 2, 0.27% for 3, 0.0063% for 4. A 4-sigma day would then happen about once in 15,800 trading days, roughly once in 63 years, and a 5-sigma day once in several thousand years.

import math
import numpy as np

for k in (1, 2, 3, 4, 5):
    probability = math.erfc(k / math.sqrt(2))      # P(|Z| > k) for a normal variable
    print(k, f"{probability:.2e}")
# 1 3.17e-01
# 2 4.55e-02
# 3 2.70e-03
# 4 6.33e-05
# 5 5.73e-07

print(round(1 / math.erfc(4 / math.sqrt(2))))                  # 15787   trading days between 4-sigma days, if returns were normal
print(round(1 / math.erfc(5 / math.sqrt(2)) / 252))            # 6922    years between 5-sigma days

Markets have fat tails

Real markets produce 4-sigma and 5-sigma days many times in a career, in 1987, 2008, 2020. Their return distributions have fat tails: more extreme days than the bell allows (and a taller, narrower middle). A simple way to see it is the tail ratio: the observed share of returns beyond k standard deviations divided by the share a normal would give. A ratio near 1 means normal tails; 5 means extremes five times too frequent; for daily data at k = 4 it can be in the hundreds. Risk models that assume normality underestimate exactly the days that matter most.

returns = np.array([0.010, 0.012, 0.008, 0.011, 0.009, 0.010, 0.012, -0.060, 0.010, 0.009])

def tail_ratio(x, k):
    observed = np.mean(np.abs(x - x.mean()) > k * x.std(ddof=1))     # share beyond k standard deviations
    return observed / math.erfc(k / math.sqrt(2))                    # divided by what a normal would give

print(round(float(tail_ratio(returns, 2)), 2))    # 2.2   the one crash is 2.2 times too frequent at 2 sigma (a tiny sample, though)

The Jarque-Bera test

A formal test asks whether the sample could plausibly come from a normal distribution. The Jarque-Bera statistic combines the two shape measures of the previous topic: JB = n / 6 * (S² + K² / 4), with S the skewness and K the excess kurtosis. For a normal sample both are near 0, so JB is small. Under normality JB follows a chi-square distribution with 2 degrees of freedom, whose tail has the closed form exp(-JB / 2): that is the p-value. If it is below 0.05 you reject normality. The critical value is JB = 5.99. Our 10-day sample with one crash has JB = 22.4 and a p-value of 0.00001.

x = returns
n = len(x)
deviations = x - x.mean()
m2, m3, m4 = (np.mean(deviations ** k) for k in (2, 3, 4))
skew, kurt = m3 / m2 ** 1.5, m4 / m2 ** 2 - 3

jb = n / 6 * (skew ** 2 + kurt ** 2 / 4)
p_value = math.exp(-jb / 2)
print(round(float(jb), 2), p_value < 0.05)     # 22.37 True   normality rejected
print(f"{p_value:.1e}")                         # 1.4e-05
# The p-value falls fast as the statistic grows: 5.99 is the 5% critical value
for jb in (0, 2, 5.99, 10, 22.37):
    print(jb, round(math.exp(-jb / 2), 5))
# 0 1.0
# 2 0.36788
# 5.99 0.05004
# 10 0.00674
# 22.37 1e-05
Rejecting normality is not the endWith hundreds of daily returns, the test rejects normality for almost every asset, so a rejection is expected news. What matters is how far the tails differ: use the tail ratio to size the problem, and then stress-test with real crashes, a heavier-tailed distribution such as the Student t, or historical simulation, instead of trusting a bell curve.
  • A normal variable is more than k standard deviations away with probability erfc(k / sqrt(2)): 4.55% for 2, 0.0063% for 4.
  • The tail ratio, observed share divided by the normal share, shows fat tails: markets are far above 1 at 4 sigma.
  • Jarque-Bera: n / 6 * (S**2 + K**2 / 4); the p-value is exp(-JB / 2); below 0.05, reject normality (JB above 5.99).

Next up: hypothesis tests: the t-test, deciding whether a mean return is really different from zero.

Lab · what's graded

  • Score how far a sample is from normal with jarque_bera25 pts
  • Turn the statistic into a p-value with normality_p_value25 pts
  • Compute what the bell curve promises with normal_tail_probability25 pts
  • Count how much fatter the tails are with tail_ratio25 pts
Explorer · lab-56-1
▾ lab-56-1
…
bash · in-browser
learner@quantcraft:~/lab-56-1$
Python not loaded yetPythonLn 1, Col 1Spaces: 4UTF-8

Reset this topic?

Everything in lab-56-1 goes back to the starter files and the terminal is cleared. Your current solution will be lost — this can't be undone.

Will be reset
  • main.py → starter code
  • Terminal history and test output
  • Unsubmitted changes
Will be kept
  • Best lab score
  • Your XP and streak
  • Lecture progress and notes

Log in to run your code.

Every lecture is free to read. Create an account or log in to run code, run tests and submit this lab.

  • In-browser IDE with a bash terminal — nothing to install
  • Instant grading, XP and your place on the skill
Create an accountLog in
OR
Continue with GitHub