The Black–Litterman Model

Python · PyMC · R  ·  Download Black–Litterman module

Model

The previous projects established that plugging sample estimates into an optimiser produces nonsense. Black–Litterman is the practitioner's answer, and its insight is to change the starting point. Instead of estimating expected returns from history, it asks what returns the market must already believe in for today's capitalisation weights to be optimal — then treats those as a prior and lets the investor state a few views on top. The result is a posterior that departs from the market only where an opinion was actually expressed.

Reverse optimisation — the equilibrium prior

Step one is reverse optimisation. Given the market's weights and its covariance, invert the optimality condition to recover the implied equilibrium returns Π=δΣwmkt\boldsymbol\Pi=\delta\Sigma\mathbf{w}_{\text{mkt}}. This is the move that makes everything else work: it produces an expected-return vector that is internally consistent with observed prices rather than extrapolated from noisy averages, and the sectors it rewards most are exactly those contributing most to market risk. The notebooks verify the construction closes properly — with no views at all, the model returns the market portfolio to 5.6×10165.6\times10^{-16}.

Π=δΣwmkt\boldsymbol\Pi=\delta\,\Sigma\,\mathbf{w}_{\text{mkt}}

Why not just use historical means

Why bother is answered by simply doing the naive thing. Feeding historical mean returns into a mean–variance optimiser on the same ten sectors produces positions ranging from −109% to +188%, with 518% gross leverage — a book no investor would hold, driven entirely by sampling noise in the return averages. The market portfolio next to it holds every sector between 2% and 32% at 100% gross. That contrast is the entire motivation, and it is shown rather than asserted.

Ten-sector portfoliomin weightmax weightgross leverage
naive mean–variance−109%188%518%
market (cap weights)2%32%100%
Black–Litterman with two viewstilts around the market219%

Stating views

Views are then stated in the model's own grammar: a pick matrix PP selecting what each view is about, a vector QQ of what it claims, and Ω\Omega encoding how confident it is. The notebooks use one relative view (Tech will beat Staples by 0.60%/week, where equilibrium implies only 0.186%) and one absolute view (Energy returns 0.20%/week, where equilibrium implies 0.351% — a bearish call). Both are compared against equilibrium before fitting, so the direction of each bet is explicit rather than implied.

μBL=[(τΣ)1+PΩ1P]1[(τΣ)1Π+PΩ1Q]\boldsymbol\mu_{\text{BL}}=\big[(\tau\Sigma)^{-1}+P^{\top}\Omega^{-1}P\big]^{-1}\big[(\tau\Sigma)^{-1}\boldsymbol\Pi+P^{\top}\Omega^{-1}Q\big]

What Comes Out

The posterior behaves exactly as the design intends. Tech is pulled up and Staples down by the first view; Energy is pulled down by the second; and sectors carrying no view barely move — they stay at equilibrium. The resulting portfolio tilts around the market rather than replacing it, and the confidence dial makes the mechanism tangible: sweep Ω\Omega from near-certainty to near-ignorance and the portfolio slides continuously from a strongly tilted book to the market portfolio itself. The investor chooses how hard to bet on each opinion, and nothing else moves.

The master formula is a posterior mean

A PyMC companion demonstrates that the celebrated master formula is not a special construction at all — it is the posterior mean of an ordinary Bayesian model, with the equilibrium as prior and the views as observations. Sampling that model reproduces the analytic result across all ten sectors, and returns a distribution over tilts: the viewed sectors carry the largest and most confident departures, un-viewed ones sit near zero with tight posteriors. Extending it with an Inverse-Wishart posterior for Σ\Sigma yields an instructive non-result — the average tilt uncertainty grows by a factor of just 1.02×, because with 310 observations on ten assets the covariance is already pinned down and μ\boldsymbol\mu carries essentially all the parameter uncertainty. That reverses at short windows and high dimension, which is precisely the regime the estimation-risk project mapped.

Notebooks

Downloads

Black–Litterman Module — Source Code

"""
blacklitterman.py -- the Black-Litterman model as Bayesian updating.

Backs the notebooks in  "The Black-Litterman Model".
Follows Meucci, "Risk and Asset Allocation" (Springer 2005), chapter 9-B, and the
original Black & Litterman (1992) / He & Litterman (1999) construction.

THE IDEA IN ONE PARAGRAPH
-------------------------
Plugging historical average returns into a mean-variance optimiser produces crazy,
unstable portfolios (Project 2). Black-Litterman fixes the *inputs* instead of the
optimiser. It starts from a sensible PRIOR for expected returns -- the returns that
would make today's MARKET-CAP portfolio optimal (the "equilibrium" or "implied"
returns) -- and then nudges that prior in the direction of the investor's own
VIEWS, by an amount that reflects how confident the investor is. The result is a
posterior expected-return vector that is close to equilibrium for assets you have
no opinion on, and tilted where you do. It is exactly a Bayesian (normal-normal)
update, which is why it belongs in this arc.

THE MATH
--------
Inputs:
  Sigma  (N,N)  covariance of returns (from data).
  w_mkt  (N,)   market-capitalisation weights.
  delta         market risk-aversion  = E[r_mkt - r_f] / Var[r_mkt].
  tau           small scalar: uncertainty in the equilibrium prior (e.g. 0.05).
  P (K,N),Q(K,) K views:  P @ mu = Q + eps,  eps ~ N(0, Omega).
  Omega (K,K)   view uncertainty (diagonal).

Equilibrium prior on expected returns:
  Pi = delta * Sigma @ w_mkt                 (reverse optimisation)
  mu ~ N(Pi, tau*Sigma)

Posterior (the Black-Litterman "master formula"):
  M     = ( (tau*Sigma)^-1 + P' Omega^-1 P )^-1        posterior covariance of the mean
  mu_BL = M ( (tau*Sigma)^-1 Pi + P' Omega^-1 Q )       posterior mean of expected returns

Allocation (optimal risky portfolio):
  w_BL = (1/delta) Sigma^-1 mu_BL
With NO views, mu_BL = Pi and w_BL = w_mkt exactly: Black-Litterman recovers the
market portfolio, and only tilts it where views are expressed. That self-consistency
is the property naive mean-variance lacks.
"""

import numpy as np


def risk_aversion(mkt_excess_mean, mkt_var):
    """delta = E[r_mkt - r_f] / Var[r_mkt].  The market's price of risk."""
    return mkt_excess_mean / mkt_var


def implied_returns(delta, Sigma, w_mkt):
    """Reverse optimisation: the expected returns that make w_mkt optimal."""
    return delta * Sigma @ np.asarray(w_mkt, float)


def omega_proportional(P, tau, Sigma):
    """Default view-uncertainty (He-Litterman): Omega = diag( P (tau Sigma) P' ).
    Each view is as uncertain as its variance under the prior -- so no free
    confidence parameter is needed."""
    P = np.atleast_2d(P)
    return np.diag(np.diag(P @ (tau * Sigma) @ P.T))


def black_litterman(Pi, Sigma, tau, P, Q, Omega):
    """Return the Black-Litterman posterior.

    dict keys:
      mu_bl      posterior mean of expected returns
      M          posterior covariance of the mean estimate ( (tauS)^-1 + P'O^-1P )^-1
      Sigma_post Sigma + M   (predictive covariance of returns, for allocation)
    """
    P = np.atleast_2d(P); Q = np.atleast_1d(Q).astype(float)
    tauS = tau * Sigma
    inv_tauS = np.linalg.inv(tauS)
    inv_O = np.linalg.inv(np.atleast_2d(Omega))
    M = np.linalg.inv(inv_tauS + P.T @ inv_O @ P)
    mu_bl = M @ (inv_tauS @ Pi + P.T @ inv_O @ Q)
    return dict(mu_bl=mu_bl, M=M, Sigma_post=Sigma + M)


def mv_weights(mu, Sigma, delta):
    """Optimal risky-portfolio weights  w = (1/delta) Sigma^-1 mu  (unnormalised;
    with mu = Pi this returns w_mkt exactly)."""
    return (1.0 / delta) * np.linalg.solve(Sigma, mu)


def mv_weights_budget(mu, Sigma, gamma):
    """Mean-variance optimal weights subject to full investment w'1 = 1."""
    Si = np.linalg.inv(Sigma); one = np.ones(len(mu))
    A = one @ Si @ one; B = one @ Si @ mu
    lam = (B - gamma) / A
    return Si @ (mu - lam * one) / gamma


def view_matrix(names, views):
    """Build (P, Q) from a list of human-readable views.

    Each view is a dict:
      absolute : {"assets": ["XLK"],          "weights": [1],      "q": 0.4}
      relative : {"assets": ["XLK","XLP"],    "weights": [1,-1],   "q": 0.5}
    `names` is the ordered list of asset tickers.
    """
    N = len(names); idx = {n: i for i, n in enumerate(names)}
    P = np.zeros((len(views), N)); Q = np.zeros(len(views))
    for k, v in enumerate(views):
        for a, w in zip(v["assets"], v["weights"]):
            P[k, idx[a]] = w
        Q[k] = v["q"]
    return P, Q

References