Schur Complement Hierarchical Risk Parity

PortfolioOptimisers.NonMonotonicSchurComplementType
struct NonMonotonicSchurComplement <: SchurComplementAlgorithm

Runs the allocation at the $\gamma$ the caller gave, with no search.

The augmentation uses SchurComplementParams.gamma exactly. Portfolio variance is not monotonic in $\gamma$, so a larger value does not always give a lower-variance portfolio; MonotonicSchurComplement searches for the value that does.

Related

source
PortfolioOptimisers.MonotonicSchurComplementType
struct MonotonicSchurComplement{__T_N, __T_tol, __T_iter, __T_strict} <: SchurComplementAlgorithm

Searches $[0, \gamma]$ for the value that gives the lowest portfolio variance.

Portfolio variance is not monotonic in the Schur complement parameter: it falls, then rises again. This algorithm scans N values across the range, stops at the first one whose variance is no lower than its predecessor, and bisects the bracket around that turning point to tol. The allocation then runs at the value it found, which is at most the SchurComplementParams.gamma the caller asked for.

The objective is the variance $\boldsymbol{w}^\intercal \mathbf{\Sigma} \boldsymbol{w}$ in every case, including when the measure is a StandardDeviation. The search also runs with the positive-definite repair off, so a $\gamma$ whose augmented block is not positive definite scores an infinite variance and is passed over rather than raising.

Fields

  • N: Number of bisection steps for the monotonic Schur complement.
  • tol: Convergence tolerance.
  • iter: Maximum number of iterations.
  • strict: Whether to raise an error if convergence is not achieved.

Constructors

MonotonicSchurComplement(;    N::Integer = 10,    tol::Number = 1e-4,    iter::Option{<:Integer} = nothing,    strict::Bool = false) -> MonotonicSchurComplement

Keywords correspond to the struct's fields. iter defaults to nothing, which means the bisection derives its own budget from the bracket and tol, as ceil(Int, log2((hgamma - lgamma) / tol) * 4 + 10).

Validation

  • N > 0.
  • tol > 0.
  • If iter is given: iter > 0.

Related

source
PortfolioOptimisers.SchurComplementParamsType
struct SchurComplementParams{__T_r, __T_gamma, __T_pdm, __T_alg, __T_flag} <: AbstractAlgorithm

Collects the risk measure, the interpolation parameter $\gamma$, and the two algorithms that one Schur complement bundle runs with.

SchurComplementHierarchicalRiskParity holds one of these, or a vector of them. A vector runs one allocation per bundle and blends the resulting portfolios by each bundle's r.settings.scale.

Fields

  • r: Risk measure or vector of risk measures.
  • gamma: Schur complement interpolation parameter, in [0, 1]. At 0 no augmentation happens and the allocation is exactly HierarchicalRiskParity under r. A larger value subtracts more of the cross-cluster block from each sub-cluster covariance, which moves the allocation towards the minimum variance portfolio. Under MonotonicSchurComplement it is the upper end of the searched range, not the value used.
  • pdm: Positive definite matrix estimator.
  • alg: Schur complement algorithm variant.
  • flag: Whether to repair an augmented covariance block that is not positive definite. When true, pdm repairs it, and a failed repair raises. When false, no repair happens and the allocation is abandoned instead, which is what the MonotonicSchurComplement search needs; a caller that keeps the weights gets an error naming the gamma that failed.

Constructors

SchurComplementParams(;    r::Sd_Var = Variance(),    gamma::Number = 0.5,    pdm::Option{<:AbstractPosdefEstimator} = Posdef(),    alg::SchurComplementAlgorithm = MonotonicSchurComplement(),    flag::Bool = true) -> SchurComplementParams

Keywords correspond to the struct's fields. r is bounded to Sd_Var because the allocation needs a risk it can read straight off an augmented covariance block.

Validation

  • 0 <= gamma <= 1.

Related

References

  • [121] P. Cotton. Schur Complementary Allocation: A Unification of Hierarchical Risk Parity and Minimum Variance Portfolios. arXiv preprint arXiv:2411.05807 (2024).
source
PortfolioOptimisers.SchurComplementHierarchicalRiskParityResultType
struct SchurComplementHierarchicalRiskParityResult{__T_pr, __T_wb, __T_clr, __T_r, __T_gamma, __T_retcode, __T_w, __T_imsk, __T_fb} <: HierarchicalOptimisationResult

Result type returned by SchurComplementHierarchicalRiskParity optimisation.

Holds the prior result, the resolved weight bounds, the clustering result, the resolved risk measure, the Schur complement parameter the allocation ran at, the return code, the optimised weights, and the optional fallback estimator.

Fields

  • pr: Prior result.
  • wb: Weight bounds.
  • clr: Clusters result.
  • r: The risk measure the optimisation ran under, stored resolved. It parallels gamma: one measure for the single-bundle path, a vector of them for the multi-bundle path. Schur carries no scalariser, because it carries no vector of measures to combine — SchurComplementParams.r is bounded to a standard deviation or a variance.
  • gamma: The Schur complement interpolation parameter the allocation ran at. It parallels r: one value for the single-bundle path, one per bundle for the multi-bundle path. Under MonotonicSchurComplement this is the value the search chose, which is at most the gamma the estimator asked for.
  • retcode: Optimisation return code.
  • w: Portfolio weights vector assets × 1.
  • imsk: The Investable Mask the optimisation reduced on: true at every asset whose prior moments were finite. It is nothing when every asset was investable, and that sentinel is what skips both the reduction and the expansion. investable_mask derives it once from the full-universe prior result, and the result carries it, because the reduced prior can no longer yield it.
  • fb: The fallback chain that answered this result: the (estimator, result) pair of every attempt optimise made before this one, in the order they ran, or nothing when the estimator it was asked of answered (see FbChain).

The measure is on the result, and the result is flat

Schur joins HierarchicalOptimisationResult — it embeds a HierarchicalOptimiser, which is the family's membership rule — but it keeps its own flat field block rather than embedding HierarchicalResult. Its field set genuinely differs: it carries gamma, and it has no fees field.

It carries no scalariser, because it carries no vector of measures to combine. SchurComplementParams.r is bounded Sd_Var, so Schur takes one standard deviation or one variance.

Warning

On the multi-bundle path r is a vector, and the blend is over portfolios, not risks: the loop accumulates w .+= ps.r.settings.scale * wi. So expected_risk(res.r, res.w, res.pr) reports the measure-scalarised figure on the blended weights, which is not the number Schur computed. On the single-bundle path, which is the default, the same call is exactly right.

Constructors

SchurComplementHierarchicalRiskParityResult(;    pr::Option{<:AbstractPriorResult},    wb::Option{<:WeightBounds},    clr::Option{<:AbstractClusteringResult},    r::Union{<:Sd_Var, <:VecBaseRM},    gamma::Union{<:Number, <:VecNum},    retcode::OptimisationReturnCode,    w::Option{<:VecNum},    imsk::Option{<:BitVector} = nothing,    fb::Option{<:OptE_Opt_FbChain}) -> SchurComplementHierarchicalRiskParityResult

Keywords correspond to the struct's fields.

The keyword constructor is the one door _optimise exits through, so it is where the solved weights expand back onto the full asset universe, through expand_investable_weights. The positional constructor never expands, because a rebuild goes through it and a second pass would expand twice.

Related

source
PortfolioOptimisers.SchurComplementHierarchicalRiskParityType
struct SchurComplementHierarchicalRiskParity{__T_opt, __T_params, __T_fb} <: ClusteringOptimisationEstimator

Runs the hierarchical risk parity recursion on covariance blocks that a Schur complement has augmented with the information in the cross-cluster block.

The parameter $\gamma$ interpolates: at gamma = 0 the allocation is exactly HierarchicalRiskParity, and a larger value moves it towards the minimum variance portfolio.

Mathematical definition

The recursion is that of HierarchicalRiskParity: split the dendrogram's leaf order in half, and divide the part's weight between the two halves in inverse proportion to their risks. Schur changes only how each half's covariance block is read. Partition the part's covariance over its two halves $C_1$ and $C_2$:

\[\begin{align} \mathbf{\Sigma} &= \begin{pmatrix} \mathbf{\Sigma}_{11} & \mathbf{\Sigma}_{12} \\ \mathbf{\Sigma}_{21} & \mathbf{\Sigma}_{22} \end{pmatrix}\,,\\ \mathbf{A} &= \mathbf{\Sigma}_{11} - \gamma \, \mathbf{\Sigma}_{12} \mathbf{\Sigma}_{22}^{-1} \mathbf{\Sigma}_{21}\,,\\ \mathbf{R} &= \mathbf{I} - \gamma \, \mathbf{\Sigma}_{12} \mathbf{\Sigma}_{22}^{-1} \mathbf{M}^\intercal\,,\\ \hat{\mathbf{\Sigma}}_{11} &= \frac{1}{2}\left(\mathbf{R}^{-1}\mathbf{A} + \left(\mathbf{R}^{-1}\mathbf{A}\right)^\intercal\right)\,. \end{align}\]

$\hat{\mathbf{\Sigma}}_{22}$ follows by exchanging the two halves. The risk of a half is then read off its augmented block with the naive risk parity weights that block implies:

\[\begin{align} \tilde{w}_i &= \frac{\left(\hat{\mathbf{\Sigma}}_{11}\right)_{ii}^{-1}}{\sum_{j} \left(\hat{\mathbf{\Sigma}}_{11}\right)_{jj}^{-1}}\,,\\ \tilde{\rho}(C_1) &= \tilde{\boldsymbol{w}}^\intercal \hat{\mathbf{\Sigma}}_{11} \tilde{\boldsymbol{w}}\,,\\ \alpha &= \frac{\tilde{\rho}(C_2)}{\tilde{\rho}(C_1) + \tilde{\rho}(C_2)}\,. \end{align}\]

Where:

  • $\gamma$: The interpolation parameter, params.gamma, in $[0, 1]$.
  • $\mathbf{\Sigma}_{11}$, $\mathbf{\Sigma}_{12}$, $\mathbf{\Sigma}_{21}$, $\mathbf{\Sigma}_{22}$: Covariance blocks of the partition of the part into $C_1$ and $C_2$.
  • $\mathbf{A}$: The Schur complement of $\mathbf{\Sigma}_{22}$, scaled by $\gamma$. At $\gamma = 1$ it is that Schur complement exactly.
  • $\mathbf{M}$: The symmetric step-up matrix of size $|C_1|$ by $|C_2|$, see symmetric_step_up_matrix.
  • $\mathbf{R}$: The step-up correction that carries the augmentation back to the size of $C_1$.
  • $\hat{\mathbf{\Sigma}}_{11}$: The augmented block of $C_1$, symmetrised.
  • $\tilde{\boldsymbol{w}}$: Naive risk parity weights over $C_1$, read from the augmented diagonal.
  • $\tilde{\rho}$: Risk of that sub-portfolio. A StandardDeviation measure takes the square root of the quadratic form; a Variance measure does not.
  • $\alpha$: Fraction of the part's weight that goes to $C_1$.

Three details bound the recursion. $\gamma = 0$ leaves $\hat{\mathbf{\Sigma}}_{11} = \mathbf{\Sigma}_{11}$, so the allocation is then exactly HierarchicalRiskParity under the same measure. A half holding one asset is left unaugmented, because $\mathbf{M}$ needs two halves that differ in size by at most one. And $\alpha$ is clamped against the resolved weight bounds by split_factor_weight_constraints before it is applied, exactly as in HRP.

Fields

  • opt: Base hierarchical optimiser configuration.
  • params: Schur complement decomposition parameters.
  • fb: Fallback result or estimator.

Constructors

SchurComplementHierarchicalRiskParity(;    opt::HierarchicalOptimiser = HierarchicalOptimiser(),    params::TD{<:ScP_VecScP} = SchurComplementParams(),    fb::TDO_Option{<:OptE_Opt} = nothing) -> SchurComplementHierarchicalRiskParity

Keywords correspond to the struct's fields. Fields typed TD or TDO_Option may hold a TimeDependent per-fold schedule instead of a static value: the Schur parameters (risk measure and gamma) and the fallback are problem definition, so a cross-validation fold loop resolves them per fold, and a fold-less optimise runs with each at its static default (nothing for fb).

Validation

  • If params is a vector: !isempty(params).
  • fb schedules: bind !== :nearest.

Propagated parameters

When factory is called on this type, the following @fprop-tagged fields are automatically propagated:

  • opt: Recursively updated via factory.
  • fb: Recursively updated via factory.

View parameters

SchurComplementHierarchicalRiskParity defines its own port_opt_view method rather than deriving one from field tags.

  • The method reads the returns matrix X as its third argument. When opt.pe already holds a prior result, the method replaces X with opt.pe.X, so the children are viewed against the prior's own observations rather than the caller's matrix.
  • params recurses through port_opt_view with that matrix. opt recurses with the index alone.
  • fb is carried through unchanged.

Examples

julia> SchurComplementHierarchicalRiskParity()SchurComplementHierarchicalRiskParity     opt ┼ HierarchicalOptimiser         │       pe ┼ EmpiricalPrior         │          │           ce ┼ PortfolioOptimisersCovariance         │          │              │   ce ┼ Covariance         │          │              │      │    me ┼ SimpleExpectedReturns         │          │              │      │       │   w ┴ nothing         │          │              │      │    ce ┼ GeneralCovariance         │          │              │      │       │   ce ┼ StatsBase.SimpleCovariance: StatsBase.SimpleCovariance(true)         │          │              │      │       │    w ┴ nothing         │          │              │      │   alg ┼ FullMoment()         │          │              │      │     w ┴ nothing         │          │              │   mp ┼ MatrixProcessing         │          │              │      │     pdm ┼ Posdef         │          │              │      │         │      alg ┼ UnionAll: NearestCorrelationMatrix.Newton         │          │              │      │         │   kwargs ┴ @NamedTuple{}: NamedTuple()         │          │              │      │      dn ┼ nothing         │          │              │      │      dt ┼ nothing         │          │              │      │     alg ┼ nothing         │          │              │      │   order ┴ NTuple{4, Symbol}: (:pdm, :dn, :dt, :alg)         │          │           me ┼ SimpleExpectedReturns         │          │              │   w ┴ nothing         │          │      horizon ┼ nothing         │          │   fill_limit ┴ nothing         │      cle ┼ ClustersEstimator         │          │    ce ┼ PortfolioOptimisersCovariance         │          │       │   ce ┼ Covariance         │          │       │      │    me ┼ SimpleExpectedReturns         │          │       │      │       │   w ┴ nothing         │          │       │      │    ce ┼ GeneralCovariance         │          │       │      │       │   ce ┼ StatsBase.SimpleCovariance: StatsBase.SimpleCovariance(true)         │          │       │      │       │    w ┴ nothing         │          │       │      │   alg ┼ FullMoment()         │          │       │      │     w ┴ nothing         │          │       │   mp ┼ MatrixProcessing         │          │       │      │     pdm ┼ Posdef         │          │       │      │         │      alg ┼ UnionAll: NearestCorrelationMatrix.Newton         │          │       │      │         │   kwargs ┴ @NamedTuple{}: NamedTuple()         │          │       │      │      dn ┼ nothing         │          │       │      │      dt ┼ nothing         │          │       │      │     alg ┼ nothing         │          │       │      │   order ┴ NTuple{4, Symbol}: (:pdm, :dn, :dt, :alg)         │          │    de ┼ Distance         │          │       │   power ┼ nothing         │          │       │     alg ┴ CanonicalDistance()         │          │   alg ┼ HClustAlgorithm         │          │       │   linkage ┴ Symbol: :ward         │          │   onc ┼ OptimalNumberClusters         │          │       │   max_k ┼ nothing         │          │       │     alg ┼ SecondOrderDifference         │          │       │         │   alg ┼ StandardisedValue         │          │       │         │       │   mv ┼ MeanValue         │          │       │         │       │      │   w ┴ nothing         │          │       │         │       │   sv ┼ StdValue         │          │       │         │       │      │           w ┼ nothing         │          │       │         │       │      │   corrected ┴ Bool: true         │      slv ┼ nothing         │       wb ┼ WeightBounds         │          │   lb ┼ Float64: 0.0         │          │   ub ┴ Float64: 1.0         │     fees ┼ nothing         │     sets ┼ nothing         │       wf ┼ IterativeWeightFinaliser         │          │   iter ┴ Int64: 100         │      brt ┼ Bool: false         │    x_src ┼ Symbol: :prior         │   strict ┴ Bool: false  params ┼ SchurComplementParams         │       r ┼ Variance         │         │   settings ┼ RiskMeasureSettings         │         │            │   scale ┼ Float64: 1.0         │         │            │      ub ┼ nothing         │         │            │     rke ┴ Bool: true         │         │      sigma ┼ nothing         │         │       chol ┼ nothing         │         │         rc ┼ nothing         │         │        alg ┴ SquaredSOCRiskExpr()         │   gamma ┼ Float64: 0.5         │     pdm ┼ Posdef         │         │      alg ┼ UnionAll: NearestCorrelationMatrix.Newton         │         │   kwargs ┴ @NamedTuple{}: NamedTuple()         │     alg ┼ MonotonicSchurComplement         │         │        N ┼ Int64: 10         │         │      tol ┼ Float64: 0.0001         │         │     iter ┼ nothing         │         │   strict ┴ Bool: false         │    flag ┴ Bool: true      fb ┴ nothing

Related

References

  • [121] P. Cotton. Schur Complementary Allocation: A Unification of Hierarchical Risk Parity and Minimum Variance Portfolios. arXiv preprint arXiv:2411.05807 (2024).
source
PortfolioOptimisers.port_opt_viewMethod
port_opt_view(sp, i, X)

Get a view or subset of Schur complement parameters for cluster index i.

Returns a SchurComplementParams with the risk measure sliced for the given cluster index. Used internally when iterating over cluster levels.

Arguments

  • sp: SchurComplementParams or vector thereof.
  • i: Cluster index or range.
  • X: Data matrix (used for slicing risk measures).

Returns

Related

source
PortfolioOptimisers.factoryMethod
factory(a::Union{Nothing, <:AbstractEstimator, <:AbstractAlgorithm,
                 <:AbstractResult}, args...; kwargs...) -> a
factory(a::AbstractVector{<:Union{Nothing, <:AbstractEstimator, <:AbstractAlgorithm,
                                  <:AbstractResult}}, args...; kwargs...) -> Vector

No-op factory function for constructing objects with a uniform interface.

Defining methods which dispatch on the first argument allows for a consistent factory interface across different types.

factory and port_opt_view are the two propagation mechanisms in this library. They are duals: factory threads runtime values (prior moments, observation weights, previous portfolio weights) down through a composed struct tree; port_opt_view threads an index selection (a subset of assets or observations) down through the same tree.

The vector method is the one forwarding contract for every vector-valued propagation field: it applies factory to each element and forwards args... and kwargs... unchanged, so a family that admits a vector of estimators, algorithms, or results needs no method of its own. A family that needs more than the forward, such as a concrete element type (concrete_typed_array_if_abstract), defines its own more specific method.

Algorithm

The scalar method:

  1. Return a unchanged, and drop args... and kwargs.... This method is the leaf of the recursion, and it is what makes an untagged type safe to call the verb on.

The vector method:

  1. For each element ai of a, call factory on ai, and forward args... and kwargs... unchanged.
  2. Collect the results into a new vector, in the order of a, and return it.

A @propagatable struct with at least one @fprop- or @wprop-tagged field carries a generated method that dominates the scalar method. That method rebuilds the struct with its keyword constructor, sending each @fprop field through factory_child and each @wprop field through _wprop.

Arguments

  • a: Indicates no object should be constructed, or a vector whose elements are rebuilt one by one.
  • args...: Arbitrary positional arguments (ignored by the scalar method, forwarded by the vector method).
  • kwargs...: Arbitrary keyword arguments (ignored by the scalar method, forwarded by the vector method).

Returns

  • a: The input unchanged.
  • v::Vector: The element-wise rebuilds, for the vector method.

Examples

julia> factory(nothing, 1, 2; x = 3)julia> factory(MeanValue())MeanValue  w ┴ nothing

Related

source
factory(res::NonFiniteAllocationOptimisationResult, fb::Option{<:OptE_Opt_FbChain})

Rebuild a continuous optimisation result with an updated fallback record fb.

Every optimisation result carries fb as its last field, so the generic rebuild copies all fields unchanged except the trailing fb. Concrete result types may override this method when rebuilding requires more than swapping fb. optimise is the one caller, and it hands in the FbChain it walked.

Related

source
factory(
    opt::Union{NonFiniteAllocationOptimisationEstimator, NonFiniteAllocationOptimisationResult},
    _
) -> RandomWeighted{_A, var"#s185", _B, _C, _D, _E, _F, Bool} where {_A, var"#s185"<:AbstractRNG, _B, _C, _D, _E, _F}

Return opt unchanged.

Default pass-through factory for optimisation estimators and results. Overridden for estimators that carry parameters requiring update at each optimisation step.

Related

source
PortfolioOptimisers.port_opt_viewMethod
port_opt_view(
    sh::SchurComplementHierarchicalRiskParity,
    i,
    X::AbstractMatrix{<:Union{var"#s136", var"#s53"} where {var"#s136"<:Number, var"#s53"<:AbstractJuMPScalar}},
    args...
) -> SchurComplementHierarchicalRiskParity{HierarchicalOptimiser{__T_pe, __T_cle, __T_slv, __T_wb, __T_fees, __T_sets, __T_wf, __T_brt, __T_x_src, __T_strict, __T_cache}} where {__T_pe, __T_cle, __T_slv, __T_wb, __T_fees, __T_sets, __T_wf, __T_brt, __T_x_src, __T_strict, __T_cache}

Return a view of SchurComplementHierarchicalRiskParity sh sliced to asset indices i.

Related

source
PortfolioOptimisers.optimiseMethod
optimise(sh::SchurComplementHierarchicalRiskParity{<:Any, <:Any, Nothing},
         rd::ReturnsResult; dims::Int = 1, kwargs...) -> SchurComplementHierarchicalRiskParityResult

Run the Schur Complement Hierarchical Risk Parity portfolio optimisation.

Arguments

  • sh: The Schur complement hierarchical risk parity optimiser to use.
  • rd: The returns result to use. If isa(sh.opt.pe, AbstractPriorResult), rd is not necessary if doing a standalone optimisation, but may be required/desired by fallbacks and/or clusterisation.
  • dims: The dimension along which observations advance in time.
  • kwargs: Additional keyword arguments passed to the optimisation function.

Details

Unlike HierarchicalEqualRiskContribution and NestedClustered, this optimiser accepts no branchorder keyword. Recursive bisection allocates by splitting the dendrogram's leaf permutation, so that permutation is the algorithm's input rather than a presentation detail, and the clusterisation always runs with the optimal ordering. A branchorder passed here is absorbed by kwargs and ignored.

Validation

  • No field in the tree of sh holds an Online. An ArgumentError naming the field is thrown otherwise, through assert_batch_entry: a plain optimise is a batch fit, and a wrapper resolves only at the warm-up of the fold loop's online arm.

Related

source

References