Exposure Constraints
PortfolioOptimisers.AbstractConstraintSpace — Type
abstract type AbstractConstraintSpace <: AbstractAlgorithmAbstract 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.
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.
Interfaces
In order to implement a new basis which will work seamlessly with the library, subtype AbstractConstraintSpace with the source of its change of basis as its fields, and implement the following methods.
Basis resolution
PortfolioOptimisers.constraint_space_basis(space::AbstractConstraintSpace, sets::UniverseSets, rr, rd) -> (basis, key): The change of basis the rows project through, and the key their names resolve against.
There is no fallback. Every check the basis needs is made here, so that the assembly loop after it can assume a consistent basis, and a space that checks nothing states so rather than omitting the method.
Arguments
space: The basis.sets: The declared universe the names resolve against.rr: The prior's loadings, ornothing.rd: Returns the space may refit from, ornothing.
Returns
basis: The change of basis, in whatever formrebase_linear_constraintsreads for this space.key::AbstractString: The key ofsets.dictthe row's names resolve against.
Asset view
PortfolioOptimisers.port_opt_view(space::AbstractConstraintSpace, i, args...) -> AbstractConstraintSpace: An asset-sliced copy of the space.
A basis is indexed by asset on one side and by the space's own names on the other, so the generic fallback is wrong for every member of this family. FactorSpace's method is the reference: it slices the loadings by asset and leaves the factor axis whole.
Arguments
space: The basis.i: The asset index the view keeps.
Returns
space::AbstractConstraintSpace: The basis over the selected assets.
Related
PortfolioOptimisers.FactorSpace — Type
struct FactorSpace{__T_re} <: AbstractConstraintSpaceThe factor basis: a constraint written in factor names, re-based through a regression's loadings.
A space carries the source of its own basis in re. A precomputed Regression states the basis outright, an estimator refits it from the returns, and nothing reads the prior's own loadings. factor_space_regression ranks the three and owns the order.
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.
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} \\ \mathbf{A}\boldsymbol{w}_f \leq \boldsymbol{b} &\iff \left(\mathbf{A}\mathbf{M}^\intercal\right)\boldsymbol{w} \leq \boldsymbol{b}\,. \end{align}\]
Where:
- $\mathbf{M}$: $Na \times Nf$ loadings matrix,
Regression'sM. - $\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.
- $\mathbf{A}$: $Nc \times Nf$ coefficient matrix, one row per constraint.
- $\boldsymbol{b}$: $Nc \times 1$ right-hand side of those constraints.
The third line is the whole of the re-basis, and it says two things. A constraint written over the factors becomes a constraint over the assets when its coefficient matrix is post-multiplied by $\mathbf{M}^\intercal$. And $\boldsymbol{b}$ is the same on both sides: a change of basis acts on the row, never on the bound. An equality reads the same way, with $\leq$ replaced by $=$.
A factor exposure is $\mathbf{M}^\intercal\boldsymbol{w}$ and nothing more. It is defined without the intercept a Regression also carries, which appears in no line above. $\boldsymbol{b}$ above is the right-hand side of the constraints and is a different quantity from that intercept.
The names resolve against the factor axis the loadings themselves name — factor_axis_key reads sets.tfkey off a Regression and sets.cfkey off a CrossSectionalFactorModel, so a mandate written for one family can never resolve against the other's names. The loadings are rr.M rather than rr.L — M'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, ornothingto read the prior'srr. A precomputedRegressionstates 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 isresolve_factor_regression's: a precomputed result wins, then the prior'srr, then a refit.
Constructors
FactorSpace(; re::Option{<:RegE_Reg} = nothing) -> FactorSpaceKeywords correspond to the struct's fields.
Validation
reis bounded by its type,Option{<:RegE_Reg}, and by nothing else. The constructor checks no condition of its own. A value outside the bound — a matrix of loadings passed raw, for instance — raises aTypeErrorfrom the keyword constructor, so a space that is built is a space whose source is of a kind the resolution can read.
Related
AbstractConstraintSpaceExposureConstraintEstimatorUniverseSetsRegressionfactor_space_regression: ranks the three carriersrecompetes with, and states the order.resolve_factor_regression: the shared precedence, whichFactorRiskContributionandFactorRiskBudgetingread too.
References
- [5] D. Cajas. Advanced Portfolio Optimization: A Cutting-edge Quantitative Approach (Springer Nature Switzerland, 2025). Section 9.1, Equation 9.6.
PortfolioOptimisers.ExposureConstraintEstimator — Type
struct ExposureConstraintEstimator{__T_lce, __T_space} <: AbstractConstraintEstimatorDecorator 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.
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. Prefer the lcse field of a JuMPOptimiser to the step for a factor mandate: the step projects once, against the prior it saw, and the field re-projects against the optimiser's own prior, per fold. constraint_step_value states the trade-off in full.
Fields
lce: Wrapped linear constraint estimator(s) or precomputed constraint, written in the names of the space's basis. Exactly whatlcseitself 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 bareLinearConstraintEstimator, not by a space member.
Constructors
ExposureConstraintEstimator(; lce::LcE_Lc_VecLcE_Lc, space::AbstractConstraintSpace) -> ExposureConstraintEstimatorKeywords 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
lceis anAbstractVector,!isempty(lce).
The constructor checks that one condition and no more. Every check the basis needs is made when the constraint is generated, because the names sit on the factor axis while the basis comes from the prior, and a single object cannot see both. constraint_space_basis owns those three checks and states them.
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.3Related
AbstractConstraintSpaceFactorSpaceLinearConstraintEstimatorLinearConstraintlinear_constraintsUniverseSetsPipelineconstraint_space_basis: every check the basis needs, made when the constraint is generated.constraint_step_value: the pipeline step, and why a step-generated constraint is pinned to the pipeline's prior.pipeline_asset_sets: the factor axis the step's names resolve against.
References
- [5] D. Cajas. Advanced Portfolio Optimization: A Cutting-edge Quantitative Approach (Springer Nature Switzerland, 2025). Section 9.1, Equation 9.6.
PortfolioOptimisers.constraint_space_basis — Function
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.
Algorithm
- Resolve
rr, the loadings, withfactor_space_regressionover the three carriersspace.re,rrandrd. - Raise when
rrisnothing. No carrier holds a basis, and no row can be assembled. - Check the declared factor axis with
factor_universe, againstsize(rr.M, 2). The call raises and returns no value the assembly reads. - Return
rrand the factor axis keyfactor_axis_keyreads offrr, which is the key the row's names resolve against.
Arguments
space: TheFactorSpacewhose basis is resolved.sets: The declared universe, which must carry the factor axisrrnames, undersets.tfkeyorsets.cfkey.rr: The prior's loadings, ornothing.rd: Returns the space may refit from, ornothingon the standalone route.
Validation
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. That is why all three of these are checked here and not at construction.
- A missing basis throws, ignoring
strict.strictgoverns 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 throughFactorSpace'sre, which is what makes a factor mandate legal on a prior with no factor block. Raises anIsNothingError. - A missing factor axis throws, naming the key
factor_axis_keychose. The axis is optional onUniverseSets; it is not optional for a constraint written against it.factor_universeraises aKeyError. size(rr.M, 2) == length(sets.dict[key])always.factor_universeraises aDimensionMismatchotherwise. The name-level cross-check againstrd.nfneeds the returns and lives at the optimiser.
Returns
basis::AbstractLoadingsRegressionResult: The loadings the rows project through.key::AbstractString: The key the row's names resolve against,factor_axis_keyofrrfor aFactorSpace.
Related
PortfolioOptimisers.port_opt_view — Method
port_opt_view(space::FactorSpace, i, args...) -> FactorSpaceReturn 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.
Arguments
space: TheFactorSpaceto view.i: The asset index the view keeps.args...: Additional positional arguments, forwarded to theRegressionmethod.
Returns
space::FactorSpace: A space whosereholds the loadings of the selected assets, over the whole factor axis.
Related
PortfolioOptimisers.port_opt_view — Method
port_opt_view(ece::ExposureConstraintEstimator, i, args...) -> ExposureConstraintEstimatorReturn 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.
Arguments
ece: TheExposureConstraintEstimatorto view.i: The asset index the view keeps.args...: Additional positional arguments, forwarded to the space's method.
Returns
ece::ExposureConstraintEstimator: The same wrapped shape, over a viewed space.
Related
References
- [5]
- D. Cajas. Advanced Portfolio Optimization: A Cutting-edge Quantitative Approach (Springer Nature Switzerland, 2025).