Mathew K Analytics

Lesson 19 · Real-World Data Analytics

Python Data Analytics #19: Value-at-Risk & Drawdown Analysis in Python

Video nineteen of the hundred-video real-world data analytics series. Three real ways to estimate Value-at-Risk, plus a full real drawdown and…

What you'll learn

Datasets used in this lesson

Save these next to the notebook. In Google Colab, upload them with the 📁 icon on the left first.

📓 Full notebook

Download .ipynb

Data Analytics 100, Video 19: Value-at-Risk and Drawdown Analysis#

  • Video nineteen of the hundred-video real-world data analytics series.
  • Three real ways to estimate Value-at-Risk, plus a full real drawdown and underwater-period breakdown for Apple.
  • Let's get into it.

Part 1: Quantifying Real Tail Risk#

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
aapl = pd.read_csv('aapl_clean.csv', parse_dates=['Date'])
returns = aapl['AAPL.Close'].pct_change().dropna()

Part 2: Real Historical VaR#

var95_historical = np.percentile(returns, 5)
round(var95_historical * 100, 2)
np.float64(-2.49)

Part 3: Real Parametric VaR#

from scipy.stats import norm
mean_return = returns.mean()
std_return = returns.std()
z_score_95 = norm.ppf(0.05)
var95_parametric = mean_return + z_score_95 * std_return
round(var95_parametric * 100, 2)
np.float64(-2.49)

Part 4: Real Monte Carlo VaR#

np.random.seed(42)
simulated_returns = np.random.normal(mean_return, std_return, 100000)
var95_montecarlo = np.percentile(simulated_returns, 5)
round(var95_montecarlo * 100, 2)
np.float64(-2.49)

Part 5: Comparing All Three Real VaR Methods#

var_comparison = pd.Series({'Historical': var95_historical, 'Parametric': var95_parametric, 'Monte Carlo': var95_montecarlo})
(var_comparison * 100).round(2)
Historical    -2.49
Parametric    -2.49
Monte Carlo   -2.49
dtype: float64

Part 6: Real Conditional VaR (Expected Shortfall)#

tail_losses = returns[returns <= var95_historical]
cvar95 = tail_losses.mean()
round(cvar95 * 100, 2)
np.float64(-3.45)

Part 7: Visualizing Real VaR and Real CVaR on the Return Distribution#

plt.figure(figsize=(11, 5))
plt.hist(returns, bins=50, color='steelblue', edgecolor='white', alpha=0.8)
plt.axvline(var95_historical, color='orange', linestyle='--', label=f'Real VaR 95% ({round(var95_historical*100,2)}%)')
plt.axvline(cvar95, color='red', linestyle='--', label=f'Real CVaR 95% ({round(cvar95*100,2)}%)')
plt.xlabel('Real Daily Return')
plt.ylabel('Real Frequency')
plt.title('Real Return Distribution with VaR and CVaR')
plt.legend()
plt.tight_layout()
plt.savefig('var_cvar_distribution.png', dpi=120)
plt.close()

Part 8: Real Multi-Day VaR Scaling#

var95_10day = var95_historical * np.sqrt(10)
var95_30day = var95_historical * np.sqrt(30)
round(var95_10day * 100, 2), round(var95_30day * 100, 2)
(np.float64(-7.88), np.float64(-13.66))

Part 9: Real VaR Backtesting, Counting Exceedances#

exceedances = (returns < var95_historical).sum()
expected_exceedances = len(returns) * 0.05
exceedances, round(expected_exceedances, 1)
(np.int64(26), 25.2)

Part 10: Real Exceedance Rate#

exceedance_rate = exceedances / len(returns)
round(exceedance_rate * 100, 2)
np.float64(5.15)

Part 11: Visualizing Real VaR Breaches Over Time#

breach_dates = aapl['Date'].iloc[1:][returns.values < var95_historical]
breach_returns = returns[returns < var95_historical]
plt.figure(figsize=(11, 5))
plt.plot(aapl['Date'].iloc[1:], returns, color='gray', alpha=0.5, label='Real Daily Return')
plt.scatter(breach_dates, breach_returns, color='red', s=25, label='Real VaR Breach', zorder=5)
plt.axhline(var95_historical, color='orange', linestyle='--', label='Real VaR 95% Threshold')
plt.xlabel('Real Date')
plt.ylabel('Real Daily Return')
plt.title('Real VaR Breaches Over Time')
plt.legend()
plt.tight_layout()
plt.savefig('var_breaches.png', dpi=120)
plt.close()

Part 12: Real Drawdown Series#

cumulative = (1 + returns).cumprod()
running_peak = cumulative.cummax()
drawdown = (cumulative - running_peak) / running_peak
drawdown.describe().round(3)
count    505.000
mean      -0.151
std        0.085
min       -0.321
25%       -0.205
50%       -0.150
75%       -0.083
max        0.000
Name: AAPL.Close, dtype: float64

Part 13: Real Maximum Drawdown#

max_drawdown = drawdown.min()
max_drawdown_date = aapl['Date'].iloc[1:][drawdown.values == max_drawdown].iloc[0]
round(max_drawdown * 100, 2), max_drawdown_date
(np.float64(-32.08), Timestamp('2016-05-12 00:00:00'))

Part 14: Visualizing the Real Drawdown Curve#

plt.figure(figsize=(11, 5))
plt.fill_between(aapl['Date'].iloc[1:], drawdown * 100, 0, color='crimson', alpha=0.5)
plt.xlabel('Real Date')
plt.ylabel('Real Drawdown (%)')
plt.title('Real Apple Drawdown from Prior Peak')
plt.tight_layout()
plt.savefig('full_drawdown_curve.png', dpi=120)
plt.close()

Part 15: Real Underwater Periods#

is_underwater = drawdown < 0
pct_time_underwater = is_underwater.mean()
round(pct_time_underwater * 100, 1)
np.float64(98.8)

Part 16: Real Longest Underwater Streak#

longest_streak = 0
current_streak = 0
for underwater_flag in is_underwater:
    if underwater_flag:
        current_streak += 1
        longest_streak = max(longest_streak, current_streak)
    else:
        current_streak = 0
longest_streak
497

Part 17: Real Recovery Time from Maximum Drawdown#

bottom_idx = drawdown.values.argmin()
post_bottom_drawdown = drawdown.iloc[bottom_idx:]
recovery_points = post_bottom_drawdown[post_bottom_drawdown >= -0.001]
recovered = len(recovery_points) > 0
recovered
True

Part 18: Real Number of Distinct Drawdown Episodes#

underwater_starts = (is_underwater & ~is_underwater.shift(1, fill_value=False)).sum()
underwater_starts
np.int64(3)

Part 19: Real Average Drawdown Depth#

average_drawdown_when_underwater = drawdown[is_underwater].mean()
round(average_drawdown_when_underwater * 100, 2)
np.float64(-15.27)

Part 20: Real VaR at a Different Confidence Level#

var99_historical = np.percentile(returns, 1)
round(var99_historical * 100, 2)
np.float64(-4.23)

Part 21: Real 99% Exceedance Backtest#

exceedances_99 = (returns < var99_historical).sum()
expected_99 = len(returns) * 0.01
exceedances_99, round(expected_99, 1)
(np.int64(6), 5.0)

Part 22: Real Dollar VaR for a Hypothetical Position#

hypothetical_position_usd = 100000
dollar_var95 = hypothetical_position_usd * abs(var95_historical)
round(dollar_var95, 2)
np.float64(2493.43)

Part 23: Real Rolling VaR Over Time#

rolling_var95 = returns.rolling(60).apply(lambda x: np.percentile(x, 5), raw=True)
rolling_var95.dropna().describe().round(4)
count    446.0000
mean      -0.0236
std        0.0076
min       -0.0424
25%       -0.0272
50%       -0.0227
75%       -0.0183
max       -0.0072
Name: AAPL.Close, dtype: float64

Part 24: Visualizing Real Rolling VaR#

plt.figure(figsize=(11, 5))
plt.plot(aapl['Date'].iloc[1:], rolling_var95, color='darkred')
plt.xlabel('Real Date')
plt.ylabel('Real Rolling 60-Day VaR (95%)')
plt.title('Real Rolling VaR Over Time, Apple')
plt.tight_layout()
plt.savefig('rolling_var.png', dpi=120)
plt.close()

Part 25: Saving the Real VaR and Drawdown Summary#

risk_summary = pd.DataFrame({'Metric': ['Historical VaR 95%', 'Parametric VaR 95%', 'Monte Carlo VaR 95%', 'CVaR 95%', 'Historical VaR 99%', 'Max Drawdown'], 'ValuePct': [round(var95_historical*100,2), round(var95_parametric*100,2), round(var95_montecarlo*100,2), round(cvar95*100,2), round(var99_historical*100,2), round(max_drawdown*100,2)]})
risk_summary.to_csv('var_drawdown_summary.csv', index=False)
reloaded = pd.read_csv('var_drawdown_summary.csv')
reloaded.shape[0] == 6
True

Part 26: Real Sanity Check, VaR Ordering#

var99_historical < var95_historical
cvar95 <= var95_historical
np.True_

Part 27: Real Sanity Check, Drawdown Bounds#

(drawdown <= 0).all()
np.True_

Part 28: Real Comparison, VaR vs Real Max Drawdown#

round(abs(max_drawdown) / abs(var95_historical), 1)
np.float64(12.9)

Part 29: Real Number of Drawdown Episodes vs Real VaR Breaches#

underwater_starts, exceedances
(np.int64(3), np.int64(26))

Part 30: Real Recap Print#

print(f'Across {len(returns)} real trading days, Apple had a {round(var95_historical*100,2)}% one-day VaR, a {round(cvar95*100,2)}% Expected Shortfall, and a {round(max_drawdown*100,1)}% maximum drawdown that lasted up to {longest_streak} real consecutive underwater days.')
Across 505 real trading days, Apple had a -2.49% one-day VaR, a -3.45% Expected Shortfall, and a -32.1% maximum drawdown that lasted up to 497 real consecutive underwater days.

Wrap-Up: What You Learned#

  • Historical, parametric, and Monte Carlo VaR are three genuinely different ways to estimate the real same downside threshold, and comparing them is a real honesty check.
  • Conditional VaR, or Expected Shortfall, answers the real question VaR leaves open: how bad does it actually get once the threshold is breached.
  • Backtesting VaR by counting real exceedances against the real theoretically expected rate is how you check whether a risk model is actually well-calibrated.
  • Drawdown and VaR measure genuinely different things, VaR is about a real single bad day, drawdown is about a real sustained decline from a prior peak.
  • Underwater period length and real recovery time turn an abstract percentage into a real, human sense of how long a loss actually took to live through.
  • Next video: the Domain 2 capstone, building one real multi-asset portfolio report that pulls together everything covered across this whole finance domain.

Found this useful?

All lessons, notebooks and datasets here are free. If they helped you, a coffee keeps new lessons coming.