The source files can be found in examples/.
Nested clustered optimisation with layered constraints and fees
This example shows how to apply constraints at three distinct layers of NestedClustered:
- the inner optimisation within each cluster,
- the outer optimisation across synthetic cluster portfolios,
- and a final overall optimisation pass on the full universe.
It also shows how fees can be attached to the nested stage and to the final overall stage.
Reach for this when you want cluster structure for robustness, but still need practical controls (fees and weight limits) at more than one stage of the nested workflow.
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 data and compute clusters once. Then we compare three nested setups:
- inner-only weight bounds,
- inner + outer bounds plus fees in the nested stage,
- direct overall asset bounds on
NestedClustered.
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], [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
2. Layering constraints in NCO
The nested workflow solves:
The key wiring rule remains the same: the outer optimiser must not consume the asset-level prior directly; it works on synthetic cluster returns.
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, fees = Fees(; l = 0.001), 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 direct NestedClustered(wb = ...) bound always applies to the final aggregated asset weights. Per-asset vectors are used here to vary the cap by asset; a scalar bound is equivalent to the vector spelling of the same number repeated across every asset.
res_nested_overall = optimise(NestedClustered(; pe = pr, cle = clr, wb = wb_overall_assets, fees = Fees(; l = 0.001), 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, :InnerOuterWBFees => res_inner_outer.w, :NestedDirectOverallWB => res_nested_overall.w); formatters = [resfmt])┌────────┬─────────────┬──────────────────┬───────────────────────┐
│ assets │ InnerOnlyWB │ InnerOuterWBFees │ 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 % │
└────────┴─────────────┴──────────────────┴───────────────────────┘The following audit confirms where the constraints are active.
- Inner bound (
0.35) applies to each cluster-level solve. - Outer bound (
0.62) applies to cluster allocation weights. - Overall bound (
0.20) can be applied directly inNestedClustered, applied here using per-asset vector bounds.
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)], :NestedInnerOuterFees => [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 │ NestedInnerOuterFees │ 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 % │
└──────────────────────────┴─────────┴─────────────────┴──────────────────────┴───────────────────────┘These runs isolate layer placement:
- inner bounds shape per-cluster compositions,
- outer bounds shape allocation across clusters,
- direct
NestedClustered(wb = ...)bounds constrain the final aggregated asset weights.
using StatsPlots, GraphRecipesplot_stacked_bar_composition([res_inner, res_inner_outer, res_nested_overall], rd; xticks = ([1, 2, 3], ["Inner WB", "Inner+Outer WB+Fees", "Overall WB"]))Summary
Layered controls can be applied around NestedClustered without giving up the cluster-based decomposition.
- Inner
wbcontrols weights inside each cluster. - Outer
wbcontrols allocation across synthetic cluster portfolios. - Direct
NestedClustered(wb = ...)constrains the final aggregated asset weights, whether given as a scalar, a per-asset vector, or an estimator.
This page was generated using Literate.jl.