The source files can be found in examples/.
Profile: institutional
The third profile runs a large benchmarked book under a written mandate. The retail profile optimised against cost and the desk profile optimised on a view. This one optimises inside a set of rules: a cap on each name, a cap on the energy sector, and a limit on how far the book may drift from its benchmark. The prior is the plain empirical one.
Of the limits in the strategy decision framework, three shape this mandate's choices.
- The mandate's caps are hard limits, not preferences. Each one is a keyword on the
JuMPOptimiser. - The book is measured against a benchmark, so we bound the tracking error while we minimise risk. Turnover and Tracking covers that bound.
- The mandate invests $10,000,000, and
DiscreteAllocationturns it into whole shares with a mixed-integer solve.
Reach for this profile when a mandate, rather than a forecast, decides what the book may hold. Write each rule as a JuMPOptimiser keyword, bound the tracking error against the benchmark, and allocate with a mixed-integer solve.
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, benchmark, and groups
The benchmark is an equal-weight book. We also name the sectors, because the mandate's sector cap is written against a sector name.
X = TimeArray(CSV.File(joinpath(@__DIR__, "..", "SP500.csv.gz")); timestamp = :Date)[(end - 252):end]rd = prices_to_returns(X)pr = prior(EmpiricalPrior(), rd)N = length(rd.nx)prices = vec(values(X)[end, :])benchmark = fill(1 / N, N)sets = UniverseSets(; dict = Dict("nx" => rd.nx, "tech" => ["AAPL", "AMD", "MSFT"], "energy" => ["CVX", "XOM", "RRC"], "healthcare" => ["JNJ", "LLY", "MRK", "PFE", "UNH"]))slv = Solver(; name = :clarabel, solver = Clarabel.Optimizer, settings = Dict("verbose" => false), check_sol = (; allow_local = true, allow_almost = true))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
2. The constrained optimisation
We minimise risk under the whole mandate. wb caps each name at 10%, lcse caps energy at 20%, and tr bounds the tracking error against the benchmark at 0.005. Each rule is one keyword on the JuMPOptimiser.
institutional = optimise(MeanRisk(; obj = MinimumRisk(), opt = JuMPOptimiser(; pe = pr, slv = slv, sets = sets, wb = WeightBounds(; lb = 0.0, ub = 0.10), lcse = LinearConstraintEstimator(; val = ["energy <= 0.2"]), tr = TrackingError(; tr = WeightsTracking(; w = benchmark), err = 0.005))))sector_weight(w, g) = sum(w[i] for i in eachindex(w) if rd.nx[i] in sets.dict[g])pretty_table(DataFrame("Sector" => ["tech", "energy", "healthcare"], "Benchmark" => [sector_weight(benchmark, g) for g in ["tech", "energy", "healthcare"]], "Mandate book" => [sector_weight(institutional.w, g) for g in ["tech", "energy", "healthcare"]]); formatters = [resfmt], title = "Sector weights of the benchmark and the mandate book")Sector weights of the benchmark and the mandate book
┌────────────┬───────────┬──────────────┐
│ Sector │ Benchmark │ Mandate book │
│ String │ Float64 │ Float64 │
├────────────┼───────────┼──────────────┤
│ tech │ 15.0 % │ 2.818 % │
│ energy │ 15.0 % │ 15.002 % │
│ healthcare │ 25.0 % │ 28.487 % │
└────────────┴───────────┴──────────────┘The table gives the benchmark weight and the mandate weight for each of the three named sectors. Compare the energy row with the 20% the mandate sets. The per-name cap shows in the weight table of the next section. The tracking error does not show in either table.
3. Exact finite allocation
The mandate invests $10,000,000. We round the target to whole shares with DiscreteAllocation, which solves a mixed-integer problem in HiGHS. The title of the table prints the cash left over, the cost of rounding to whole shares.
mip_slv = Solver(; name = :highs, solver = HiGHS.Optimizer, settings = Dict("log_to_console" => false))alloc = optimise(DiscreteAllocation(; slv = mip_slv), FiniteAllocationInput(; w = institutional.w, prices = prices, cash = 10_000_000.0))invested = sum(alloc.shares .* prices)pretty_table(DataFrame("Asset" => rd.nx, "Target" => institutional.w, "Shares" => round.(Int, alloc.shares), "Realised" => alloc.w); formatters = [resfmt], title = "\$10,000,000 to invest, \```math(round(Int, invested)) invested, \```(round(alloc.cash, digits = 2)) left in cash")$10,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 % │ 1 │ 0.0 % │
│ BBY │ 0.0 % │ 0 │ 0.0 % │
│ CVX │ 9.999 % │ 5756 │ 10.0 % │
│ GE │ 4.039 % │ 6322 │ 4.039 % │
│ HD │ 3.983 % │ 1280 │ 3.984 % │
│ JNJ │ 10.0 % │ 5744 │ 9.999 % │
│ JPM │ 6.76 % │ 5217 │ 6.76 % │
│ KO │ 10.0 % │ 15972 │ 10.0 % │
│ LLY │ 2.603 % │ 717 │ 2.603 % │
│ MRK │ 10.0 % │ 9126 │ 10.0 % │
│ MSFT │ 2.818 % │ 1207 │ 2.818 % │
│ PEP │ 10.0 % │ 5578 │ 10.0 % │
│ PFE │ 3.219 % │ 6536 │ 3.219 % │
│ PG │ 9.855 % │ 6608 │ 9.855 % │
│ RRC │ 0.24 % │ 981 │ 0.24 % │
│ UNH │ 2.664 % │ 508 │ 2.664 % │
│ WMT │ 9.056 % │ 6460 │ 9.056 % │
│ XOM │ 4.763 % │ 4467 │ 4.763 % │
└────────┴─────────┴────────┴──────────┘4. The book
plot_stacked_bar_composition([institutional], rd; xticks = (1:1, ["Institutional"]))This page was generated using Literate.jl.