The source files can be found in examples/.

Black-Litterman

The estimators on the earlier pages take the data as it is. But you often hold a view, such as "Apple will return 8 bps a day", "Microsoft will beat AMD" or "tech as a group will do well", and you want to add it to the prior without losing what the market already tells you. The Black-Litterman model does this. It starts from an equilibrium prior, the returns that reverse optimisation implies for a given portfolio, and moves it toward your views. The confidence of each view sets how far it moves the prior. The result is a prior with a posterior mean and covariance, and you give it to an optimiser as you give any other prior.

This page is the first of three on priors built from views. Entropy pooling and opinion pooling follow, and each builds on the page before it. You can also read each page on its own.

BlackLittermanPrior takes a base estimator in pe, whose default mean is EquilibriumExpectedReturns, a UniverseSets that names the assets and the groups, and the views in views. You write the views as strings in a LinearConstraintEstimator. views_conf sets the confidence of each view, and tau scales the uncertainty of the prior for all the views at once.

When to reach for this

Reach for Black-Litterman when you hold forecasts of your own and want to blend them with a market equilibrium, rather than replace the mean outright. A view can be absolute, relative or about a group, as section 4 shows. It is the simplest of the three view priors, because a view enters as a Gaussian update of the mean. If your views are about other quantities, such as the variance, the tail risk or the skew, or you want them to hold as constraints on the distribution, see entropy pooling.

using PortfolioOptimisers, PrettyTablesmmtfmt = (v, i, j) -> begin    if j == 1        return v    else        return isa(v, Number) ? "$(round(v*100, digits=4)) %" : v    endend;resfmt = (v, i, j) -> begin    if j == 1        return v    else        return isa(v, Number) ? "$(round(v*100, digits=3)) %" : v    endend;

1. The data

We load the daily returns of 20 assets over one year. The views below name some of these assets.

using CSV, TimeSeries, DataFramesX = TimeArray(CSV.File(joinpath(@__DIR__, "..", "SP500.csv.gz")); timestamp = :Date)[(end - 252):end]rd = prices_to_returns(X)
ReturnsResult
    nx ┼ 20-element Vector{String}
     X ┼ 252×20 Matrix{Float64}
    nf ┼ nothing
     F ┼ nothing
    nb ┼ nothing
     B ┼ nothing
    ne ┼ nothing
     E ┼ nothing
    ts ┼ 252-element Vector{Date}
    iv ┼ nothing
  ivpa ┼ nothing
   pnl ┼ AssetPanel
       │     pf ┼ Vector{AbstractPanelField}: AbstractPanelField[]
       │   amsk ┼ 252×20 AllTrueMask
       │   emsk ┴ 252×20 AllTrueMask

2. The equilibrium prior

Black-Litterman does not start from the sample mean. It starts from the EquilibriumExpectedReturns vector, the returns that reverse optimisation implies for a portfolio $\boldsymbol{w}_{mkt}$, $\boldsymbol{\pi} = \lambda \mathbf{\Sigma} \boldsymbol{w}_{mkt}$. The page passes no weights, so $\boldsymbol{w}_{mkt}$ is the equal-weight portfolio. Pass market capitalisations in w to use the market portfolio. The sample mean of a single year is noisy and often negative. The equilibrium prior is smoother and has an economic reason behind it, and the views move it from there.

We build both and compare them.

pr_sample = prior(EmpiricalPrior(), rd)pr_eq = prior(EmpiricalPrior(; me = EquilibriumExpectedReturns()), rd)pretty_table(DataFrame(["Assets" => rd.nx, "Sample mean" => pr_sample.mu,                        "Equilibrium" => pr_eq.mu]); formatters = [mmtfmt],             title = "Sample mean and equilibrium expected returns")using StatsPlots, GraphRecipes
Sample mean and equilibrium expected returns
┌────────┬─────────────┬─────────────┐
│ Assets │ Sample mean │ Equilibrium │
│ String │     Float64 │     Float64 │
├────────┼─────────────┼─────────────┤
│   AAPL │   -0.1126 % │    0.0231 % │
│    AMD │   -0.2809 % │    0.0362 % │
│    BAC │   -0.0934 % │    0.0191 % │
│    BBY │   -0.0279 % │    0.0239 % │
│    CVX │    0.1945 % │    0.0144 % │
│     GE │   -0.0339 % │    0.0194 % │
│     HD │   -0.0707 % │    0.0178 % │
│    JNJ │    0.0307 % │    0.0075 % │
│    JPM │   -0.0417 % │    0.0176 % │
│     KO │    0.0497 % │    0.0108 % │
│    LLY │    0.1305 % │    0.0122 % │
│    MRK │    0.1669 % │    0.0075 % │
│   MSFT │   -0.1206 % │     0.022 % │
│    PEP │     0.039 % │    0.0106 % │
│    PFE │   -0.0256 % │    0.0118 % │
│     PG │   -0.0081 % │    0.0103 % │
│    RRC │    0.1824 % │    0.0277 % │
│    UNH │    0.0364 % │    0.0125 % │
│    WMT │    0.0163 % │    0.0101 % │
│    XOM │    0.2637 % │    0.0146 % │
└────────┴─────────────┴─────────────┘

The sample mean of each asset.

plot_mu(pr_sample, rd.nx)
Example block output

The equilibrium expected return of each asset.

plot_mu(pr_eq, rd.nx)
Example block output

3. Naming assets and groups

A view names assets and groups, so we declare a UniverseSets. The nx key holds every asset, and we add two example groups so that a view can name a group.

sets = UniverseSets(;                    dict = Dict("nx" => rd.nx, "tech" => ["AAPL", "AMD", "MSFT"],                                "energy" => ["CVX"]))
UniverseSets
    xkey ┼ String: "nx"
   uxkey ┼ String: "ux"
   tfkey ┼ String: "nf"
  utfkey ┼ String: "uf"
   cfkey ┼ String: "ncf"
  ucfkey ┼ String: "ucf"
   nikey ┼ String: "ni"
    dict ┴ Dict{String, Vector{String}}: Dict("tech" => ["AAPL", "AMD", "MSFT"], "energy" => ["CVX"], "nx" => ["AAPL", "AMD", "BAC", "BBY", "CVX", "GE", "HD", "JNJ", "JPM", "KO", "LLY", "MRK", "MSFT", "PEP", "PFE", "PG", "RRC", "UNH", "WMT", "XOM"])

4. The three kinds of views

A view is a string, and Black-Litterman reads three kinds.

  • An absolute view, "AAPL == 0.0008", says that Apple returns 8 bps a day.
  • A relative view, "MSFT - AMD == 0.0005", says that Microsoft beats AMD by 5 bps a day.
  • A group view, "tech == 0.0006", says that the tech group returns 6 bps a day on average.

We build one posterior per kind of view and compare its expected returns with the equilibrium prior. A Black-Litterman view is soft, so the posterior does not reproduce it exactly. The model blends the view with the equilibrium prior, and the posterior moves only part of the way. In the table, the gap between Microsoft and AMD stays well short of the 5 bps the view states.

tau = 1 / size(rd.X, 1)pr_abs = prior(BlackLittermanPrior(; sets = sets, tau = tau,                                   views = LinearConstraintEstimator(;                                                                     val = ["AAPL == 0.0008"])),               rd)pr_rel = prior(BlackLittermanPrior(; sets = sets, tau = tau,                                   views = LinearConstraintEstimator(;                                                                     val = ["MSFT - AMD == 0.0005"])),               rd)pr_grp = prior(BlackLittermanPrior(; sets = sets, tau = tau,                                   views = LinearConstraintEstimator(;                                                                     val = ["tech == 0.0006"])),               rd)pretty_table(DataFrame(["Assets" => rd.nx, "Equilibrium" => pr_eq.mu,                        "Absolute" => pr_abs.mu, "Relative" => pr_rel.mu,                        "Group" => pr_grp.mu]); formatters = [mmtfmt],             title = "Posterior expected returns by view type")
         Posterior expected returns by view type
┌────────┬─────────────┬──────────┬───────────┬──────────┐
│ Assets │ Equilibrium │ Absolute │  Relative │    Group │
│ String │     Float64 │  Float64 │   Float64 │  Float64 │
├────────┼─────────────┼──────────┼───────────┼──────────┤
│   AAPL │    0.0231 % │ 0.0516 % │  0.0135 % │ 0.0363 % │
│    AMD │    0.0362 % │ 0.0719 % │ -0.0013 % │ 0.0594 % │
│    BAC │    0.0191 % │ 0.0338 % │   0.011 % │ 0.0272 % │
│    BBY │    0.0239 % │ 0.0434 % │  0.0133 % │ 0.0345 % │
│    CVX │    0.0144 % │ 0.0222 % │  0.0089 % │ 0.0187 % │
│     GE │    0.0194 % │ 0.0348 % │  0.0082 % │ 0.0279 % │
│     HD │    0.0178 % │ 0.0327 % │  0.0124 % │ 0.0257 % │
│    JNJ │    0.0075 % │ 0.0126 % │  0.0074 % │ 0.0096 % │
│    JPM │    0.0176 % │ 0.0307 % │  0.0109 % │ 0.0247 % │
│     KO │    0.0108 % │  0.019 % │  0.0089 % │ 0.0145 % │
│    LLY │    0.0122 % │ 0.0202 % │  0.0106 % │ 0.0162 % │
│    MRK │    0.0075 % │  0.012 % │  0.0084 % │ 0.0092 % │
│   MSFT │     0.022 % │ 0.0451 % │  0.0166 % │ 0.0349 % │
│    PEP │    0.0106 % │ 0.0192 % │  0.0093 % │ 0.0144 % │
│    PFE │    0.0118 % │  0.019 % │  0.0104 % │ 0.0154 % │
│     PG │    0.0103 % │ 0.0184 % │  0.0094 % │  0.014 % │
│    RRC │    0.0277 % │ 0.0438 % │  0.0186 % │ 0.0366 % │
│    UNH │    0.0125 % │  0.022 % │  0.0103 % │ 0.0171 % │
│    WMT │    0.0101 % │ 0.0174 % │  0.0095 % │ 0.0133 % │
│    XOM │    0.0146 % │ 0.0224 % │  0.0095 % │ 0.0186 % │
└────────┴─────────────┴──────────┴───────────┴──────────┘

5. The confidence of a view, views_conf

views_conf sets how far the posterior moves toward each view, as a confidence strictly between 0 and 1. At a low confidence the posterior stays near the equilibrium prior, and at a high confidence it moves most of the way to the view. We sweep the confidence of one absolute view. The table prints Apple's posterior expected return at each confidence, and its title gives the equilibrium value and the 8 bps of the view for comparison.

abs_view = LinearConstraintEstimator(; val = ["AAPL == 0.0008"])confs = [0.1, 0.3, 0.5, 0.7, 0.9]pr_confs = [prior(BlackLittermanPrior(; sets = sets, tau = tau, views = abs_view,                                      views_conf = [c]), rd) for c in confs]i_aapl = findfirst(==("AAPL"), rd.nx)pretty_table(DataFrame(; confidence = confs,                       Symbol("AAPL posterior") => [p.mu[i_aapl] for p in pr_confs]);             formatters = [mmtfmt],             title = "AAPL posterior by view confidence (equilibrium ≈ $(round(pr_eq.mu[i_aapl]*100; digits=4))%, view = 0.08%)")
AAPL posterior by view confidence (equilibrium ≈ 0.0231%, view = 0.08%)
┌────────────┬────────────────┐
│ confidence │ AAPL posterior │
│    Float64 │        Float64 │
├────────────┼────────────────┤
│        0.1 │       0.0288 % │
│        0.3 │       0.0402 % │
│        0.5 │       0.0516 % │
│        0.7 │       0.0629 % │
│        0.9 │       0.0743 % │
└────────────┴────────────────┘

6. The posterior covariance

Black-Litterman updates the covariance as well as the mean. The posterior covariance adds tau times the covariance, for the uncertainty of the mean, and subtracts a smaller term for what the views tell. The posterior variance is therefore never below the prior one. With a small tau both terms are small. The table compares Apple's posterior variance with the empirical one.

pretty_table(DataFrame(["quantity" => ["AAPL variance"],                        "Empirical" => [pr_sample.sigma[i_aapl, i_aapl]],                        "BL posterior" => [pr_abs.sigma[i_aapl, i_aapl]]]);             formatters = [mmtfmt], title = "Posterior covariance adjustment")
      Posterior covariance adjustment
┌───────────────┬───────────┬──────────────┐
│      quantity │ Empirical │ BL posterior │
│        String │   Float64 │      Float64 │
├───────────────┼───────────┼──────────────┤
│ AAPL variance │    0.05 % │     0.0501 % │
└───────────────┴───────────┴──────────────┘

7. From views to portfolios

The views change the portfolio, and we show it in two ways. First we compare the maximum-ratio portfolio under the equilibrium prior with the one under the posterior that is bullish on Apple. Then we compute an efficient frontier under each, so that you can see the change over the whole range of risk and return.

using Clarabelslv = Solver(; name = :clarabel1, solver = Clarabel.Optimizer,             settings = Dict("verbose" => false),             check_sol = (; allow_local = true, allow_almost = true))rf = 4.2 / 100 / 252res_eq = optimise(MeanRisk(; obj = MaximumRatio(; rf = rf),                           opt = JuMPOptimiser(; pe = pr_eq, slv = slv)))res_bl = optimise(MeanRisk(; obj = MaximumRatio(; rf = rf),                           opt = JuMPOptimiser(; pe = pr_abs, slv = slv)))pretty_table(DataFrame(["Assets" => rd.nx, "Equilibrium" => res_eq.w,                        "Black-Litterman" => res_bl.w]); formatters = [resfmt],             title = "Maximum-ratio weights under the equilibrium prior and the Black-Litterman posterior")
Maximum-ratio weights under the equilibrium prior and the Black-Litterman posterior
┌────────┬─────────────┬─────────────────┐
│ Assets │ Equilibrium │ Black-Litterman │
│ String │     Float64 │         Float64 │
├────────┼─────────────┼─────────────────┤
│   AAPL │       0.0 % │        70.119 % │
│    AMD │    78.713 % │        23.276 % │
│    BAC │       0.0 % │           0.0 % │
│    BBY │       0.0 % │         1.041 % │
│    CVX │       0.0 % │           0.0 % │
│     GE │       0.0 % │           0.0 % │
│     HD │       0.0 % │           0.0 % │
│    JNJ │       0.0 % │           0.0 % │
│    JPM │       0.0 % │           0.0 % │
│     KO │       0.0 % │           0.0 % │
│    LLY │       0.0 % │           0.0 % │
│    MRK │       0.0 % │           0.0 % │
│   MSFT │       0.0 % │           0.0 % │
│    PEP │       0.0 % │           0.0 % │
│    PFE │       0.0 % │           0.0 % │
│     PG │       0.0 % │           0.0 % │
│    RRC │    21.287 % │         5.563 % │
│    UNH │       0.0 % │           0.0 % │
│    WMT │       0.0 % │           0.0 % │
│    XOM │       0.0 % │           0.0 % │
└────────┴─────────────┴─────────────────┘

In the composition plot, compare the weight of Apple in the two bars.

plot_stacked_bar_composition([res_eq, res_bl], rd;                             xticks = (1:2, ["Equilibrium", "Black-Litterman"]))
Example block output

Next we compute the two efficient frontiers. Each is a set of minimum-risk portfolios over a range of return targets, one under the equilibrium prior and one under the Black-Litterman posterior. The first plot is the equilibrium frontier. The view moves the whole frontier.

fr_eq = optimise(MeanRisk(; obj = MinimumRisk(),                          opt = JuMPOptimiser(; pe = pr_eq, slv = slv,                                              ret = ArithmeticReturn(;                                                                     settings = JuMPReturnsSettings(;                                                                                                    lb = Frontier(;                                                                                                                  N = 20))))))fr_bl = optimise(MeanRisk(; obj = MinimumRisk(),                          opt = JuMPOptimiser(; pe = pr_abs, slv = slv,                                              ret = ArithmeticReturn(;                                                                     settings = JuMPReturnsSettings(;                                                                                                    lb = Frontier(;                                                                                                                  N = 20))))))plot_measures(fr_eq.w, pr_eq; x = Variance(), y = ExpectedReturn(; rt = fr_eq.ret),              title = "Efficient frontier: equilibrium prior", xlabel = "Variance",              ylabel = "Expected return")
Example block output

The frontier under the Black-Litterman posterior.

plot_measures(fr_bl.w, pr_abs; x = Variance(), y = ExpectedReturn(; rt = fr_bl.ret),              title = "Efficient frontier: Black-Litterman posterior", xlabel = "Variance",              ylabel = "Expected return")
Example block output

This page was generated using Literate.jl.