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()Week 7: Multivariate Volatility
Version 5.0 - August 2026
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.
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.
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
- Plot rolling correlations
- Compare calm and crisis periods
- Update a covariance matrix with EWMA
- Convert covariances to correlations
- Show how correlation changes portfolio volatility
7.2 Loading data and libraries
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 |
- 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()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 |
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, correlationlambda_ = 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()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-12These 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()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
- 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.
- Compare JPM-AAPL correlation in the calm and crisis date ranges used above.
- 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
- Recalculate EWMA with
lambda_=0.90andlambda_=0.97. Which line responds more quickly to new observations, and why? - Choose two different asset pairs and compare their rolling and EWMA correlations.
7.8.3 Extend the analysis
- Change the equal portfolio weights and investigate how the effect of covariance changes.
- Check the eigenvalues of every EWMA covariance matrix and explain what positive semidefiniteness means for a covariance matrix.