The source files can be found in examples/.

Getting started: a simple MeanRisk optimisation

This page runs the classic Markowitz optimisation with PortfolioOptimisers, from a table of prices to a portfolio you can buy with a fixed amount of cash.

using PortfolioOptimisers

We use PrettyTables to format the tables the page prints.

using PrettyTablestsfmt = (v, i, j) -> begin    if j == 1        return Date(v)    else        return v    endend;resfmt = (v, i, j) -> begin    if j == 1        return v    else        return isa(v, Number) ? "$(round(v*100, digits=3)) %" : v    endend;mipresfmt = (v, i, j) -> begin    if j ∈ (1, 2, 3)        return v    else        return isa(v, Number) ? "$(round(v*100, digits=3)) %" : v    endend;

1. Load the data

We load the S&P 500 prices from a compressed .csv file and keep the last 253 observations.

using CSV, TimeSeries, DataFramesX = TimeArray(CSV.File(joinpath(@__DIR__, "..", "SP500.csv.gz")); timestamp = :Date)[(end - 252):end]pretty_table(X[(end - 5):end]; formatters = [tsfmt])
┌────────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┐
│  timestamp │    AAPL │     AMD │     BAC │     BBY │     CVX │      GE │      HD │     JNJ │     JPM │      KO │     LLY │     MRK │    MSFT │     PEP │     PFE │      PG │     RRC │     UNH │     WMT │     XOM │
│       Date │ Float64 │ Float64 │ Float64 │ Float64 │ Float64 │ Float64 │ Float64 │ Float64 │ Float64 │ Float64 │ Float64 │ Float64 │ Float64 │ Float64 │ Float64 │ Float64 │ Float64 │ Float64 │ Float64 │ Float64 │
├────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
│ 2022-12-20 │ 131.916 │   65.05 │  31.729 │  77.371 │ 169.497 │  62.604 │ 310.342 │ 173.109 │ 127.844 │  61.841 │  357.55 │ 108.229 │  240.67 │ 178.765 │  49.754 │ 147.661 │   25.65 │ 516.245 │ 142.919 │ 104.964 │
│ 2022-12-21 │ 135.057 │   67.68 │  32.212 │  78.729 │  171.49 │   64.67 │ 314.798 │  175.09 │ 129.282 │  62.836 │ 365.872 │ 109.611 │ 243.287 │ 180.017 │  50.084 │ 149.015 │  26.574 │ 523.519 │  144.04 │ 106.312 │
│ 2022-12-22 │ 131.846 │   63.86 │  31.927 │  78.563 │ 168.918 │  63.727 │ 311.604 │  174.45 │ 127.814 │  62.383 │ 363.187 │ 109.739 │ 237.077 │ 178.627 │  50.065 │ 149.359 │  25.232 │ 523.072 │ 142.354 │ 104.168 │
│ 2022-12-23 │ 131.477 │   64.52 │  32.005 │  79.432 │  174.14 │  63.742 │ 314.177 │ 174.893 │ 128.421 │  62.855 │ 365.762 │  110.35 │ 237.614 │ 179.781 │  50.249 │ 149.781 │  26.226 │  527.26 │ 142.641 │ 106.922 │
│ 2022-12-27 │ 129.652 │   63.27 │  32.065 │   79.93 │ 176.329 │  64.561 │ 314.985 │ 174.844 │ 128.871 │   63.24 │  362.76 │ 110.607 │ 235.852 │  180.58 │   49.57 │ 151.086 │  26.375 │ 527.935 │ 142.681 │ 108.408 │
│ 2022-12-28 │ 125.674 │   62.57 │  32.301 │  78.279 │ 173.728 │  63.883 │  311.22 │ 174.085 │ 129.575 │  62.609 │ 363.098 │ 109.581 │ 233.434 │ 179.278 │   49.25 │ 149.133 │  24.497 │ 524.422 │ 140.181 │ 106.627 │
└────────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┘

The optimiser works on returns, so we compute them from the prices. The ReturnsResult that comes back stores the asset names in nx, the asset returns in X and the timestamps in ts. Its other fields store data that this page does not use.

rd = prices_to_returns(X)
ReturnsResult
    nx ┼ 20-element Vector{String}
     X ┼ 252×20 Matrix{Float64}
    nf ┼ nothing
     F ┼ nothing
    nb ┼ nothing
     B ┼ nothing
    ne ┼ nothing
     E ┼ nothing
    ts ┼ 252-element Vector{Date}
    iv ┼ nothing
  ivpa ┼ nothing
   pnl ┼ AssetPanel
       │     pf ┼ Vector{AbstractPanelField}: AbstractPanelField[]
       │   amsk ┼ 252×20 AllTrueMask
       │   emsk ┴ 252×20 AllTrueMask

2. MeanRisk optimisation

2.1 Creating a solver instance

Every optimisation needs statistics of the returns, such as their mean and covariance, which the library calls the prior. You can compute the prior before the optimisation and pass it in, or let the optimisation compute it. Computing it first saves time when several optimisations share one prior. Here there is only one optimisation, so we let it compute its own.

The MeanRisk estimator states a mean-risk optimisation problem. The library builds that problem as a JuMP model, so MeanRisk needs a solver that JuMP can call. We use Clarabel.

using Clarabel

A Solver takes the solver to use, an optional name that appears in the logs, optional solver settings, and optional keyword arguments for JuMP.assert_is_solved_and_feasible.

A hard problem can fail with one solver or one group of settings and succeed with another. For that case you can pass a vector of Solver objects, and the optimisation tries each in turn until one succeeds or all fail. The Markowitz problem is easy to solve, so we use one solver.

slv = Solver(; name = :clarabel1, solver = Clarabel.Optimizer,             settings = Dict("verbose" => false),             check_sol = (; allow_local = true, allow_almost = true))
Solver
         name ┼ Symbol: :clarabel1
       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.2 Defining the optimisation estimator

PortfolioOptimisers builds an optimisation out of smaller estimators. MeanRisk takes its solver, its constraints and its prior estimator from a JuMPOptimiser, in its opt field.

We create a MeanRisk estimator. The printed output lists many more fields of JuMPOptimiser and MeanRisk than this page uses.

mr = MeanRisk(; opt = JuMPOptimiser(; slv = slv))
MeanRisk
  opt ┼ JuMPOptimiser
      │        pe ┼ EmpiricalPrior
      │           │           ce ┼ PortfolioOptimisersCovariance
      │           │              │   ce ┼ Covariance
      │           │              │      │    me ┼ SimpleExpectedReturns
      │           │              │      │       │   w ┴ nothing
      │           │              │      │    ce ┼ GeneralCovariance
      │           │              │      │       │   ce ┼ SimpleCovariance: SimpleCovariance(true)
      │           │              │      │       │    w ┴ nothing
      │           │              │      │   alg ┼ FullMoment()
      │           │              │      │     w ┴ nothing
      │           │              │   mp ┼ MatrixProcessing
      │           │              │      │     pdm ┼ Posdef
      │           │              │      │         │      alg ┼ UnionAll: NearestCorrelationMatrix.Newton
      │           │              │      │         │   kwargs ┴ @NamedTuple{}: NamedTuple()
      │           │              │      │      dn ┼ nothing
      │           │              │      │      dt ┼ nothing
      │           │              │      │     alg ┼ nothing
      │           │              │      │   order ┴ NTuple{4, Symbol}: (:pdm, :dn, :dt, :alg)
      │           │           me ┼ SimpleExpectedReturns
      │           │              │   w ┴ nothing
      │           │      horizon ┼ nothing
      │           │   fill_limit ┼ nothing
      │           │        x_src ┴ nothing
      │       slv ┼ Solver
      │           │          name ┼ Symbol: :clarabel1
      │           │        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
      │       bgt ┼ Float64: 1.0
      │      sbgt ┼ nothing
      │      gbgt ┼ nothing
      │        lt ┼ nothing
      │        st ┼ nothing
      │      lcse ┼ nothing
      │       cte ┼ nothing
      │    gcarde ┼ nothing
      │   sgcarde ┼ nothing
      │      smtx ┼ nothing
      │     sgmtx ┼ nothing
      │       slt ┼ nothing
      │       sst ┼ nothing
      │      sglt ┼ nothing
      │      sgst ┼ nothing
      │        tn ┼ nothing
      │      fees ┼ nothing
      │      sets ┼ nothing
      │        tr ┼ nothing
      │       ple ┼ nothing
      │       ret ┼ ArithmeticReturn
      │           │   settings ┼ JuMPReturnsSettings
      │           │            │   scale ┼ Float64: 1.0
      │           │            │      lb ┼ nothing
      │           │            │     rte ┼ Bool: true
      │           │            │     fee ┼ Bool: true
      │           │            │     mic ┴ Bool: true
      │           │        ucs ┼ nothing
      │           │         mu ┼ nothing
      │           │   mtx_sqrt ┴ EigenFallbackSquareRoot()
      │       sca ┼ SumScalariser()
      │      ccnt ┼ nothing
      │      cobj ┼ nothing
      │        sc ┼ Int64: 1
      │        so ┼ Int64: 1
      │        ss ┼ nothing
      │      card ┼ nothing
      │     scard ┼ nothing
      │       l2c ┼ nothing
      │       lpc ┼ nothing
      │     linfc ┼ nothing
      │        l1 ┼ nothing
      │        l2 ┼ nothing
      │        lp ┼ nothing
      │      linf ┼ nothing
      │      xbgt ┼ Bool: false
      │    strict ┴ Bool: false
    r ┼ Variance
      │    settings ┼ RiskMeasureSettings
      │             │   scale ┼ Float64: 1.0
      │             │      ub ┼ nothing
      │             │     rke ┴ Bool: true
      │       sigma ┼ nothing
      │        chol ┼ nothing
      │          rc ┼ nothing
      │         alg ┼ SquaredSOCRiskExpr()
      │    mtx_sqrt ┼ EigenFallbackSquareRoot()
      │   ratio_alg ┼ SquareRootRatio()
      │      corner ┴ nothing
  obj ┼ MinimumRisk()
   wi ┼ nothing
   fb ┴ nothing

2.3 Performing the optimisation

The optimise function runs every optimisation in PortfolioOptimisers. It returns a result with a return code, the solution, and the statistics the optimisation used, whether you computed them first or the optimisation did.

The retcode field of the result is an OptimisationSuccess, which means that the solver found a solution.

res = optimise(mr, rd)
MeanRiskResult
  jr ┼ JuMPOptimisationResult
     │        pa ┼ ProcessedJuMPOptimiserAttributes
     │           │        pr ┼ 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
     │           │           │     x_src ┼ nothing
     │           │           │   ss_gaps ┼ nothing
     │           │           │        pc ┴ 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
     │           │           │   settings ┼ JuMPReturnsSettings
     │           │           │            │   scale ┼ Float64: 1.0
     │           │           │            │      lb ┼ nothing
     │           │           │            │     rte ┼ Bool: true
     │           │           │            │     fee ┼ Bool: true
     │           │           │            │     mic ┴ Bool: true
     │           │           │        ucs ┼ nothing
     │           │           │         mu ┼ 20-element Vector{Float64}
     │           │           │   mtx_sqrt ┴ EigenFallbackSquareRoot()
     │           │       sca ┼ SumScalariser()
     │           │      imsk ┴ nothing
     │   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, :T, :bgt, :cdev_soc_1, :dev_1, :k, :lw, :obj_expr, :ret, :ret_1, :ret_vec, :risk, :risk_minimised, :risk_vec, :sc, :so, :variance_flag, :variance_risk_1, :w, :w_gross_ub, :w_lb, :w_ub
   r ┼ Variance
     │    settings ┼ RiskMeasureSettings
     │             │   scale ┼ Float64: 1.0
     │             │      ub ┼ nothing
     │             │     rke ┴ Bool: true
     │       sigma ┼ 20×20 Matrix{Float64}
     │        chol ┼ nothing
     │          rc ┼ nothing
     │         alg ┼ SquaredSOCRiskExpr()
     │    mtx_sqrt ┼ EigenFallbackSquareRoot()
     │   ratio_alg ┼ SquareRootRatio()
     │      corner ┴ nothing
  fb ┴ nothing

An optimisation result has a w property that returns the weights, sol.w. The optimisation keeps the assets in the order of the returns, so the table below puts each asset name beside its weight.

pretty_table(DataFrame(:assets => rd.nx, :weights => res.w); formatters = [resfmt])
┌────────┬──────────┐
│ assets │  weights │
│ String │  Float64 │
├────────┼──────────┤
│   AAPL │    0.0 % │
│    AMD │    0.0 % │
│    BAC │    0.0 % │
│    BBY │    0.0 % │
│    CVX │  7.432 % │
│     GE │  0.806 % │
│     HD │    0.0 % │
│    JNJ │ 36.974 % │
│    JPM │  0.749 % │
│     KO │ 11.161 % │
│    LLY │    0.0 % │
│    MRK │ 17.467 % │
│   MSFT │    0.0 % │
│    PEP │  8.978 % │
│    PFE │    0.0 % │
│     PG │  2.353 % │
│    RRC │    0.0 % │
│    UNH │    0.0 % │
│    WMT │  9.355 % │
│    XOM │  4.725 % │
└────────┴──────────┘

3. Visualising the portfolio

We plot the composition of the portfolio, its cumulative returns, the distribution of its returns and its drawdowns.

using StatsPlots, GraphRecipes

The portfolio weights as a bar chart.

plot_composition(res, rd)
Example block output

The cumulative returns of the portfolio over the sample.

plot_portfolio_cumulative_returns(res, rd)
Example block output

A histogram of the portfolio returns, with markers for the value at risk and the conditional value at risk.

plot_histogram(res, rd)
Example block output

The drawdowns, each the loss from the highest value the portfolio reached before it.

plot_drawdowns(res, rd)
Example block output

4. Finite allocation

The weights are fractions of capital, but an investor buys whole shares with a fixed amount of cash. A finite allocation turns the weights, the latest prices and the cash into a number of shares per asset. We use DiscreteAllocation, which solves a mixed-integer programme for the best whole-share portfolio. The library also has a greedy method that can allocate fractional shares, and Finite allocation compares the two.

A mixed-integer programme needs a solver that supports it, and we use HiGHS.

using HiGHSmip_slv = Solver(; name = :highs1, solver = HiGHS.Optimizer,                 settings = Dict("log_to_console" => false),                 check_sol = (; allow_local = true, allow_almost = true))da = DiscreteAllocation(; slv = mip_slv)
DiscreteAllocation
  slv ┼ Solver
      │          name ┼ Symbol: :highs1
      │        solver ┼ DataType: HiGHS.Optimizer
      │      settings ┼ Dict{String, Bool}: Dict{String, Bool}("log_to_console" => 0)
      │     check_sol ┼ @NamedTuple{allow_local::Bool, allow_almost::Bool}: (allow_local = true, allow_almost = true)
      │   add_bridges ┴ Bool: true
   sc ┼ Int64: 1
   so ┼ Int64: 1
   wf ┼ AbsoluteErrorWeightFinaliser()
   fb ┼ GreedyAllocation
      │     unit ┼ Int64: 1
      │     args ┼ Tuple{}: ()
      │   kwargs ┼ @NamedTuple{}: NamedTuple()
      │       fb ┴ nothing

The allocation needs three inputs. The weights come from the optimisation, the latest prices are the last row of the price table X, and we invest 4206.9 USD.

A FiniteAllocationInput takes the three. It also takes a time horizon and fees, which this page does not use.

mip_res = optimise(da,                   FiniteAllocationInput(; w = res.w, prices = vec(values(X[end])),                                         cash = 4206.9))
DiscreteAllocationResult
    retcode ┼ OptimisationSuccess
            │   res ┴ nothing
  s_retcode ┼ nothing
  l_retcode ┼ OptimisationSuccess
            │   res ┴ Dict{Any, Any}: Dict{Any, Any}()
     shares ┼ 20-element SubArray{Float64, 1, Matrix{Float64}, Tuple{Base.Slice{Base.OneTo{Int64}}, Int64}, true}
       cost ┼ 20-element SubArray{Float64, 1, Matrix{Float64}, Tuple{Base.Slice{Base.OneTo{Int64}}, Int64}, true}
          w ┼ 20-element SubArray{Float64, 1, Matrix{Float64}, Tuple{Base.Slice{Base.OneTo{Int64}}, Int64}, true}
       cash ┼ Float64: 8.471999999997934
       fees ┼ Float64: 0.0
    s_model ┼ nothing
    l_model ┼ A JuMP Model
            │ ├ solver: HiGHS
            │ ├ objective_sense: MIN_SENSE
            │ │ └ objective_function_type: AffExpr
            │ ├ num_variables: 21
            │ ├ num_constraints: 42
            │ │ ├ AffExpr in MOI.GreaterThan{Float64}: 1
            │ │ ├ Vector{AffExpr} in MOI.NormOneCone: 1
            │ │ ├ VariableRef in MOI.GreaterThan{Float64}: 20
            │ │ └ VariableRef in MOI.Integer: 20
            │ └ Names registered in the model
            │   └ :cabs_err, :cr, :fee, :r, :sc, :so, :u, :x
         fb ┴ nothing

This result has different fields from the optimisation result. The discrete allocation solves the long and the short positions apart and then combines them, and the fields that start with l_ or s_ belong to the two halves.

The table below puts the shares, their cost, the optimised weights and the weights of the allocation side by side.

pretty_table(DataFrame(:assets => rd.nx, :shares => mip_res.shares, :cost => mip_res.cost,                       :opt_weights => res.w, :mip_weights => mip_res.w);             formatters = [mipresfmt])
┌────────┬─────────┬─────────┬─────────────┬─────────────┐
│ assets │  shares │    cost │ opt_weights │ mip_weights │
│ String │ Float64 │ Float64 │     Float64 │     Float64 │
├────────┼─────────┼─────────┼─────────────┼─────────────┤
│   AAPL │     0.0 │     0.0 │       0.0 % │       0.0 % │
│    AMD │     0.0 │     0.0 │       0.0 % │       0.0 % │
│    BAC │     0.0 │     0.0 │       0.0 % │       0.0 % │
│    BBY │     0.0 │     0.0 │       0.0 % │       0.0 % │
│    CVX │     2.0 │ 347.456 │     7.432 % │     8.276 % │
│     GE │     0.0 │     0.0 │     0.806 % │       0.0 % │
│     HD │     0.0 │     0.0 │       0.0 % │       0.0 % │
│    JNJ │     9.0 │ 1566.77 │    36.974 % │    37.318 % │
│    JPM │     0.0 │     0.0 │     0.749 % │       0.0 % │
│     KO │     6.0 │ 375.654 │    11.161 % │     8.947 % │
│    LLY │     0.0 │     0.0 │       0.0 % │       0.0 % │
│    MRK │     7.0 │ 767.067 │    17.467 % │     18.27 % │
│   MSFT │     0.0 │     0.0 │       0.0 % │       0.0 % │
│    PEP │     2.0 │ 358.556 │     8.978 % │      8.54 % │
│    PFE │     0.0 │     0.0 │       0.0 % │       0.0 % │
│     PG │     1.0 │ 149.133 │     2.353 % │     3.552 % │
│    RRC │     0.0 │     0.0 │       0.0 % │       0.0 % │
│    UNH │     0.0 │     0.0 │       0.0 % │       0.0 % │
│    WMT │     3.0 │ 420.543 │     9.355 % │    10.017 % │
│    XOM │     2.0 │ 213.254 │     4.725 % │     5.079 % │
└────────┴─────────┴─────────┴─────────────┴─────────────┘

The weights of the allocation differ from the optimised weights, because a whole number of shares at a fixed cash amount can only come close to them. The cash property of the result is the cash left over. We print it beside the starting cash less the sum of the costs. When the input has fees, the allocation also subtracts the fees from the cash left over.

println("cash left over: $(round(mip_res.cash; digits = 2)), starting cash less the costs: $(round(4206.9 - sum(mip_res.cost); digits = 2))")
cash left over: 8.47, starting cash less the costs: 8.47

The cost of each asset is its number of shares times its price, and the next cell compares the two.

println("cost of shares ≈ cost of portfolio: $(all(isapprox.(mip_res.shares .* vec(values(X[end])), mip_res.cost)))")
cost of shares ≈ cost of portfolio: true

The last plot puts the optimised weights and the weights of the allocation side by side. The two differ where the allocation rounds a weight to whole shares.

plot_stacked_bar_composition([res, mip_res], rd)
Example block output

This page was generated using Literate.jl.