The source files can be found in examples/.

OWA risk measures

An ordered weighted average (OWA) risk measure is a weighted sum of the portfolio returns sorted from worst to best. The weights of that sum, the OWA weights, decide which part of the distribution counts most. Weights on the few worst returns give a tail measure like CVaR, and weights spread over the whole distribution give a measure of dispersion.

This page covers three points about OWA measures.

  1. CVaR, the Gini mean difference, the tail Gini, the worst realisation and the range each have a closed-form weight vector. You can also pass your own w to OrderedWeightsArray.
  2. One family of OWA weights gives the L-moments of the return distribution. They describe the spread and the tails of the distribution without a model of its shape.
  3. The exact formulation of an OWA measure is a linear programme in the portfolio weights, and no OWA measure needs a covariance matrix. MeanRisk, RiskBudgeting and the other optimisers that take a risk measure accept an OWA measure.
When to reach for this

Reach for an OWA risk measure when you want a tail or dispersion measure that needs no covariance matrix and covers more cases than CVaR. It suits returns that are far from normal, where the variance or one quantile misses part of the distribution and you want the measure to depend on its whole shape.

Exact and approximate formulations

OrderedWeightsArray has three formulations. ExactOrderedWeightsArray is a linear programme that adds T × T constraints for T observations, so it grows fast with the length of the sample. SortingNetworkOrderedWeightsArray gives the same exact risk through a relaxed sorting network of O(T log² T) linear constraints, so it is the exact formulation for a long sample. Its time of solve comes from the factorisation of the KKT matrix in each iteration. Clarabel factorises with QDLDL by default, one column at a time, and a supernodal factorisation is much faster. With eight assets, Clarabel with direct_solve_method = :mkl after using Pardiso solved T = 1000 in 28 s, against 299 s with QDLDL. MKL Pardiso runs on x86-64 only. Tulip with KKT_System = Tulip.KKT.K1() and KKT_Backend = Tulip.KKT.TlpCholmod.Backend() needs no other binary, and it solved the same problem in 40 s. The approximate one, ApproxOrderedWeightsArray, is the default and the fastest. It replaces those constraints with one p-norm constraint for each entry of p, and it gives an upper bound on the exact risk that is close when the weights change almost linearly with rank. Every optimisation on this page uses the approximate formulation.

using PortfolioOptimisers, PrettyTables, DataFramesresfmt = (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

using CSV, TimeSeries, ClarabelX = TimeArray(CSV.File(joinpath(@__DIR__, "..", "SP500.csv.gz")); timestamp = :Date)[(end - 252):end]rd = prices_to_returns(X)T = size(rd.X, 1)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)),       Solver(; name = :clarabel4, solver = Clarabel.Optimizer,              settings = Dict("verbose" => false, "max_step_fraction" => 0.6,                              "max_iter" => 1500, "tol_gap_abs" => 1e-4,                              "tol_gap_rel" => 1e-4, "tol_ktratio" => 1e-3,                              "tol_feas" => 1e-4, "tol_infeas_abs" => 1e-4,                              "tol_infeas_rel" => 1e-4, "reduced_tol_gap_abs" => 1e-4,                              "reduced_tol_gap_rel" => 1e-4, "reduced_tol_ktratio" => 1e-3,                              "reduced_tol_feas" => 1e-4, "reduced_tol_infeas_abs" => 1e-4,                              "reduced_tol_infeas_rel" => 1e-4),              check_sol = (; allow_local = true, allow_almost = true))]pr = prior(EmpiricalPrior(), rd)opt = JuMPOptimiser(; pe = pr, slv = slv)
JuMPOptimiser
       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
          │     x_src ┼ nothing
          │   ss_gaps ┼ nothing
          │        pc ┴ nothing
      slv ┼ 4-element Vector{Solver}
          │ Solver ⋯
          │ Solver ⋯
          │ Solver ⋯
          │ Solver ⋯
       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

2. Closed-form OWA weight vectors

The library has one function for the OWA weights of each classical case. Each function takes T, the number of observations, and returns a vector of length T. We pass each vector as w to OrderedWeightsArray to build a risk measure.

FunctionMeasureWhat it measures
owa_gmd(T)Gini mean differenceThe mean absolute difference over all pairs of returns, a dispersion measure
owa_cvar(T)CVaRThe CVaR at 5 %, written as an OWA measure
owa_tg(T)Tail GiniThe Gini spread within the worst tail, which depends on the shape of the tail as well as its level
owa_tgrg(T)Tail Gini rangeThe tail Gini of the worst returns and of the best returns together, a measure of both tails
owa_wr(T)Worst realisationThe negative of the worst return, so only the worst day counts
owa_rg(T)RangeThe best return minus the worst return
owa_cvarrg(T)CVaR rangeThe mean of the best 5 % of returns minus the mean of the worst 5 %
owa_l_moment_crm(T)L-moment CRMA convex risk measure built from the L-moments of order 2 to k, with k = 2 by default
r_gmd = OrderedWeightsArray(; w = owa_gmd(T))r_cvar = OrderedWeightsArray(; w = owa_cvar(T))r_tg = OrderedWeightsArray(; w = owa_tg(T))r_tgrg = OrderedWeightsArray(; w = owa_tgrg(T))r_wr = OrderedWeightsArray(; w = owa_wr(T))r_rg = OrderedWeightsArray(; w = owa_rg(T))r_cvarrg = OrderedWeightsArray(; w = owa_cvarrg(T))r_lcrm = OrderedWeightsArray(; w = owa_l_moment_crm(T; k = 5))
OrderedWeightsArray
  settings ┼ RiskMeasureSettings
           │   scale ┼ Float64: 1.0
           │      ub ┼ nothing
           │     rke ┴ Bool: true
         w ┼ 252-element Vector{Float64}
       alg ┼ ApproxOrderedWeightsArray
           │   p ┴ Vector{Float64}: [2.0, 3.0, 4.0, 10.0, 50.0]

3. Minimising each OWA risk measure

We build a minimum-risk MeanRisk portfolio for each OWA measure and print the weights. Every measure uses the default ApproxOrderedWeightsArray, so Clarabel solves each one as a conic problem.

rs = [r_gmd, r_cvar, r_tg, r_tgrg, r_wr, r_rg, r_cvarrg, r_lcrm]names_r = ["GMD", "CVaR", "TailGini", "TailGiniRange", "WorstReal", "Range", "CVaRRange",           "L-moment"]results = [optimise(MeanRisk(; r = r, opt = opt)) for r in rs]pretty_table(DataFrame(hcat(rd.nx, [r.w for r in results]...),                       [:assets; Symbol.(names_r)...]); formatters = [resfmt])
┌────────┬──────────┬──────────┬──────────┬───────────────┬───────────┬──────────┬───────────┬──────────┐
│ assets │      GMD │     CVaR │ TailGini │ TailGiniRange │ WorstReal │    Range │ CVaRRange │ L-moment │
│    Any │      Any │      Any │      Any │           Any │       Any │      Any │       Any │      Any │
├────────┼──────────┼──────────┼──────────┼───────────────┼───────────┼──────────┼───────────┼──────────┤
│   AAPL │    0.0 % │    0.0 % │    0.0 % │         0.0 % │     0.0 % │    0.0 % │     0.0 % │    0.0 % │
│    AMD │    0.0 % │    0.0 % │    0.0 % │         0.0 % │     0.0 % │    0.0 % │     0.0 % │    0.0 % │
│    BAC │    0.0 % │    0.0 % │    0.0 % │         0.0 % │     0.0 % │    0.0 % │     0.0 % │    0.0 % │
│    BBY │    0.0 % │    0.0 % │    0.0 % │         0.0 % │     0.0 % │    0.0 % │     0.0 % │    0.0 % │
│    CVX │  9.147 % │ 13.096 % │  22.53 % │       7.412 % │  12.205 % │  7.412 % │   8.211 % │  9.829 % │
│     GE │    0.0 % │    0.0 % │    0.0 % │       0.824 % │     0.0 % │  0.824 % │   0.889 % │    0.0 % │
│     HD │    0.0 % │    0.0 % │    0.0 % │         0.0 % │     0.0 % │    0.0 % │     0.0 % │    0.0 % │
│    JNJ │ 34.514 % │  45.42 % │ 55.659 % │      36.975 % │  29.391 % │ 36.976 % │  37.248 % │ 40.414 % │
│    JPM │  0.982 % │    0.0 % │    0.0 % │       0.762 % │     0.0 % │  0.762 % │    0.24 % │  2.759 % │
│     KO │ 12.165 % │ 13.309 % │   0.67 % │      11.095 % │     0.0 % │ 11.095 % │  12.238 % │  12.55 % │
│    LLY │    0.0 % │    0.0 % │    0.0 % │         0.0 % │     0.0 % │    0.0 % │     0.0 % │    0.0 % │
│    MRK │ 15.247 % │ 20.481 % │ 19.348 % │      17.465 % │  44.659 % │ 17.465 % │  16.771 % │ 15.906 % │
│   MSFT │    0.0 % │    0.0 % │    0.0 % │         0.0 % │     0.0 % │    0.0 % │     0.0 % │    0.0 % │
│    PEP │ 11.995 % │    0.0 % │    0.0 % │       8.971 % │     0.0 % │  8.971 % │   8.174 % │  4.071 % │
│    PFE │    0.0 % │    0.0 % │    0.0 % │         0.0 % │     0.0 % │    0.0 % │     0.0 % │    0.0 % │
│     PG │  4.126 % │    0.0 % │    0.0 % │       2.401 % │     0.0 % │  2.401 % │   3.119 % │  4.112 % │
│    RRC │    0.0 % │    0.0 % │  0.418 % │         0.0 % │     0.0 % │    0.0 % │     0.0 % │    0.0 % │
│    UNH │    0.0 % │    0.0 % │    0.0 % │         0.0 % │     0.0 % │    0.0 % │     0.0 % │    0.0 % │
│    WMT │  8.009 % │    0.0 % │    0.0 % │       9.357 % │  13.746 % │  9.357 % │   9.056 % │  7.159 % │
│    XOM │  3.814 % │  7.692 % │  1.375 % │       4.737 % │     0.0 % │  4.737 % │   4.055 % │    3.2 % │
└────────┴──────────┴──────────┴──────────┴───────────────┴───────────┴──────────┴───────────┴──────────┘

Every portfolio minimises a risk, but each measure counts a different part of the distribution, so the portfolios differ.

  • GMD weights every pair of days, so every day counts.
  • CVaR uses only the worst 5 % of days and ignores smaller losses.
  • TailGini also uses the worst days, and it responds to the Gini spread within the tail as well as to its level.
  • WorstReal depends on one day and Range on two days, so the solver chooses the weights that improve those days alone.
  • L-moment uses the L-moments of order 2 to 5, k = 5. The second L-moment alone has half the GMD weights, and the higher orders add weight on the tails. On this sample the portfolio holds the same assets as GMD, with different weights. Section 7 changes the mix of the orders.
using StatsPlots, GraphRecipesplot_stacked_bar_composition(results, rd)
Example block output

4. Default approximate formulation

OrderedWeightsArray with no arguments uses the Gini mean difference weights, owa_gmd, and the formulation ApproxOrderedWeightsArray with p = [2, 3, 4, 10, 50]. This is the GMD measure of section 3. We print the largest absolute difference between its weights and the weights of the GMD column. A difference of zero means that the two portfolios are the same.

Use the approximate formulation when the time of the solve matters most. When the weights are far from linear in rank, the approximation is less close, so use an exact formulation: SortingNetworkOrderedWeightsArray for a long sample, and ExactOrderedWeightsArray for a short one. For a long sample, give the solver a supernodal factorisation, as the note at the top of this page states.

r_approx = OrderedWeightsArray()res_approx = optimise(MeanRisk(; r = r_approx, opt = opt))println("Largest weight difference from the GMD column: ",        maximum(abs, res_approx.w .- results[1].w))
Largest weight difference from the GMD column: 0.0

5. OWA range measures for both tails

OrderedWeightsArrayRange builds a range measure from two OWA weight vectors, w1 for the worst returns and w2 for the best returns. You write w2 in the same orientation as w1, and the constructor reverses it. The measure then weights the sorted returns by w1 - w2, which covers the worst returns through w1 and the best through w2. Pass rev = true if your w2 is already reversed. This is the OWA form of a risk measure for both tails.

The default range takes owa_tg for both tails, which gives the same weights as owa_tgrg. Its portfolio still differs from the TailGiniRange column of section 3. The approximate formulation of a range approximates each tail on its own and adds the two, and the TailGiniRange measure of section 3 approximates the combined weights in one block. You can pass your own w1 and w2 to choose what each side measures. We also build a range from owa_cvar and owa_wr, the CVaR of the losses plus the largest return.

r_range_default = OrderedWeightsArrayRange()T_obs = Tr_range_custom = OrderedWeightsArrayRange(; w1 = owa_cvar(T_obs), w2 = owa_wr(T_obs))res_range_d = optimise(MeanRisk(; r = r_range_default, opt = opt))res_range_c = optimise(MeanRisk(; r = r_range_custom, opt = opt))pretty_table(DataFrame(; :assets => rd.nx, :TailGiniRange => res_range_d.w,                       :CVaR_plus_MaxReturn => res_range_c.w); formatters = [resfmt])
┌────────┬───────────────┬─────────────────────┐
│ assets │ TailGiniRange │ CVaR_plus_MaxReturn │
│ String │       Float64 │             Float64 │
├────────┼───────────────┼─────────────────────┤
│   AAPL │         0.0 % │               0.0 % │
│    AMD │         0.0 % │               0.0 % │
│    BAC │         0.0 % │               0.0 % │
│    BBY │         0.0 % │               0.0 % │
│    CVX │       1.142 % │               0.0 % │
│     GE │       0.135 % │             5.771 % │
│     HD │       3.307 % │             9.395 % │
│    JNJ │       51.25 % │             0.891 % │
│    JPM │         0.0 % │             2.461 % │
│     KO │       7.633 % │               0.0 % │
│    LLY │         0.0 % │             6.704 % │
│    MRK │      18.154 % │            24.647 % │
│   MSFT │         0.0 % │               0.0 % │
│    PEP │         0.0 % │            25.679 % │
│    PFE │         0.0 % │               0.0 % │
│     PG │         0.0 % │               0.0 % │
│    RRC │       4.739 % │             4.662 % │
│    UNH │         0.0 % │               0.0 % │
│    WMT │       4.664 % │            19.788 % │
│    XOM │       8.976 % │               0.0 % │
└────────┴───────────────┴─────────────────────┘

6. Maximum risk-adjusted ratio with an OWA measure

An OWA measure can also be the risk in the ratio objective. We maximise the return, net of the risk-free rate, per unit of Gini mean difference.

rf = 4.2 / 100 / 252res_ratio = optimise(MeanRisk(; r = r_gmd, obj = MaximumRatio(; rf = rf), opt = opt))println("GMD ratio portfolio, max weight: $(round(maximum(res_ratio.w)*100; digits=2)) %")pretty_table(DataFrame(; :assets => rd.nx, :weight => res_ratio.w); formatters = [resfmt])
GMD ratio portfolio, max weight: 66.84 %
┌────────┬──────────┐
│ assets │   weight │
│ String │  Float64 │
├────────┼──────────┤
│   AAPL │    0.0 % │
│    AMD │   -0.0 % │
│    BAC │    0.0 % │
│    BBY │    0.0 % │
│    CVX │    0.0 % │
│     GE │    0.0 % │
│     HD │    0.0 % │
│    JNJ │    0.0 % │
│    JPM │    0.0 % │
│     KO │    0.0 % │
│    LLY │    0.0 % │
│    MRK │ 66.842 % │
│   MSFT │    0.0 % │
│    PEP │    0.0 % │
│    PFE │    0.0 % │
│     PG │    0.0 % │
│    RRC │    0.0 % │
│    UNH │    0.0 % │
│    WMT │    0.0 % │
│    XOM │ 33.158 % │
└────────┴──────────┘

7. The risk aversion g of the L-moment CRM

NormalisedConstantRelativeRiskAversion builds the weights of the L-moment CRM from a risk aversion g, with 0 < g < 1. We use the L-moments of order 2 to 5, k = 5. A larger g gives the higher-order L-moments more weight, and the OWA weights then put more of their mass on the worst returns. A smaller g brings the measure closer to the second L-moment, which is half the Gini mean difference.

We minimise the measure for g equal to 0.25, 0.5 and 0.75. The default is g = 0.5, which is the L-moment measure of section 3, so the g=0.5 column repeats that column.

gs = [0.25, 0.5, 0.75]lcrm_results = map(gs) do g    w_lcrm = owa_l_moment_crm(T, NormalisedConstantRelativeRiskAversion(; g = g); k = 5)    r_lcrm_g = OrderedWeightsArray(; w = w_lcrm)    return optimise(MeanRisk(; r = r_lcrm_g, opt = opt))endpretty_table(DataFrame(hcat(rd.nx, [r.w for r in lcrm_results]...),                       [:assets, Symbol.("g=" .* string.(gs))...]); formatters = [resfmt])
┌────────┬──────────┬──────────┬──────────┐
│ assets │   g=0.25 │    g=0.5 │   g=0.75 │
│    Any │      Any │      Any │      Any │
├────────┼──────────┼──────────┼──────────┤
│   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 │   9.42 % │  9.829 % │ 10.116 % │
│     GE │    0.0 % │    0.0 % │    0.0 % │
│     HD │    0.0 % │    0.0 % │    0.0 % │
│    JNJ │ 38.628 % │ 40.414 % │ 42.017 % │
│    JPM │  2.583 % │  2.759 % │  2.885 % │
│     KO │ 12.796 % │  12.55 % │ 12.423 % │
│    LLY │    0.0 % │    0.0 % │    0.0 % │
│    MRK │ 16.025 % │ 15.906 % │  15.91 % │
│   MSFT │    0.0 % │    0.0 % │    0.0 % │
│    PEP │  5.156 % │  4.071 % │  2.781 % │
│    PFE │    0.0 % │    0.0 % │    0.0 % │
│     PG │  4.535 % │  4.112 % │  3.921 % │
│    RRC │    0.0 % │    0.0 % │    0.0 % │
│    UNH │    0.0 % │    0.0 % │    0.0 % │
│    WMT │  7.294 % │  7.159 % │  7.008 % │
│    XOM │  3.563 % │    3.2 % │  2.939 % │
└────────┴──────────┴──────────┴──────────┘

As g rises, the portfolio moves weight from PEP to JNJ, and every other weight changes by less than one percentage point. The bars run from the smallest g on the left to the largest on the right.

plot_stacked_bar_composition(lcrm_results, rd)
Example block output

Summary

  • The closed-form functions, such as owa_gmd, owa_tg and owa_cvar, return a weight vector that you pass to OrderedWeightsArray. Building the weights needs no solver.
  • owa_l_moment_crm with NormalisedConstantRelativeRiskAversion sets the weight on the worst returns through g. With its default k = 2 it gives the Gini mean difference portfolio.
  • OrderedWeightsArrayRange measures both tails at once.

This page was generated using Literate.jl.