Skip to content
18

The source files can be found in examples/.

Phylogeny and centrality constraints

The constraints in Linear and group constraints act on names and hand-drawn groups. Phylogeny and centrality constraints act on the structure of the asset network instead — the graph of how assets co-move. Rather than telling the optimiser "tech ≤ 30%", you tell it "don't pile into a tightly-knit cluster" or "tilt toward (away from) the hubs of the correlation network". The groups are discovered from the data, not declared.

PortfolioOptimisers.jl builds the network with a NetworkEstimator (or a clustering estimator) and then exposes two families:

When to reach for this

Reach for these when your diversification concern is structural rather than by label: you do not want a book that looks diversified by sector but is actually one big correlated bet, or you want to deliberately tilt toward stable hubs or peripheral diversifiers. They need no hand-built groups — the structure comes from the covariance. The semidefinite phylogeny and centrality forms are convex; the integer phylogeny form needs a MIP solver.

julia
using PortfolioOptimisers, CSV, TimeSeries, DataFrames, PrettyTables, Clarabel, StatsPlots,
      GraphRecipes

resfmt = (v, i, j) -> begin
    return if j == 1
        v
    else
        isa(v, AbstractFloat) ? "$(round(v*100, digits=3)) %" : v
    end
end;

1. ReturnsResult data

julia
X = TimeArray(CSV.File(joinpath(@__DIR__, "..", "SP500.csv.gz")); timestamp = :Date)[(end - 252):end]
rd = prices_to_returns(X)
pr = prior(EmpiricalPrior(), rd)

slv = Solver(; name = :clarabel, solver = Clarabel.Optimizer,
             settings = Dict("verbose" => false),
             check_sol = (; allow_local = true, allow_almost = true))

res_base = optimise(MeanRisk(; obj = MinimumRisk(),
                             opt = JuMPOptimiser(; pe = pr, slv = slv)))
MeanRiskResult
  jr ┼ JuMPOptimisationResult
     │        pa ┼ ProcessedJuMPOptimiserAttributes
     │           │        pr ┼ LowOrderPrior
     │           │           │         X ┼ 252×20 Matrix{Float64}
     │           │           │        mu ┼ 20-element Vector{Float64}
     │           │           │     sigma ┼ 20×20 Matrix{Float64}
     │           │           │      chol ┼ nothing
     │           │           │         w ┼ nothing
     │           │           │       ens ┼ nothing
     │           │           │       kld ┼ nothing
     │           │           │        ow ┼ nothing
     │           │           │        rr ┼ nothing
     │           │           │      f_mu ┼ nothing
     │           │           │   f_sigma ┼ nothing
     │           │           │       f_w ┴ nothing
     │           │        wb ┼ WeightBounds
     │           │           │   lb ┼ 20-element StepRangeLen{Float64, Base.TwicePrecision{Float64}, Base.TwicePrecision{Float64}, Int64}
     │           │           │   ub ┴ 20-element StepRangeLen{Float64, Base.TwicePrecision{Float64}, Base.TwicePrecision{Float64}, Int64}
     │           │        lt ┼ nothing
     │           │        st ┼ nothing
     │           │      lcsr ┼ nothing
     │           │       ctr ┼ nothing
     │           │    gcardr ┼ nothing
     │           │   sgcardr ┼ nothing
     │           │      smtx ┼ nothing
     │           │     sgmtx ┼ nothing
     │           │       slt ┼ nothing
     │           │       sst ┼ nothing
     │           │      sglt ┼ nothing
     │           │      sgst ┼ nothing
     │           │        tn ┼ nothing
     │           │      fees ┼ nothing
     │           │       plr ┼ nothing
     │           │       ret ┼ ArithmeticReturn
     │           │           │   ucs ┼ nothing
     │           │           │    lb ┼ nothing
     │           │           │    mu ┴ 20-element Vector{Float64}
     │   retcode ┼ OptimisationSuccess
     │           │   res ┴ Dict{Any, Any}: Dict{Any, Any}()
     │       sol ┼ JuMPOptimisationSolution
     │           │   w ┴ 20-element Vector{Float64}
     │     model ┼ A JuMP Model
     │           │ ├ solver: Clarabel
     │           │ ├ objective_sense: MIN_SENSE
     │           │ │ └ objective_function_type: QuadExpr
     │           │ ├ num_variables: 21
     │           │ ├ num_constraints: 4
     │           │ │ ├ AffExpr in MOI.EqualTo{Float64}: 1
     │           │ │ ├ Vector{AffExpr} in MOI.Nonnegatives: 1
     │           │ │ ├ Vector{AffExpr} in MOI.Nonpositives: 1
     │           │ │ └ Vector{AffExpr} in MOI.SecondOrderCone: 1
     │           │ └ Names registered in the model
     │           │   └ :G, :bgt, :dev_1, :dev_1_soc, :k, :lw, :obj_expr, :ret, :risk, :risk_vec, :sc, :so, :variance_flag, :variance_risk_1, :w, :w_lb, :w_ub
  fb ┴ nothing

2. The asset network

A NetworkEstimator turns the covariance into a graph: assets are nodes, and edges link assets whose returns are connected after filtering out the noisy links (a minimum-spanning-tree or similar backbone). Both constraint families below read this graph. You do not have to build it by hand — the estimators take a NetworkEstimator() and construct it from the prior internally.

3. Phylogeny constraints

A SemiDefinitePhylogenyEstimator adds a semidefinite constraint that discourages holding assets which are neighbours in the network — concentrated, mutually-correlated bets. Passing it through ple reshapes the minimum-risk portfolio toward combinations that are diversified in network terms, not just in count.

julia
res_phylo = optimise(MeanRisk(; obj = MinimumRisk(),
                              opt = JuMPOptimiser(; pe = pr, slv = slv,
                                                  ple = SemiDefinitePhylogenyEstimator(;
                                                                                       pl = NetworkEstimator()))))

pretty_table(DataFrame("Asset" => rd.nx, "Baseline" => res_base.w,
                       "Phylogeny" => res_phylo.w); formatters = [resfmt],
             title = "Minimum risk: baseline vs network-phylogeny constrained")
Minimum risk: baseline vs network-phylogeny constrained
┌────────┬──────────┬───────────┐
  Asset  Baseline  Phylogeny 
 String   Float64    Float64 
├────────┼──────────┼───────────┤
│   AAPL │    0.0 % │     0.0 % │
│    AMD │    0.0 % │     0.0 % │
│    BAC │    0.0 % │   0.005 % │
│    BBY │    0.0 % │     0.0 % │
│    CVX │  7.432 % │  12.063 % │
│     GE │  0.806 % │   0.109 % │
│     HD │    0.0 % │   0.001 % │
│    JNJ │ 36.974 % │  52.972 % │
│    JPM │  0.749 % │    1.95 % │
│     KO │ 11.161 % │   21.12 % │
│    LLY │    0.0 % │   0.001 % │
│    MRK │ 17.467 % │   0.024 % │
│   MSFT │    0.0 % │   0.001 % │
│    PEP │  8.978 % │   0.029 % │
│      ⋮ │        ⋮ │         ⋮ │
└────────┴──────────┴───────────┘
                   6 rows omitted

The constraint moves a large fraction of the book — it is enforcing genuine structural diversification, not a cosmetic tweak. For a hard limit on the number of names drawn from each network cluster, IntegerPhylogenyEstimator imposes an integer (cardinality-style) version; being combinatorial it needs a MIP solver (see Budget Constraints for the Pajarito/HiGHS setup).

4. Centrality constraints

Centrality measures how central each asset is in the network — a hub that co-moves with many others, versus a periphery name that diversifies. A CentralityEstimator scores every asset, and a CentralityConstraint bounds the portfolio's weighted-average centrality through cte. You can push the book toward hubs (comp = >=, a higher floor) or toward the periphery (comp = <=, a lower ceiling).

julia
res_hub = optimise(MeanRisk(; obj = MinimumRisk(),
                            opt = JuMPOptimiser(; pe = pr, slv = slv,
                                                cte = CentralityConstraint(;
                                                                           A = CentralityEstimator(),
                                                                           B = 0.20,
                                                                           comp = >=))))
res_periph = optimise(MeanRisk(; obj = MinimumRisk(),
                               opt = JuMPOptimiser(; pe = pr, slv = slv,
                                                   cte = CentralityConstraint(;
                                                                              A = CentralityEstimator(),
                                                                              B = 0.08,
                                                                              comp = <=))))

centrality = centrality_vector(CentralityEstimator(), pr).X
avg_centrality(w) = sum(w .* centrality)
pretty_table(DataFrame("Portfolio" =>
                           ["Baseline", "Hub-tilted (≥ 0.20)", "Periphery (≤ 0.08)"],
                       "Avg centrality" =>
                           [avg_centrality(res_base.w), avg_centrality(res_hub.w),
                            avg_centrality(res_periph.w)]);
             title = "Average network centrality of the portfolio")
Average network centrality of the portfolio
┌─────────────────────┬────────────────┐
           Portfolio  Avg centrality 
              String         Float64 
├─────────────────────┼────────────────┤
│            Baseline │       0.143427 │
│ Hub-tilted (≥ 0.20) │            0.2 │
│  Periphery (≤ 0.08) │      0.0799999 │
└─────────────────────┴────────────────┘

The constraint binds in both directions — the hub tilt lifts the average centrality to its floor, the periphery tilt drops it to its ceiling. Centrality is not one number: a CentralityEstimator accepts different algorithms (degree, eigenvector, closeness, betweenness, …), each emphasising a different notion of "central", so the right one depends on what kind of connectedness you care about.

5. Comparing the structural constraints

julia
results = [res_base, res_phylo, res_hub, res_periph]
labels = ["Baseline", "Phylogeny", "Hub", "Periphery"]

plot_stacked_bar_composition(results, rd; xticks = (1:length(labels), labels))


This page was generated using Literate.jl.