The source files can be found in examples/.

Advanced Black-Litterman variants

The base BlackLittermanPrior of the previous page works on the assets alone, and each view is about one asset or a group of assets. But a view is often easier to state about a factor, such as "momentum will earn a premium" or "quality minus low volatility will be negative". Sometimes you also hold views on assets and on factors at once. The library has three variants of the model for these cases.

  • BayesianBlackLittermanPrior is the Bayesian form of the model, and it builds on a FactorPrior. You state the views on the factors, and the factor model carries the posterior back to the assets.
  • FactorBlackLittermanPrior takes views on the factor premia and passes them to the assets through a factor regression. The rsd flag sets whether the posterior keeps the residual variance of each asset. l is the risk aversion of an implied factor equilibrium, and it is off by default.
  • AugmentedBlackLittermanPrior takes views on assets in a_views and views on factors in f_views in one model, each with its own confidence.

Each variant returns a posterior mean and covariance over the assets, mu and sigma, and you give it to an optimiser as you give the base model.

When to reach for this

Reach for these when your views are about factors, alone or next to views on single assets. Examples are a macro view on momentum or value, a spread between quality and low volatility, or a view on a stock that you want to combine with a view on a factor in one posterior. If every view is about assets, the base BlackLittermanPrior is simpler, and it is enough.

Factor data required

These variants need factor returns, so you must build the ReturnsResult with the factor data, which the price ingestion step adds: prices_to_returns(price_ingestion(PriceIngestion(), X; F = F)). Factor views and factor sets use the factor names in rd.nf.

using PortfolioOptimisers, PrettyTables, DataFramesmmtfmt = (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. Data, sets and the equilibrium prior

We load one year of daily prices of 20 assets and of five factor funds, and convert both to returns. Then we declare one UniverseSets that all three variants read. It lists the assets under "nx" and the factors under "nf", each in the column order of rd.X and rd.F. These are the default keys under which every estimator looks for the assets and the factors. The xkey and tfkey fields of UniverseSets hold the two keys. The object also holds two groups of assets for the asset views of the augmented model. One object therefore serves a model with factor views only, a model with asset views only, and the augmented model that has both.

We also compute the EquilibriumExpectedReturns prior as a reference to compare the posteriors with. The Bayesian, the factor and the augmented posteriors of the comparison table do not start from it. Each starts from the sample mean of the quantities its views update.

using CSV, TimeSeriesX = TimeArray(CSV.File(joinpath(@__DIR__, "..", "SP500.csv.gz")); timestamp = :Date)[(end - 252):end]F = TimeArray(CSV.File(joinpath(@__DIR__, "..", "Factors.csv.gz")); timestamp = :Date)[(end - 252):end]rd = prices_to_returns(price_ingestion(PriceIngestion(), X; F = F))universe_sets = UniverseSets(;                             dict = Dict("nx" => rd.nx, "nf" => rd.nf,                                         "tech" => ["AAPL", "AMD", "MSFT"],                                         "energy" => ["CVX"]))tau = 1 / size(rd.X, 1)pr_eq = prior(EmpiricalPrior(; me = EquilibriumExpectedReturns()), rd)
LowOrderPrior
        X ┼ 252×20 Matrix{Float64}
      o_X ┼ nothing
       mu ┼ 20-element Vector{Float64}
    sigma ┼ 20×20 Matrix{Float64}
     chol ┼ nothing
        w ┼ nothing
      ens ┼ nothing
      kld ┼ nothing
       ow ┼ nothing
       rr ┼ nothing
      fpr ┼ nothing
    x_src ┼ nothing
  ss_gaps ┼ nothing
       pc ┴ nothing

rd.nf holds the names of the factors in this data set.

pretty_table(DataFrame(; factor = rd.nf); title = "Factor names (rd.nf)")
Factor names (rd.nf)
┌────────┐
│ factor │
│ String │
├────────┤
│   MTUM │
│   QUAL │
│   SIZE │
│   USMV │
│   VLUE │
└────────┘

2. Bayesian Black-Litterman with factor views

BayesianBlackLittermanPrior takes a FactorPrior as its base estimator, and views on the factors, whose names must be in sets.dict[sets.tfkey]. In this Bayesian form the factor prior gives the structure, and the views update the mean and the covariance of the factors. The model then maps the result back to a posterior over the assets through the factor loadings.

We state two factor views. Momentum earns 5 bps a day, and quality returns 3 bps a day less than low volatility.

factor_views = LinearConstraintEstimator(;                                         val = ["MTUM == 0.0005", "QUAL - USMV == -0.0003"])pr_bayes = prior(BayesianBlackLittermanPrior(; pe = FactorPrior(; pe = EmpiricalPrior()),                                             sets = universe_sets, tau = tau,                                             views = factor_views), rd)
LowOrderPrior
        X ┼ 252×20 Matrix{Float64}
      o_X ┼ 252×20 Matrix{Float64}
       mu ┼ 20-element Vector{Float64}
    sigma ┼ 20×20 Matrix{Float64}
     chol ┼ nothing
        w ┼ nothing
      ens ┼ nothing
      kld ┼ nothing
       ow ┼ nothing
       rr ┼ Regression
          │        M ┼ 20×5 SubArray{Float64, 2, Matrix{Float64}, Tuple{Base.Slice{Base.OneTo{Int64}}, UnitRange{Int64}}, true}
          │        L ┼ 20×5 SubArray{Float64, 2, Matrix{Float64}, Tuple{Base.Slice{Base.OneTo{Int64}}, UnitRange{Int64}}, true}
          │        b ┼ 20-element SubArray{Float64, 1, Matrix{Float64}, Tuple{Base.Slice{Base.OneTo{Int64}}, Int64}, true}
          │   esigma ┼ 20-element Vector{Float64}
          │     edof ┼ 20-element Vector{Int64}
          │     ediv ┼ 20-element Vector{Int64}
          │      idx ┼ 252-element UnitRange{Int64}
          │       ts ┴ nothing
      fpr ┼ LowOrderPrior
          │         X ┼ 252×5 Matrix{Float64}
          │       o_X ┼ nothing
          │        mu ┼ Vector{Float64}: [0.0004935513857205133, 0.00013981052864864518, 0.0002941301399949343, 0.0004387517589914691, 0.0003055527028387441]
          │     sigma ┼ 5×5 Matrix{Float64}
          │      chol ┼ nothing
          │         w ┼ nothing
          │       ens ┼ nothing
          │       kld ┼ nothing
          │        ow ┼ nothing
          │        rr ┼ nothing
          │       fpr ┼ nothing
          │     x_src ┼ nothing
          │   ss_gaps ┼ nothing
          │        pc ┴ nothing
    x_src ┼ nothing
  ss_gaps ┼ nothing
       pc ┴ nothing

3. Factor Black-Litterman with views on factor premia

FactorBlackLittermanPrior also takes factor views. It runs the ordinary Black-Litterman update on a prior fit to the factors alone, and carries the result to the assets through the regression loadings. Its views name factors from the same list, sets.dict[sets.tfkey]. Two parameters change the posterior.

  • rsd = true adds the residual variance of each asset on the diagonal of the posterior covariance, and rsd = false keeps only the part the factors explain.
  • l is off by default, nothing. Set it, and the model replaces the factor prior's own mean with the equilibrium mean that this risk aversion implies for the equal-weight portfolio.

We build one prior with each rsd setting, and a third that sets l = 5. The table compares Apple's posterior mean and variance under the two rsd settings. The page prints nothing for the third prior, and you can compare pr_fbl_l.mu with pr_fbl_rsd.mu to see what l changes.

pr_fbl_rsd = prior(FactorBlackLittermanPrior(; pe = EmpiricalPrior(), rsd = true,                                             sets = universe_sets, tau = tau,                                             views = factor_views), rd)pr_fbl_nors = prior(FactorBlackLittermanPrior(; pe = EmpiricalPrior(), rsd = false,                                              sets = universe_sets, tau = tau,                                              views = factor_views), rd)pr_fbl_l = prior(FactorBlackLittermanPrior(; pe = EmpiricalPrior(), rsd = true, l = 5.0,                                           sets = universe_sets, tau = tau,                                           views = factor_views), rd)i_aapl = findfirst(==("AAPL"), rd.nx)pretty_table(DataFrame(; quantity = ["AAPL posterior mean", "AAPL posterior variance"],                       rsd_true = [pr_fbl_rsd.mu[i_aapl], pr_fbl_rsd.sigma[i_aapl, i_aapl]],                       rsd_false = [pr_fbl_nors.mu[i_aapl],                                    pr_fbl_nors.sigma[i_aapl, i_aapl]]);             formatters = [mmtfmt],             title = "AAPL posterior mean and variance of FactorBlackLittermanPrior with and without the residual variance")
AAPL posterior mean and variance of FactorBlackLittermanPrior with and without the residual variance
┌─────────────────────────┬──────────┬───────────┐
│                quantity │ rsd_true │ rsd_false │
│                  String │  Float64 │   Float64 │
├─────────────────────────┼──────────┼───────────┤
│     AAPL posterior mean │ -0.044 % │  -0.044 % │
│ AAPL posterior variance │ 0.0501 % │  0.0384 % │
└─────────────────────────┴──────────┴───────────┘

With rsd = false the covariance keeps only the part the factors explain, so the posterior variance is smaller. The model then assumes that the factors explain all of the risk.

4. Augmented Black-Litterman with asset and factor views

AugmentedBlackLittermanPrior is the most general variant. It takes views on assets in a_views and views on factors in f_views, in the same posterior. Use it when you hold a view on a stock and a view on a factor, and do not want to choose between them.

It is the only estimator of the three that reads both declared lists, and it reads them from the same universe_sets as the variants above. It resolves the names of a_views against the assets, sets.dict[sets.xkey], and the names of f_views against the factors, sets.dict[sets.tfkey]. A group name such as tech is read from its own entry in sets.dict, and in Black-Litterman it stands for the average of its members.

asset_views = LinearConstraintEstimator(; val = ["AAPL == 0.0008", "tech == 0.0006"])pr_aug = prior(AugmentedBlackLittermanPrior(; sets = universe_sets, tau = tau,                                            a_views = asset_views, f_views = factor_views),               rd)
LowOrderPrior
        X ┼ 252×20 Matrix{Float64}
      o_X ┼ 252×20 Matrix{Float64}
       mu ┼ 20-element Vector{Float64}
    sigma ┼ 20×20 Matrix{Float64}
     chol ┼ nothing
        w ┼ nothing
      ens ┼ nothing
      kld ┼ nothing
       ow ┼ nothing
       rr ┼ Regression
          │        M ┼ 20×5 SubArray{Float64, 2, Matrix{Float64}, Tuple{Base.Slice{Base.OneTo{Int64}}, UnitRange{Int64}}, true}
          │        L ┼ 20×5 SubArray{Float64, 2, Matrix{Float64}, Tuple{Base.Slice{Base.OneTo{Int64}}, UnitRange{Int64}}, true}
          │        b ┼ 20-element SubArray{Float64, 1, Matrix{Float64}, Tuple{Base.Slice{Base.OneTo{Int64}}, Int64}, true}
          │   esigma ┼ nothing
          │     edof ┼ 20-element Vector{Int64}
          │     ediv ┼ nothing
          │      idx ┼ nothing
          │       ts ┴ nothing
      fpr ┼ LowOrderPrior
          │         X ┼ 252×5 Matrix{Float64}
          │       o_X ┼ nothing
          │        mu ┼ Vector{Float64}: [0.0001738101695024088, 0.00010766089837441716, 0.00023286771481700614, 0.0003149866079392385, 0.00021554387985553566]
          │     sigma ┼ 5×5 Matrix{Float64}
          │      chol ┼ nothing
          │         w ┼ nothing
          │       ens ┼ nothing
          │       kld ┼ nothing
          │        ow ┼ nothing
          │        rr ┼ nothing
          │       fpr ┼ nothing
          │     x_src ┼ nothing
          │   ss_gaps ┼ nothing
          │        pc ┴ nothing
    x_src ┼ nothing
  ss_gaps ┼ nothing
       pc ┴ nothing

5. Comparing the posteriors

Each variant gives a different posterior mean. The table lines them up with the equilibrium prior for the first few assets. The factor views move every asset, and the augmented model also moves the assets that its asset views name.

cmp = DataFrame(; Assets = rd.nx, Equilibrium = pr_eq.mu, Bayesian = pr_bayes.mu,                Factor = pr_fbl_rsd.mu, Augmented = pr_aug.mu)pretty_table(first(cmp, 8); formatters = [mmtfmt],             title = "Posterior expected returns (first 8 assets)")
        Posterior expected returns (first 8 assets)
┌────────┬─────────────┬───────────┬───────────┬───────────┐
│ Assets │ Equilibrium │  Bayesian │    Factor │ Augmented │
│ String │     Float64 │   Float64 │   Float64 │   Float64 │
├────────┼─────────────┼───────────┼───────────┼───────────┤
│   AAPL │    0.0231 % │  0.0036 % │  -0.044 % │  0.0222 % │
│    AMD │    0.0362 % │ -0.1036 % │ -0.1656 % │ -0.0792 % │
│    BAC │    0.0191 % │  0.0005 % │ -0.0392 % │ -0.0127 % │
│    BBY │    0.0239 % │  0.0659 % │  0.0316 % │   0.072 % │
│    CVX │    0.0144 % │  0.2523 % │  0.2295 % │  0.2375 % │
│     GE │    0.0194 % │  0.0642 % │  0.0212 % │  0.0512 % │
│     HD │    0.0178 % │  0.0126 % │ -0.0229 % │  0.0082 % │
│    JNJ │    0.0075 % │  0.0701 % │  0.0456 % │  0.0588 % │
└────────┴─────────────┴───────────┴───────────┴───────────┘

The posterior expected returns of the augmented model, for every asset.

using StatsPlots, GraphRecipesplot_mu(pr_aug, rd.nx)
Example block output

6. From views to portfolios

As with the base model, each posterior changes the optimal portfolio. We solve a maximum-ratio portfolio under the equilibrium prior and under each variant, then compare the compositions.

using Clarabelslv = Solver(; name = :clarabel1, solver = Clarabel.Optimizer,             settings = Dict("verbose" => false),             check_sol = (; allow_local = true, allow_almost = true))rf = 4.2 / 100 / 252priors = ["Equilibrium" => pr_eq, "Bayesian" => pr_bayes, "Factor" => pr_fbl_rsd,          "Augmented" => pr_aug]res = [optimise(MeanRisk(; obj = MaximumRatio(; rf = rf),                         opt = JuMPOptimiser(; pe = p, slv = slv))) for (_, p) in priors]pretty_table(DataFrame(hcat(rd.nx, [r.w for r in res]...),                       [:assets; Symbol.(first.(priors))...]); formatters = [resfmt],             title = "Maximum-ratio weights by prior")plot_stacked_bar_composition(res, rd; xticks = (1:length(priors), first.(priors)))
Example block output

Summary

The three variants take views that the base model cannot.


This page was generated using Literate.jl.