Exposure Constraints

PortfolioOptimisers.AbstractConstraintSpaceType
abstract type AbstractConstraintSpace <: AbstractAlgorithm

Abstract supertype for the bases a linear constraint can be re-based into.

A constraint can be re-based into another basis if and only if it is a linear form in the weights. Under a change of basis $\boldsymbol{w}_b = \mathbf{P}^\intercal \boldsymbol{w}$, the row $\boldsymbol{a}$ becomes $\mathbf{P}\boldsymbol{a}$, and nothing else about the problem changes.

The boundary is a property of the mechanism: a re-basis rewrites a row and leaves the model untouched, so a constraint that reaches the model through its own variables is outside it even where a change of basis is well defined for the quantity constrained. Cardinality, sub-group cardinality and buy-in thresholds index the binary held-indicators rather than the weights. A per-asset weight box has no counterpart because $lb \leq \mathbf{P}^\intercal\boldsymbol{w} \leq ub$ is a linear constraint and already has a home. Turnover and tracking error are norm forms: $\lVert \mathbf{P}^\intercal(\boldsymbol{w} - \boldsymbol{w}_0) \rVert$ is meaningful, but it needs its own variables and cones, so it is re-basable in mathematics and not by this mechanism. A fee is priced per traded position and is subtracted from the return, so it is not a constraint on the weights at all. The list illustrates the rule rather than exhausting it, and these absences are a property of the constraint, not a gap in the implementation; see ADR 0047 and its 2026-08-16 amendment.

Tracking a factor needs no re-basis: ReturnsTracking takes a benchmark return series, and a factor's return series is a column of the factor matrix, so it is passed directly.

The family has one member today, FactorSpace, and the abstract type exists so it can gain more: any linear change of basis in the weights — currency, sector, a caller-supplied exposure table — lands here without a new pathway.

There is deliberately no AssetSpace. The asset frame is the absence of a re-basis, spelled by using a bare LinearConstraintEstimator; an AssetSpace member would make ExposureConstraintEstimator a no-op decorator computing bit-for-bit what it wraps.

Related

source
PortfolioOptimisers.FactorSpaceType
struct FactorSpace{__T_re} <: AbstractConstraintSpace

The factor basis: a constraint written in factor names, re-based through a regression's loadings.

Mathematical definition

\[\begin{align} \boldsymbol{w}_f &= \mathbf{M}^\intercal \boldsymbol{w} \\ \boldsymbol{a}^\intercal \boldsymbol{w}_f &= (\mathbf{M}\boldsymbol{a})^\intercal \boldsymbol{w}\,. \end{align}\]

Where:

  • $\mathbf{M}$: $Na \times Nf$ loadings matrix, Regression's M.
  • $\boldsymbol{w}$: Portfolio weights vector $N \times 1$.
  • $\boldsymbol{w}_f$: $Nf \times 1$ factor weights (exposures).
  • $\boldsymbol{a}$: $Nf \times 1$ row the user wrote in factor names.

The names resolve against the declared factor axis, sets.dict[sets.fkey], and the loadings are rr.M rather than rr.LM's columns are the named original factors, and a constraint must be written in names a user can put in an equation, whereas L's columns are principal components under DimensionReductionRegression. Risk decomposition reads L and is correct to; the two are the two sides of one projection, not a drift between conventions.

Fields

  • re: Source of the loadings the rows are re-based through, or nothing to read the prior's rr. A precomputed Regression states the basis outright; an estimator fits one from the returns, which is what makes a factor mandate legal on a prior that carries no factor block. The precedence is resolve_factor_regression's: a precomputed result wins, then the prior's rr, then a refit.

Constructors

FactorSpace(; re::Option{<:RegE_Reg} = nothing) -> FactorSpace

Keywords correspond to the struct's fields.

Where the loadings come from

A space carries the source of its own basis. re is that source, and its precedence is resolve_factor_regression's, shared with FactorRiskContribution and FactorRiskBudgeting:

  1. re when it is a precomputed Regression. The caller has stated the basis, and no data is needed.
  2. The prior's rr, when the prior carries a factor block. This is what re === nothing always resolves to, and it is the behaviour of every FactorSpace written before the field existed.
  3. regression(re, rd) otherwise — an estimator refits the loadings from the returns.

Arm 3 is a capability the field adds rather than a fallback: FactorSpace(; re = StepwiseRegression()) is a factor mandate on a prior that carries no loadings, which previously threw. It needs rd, so it is available at a JuMPOptimiser and as a Pipeline step, and refused on the standalone linear_constraints route, which receives no returns.

Warning

A precomputed re does not refit. The rows are re-based through exactly the loadings handed in, whatever data the problem is later solved on, and nothing at generation time can see that it is inside a cross-validation fold: the projection is silently stale rather than wrong-shaped, because the loadings are the right shape for the full universe. Two spellings already exist for a basis that must move — re = <an estimator>, which refits per fold and per subproblem, and a time-dependent schedule on lcse, which is already TD_Option and can carry a different basis per period. A stated result is also refused outright at a NestedClustered outer solve, where the universe is replaced by cluster names rather than sliced, so no view of the loadings can follow it.

Related

References

  • [4] D. Cajas. Advanced Portfolio Optimization: A Cutting-edge Quantitative Approach (Springer Nature Switzerland, 2025). Section 9.1, Equation 9.6.
source
PortfolioOptimisers.ExposureConstraintEstimatorType
struct ExposureConstraintEstimator{__T_lce, __T_space} <: AbstractConstraintEstimator

Decorator that re-bases a linear constraint into another basis.

ExposureConstraintEstimator wraps whatever lcse itself accepts — a LinearConstraintEstimator, a precomputed LinearConstraint, or a vector of either — and declares the basis its rows are written in. It wraps rather than reimplements, so equation parsing, val/key validation and group expansion are inherited, and the bound being exactly lcse's means no shape can reach the optimiser un-re-based.

The projection happens at generation time, so what leaves constraint generation is an ordinary asset-space LinearConstraint. It flows into the existing lcsr slot and the existing model assembly: there is no second constraint pathway, and Near Optimal Centering, time-dependent schedules and every optimiser sharing JuMPOptimiser get factor exposure constraints without knowing they exist.

The re-basis lives here and not on LinearConstraintEstimator on purpose. That type also feeds gcarde and sgcarde, which build rows over the binary held-indicators; a projected row is neither integral nor an index into them. Leaving it unmarked is what keeps it the only thing those slots admit, and therefore what makes the category error unrepresentable rather than validated. See ADR 0047.

Fields

  • lce: Wrapped linear constraint estimator(s) or precomputed constraint, written in the names of the space's basis. Exactly what lcse itself accepts, so no shape can reach the optimiser un-re-based.
  • space: Basis the wrapped constraint is written in. Required — the absence of a re-basis is spelled by using a bare LinearConstraintEstimator, not by a space member.

Constructors

ExposureConstraintEstimator(;    lce::LcE_Lc_VecLcE_Lc,    space::AbstractConstraintSpace) -> ExposureConstraintEstimator

Keywords correspond to the struct's fields. space is required and has no default: a re-basis is something the user asks for, never something inferred.

Validation

  • If lce is an AbstractVector, !isempty(lce).

Failure modes

The names are looked up on the factor axis, but the basis comes from the prior, so the two can disagree in ways a single object cannot check. All three are checked when the constraint is generated:

  • Missing loadings throw, ignoring strict. strict governs unknown names: a per-row, recoverable condition where the offending row is dropped and the rest of the problem is still the problem the caller described. A missing regression is not that — it makes every row unbuildable, and dropping them silently yields a feasible, plausible-looking portfolio carrying none of the requested exposure. "Missing" means no carrier holds any: the space can supply its own through FactorSpace's re, which is what makes a factor mandate legal on a prior with no factor block.
  • A missing factor axis throws, naming fkey. The axis is optional on UniverseSets; it is not optional for a constraint written against it.
  • size(rr.M, 2) == length(sets.dict[fkey]) always. The name-level cross-check against rd.nf needs the returns and lives at the optimiser.

As a pipeline step

An ExposureConstraintEstimator is also usable as a bare Pipeline step, where it reads the prior slot for its basis and writes an ordinary asset-space LinearConstraint into constraints. The factor names resolve against the nf axis pipeline_asset_sets builds from rd.nf, so the axis and the loadings agree by construction.

Warning

A step-generated constraint is pinned to the pipeline's prior. The projection happens once, when the step runs, and a downstream optimiser that refits its own prior receives rows computed against the loadings the step saw — under cross_val_predict that is the loadings of whatever prior step preceded it in the same fold, which is right only if the optimiser shares that prior. Passing the estimator to a JuMPOptimiser's lcse field instead re-projects it with the optimiser's own prior, and is the default advice for a factor mandate. This is the same trade-off a phylogeny constraint step already makes.

Examples

julia> sets = UniverseSets(; dict = Dict("nx" => ["A", "B", "C"], "nf" => ["MTUM", "VLUE"]));julia> rr = Regression(; M = [1.0 0.0; 0.5 0.5; 0.0 1.0]);julia> ece = ExposureConstraintEstimator(; lce = LinearConstraintEstimator(; val = "MTUM <= 0.3"),                                         space = FactorSpace());julia> lcr = linear_constraints(ece, sets; rr = rr);julia> lcr.ineq.A1×3 transpose(::Matrix{Float64}) with eltype Float64: 1.0  0.5  0.0julia> lcr.ineq.B1-element Vector{Float64}: 0.3

Related

References

  • [4] D. Cajas. Advanced Portfolio Optimization: A Cutting-edge Quantitative Approach (Springer Nature Switzerland, 2025). Section 9.1, Equation 9.6.
source
PortfolioOptimisers.port_opt_viewMethod
port_opt_view(space::FactorSpace, i, args...) -> FactorSpace

Return an asset-sliced copy of a FactorSpace.

Only the basis is data. re is sliced by port_opt_view's Regression method, which takes the rows of M belonging to i — the loadings are assets-major, so a subset of assets is a subset of rows and the projection over the subset is exact. A space that states no basis comes back bit-identical, because a nothing and an estimator both carry no universe.

This is why a space is viewed where the universe is sliced and refused where it is replaced: a NestedClustered outer solve writes cluster names over the asset universe, and no row-slice of asset loadings follows that. See assert_external_optimiser.

Related

source
PortfolioOptimisers.port_opt_viewMethod
port_opt_view(ece::ExposureConstraintEstimator, i, args...) -> ExposureConstraintEstimator

Return an asset-sliced copy of the lcse slot.

The wrapped constraint is passed through and the space is viewed. That split is the whole content of the method: the wrapped shape is written in the space's names, so an asset index means nothing to it, while the space's basis is the one thing in the slot that is indexed by asset.

A vector slot (VecEcE_LcE_Lc) is covered by the generic vector port_opt_view method, mixed or not, which views each element in turn instead of slicing the list of constraints by asset index.

Related

source
PortfolioOptimisers.factor_space_regressionFunction
factor_space_regression(re::Option{<:RegE_Reg}, rr::Option{<:AbstractRegressionResult},
                        rd::Option{<:ReturnsResult})
    -> Option{<:AbstractRegressionResult}

Apply resolve_factor_regression's precedence to the three carriers a FactorSpace can read its loadings from, and return nothing when none of them holds any.

The precedence is the one FactorRiskContribution already uses — a stated result, then the prior's own rr, then a refit — and the refit arm delegates to resolve_factor_regression rather than repeating it. What differs is the carriers: constraint generation is handed the loadings as rr, already read off the prior, so the prior arm is a plain argument rather than a field read, and both re and rd are optional here because a space that names no source and a route that carries no returns are both ordinary.

Returning nothing rather than throwing is deliberate: the diagnosis for "no basis anywhere" belongs to constraint_space_basis, which knows it is assembling a constraint.

Arguments

  • re::Option{<:RegE_Reg}: The space's stated basis source, or nothing to read rr.
  • rr::Option{<:AbstractRegressionResult}: The prior's loadings, or nothing.
  • rd::Option{<:ReturnsResult}: Returns to refit from, or nothing on the standalone route.

Validation

  • An estimator in re with no rd throws an IsNothingError naming both fixes.

Related

source
PortfolioOptimisers.constraint_space_basisFunction
constraint_space_basis(space::AbstractConstraintSpace, sets::UniverseSets, rr, rd)
    -> (basis, key)

Resolve the basis a re-based constraint projects through, and the key its names resolve against, throwing if either is unusable.

Every check a space needs before a single row is assembled lives here, so the assembly loop can assume a consistent basis. For FactorSpace that is: the loadings resolve from one of the three carriers factor_space_regression ranks, and — via factor_universe, shared with every other consumer of the declared axis — the factor axis is declared and agrees with those loadings on how many factors there are.

rd is the returns the space may refit from. It is threaded from the JuMPOptimiser and from the Pipeline step, and is nothing on the standalone route.

Related

source
PortfolioOptimisers.project_linear_constraintFunction
project_linear_constraint(lc::LinearConstraint, M::MatNum) -> LinearConstraint
project_linear_constraint(plc::PartialLinearConstraint, M::MatNum) -> PartialLinearConstraint

Re-base an already assembled constraint by projecting its coefficient matrix, A * transpose(M).

This is the precomputed-constraint half of the estimator path's per-term projection, and it is the one place linear_constraints stops being a pass-through for a LinearConstraint: a precomputed constraint reaching the optimiser through an ExposureConstraintEstimator was written in the wrapped basis and must still be re-based. The right-hand side is untouched — a change of basis acts on the row, not on the bound.

size(A, 2) == size(M, 2) is checked rather than assumed, because a precomputed constraint carries no names and nothing else would catch an asset-space matrix handed to a FactorSpace wrapper whose asset and factor counts happen to differ.

Related

source
PortfolioOptimisers.rebase_linear_constraintsFunction
rebase_linear_constraints(lce, sets::UniverseSets, basis, key; datatype, strict, bl_flag)

Re-base one wrapped shape. Dispatches on what ExposureConstraintEstimator is decorating:

Related

source

References

[4]
D. Cajas. Advanced Portfolio Optimization: A Cutting-edge Quantitative Approach (Springer Nature Switzerland, 2025).