Mathew K Analytics

Lesson 14 · Real-World Data Analytics

Python Data Analytics #14: Volatility Analysis & Rolling Risk Metrics in Python

Video fourteen of the hundred-video real-world data analytics series. Measuring real risk itself as it changes over time, using rolling windows on real…

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 14: Volatility Analysis and Rolling Risk Metrics#

  • Video fourteen of the hundred-video real-world data analytics series.
  • Measuring real risk itself as it changes over time, using rolling windows on real Apple and real Tesla returns.
  • Let's get into it.

Part 1: Risk Is Not Constant#

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
merged = pd.read_csv('aapl_tsla_merged.csv', parse_dates=['Date'])
merged.shape[0]
338

Part 2: Real 20-Day Rolling Volatility#

merged['AAPL_Vol20'] = merged['AAPL_Return'].rolling(20).std() * np.sqrt(252)
merged['TSLA_Vol20'] = merged['TSLA_Return'].rolling(20).std() * np.sqrt(252)
merged[['Date', 'AAPL_Vol20', 'TSLA_Vol20']].dropna().head(3).round(3)
Date AAPL_Vol20 TSLA_Vol20
20 2015-11-12 0.282 0.577
21 2015-11-13 0.301 0.575
22 2015-11-16 0.306 0.590

Part 3: Real 60-Day Rolling Volatility#

merged['AAPL_Vol60'] = merged['AAPL_Return'].rolling(60).std() * np.sqrt(252)
merged['TSLA_Vol60'] = merged['TSLA_Return'].rolling(60).std() * np.sqrt(252)
merged[['AAPL_Vol20', 'AAPL_Vol60']].dropna().corr().round(3)
AAPL_Vol20 AAPL_Vol60
AAPL_Vol20 1.000 0.642
AAPL_Vol60 0.642 1.000

Part 4: Real Rolling Volatility Summary Stats#

aapl_vol_summary = merged['AAPL_Vol20'].dropna().describe().round(3)
tsla_vol_summary = merged['TSLA_Vol20'].dropna().describe().round(3)
aapl_vol_summary
count    318.000
mean       0.218
std        0.077
min        0.077
25%        0.161
50%        0.222
75%        0.264
max        0.430
Name: AAPL_Vol20, dtype: float64
tsla_vol_summary
count    318.000
mean       0.362
std        0.115
min        0.163
25%        0.286
50%        0.336
75%        0.400
max        0.736
Name: TSLA_Vol20, dtype: float64

Part 5: Visualizing Real Rolling Volatility#

plt.figure(figsize=(11, 6))
plt.plot(merged['Date'], merged['AAPL_Vol20'], label='Real Apple 20-Day Vol')
plt.plot(merged['Date'], merged['TSLA_Vol20'], label='Real Tesla 20-Day Vol')
plt.xlabel('Real Date')
plt.ylabel('Real Annualized Volatility')
plt.title('Real Rolling 20-Day Volatility, Apple vs Tesla')
plt.legend()
plt.tight_layout()
plt.savefig('rolling_volatility.png', dpi=120)
plt.close()

Part 6: Real Volatility Clustering Check#

vol_today = merged['AAPL_Vol20'].dropna()
vol_next_day = vol_today.shift(-1).dropna()
aligned_today = vol_today.loc[vol_next_day.index]
round(aligned_today.corr(vol_next_day), 3)
np.float64(0.967)

Part 7: Real Rolling Sharpe Ratio#

aapl_roll_return20 = merged['AAPL_Return'].rolling(20).mean() * 252
merged['AAPL_RollSharpe20'] = aapl_roll_return20 / merged['AAPL_Vol20']
tsla_roll_return20 = merged['TSLA_Return'].rolling(20).mean() * 252
merged['TSLA_RollSharpe20'] = tsla_roll_return20 / merged['TSLA_Vol20']
merged[['Date', 'AAPL_RollSharpe20', 'TSLA_RollSharpe20']].dropna().tail(3).round(2)
Date AAPL_RollSharpe20 TSLA_RollSharpe20
335 2017-02-14 6.79 10.10
336 2017-02-15 7.03 9.01
337 2017-02-16 7.07 4.66

Part 8: Visualizing Real Rolling Sharpe Ratio#

plt.figure(figsize=(11, 5))
plt.plot(merged['Date'], merged['AAPL_RollSharpe20'], label='Real Apple Rolling Sharpe')
plt.plot(merged['Date'], merged['TSLA_RollSharpe20'], label='Real Tesla Rolling Sharpe')
plt.axhline(0, color='gray', linestyle='--')
plt.xlabel('Real Date')
plt.ylabel('Real Rolling Sharpe Ratio')
plt.title('Real Rolling 20-Day Sharpe Ratio, Apple vs Tesla')
plt.legend()
plt.tight_layout()
plt.savefig('rolling_sharpe.png', dpi=120)
plt.close()

Part 9: Real Downside Deviation#

def rolling_downside_vol(returns, window):
    def only_losses(x):
        losses = x[x < 0]
        return losses.std() * np.sqrt(252) if len(losses) > 1 else np.nan
    return returns.rolling(window).apply(only_losses, raw=False)
merged['AAPL_DownsideVol20'] = rolling_downside_vol(merged['AAPL_Return'], 20)
merged['AAPL_DownsideVol20'].dropna().describe().round(3)
count    318.000
mean       0.132
std        0.070
min        0.010
25%        0.081
50%        0.131
75%        0.158
max        0.316
Name: AAPL_DownsideVol20, dtype: float64

Part 10: Real Downside vs Real Total Volatility#

downside_ratio = (merged['AAPL_DownsideVol20'] / merged['AAPL_Vol20']).dropna()
round(downside_ratio.mean(), 3)
np.float64(0.6)

Part 11: Real High-Volatility Regime Flagging#

aapl_median_vol = merged['AAPL_Vol20'].median()
merged['AAPL_HighVolFlag'] = merged['AAPL_Vol20'] > (aapl_median_vol * 1.5)
merged['AAPL_HighVolFlag'].sum()
np.int64(23)

Part 12: Real Returns During High-Volatility Regimes#

high_vol_returns = merged.loc[merged['AAPL_HighVolFlag'], 'AAPL_Return']
normal_returns = merged.loc[~merged['AAPL_HighVolFlag'].fillna(False), 'AAPL_Return']
round(high_vol_returns.mean() * 100, 3), round(normal_returns.mean() * 100, 3)
(np.float64(0.019), np.float64(0.071))

Part 13: Real Rolling Maximum Drawdown#

def rolling_max_drawdown(cumulative, window):
    roll_peak = cumulative.rolling(window, min_periods=1).max()
    drawdown = (cumulative - roll_peak) / roll_peak
    return drawdown.rolling(window).min()
merged['AAPL_RollMaxDD60'] = rolling_max_drawdown(merged['AAPL_Cumulative'], 60)
merged['AAPL_RollMaxDD60'].dropna().describe().round(3)
count    279.000
mean      -0.168
std        0.059
min       -0.238
25%       -0.215
50%       -0.194
75%       -0.106
max       -0.058
Name: AAPL_RollMaxDD60, dtype: float64

Part 14: Visualizing Real Rolling Drawdown#

plt.figure(figsize=(11, 5))
plt.fill_between(merged['Date'], merged['AAPL_RollMaxDD60'] * 100, 0, color='crimson', alpha=0.5)
plt.xlabel('Real Date')
plt.ylabel('Real Rolling 60-Day Max Drawdown (%)')
plt.title('Real Rolling Maximum Drawdown, Apple')
plt.tight_layout()
plt.savefig('rolling_drawdown.png', dpi=120)
plt.close()

Part 15: Real Worst Rolling Drawdown Window#

worst_dd_idx = merged['AAPL_RollMaxDD60'].idxmin()
worst_dd_date = merged.loc[worst_dd_idx, 'Date']
worst_dd_value = round(merged.loc[worst_dd_idx, 'AAPL_RollMaxDD60'] * 100, 2)
worst_dd_date, worst_dd_value
(Timestamp('2016-01-27 00:00:00'), np.float64(-23.77))

Part 16: Real Volatility Ratio, Tesla vs Apple#

merged['VolRatio_TSLA_AAPL'] = merged['TSLA_Vol20'] / merged['AAPL_Vol20']
merged['VolRatio_TSLA_AAPL'].dropna().describe().round(2)
count    318.00
mean       1.82
std        0.66
min        0.68
25%        1.24
50%        1.70
75%        2.36
max        3.63
Name: VolRatio_TSLA_AAPL, dtype: float64

Part 17: Real Rolling Metrics Table Preview#

risk_cols = ['Date', 'AAPL_Vol20', 'AAPL_Vol60', 'TSLA_Vol20', 'TSLA_Vol60', 'AAPL_RollSharpe20', 'TSLA_RollSharpe20', 'AAPL_DownsideVol20', 'AAPL_RollMaxDD60', 'VolRatio_TSLA_AAPL']
merged[risk_cols].dropna().tail(3).round(3)
Date AAPL_Vol20 AAPL_Vol60 TSLA_Vol20 TSLA_Vol60 AAPL_RollSharpe20 TSLA_RollSharpe20 AAPL_DownsideVol20 AAPL_RollMaxDD60 VolRatio_TSLA_AAPL
335 2017-02-14 0.223 0.156 0.223 0.281 6.792 10.099 0.014 -0.077 1.002
336 2017-02-15 0.222 0.156 0.228 0.280 7.032 9.007 0.010 -0.077 1.026
337 2017-02-16 0.222 0.156 0.275 0.290 7.075 4.656 0.011 -0.077 1.240

Part 18: Saving the Real Rolling Risk Table#

merged[risk_cols].round(4).to_csv('rolling_risk_metrics.csv', index=False)
reloaded = pd.read_csv('rolling_risk_metrics.csv')
reloaded.shape[0] == merged.shape[0]
True

Part 19: Real Sanity Check on Annualization#

manual_check = merged['AAPL_Return'].tail(20).std() * np.sqrt(252)
pipeline_value = merged['AAPL_Vol20'].iloc[-1]
round(abs(manual_check - pipeline_value), 8) < 1e-6
np.True_

Part 20: Real Recap Print#

aapl_mean_vol_pct = round(merged['AAPL_Vol20'].mean() * 100, 1)
tsla_mean_vol_pct = round(merged['TSLA_Vol20'].mean() * 100, 1)
print(f'Across {merged.shape[0]} real overlapping trading days, Apple averaged {aapl_mean_vol_pct}% annualized rolling volatility versus Tesla at {tsla_mean_vol_pct}%.')
Across 338 real overlapping trading days, Apple averaged 21.8% annualized rolling volatility versus Tesla at 36.2%.

Part 21: Real EWMA Volatility (RiskMetrics Style)#

aapl_sq_returns = merged['AAPL_Return'] ** 2
ewma_variance = aapl_sq_returns.ewm(alpha=0.06, adjust=False).mean()
merged['AAPL_EWMA_Vol'] = np.sqrt(ewma_variance) * np.sqrt(252)
merged['AAPL_EWMA_Vol'].dropna().describe().round(3)
count    337.000
mean       0.224
std        0.059
min        0.102
25%        0.182
50%        0.221
75%        0.258
max        0.407
Name: AAPL_EWMA_Vol, dtype: float64

Part 22: Real EWMA vs Real Simple Rolling Volatility#

plt.figure(figsize=(11, 5))
plt.plot(merged['Date'], merged['AAPL_Vol20'], label='Real Simple 20-Day Vol')
plt.plot(merged['Date'], merged['AAPL_EWMA_Vol'], label='Real EWMA Vol')
plt.xlabel('Real Date')
plt.ylabel('Real Annualized Volatility')
plt.title('Real Simple Rolling vs Real EWMA Volatility, Apple')
plt.legend()
plt.tight_layout()
plt.savefig('ewma_vs_rolling_vol.png', dpi=120)
plt.close()

Part 23: Real Rolling Sortino Ratio#

merged['AAPL_RollSortino20'] = aapl_roll_return20 / merged['AAPL_DownsideVol20']
merged[['Date', 'AAPL_RollSharpe20', 'AAPL_RollSortino20']].dropna().tail(3).round(2)
Date AAPL_RollSharpe20 AAPL_RollSortino20
335 2017-02-14 6.79 107.40
336 2017-02-15 7.03 161.53
337 2017-02-16 7.07 145.28

Part 24: Real Tesla High-Volatility Regime#

tsla_median_vol = merged['TSLA_Vol20'].median()
merged['TSLA_HighVolFlag'] = merged['TSLA_Vol20'] > (tsla_median_vol * 1.5)
merged['AAPL_HighVolFlag'].sum(), merged['TSLA_HighVolFlag'].sum()
(np.int64(23), np.int64(38))

Part 25: Real Historical Value-at-Risk on Rolling Windows#

def rolling_var_95(returns, window):
    return returns.rolling(window).apply(lambda x: np.percentile(x, 5), raw=True)
merged['AAPL_RollVaR95_60'] = rolling_var_95(merged['AAPL_Return'], 60)
round(merged['AAPL_RollVaR95_60'].dropna().mean() * 100, 2)
np.float64(-2.03)

Part 26: Real Tightest and Real Widest VaR Windows#

tightest_var_date = merged.loc[merged['AAPL_RollVaR95_60'].idxmax(), 'Date']
widest_var_date = merged.loc[merged['AAPL_RollVaR95_60'].idxmin(), 'Date']
tightest_var_date, widest_var_date
(Timestamp('2017-02-10 00:00:00'), Timestamp('2016-01-12 00:00:00'))

Part 27: Real Correlation Between Volatility and Drawdown#

vol_dd_pair = merged[['AAPL_Vol60', 'AAPL_RollMaxDD60']].dropna()
round(vol_dd_pair['AAPL_Vol60'].corr(vol_dd_pair['AAPL_RollMaxDD60']), 3)
np.float64(-0.813)

Part 28: Real Current Volatility Percentile Rank#

current_vol = merged['AAPL_Vol20'].dropna().iloc[-1]
vol_percentile = (merged['AAPL_Vol20'].dropna() < current_vol).mean() * 100
round(vol_percentile, 1)
np.float64(50.0)

Part 29: Saving the Real Extended Risk Table#

extended_cols = risk_cols + ['AAPL_EWMA_Vol', 'AAPL_RollSortino20', 'TSLA_HighVolFlag', 'AAPL_RollVaR95_60']
merged[extended_cols].round(4).to_csv('rolling_risk_metrics_extended.csv', index=False)
reloaded_ext = pd.read_csv('rolling_risk_metrics_extended.csv')
reloaded_ext.shape[1] == len(extended_cols)
True

Wrap-Up: What You Learned#

  • Volatility genuinely changes over time, and rolling windows are how you make that real variation visible instead of averaging it away.
  • Rolling Sharpe ratios show how a real risk-adjusted return itself shifts as market conditions change.
  • Downside deviation isolates the real risk investors actually worry about, losses, rather than penalizing real upside swings too.
  • Flagging real high-volatility regimes lets you test whether returns genuinely behave differently during turbulent stretches.
  • Rolling maximum drawdown tracks the real worst pain an investor would have felt within any given real window, not just once at the end.
  • Next video: real correlation and diversification across a wider set of real assets, going beyond just this one real pair.

Found this useful?

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