The source files can be found in examples/.

Profile: factor-views desk

The fourth profile states its views on factors rather than on assets. The desk monthly profile stated a thesis about two sectors, healthcare over energy, and entropy pooling turned it into a prior. This desk states what it expects momentum and value to earn, and a factor model takes those two numbers to every asset through the regression of assets on factors. The posterior then goes into an optimiser with caps on it.

The advanced Black-Litterman page builds this prior one variant at a time. This page puts it to work in a whole book, with a FactorBlackLittermanPrior, sector caps and an exact allocation.

For this desk, three limits of the strategy decision framework bind.

  • The view is about what a factor earns, so we write it against the factor names and let the regression take it to the assets.
  • With only a cap on each asset, the book on this view puts most of its weight into two sectors. We give the optimiser a cap on each asset and a cap on each of those two sectors.
  • We take the risk-adjusted book with MaximumRatio, and turn it into whole shares with a mixed-integer solve.
When to reach for this

Reach for this profile when your view is about a factor, such as momentum, value, quality, size or low volatility, rather than about a name. Write the view against the factors with a factor Black-Litterman prior, then cap the sectors in the optimiser.

using PortfolioOptimisers, CSV, TimeSeries, DataFrames, PrettyTables, Clarabel, HiGHS,      StatsPlots, GraphRecipesresfmt = (v, i, j) -> begin    return if j == 1        v    else        isa(v, AbstractFloat) ? "$(round(v * 100, digits = 3)) %" : v    endend;

1. Data, factors, and the factor view

We load the S&P 500 slice together with its factor block, MTUM, QUAL, SIZE, USMV and VLUE. Two UniverseSets follow. The asset one names the sectors the caps refer to. The factor one names the assets and the factors in the column order of rd.F, which is the order FactorBlackLittermanPrior reads them in. The view itself is two numbers: momentum earns 5 basis points a day, and value earns 3.

X = 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))prices = vec(values(X)[end, :])asset_sets = UniverseSets(;                          dict = Dict("nx" => rd.nx, "tech" => ["AAPL", "AMD", "MSFT"],                                      "energy" => ["CVX", "XOM", "RRC"],                                      "healthcare" => ["JNJ", "LLY", "MRK", "PFE", "UNH"]))factor_sets = UniverseSets(; dict = Dict("nx" => rd.nx, "nf" => rd.nf))tau = 1 / size(rd.X, 1)factor_views = LinearConstraintEstimator(; val = ["MTUM == 0.0005", "VLUE == 0.0003"])
LinearConstraintEstimator
  val ┼ Vector{String}: ["MTUM == 0.0005", "VLUE == 0.0003"]
  key ┴ nothing

2. The factor Black-Litterman posterior

FactorBlackLittermanPrior regresses the assets on the factors, updates the factor means and the factor covariance with the two views, and returns a posterior mu and sigma over the assets. With rsd = true the posterior covariance adds the residual variance of each regression, so it measures the whole risk of an asset rather than the part the factors explain.

prior_est = FactorBlackLittermanPrior(; pe = EmpiricalPrior(), rsd = true,                                      sets = factor_sets, tau = tau, views = factor_views)pr = prior(prior_est, rd)slv = Solver(; name = :clarabel, solver = Clarabel.Optimizer,             settings = Dict("verbose" => false),             check_sol = (; allow_local = true, allow_almost = true))rf = 4.2 / 100 / 252
0.0001666666666666667

3. What the caps are for

A cap on each asset does not spread the book across sectors. We solve the risk-adjusted book once with a 15% cap on each asset and nothing else, then add up the weight each sector holds.

raw = optimise(MeanRisk(; obj = MaximumRatio(; rf = rf),                        opt = JuMPOptimiser(; pe = pr, slv = slv,                                            wb = WeightBounds(; lb = 0.0, ub = 0.15),                                            sets = asset_sets)))sector_weight(w, sec) = sum(w[[findfirst(==(t), rd.nx) for t in asset_sets.dict[sec]]])raw_sectors = DataFrame("sector" => ["tech", "energy", "healthcare"],                        "weight" => [sector_weight(raw.w, s)                                     for s in ("tech", "energy", "healthcare")])pretty_table(raw_sectors; formatters = [resfmt],             title = "Sector weights with only a per-asset cap")
Sector weights with only a per-asset cap
┌────────────┬──────────┐
│     sector │   weight │
│     String │  Float64 │
├────────────┼──────────┤
│       tech │    0.0 % │
│     energy │ 35.256 % │
│ healthcare │ 44.998 % │
└────────────┴──────────┘

The healthcare and energy rows take most of the book, and the tech row takes none of it.

4. The constrained desk book

The desk caps those two sectors, healthcare at 35% and energy at 25%, on top of the 15% cap on each asset. The optimiser still chooses which names to buy, and tech has no sector cap.

desk = optimise(MeanRisk(; obj = MaximumRatio(; rf = rf),                         opt = JuMPOptimiser(; pe = pr, slv = slv,                                             wb = WeightBounds(; lb = 0.0, ub = 0.15),                                             lcse = LinearConstraintEstimator(;                                                                              val = ["healthcare <= 0.35",                                                                                     "energy <= 0.25"]),                                             sets = asset_sets)))constrained_sectors = DataFrame("sector" => ["tech", "energy", "healthcare"],                                "constrained" => [sector_weight(desk.w, s)                                                  for s in ("tech", "energy", "healthcare")])pretty_table(constrained_sectors; formatters = [resfmt],             title = "Sector weights with healthcare capped at 35% and energy at 25%")pretty_table(DataFrame("Asset" => rd.nx, "Weight" => desk.w); formatters = [resfmt],             title = "Factor-views desk tangency weights with the sector caps")
Sector weights with healthcare capped at 35% and energy at 25%
┌────────────┬─────────────┐
│     sector │ constrained │
│     String │     Float64 │
├────────────┼─────────────┤
│       tech │       0.0 % │
│     energy │      25.0 % │
│ healthcare │      35.0 % │
└────────────┴─────────────┘
Factor-views desk tangency weights with the sector caps
┌────────┬──────────┐
│  Asset │   Weight │
│ String │  Float64 │
├────────┼──────────┤
│   AAPL │    0.0 % │
│    AMD │    0.0 % │
│    BAC │    0.0 % │
│    BBY │    0.0 % │
│    CVX │   10.0 % │
│     GE │    0.0 % │
│     HD │    0.0 % │
│    JNJ │  4.999 % │
│    JPM │    0.0 % │
│     KO │   15.0 % │
│    LLY │   15.0 % │
│    MRK │   15.0 % │
│   MSFT │    0.0 % │
│    PEP │   15.0 % │
│    PFE │    0.0 % │
│     PG │  0.001 % │
│    RRC │    0.0 % │
│    UNH │  0.001 % │
│    WMT │  9.999 % │
│    XOM │ 14.999 % │
└────────┴──────────┘

Compare the two sector tables. Healthcare and energy sit at their caps in the second one, and the weight that left them went to names outside the three sectors. The third table gives the whole book, name by name.

5. Exact finite allocation

The desk invests $1,000,000. As in the institutional profile, DiscreteAllocation rounds the target to whole shares with a mixed-integer solve.

mip_slv = Solver(; name = :highs, solver = HiGHS.Optimizer,                 settings = Dict("log_to_console" => false))alloc = optimise(DiscreteAllocation(; slv = mip_slv),                 FiniteAllocationInput(; w = desk.w, prices = prices, cash = 1_000_000.0))invested = sum(alloc.shares .* prices)pretty_table(DataFrame("Asset" => rd.nx, "Target" => desk.w,                       "Shares" => round.(Int, alloc.shares), "Realised" => alloc.w);             formatters = [resfmt],             title = "\$1,000,000 to invest, \```math(round(Int, invested)) invested, \```(round(alloc.cash, digits = 2)) left in cash")
$1,000,000 to invest, ```math(round(Int, invested)) invested, ```(round(alloc.cash, digits = 2)) left in cash
┌────────┬──────────┬────────┬──────────┐
│  Asset │   Target │ Shares │ Realised │
│ String │  Float64 │  Int64 │  Float64 │
├────────┼──────────┼────────┼──────────┤
│   AAPL │    0.0 % │      0 │    0.0 % │
│    AMD │    0.0 % │      0 │    0.0 % │
│    BAC │    0.0 % │      0 │    0.0 % │
│    BBY │    0.0 % │      0 │    0.0 % │
│    CVX │   10.0 % │    576 │ 10.007 % │
│     GE │    0.0 % │      0 │    0.0 % │
│     HD │    0.0 % │      0 │    0.0 % │
│    JNJ │  4.999 % │    287 │  4.996 % │
│    JPM │    0.0 % │      0 │    0.0 % │
│     KO │   15.0 % │   2395 │ 14.995 % │
│    LLY │   15.0 % │    413 │ 14.996 % │
│    MRK │   15.0 % │   1369 │ 15.002 % │
│   MSFT │    0.0 % │      0 │    0.0 % │
│    PEP │   15.0 % │    837 │ 15.006 % │
│    PFE │    0.0 % │      0 │    0.0 % │
│     PG │  0.001 % │      0 │    0.0 % │
│    RRC │    0.0 % │      0 │    0.0 % │
│    UNH │  0.001 % │      0 │    0.0 % │
│    WMT │  9.999 % │    713 │  9.995 % │
│    XOM │ 14.999 % │   1407 │ 15.003 % │
└────────┴──────────┴────────┴──────────┘

6. The book

plot_stacked_bar_composition([desk], rd; xticks = (1:1, ["Factor-views desk"]))
Example block output

This page was generated using Literate.jl.