The source files can be found in examples/.

Risk contribution

This page is about how the risk of a portfolio divides between its assets or its factors, not about how much risk it takes. It covers two jobs:

  • asset risk contribution under the variance measure, where the rc field caps what each asset contributes and three objective functions run against the same caps;
  • factor risk contribution, where the same kind of cap names factors instead of assets.
When to reach for this

Reach for a risk contribution workflow when the weights are not what you want to control. It shows you how concentrated the realised risk is, it lets you ask for a bound on what one asset or one factor contributes, and it lets you hold those bounds fixed while you change the objective.

using PortfolioOptimisers, PrettyTablesresfmt = (v, i, j) -> begin    if j == 1        return v    else        return isa(v, Number) ? "$(round(v * 100, digits = 3)) %" : v    endend;

1. Data

We use one year of S&P 500 prices and the factor returns over the same dates. Section 4 needs the factor returns.

using CSV, TimeSeries, DataFrames, ClarabelX = TimeArray(CSV.File(joinpath(@__DIR__, "..", "SP500.csv.gz")); timestamp = :Date)[(end - 252):end]F = TimeArray(CSV.File(joinpath(@__DIR__, "..", "Factors.csv.gz")); timestamp = :Date)[(end - 252):end]rd = prices_to_returns(price_ingestion(PriceIngestion(), X; F = F))
ReturnsResult
    nx ┼ 20-element Vector{String}
     X ┼ 252×20 Matrix{Float64}
    nf ┼ Vector{String}: ["MTUM", "QUAL", "SIZE", "USMV", "VLUE"]
     F ┼ 252×5 Matrix{Float64}
    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. Shared optimiser setup

We pass a vector of solver settings. If the first Clarabel setting stalls on this data, the optimiser tries the next one.

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))]pr = prior(EmpiricalPrior(), rd)opt = JuMPOptimiser(; pe = pr, slv = slv)sets_asset = UniverseSets(; dict = Dict("nx" => rd.nx))opt_asset = JuMPOptimiser(; pe = pr, slv = slv, sets = sets_asset)
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 ┼ 3-element Vector{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 ┼ UniverseSets
          │     xkey ┼ String: "nx"
          │    uxkey ┼ String: "ux"
          │    tfkey ┼ String: "nf"
          │   utfkey ┼ String: "uf"
          │    cfkey ┼ String: "ncf"
          │   ucfkey ┼ String: "ucf"
          │    nikey ┼ String: "ni"
          │     dict ┴ Dict{String, Vector{String}}: Dict("nx" => ["AAPL", "AMD", "BAC", "BBY", "CVX", "GE", "HD", "JNJ", "JPM", "KO", "LLY", "MRK", "MSFT", "PEP", "PFE", "PG", "RRC", "UNH", "WMT", "XOM"])
       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

3. Asset risk contribution with rc constraints

risk_contribution splits the total risk of a portfolio into one contribution per asset. Risk budgeting asks for a whole budget. The rc field of Variance instead bounds each contribution, and the cell below asks for every asset at or below 20% of the total risk. The optimiser states these bounds on a relaxation, which section 4 describes, so read the realised shares in the table.

The loop then solves the same constraints under three objectives, so you can read how the objective moves the weights while the caps stay put.

lcs_asset = LinearConstraintEstimator(; val = ["$a <= 0.2" for a in rd.nx])r_asset = Variance(; rc = lcs_asset)rf_asset = factory(r_asset, pr)obj_specs = [(:min_risk, MinimumRisk()), (:max_utility, MaximumUtility()),             (:max_ratio, MaximumRatio())]asset_df = DataFrame(; assets = rd.nx)asset_res = Dict{Symbol, Any}()for (name, obj) in obj_specs    res = optimise(MeanRisk(; r = r_asset, obj = obj, opt = opt_asset))    asset_res[name] = res    rcs = risk_contribution(rf_asset, res.w, pr.X)    rcs ./= sum(rcs)    asset_df[!, Symbol("$(name)_weight")] = res.w    asset_df[!, Symbol("$(name)_risk")] = rcsendpretty_table(asset_df; formatters = [resfmt])
┌────────┬─────────────────┬───────────────┬────────────────────┬──────────────────┬──────────────────┬────────────────┐
│ assets │ min_risk_weight │ min_risk_risk │ max_utility_weight │ max_utility_risk │ max_ratio_weight │ max_ratio_risk │
│ String │         Float64 │       Float64 │            Float64 │          Float64 │          Float64 │        Float64 │
├────────┼─────────────────┼───────────────┼────────────────────┼──────────────────┼──────────────────┼────────────────┤
│   AAPL │           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 % │
│    BAC │           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 % │
│    CVX │         9.212 % │       9.707 % │              0.0 % │            0.0 % │          17.06 % │       21.297 % │
│     GE │         0.407 % │       0.425 % │              0.0 % │            0.0 % │            0.0 % │          0.0 % │
│     HD │           0.0 % │         0.0 % │              0.0 % │            0.0 % │            0.0 % │          0.0 % │
│    JNJ │        21.939 % │        20.0 % │              0.0 % │            0.0 % │            0.0 % │          0.0 % │
│    JPM │         1.652 % │       1.715 % │              0.0 % │            0.0 % │            0.0 % │          0.0 % │
│     KO │        13.302 % │      13.797 % │              0.0 % │            0.0 % │          1.192 % │        0.508 % │
│    LLY │           0.0 % │         0.0 % │              0.0 % │            0.0 % │         24.183 % │       21.798 % │
│    MRK │        20.466 % │        20.0 % │           58.664 % │         41.028 % │         34.913 % │        22.97 % │
│   MSFT │           0.0 % │         0.0 % │              0.0 % │            0.0 % │            0.0 % │          0.0 % │
│    PEP │        12.847 % │      13.311 % │              0.0 % │            0.0 % │            0.0 % │          0.0 % │
│    PFE │           0.0 % │         0.0 % │              0.0 % │            0.0 % │            0.0 % │          0.0 % │
│     PG │         5.642 % │       5.824 % │              0.0 % │            0.0 % │            0.0 % │          0.0 % │
│    RRC │           0.0 % │         0.0 % │              0.0 % │            0.0 % │          6.219 % │        11.68 % │
│    UNH │           0.0 % │         0.0 % │              0.0 % │            0.0 % │            0.0 % │          0.0 % │
│    WMT │        10.438 % │      10.898 % │              0.0 % │            0.0 % │            0.0 % │          0.0 % │
│    XOM │         4.095 % │       4.322 % │           41.336 % │         58.972 % │         16.431 % │       21.747 % │
└────────┴─────────────────┴───────────────┴────────────────────┴──────────────────┴──────────────────┴────────────────┘

Read the risk columns of the table against the 20% cap. The weights differ from one objective to the next. The plots below draw one risk profile per objective.

using StatsPlots, GraphRecipesfor (name, _) in obj_specs    display(plot_risk_contribution(rf_asset, asset_res[name], rd;                                   title = "Asset risk contribution, $(name)"))end

4. Factor risk contribution optimisation

FactorRiskContribution caps a named factor rather than a named asset. It regresses the asset returns onto the factor returns and computes the contribution of each factor from that regression. The regression estimator needs the factor returns when the optimiser builds the problem, so we pass rd to optimise.

The loop below runs the same three objectives against one set of factor constraints. The constraints cap the share of VLUE in the portfolio variance at 74%, keep the share of QUAL at or above -7%, and fix the share of MTUM at 9%. A factor can contribute a negative share.

sets = UniverseSets(; dict = Dict("nx" => rd.nf))lcs = LinearConstraintEstimator(; val = ["VLUE <= 0.74", "QUAL >= -0.07", "MTUM==0.09"])r_fac = Variance(; rc = lcs)rf_fac = factory(r_fac, pr)factor_df = DataFrame(; factor = [rd.nf; "Off-factor"])factor_res = Dict{Symbol, Any}()for (name, obj) in obj_specs    res = optimise(FactorRiskContribution(; r = r_fac, obj = obj, opt = opt, sets = sets),                   rd)    factor_res[name] = res    frc_risk = factor_risk_contribution(rf_fac, res.w, pr.X; rd = rd)    frc_risk ./= sum(frc_risk)    factor_df[!, Symbol("$(name)_risk")] = frc_riskendpretty_table(factor_df; formatters = [resfmt])
┌────────────┬───────────────┬──────────────────┬────────────────┐
│     factor │ min_risk_risk │ max_utility_risk │ max_ratio_risk │
│     String │       Float64 │          Float64 │        Float64 │
├────────────┼───────────────┼──────────────────┼────────────────┤
│       MTUM │       5.299 % │         10.532 % │        9.001 % │
│       QUAL │       9.545 % │        -13.214 % │       -6.857 % │
│       SIZE │     -60.711 % │        -42.157 % │       -48.14 % │
│       USMV │      90.242 % │          64.72 % │       71.993 % │
│       VLUE │      55.626 % │         80.119 % │       74.003 % │
│ Off-factor │        -0.0 % │           -0.0 % │         -0.0 % │
└────────────┴───────────────┴──────────────────┴────────────────┘

The table prints each factor's share of the total, so every column sums to one. The rc constraints bound each factor's share of the portfolio variance. The optimiser states them on a semidefinite relaxation, in which a matrix variable takes the place of the product of the factor weights with themselves. When the relaxation is not tight, the shares of the portfolio it returns miss the targets. On this data only the maximum-ratio column meets all three constraints.

for (name, _) in obj_specs    display(plot_factor_risk_contribution(rf_fac, factor_res[name], rd;                                          title = "Factor risk contribution, $(name)"))end

Summary

The rc field bounds each asset's or each factor's share of the variance, whatever the objective.

  • Variance with rc constraints states the bounds on a semidefinite relaxation, so a solve can succeed and still return weights whose realised shares miss a bound.
  • MeanRisk and FactorRiskContribution take the same constraints under different objectives, so you can compare the weights and the concentration they give.
  • risk_contribution and factor_risk_contribution compute the realised profile, which you read against the caps you set.

This page was generated using Literate.jl.