The covariance matrix
The data are invented but typical, in annual decimals: 0.12 means 12% a year.
| asset | A | B | C |
|---|---|---|---|
| expected return | 0.06 | 0.08 | 0.11 |
| volatility | 0.12 | 0.18 | 0.25 |
| weight | 0.5 | 0.3 | 0.2 |
The correlations are , and . As in case study 1.2.4, the volatility is how widely asset 's yearly return typically swings. The correlation , between and 1, says how closely two assets move together.
The covariance of two assets is their correlation times both volatilities, . Module 5.5 defines it from probability; here this product is all you need. An asset's covariance with itself is its variance , because .
Rule: The covariance matrix
(capital sigma) holds every variance on its diagonal and every covariance off it. is the correlation matrix, with entries and 1 on its diagonal, and is the diagonal matrix of the volatilities.
Multiplying by on the left scales row of by ; multiplying by on the right scales column by (lesson 3.2.1). So entry of is . For the three assets:
The diagonal holds , and . The entry appears twice, in row A and in row B.
is symmetric (lesson 3.2.3), because . The transpose rule says the same: , since and are symmetric. Its entries are annual variances and covariances, in squared decimals.
Try it
np.diag(sigma) builds . The second line checks against np.outer(sigma, sigma) * R, the products times entry by entry. It also checks that is symmetric.
The last line repeats case study 3.1.4: , and the weights add up to 1, , where is the vector of ones.
Portfolio variance, three ways
First way: the double sum
The portfolio's return is , where , and are the assets' returns over the year (case study 3.1.4). Its variance adds one term for every pair of assets, an asset paired with itself included:
Here and each run over A, B and C, and is the portfolio's volatility. Module 5.5 derives the formula from the rules of variance; take it as given, as case study 1.2.4 did for two assets. With three assets there are nine terms :
| A | B | C | |
|---|---|---|---|
| A | 0.0036 | 0.000972 | 0.0003 |
| B | 0.000972 | 0.002916 | 0.00135 |
| C | 0.0003 | 0.00135 | 0.0025 |
The three diagonal terms, each asset on its own, add up to 0.009016. The six off-diagonal terms, the pairs, add 0.005244. The total is , a volatility of , or 11.94%.
Second way: the matrix product
The double sum is a row times a matrix times a column, as in lesson 3.2.3. First : each entry is a row of dotted with , giving . Then .
Rule: Portfolio variance
The shapes are , so the answer is a single number. It is in the units of : an annual variance. Its square root is the volatility.
The correlations decide where between two extremes the answer lands.
If every correlation were 0, only the diagonal would count: a variance of 0.009016 and a volatility of 9.50%. If every correlation were 1, the volatility would be the weighted average from the hook. The actual 11.94% sits between: that gap is diversification.
Try it
The double loop adds the nine terms one at a time; np.outer(w, w) * Sigma prints them as the table above.
Then , and its square root. The last two lines are the extremes: the diagonal alone, and the weighted average of the volatilities.
Two assets: case study 1.2.4 again
Case study 1.2.4 had two assets with , and , and weights in A and in B. Its covariance matrix is :
Multiplying out gives back case study 1.2.4's formula. The two off-diagonal entries, both , are where its factor 2 comes from:
The numbers match case study 1.2.4's table:
| 0 | 0.25 | 0.5 | 0.75 | 1 | |
|---|---|---|---|---|---|
| 0.09 | 0.0576 | 0.0385 | 0.0326 | 0.04 | |
| volatility | 0.3 | 0.2401 | 0.1962 | 0.1806 | 0.2 |
At its minimum-variance weight , the product gives a variance of 0.0326 and a volatility of 0.1806, as before. The parabola of case study 1.2.4 is the two-asset case of .
Try it
The loop computes for five weights, then at case study 1.2.4's .
Third way: from a sample of returns
On a desk, is not handed to you: it is estimated from past returns. Here is an invented sample of 120 monthly returns, ten years' worth, drawn with a fixed seed from the model above. Its monthly means are and its monthly covariance matrix is .
Module 3.7 shows how such samples are made. They are illustrative, not market data.
Put the returns in a matrix : row holds the returns of A, B and C in month . Then dots each row with : the 120 monthly returns of the portfolio. The first three are 0.0095, and 0.0311.
The sample covariance of two columns is the dot product of their centred vectors divided by , for months, as in case study 3.1.4. The sample covariance matrix collects these for every pair of columns: it does for the sample what does for the model. np.cov computes it.
The sample variance of the portfolio's returns then equals , exactly, for any sample: both are 0.000973 a month.
Times 12, that is 0.011679 a year, a volatility of 10.81%: not 11.94%. The sample is only an estimate of the model, and ten years are not enough to pin down. Compare the annualised with :
The sample volatilities are 11.13%, 16.91% and 20.59%, against 12%, 18% and 25%. np.corrcoef gives correlations 0.308, 0.181 and 0.419, against 0.3, 0.1 and 0.5; each is the cosine of an angle between centred return vectors (case study 3.1.4).
Try it
rowvar=False tells np.cov that the assets are the columns; by default it treats each row as one variable. ddof=1 makes np.var divide by , as np.cov does; module 5.8 explains why.
Multiplying a monthly variance by 12 gives an annual one, if months are independent of each other (module 5.5 treats independence).
Risk contributions
Which asset carries the risk? The last step of is a dot product, so the variance splits into one term per asset.
Rule: Risk contributions
, row of dotted with , is the covariance of asset with the whole portfolio. Times the weight, it is that asset's share of the portfolio's variance.
Here , so the contributions are 0.004872, 0.005238 and 0.00415, which add up to 0.01426. As shares of the variance: 34.17%, 36.73% and 29.10%.
How the money is split, and how the variance is split. A holds half the money but about a third of the risk; C holds a fifth of the money but 29.1% of the risk.
Divided by , the contributions add up to the volatility instead: 4.08, 4.39 and 3.48 percentage points of the 11.94%.
A contribution can be negative. Sell C short, with , which still adds up to 1. The variance is then 0.016852, a volatility of 12.98%.
C still moves with the rest of the portfolio, , but its weight is negative. So its contribution is : the short position offsets part of the risk of A and B.
One more pattern links back to case study 1.2.4. At its minimum-variance weight, both entries of equal 0.0326, the variance itself. Module 4.5 shows that every minimum-variance portfolio has this property.
Try it
w * (Sigma @ w) multiplies entry by entry, which here is what we want. The next two lines give the shares in percent, and the contributions in volatility points.
Then the short position in C, and at case study 1.2.4's .
On the desk
Try it
cov builds . The lines follow the notes in order: percent units, a mixed entry, monthly volatility, the typo, and the impossible correlations.
Try it
1,000 samples of 120 months, then 1,000 of 480 months, all drawn from the same model with a fixed seed. Each line prints the 5th, 50th and 95th percentiles of the estimated volatility.
For the ten-year samples, the next line gives the 5th and 95th percentiles of the estimated , 0.376 and 0.606 around 0.5, then of A's annual mean.
So treat as an estimate with error bars, and a portfolio built on it as no more precise than its inputs. Module 3.5 uses covariances to find the minimum-variance hedge, and module 3.6 finds the main directions of risk in the covariance matrix of yield changes.
Covariance matrix, variance and risk contributions
Write three functions, with NumPy.
covariance_matrix(vols, corr)returns . RaiseValueErrorunlesscorris square with one row per asset, symmetric, has 1 on its diagonal and every entry between and 1, and no volatility is negative.portfolio_variance(w, cov)returns as a number. RaiseValueErrorifcovis not symmetric or its size does not matchw.risk_contributions(w, cov)returns the array of .
The tests use the three assets of this case study, case study 1.2.4's two assets, a short position, and a sample covariance matrix from np.cov. They also try each bad input from the desk notes.
See the tests
import numpy as np
SIGMA = [0.12, 0.18, 0.25]
R = [[1.0, 0.3, 0.1], [0.3, 1.0, 0.5], [0.1, 0.5, 1.0]]
W = [0.5, 0.3, 0.2]
EXPECTED = [[0.0144, 0.00648, 0.003], [0.00648, 0.0324, 0.0225], [0.003, 0.0225, 0.0625]]
def _raises_value_error(fn, *args):
try:
fn(*args)
except ValueError:
return True
return False
def test_covariance_matrix():
"""The three assets: Σ has variances 0.0144, 0.0324, 0.0625 and covariances 0.00648, 0.003, 0.0225"""
expect_close(covariance_matrix(SIGMA, R), EXPECTED, what="covariance_matrix(sigma, R)")
def test_two_assets():
"""Case study 1.2.4's two assets give [[0.04, 0.012], [0.012, 0.09]]"""
expect_close(covariance_matrix([0.2, 0.3], [[1, 0.2], [0.2, 1]]), [[0.04, 0.012], [0.012, 0.09]], what="two-asset covariance matrix")
def test_portfolio_variance():
"""The portfolio (0.5, 0.3, 0.2) has variance 0.01426"""
expect_close(portfolio_variance(W, EXPECTED), 0.01426, what="portfolio_variance(w, Sigma)")
def test_case_study_124():
"""With σ_A = 0.2, σ_B = 0.3, ρ = 0.2: variance 0.032625 at w = 0.75, and case study 1.2.4's minimum at w*"""
cov = [[0.04, 0.012], [0.012, 0.09]]
expect_close(portfolio_variance([0.75, 0.25], cov), 0.032625, what="variance at w = 0.75")
w_star = 0.156 / 0.212
expect_close(portfolio_variance([w_star, 1 - w_star], cov), 0.09 - 0.156**2 / (4 * 0.106), what="variance at w*")
def test_contributions():
"""The risk contributions are 0.004872, 0.005238 and 0.00415, and add up to the variance"""
rc = np.asarray(risk_contributions(W, EXPECTED), dtype=float)
expect_close(rc, [0.004872, 0.005238, 0.00415], what="risk_contributions(w, Sigma)")
expect_close(rc.sum(), 0.01426, what="sum of the risk contributions")
def test_short_position():
"""Short 20% of C: C's risk contribution is negative, about −0.00017"""
rc = np.asarray(risk_contributions([0.7, 0.5, -0.2], EXPECTED), dtype=float)
expect_close(rc, [0.008904, 0.008118, -0.00017], what="risk contributions with C sold short")
def test_sample():
"""For a sample of returns, the variance of the portfolio's returns equals wᵀSw with S from np.cov"""
rng = np.random.default_rng(5)
X = rng.normal(0.0, 0.05, size=(60, 3))
w = np.array(W)
expect_close(portfolio_variance(w, np.cov(X, rowvar=False)), np.var(X @ w, ddof=1), what="wᵀSw against the sample variance", rel=1e-9)
def test_correlation_above_one():
"""A correlation of 1.2 raises ValueError"""
bad = [[1.0, 1.2, 0.1], [1.2, 1.0, 0.5], [0.1, 0.5, 1.0]]
assert _raises_value_error(covariance_matrix, SIGMA, bad), "a correlation of 1.2 should raise ValueError"
def test_correlation_not_symmetric():
"""A correlation matrix with ρ_AB = 0.3 in one place and 0.03 in the other raises ValueError"""
bad = [[1.0, 0.3, 0.1], [0.03, 1.0, 0.5], [0.1, 0.5, 1.0]]
assert _raises_value_error(covariance_matrix, SIGMA, bad), "a correlation matrix that is not symmetric should raise ValueError"
def test_diagonal_not_one():
"""A correlation matrix with 0.9 on its diagonal raises ValueError"""
bad = [[0.9, 0.3, 0.1], [0.3, 1.0, 0.5], [0.1, 0.5, 1.0]]
assert _raises_value_error(covariance_matrix, SIGMA, bad), "a diagonal entry other than 1 should raise ValueError"
def test_sizes_differ():
"""Three volatilities with a 2 × 2 correlation matrix raise ValueError"""
assert _raises_value_error(covariance_matrix, SIGMA, [[1, 0.3], [0.3, 1]]), "three volatilities need a 3 × 3 correlation matrix"
def test_negative_volatility():
"""A negative volatility raises ValueError"""
assert _raises_value_error(covariance_matrix, [0.12, -0.18, 0.25], R), "a volatility of -0.18 should raise ValueError"
def test_covariance_not_symmetric():
"""portfolio_variance refuses a covariance matrix with a typo in one triangle"""
typo = [row[:] for row in EXPECTED]
typo[0][1] = 0.0648
assert _raises_value_error(portfolio_variance, W, typo), "a covariance matrix that is not symmetric should raise ValueError"
def test_weights_wrong_length():
"""Two weights with a 3 × 3 covariance matrix raise ValueError"""
assert _raises_value_error(portfolio_variance, [0.5, 0.5], EXPECTED), "two weights cannot go with a 3 × 3 matrix"
Summary card
- The covariance matrix has : variances on the diagonal, covariances off it. , with the diagonal matrix of volatilities and the correlation matrix. It is symmetric.
- Portfolio variance is ; for the three assets, 0.01426, a volatility of 11.94%. With two assets it is case study 1.2.4's parabola.
- For a sample of returns , the sample variance of equals , with the sample covariance matrix (
np.cov(X, rowvar=False)). - Risk contributions add up to . They can differ a lot from the weights, and a hedge or short position can contribute a negative amount.
- Check units (annual decimals), symmetry and valid correlations; estimates of from ten years of data are noisy.