Skip to content

Empirical Volatility Analysis (QuantPie)

Background

quantpie_empirical_volatility_analysis.py

Empirical analysis of time-varying volatility, fat tails, and leptokurtic distributions using historical stock data. This module demonstrates the key empirical failures of the constant volatility assumption.

Based on QuantPie Lecture Notes: Non-constant volatilities, high peak, and fat tails.

Author: Financial Math Library


Code

```python

-- coding: utf-8 --

""" quantpie_empirical_volatility_analysis.py

Empirical analysis of time-varying volatility, fat tails, and leptokurtic distributions using historical stock data. This module demonstrates the key empirical failures of the constant volatility assumption.

Based on QuantPie Lecture Notes: Non-constant volatilities, high peak, and fat tails.

Author: Financial Math Library """

import numpy as np import pandas as pd import matplotlib.pyplot as plt import yfinance as yf from scipy import stats

======================================================================

def download_stock_data(ticker: str, period: str = 'max') -> pd.DataFrame: """ Download historical stock price data using yfinance.

Parameters
----------
ticker : str
    Stock ticker symbol (e.g., 'WMT', 'AAPL').
period : str, default 'max'
    Historical period to retrieve ('max', '5y', '1y', etc.).

Returns
-------
pd.DataFrame
    DataFrame with columns ['Open', 'High', 'Low', 'Close', 'Volume',
    'Dividends', 'Stock Splits'].
"""
print(f"Downloading historical data for {ticker}...")
stock = yf.Ticker(ticker)
df = stock.history(period=period)
print(f"Data downloaded: {len(df)} trading days from {df.index[0].date()} "
      f"to {df.index[-1].date()}")
return df

def compute_returns(df: pd.DataFrame) -> pd.DataFrame: """ Compute daily log-returns from closing prices.

Parameters
----------
df : pd.DataFrame
    DataFrame with 'Close' column.

Returns
-------
pd.DataFrame
    DataFrame with 'Return' column containing daily percentage changes.
"""
df = df.copy()
df['Return'] = df['Close'].pct_change()
df = df[['Close', 'Return']].iloc[1:]  # Remove NaN from pct_change
return df

def compute_rolling_volatility(returns: pd.Series, window: int = 30) -> pd.Series: """ Compute rolling window standard deviation (realized volatility).

Parameters
----------
returns : pd.Series
    Daily returns series.
window : int, default 30
    Rolling window size in days.

Returns
-------
pd.Series
    Rolling standard deviation series.
"""
rolling_vol = returns.rolling(window).std()
return rolling_vol

def compute_distribution_stats(returns: pd.Series) -> dict: """ Compute distribution statistics: mean, std, skewness, kurtosis.

Parameters
----------
returns : pd.Series
    Daily returns series (no NaN values).

Returns
-------
dict
    Dictionary with keys: 'mean', 'std', 'skewness', 'kurtosis',
    'excess_kurtosis'.
"""
clean_returns = returns.dropna()

mean = clean_returns.mean()
std = clean_returns.std()
skewness = stats.skew(clean_returns)
kurtosis_val = stats.kurtosis(clean_returns, fisher=False)  # Excess=False gives Kurt=4 for normal
excess_kurtosis = stats.kurtosis(clean_returns, fisher=True)  # Excess=True gives Kurt=0 for normal

return {
    'mean': mean,
    'std': std,
    'skewness': skewness,
    'kurtosis': kurtosis_val,
    'excess_kurtosis': excess_kurtosis
}

def analyze_tail_events(returns: pd.Series, threshold_sigma: float = 3.0) -> dict: """ Analyze extreme tail events (returns beyond ±threshold_sigma standard deviations).

Parameters
----------
returns : pd.Series
    Daily returns series.
threshold_sigma : float, default 3.0
    Number of standard deviations defining tail events.

Returns
-------
dict
    Dictionary with tail statistics including:
    - 'right_tail_count': Number of positive extreme events
    - 'left_tail_count': Number of negative extreme events
    - 'right_tail_pct': Percentage of data in right tail
    - 'left_tail_pct': Percentage of data in left tail
    - 'total_tail_pct': Total percentage in either tail
    - 'normal_tail_probability': Normal distribution prediction
"""
clean_returns = returns.dropna()
mu = clean_returns.mean()
sigma = clean_returns.std()

threshold_val = threshold_sigma * sigma

right_tail = clean_returns > (mu + threshold_val)
left_tail = clean_returns < (mu - threshold_val)

right_tail_count = right_tail.sum()
left_tail_count = left_tail.sum()
total_count = len(clean_returns)

right_tail_pct = 100 * right_tail_count / total_count
left_tail_pct = 100 * left_tail_count / total_count
total_tail_pct = right_tail_pct + left_tail_pct

# Normal distribution probability
normal_prob_one_tail = 1 - stats.norm.cdf(threshold_sigma)
normal_prob_both_tails = 2 * normal_prob_one_tail

return {
    'threshold_sigma': threshold_sigma,
    'threshold_value': threshold_val,
    'right_tail_count': right_tail_count,
    'left_tail_count': left_tail_count,
    'right_tail_pct': right_tail_pct,
    'left_tail_pct': left_tail_pct,
    'total_tail_pct': total_tail_pct,
    'normal_tail_probability_pct': 100 * normal_prob_both_tails,
}

def empirical_volatility_analysis(ticker: str = 'WMT', window: int = 30): """ Main analysis function: time-varying volatility, distribution analysis, and tail analysis.

Parameters
----------
ticker : str, default 'WMT'
    Stock ticker symbol.
window : int, default 30
    Rolling volatility window in days.

Returns
-------
dict
    Comprehensive results including data, statistics, and analysis.
"""
# Download and prepare data
df = download_stock_data(ticker, period='max')
df = compute_returns(df)

# Compute rolling volatility
rolling_vol = compute_rolling_volatility(df['Return'], window=window)
df['Rolling_Vol'] = rolling_vol

# Remove NaN from rolling volatility
df_clean = df.dropna()

# Distribution statistics
dist_stats = compute_distribution_stats(df_clean['Return'])

# Tail analysis at different thresholds
tail_3sigma = analyze_tail_events(df_clean['Return'], threshold_sigma=3.0)
tail_5sigma = analyze_tail_events(df_clean['Return'], threshold_sigma=5.0)

print("\n" + "="*70)
print(f"EMPIRICAL VOLATILITY ANALYSIS: {ticker}")
print("="*70)

print(f"\nData period: {df_clean.index[0].date()} to {df_clean.index[-1].date()}")
print(f"Number of observations: {len(df_clean)}")
print(f"Rolling volatility window: {window} days")

print(f"\n{'-'*70}")
print("RETURN DISTRIBUTION STATISTICS")
print(f"{'-'*70}")
print(f"Mean (annualized):        {dist_stats['mean']*252:8.4f} ({dist_stats['mean']:10.6f} daily)")
print(f"Std Dev (annualized):     {dist_stats['std']*np.sqrt(252):8.4f} ({dist_stats['std']:10.6f} daily)")
print(f"Skewness:                 {dist_stats['skewness']:10.6f}")
print(f"  (Normal = 0; Negative = Left-skewed with fat left tail)")
print(f"Kurtosis (excess):        {dist_stats['excess_kurtosis']:10.6f}")
print(f"  (Normal = 0; Positive = Heavy-tailed/Leptokurtic)")

print(f"\n{'-'*70}")
print("VOLATILITY STATISTICS")
print(f"{'-'*70}")
rolling_stats = df_clean['Rolling_Vol'].describe()
print(f"30-day rolling volatility (daily):")
print(f"  Min:      {rolling_stats['min']:10.6f}")
print(f"  25th %ile: {rolling_stats['25%']:10.6f}")
print(f"  Mean:     {rolling_stats['mean']:10.6f}")
print(f"  Median:   {rolling_stats['50%']:10.6f}")
print(f"  75th %ile: {rolling_stats['75%']:10.6f}")
print(f"  Max:      {rolling_stats['max']:10.6f}")
print(f"  Std Dev:  {rolling_stats['std']:10.6f}")

ratio = rolling_stats['max'] / rolling_stats['min']
print(f"\nMax/Min volatility ratio: {ratio:.2f}x")
print(f"  (Constant volatility assumption would imply ratio = 1.0)")

print(f"\n{'-'*70}")
print("TAIL EVENT ANALYSIS")
print(f"{'-'*70}")

print(f"\nReturns beyond ±3σ from mean:")
print(f"  Right tail (> μ + 3σ):  {tail_3sigma['right_tail_count']:4d} occurrences "
      f"({tail_3sigma['right_tail_pct']:5.2f}%)")
print(f"  Left tail  (< μ - 3σ):  {tail_3sigma['left_tail_count']:4d} occurrences "
      f"({tail_3sigma['left_tail_pct']:5.2f}%)")
print(f"  Total:                  {tail_3sigma['right_tail_count'] + tail_3sigma['left_tail_count']:4d} "
      f"({tail_3sigma['total_tail_pct']:5.2f}%)")
print(f"  Normal distribution predicts: {tail_3sigma['normal_tail_probability_pct']:.4f}%")
print(f"  Excess over normal:     {tail_3sigma['total_tail_pct'] - tail_3sigma['normal_tail_probability_pct']:.2f}%")

print(f"\nReturns beyond ±5σ from mean:")
print(f"  Right tail (> μ + 5σ):  {tail_5sigma['right_tail_count']:4d} occurrences "
      f"({tail_5sigma['right_tail_pct']:5.2f}%)")
print(f"  Left tail  (< μ - 5σ):  {tail_5sigma['left_tail_count']:4d} occurrences "
      f"({tail_5sigma['left_tail_pct']:5.2f}%)")
print(f"  Total:                  {tail_5sigma['right_tail_count'] + tail_5sigma['left_tail_count']:4d} "
      f"({tail_5sigma['total_tail_pct']:5.2f}%)")
print(f"  Normal distribution predicts: {tail_5sigma['normal_tail_probability_pct']:.6f}%")
print(f"  Excess over normal:     {tail_5sigma['total_tail_pct'] - tail_5sigma['normal_tail_probability_pct']:.4f}%")

print("\n" + "="*70)

results = {
    'ticker': ticker,
    'window': window,
    'data': df_clean,
    'dist_stats': dist_stats,
    'tail_3sigma': tail_3sigma,
    'tail_5sigma': tail_5sigma,
    'rolling_stats': rolling_stats
}

return results

def create_visualizations(results: dict, output_dir: str = '/tmp'): """ Create comprehensive visualizations of empirical volatility analysis.

Parameters
----------
results : dict
    Results dictionary from empirical_volatility_analysis().
output_dir : str, default '/tmp'
    Directory to save figures.
"""
df = results['data']
ticker = results['ticker']
dist_stats = results['dist_stats']
tail_3sigma = results['tail_3sigma']

# Figure 1: Price history
fig, ax = plt.subplots(figsize=(14, 4))
ax.plot(df.index, df['Close'], linewidth=1.5, color='navy')
ax.set_xlabel('Date')
ax.set_ylabel('Close Price')
ax.set_title(f'{ticker} Historical Price')
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig(f'{output_dir}/quantpie_price_history.png', dpi=150)
print(f"Saved: {output_dir}/quantpie_price_history.png")
plt.close()

# Figure 2: Returns and rolling volatility
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 8))

# Returns
ax1.plot(df.index, df['Return'], alpha=0.5, linewidth=0.8, color='steelblue')
ax1.fill_between(df.index, df['Rolling_Vol'], -df['Rolling_Vol'],
                  alpha=0.3, color='red', label='±30-day Rolling Vol')
ax1.set_ylabel('Daily Return')
ax1.set_title(f'{ticker} Daily Returns and Rolling Volatility')
ax1.grid(True, alpha=0.3)
ax1.legend()

# Rolling volatility
ax2.plot(df.index, df['Rolling_Vol'], linewidth=1.5, color='red', label='30-day rolling std')
ax2.fill_between(df.index, df['Rolling_Vol'], alpha=0.3, color='red')
ax2.set_xlabel('Date')
ax2.set_ylabel('Volatility (daily)')
ax2.set_title('Time-Varying Volatility')
ax2.grid(True, alpha=0.3)
ax2.legend()

plt.tight_layout()
plt.savefig(f'{output_dir}/quantpie_returns_and_vol.png', dpi=150)
print(f"Saved: {output_dir}/quantpie_returns_and_vol.png")
plt.close()

# Figure 3: Histogram with normal fit (high peak)
fig, ax = plt.subplots(figsize=(10, 6))

mu = dist_stats['mean']
sigma = dist_stats['std']
returns = df['Return'].values

# Histogram
n_bins = 100
counts, bins, patches = ax.hist(returns, bins=n_bins, density=True,
                                 alpha=0.7, color='steelblue', edgecolor='black', linewidth=0.5)

# Normal fit
x = np.linspace(returns.min(), returns.max(), 200)
y_normal = stats.norm(mu, sigma).pdf(x)
ax.plot(x, y_normal, 'r--', linewidth=2.5, label=f'Normal(μ={mu:.6f}, σ={sigma:.6f})')

ax.set_xlabel('Daily Return')
ax.set_ylabel('Probability Density')
ax.set_title(f'{ticker} Return Distribution: High Peak (Leptokurtic)')
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig(f'{output_dir}/quantpie_high_peak.png', dpi=150)
print(f"Saved: {output_dir}/quantpie_high_peak.png")
plt.close()

# Figure 4: Fat right tail
fig, ax = plt.subplots(figsize=(10, 6))

threshold_3sigma = mu + 3 * sigma
right_tail_mask = returns > threshold_3sigma

if right_tail_mask.sum() > 0:
    right_tail_returns = returns[right_tail_mask]

    counts, bins, patches = ax.hist(right_tail_returns, bins=30, density=True,
                                    alpha=0.7, color='steelblue', edgecolor='black', linewidth=0.5)

    # Normal fit for comparison
    x = np.linspace(right_tail_returns.min(), right_tail_returns.max(), 100)
    y_normal = stats.norm(mu, sigma).pdf(x)
    ax.plot(x, y_normal, 'r--', linewidth=2.5, label='Normal PDF')

    ax.set_xlabel('Daily Return')
    ax.set_ylabel('Probability Density')
    ax.set_title(f'{ticker} Right Tail (r > μ + 3σ): Fat Tail Evidence')
    ax.legend(fontsize=10)
    ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig(f'{output_dir}/quantpie_fat_right_tail.png', dpi=150)
print(f"Saved: {output_dir}/quantpie_fat_right_tail.png")
plt.close()

# Figure 5: Fat left tail
fig, ax = plt.subplots(figsize=(10, 6))

threshold_left = mu - 3 * sigma
left_tail_mask = returns < threshold_left

if left_tail_mask.sum() > 0:
    left_tail_returns = returns[left_tail_mask]

    counts, bins, patches = ax.hist(left_tail_returns, bins=30, density=True,
                                    alpha=0.7, color='salmon', edgecolor='black', linewidth=0.5)

    # Normal fit for comparison
    x = np.linspace(left_tail_returns.min(), left_tail_returns.max(), 100)
    y_normal = stats.norm(mu, sigma).pdf(x)
    ax.plot(x, y_normal, 'r--', linewidth=2.5, label='Normal PDF')

    ax.set_xlabel('Daily Return')
    ax.set_ylabel('Probability Density')
    ax.set_title(f'{ticker} Left Tail (r < μ - 3σ): Fat Tail & Downside Risk')
    ax.legend(fontsize=10)
    ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig(f'{output_dir}/quantpie_fat_left_tail.png', dpi=150)
print(f"Saved: {output_dir}/quantpie_fat_left_tail.png")
plt.close()

print(f"\nAll visualizations saved to {output_dir}/")

def main(): """Main execution function.""" # Run empirical analysis results = empirical_volatility_analysis(ticker='WMT', window=30)

# Create visualizations
create_visualizations(results, output_dir='/tmp')

return results

if name == "main": main() ```

Exercises

Exercise 1. In the Heston model, the vol-of-vol parameter \(\sigma_v\) controls the curvature of the implied volatility smile. Explain this connection qualitatively.

Solution to Exercise 1

Higher \(\sigma_v\) means the variance process \(v(t)\) fluctuates more widely, creating a broader distribution of realized volatilities over the option lifetime. This broadening increases the kurtosis of the log-return distribution, which manifests as higher curvature (more pronounced U-shape) in the IV smile. Low \(\sigma_v\) gives a nearly flat smile (approaching GBM).


Exercise 2. The SABR model uses \(dF = \alpha F^\beta\,dW_1\), \(d\alpha = \nu\alpha\,dW_2\). Explain the role of \(\beta\) in controlling the smile shape.

Solution to Exercise 2

\(\beta\) controls the backbone: how the ATM volatility changes with the forward level. \(\beta = 1\) gives lognormal dynamics (percentage vol constant). \(\beta = 0\) gives normal dynamics (absolute vol constant). \(\beta \in (0,1)\) interpolates. Higher \(\beta\) produces less skew for the same \(\nu\). The choice of \(\beta\) affects the overall smile shape and is often fixed before calibrating \(\alpha, \nu, \rho\).


Exercise 3. Explain the leverage effect: why do stock returns and volatility tend to be negatively correlated (\(\rho < 0\))?

Solution to Exercise 3

Several explanations: (1) Leverage hypothesis: when a firm stock drops, its debt-to-equity ratio increases, making it riskier (higher vol). (2) Feedback effect: increased vol raises risk premiums, pushing prices down. (3) Behavioral: panic selling during declines increases vol. Empirically, \(\rho \approx -0.7\) for equity indices. This asymmetry is the primary driver of the IV skew.


Exercise 4. Compare the Heston and SABR models for calibrating to equity index smiles. Which is better for short maturities? For long maturities?

Solution to Exercise 4

SABR excels at fitting individual maturity smiles (one smile at a time) and is standard for interest rate options. Heston is better for fitting the entire volatility surface (all maturities simultaneously) because it has a proper term structure model for variance. For short maturities, SABR flexibility can match steep smiles well. For long maturities, Heston mean-reversion \(\kappa(\theta - v)\) naturally captures the flattening of the smile term structure.