The source files can be found in examples/.

Entropy pooling

Black–Litterman blends views into the mean through a Gaussian update. Entropy pooling is more general in two ways. First, it expresses views as constraints on any moment — mean, variance, CVaR, skewness, kurtosis, even individual covariances and correlations. Second, it does not assume normality: it reweights the empirical scenarios so that the new distribution satisfies your views while staying as close as possible (in relative entropy / Kullback–Leibler divergence) to the original. The output is a fully reweighted prior, not just a shifted mean.

This is the second page of the view-prior arc — Black–Litterman came first, and Opinion Pooling follows, combining several entropy-pooling views into one.

In PortfolioOptimisers, EntropyPoolingPrior accepts a separate LinearConstraintEstimator per quantity. Mind the naming: mu_views is the mean, sigma_views is the variance, var_views is the Value at Risk, cvar_views the Conditional VaR and evar_views the Entropic VaR (tail-risk views), sk_views/kt_views are skewness/kurtosis, and cov_views/rho_views target covariances/correlations. Each is a list of string constraints over the UniverseSets names.

When to reach for this

Reach for entropy pooling when your views are richer than "the mean will be x": views on volatility, tail risk (CVaR), skewness, or the correlation between two assets, possibly several at once. It is also the right tool when you distrust the normality assumption baked into Black–Litterman, since it reweights the empirical scenarios directly. For a simple mean-only view, Black–Litterman is lighter; to combine several entropy-pooling opinions, see Opinion 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. ReturnsResult data

We use the same S&P 500 slice as the other examples.

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
    ts ┼ 252-element Vector{Date}
    iv ┼ nothing
  ivpa ┼ nothing
    nz ┼ nothing
     Z ┴ nothing

2. Naming assets and groups

As with Black–Litterman, views reference assets and groups by name through an UniverseSets.

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

3. Views on several moments

Entropy-pooling views are also plain strings, but they can target different quantities. Here we state a mean view (Apple returns 8 bps) via mu_views, a relative mean view (tech outperforms energy), and a variance view (pin Apple's variance) via sigma_views. The comparison operators a view accepts depend on the moment: mu_views, sigma_views, sk_views, kt_views, cov_views, rho_views, cvar_views and evar_views take ==, >= and <=; var_views (VaR) takes only == and >=. An unsupported operator raises a ParseError listing the ones allowed for that view.

A significance level belongs to the view rather than to the estimator: the CVaR at 1% and at 10% are different statistics of the same series. So var_views, cvar_views and evar_views each take a ValueatRiskView, a ConditionalValueatRiskView or an EntropicValueatRiskView — each pairing a group of view equations with the alpha it is read under — or a vector of them for views stated at several levels. A prior(...) reference inside a group resolves at that group's level.

A tail view is not a linear function of the posterior probabilities, so it needs auxiliary variables and therefore a JuMPEntropyPooling in opt. The alg field of a tail view group picks how each view is written; left at nothing each takes the cheapest formulation that expresses it exactly — LinearConditionalValueatRiskView and ConicEntropicValueatRiskView for a lower bound or an equality at or above the prior value, and IntegerConditionalValueatRiskView or GridEntropicValueatRiskView otherwise, which need a mixed-integer conic solver. For an EntropicValueatRiskView the alg field is also where the grid of dual variables and the big-M constant live, so one group can take its own GridEntropicValueatRiskView. ValueatRiskView has no alg: a VaR view is linear in the posterior probabilities, so there is no formulation to choose.

mu_views = LinearConstraintEstimator(; val = ["AAPL == 0.0008", "tech >= energy"])sigma_views = LinearConstraintEstimator(; val = ["AAPL == 0.0003"])ep = EntropyPoolingPrior(; sets = sets, mu_views = mu_views, sigma_views = sigma_views)
EntropyPoolingPrior
           pe ┼ EmpiricalPrior
              │        ce ┼ PortfolioOptimisersCovariance
              │           │   ce ┼ Covariance
              │           │      │    me ┼ SimpleExpectedReturns
              │           │      │       │   w ┴ nothing
              │           │      │    ce ┼ GeneralCovariance
              │           │      │       │   ce ┼ SimpleCovariance: SimpleCovariance(true)
              │           │      │       │    w ┴ nothing
              │           │      │   alg ┴ FullMoment()
              │           │   mp ┼ MatrixProcessing
              │           │      │     pdm ┼ Posdef
              │           │      │         │      alg ┼ UnionAll: NearestCorrelationMatrix.Newton
              │           │      │         │   kwargs ┴ @NamedTuple{}: NamedTuple()
              │           │      │      dn ┼ nothing
              │           │      │      dt ┼ nothing
              │           │      │     alg ┼ nothing
              │           │      │   order ┴ NTuple{4, Symbol}: (:pdm, :dn, :dt, :alg)
              │        me ┼ SimpleExpectedReturns
              │           │   w ┴ nothing
              │   horizon ┴ nothing
     mu_views ┼ LinearConstraintEstimator
              │   val ┼ Vector{String}: ["AAPL == 0.0008", "tech >= energy"]
              │   key ┴ nothing
    var_views ┼ nothing
   cvar_views ┼ nothing
   evar_views ┼ nothing
  sigma_views ┼ LinearConstraintEstimator
              │   val ┼ Vector{String}: ["AAPL == 0.0003"]
              │   key ┴ nothing
     sk_views ┼ nothing
     kt_views ┼ nothing
    cov_views ┼ nothing
    rho_views ┼ nothing
         sets ┼ UniverseSets
              │    xkey ┼ String: "nx"
              │   uxkey ┼ String: "ux"
              │    fkey ┼ String: "nf"
              │   ufkey ┼ String: "uf"
              │    zkey ┼ String: "nz"
              │    dict ┴ Dict{String, Vector{String}}: Dict("nx" => ["AAPL", "AMD", "BAC", "BBY", "CVX", "GE", "HD", "JNJ", "JPM", "KO", "LLY", "MRK", "MSFT", "PEP", "PFE", "PG", "RRC", "UNH", "WMT", "XOM"], "tech" => ["AAPL", "AMD", "MSFT"], "energy" => ["CVX"])
          opt ┼ OptimEntropyPooling
              │     args ┼ Tuple{}: ()
              │   kwargs ┼ @NamedTuple{}: NamedTuple()
              │      sc1 ┼ Int64: 1
              │      sc2 ┼ Float64: 1000.0
              │      alg ┼ ExpEntropyPooling()
              │      err ┴ nothing
            w ┼ nothing
          alg ┴ H1_EntropyPooling()

4. Prior vs reweighted posterior

We compute the entropy-pooling posterior and compare both the mean and the variance of Apple against the plain empirical prior — the mean view lifts the expected return while the variance view tightens the dispersion, exactly as instructed.

pr_ep = prior(ep, rd)pr_emp = prior(EmpiricalPrior(), rd)i_aapl = findfirst(==("AAPL"), rd.nx)pretty_table(DataFrame(["moment" => ["mean (AAPL)", "variance (AAPL)"],                        "Empirical" => [pr_emp.mu[i_aapl], pr_emp.sigma[i_aapl, i_aapl]],                        "Entropy pooling" =>                            [pr_ep.mu[i_aapl], pr_ep.sigma[i_aapl, i_aapl]]]);             formatters = [mmtfmt],             title = "Apple moments: empirical vs entropy-pooling view")
Apple moments: empirical vs entropy-pooling view
┌─────────────────┬───────────┬─────────────────┐
│          moment  Empirical  Entropy pooling │
│          String    Float64          Float64 │
├─────────────────┼───────────┼─────────────────┤
│     mean (AAPL) │ -0.1126 % │          0.08 % │
│ variance (AAPL) │    0.05 % │        0.0301 % │
└─────────────────┴───────────┴─────────────────┘

The full expected-returns vectors, side by side.

pretty_table(DataFrame(["Assets" => rd.nx, "Empirical" => pr_emp.mu,                        "Entropy pooling" => pr_ep.mu]); formatters = [mmtfmt],             title = "Expected returns: empirical vs entropy-pooling posterior")
Expected returns: empirical vs entropy-pooling posterior
┌────────┬───────────┬─────────────────┐
│ Assets  Empirical  Entropy pooling │
│ String    Float64          Float64 │
├────────┼───────────┼─────────────────┤
│   AAPL │ -0.1126 % │          0.08 % │
│    AMD │ -0.2809 % │        0.0698 % │
│    BAC │ -0.0934 % │       -0.0124 % │
│    BBY │ -0.0279 % │        0.1921 % │
│    CVX │  0.1945 % │         0.243 % │
│     GE │ -0.0339 % │        0.1278 % │
│     HD │ -0.0707 % │        0.0504 % │
│    JNJ │  0.0307 % │        0.0766 % │
│    JPM │ -0.0417 % │        0.0256 % │
│     KO │  0.0497 % │         0.098 % │
│    LLY │  0.1305 % │        0.1942 % │
│    MRK │  0.1669 % │        0.1861 % │
│   MSFT │ -0.1206 % │        0.0932 % │
│    PEP │   0.039 % │        0.1159 % │
│    PFE │ -0.0256 % │         0.033 % │
│     PG │ -0.0081 % │        0.0576 % │
│    RRC │  0.1824 % │        0.2808 % │
│    UNH │  0.0364 % │        0.1196 % │
│    WMT │  0.0163 % │        0.0763 % │
│    XOM │  0.2637 % │        0.3058 % │
└────────┴───────────┴─────────────────┘

Entropy-pooling posterior expected returns.

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

5. Why it matters: views change the portfolio

Feeding the reweighted prior to a return-seeking optimiser tilts the portfolio toward the view-favoured assets, just as Black–Litterman did — but here the whole distribution, not only the mean, has been updated.

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_emp = optimise(MeanRisk(; obj = MaximumRatio(; rf = rf),                            opt = JuMPOptimiser(; pe = pr_emp, slv = slv)))res_ep = optimise(MeanRisk(; obj = MaximumRatio(; rf = rf),                           opt = JuMPOptimiser(; pe = pr_ep, slv = slv)))pretty_table(DataFrame(["Assets" => rd.nx, "Empirical" => res_emp.w,                        "Entropy pooling" => res_ep.w]); formatters = [resfmt],             title = "Maximum-ratio weights: empirical vs entropy pooling")
Maximum-ratio weights: empirical vs entropy pooling
┌────────┬───────────┬─────────────────┐
│ Assets  Empirical  Entropy pooling │
│ String    Float64          Float64 │
├────────┼───────────┼─────────────────┤
│   AAPL │     0.0 % │           0.0 % │
│    AMD │     0.0 % │           0.0 % │
│    BAC │     0.0 % │           0.0 % │
│    BBY │     0.0 % │          6.52 % │
│    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.002 % │         6.913 % │
│    MRK │  65.977 % │        44.433 % │
│   MSFT │     0.0 % │           0.0 % │
│    PEP │     0.0 % │        12.545 % │
│    PFE │     0.0 % │           0.0 % │
│     PG │     0.0 % │           0.0 % │
│    RRC │     0.0 % │           0.0 % │
│    UNH │     0.0 % │           0.0 % │
│    WMT │     0.0 % │           0.0 % │
│    XOM │   34.02 % │         29.59 % │
└────────┴───────────┴─────────────────┘

The composition plot makes the tilt visible.

plot_stacked_bar_composition([res_emp, res_ep], rd;                             xticks = (1:2, ["Empirical", "Entropy pooling"]))
Example block output

This page was generated using Literate.jl.