Cross-Sectional Weights
Types
PortfolioOptimisers.AbstractCrossSectionalWeightsAlgorithm — Type
abstract type AbstractCrossSectionalWeightsAlgorithm <: AbstractAlgorithmAbstract supertype for all cross-sectional regression weight policy types.
A member says what weight an asset carries in the cross-sectional fit of an observation, and whether that weight is decided once or twice. A one-pass member reads the cross-section alone. A two-pass member reads the residuals of a first fit, so the caller runs the regression again with the weights the member returns.
All concrete and/or abstract types representing cross-sectional weight policies should be subtypes of AbstractCrossSectionalWeightsAlgorithm.
Interfaces
In order to implement a new concrete type that works seamlessly with the library, subtype AbstractCrossSectionalWeightsAlgorithm and implement the following methods:
cs_weights_initial
cs_weights_initial(alg::AbstractCrossSectionalWeightsAlgorithm, mcap::Option{<:MatNum}, mask::AbstractMatrix{Bool}): Returns the weights of the first pass.
Arguments
alg: The concrete subtype instance.mcap: Market capitalisation matrixobservations × assets, ornothing.mask: Eligibility maskobservations × assets.
Returns
W0::Matrix{<:Number}: First-pass weightsobservations × assets, zero outsidemask.
cs_weights_refine
cs_weights_refine(alg::AbstractCrossSectionalWeightsAlgorithm, W0::MatNum, eps::MatNum, ve::AbstractCovarianceEstimator, mask::AbstractMatrix{Bool}; kwargs...): Returns the weights of the second pass. A member whoseneeds_second_passisfalseneeds no method, because the caller never calls it.
Arguments
alg: The concrete subtype instance.W0: First-pass weightsobservations × assets.eps: First-pass residual matrixobservations × assets.ve: Variance estimator.mask: Eligibility maskobservations × assets.
Returns
W1::Matrix{<:Number}: Second-pass weightsobservations × assets, zero outsidemask.
needs_second_pass
needs_second_pass(alg::AbstractCrossSectionalWeightsAlgorithm): Returnstruewhen the caller must fit twice. The root answersfalse, so a one-pass member needs no method.
Arguments
alg: The concrete subtype instance.
Returns
val::Bool:truewhencs_weights_refineis called,falseotherwise.
Examples
julia> struct MyWeights <: PortfolioOptimisers.AbstractCrossSectionalWeightsAlgorithm endjulia> PortfolioOptimisers.needs_second_pass(MyWeights())falseRelated
PortfolioOptimisers.MarketCapWeights — Type
struct MarketCapWeights{__T_p} <: AbstractCrossSectionalWeightsAlgorithmWeights an asset by a power of its market capitalisation, in one pass.
The power sets how concentrated the cross-section is: 0 gives every eligible asset the same weight, 1 gives raw capitalisation weights, and a value between the two shrinks the concentration of the largest assets.
Mathematical definition
\[\begin{align} w_{t,i} &= \begin{cases} m_{t,i}^{p} & \text{if } (t, i) \in \mathcal{M} \\ 0 & \text{otherwise} \end{cases}\,. \end{align}\]
Where:
- $w_{t,i}$: Weight of asset $i$ at observation $t$.
- $m_{t,i}$: Market capitalisation of asset $i$ at observation $t$.
- $p$: Market capitalisation power.
- $\mathcal{M}$: Eligibility mask.
The weights are relative, so they need not sum to one. A weighted least squares is invariant to their scale.
Fields
p: Exponent applied to the market capitalisation. A value of0needs no capitalisation matrix, because every eligible asset takes the same weight.
Constructors
MarketCapWeights(; p::Real = 0.5) -> MarketCapWeightsKeywords correspond to the struct's fields.
Validation
isfinite(p).p >= 0.
Examples
julia> MarketCapWeights()MarketCapWeights p ┴ Float64: 0.5Related
PortfolioOptimisers.BlendedInverseVarianceWeights — Type
struct BlendedInverseVarianceWeights{__T_p, __T_lambda, __T_ratio, __T_wins} <: AbstractCrossSectionalWeightsAlgorithmBlends market capitalisation weights with inverse idiosyncratic variance weights, in two passes.
The blend is a two-step feasible generalised least squares: a first fit under capitalisation weights gives the residuals, a variance estimated from those residuals gives the second component, and the caller fits again. The variance paired with observation t reads residuals up to t - 1 only, so a weight never reads the return it multiplies.
Mathematical definition
\[\begin{align} u_{t,i} &= \min\left(\mathrm{clip}\left(\frac{1}{v_{t-1,i}},\, q_{t}^{\mathrm{lo}},\, q_{t}^{\mathrm{hi}}\right),\, \rho \, \mathrm{med}_{j}\left(u_{t,j}\right)\right) \\ w_{t,i} &= \lambda \frac{u_{t,i}}{\sum_{j} u_{t,j}} + \left(1 - \lambda\right) \frac{m_{t,i}^{p}}{\sum_{j} m_{t,j}^{p}}\,. \end{align}\]
Where:
- $w_{t,i}$: Weight of asset $i$ at observation $t$.
- $v_{t-1,i}$: Idiosyncratic variance of asset $i$ after observation $t - 1$.
- $q_{t}^{\mathrm{lo}}$, $q_{t}^{\mathrm{hi}}$: Cross-sectional quantiles of the inverse variances at the levels
wins. - $\rho$: Median ratio cap.
- $\lambda$: Blend coefficient.
- $m_{t,i}$: Market capitalisation of asset $i$ at observation $t$.
- $p$: Market capitalisation power.
Both components are normalised over the same eligible set before the blend, so the realised blend equals the nominal $\lambda$. An observation with no variance estimate yet takes the capitalisation weights alone.
Fields
p: Exponent applied to the market capitalisation of the first blend component. A value of0needs no capitalisation matrix, because every eligible asset takes the same weight.
lambda: Blend coefficient. It is the share of the inverse variance component in the second-pass weights, so0recovers the capitalisation weights and1drops them.
ratio: Largest multiple of the cross-sectional median an inverse variance weight may take. It caps the influence of an asset whose estimated variance is very small.
wins: Lower and upper quantile levels of the cross-sectional winsorisation of the inverse variances, applied before the median cap.
Constructors
BlendedInverseVarianceWeights(; p::Real = 0.5, lambda::Real, ratio::Real = 20.0, wins::Tuple{<:Real, <:Real} = (0.025, 0.975)) -> BlendedInverseVarianceWeightsKeywords correspond to the struct's fields. lambda takes no default, because naming this member is the request for the second pass. A caller that wants one pass writes MarketCapWeights instead.
Validation
isfinite(p)andp >= 0.0 <= lambda <= 1.isfinite(ratio)andratio > 0.0 <= wins[1] < wins[2] <= 1.
Examples
julia> BlendedInverseVarianceWeights(; lambda = 0.5)BlendedInverseVarianceWeights p ┼ Float64: 0.5 lambda ┼ Float64: 0.5 ratio ┼ Float64: 20.0 wins ┴ Tuple{Float64, Float64}: (0.025, 0.975)Related
Functions
PortfolioOptimisers.cs_weights_initial — Function
cs_weights_initial(alg::MarketCapWeights, mcap::Option{<:MatNum},
mask::AbstractMatrix{Bool}) -> Matrix{<:Number}
cs_weights_initial(alg::BlendedInverseVarianceWeights, mcap::Option{<:MatNum},
mask::AbstractMatrix{Bool}) -> Matrix{<:Number}Return the first-pass cross-sectional regression weights of a weight policy.
Both members read the capitalisation the same way, so both call cross_sectional_cap_weights. They part on what the caller does next: needs_second_pass is false for one and true for the other.
Arguments
alg: Cross-sectional weight policy.mcap::Option{<:MatNum}: Market capitalisation matrixobservations × assets, ornothingwhen the policy'spis zero.mask::AbstractMatrix{Bool}: Eligibility maskobservations × assets.
Validation
- The rules of
cross_sectional_cap_weights.
Returns
W0::Matrix{<:Number}: First-pass weightsobservations × assets, zero outsidemask.
Examples
julia> PortfolioOptimisers.cs_weights_initial(MarketCapWeights(; p = 1.0), [2.0 3.0 5.0], [true true false])1×3 Matrix{Float64}: 2.0 3.0 0.0Related
PortfolioOptimisers.cs_weights_refine — Function
cs_weights_refine(alg::BlendedInverseVarianceWeights, W0::MatNum, eps::MatNum,
ve::AbstractCovarianceEstimator, mask::AbstractMatrix{Bool};
kwargs...) -> Matrix{<:Number}Return the second-pass cross-sectional regression weights of a weight policy.
The verb takes the first-pass weights and residuals as arguments rather than reading them off the policy, because a weight policy is configuration and holds no fitted data. A caller runs it only when needs_second_pass answers true. An observation whose eligible first-pass weights sum to zero returns NaN rather than a weight, because both components are normalised. An eligible pair carries a positive weight whenever alg.p is zero or its market capitalisation is positive, so a caller reaches that state only by handing in a weight matrix that cs_weights_initial did not build.
Algorithm
- Take the lagged inverse variances through
cross_sectional_lagged_inverse_variance. - Clamp them to the cross-sectional quantiles through
cross_sectional_winsorise!. - Cap and normalise them through
cross_sectional_median_cap!, which also names the observations that carry an estimate. - Normalise the first-pass weights over the same universe.
- Write zero over every entry that is still
NaN, so a missing entry does not contribute. - Write the normalised first-pass weights into every observation that carries no estimate.
- Blend the two components by
alg.lambda, and write zero outsidemask.
Arguments
alg: Cross-sectional weight policy.W0::MatNum: First-pass weightsobservations × assets.eps::MatNum: First-pass residual matrixobservations × assets.ve: Variance estimator.mask::AbstractMatrix{Bool}: Eligibility maskobservations × assets.kwargs...: Additional keyword arguments passed tovariance_series.
Validation
size(W0) == size(eps).all(isfinite, W0)andall(x -> x >= 0, W0).- The rules of
cross_sectional_lagged_inverse_variance, which holdepsagainstmask.
Returns
W1::Matrix{<:Number}: Second-pass weightsobservations × assets, zero outsidemask.
Examples
julia> eps = [1.0 2.0; 3.0 6.0; 2.0 4.0];julia> alg = BlendedInverseVarianceWeights(; p = 0.0, lambda = 1.0);julia> W0 = PortfolioOptimisers.cs_weights_initial(alg, nothing, trues(3, 2));julia> PortfolioOptimisers.cs_weights_refine(alg, W0, eps, SimpleVariance(), trues(3, 2))3×2 Matrix{Float64}: 0.5 0.5 0.5 0.5 0.785 0.215Related
PortfolioOptimisers.needs_second_pass — Function
needs_second_pass(alg::AbstractCrossSectionalWeightsAlgorithm) -> BoolReturn whether a weight policy asks the caller to fit the cross-sectional regression twice.
The trait keeps the fit loop in the caller, which reads top to bottom, so the weight family and the regression family never name each other's types. It follows needs_previous_weights, which the tracking family uses the same way.
Arguments
alg: Cross-sectional weight policy.
Returns
val::Bool:truewhen the caller must runcs_weights_refineand fit again,falsewhen the first-pass weights are the answer.
Examples
julia> PortfolioOptimisers.needs_second_pass(MarketCapWeights())falsejulia> PortfolioOptimisers.needs_second_pass(BlendedInverseVarianceWeights(; lambda = 0.5))trueRelated