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
rcfield 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.
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)"))end4. 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)"))endSummary
The rc field bounds each asset's or each factor's share of the variance, whatever the objective.
Variancewithrcconstraints states the bounds on a semidefinite relaxation, so a solve can succeed and still return weights whose realised shares miss a bound.MeanRiskandFactorRiskContributiontake the same constraints under different objectives, so you can compare the weights and the concentration they give.risk_contributionandfactor_risk_contributioncompute the realised profile, which you read against the caps you set.
This page was generated using Literate.jl.