← Back to Home
Measuring and Monitoring Financial Volatility

Measuring and Monitoring Financial Volatility

Volatility, in finance, refers to the degree of variation in the trading price of an asset over time. It’s a crucial concept for risk management, option pricing, and portfolio construction. High volatility means the price can change dramatically over a short period in either direction, while low volatility indicates smaller price fluctuations. This guide explores how asset return distributions often differ from the simple normal distribution and introduces practical methods for estimating and monitoring volatility using historical data.

Why Returns Aren’t Always “Normal”

While the normal distribution is a common starting point in finance, real-world asset returns often deviate from it in several ways:

  1. Fat Tails (Leptokurtosis): Return distributions frequently exhibit “fat tails,” meaning extreme events (large positive or negative returns) occur more often than predicted by a normal distribution. This can happen if the underlying volatility isn’t constant. Imagine volatility switching between low and high states; the overall distribution becomes a mixture, more peaked in the center and with heavier tails than a single normal distribution with the same overall standard deviation.
  2. Skewness (Non-Symmetry): Sometimes, the distribution isn’t symmetrical. Large negative returns might be more common than large positive returns (negative skew), or vice-versa. This can arise if both the average return (mean) and the volatility change over time in related ways.
  3. Instability: The parameters defining the return distribution (like the average return and volatility) are not constant. Volatility, in particular, clusters – periods of high volatility tend to follow high volatility, and low periods follow low periods. However, sudden jumps or regime switches can also occur, where volatility abruptly shifts to a new level due to unexpected events.

For short-term risk management (like daily calculations), the changing nature of volatility is often the most critical deviation from simple normality.

Conditional vs. Unconditional Views

Measuring Basic Historical Volatility

Volatility is typically measured as the standard deviation of returns. The return on day i for an asset with price S (assuming no dividends) is:

r_i = \frac{S_i - S_{i-1}}{S_{i-1}}

A standard statistical estimate for variance (the square of volatility) based on the most recent m daily returns (r_{n-1}, r_{n-2}, ..., r_{n-m}) is:

\sigma_{n}^{2} = \frac{1}{m-1}\sum_{i=1}^{m}(r_{n-i}-\overline{r})^{2}

where \overline{r} is the average return over the m days.

For daily risk management, this is often simplified by:

  1. Assuming the average daily return (\overline{r}) is zero (a reasonable approximation for short periods).
  2. Using m in the denominator instead of m-1 (this corresponds to the maximum likelihood estimate).

The simplified variance rate estimate becomes the average of the squared returns:

\sigma_{n}^{2} = \frac{1}{m}\sum_{i=1}^{m}r_{n-i}^{2}

The daily volatility is then \sigma_n = \sqrt{\sigma_{n}^{2}}.

Python Example: Simple Historical Volatility

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

# Download historical data (e.g., S&P 500 ETF)
ticker = "SPY"
data = yf.download(ticker, start="2020-01-01", end="2023-12-31", auto_adjust=False)

# Calculate daily returns
data['Return'] = data['Adj Close'].pct_change()

# Simplified historical volatility (e.g., using a 20-day rolling window)
m = 20
data['Simple_Variance'] = data['Return'].pow(2).rolling(window=m).mean()
data['Simple_Volatility'] = np.sqrt(data['Simple_Variance']) * np.sqrt(252) # Annualized

# Plotting
plt.figure(figsize=(12, 6))
data['Simple_Volatility'].plot(title=f'{ticker} {m}-Day Rolling Annualized Volatility (Simple Method)')
plt.ylabel("Annualized Volatility")
plt.grid(True)
plt.show()

# Display last few values
print(f"Last few {m}-day simple volatility estimates (annualized):")
print(data['Simple_Volatility'].tail())
Pasted image 20250513044408.png

This simple method gives equal weight to all m past observations. If m is large, the estimate reacts slowly to changes. If m is small, the estimate is noisy. A sudden large return also causes the volatility estimate to jump up and then jump down m days later when the observation drops out of the window.

Exponentially Weighted Moving Average (EWMA)

EWMA addresses the shortcomings of the simple method by giving more weight to recent observations. Weights decrease exponentially as observations get older.

The update formula for the variance on day n is recursive:

\sigma_{n}^{2}=(1-\lambda)r_{n-1}^{2}+\lambda\sigma_{n-1}^{2}

Here:

A higher \lambda (e.g., 0.97) means slower decay and more weight on past variance estimates (less responsive). A lower \lambda (e.g., 0.90) means faster decay and more weight on the latest squared return (more responsive). A common value, historically used by RiskMetrics, is \lambda = 0.94.

Python Example: EWMA Volatility

# EWMA calculation
lambda_ewma = 0.94
# Use the simple variance as the starting point (or use a longer historical average)
initial_variance = data['Return'].pow(2).head(m).mean() # Use first 'm' days simple variance
data['EWMA_Variance'] = np.nan
data.iloc[m, data.columns.get_loc('EWMA_Variance')] = initial_variance # Seed value

for i in range(m + 1, len(data)):
    prev_variance = data.iloc[i-1]['EWMA_Variance']
    prev_return_sq = data.iloc[i-1]['Return']**2
    data.iloc[i, data.columns.get_loc('EWMA_Variance')] = lambda_ewma * prev_variance + (1 - lambda_ewma) * prev_return_sq

data['EWMA_Volatility'] = np.sqrt(data['EWMA_Variance']) * np.sqrt(252) # Annualized

# Plotting Comparison
plt.figure(figsize=(12, 6))
data['Simple_Volatility'].plot(label=f'{m}-Day Simple Volatility', alpha=0.7)
data['EWMA_Volatility'].plot(label=f'EWMA Volatility (lambda={lambda_ewma})', alpha=0.7)
plt.title(f'{ticker} Annualized Volatility Comparison')
plt.ylabel("Annualized Volatility")
plt.legend()
plt.grid(True)
plt.show()

# Display last few values
print(f"\nLast few EWMA volatility estimates (annualized, lambda={lambda_ewma}):")
print(data['EWMA_Volatility'].tail())
Pasted image 20250513044616.png

EWMA requires storing only the previous day’s variance estimate, making it efficient. It adapts more smoothly to changing volatility than the simple rolling window method.

GARCH (1,1) Model

The Generalized Autoregressive Conditional Heteroskedasticity (GARCH) model, specifically GARCH(1,1), is an extension of EWMA. It adds a term that pulls the volatility estimate towards a long-run average level, incorporating mean reversion.

The GARCH(1,1) update formula for variance is:

\sigma_{n}^{2}=\omega+\alpha r_{n-1}^{2}+\beta\sigma_{n-1}^{2}

Where:

The long-run average variance V_L towards which the model reverts is:

V_L = \frac{\omega}{1-\alpha-\beta}

The term \gamma = 1 - \alpha - \beta represents the weight given to V_L in the full formula: \sigma_{n}^{2}=\gamma V_L + \alpha r_{n-1}^{2}+\beta\sigma_{n-1}^{2}.

EWMA is a special case of GARCH(1,1) where \omega=0 (so V_L=0) and \alpha + \beta = 1. GARCH(1,1) acknowledges that volatility might be unusually high or low temporarily but is expected to eventually return to a more “normal” long-term level.

Python Example: GARCH (1,1) Volatility (Note: Estimating GARCH parameters \omega, \alpha, \beta typically requires statistical packages like arch. For simplicity here, we’ll assume some plausible values.)

# Assumed GARCH(1,1) parameters (these should ideally be estimated)
omega = data['Return'].var() * (1 - 0.06 - 0.92) # Example: targeting sample variance
alpha = 0.06
beta = 0.92

print(f"\nAssumed GARCH Parameters: omega={omega:.8f}, alpha={alpha}, beta={beta}")
long_run_variance = omega / (1 - alpha - beta)
print(f"Implied Long-Run Annualized Volatility: {np.sqrt(long_run_variance * 252):.4f}")

# GARCH calculation
data['GARCH_Variance'] = np.nan
# Use long-run variance or sample variance as the starting point
data.iloc[m, data.columns.get_loc('GARCH_Variance')] = long_run_variance

for i in range(m + 1, len(data)):
    prev_variance = data.iloc[i-1]['GARCH_Variance']
    prev_return_sq = data.iloc[i-1]['Return']**2
    data.iloc[i, data.columns.get_loc('GARCH_Variance')] = omega + alpha * prev_return_sq + beta * prev_variance

data['GARCH_Volatility'] = np.sqrt(data['GARCH_Variance']) * np.sqrt(252) # Annualized

# Plotting Comparison
plt.figure(figsize=(12, 6))
data['Simple_Volatility'].plot(label=f'{m}-Day Simple Volatility', alpha=0.6)
data['EWMA_Volatility'].plot(label=f'EWMA Volatility (lambda={lambda_ewma})', alpha=0.6)
data['GARCH_Volatility'].plot(label=f'GARCH(1,1) Volatility', alpha=0.6)
plt.axhline(np.sqrt(long_run_variance * 252), color='red', linestyle='--', label='GARCH Long-Run Volatility', alpha=0.8)
plt.title(f'{ticker} Annualized Volatility Comparison')
plt.ylabel("Annualized Volatility")
plt.legend()
plt.grid(True)
plt.show()

# Display last few values
print(f"\nLast few GARCH(1,1) volatility estimates (annualized):")
print(data['GARCH_Volatility'].tail())
Assumed GARCH Parameters: omega=0.00000406, alpha=0.06, beta=0.92
Implied Long-Run Annualized Volatility: 0.2263
Pasted image 20250513045010.png

Volatility Over Longer Horizons

How does daily volatility relate to weekly or monthly volatility? If returns were independent and identically distributed (constant volatility), the variance over T days would be T times the daily variance. The volatility over T days would be \sqrt{T} times the daily volatility (the square root rule).

However, if volatility mean-reverts (as in GARCH), this rule needs adjustment.

The GARCH(1,1) model allows forecasting future variance. The expected variance t days ahead (\sigma_{n+t}^{2}), given the current variance \sigma_{n}^{2}, is:

\sigma_{n+t}^{2}=V_{L}+(\alpha+\beta)^{t}(\sigma_{n}^{2}-V_{L})

To estimate volatility over the next T days, we can average these expected future daily variances from t=1 to T and then take the square root of the average variance multiplied by T.

Python Example: GARCH Long-Horizon Forecast

current_variance = data['GARCH_Variance'].iloc[-1]
current_volatility_ann = data['GARCH_Volatility'].iloc[-1]

print(f"\nCurrent Daily Variance: {current_variance:.8f}")
print(f"Current Annualized Volatility: {current_volatility_ann:.4f}")

T = 21 # Forecast horizon (e.g., 21 trading days ~ 1 month)
forecasted_variances = []
persistence = alpha + beta

for t in range(1, T + 1):
    expected_variance_t = long_run_variance + (persistence**t) * (current_variance - long_run_variance)
    forecasted_variances.append(expected_variance_t)

average_variance_T = np.mean(forecasted_variances)
volatility_T_days_ann = np.sqrt(average_variance_T * 252)

# Compare with simple square root rule
volatility_T_sqrt_rule_ann = current_volatility_ann # Already annualized, just use current daily annualized

print(f"\n{T}-Day Forecast:")
print(f"  Average Daily Variance over next {T} days: {average_variance_T:.8f}")
print(f"  GARCH Implied Annualized Volatility over {T} days: {volatility_T_days_ann:.4f}")
print(f"  Simple Square Root Rule Implied Ann. Volatility: {volatility_T_sqrt_rule_ann:.4f} (based on current daily)")
Current Daily Variance: 0.00008221
Current Annualized Volatility: 0.1439

21-Day Forecast:
  Average Daily Variance over next 21 days: 0.00010559
  GARCH Implied Annualized Volatility over 21 days: 0.1631
  Simple Square Root Rule Implied Ann. Volatility: 0.1439 (based on current daily)

Implied Volatility

Volatility can also be inferred from option prices. The price of an option depends on the expected future volatility of the underlying asset. Implied volatility is the volatility value that, when plugged into an option pricing model (like Black-Scholes), matches the observed market price of the option.

Evidence suggests implied volatility is often a better predictor of actual future volatility than historical measures. However, options aren’t traded on all assets, and implied volatilities can sometimes be influenced by market sentiment or supply/demand imbalances in the options market itself. The VIX index, for instance, measures the implied volatility of S&P 500 options and is often called the “fear gauge.”

Monitoring Correlation

Risk often depends not just on individual asset volatilities but also on how assets move together, measured by correlation. Like volatility, correlation is not constant.

We can use EWMA to update the covariance between two return series, x and y:

cov_{n}=\lambda cov_{n-1}+(1-\lambda)x_{n-1}y_{n-1}

Here, cov_n is the covariance estimate for day n, cov_{n-1} is the previous day’s estimate, and x_{n-1}, y_{n-1} are the most recent returns for the two assets. The same \lambda should be used as for the individual asset variance updates using EWMA.

The correlation estimate for day n (\rho_n) is then calculated using the updated covariance and the updated individual variances (\sigma_{x,n}^2, \sigma_{y,n}^2):

\rho_n = \frac{cov_n}{\sqrt{\sigma_{x,n}^2} \sqrt{\sigma_{y,n}^2}} = \frac{cov_n}{\sigma_{x,n} \sigma_{y,n}}

Python Example: EWMA Correlation

# Download data for a second asset (e.g., Nasdaq 100 ETF)
ticker2 = "QQQ"
data2 = yf.download(ticker2, start="2020-01-01", end="2023-12-31", auto_adjust=False)
data2['Return'] = data2['Adj Close'].pct_change()

# Combine returns into one DataFrame, align dates, drop NaNs
returns = pd.DataFrame({
    ticker: data['Return'],
    ticker2: data2['Return']
}).dropna()

# Calculate EWMA variances for both assets
lambda_corr = 0.94 # Use consistent lambda
variances = pd.DataFrame(index=returns.index)

for asset in [ticker, ticker2]:
    variances[f'{asset}_Variance'] = np.nan
    initial_var = returns[asset].head(m).var() # Use sample variance for seeding
    variances.iloc[m, variances.columns.get_loc(f'{asset}_Variance')] = initial_var
    for i in range(m + 1, len(returns)):
        prev_var = variances.iloc[i-1][f'{asset}_Variance']
        prev_ret_sq = returns.iloc[i-1][asset]**2
        variances.iloc[i, variances.columns.get_loc(f'{asset}_Variance')] = lambda_corr * prev_var + (1 - lambda_corr) * prev_ret_sq

# Calculate EWMA covariance
variances['Covariance'] = np.nan
# Seed with sample covariance
initial_cov = returns[[ticker, ticker2]].head(m).cov().iloc[0, 1]
variances.iloc[m, variances.columns.get_loc('Covariance')] = initial_cov

for i in range(m + 1, len(returns)):
    prev_cov = variances.iloc[i-1]['Covariance']
    prev_ret_prod = returns.iloc[i-1][ticker] * returns.iloc[i-1][ticker2]
    variances.iloc[i, variances.columns.get_loc('Covariance')] = lambda_corr * prev_cov + (1 - lambda_corr) * prev_ret_prod

# Calculate EWMA correlation
variances['Correlation'] = variances['Covariance'] / np.sqrt(variances[f'{ticker}_Variance'] * variances[f'{ticker2}_Variance'])

# Plotting Correlation
plt.figure(figsize=(12, 6))
variances['Correlation'].plot(title=f'EWMA Correlation between {ticker} and {ticker2} (lambda={lambda_corr})')
plt.ylabel("Correlation Coefficient")
plt.grid(True)
plt.show()

# Display last few values
print(f"\nLast few EWMA correlation estimates ({ticker} vs {ticker2}, lambda={lambda_corr}):")
print(variances['Correlation'].tail())
Pasted image 20250513045303.png

Conclusion

Volatility and correlation are dynamic. Simple historical measures can be misleading. Models like EWMA and GARCH(1,1) provide practical ways to monitor these crucial risk parameters by giving more weight to recent information. GARCH, with its inclusion of mean reversion, offers a more sophisticated view, particularly useful for forecasting volatility over longer horizons. Combining these historical estimates with forward-looking implied volatility (when available) gives risk managers a more complete picture of potential market movements.