The source files can be found in examples/.

Nested clustered optimisation with layered constraints and fees

This example puts weight bounds on three layers of NestedClustered.

  • The inner optimisation inside each cluster.
  • The outer optimisation over the cluster portfolios, the portfolio that each inner optimisation makes of its cluster.
  • The final weights of the assets over the whole universe.

It also charges a fee on the assets of one cluster, and shows which outer objective the fee changes.

When to reach for this

Reach for this when a mandate caps an asset inside its cluster, a whole cluster, or an asset in the final portfolio.

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

1. Data and shared setup

We use one year of S&P 500 prices and compute the clusters once. Then we compare three portfolios.

  • Weight bounds on the inner optimisations only.
  • Weight bounds on the inner and the outer optimisations.
  • The same, and bounds on the final asset weights, given to NestedClustered.

Section 3 adds a fee to the second portfolio.

using CSV, TimeSeries, DataFrames, ClarabelX = TimeArray(CSV.File(joinpath(@__DIR__, "..", "SP500.csv.gz")); timestamp = :Date)[(end - 252):end]rd = prices_to_returns(X)slv = [Solver(; name = :clarabel1, solver = Clarabel.Optimizer,              settings = Dict("verbose" => false),              check_sol = (; allow_local = true, allow_almost = true)),       Solver(; name = :clarabel2, solver = Clarabel.Optimizer,              settings = Dict("verbose" => false, "max_step_fraction" => 0.95),              check_sol = (; allow_local = true, allow_almost = true)),       Solver(; name = :clarabel3, solver = Clarabel.Optimizer,              settings = Dict("verbose" => false, "max_step_fraction" => 0.9),              check_sol = (; allow_local = true, allow_almost = true))]pr = prior(EmpiricalPrior(), rd)clr = clusterise(ClustersEstimator(; alg = DBHT()), pr.X)
Clusters
  res ┼ Clustering.Hclust{Float64}([-1 -13; -7 -4; … ; 12 17; 10 18], [0.1, 0.1111111111111111, 0.125, 0.14285714285714285, 0.16666666666666666, 0.2, 0.25, 0.3333333333333333, 0.5, 1.0, 0.125, 0.14285714285714285, 0.16666666666666666, 0.2, 0.25, 0.3333333333333333, 0.5, 1.0, 2.0], [17, 20, 5, 3, 9, 6, 2, 1, 13, 7, 4, 19, 14, 10, 16, 18, 11, 8, 12, 15], :DBHT)
    S ┼ 20×20 Matrix{Float64}
    D ┼ 20×20 Matrix{Float64}
    P ┼ nothing
    k ┴ Int64: 4

2. Constraints at each layer

NestedClustered solves two kinds of problem.

  • An inner MeanRisk in each cluster.
  • An outer MeanRisk over the returns of the cluster portfolios, one series per cluster.

The outer JuMPOptimiser takes no pe, because it works on the returns of the cluster portfolios and not on the assets. The inner bound caps each weight inside a cluster at 35%. The outer bound caps the weight of each cluster at 62%. The final bound caps each asset at 20% of the whole portfolio.

jopti_inner = JuMPOptimiser(; pe = pr, slv = slv, wb = WeightBounds(; lb = 0.0, ub = 0.35))jopto_base = JuMPOptimiser(; slv = slv)jopto_outer = JuMPOptimiser(; slv = slv, wb = WeightBounds(; lb = 0.0, ub = 0.62))res_inner = optimise(NestedClustered(; pe = pr, cle = clr,                                     opti = MeanRisk(; obj = MinimumRisk(),                                                     opt = jopti_inner),                                     opto = MeanRisk(; obj = MinimumRisk(),                                                     opt = jopto_base)), rd)res_inner_outer = optimise(NestedClustered(; pe = pr, cle = clr,                                           opti = MeanRisk(; obj = MinimumRisk(),                                                           opt = jopti_inner),                                           opto = MeanRisk(; obj = MinimumRisk(),                                                           opt = jopto_outer)), rd)wb_overall_assets = WeightBounds(; lb = fill(0.0, length(rd.nx)),                                 ub = fill(0.20, length(rd.nx)))
WeightBounds
  lb ┼ 20-element Vector{Float64}
  ub ┴ 20-element Vector{Float64}

The wb of NestedClustered bounds the final asset weights. We give it one bound per asset, which is how you give each asset its own cap. Here every cap is 0.20, so a scalar bound of 0.20 gives the same constraint.

res_nested_overall = optimise(NestedClustered(; pe = pr, cle = clr, wb = wb_overall_assets,                                              opti = MeanRisk(; obj = MinimumRisk(),                                                              opt = jopti_inner),                                              opto = MeanRisk(; obj = MinimumRisk(),                                                              opt = jopto_outer)), rd)pretty_table(DataFrame(; :assets => rd.nx, :InnerOnlyWB => res_inner.w,                       :InnerOuterWB => res_inner_outer.w,                       :NestedDirectOverallWB => res_nested_overall.w);             formatters = [resfmt])
┌────────┬─────────────┬──────────────┬───────────────────────┐
│ assets │ InnerOnlyWB │ InnerOuterWB │ NestedDirectOverallWB │
│ String │     Float64 │      Float64 │               Float64 │
├────────┼─────────────┼──────────────┼───────────────────────┤
│   AAPL │       0.0 % │        0.0 % │                 0.0 % │
│    AMD │       0.0 % │        0.0 % │                 0.0 % │
│    BAC │       0.0 % │        0.0 % │                 0.0 % │
│    BBY │       0.0 % │        0.0 % │                 0.0 % │
│    CVX │     3.274 % │      3.304 % │               3.458 % │
│     GE │       0.0 % │        0.0 % │                 0.0 % │
│     HD │       0.0 % │        0.0 % │                 0.0 % │
│    JNJ │    22.366 % │       21.7 % │                20.0 % │
│    JPM │       0.0 % │        0.0 % │                 0.0 % │
│     KO │      9.36 % │      9.996 % │              10.463 % │
│    LLY │       0.0 % │        0.0 % │                 0.0 % │
│    MRK │    21.618 % │     20.975 % │                20.0 % │
│   MSFT │       0.0 % │        0.0 % │                 0.0 % │
│    PEP │      9.36 % │      9.996 % │              10.463 % │
│    PFE │     0.264 % │      0.256 % │               0.268 % │
│     PG │    14.382 % │     13.954 % │              14.605 % │
│    RRC │     2.806 % │      2.832 % │               2.964 % │
│    UNH │     5.272 % │      5.115 % │               5.354 % │
│    WMT │     8.023 % │      8.568 % │               8.968 % │
│    XOM │     3.274 % │      3.304 % │               3.458 % │
└────────┴─────────────┴──────────────┴───────────────────────┘

For each portfolio, we print three numbers next to the bound of their layer.

  • The largest weight inside any cluster, against the inner bound, 0.35.
  • The largest weight of a cluster, against the outer bound, 0.62.
  • The largest final asset weight, against the final bound, 0.20.
inner_local_max(res) = maximum(maximum(ri.w) for ri in res.resi)outer_cluster_max(res) = maximum(res.reso.w)audit = DataFrame(:Metric => ["Max inner local weight", "Max outer cluster weight",                              "Max final asset weight"], :Limit => [0.35, 0.62, 0.20],                  :NestedInnerOnly =>                      [inner_local_max(res_inner), maximum(res_inner.reso.w),                       maximum(res_inner.w)],                  :NestedInnerOuter =>                      [inner_local_max(res_inner_outer), outer_cluster_max(res_inner_outer),                       maximum(res_inner_outer.w)],                  :NestedDirectOverallWB => [inner_local_max(res_nested_overall),                                             outer_cluster_max(res_nested_overall),                                             maximum(res_nested_overall.w)])pretty_table(audit; formatters = [resfmt])
┌──────────────────────────┬─────────┬─────────────────┬──────────────────┬───────────────────────┐
│                   Metric │   Limit │ NestedInnerOnly │ NestedInnerOuter │ NestedDirectOverallWB │
│                   String │ Float64 │         Float64 │          Float64 │               Float64 │
├──────────────────────────┼─────────┼─────────────────┼──────────────────┼───────────────────────┤
│   Max inner local weight │  35.0 % │          35.0 % │           35.0 % │                35.0 % │
│ Max outer cluster weight │  62.0 % │        63.903 % │           62.0 % │                62.0 % │
│   Max final asset weight │  20.0 % │        22.366 % │           21.7 % │                20.0 % │
└──────────────────────────┴─────────┴─────────────────┴──────────────────┴───────────────────────┘

Compare each column with the Limit column. The first portfolio puts no bound on the outer optimisation, and its largest cluster weight is above 0.62. In the first two portfolios, the final weight of an asset is its weight inside its cluster times the weight of the cluster. The inner and outer bounds of the second portfolio therefore let an asset reach 0.35 * 0.62 = 0.217, which is above the final bound. We plot the three portfolios.

using StatsPlots, GraphRecipesplot_stacked_bar_composition([res_inner, res_inner_outer, res_nested_overall], rd;                             xticks = ([1, 2, 3],                                       ["Inner WB", "Inner+Outer WB", "Overall WB"]))
Example block output

3. A fee on one cluster

The fees of NestedClustered reach the outer optimisation through the returns of the cluster portfolios. The library deducts the fee of each cluster portfolio from its returns, and the outer optimisation uses the net returns. The inner optimisations do not see this fee.

We charge a long fee of 0.001 on the three energy stocks, CVX, RRC and XOM. The clustering puts the three of them in one cluster. A fee by asset name needs a UniverseSets that names the group, and we give it to NestedClustered as sets. We solve the second portfolio with and without the fee, under two outer objectives, MinimumRisk and MaximumRatio.

sets = UniverseSets(; dict = Dict("nx" => rd.nx, "energy" => ["CVX", "RRC", "XOM"]))fees_energy = FeesEstimator(; l = ["energy" => 0.001])res_minrisk_fee = optimise(NestedClustered(; pe = pr, cle = clr, sets = sets,                                           fees = fees_energy,                                           opti = MeanRisk(; obj = MinimumRisk(),                                                           opt = jopti_inner),                                           opto = MeanRisk(; obj = MinimumRisk(),                                                           opt = jopto_outer)), rd)res_ratio = optimise(NestedClustered(; pe = pr, cle = clr,                                     opti = MeanRisk(; obj = MinimumRisk(),                                                     opt = jopti_inner),                                     opto = MeanRisk(; obj = MaximumRatio(),                                                     opt = jopto_outer)), rd)res_ratio_fee = optimise(NestedClustered(; pe = pr, cle = clr, sets = sets,                                         fees = fees_energy,                                         opti = MeanRisk(; obj = MinimumRisk(),                                                         opt = jopti_inner),                                         opto = MeanRisk(; obj = MaximumRatio(),                                                         opt = jopto_outer)), rd)cluster_assets = [join(rd.nx[assignments(clr) .== i], ", ") for i in 1:(clr.k)]pretty_table(DataFrame("Cluster" => cluster_assets, "MinimumRisk" => res_inner_outer.reso.w,                       "MinimumRisk, fee" => res_minrisk_fee.reso.w,                       "MaximumRatio" => res_ratio.reso.w,                       "MaximumRatio, fee" => res_ratio_fee.reso.w); formatters = [resfmt],             title = "Weight of each cluster in the outer optimisation")
                               Weight of each cluster in the outer optimisation
┌────────────────────────────────────────┬─────────────┬──────────────────┬──────────────┬───────────────────┐
│                                Cluster │ MinimumRisk │ MinimumRisk, fee │ MaximumRatio │ MaximumRatio, fee │
│                                 String │     Float64 │          Float64 │      Float64 │           Float64 │
├────────────────────────────────────────┼─────────────┼──────────────────┼──────────────┼───────────────────┤
│ AAPL, AMD, BAC, BBY, GE, HD, JPM, MSFT │       0.0 % │            0.0 % │        0.0 % │             0.0 % │
│                          CVX, RRC, XOM │     9.439 % │          9.439 % │     40.694 % │          30.376 % │
│            JNJ, LLY, MRK, PFE, PG, UNH │      62.0 % │           62.0 % │     59.306 % │            62.0 % │
│                           KO, PEP, WMT │    28.561 % │         28.561 % │        0.0 % │           7.624 % │
└────────────────────────────────────────┴─────────────┴──────────────────┴──────────────┴───────────────────┘

Compare the columns in pairs. Under MinimumRisk, the fee changes no weight. Every asset of the energy cluster pays the fee, and the inner weights of the cluster sum to one. The fee then deducts 0.001 from the return of that cluster in every period. A constant deduction lowers the mean of the returns and leaves their covariance unchanged. MinimumRisk with the default variance uses the covariance alone.

MaximumRatio uses the mean, and it moves weight away from the energy cluster. The cluster of JNJ, LLY, MRK, PFE, PG and UNH takes weight until it reaches the outer bound of 0.62, and the cluster of KO, PEP and WMT takes the rest.

What to take away

  • With no final bound, an asset can reach the product of the inner and the outer bounds. On this page that product, 0.217, is above the final bound of 0.20.
  • To cap the final weights below that product, give the cap to the wb of NestedClustered, as the third portfolio does. It takes a WeightBounds of numbers or of vectors, or a WeightBoundsEstimator.
  • The fees of NestedClustered lower the return of each cluster portfolio by a fixed amount in every period. Under MinimumRisk with the default variance, the fee changes no weight. Under MaximumRatio, which uses the mean return, it moves weight away from the cluster that pays it.

This page was generated using Literate.jl.