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.
- 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
wtoOrderedWeightsArray. - 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.
- The exact formulation of an OWA measure is a linear programme in the portfolio weights, and no OWA measure needs a covariance matrix.
MeanRisk,RiskBudgetingand the other optimisers that take a risk measure accept an OWA measure.
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.
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.
| Function | Measure | What it measures |
|---|---|---|
owa_gmd(T) | Gini mean difference | The mean absolute difference over all pairs of returns, a dispersion measure |
owa_cvar(T) | CVaR | The CVaR at 5 %, written as an OWA measure |
owa_tg(T) | Tail Gini | The Gini spread within the worst tail, which depends on the shape of the tail as well as its level |
owa_tgrg(T) | Tail Gini range | The tail Gini of the worst returns and of the best returns together, a measure of both tails |
owa_wr(T) | Worst realisation | The negative of the worst return, so only the worst day counts |
owa_rg(T) | Range | The best return minus the worst return |
owa_cvarrg(T) | CVaR range | The mean of the best 5 % of returns minus the mean of the worst 5 % |
owa_l_moment_crm(T) | L-moment CRM | A 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.
GMDweights every pair of days, so every day counts.CVaRuses only the worst 5 % of days and ignores smaller losses.TailGinialso uses the worst days, and it responds to the Gini spread within the tail as well as to its level.WorstRealdepends on one day andRangeon two days, so the solver chooses the weights that improve those days alone.L-momentuses 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 asGMD, with different weights. Section 7 changes the mix of the orders.
using StatsPlots, GraphRecipesplot_stacked_bar_composition(results, rd)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.05. 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)Summary
- The closed-form functions, such as
owa_gmd,owa_tgandowa_cvar, return a weight vector that you pass toOrderedWeightsArray. Building the weights needs no solver. owa_l_moment_crmwithNormalisedConstantRelativeRiskAversionsets the weight on the worst returns throughg. With its defaultk = 2it gives the Gini mean difference portfolio.OrderedWeightsArrayRangemeasures both tails at once.
This page was generated using Literate.jl.