The source files can be found in examples/.

Clustering optimisers with mixed risks and constraints

This example turns the clustering optimiser chapter into a deeper playground for two things that are easy to miss in the overview:

  • clustering optimisers can mix risk measures and scalarisers across the hierarchy;
  • the hierarchical optimiser can still carry constraints and fees while the cluster logic does the diversification work.

We use the same S&P 500 slice as the rest of the examples, then compare a plain HRP solve, several mixed-risk HERC variants, and a constrained HERC solve.

When to reach for this

Reach for mixed-risk clustering optimisers when no single risk measure captures what you care about and you still want the hierarchy to do the diversification — for example combining a tail measure with variance, or treating intra-cluster and inter-cluster risk differently. The clustering structure keeps the allocation robust while the scalariser controls how the mixed risk terms combine.

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

1. Data and clustering

We compute the prior and cluster hierarchy once, then reuse them across all the examples.

using CSV, TimeSeries, DataFrames, ClarabelX = TimeArray(CSV.File(joinpath(@__DIR__, "..", "SP500.csv.gz")); timestamp = :Date)[(end - 252):end]rd = prices_to_returns(X)pr = prior(EmpiricalPrior(), rd)clr = clusterise(ClustersEstimator(; alg = DBHT()), pr.X)# Shared solver and hierarchical optimiser.slv = Solver(; name = :clarabel, solver = Clarabel.Optimizer,             settings = Dict("verbose" => false),             check_sol = (; allow_local = true, allow_almost = true))opt = HierarchicalOptimiser(; pe = pr, cle = clr, slv = slv)
HierarchicalOptimiser
      pe ┼ 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
         │       Z ┴ nothing
     cle ┼ 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], [5, 20, 17, 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
     slv ┼ Solver
         │          name ┼ Symbol: :clarabel
         │        solver ┼ UnionAll: Clarabel.MOIwrapper.Optimizer
         │      settings ┼ Dict{String, Bool}: Dict{String, Bool}("verbose" => 0)
         │     check_sol ┼ @NamedTuple{allow_local::Bool, allow_almost::Bool}: (allow_local = true, allow_almost = true)
         │   add_bridges ┴ Bool: true
      wb ┼ WeightBounds
         │   lb ┼ Float64: 0.0
         │   ub ┴ Float64: 1.0
    fees ┼ nothing
    sets ┼ nothing
      wf ┼ IterativeWeightFinaliser
         │   iter ┴ Int64: 100
     brt ┼ Bool: false
   x_src ┼ Symbol: :prior
   z_src ┼ Symbol: :data
  strict ┴ Bool: false

2. HRP with mixed risk measures

HierarchicalRiskParity accepts either a single risk measure or a vector of them. When we pass a vector, the scalariser chooses how to combine the inner risk terms.

Here we mix tail risk and variance, then sweep a few scalarisers to show that the hierarchy really is responding to the combination rule rather than to a single hidden default.

r_mix = [ConditionalValueatRisk(),         Variance(; settings = RiskMeasureSettings(; scale = 2e2))]hrp_sum = optimise(HierarchicalRiskParity(; r = r_mix, opt = opt, sca = SumScalariser()))hrp_max = optimise(HierarchicalRiskParity(; r = r_mix, opt = opt, sca = MaxScalariser()))hrp_min = optimise(HierarchicalRiskParity(; r = r_mix, opt = opt, sca = MinScalariser()))hrp_lse = optimise(HierarchicalRiskParity(; r = r_mix, opt = opt,                                          sca = LogSumExpScalariser(; gamma = 1e2)))pretty_table(DataFrame(; :assets => rd.nx, :Sum => hrp_sum.w, :Max => hrp_max.w,                       :Min => hrp_min.w, :LogSumExp => hrp_lse.w); formatters = [resfmt])
┌────────┬──────────┬──────────┬──────────┬───────────┐
│ assets       Sum       Max       Min  LogSumExp │
│ String   Float64   Float64   Float64    Float64 │
├────────┼──────────┼──────────┼──────────┼───────────┤
│   AAPL │  3.866 % │   3.26 % │  4.801 % │   3.937 % │
│    AMD │  1.669 % │  1.263 % │  2.678 % │    1.53 % │
│    BAC │  3.785 % │  3.356 % │  4.017 % │   4.005 % │
│    BBY │  3.373 % │  2.871 % │  4.403 % │     3.0 % │
│    CVX │  4.403 % │  3.937 % │  4.891 % │     4.7 % │
│     GE │  4.208 % │  3.879 % │  4.133 % │   4.691 % │
│     HD │  2.751 % │  2.605 % │   2.72 % │   3.118 % │
│    JNJ │ 11.741 % │ 13.136 % │ 10.025 % │   10.95 % │
│    JPM │  4.293 % │  3.965 % │  4.208 % │   4.713 % │
│     KO │  5.384 % │  6.299 % │  4.426 % │   5.189 % │
│    LLY │  6.556 % │  5.995 % │  7.372 % │   6.223 % │
│    MRK │  6.076 % │  6.583 % │  5.297 % │   5.813 % │
│   MSFT │  2.345 % │   2.06 % │  2.675 % │   2.477 % │
│    PEP │  9.848 % │  11.24 % │  8.327 % │   9.598 % │
│    PFE │   3.77 % │  3.589 % │  3.965 % │   3.553 % │
│     PG │  4.506 % │  5.051 % │  3.887 % │   4.444 % │
│    RRC │  2.242 % │  1.691 % │  3.778 % │   2.021 % │
│    UNH │  7.473 % │  7.499 % │  7.184 % │   7.552 % │
│    WMT │  7.732 % │  8.257 % │  6.552 % │   8.344 % │
│    XOM │  3.981 % │  3.464 % │  4.662 % │   4.141 % │
└────────┴──────────┴──────────┴──────────┴───────────┘

3. HERC with mixed inner and outer risks

HierarchicalEqualRiskContribution lets the inner and outer levels use different risk measures and scalarisers. That makes it the cleanest place to show the "mixed risk" idea: the hierarchy can treat intra-cluster and inter-cluster risk differently.

We pair the same mixed risk vector at both levels, then vary the scalariser to show the contrast between additive, max, min, and log-sum-exp aggregation.

herc_sum = optimise(HierarchicalEqualRiskContribution(; opt = opt, ri = r_mix, ro = r_mix,                                                      scai = SumScalariser(),                                                      scao = SumScalariser()))herc_max = optimise(HierarchicalEqualRiskContribution(; opt = opt, ri = r_mix, ro = r_mix,                                                      scai = MaxScalariser(),                                                      scao = MaxScalariser()))herc_min = optimise(HierarchicalEqualRiskContribution(; opt = opt, ri = r_mix, ro = r_mix,                                                      scai = MinScalariser(),                                                      scao = MinScalariser()))herc_lse = optimise(HierarchicalEqualRiskContribution(; opt = opt, ri = r_mix, ro = r_mix,                                                      scai = LogSumExpScalariser(;                                                                                 gamma = 1e2),                                                      scao = LogSumExpScalariser(;                                                                                 gamma = 1e2)))pretty_table(DataFrame(; :assets => rd.nx, :Sum => herc_sum.w, :Max => herc_max.w,                       :Min => herc_min.w, :LogSumExp => herc_lse.w); formatters = [resfmt])
┌────────┬──────────┬──────────┬──────────┬───────────┐
│ assets       Sum       Max       Min  LogSumExp │
│ String   Float64   Float64   Float64    Float64 │
├────────┼──────────┼──────────┼──────────┼───────────┤
│   AAPL │  2.124 % │  1.857 % │  2.628 % │   2.175 % │
│    AMD │  0.723 % │   0.63 % │  1.445 % │   0.738 % │
│    BAC │  2.552 % │  2.232 % │  2.998 % │   2.614 % │
│    BBY │   1.31 % │  1.144 % │  1.969 % │    1.34 % │
│    CVX │  5.519 % │  4.547 % │  6.078 % │    5.41 % │
│     GE │   2.21 % │  1.934 % │   2.23 % │   2.266 % │
│     HD │  2.739 % │  2.398 % │  2.629 % │   2.809 % │
│    JNJ │ 10.319 % │ 10.791 % │  7.863 % │  10.128 % │
│    JPM │  3.013 % │  2.636 % │  3.141 % │   3.088 % │
│     KO │ 12.774 % │ 13.746 % │ 10.942 % │  13.001 % │
│    LLY │  4.226 % │  4.407 % │  5.503 % │   4.136 % │
│    MRK │  7.883 % │   8.24 % │  6.658 % │   7.733 % │
│   MSFT │  2.168 % │  1.896 % │  2.586 % │   2.221 % │
│    PEP │ 13.062 % │ 14.058 % │ 10.897 % │  13.296 % │
│    PFE │  4.304 % │  4.492 % │  4.984 % │   4.216 % │
│     PG │  6.489 % │  6.785 % │  5.241 % │   6.368 % │
│    RRC │  1.517 % │  1.244 % │  3.492 % │    1.48 % │
│    UNH │  5.279 % │  5.513 % │  5.363 % │   5.174 % │
│    WMT │  6.933 % │  7.451 % │  7.561 % │   7.047 % │
│    XOM │  4.857 % │  4.001 % │  5.792 % │   4.759 % │
└────────┴──────────┴──────────┴──────────┴───────────┘

The scalariser choice can dominate the solution when one risk measure is consistently larger than the other. That is why the max and min solutions can collapse toward the portfolio that is effectively minimising the dominating term.

using StatsPlots, GraphRecipesplot_stacked_bar_composition([hrp_sum, hrp_max, hrp_min, hrp_lse, herc_sum, herc_max,                              herc_min, herc_lse], rd)
Example block output

4. Constrained HERC

The hierarchical optimiser itself can still carry weight bounds and fees. This version keeps the same mixed risk structure, but asks the optimiser to stay inside a bounded, fee-aware universe. We set a 10% cap (ub = 0.1) deliberately tight enough to bind: the unconstrained HERC already concentrates around 13% in the largest names, so the cap pulls them down — watch the largest holdings in the comparison below.

opt_constrained = HierarchicalOptimiser(; pe = pr, cle = clr, slv = slv,                                        wb = WeightBounds(; lb = 0.0, ub = 0.1),                                        fees = Fees(; l = 0.001))herc_constrained = optimise(HierarchicalEqualRiskContribution(; opt = opt_constrained,                                                              ri = r_mix, ro = r_mix,                                                              scai = SumScalariser(),                                                              scao = SumScalariser()))pretty_table(DataFrame(; :assets => rd.nx, :Unconstrained => herc_sum.w,                       :Constrained => herc_constrained.w); formatters = [resfmt])
┌────────┬───────────────┬─────────────┐
│ assets  Unconstrained  Constrained │
│ String        Float64      Float64 │
├────────┼───────────────┼─────────────┤
│   AAPL │       2.124 % │     2.342 % │
│    AMD │       0.723 % │     0.797 % │
│    BAC │       2.552 % │     2.814 % │
│    BBY │        1.31 % │     1.444 % │
│    CVX │       5.519 % │     6.105 % │
│     GE │        2.21 % │     2.437 % │
│     HD │       2.739 % │     3.021 % │
│    JNJ │      10.319 % │      10.0 % │
│    JPM │       3.013 % │     3.322 % │
│     KO │      12.774 % │      10.0 % │
│    LLY │       4.226 % │     4.604 % │
│    MRK │       7.883 % │     8.586 % │
│   MSFT │       2.168 % │     2.391 % │
│    PEP │      13.062 % │      10.0 % │
│    PFE │       4.304 % │     4.689 % │
│     PG │       6.489 % │     7.069 % │
│    RRC │       1.517 % │     1.679 % │
│    UNH │       5.279 % │      5.75 % │
│    WMT │       6.933 % │     7.579 % │
│    XOM │       4.857 % │     5.372 % │
└────────┴───────────────┴─────────────┘

The risk-contribution view shows how the hierarchy spreads risk rather than capital. Populate the covariance via factory before calling plot_risk_contribution.

rv = factory(Variance(), pr)plot_risk_contribution(rv, herc_constrained, rd)
Example block output

Summary

Clustering optimisers give you a hierarchical lever on diversification.


This page was generated using Literate.jl.