The source files can be found in examples/.

Calibrated risk measures: a rule in place of a number

alpha = 0.05 is a statement about the probability of the tail. It is not a statement about the number of observations the tail holds. Over one year of daily data the 5% tail holds about 13 observations; over five years it holds about 63. The number a caller states once therefore means something different on every sample it meets, and a cross-validation over folds of unequal length meets a different sample on every fold.

A calibration slot takes either kind of statement. It takes the number itself, and it takes a Calibration Rule, which computes the number from the prior result of the sample in front of it. The rule runs inside factory, the verb an optimiser calls once per fit, so a cross-validation refits the quantity on every fold and no other part of the model moves.

Every alpha, beta, kappa, ambiguity radius and Esfahani-Kuhn tail weight the library carries is a calibration slot.

This example shows the slot from the caller's side.

  1. A stated number and a rule side by side on the same measure.
  2. The refit per fold, over folds of unequal length.
  3. Three of the rules that ship, and the reading that makes each one the right choice.
  4. The travelling pair, where alpha resolves first and its number reaches the $\kappa$ rule.
  5. The two tail-decay rules, which read one sample and answer per end and for both ends.
  6. A plain function as a rule, which is the case that has no type.
  7. The slot bounds, which refuse a rule of the wrong family at construction.
  8. The ambiguity radius and the tail weight of the distributionally robust measure.

The regularisation coefficients l1, linf, L2Regularisation and LpRegularisation are ambiguity radii too, and they take the same rule family. They belong to the regularisation example, which owns those slots. The three norm ceilings l2c, lpc and linfc of JuMPOptimiser bound a norm rather than price one, so they are a different quantity and take a family of their own, AbstractNormCeilingCalibrationAlgorithm. That example runs both families, and this one stays on the slots that sit on a risk measure.

using PortfolioOptimisers, PrettyTables, DataFrames, StatsBase, Statistics# Format for pretty tables.numfmt = (v, i, j) -> begin    return isa(v, AbstractFloat) ? round(v; sigdigits = 4) : vend;resfmt = (v, i, j) -> begin    if j == 1        return v    else        return isa(v, Number) ? "$(round(v*100, digits=3)) %" : v    endend;

1. Setting up

Five years of daily data give enough length for the folds to differ from each other by a meaningful amount, which is the whole point of the demonstration.

using CSV, TimeSeries, ClarabelX = TimeArray(CSV.File(joinpath(@__DIR__, "..", "SP500.csv.gz")); timestamp = :Date)[(end - 252 * 5):end]rd = prices_to_returns(X)println("size(rd.X) = $(size(rd.X))")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))];
size(rd.X) = (1260, 20)

2. A stated number and a rule side by side

ConditionalValueatRisk takes alpha = 0.05, and it takes a rule that computes the number. The slot names the quantity and the end of the distribution, so the rule states the method alone. Nothing else on the measure changes.

cvar_stated = ConditionalValueatRisk(; alpha = 0.05)cvar_rule = ConditionalValueatRisk(; alpha = ScenarioCount(; n = 25))
ConditionalValueatRisk
  settings ┼ RiskMeasureSettings
           │   scale ┼ Float64: 1.0
           │      ub ┼ nothing
           │     rke ┴ Bool: true
     alpha ┼ ScenarioCount
           │   n ┴ Int64: 25
         w ┴ nothing

What the slot stores is the rule itself, so a reader of the slot sees what the caller wrote.

cvar_rule.alpha
ScenarioCount
  n ┴ Int64: 25

ScenarioCount states the tail's population rather than its probability: alpha = n / T leaves n observations in the tail whatever the sample length is.

A rule needs a prior result, because it reads the sample size and the moments off one. So the number appears when factory runs, which is the verb the optimiser calls on the measure once it has fitted the prior.

pr = prior(EmpiricalPrior(), rd)println("resolved alpha over the whole sample = $(factory(cvar_rule, pr).alpha)")
resolved alpha over the whole sample = 0.01984126984126984

The value-level entry point has no prior result to resolve against, so it refuses the rule and names the way out rather than guessing a number.

w0 = fill(inv(size(rd.X, 2)), size(rd.X, 2))try    expected_risk(cvar_rule, w0, rd.X)catch e    println(sprint(showerror, e))end
ArgumentError: `ConditionalValueatRisk.alpha` holds a Calibration Rule, a `ScenarioCount`, and this entry point has no prior result to resolve it against. A rule reads the sample size, the moments and the effective observation weights, which a bare returns matrix does not carry. Pass the prior result itself — `expected_risk(r, w, pr, fees)` — or resolve the measure first with `factory(r, pr)`.

Passing the prior result instead resolves the rule and evaluates the measure.

println("calibrated risk = $(expected_risk(cvar_rule, w0, pr))")println("stated risk     = $(expected_risk(cvar_stated, w0, pr))")
calibrated risk = 0.0444545343482581
stated risk     = 0.03209629979913421

3. The refit per fold

This is the reason the slot widened. IndexWalkForward with expand_train = true grows the training window one test block at a time, so the folds are of unequal length by construction.

iwf = IndexWalkForward(252, 63; expand_train = true)iwf_res = split(iwf, rd)println("number of folds = $(length(iwf_res.train_idx))")
number of folds = 16

factory is the verb the optimiser itself calls, so resolving a measure against a fold's own prior reports the measure that fold optimises. This is the shortest honest way to read the number, and the library needs no accessor for it.

fold_prior(idx) = prior(EmpiricalPrior(), rd.X[idx, :])resolved(r, idx, key) = getproperty(factory(r, fold_prior(idx)), key)
resolved (generic function with 1 method)

The stated alpha is one number for every fold. The rule's alpha falls as the window grows, and the tail's population is what stays fixed.

fold_table = DataFrame(:fold => 1:length(iwf_res.train_idx),                       :T => length.(iwf_res.train_idx),                       :alpha_stated => fill(cvar_stated.alpha, length(iwf_res.train_idx)),                       :tail_count_stated => cvar_stated.alpha * length.(iwf_res.train_idx),                       :alpha_rule =>                           [resolved(cvar_rule, idx, :alpha) for idx in iwf_res.train_idx])fold_table.tail_count_rule = fold_table.alpha_rule .* fold_table.Tpretty_table(fold_table; formatters = [numfmt])
┌───────┬───────┬──────────────┬───────────────────┬────────────┬─────────────────┐
│  fold      T  alpha_stated  tail_count_stated  alpha_rule  tail_count_rule │
│ Int64  Int64       Float64            Float64     Float64          Float64 │
├───────┼───────┼──────────────┼───────────────────┼────────────┼─────────────────┤
│     1 │   252 │         0.05 │              12.6 │    0.09921 │            25.0 │
│     2 │   315 │         0.05 │             15.75 │    0.07937 │            25.0 │
│     3 │   378 │         0.05 │              18.9 │    0.06614 │            25.0 │
│     4 │   441 │         0.05 │             22.05 │    0.05669 │            25.0 │
│     5 │   504 │         0.05 │              25.2 │     0.0496 │            25.0 │
│     6 │   567 │         0.05 │             28.35 │    0.04409 │            25.0 │
│     7 │   630 │         0.05 │              31.5 │    0.03968 │            25.0 │
│     8 │   693 │         0.05 │             34.65 │    0.03608 │            25.0 │
│     9 │   756 │         0.05 │              37.8 │    0.03307 │            25.0 │
│    10 │   819 │         0.05 │             40.95 │    0.03053 │            25.0 │
│    11 │   882 │         0.05 │              44.1 │    0.02834 │            25.0 │
│    12 │   945 │         0.05 │             47.25 │    0.02646 │            25.0 │
│    13 │  1008 │         0.05 │              50.4 │     0.0248 │            25.0 │
│    14 │  1071 │         0.05 │             53.55 │    0.02334 │            25.0 │
│    15 │  1134 │         0.05 │              56.7 │    0.02205 │            25.0 │
│    16 │  1197 │         0.05 │             59.85 │    0.02089 │            25.0 │
└───────┴───────┴──────────────┴───────────────────┴────────────┴─────────────────┘

The measure goes into a MeanRisk unchanged, and the cross-validation refits it per fold without being told that anything is being calibrated.

mr_rule = MeanRisk(; r = cvar_rule, opt = JuMPOptimiser(; slv = slv))mr_stated = MeanRisk(; r = cvar_stated, opt = JuMPOptimiser(; slv = slv))pred_rule = cross_val_predict(mr_rule, rd, iwf)pred_stated = cross_val_predict(mr_stated, rd, iwf)
MultiPeriodPredictionResult
  pred ┼ 16-element Vector{PredictionResult}
       │ PredictionResult ⋯
       │ PredictionResult ⋯
       │ PredictionResult ⋯
       │ PredictionResult ⋯
       │ PredictionResult ⋯
       │ PredictionResult ⋯
       │ PredictionResult ⋯
       │ PredictionResult ⋯
       │ PredictionResult ⋯
       │ PredictionResult ⋯
       │ PredictionResult ⋯
       │ PredictionResult ⋯
       │ PredictionResult ⋯
       │ PredictionResult ⋯
       │ PredictionResult ⋯
       │ PredictionResult ⋯
   mrd ┼ PredictionReturnsResult
       │     nx ┼ 20-element SubArray{String, 1, Vector{String}, Tuple{Base.Slice{Base.OneTo{Int64}}}, true}
       │      X ┼ 1008-element Vector{Float64}
       │     nf ┼ nothing
       │      F ┼ nothing
       │     nb ┼ nothing
       │      B ┼ nothing
       │     ts ┼ 1008-element Vector{Date}
       │     iv ┼ nothing
       │   ivpa ┴ nothing
    id ┼ nothing
   opt ┴ nothing

Both run to the same folds, so the out-of-sample series are comparable.

var_rm = LowOrderMoment(; alg = SecondMoment())println("calibrated out-of-sample variance = $(expected_risk(var_rm, pred_rule))")println("stated out-of-sample variance     = $(expected_risk(var_rm, pred_stated))")
calibrated out-of-sample variance = 0.00016175524648104114
stated out-of-sample variance     = 0.00015280122008026853

Fold by fold, the level the rule produced sits beside the out-of-sample risk of each run. The two runs differ only in the measure the folds priced.

fold_risk = DataFrame(:fold => 1:length(iwf_res.train_idx),                      :T => length.(iwf_res.train_idx),                      :alpha_rule => fold_table.alpha_rule,                      :risk_rule => expected_risk.(Ref(var_rm), pred_rule.pred),                      :risk_stated => expected_risk.(Ref(var_rm), pred_stated.pred))pretty_table(fold_risk; formatters = [numfmt])
┌───────┬───────┬────────────┬───────────┬─────────────┐
│  fold      T  alpha_rule  risk_rule  risk_stated │
│ Int64  Int64     Float64    Float64      Float64 │
├───────┼───────┼────────────┼───────────┼─────────────┤
│     1 │   252 │    0.09921 │  8.257e-5 │    6.592e-5 │
│     2 │   315 │    0.07937 │   3.79e-5 │    3.671e-5 │
│     3 │   378 │    0.06614 │  7.018e-5 │    7.204e-5 │
│     4 │   441 │    0.05669 │  4.592e-5 │    4.382e-5 │
│     5 │   504 │     0.0496 │   0.00114 │     0.00114 │
│     6 │   567 │    0.04409 │ 0.0002424 │   0.0002448 │
│     7 │   630 │    0.03968 │ 0.0001013 │    9.779e-5 │
│     8 │   693 │    0.03608 │   6.71e-5 │    6.208e-5 │
│     9 │   756 │    0.03307 │  8.318e-5 │    7.246e-5 │
│    10 │   819 │    0.03053 │  5.159e-5 │    4.456e-5 │
│    11 │   882 │    0.02834 │  4.094e-5 │    3.754e-5 │
│    12 │   945 │    0.02646 │ 0.0001739 │   0.0001048 │
│    13 │  1008 │     0.0248 │  7.724e-5 │     7.74e-5 │
│    14 │  1071 │    0.02334 │ 0.0002046 │   0.0001911 │
│    15 │  1134 │    0.02205 │   8.97e-5 │    8.215e-5 │
│    16 │  1197 │    0.02089 │ 0.0001024 │    9.313e-5 │
└───────┴───────┴────────────┴───────────┴─────────────┘

The weights the calibrated run held, one column per fold.

pretty_table(hcat(DataFrame(:tickers => rd.nx),                  DataFrame(reduce(hcat, getproperty.(pred_rule.res, :w)),                            Symbol.(1:length(pred_rule.res)))); formatters = [resfmt])
┌─────────┬──────────┬──────────┬──────────┬──────────┬──────────┬──────────┬──────────┬──────────┬──────────┬──────────┬──────────┬──────────┬──────────┬──────────┬──────────┬──────────┐
│ tickers         1         2         3         4         5         6         7         8         9        10        11        12        13        14        15        16 │
│  String   Float64   Float64   Float64   Float64   Float64   Float64   Float64   Float64   Float64   Float64   Float64   Float64   Float64   Float64   Float64   Float64 │
├─────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┤
│    AAPL │  1.264 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    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 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │
│     BAC │    0.0 % │  1.588 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │
│     BBY │  8.947 % │  9.093 % │  3.834 % │  4.703 % │  5.543 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │  0.122 % │  0.122 % │    0.0 % │    0.0 % │
│     CVX │    0.0 % │    0.0 % │ 10.285 % │  4.262 % │  1.929 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │
│      GE │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │
│      HD │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │  3.854 % │  3.854 % │    0.0 % │    0.0 % │
│     JNJ │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │  1.871 % │    0.0 % │    0.0 % │    0.0 % │  4.298 % │  4.298 % │  4.298 % │    0.0 % │    0.0 % │  3.198 % │  3.198 % │
│     JPM │    0.0 % │    0.0 % │  2.556 % │  4.067 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │
│      KO │ 51.109 % │  8.709 % │    8.1 % │  8.968 % │ 10.808 % │ 13.865 % │  7.165 % │  8.371 % │  7.233 % │  6.064 % │  6.064 % │  6.064 % │ 11.181 % │ 11.181 % │   6.61 % │   6.61 % │
│     LLY │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │  0.335 % │  2.648 % │  2.648 % │  2.648 % │    0.0 % │    0.0 % │  0.217 % │  0.217 % │
│     MRK │ 18.552 % │ 19.457 % │ 23.331 % │ 20.809 % │  14.38 % │ 42.447 % │ 44.553 % │ 42.721 % │  47.03 % │ 48.825 % │ 48.825 % │ 48.825 % │ 27.692 % │ 27.692 % │  41.52 % │  41.52 % │
│    MSFT │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │
│     PEP │  2.579 % │ 17.383 % │ 21.305 % │ 26.964 % │ 29.443 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │
│     PFE │  0.032 % │  3.223 % │  4.132 % │  5.585 % │  8.736 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │  6.929 % │  6.929 % │ 15.276 % │ 15.276 % │
│      PG │ 10.523 % │ 30.281 % │ 26.457 % │ 23.876 % │ 19.842 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │   0.18 % │   0.18 % │   0.18 % │    0.0 % │    0.0 % │  0.755 % │  0.755 % │
│     RRC │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │  0.489 % │  0.489 % │  0.489 % │  2.082 % │  2.082 % │  4.791 % │  4.791 % │
│     UNH │  6.994 % │  4.497 % │    0.0 % │  0.766 % │  9.319 % │    0.0 % │  1.442 % │  3.795 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │  2.561 % │  2.561 % │    0.0 % │    0.0 % │
│     WMT │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │ 41.817 % │  46.84 % │ 45.113 % │ 45.402 % │ 37.495 % │ 37.495 % │ 37.495 % │ 45.578 % │ 45.578 % │ 27.633 % │ 27.633 % │
│     XOM │    0.0 % │  5.768 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │    0.0 % │
└─────────┴──────────┴──────────┴──────────┴──────────┴──────────┴──────────┴──────────┴──────────┴──────────┴──────────┴──────────┴──────────┴──────────┴──────────┴──────────┴──────────┘

4. Three of the rules

Eleven rules ship over the five families. Five of them compute a significance level or a deformation parameter, and three of those five are the ones this section reads. Each answers a different question about the sample.

  • ScenarioCount answers how many observations must the tail hold. It reads Kish's effective sample size when observation weights are stated, because a weighted tail holds fewer independent observations than its row count suggests.
  • RateSignificance answers how fast may the tail move outwards. alpha = c / sqrt(T) leaves c * sqrt(T) observations in the tail, which grows with the sample but more slowly than the sample does. That is the rate at which a sample mean's own error falls. It reads the raw row count, because a rate is a statement about the length of the record.
  • EntropyBudget answers what may the deformation cost. Section 5 puts it on a $\kappa$ slot.

The deformation family holds two more rules, and both answer a different question: how fast does this sample's tail decay. Each estimates a tail index and returns its reciprocal. HillTailDecay standardises every column by its own dispersion and keeps the sign of the end, so a skewed sample gives one number for the loss end and another for the gain end. RadialTailDecay whitens each observation with the covariance matrix and reads a distance, so it returns one number for both ends. They take a $\kappa$ slot on the same terms EntropyBudget does, and section 6 runs both.

count_rule = ConditionalValueatRisk(; alpha = ScenarioCount(; n = 25))rate_rule = ConditionalValueatRisk(; alpha = RateSignificance(; c = 1.5))rule_table = DataFrame(:fold => 1:length(iwf_res.train_idx),                       :T => length.(iwf_res.train_idx),                       :scenario_count =>                           [resolved(count_rule, idx, :alpha) for idx in iwf_res.train_idx],                       :rate =>                           [resolved(rate_rule, idx, :alpha) for idx in iwf_res.train_idx])rule_table.count_tail = rule_table.scenario_count .* rule_table.Trule_table.rate_tail = rule_table.rate .* rule_table.Tpretty_table(rule_table; formatters = [numfmt])
┌───────┬───────┬────────────────┬─────────┬────────────┬───────────┐
│  fold      T  scenario_count     rate  count_tail  rate_tail │
│ Int64  Int64         Float64  Float64     Float64    Float64 │
├───────┼───────┼────────────────┼─────────┼────────────┼───────────┤
│     1 │   252 │        0.09921 │ 0.09449 │       25.0 │     23.81 │
│     2 │   315 │        0.07937 │ 0.08452 │       25.0 │     26.62 │
│     3 │   378 │        0.06614 │ 0.07715 │       25.0 │     29.16 │
│     4 │   441 │        0.05669 │ 0.07143 │       25.0 │      31.5 │
│     5 │   504 │         0.0496 │ 0.06682 │       25.0 │     33.67 │
│     6 │   567 │        0.04409 │ 0.06299 │       25.0 │     35.72 │
│     7 │   630 │        0.03968 │ 0.05976 │       25.0 │     37.65 │
│     8 │   693 │        0.03608 │ 0.05698 │       25.0 │     39.49 │
│     9 │   756 │        0.03307 │ 0.05455 │       25.0 │     41.24 │
│    10 │   819 │        0.03053 │ 0.05241 │       25.0 │     42.93 │
│    11 │   882 │        0.02834 │ 0.05051 │       25.0 │     44.55 │
│    12 │   945 │        0.02646 │  0.0488 │       25.0 │     46.11 │
│    13 │  1008 │         0.0248 │ 0.04725 │       25.0 │     47.62 │
│    14 │  1071 │        0.02334 │ 0.04583 │       25.0 │     49.09 │
│    15 │  1134 │        0.02205 │ 0.04454 │       25.0 │     50.51 │
│    16 │  1197 │        0.02089 │ 0.04336 │       25.0 │      51.9 │
└───────┴───────┴────────────────┴─────────┴────────────┴───────────┘

The two columns of tail populations are the difference between the two readings. The scenario count holds its population flat, and the rate lets it grow with the square root of the record.

Observation weights separate the two rules a second time. A measure that carries w hands those weights to the rule, and ScenarioCount divides by Kish's effective sample size rather than by the row count. The effective size is the smaller of the two, so the weighted level is the higher.

obs_w = pweights(range(; start = 1, stop = 2, length = length(iwf_res.train_idx[1])))count_weighted = ConditionalValueatRisk(; alpha = count_rule.alpha, w = obs_w)rate_weighted = ConditionalValueatRisk(; alpha = rate_rule.alpha, w = obs_w)first_idx = iwf_res.train_idx[1]weight_table = DataFrame(:rule => ["ScenarioCount", "RateSignificance"],                         :unweighted => [resolved(count_rule, first_idx, :alpha),                                         resolved(rate_rule, first_idx, :alpha)],                         :weighted => [resolved(count_weighted, first_idx, :alpha),                                       resolved(rate_weighted, first_idx, :alpha)])pretty_table(weight_table; formatters = [numfmt])
┌──────────────────┬────────────┬──────────┐
│             rule  unweighted  weighted │
│           String     Float64   Float64 │
├──────────────────┼────────────┼──────────┤
│    ScenarioCount │    0.09921 │   0.1029 │
│ RateSignificance │    0.09449 │  0.09449 │
└──────────────────┴────────────┴──────────┘

The rate is unchanged, because it never reads the weights.

5. The travelling pair

RelativisticValueatRisk carries two calibration slots, alpha and kappa. EntropyBudget states the price of the deformation directly: RRM multiplies its dual variable by kappa_log(inv(alpha * T), kappa), and the rule returns the $\kappa$ that meets a stated value of that coefficient.

The rule therefore reads its sibling alpha. alpha resolves first, and the number it produced travels to the $\kappa$ rule, so the pair resolves in one pass over the measure.

rlvar_rule = RelativisticValueatRisk(; alpha = ScenarioCount(; n = 25),                                     kappa = EntropyBudget(; target = -6.0))rlvar_stated = RelativisticValueatRisk(; alpha = 0.05,                                       kappa = EntropyBudget(; target = -6.0))pair_table = DataFrame(:fold => 1:length(iwf_res.train_idx),                       :T => length.(iwf_res.train_idx),                       :alpha =>                           [resolved(rlvar_rule, idx, :alpha) for idx in iwf_res.train_idx],                       :kappa =>                           [resolved(rlvar_rule, idx, :kappa) for idx in iwf_res.train_idx],                       :kappa_stated_alpha => [resolved(rlvar_stated, idx, :kappa)                                               for idx in iwf_res.train_idx])pretty_table(pair_table; formatters = [numfmt])
┌───────┬───────┬─────────┬─────────┬────────────────────┐
│  fold      T    alpha    kappa  kappa_stated_alpha │
│ Int64  Int64  Float64  Float64             Float64 │
├───────┼───────┼─────────┼─────────┼────────────────────┤
│     1 │   252 │ 0.09921 │  0.6371 │             0.9727 │
│     2 │   315 │ 0.07937 │  0.6371 │             0.8428 │
│     3 │   378 │ 0.06614 │  0.6371 │              0.753 │
│     4 │   441 │ 0.05669 │  0.6371 │             0.6863 │
│     5 │   504 │  0.0496 │  0.6371 │             0.6341 │
│     6 │   567 │ 0.04409 │  0.6371 │             0.5919 │
│     7 │   630 │ 0.03968 │  0.6371 │             0.5569 │
│     8 │   693 │ 0.03608 │  0.6371 │             0.5271 │
│     9 │   756 │ 0.03307 │  0.6371 │             0.5013 │
│    10 │   819 │ 0.03053 │  0.6371 │             0.4788 │
│    11 │   882 │ 0.02834 │  0.6371 │             0.4588 │
│    12 │   945 │ 0.02646 │  0.6371 │             0.4409 │
│    13 │  1008 │  0.0248 │  0.6371 │             0.4248 │
│    14 │  1071 │ 0.02334 │  0.6371 │             0.4101 │
│    15 │  1134 │ 0.02205 │  0.6371 │             0.3967 │
│    16 │  1197 │ 0.02089 │  0.6371 │             0.3844 │
└───────┴───────┴─────────┴─────────┴────────────────────┘

The kappa column is flat and the kappa_stated_alpha column is not, and the reason is the handover. The coefficient reads inv(alpha * T), and a scenario count fixes alpha * T at the count itself, so the budget buys the same deformation on every fold. A stated alpha lets alpha * T grow with the window, so the same budget buys a different deformation each time.

The rule carries one check, and it is not a range check on the $\kappa$ it returns. The coefficient reaches only the band between $\ln(u)$ and $\sinh(\ln(u))$, so a target outside that band has no root at all. The band moves with alpha and with the sample, and the refusal names both.

try    resolved(RelativisticValueatRisk(; alpha = 0.05,                                     kappa = EntropyBudget(; target = -1.5)), first_idx,             :kappa)catch e    println(sprint(showerror, e))end
DomainError with -1.5:
`EntropyBudget.target` must lie in (-6.260317460317461, -2.5336968139574325), the band that `kappa_log(inv(alpha * T), kappa)` reaches over `kappa` in (0, 1) at `alpha = 0.05` and `T = 252`. No deformation parameter meets a target outside it, so the rule has nothing to return. The band moves with the sample, so a target that suits one fold need not suit another.

6. The two tail-decay rules

HillTailDecay and RadialTailDecay ask the same question, how fast does this sample's tail decay, and they read two different quantities to answer it. Each estimates a tail index and returns its reciprocal, which is the $\kappa$ whose deformed exponential decays at that rate.

RelativisticValueatRiskRange carries a $\kappa$ slot at each end, so one measure holds both answers. Each end carries a travelling pair of its own: kappa_a reads alpha, and kappa_b reads beta.

hill_range = RelativisticValueatRiskRange(; kappa_a = HillTailDecay(),                                          kappa_b = HillTailDecay())radial_range = RelativisticValueatRiskRange(; kappa_a = RadialTailDecay(),                                            kappa_b = RadialTailDecay())hill_res = factory(hill_range, pr)radial_res = factory(radial_range, pr)decay_table = DataFrame(:rule => ["HillTailDecay", "RadialTailDecay"],                        :kappa_a => [hill_res.kappa_a, radial_res.kappa_a],                        :kappa_b => [hill_res.kappa_b, radial_res.kappa_b])pretty_table(decay_table; formatters = [numfmt])
┌─────────────────┬─────────┬─────────┐
│            rule  kappa_a  kappa_b │
│          String  Float64  Float64 │
├─────────────────┼─────────┼─────────┤
│   HillTailDecay │  0.3748 │  0.4097 │
│ RadialTailDecay │  0.2438 │  0.2438 │
└─────────────────┴─────────┴─────────┘

The two rows are the difference between the two rules. The Hill row holds two numbers, because the rule keeps the sign of the end and reads the loss tail under kappa_a and the gain tail under kappa_b. The Radial row holds one number twice, because the rule whitens each observation and reads a distance, and a distance has no sign. The two Hill numbers part because the sample is skewed. A sample with no skew carries one index at both ends, and two estimates of it then differ by their own noise alone.

println("pooled skewness = $(skewness(vec(pr.X)))")
pooled skewness = 0.4329087335879429

The two rules read two different quantities, so their numbers are not two estimates of one thing. The Hill number is the index of one column's own tail after standardisation, and the Radial number is the index of the whole cross-section's radius.

The rule refits per fold on the same terms every other rule does. The two ends move apart by a different amount on every window, and which end carries the heavier tail is a property of the window rather than a law of the record.

decay_fold = DataFrame(:fold => 1:length(iwf_res.train_idx),                       :T => length.(iwf_res.train_idx),                       :kappa_a => [resolved(hill_range, idx, :kappa_a)                                    for idx in iwf_res.train_idx],                       :kappa_b => [resolved(hill_range, idx, :kappa_b)                                    for idx in iwf_res.train_idx])pretty_table(decay_fold; formatters = [numfmt])
┌───────┬───────┬─────────┬─────────┐
│  fold      T  kappa_a  kappa_b │
│ Int64  Int64  Float64  Float64 │
├───────┼───────┼─────────┼─────────┤
│     1 │   252 │  0.3322 │  0.3251 │
│     2 │   315 │  0.3299 │  0.3243 │
│     3 │   378 │  0.3354 │   0.317 │
│     4 │   441 │  0.3403 │  0.3303 │
│     5 │   504 │  0.3405 │   0.333 │
│     6 │   567 │  0.4576 │  0.4877 │
│     7 │   630 │  0.4297 │   0.497 │
│     8 │   693 │  0.4205 │   0.478 │
│     9 │   756 │   0.419 │  0.4682 │
│    10 │   819 │  0.4089 │  0.4523 │
│    11 │   882 │  0.4028 │  0.4418 │
│    12 │   945 │  0.3961 │   0.439 │
│    13 │  1008 │  0.3942 │   0.435 │
│    14 │  1071 │  0.3869 │  0.4289 │
│    15 │  1134 │  0.3785 │  0.4205 │
│    16 │  1197 │   0.377 │  0.4123 │
└───────┴───────┴─────────┴─────────┘

The Radial rule refuses the first fold, and the count is the reason. Both rules read the largest k order statistics of a pool and both floor k at kmin, but the two pools are of two different sizes: the Hill pool holds T * N standardised values and the radial pool holds T distances. The same floor therefore binds N times harder on the radial side, and a one-year fold at alpha = 0.05 leaves it 13 distances.

try    resolved(radial_range, first_idx, :kappa_a)catch e    println(sprint(showerror, e))end
DomainError with 13:
`RadialTailDecay` reads the largest `k = ceil(alpha * T) = 13` of the 252 radial distances, and `RadialTailDecay.kmin` puts the floor at 30. A Hill estimate over fewer order statistics moves from fold to fold for no reason in the data, and the deformation parameter moves with it. The radial series holds one entry per observation where the pool of `HillTailDecay` holds `N`, so the same floor binds harder here. Lengthen the sample, widen `alpha`, or lower `kmin` and take the noise.

7. A plain function as a rule

A rule is run by calling it, so a callable struct and a plain function are the same thing to the resolver. A closure over a caller's own data is the case that has no type, and it is the shortest way to state a one-off rule. The signature is (key, pr, w, slv, ctx):

  • key: name of the slot being resolved;
  • pr: prior result the rule reads the sample size and the moments off;
  • w: effective observation weights, or nothing;
  • slv: effective solver, or nothing;
  • ctx: a CalibrationContext, which carries what the site knows and key does not: the significance level of a sibling slot, the series the owner prices, and the norm order of the constraint the quantity stands in. A rule that reads none of the three names the type and ignores it, as this one does.

key earns its keep on a Range measure, where one function serves both ends and reads its own budget for each.

tail_budget = Dict(:alpha => 25, :beta => 50)budgeted(key, pr, w, slv, ctx) = tail_budget[key] / size(pr.X, 1)vrr = ValueatRiskRange(; alpha = budgeted, beta = budgeted)vrr_res = factory(vrr, fold_prior(first_idx))println("alpha = $(vrr_res.alpha), beta = $(vrr_res.beta)")
alpha = 0.0992063492063492, beta = 0.1984126984126984

Every Range measure defaults beta to alpha. The rule states the method and the slot states the end, so one rule serves both ends and the occupant crosses unchanged.

owa_range = OrderedWeightsArrayConditionalValueatRiskRange(; alpha = count_rule.alpha)println("beta is a $(typeof(owa_range.beta).name.name), same rule = $(owa_range.beta === count_rule.alpha)")
beta is a ScenarioCount, same rule = true

8. The slot bounds refuse a rule of the wrong family

Each slot's type bound names the one rule family that computes the quantity the slot holds. A deformation rule in a significance slot is therefore refused at construction, before any data is in sight, and no guard method is written for it.

try    ConditionalValueatRisk(; alpha = EntropyBudget(; target = -6.0))catch e    println(sprint(showerror, e))end
TypeError: in keyword argument alpha, expected Union{var"#s1773", var"#s1772", var"#s1771"} where {var"#s1773"<:AbstractSignificanceCalibrationAlgorithm, var"#s1772"<:Function, var"#s1771"<:Number}, got a value of type EntropyBudget{Float64}

A radius and a tail weight are two quantities as well, so each carries a family of its own and each slot refuses the other's rule.

try    DistributionallyRobustConditionalValueatRisk(; r = TailTermParity(; ratio = 1))catch e    println(sprint(showerror, e))end
TypeError: in keyword argument r, expected Union{var"#s1773", var"#s1772", var"#s1771"} where {var"#s1773"<:AbstractAmbiguityRadiusCalibrationAlgorithm, var"#s1772"<:Function, var"#s1771"<:Number}, got a value of type TailTermParity{Int64}

9. The ambiguity radius and the tail weight

DistributionallyRobustConditionalValueatRisk prices a ball of probability measures around the empirical one. Its r is the radius of that ball and its l is the weight of the tail term, and both are calibration slots beside alpha. So one measure can refit all three quantities per fold.

Four radius rules ship. The two below are the two this section runs.

  • ConcentrationRadius is the Blanchet-Kang-Murthy form: a scale in the units of the returns times the square root of a chi-squared quantile over the sample size. A wider universe buys a wider ball at a fixed confidence level, and a longer sample shrinks it. scale = nothing reads the average asset volatility off the prior result.
  • RateRadius is c / sqrt(T). The rate is the part of the form to trust and c is the part to calibrate, so a cross-validation over c is the honest route to a radius.

The other two answer a question these two do not. DimensionalRateRadius shrinks the ball at the rate the number of assets sets rather than at the square-root rate of the sample length, which is far slower over a wide universe. DualNormRadius reads the slot's own key, picks the ground metric that slot names, and returns the sampling error in it, so two slots of two different norms get two different numbers. Both take a radius slot on the same terms the two below do, and the regularisation example runs them, because the slots that separate them are the four penalty coefficients of JuMPOptimiser.

The tail-weight family ships one rule, TailTermParity, which prices the tail term of the loss at a stated multiple of its mean term. A caller's own function serves the slot too, which is the case section 7 covers.

drcvar = DistributionallyRobustConditionalValueatRisk(; alpha = count_rule.alpha,                                                      r = ConcentrationRadius(;                                                                              confidence = 0.95),                                                      l = TailTermParity(; ratio = 1))drcvar_rate = DistributionallyRobustConditionalValueatRisk(; r = RateRadius(; c = 0.02))amb_table = DataFrame(:fold => 1:length(iwf_res.train_idx),                      :T => length.(iwf_res.train_idx),                      :alpha =>                          [resolved(drcvar, idx, :alpha) for idx in iwf_res.train_idx],                      :concentration_r =>                          [resolved(drcvar, idx, :r) for idx in iwf_res.train_idx],                      :rate_r =>                          [resolved(drcvar_rate, idx, :r) for idx in iwf_res.train_idx],                      :l => [resolved(drcvar, idx, :l) for idx in iwf_res.train_idx])pretty_table(amb_table; formatters = [numfmt])
┌───────┬───────┬─────────┬─────────────────┬───────────┬───────────┐
│  fold      T    alpha  concentration_r     rate_r          l │
│ Int64  Int64  Float64          Float64    Float64    Float64 │
├───────┼───────┼─────────┼─────────────────┼───────────┼───────────┤
│     1 │   252 │ 0.09921 │        0.005978 │   0.00126 │ 0.0009744 │
│     2 │   315 │ 0.07937 │        0.005344 │  0.001127 │    0.0151 │
│     3 │   378 │ 0.06614 │        0.004756 │  0.001029 │   0.01419 │
│     4 │   441 │ 0.05669 │        0.004467 │ 0.0009524 │   0.01042 │
│     5 │   504 │  0.0496 │        0.004126 │ 0.0008909 │   0.01657 │
│     6 │   567 │ 0.04409 │        0.005057 │ 0.0008399 │  0.005111 │
│     7 │   630 │ 0.03968 │        0.005022 │ 0.0007968 │  0.009213 │
│     8 │   693 │ 0.03608 │        0.004737 │ 0.0007597 │   0.01076 │
│     9 │   756 │ 0.03307 │        0.004522 │ 0.0007274 │   0.01259 │
│    10 │   819 │ 0.03053 │        0.004299 │ 0.0006989 │   0.01366 │
│    11 │   882 │ 0.02834 │         0.00406 │ 0.0006734 │   0.01435 │
│    12 │   945 │ 0.02646 │        0.003849 │ 0.0006506 │    0.0142 │
│    13 │  1008 │  0.0248 │        0.003691 │ 0.0006299 │   0.01522 │
│    14 │  1071 │ 0.02334 │         0.00357 │ 0.0006111 │   0.01486 │
│    15 │  1134 │ 0.02205 │        0.003488 │ 0.0005939 │   0.01219 │
│    16 │  1197 │ 0.02089 │        0.003376 │ 0.0005781 │   0.01134 │
└───────┴───────┴─────────┴─────────────────┴───────────┴───────────┘

The rate radius falls with every fold, because the window only grows. The concentration radius does not, and the reason is its scale: scale = nothing reads the average asset volatility off the fold's own prior, so a window that takes in a more volatile period buys a wider ball even though it is longer. A radius is in the units of the returns, and this is what reading those units off the sample looks like.

The l column is a ratio of two scales the rule reads off the fold, so it moves with both. The numerator is the mean loss of the pooled cross-section, and the denominator is the mean per-column CVaR at the fold's own alpha. ratio = 1 therefore prices one tail term at one mean term. The first fold's mean return is the one nearest to zero, and its tail term is priced an order of magnitude below every other fold's for that reason alone.

The measure optimises in the same way the calibrated CVaR did.

mr_drcvar = MeanRisk(; r = drcvar, opt = JuMPOptimiser(; slv = slv))pred_drcvar = cross_val_predict(mr_drcvar, rd, iwf)println("robust out-of-sample variance = $(expected_risk(var_rm, pred_drcvar))")
robust out-of-sample variance = 0.0002023741480156217

10. What to take away

  • A calibration slot takes a number or a rule, and nothing else about the measure changes.
  • The rule resolves inside factory, so a cross-validation refits it per fold and factory(r, pr) is how a caller reads the number a fold produced.
  • The rule states the question. A scenario count fixes the tail's population, a rate lets it grow with the square root of the record, and an entropy budget fixes the price of a deformation.
  • alpha and $\kappa$ travel together, so a scenario count on alpha holds the entropy band still and a stated alpha does not.
  • The two tail-decay rules read two different quantities. HillTailDecay answers per end, and RadialTailDecay answers once for both.
  • A plain function of (key, pr, w, slv, ctx) is a rule, which covers the one-off case in every family.
  • A slot names its quantity, and its type bound refuses a rule of another family at construction.
  • The radius rules that read the slot's key, and the three norm ceilings, run in the regularisation example, because that example owns the slots those readings need.

This page was generated using Literate.jl.