Week 7: Multivariate Volatility

Version 5.0 - August 2026

Author
Affiliation

Jon Danielsson

London School of Economics

7 Week 7

Portfolio risk depends on more than the volatility of each asset. It also depends on whether the assets move together. Correlation is not constant. It can change through time and often rises during market stress, just when diversification is most valuable.

This week studies that idea directly. We calculate rolling correlations, compare calmer and more turbulent periods, build an exponentially weighted moving average (EWMA) covariance matrix, and show how correlation changes portfolio volatility.

NoteBefore you start

Open the main course folder and check that Returns.csv, created in Week 2, is present. You should be comfortable selecting columns from a pandas data frame and interpreting correlation. This week introduces no additional libraries.

TipBy the end of this session

You should be able to plot a rolling correlation, explain why correlations change, calculate EWMA covariance and correlation estimates, and show how correlation affects portfolio risk.

7.1 The plan for this week

  1. Plot rolling correlations
  2. Compare calm and crisis periods
  3. Update a covariance matrix with EWMA
  4. Convert covariances to correlations
  5. Show how correlation changes portfolio volatility

7.2 Loading data and libraries

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

Returns = pd.read_csv("Returns.csv", index_col="date", parse_dates=True)
assets = ["JPM", "C", "XOM", "AAPL"]
y = Returns[assets].dropna()
NoteCheck your result

y should have four columns, one for each asset in assets, and a date index. The values are decimal daily returns.

print(y.shape)
print(y.index.min(), "to", y.index.max())
y.head()
(6470, 4)
2000-01-03 00:00:00 to 2025-09-23 00:00:00
JPM C XOM AAPL
date
2000-01-03 -0.063952 -0.049464 -0.028324 0.085055
2000-01-04 -0.022182 -0.063281 -0.019338 -0.088040
2000-01-05 -0.006196 0.039414 0.053081 0.014454
2000-01-06 0.014101 0.047178 0.050405 -0.090453
2000-01-07 0.018204 -0.004619 -0.002937 0.046316
CautionUnits used this week
  • Returns are daily decimals: 1% is 0.01.
  • Variances and covariances are in daily return-squared units.
  • Volatility is daily unless a plot explicitly says it is annualised.
  • Correlations have no units and must lie between -1 and 1.

7.3 Rolling correlations

A correlation calculated from the full sample gives one number for the entire period. A rolling correlation instead uses a moving window. Here each point uses the latest 60 trading days.

window = 60
rolling_jpm_c = y["JPM"].rolling(window).corr(y["C"])

fig, ax = plt.subplots()
ax.plot(rolling_jpm_c.index, rolling_jpm_c)
ax.axhline(0, color="black", linewidth=0.8)
ax.set_title("60-day rolling correlation: JPM and C")
ax.set_xlabel("Date")
ax.set_ylabel("Correlation")
ax.set_ylim(-1, 1)
plt.show()

NoteCheck your result

The first 59 values are missing because a complete 60-day window is not yet available. After that, every value should lie between -1 and 1, and the line should visibly change through time.

valid_rolling = rolling_jpm_c.dropna()
assert valid_rolling.between(-1, 1).all()
print("lowest rolling correlation:", round(valid_rolling.min(), 3))
print("highest rolling correlation:", round(valid_rolling.max(), 3))
lowest rolling correlation: 0.339
highest rolling correlation: 0.972

Different pairs need not behave alike.

rolling_xom_aapl = y["XOM"].rolling(window).corr(y["AAPL"])

fig, ax = plt.subplots()
ax.plot(rolling_jpm_c.index, rolling_jpm_c, label="JPM-C")
ax.plot(rolling_xom_aapl.index, rolling_xom_aapl, label="XOM-AAPL")
ax.set_title("Rolling correlations for two asset pairs")
ax.set_xlabel("Date")
ax.set_ylabel("Correlation")
ax.set_ylim(-1, 1)
ax.legend(frameon=False)
plt.show()

7.4 Calm and crisis periods

The dates below provide a simple comparison, not a universal definition of “calm” and “crisis”. The important step is to state the dates before inspecting the answer. Note also that the calm window holds three years of returns and the crisis window five months, so the two correlations are not estimated with the same precision, and a difference between them is not by itself a statistical finding.

calm = y[(y.index >= "2017-01-01") & (y.index <= "2019-12-31")]
crisis = y[(y.index >= "2020-02-01") & (y.index <= "2020-06-30")]

period_comparison = pd.DataFrame(
    {
        "Calm period": [
            calm["JPM"].corr(calm["C"]),
            calm["XOM"].corr(calm["AAPL"]),
        ],
        "Crisis period": [
            crisis["JPM"].corr(crisis["C"]),
            crisis["XOM"].corr(crisis["AAPL"]),
        ],
    },
    index=["JPM-C", "XOM-AAPL"],
)
period_comparison.round(3)
Calm period Crisis period
JPM-C 0.859 0.938
XOM-AAPL 0.372 0.682
NoteCheck your result

Do not stop at “the numbers are different”. State which pair changed more, whether its correlation rose or fell, and why that matters for diversification.

7.5 EWMA covariance

EWMA gives more weight to recent observations. Its covariance recursion is

\[ H_t = \lambda H_{t-1} + (1-\lambda)y_{t-1}y_{t-1}'. \]

Formula Python name Meaning
\(H_t\) ewma_cov[t] Covariance forecast for date \(t\), using returns through \(t-1\)
\(H_{t-1}\) ewma_cov[t - 1] Previous covariance matrix
\(y_{t-1}\) values[t - 1] Previous day’s vector of returns
\(y_{t-1}y_{t-1}'\) np.outer(...) Matrix of squared returns and cross-products
\(\lambda\) lambda_ Weight retained from the previous estimate

The recursion uses raw products rather than deviations from a mean, so it treats the expected daily return as zero. For daily data that is a small assumption, and it is applied consistently below, including in the starting value.

Because \(H_t\) depends only on returns up to \(t-1\), it is a forecast that could have been made on the previous day. That is what makes it usable in the backtesting of Week 10.

For two returns, the outer product contains their squared returns on the diagonal and their cross-product off the diagonal.

first_returns = y.iloc[0].to_numpy()
np.outer(first_returns, first_returns)
array([[ 0.00408991,  0.00316335,  0.00181137, -0.00543945],
       [ 0.00316335,  0.0024467 ,  0.001401  , -0.00420715],
       [ 0.00181137,  0.001401  ,  0.00080223, -0.00240906],
       [-0.00543945, -0.00420715, -0.00240906,  0.00723429]])

7.5.1 A function for EWMA covariances

The function below carries a covariance matrix through the full sample and then turns each covariance into a correlation matrix. The three-dimensional array and the broadcasting are incidental to this week. Run the block unchanged and focus on its inputs and outputs.

The recursion has to start somewhere. The first initial_days returns provide the starting matrix, as their average outer product, and it is placed at the first date after that period. Every earlier date is left missing, because no forecast could have been made there without using returns that had not yet occurred.

def ewma_covariances(returns, lambda_=0.94, initial_days=60):
    values = returns.to_numpy()
    observations, number_of_assets = values.shape
    covariance = np.full(
        (observations, number_of_assets, number_of_assets), np.nan
    )
    initial = values[:initial_days]
    covariance[initial_days] = initial.T @ initial / initial_days

    for t in range(initial_days + 1, observations):
        latest_outer_product = np.outer(values[t - 1], values[t - 1])
        covariance[t] = (
            lambda_ * covariance[t - 1]
            + (1 - lambda_) * latest_outer_product
        )

    standard_deviations = np.sqrt(
        np.diagonal(covariance, axis1=1, axis2=2)
    )
    correlation = (
        covariance
        / standard_deviations[:, :, None]
        / standard_deviations[:, None, :]
    )
    return covariance, correlation
lambda_ = 0.94
ewma_cov, ewma_corr = ewma_covariances(y, lambda_=lambda_)
print("covariance array shape:", ewma_cov.shape)
print("correlation array shape:", ewma_corr.shape)
covariance array shape: (6470, 4, 4)
correlation array shape: (6470, 4, 4)

The first index is time, and the next two identify the row and column of the matrix. For example, indices 0 and 1 select JPM and C.

The two lines below are not quite measured on the same information. The rolling correlation at date \(t\) uses the sixty returns up to and including \(t\), while the EWMA line is the forecast for \(t\) made with returns through \(t-1\). The offset is one day against a sixty-day window, so it is invisible here, but it is the difference between describing the past and forecasting the next day.

jpm_c_ewma = pd.Series(ewma_corr[:, 0, 1], index=y.index)

fig, ax = plt.subplots()
ax.plot(rolling_jpm_c.index, rolling_jpm_c, label="60-day rolling")
ax.plot(jpm_c_ewma.index, jpm_c_ewma, label="EWMA", alpha=0.8)
ax.set_title("JPM-C correlation estimates")
ax.set_xlabel("Date")
ax.set_ylabel("Correlation")
ax.set_ylim(-1, 1)
ax.legend(frameon=False)
plt.show()

NoteCheck your result

The first 60 dates should be missing, since no forecast exists for them. After that every EWMA covariance matrix should be symmetric, every variance on its diagonal should be positive, and every correlation should be between -1 and 1.

assert np.allclose(
    ewma_cov, np.transpose(ewma_cov, (0, 2, 1)), equal_nan=True
)
variances = np.diagonal(ewma_cov, axis1=1, axis2=2)
assert (variances[60:] > 0).all()
assert np.nanmin(ewma_corr) >= -1 - 1e-12
assert np.nanmax(ewma_corr) <= 1 + 1e-12

These checks do not establish that each matrix is a valid covariance matrix. That also requires positive semidefiniteness, which exercise 7 examines through the eigenvalues.

7.6 Correlation and portfolio risk

For a two-asset portfolio,

\[ \sigma_p = \sqrt{w_1^2\sigma_1^2 + w_2^2\sigma_2^2 + 2w_1w_2\rho_{12}\sigma_1\sigma_2}. \]

Everything except correlation is held fixed below. This isolates the effect of assets moving together.

sigma_jpm = y["JPM"].std()
sigma_c = y["C"].std()
w_jpm = 0.5
w_c = 0.5
correlation_scenarios = np.array([-0.5, 0.0, 0.5, 0.9])

portfolio_volatility = np.sqrt(
    w_jpm**2 * sigma_jpm**2
    + w_c**2 * sigma_c**2
    + 2 * w_jpm * w_c * correlation_scenarios * sigma_jpm * sigma_c
)

pd.DataFrame(
    {
        "Correlation": correlation_scenarios,
        "Daily portfolio volatility": portfolio_volatility,
    }
).round(4)
Correlation Daily portfolio volatility
0 -0.5 0.0133
1 0.0 0.0186
2 0.5 0.0227
3 0.9 0.0255

Higher positive correlation produces higher portfolio volatility because the covariance term in the formula grows, so less of each position’s variation is offset by the other. Correlation measures that linear co-movement. It says nothing on its own about whether the two assets suffer their worst days together.

We can also use each EWMA covariance matrix to estimate the volatility of an equal-weighted four-asset portfolio. The second line shows what the estimate would be if all covariances were set to zero while keeping the same individual variances.

weights = np.full(len(assets), 1 / len(assets))
portfolio_variance = np.einsum("i,tij,j->t", weights, ewma_cov, weights)
zero_covariance_variance = np.sum(
    weights**2 * np.diagonal(ewma_cov, axis1=1, axis2=2), axis=1
)

fig, ax = plt.subplots()
ax.plot(y.index, np.sqrt(252 * portfolio_variance), label="With EWMA covariances")
ax.plot(
    y.index,
    np.sqrt(252 * zero_covariance_variance),
    label="If covariances were zero",
)
ax.set_title("Equal-weight portfolio volatility")
ax.set_xlabel("Date")
ax.set_ylabel("Annualised volatility")
ax.legend(frameon=False)
plt.show()

NoteCheck your result

The plotted values are annualised by multiplying daily variance by 252 before taking its square root. That expresses today’s daily volatility in annual units. It is not a forecast of volatility over the coming year, which would require the covariance matrix to stay as it is for a year. Explain why ignoring positive covariances usually makes the portfolio look safer than it is.

7.7 Recap

  • A full-sample correlation hides changes through time.
  • A rolling window gives equal weight to recent observations inside the window.
  • EWMA gives progressively less weight to older observations.
  • A covariance matrix contains individual variances on its diagonal and cross-asset covariances off the diagonal.
  • Higher positive correlation reduces diversification and raises portfolio risk, holding individual volatilities fixed.

7.8 Exercises

7.8.1 Apply the method

  1. Plot the 60-day rolling correlation between JPM and AAPL. Identify a period when it was relatively high and one when it was relatively low.
  2. Compare JPM-AAPL correlation in the calm and crisis date ranges used above.
  3. Use the two-asset formula to explain what happens to portfolio volatility as correlation changes from 0 to 0.8.

7.8.2 Compare results

  1. Recalculate EWMA with lambda_=0.90 and lambda_=0.97. Which line responds more quickly to new observations, and why?
  2. Choose two different asset pairs and compare their rolling and EWMA correlations.

7.8.3 Extend the analysis

  1. Change the equal portfolio weights and investigate how the effect of covariance changes.
  2. Check the eigenvalues of every EWMA covariance matrix and explain what positive semidefiniteness means for a covariance matrix.