SigmaField
Mathematical Foundations for Quant Finance
0 of 256 lessons
      1. 4.6.1 Utility and mean–variance preferencesConcept · 35 min
      2. 4.6.2 The tangency portfolio and the Sharpe ratioConcept · 30 min
      3. 4.6.3 The capital asset pricing modelConcept · 30 min
      4. 4.6.4 Practice set: portfolio theoryPractice set · 25 min
      5. 4.6.5 Case study: the tangency portfolio from dataCase study · 35 min

Level 4, Multivariable calculus and optimisation 4.6 Portfolio theory Lesson 5 of 5

Case studyLesson 4.6.5 in Level 4, Multivariable calculus and optimisation. About 35 minutes.

Case study: the tangency portfolio from data

The sample

The returns are invented for illustration, not market data. They are 120 months drawn with a fixed seed from the model of case study 3.2.5: expected returns μ=(0.06,0.08,0.11)\boldsymbol{\mu} = (0.06, 0.08, 0.11) a year and covariance matrix Σ\Sigma. Case study 3.2.5 drew such a sample with seed 7; this one uses seed 13.

Because we invented the model, we can compare each estimate with the truth, which a desk never can. The sample means times 12 estimate the annual expected returns, and 12 times the sample covariance matrix estimates Σ\Sigma (case study 3.2.5):

ABC
estimated mean0.08440.02490.1272
true mean0.060.080.11
estimated volatility0.12450.19950.2473
true volatility0.120.180.25

The estimated correlations are 0.302, 0.008 and 0.469 for AB, AC and BC, against 0.3, 0.1 and 0.5. The volatilities are close; the means are not.

B had a poor decade, 2.49% a year against a true 8%, and that is not unusual. An annual mean estimated from TT years has a typical error, its standard error, of about σiT\frac{\sigma_i}{\sqrt{T}} (lesson 5.8.2 explains why).

With the true volatilities and ten years, that is 3.79, 5.69 and 7.91 percentage points: as large as the excess returns themselves. B's shortfall is only 0.97 standard errors.

Try it

multivariate_normal draws 120 months from the model with seed 13, as case study 3.2.5 did with seed 7. The first line prints the shape and the first month.

Then the annualised means, volatilities and correlations, and the standard errors σi10\frac{\sigma_i}{\sqrt{10}} with B's shortfall measured in them.

⌘/Ctrl + Enter runs the cell

The tangency portfolio from the sample

Put the estimates μ^\hat{\boldsymbol{\mu}} and Σ^\hat{\Sigma} into lesson 4.6.2's formula, w^=1kΣ^−1(μ^−rf1)\hat{\mathbf{w}} = \frac{1}{k}\hat{\Sigma}^{-1}(\hat{\boldsymbol{\mu}} - r_f\mathbf{1}) with rf=0.03r_f = 0.03. The hat marks an estimate. First check that kk, the sum of the entries of Σ^−1(μ^−rf1)\hat{\Sigma}^{-1}(\hat{\boldsymbol{\mu}} - r_f\mathbf{1}), is positive: here k=4.7006k = 4.7006.

The weights are w^=(0.9911,−0.5234,0.5323)\hat{\mathbf{w}} = (0.9911, -0.5234, 0.5323). B's poor decade earned it a short sale of 52%. Measured on the sample itself, the portfolio expects 13.83% with a volatility of 15.18%, a Sharpe ratio of 0.7136.

To confirm, scipy.optimize.minimize searches directly for the weights with the highest sample Sharpe ratio, adding up to 1. It minimises the negative of the ratio with SLSQP, the method case study 4.5.5 used for the frontier. It finds the same weights, to two decimal places, and the same 0.7136.

Units. The formula works in any period, as long as every input uses the same one. With monthly means, the monthly covariance matrix and rf12\frac{r_f}{12}, the weights come out identical. Monthly means against the annual 3% give k=−22.06k = -22.06: a false "no tangency portfolio".

Try it

tangency returns the weights and kk. neg_sharpe is the function minimize makes as small as possible; budget keeps the weights adding up to 1.

The last line uses monthly inputs, first with rf12\frac{r_f}{12}, then with the annual rfr_f by mistake.

⌘/Ctrl + Enter runs the cell

How much would another decade change it?

A desk has one sample, not the model. To see how much the answer depends on the particular months, it uses the bootstrap.

Draw 120 months at random from the 120 observed, with replacement, so some months appear twice and others not at all. Each draw is a decade that could have happened, built from months that did.

Repeat 1,000 times and recompute the tangency weights each time. In 49 of the resamples k≤0k \le 0: there is no tangency portfolio at all. For the other 951, the middle 90% of the weights runs over:

ABC
5th percentile0.3419−3.6504-3.6504−0.0088-0.0088
median0.9871−0.5164-0.51640.5428
95th percentile2.5020.17812.5695
true weight0.51850.16010.3214

The weight in B could as easily be −365%-365\% as +18%+18\%. The ranges are centred on this sample's estimate, not on the truth.

So the bootstrap measures how noisy the answer is, not where the true answer lies. Here each true weight is inside its range, near one end.

The middle 90% of the bootstrap tangency weights, with the sample means and with the means shrunk halfway to their average. Dots mark the medians; shrinking shortens every bar.

Try it

rng.integers(0, 120, size=(1000, 120)) draws all the resampled months at once: row jj lists the months of resample jj. X[r] picks those rows of XX.

The cell counts the resamples with no tangency portfolio, then prints the percentiles and the true weights.

⌘/Ctrl + Enter runs the cell
Check yourself
The bootstrap gives B's tangency weight a 90% range of −365%-365\% to 18%18\%. What does that range tell the desk?

The means do the damage

Which estimate causes the trouble? Swap them one at a time.

  • True means, sample covariance: the weights are (0.5603,0.0622,0.3774)(0.5603, 0.0622, 0.3774), close to the true (0.5185,0.1601,0.3214)(0.5185, 0.1601, 0.3214).
  • Sample means, true covariance: (1.0855,−0.6395,0.554)(1.0855, -0.6395, 0.554), as far off as before.

The covariance matrix is estimated well from 120 months; the means are not. And the formula Σ−1(μ−rf1)\Sigma^{-1}(\boldsymbol{\mu} - r_f\mathbf{1}) turns small differences between means into large positions, more so when assets are correlated.

Raise B's estimated mean by one percentage point, well inside its standard error of 5.69 points. B's weight moves from −52.34%-52.34\% to −44.11%-44.11\%.

Try it

The first line swaps the estimates; the second raises B's mean by 0.01.

⌘/Ctrl + Enter runs the cell

The sample flatters itself

In the sample, w^\hat{\mathbf{w}} always looks best: it was chosen to have the highest sample Sharpe ratio, noise included. Judged with the model's true μ\boldsymbol{\mu} and Σ\Sigma, it expects 7.61% with a volatility of 15.7%, a Sharpe ratio of 0.294. An equal split, (13,13,13)(\frac{1}{3}, \frac{1}{3}, \frac{1}{3}), which needs no estimates at all, has 0.3844; the true best is 0.3951.

This sample is typical.

Over 1,000 fresh ten-year samples from the model, 138 have no tangency portfolio. For the rest, the median in-sample Sharpe ratio is 0.6424, while the median true Sharpe ratio of the estimated portfolio is 0.2935. In 93.2% of them the estimated portfolio does worse than the equal split.

So a Sharpe ratio measured on the data used to choose the weights overstates what the weights will earn. Judge a method on data it has not seen. Case study 5.8.5 asks when a Sharpe ratio is large enough to be more than luck.

Try it

stats gives the expected return, volatility and Sharpe ratio of weights w under given means and covariance. The first line judges w^\hat{\mathbf{w}}, the equal split and wT\mathbf{w}_T with the true μ\boldsymbol{\mu} and Σ\Sigma.

The loop draws 1,000 new samples with seed 11. It prints the count with no tangency portfolio, the two medians, and the share that loses to the equal split.

⌘/Ctrl + Enter runs the cell

Constraints and shrinkage

Two families of fixes tame the noise, and desks often use both.

Constraints. Forbid short sales, as case study 4.5.5 did for the frontier: give minimize bounds of 0 and 1 on each weight.

On this sample the long-only tangency portfolio is (0.689,0,0.311)(0.689, 0, 0.311). Its sample Sharpe ratio falls to 0.5855, but its true one rises to 0.3827, almost the equal split's. Caps on each weight work the same way.

Shrinkage. Pull each estimated mean part of the way towards the average of the three means, μˉ\bar{\mu}:

μ^δ=(1−δ) μ^+δ μˉ 1\hat{\boldsymbol{\mu}}_\delta = (1 - \delta)\,\hat{\boldsymbol{\mu}} + \delta\,\bar{\mu}\,\mathbf{1}

δ\delta (delta), between 0 and 1, is how far to pull. With δ=0.5\delta = 0.5 the weights become (0.8858,−0.254,0.3682)(0.8858, -0.254, 0.3682), with a true Sharpe ratio of 0.3353. With δ=1\delta = 1 every mean is the same, so the formula returns the sample minimum-variance portfolio (0.7609,0.0655,0.1736)(0.7609, 0.0655, 0.1736), with a true 0.3604.

Shrinking gives up a little of what the sample says in exchange for much less noise. In the bootstrap, δ=0.5\delta = 0.5 shortens every range in the figure: B's runs from −173%-173\% to 14%.

Three more fixes are common. Desks shrink Σ^\hat{\Sigma} towards a simpler matrix, start from the means CAPM implies (lesson 4.6.3), or average the weights over resamples.

Try it

The first line is the long-only search, with bounds. Rounding to three places and adding 0.0 keep the zero weight from printing as -0.0.

Then shrinkage with δ=0.5\delta = 0.5 and 1, and the sample minimum-variance portfolio. The last lines rerun the bootstrap with δ=0.5\delta = 0.5.

⌘/Ctrl + Enter runs the cell
Check yourself
Which estimate does most of the damage to the sample tangency weights?

On the desk

Try it

The first lines use case study 3.2.5's sample. Then matrix_rank for three months of data, and for 120 months with B's returns replaced by zeros.

⌘/Ctrl + Enter runs the cell
Exercise

Tangency weights from returns, with a bootstrap

Write two functions, with NumPy.

  1. sample_tangency(X, rf, periods=12, shrink=0.0) returns the tangency weights estimated from X, a T×nT \times n array of returns per period. Annualise: the means times periods and periods times np.cov(X, rowvar=False). Shrink the means by shrink, the δ\delta above, then apply lesson 4.6.2's formula with the annual rf.
  2. bootstrap_tangency(X, rf, n_boot=1000, seed=0, periods=12, shrink=0.0) returns (weights, skipped). Draw all the resamples at once with np.random.default_rng(seed).integers(0, T, size=(n_boot, T)), and call sample_tangency on each. weights is an array with one row per resample that has a tangency portfolio; skipped counts the others.

sample_tangency raises ValueError when X is not two-dimensional, has no more rows than columns, or contains a NaN. It also raises it when shrink is outside [0,1][0, 1], when the covariance matrix is singular (use matrix_rank), and when k≤0k \le 0.

The tests use this case study's sample and case study 3.2.5's, monthly and annual inputs, and full shrinkage, which must give the sample minimum-variance portfolio. They also try every bad input above.

See the tests
import numpy as np

_MU = np.array([0.06, 0.08, 0.11])
_SIGMA = np.array([0.12, 0.18, 0.25])
_R = np.array([[1.0, 0.3, 0.1], [0.3, 1.0, 0.5], [0.1, 0.5, 1.0]])
_COV = np.diag(_SIGMA) @ _R @ np.diag(_SIGMA)


def _sample(seed):
    return np.random.default_rng(seed).multivariate_normal(_MU / 12, _COV / 12, size=120, method="cholesky")


def _raises(f, *args, **kwargs):
    try:
        f(*args, **kwargs)
    except ValueError:
        return True
    return False


def test_sample_weights():
    """The seed-13 sample gives tangency weights of about (0.9911, -0.5234, 0.5323)"""
    w = sample_tangency(_sample(13), 0.03)
    expect_close(w, [0.9911295770835862, -0.5234319223429728, 0.5323023452593866], what="sample_tangency(X, 0.03)", rel=1e-8)


def test_weights_add_up():
    """The weights add up to 1"""
    expect_close(float(np.sum(sample_tangency(_sample(13), 0.03))), 1.0, what="sum of the weights")


def test_periods():
    """Monthly inputs with a monthly rate give the same weights as annualised ones"""
    X = _sample(13)
    expect_close(sample_tangency(X, 0.0025, periods=1), sample_tangency(X, 0.03, periods=12), what="weights from monthly inputs", rel=1e-8)


def test_shrink_half():
    """Shrinking the means halfway gives about (0.8858, -0.2540, 0.3682)"""
    w = sample_tangency(_sample(13), 0.03, shrink=0.5)
    expect_close(w, [0.8858113300671483, -0.2540155180408047, 0.36820418797365634], what="sample_tangency(X, 0.03, shrink=0.5)", rel=1e-8)


def test_full_shrink_is_min_variance():
    """Full shrinkage gives the sample minimum-variance portfolio"""
    X = _sample(13)
    S = np.cov(X, rowvar=False)
    a = np.linalg.solve(S, np.ones(3))
    expect_close(sample_tangency(X, 0.03, shrink=1.0), a / a.sum(), what="sample_tangency(X, 0.03, shrink=1)", rel=1e-8)


def test_no_tangency():
    """Case study 3.2.5's seed-7 sample has all means below 3%: ValueError"""
    assert _raises(sample_tangency, _sample(7), 0.03), "expected ValueError: k is negative, so there is no tangency portfolio"


def test_too_few_rows():
    """Three months of three assets are not enough: ValueError"""
    assert _raises(sample_tangency, _sample(13)[:3], 0.03), "expected ValueError for 3 rows and 3 columns"


def test_stale_price():
    """An asset whose returns are all 0 makes the covariance matrix singular: ValueError"""
    X = _sample(13).copy()
    X[:, 1] = 0.0
    assert _raises(sample_tangency, X, 0.03), "expected ValueError for a singular covariance matrix"


def test_nan():
    """A missing return (NaN) raises ValueError"""
    X = _sample(13).copy()
    X[5, 2] = np.nan
    assert _raises(sample_tangency, X, 0.03), "expected ValueError when X contains NaN"


def test_bad_shape_and_shrink():
    """A one-dimensional X, or shrink outside [0, 1], raises ValueError"""
    X = _sample(13)
    assert _raises(sample_tangency, X[:, 0], 0.03), "expected ValueError for a one-dimensional X"
    assert _raises(sample_tangency, X, 0.03, shrink=1.5), "expected ValueError for shrink = 1.5"
    assert _raises(sample_tangency, X, 0.03, shrink=-0.1), "expected ValueError for shrink = -0.1"


def test_bootstrap_counts():
    """1,000 resamples with seed 1: 951 tangency portfolios and 49 skipped"""
    weights, skipped = bootstrap_tangency(_sample(13), 0.03, n_boot=1000, seed=1)
    weights = np.asarray(weights)
    expect_equal(weights.shape, (951, 3), what="shape of weights")
    expect_equal(skipped, 49, what="skipped")


def test_bootstrap_median():
    """The median bootstrap weights are about (0.9871, -0.5164, 0.5428)"""
    weights, _ = bootstrap_tangency(_sample(13), 0.03, n_boot=1000, seed=1)
    expect_close(np.median(np.asarray(weights), axis=0), [0.9871146764558107, -0.5163783830856097, 0.5428365007967242], what="median weights", rel=1e-8)


def test_bootstrap_repeatable():
    """The same seed gives the same resamples; each row adds up to 1"""
    X = _sample(13)
    a, _ = bootstrap_tangency(X, 0.03, n_boot=50, seed=3)
    b, _ = bootstrap_tangency(X, 0.03, n_boot=50, seed=3)
    expect_close(np.asarray(a), np.asarray(b), what="two runs with seed 3")
    expect_close(np.asarray(a).sum(axis=1), np.ones(len(a)), what="row sums")


def test_shrinking_narrows():
    """Shrinking the means halfway narrows the spread of B's bootstrap weight"""
    X = _sample(13)
    plain, _ = bootstrap_tangency(X, 0.03, n_boot=1000, seed=1)
    shrunk, skipped = bootstrap_tangency(X, 0.03, n_boot=1000, seed=1, shrink=0.5)
    wide = np.ptp(np.percentile(np.asarray(plain)[:, 1], [5, 95]))
    narrow = np.ptp(np.percentile(np.asarray(shrunk)[:, 1], [5, 95]))
    assert narrow < wide, f"expected a narrower 90% range for B with shrink=0.5, got {narrow:.4f} against {wide:.4f}"
    expect_equal(skipped, 88, what="resamples skipped with shrink=0.5")

Summary card

  • Estimated from ten years, volatilities and correlations are fairly close to the truth; means are not. An annual mean's standard error is about σiT\frac{\sigma_i}{\sqrt{T}}, here as large as the excess returns.
  • The sample tangency portfolio 1kΣ^−1(μ^−rf1)\frac{1}{k}\hat{\Sigma}^{-1}(\hat{\boldsymbol{\mu}} - r_f\mathbf{1}) swings with the sample: here (0.9911,−0.5234,0.5323)(0.9911, -0.5234, 0.5323) against a true (0.5185,0.1601,0.3214)(0.5185, 0.1601, 0.3214). Check k>0k > 0, use one period throughout, and confirm with scipy.optimize.minimize.
  • The bootstrap resamples the observed months to show how noisy the weights are; it is centred on the estimate, not on the truth.
  • In-sample Sharpe ratios flatter the estimated weights: 0.71 in sample against 0.29 in truth, below an equal split's 0.3844.
  • Long-only constraints and shrinking the means, (1−δ)μ^+δμˉ1(1 - \delta)\hat{\boldsymbol{\mu}} + \delta\bar{\mu}\mathbf{1}, trade a little fit for much less noise; δ=1\delta = 1 gives the sample minimum-variance portfolio.

Free further reading

Optional. This lesson covers what you need; these add more examples and practice. All are free to read.