Linear Constraints (b)
PortfolioOptimisers.LinearConstraintEstimator — Type
struct LinearConstraintEstimator{__T_val, __T_key} <: AbstractConstraintEstimatorHolds the linear constraint equations to parse, and the universe key their names resolve against.
linear_constraints parses val and assembles the coefficient matrices of a LinearConstraint from it.
Fields
val: Constraint equation(s) to parse.
key: Key of the universe insets.dictthat names resolve against. Ifnothing, the caller reads the key fromsets.xkey, or from the key of another declared axis when the caller is written against that axis.
Constructors
LinearConstraintEstimator(; val::EqnType, key::Option{<:AbstractString} = nothing) -> LinearConstraintEstimatorKeywords correspond to the struct's fields.
Validation
!isempty(val), whenvalis a string or a vector.!isempty(key), whenkeyis notnothing.
Examples
julia> lce = LinearConstraintEstimator(; val = ["w_A + w_B == 1", "w_A >= 0.1"]);julia> sets = UniverseSets(; xkey = "nx", dict = Dict("nx" => ["w_A", "w_B"]));julia> linear_constraints(lce, sets)LinearConstraint ineq ┼ PartialLinearConstraint │ A ┼ 1×2 LinearAlgebra.Transpose{Float64, Matrix{Float64}} │ B ┴ Vector{Float64}: [-0.1] eq ┼ PartialLinearConstraint │ A ┼ 1×2 LinearAlgebra.Transpose{Float64, Matrix{Float64}} │ B ┴ Vector{Float64}: [1.0]Related
References
- [5] D. Cajas. Advanced Portfolio Optimization: A Cutting-edge Quantitative Approach (Springer Nature Switzerland, 2025). Section 9.1.
PortfolioOptimisers.replace_group_by_assets — Function
replace_group_by_assets(res::PR_VecPR, sets::UniverseSets, bl_flag::Bool = false,
ep_flag::Bool = false, rho_flag::Bool = false;
ledger::Option{<:AbstractVector} = nothing)Expand group or special variable references in a ParsingResult to their corresponding asset names.
A variable name of res can be a group name, a prior(...) expression or a correlation view such as (A, B). The function replaces each of them with the asset names that the UniverseSets holds. It gives the Black-Litterman mean expansion, the prior(...) views of entropy pooling and the correlation views. A vector of results gives a vector, one result per element.
Mathematical definition
\[\begin{align} c\, g &\to \sum_{j=1}^{k} c\, m_j\,, \\ c\, g &\to \sum_{j=1}^{k} \frac{c}{k}\, m_j\,. \end{align}\]
Where:
- $g$: A group name written in the equation.
- $m_j$: The $j$-th member of the group $g$.
- $k$: The number of members of the group $g$.
- $c$: The coefficient the group name carries.
The two lines are different operations. The first repeats the coefficient on every member, so the expanded row constrains the sum over the group. The second divides the coefficient by the member count, so the expanded row constrains the mean over the group. A group of one member is the only case in which the two agree.
Algorithm
- Copy
res.varsandres.coefintovariables_newandcoeffs_new, and open the empty accumulatorsvariables_tmp,coeffs_tmpandidx_rm. - For each variable name of
res.vars, match it against the prior patternprior(...)and against the correlation pattern(a, b). The four combinations of the two matches select steps 3 to 6. - A name matching neither pattern, with
rho_flagfalse, is a plain name. Look it up insets.dict, and leave it where it stands when the dictionary does not hold it, because a name that is not a group is already the name of one column. A group name sheds its departed members withshed_departed_members, then expands to what survived, each member carrying the coefficient the mathematics above gives over the surviving count, and the index of the group joinsidx_rm. A group that shed every member keeps its first member, so the row names a departed asset, andget_linear_constraintsdrops that row in silence. A group that holds no member expands to nothing, and its index joinsidx_rmall the same. - A name matching the correlation pattern expands to one entry naming the two member lists, and that entry carries the coefficient of the view unchanged. A correlation view is one row over a pair of universes, so no coefficient is spread over members. The two lists shed jointly, so a pair survives only when both of its names did.
- A name matching the prior pattern expands the name inside
prior(...)exactly as step 3 does, and wraps each member back inprior(...). - A name matching both patterns expands as step 4 does, and wraps each of the two member lists in
prior(...). - Return
resunchanged when nothing was struck, so an equation written in asset names costs no allocation. - Delete the entries at
idx_rmfromvariables_newandcoeffs_new, append the two accumulators to them, and render the expanded equation string. - Return the
ParsingResultbuilt from the new names and coefficients, together with the operator and the right-hand side ofres, which the expansion leaves untouched.
Arguments
res: AParsingResultobject containing variables and coefficients to be expanded.sets: AUniverseSetsobject specifying the asset universe and groupings.bl_flag: Selects which of the two expansions above runs.falsetakes the first, which constrains the sum over the group.truetakes the second, the Black-Litterman-style expansion, which constrains the mean.ep_flag: Iftrue, the function expandsprior(...)expressions, for entropy pooling.rho_flag: Iftrue, the function expands correlation views(A, B), for entropy pooling.ledger: The door's ledger of departure casualties, ornothingwhen nobody is collecting. A shed group is recorded into it throughrecord_group_shed!.
Validation
The three flags are not independent, and five guards hold the grammar they describe.
bl_flagcan only betrueif bothep_flagandrho_flagarefalse.rho_flagcan only betrueifep_flagis alsotrue.- The pattern
(a, b)can only be used whenep_flagandrho_flagare bothtrue. - The pattern
prior(a)can only be used whenep_flagistrue. - The pattern
prior(a, b)can only be used whenrho_flagistrue.
Two further guards hold the shape of a correlation view.
- A correlation view is written
(a, b), and a correlation view prior is writtenprior(a, b). - Both sides of a correlation view name a group that
sets.dictholds, and the two groups have the same number of members. A view whose two sides are both absent fromsets.dictis skipped instead of raised on.
Returns
res::ParsingResult: A newParsingResultwith all group and special variable references expanded to asset names.
Examples
julia> sets = UniverseSets(; xkey = "nx", dict = Dict("nx" => ["A", "B", "C"], "group1" => ["A", "B"]));julia> res = parse_equation("group1 + 2C == 1")ParsingResult vars ┼ Vector{String}: ["group1", "C"] coef ┼ Vector{Float64}: [1.0, 2.0] op ┼ String: "==" rhs ┼ Float64: 1.0 eqn ┴ SubString{String}: "group1 + 2.0*C == 1.0"julia> replace_group_by_assets(res, sets)ParsingResult vars ┼ Vector{String}: ["C", "A", "B"] coef ┼ Vector{Float64}: [2.0, 1.0, 1.0] op ┼ String: "==" rhs ┼ Float64: 1.0 eqn ┴ String: "2.0*C + A + B == 1.0"Related
PortfolioOptimisers.parse_equation — Function
parse_equation(eqn::EqnType;
ops1::Tuple = ("==", "<=", ">="), ops2::Tuple = (:call, :(==), :(<=), :(>=)),
datatype::DataType = Float64, kwargs...)Parse a linear constraint equation from a string into a structured ParsingResult.
An equation string can come from outside the program, so both forms of input meet a limit of EQUATION_LIMITS[] before any recursive walk. The function caps the length of the string form before Meta.parse runs, and no length limit applies to the Expr form. It then caps the depth of the expression tree of both forms, so one number bounds the recursion for either form.
Julia parses the equation, so the number syntax of Julia applies. 2e1 is the number 20.0 and 2f1 is the number 20.0f0, and neither is a coefficient times a variable e1 or f1. Write 2*f1 for a variable whose name starts like an exponent.
Algorithm
The method that Julia selects is the algorithm, and one method handles each shape of eqn.
- When
eqnis a vector, apply this function to each element, and return the vector of results. - When
eqnis a string, check its length againstEQUATION_LIMITS[].max_length, and refuse the pattern++. - Find the first operator of
ops1that occurs in the string, givingopstr, and split the string on it intolhsandrhs. - Parse both parts with
Meta.parse, givinglexprandrexpr, check each withrethrow_parse_error, and check the depth of each againstEQUATION_LIMITS[].max_depthwith_expr_depth_exceeds. - When
eqnis anExpr, check its depth againstEQUATION_LIMITS[].max_depthwith_expr_depth_exceeds, and refuse a++pattern withhas_invalid_plus. - Check that the head of the expression is a call and is exactly one operator of
ops2, givingopstr, and readlhsandrhsoff the arguments of the call. - Give
opstrand the two sides to_parse_equation, which puts them in canonical form and builds theParsingResult.
Arguments
eqn: The equation string to parse.eqn::AbstractVector: Each element needs to meet the criteria below.eqn::AbstractString: Must contain exactly one comparison operator fromops1.ops1: Tuple of valid comparison operators as strings.
eqn::Expr: Must contain exactly one comparison operator fromops2.ops2: Tuple of valid comparison operator expressions.
datatype: The numeric type to use for coefficients and right-hand side.kwargs...: Additional keyword arguments, ignored.
Validation
length(eqn) <= EQUATION_LIMITS[].max_length, for the string form. AMeta.ParseErrornaming both lengths is thrown otherwise.- The expression tree of
eqnis no deeper thanEQUATION_LIMITS[].max_depth, for both forms. The string form is checked afterMeta.parse, on each side of the operator. AMeta.ParseErrornaming the limit is thrown otherwise. eqnholds no++pattern.eqnholds exactly one comparison operator, fromops1for the string form and fromops2for theExprform.- The head of the
Exprform is a call. - Neither side of the equation is empty or incomplete, which
rethrow_parse_errorchecks.
Returns
If
eqn::Str_Expr:res::ParsingResult: Structured parsing result.
If
eqn::AbstractVector:res::Vector{ParsingResult}: Vector of structured parsing results.
Examples
julia> parse_equation("w_A + 2w_B <= 1")ParsingResult vars ┼ Vector{String}: ["w_A", "w_B"] coef ┼ Vector{Float64}: [1.0, 2.0] op ┼ String: "<=" rhs ┼ Float64: 1.0 eqn ┴ SubString{String}: "w_A + 2.0*w_B <= 1.0"Related
PortfolioOptimisers.linear_constraints — Function
linear_constraints(lcs::Option{<:LinearConstraint}, args...; kwargs...)
linear_constraints(lcs::AbstractVector{<:LinearConstraint}, ::Nothing, args...; kwargs...)Return an assembled LinearConstraint, nothing, or a vector of assembled constraints unchanged.
A constraint slot can hold equations or an assembled LinearConstraint, so a caller can call linear_constraints on the slot with no check of its type.
The vector method takes a nothing universe and no other. A vector of assembled constraints needs no UniverseSets, and a Pipeline gives an optimiser this shape when more than one constraint step ran. With a real UniverseSets, a wider vector method takes the call and applies this method to each element.
Algorithm
- Return
lcs. Neither method reads its further positional arguments or its keywords.
Arguments
lcs: An existingLinearConstraintobject,nothing, or a vector of constraints.args...: Additional positional arguments (ignored).kwargs...: Additional keyword arguments (ignored).
Returns
lcs: The input, unchanged.
Related
linear_constraints(eqn::EqnType, sets::UniverseSets,
key::Option{<:AbstractString} = nothing;
ops1::Tuple = ("==", "<=", ">="),
ops2::Tuple = (:call, :(==), :(<=), :(>=)), datatype::DataType = Float64,
strict::Bool = false, bl_flag::Bool = false,
rr::Option{<:AbstractLoadingsRegressionResult} = nothing)Parse and convert one or more linear constraint equations into a LinearConstraint object.
The function parses one or more equations, given as strings, as expressions or as a vector of them. It expands the group names through the UniverseSets, builds the coefficient matrices, and returns a LinearConstraint that holds the equality rows and the inequality rows.
Algorithm
This method is the whole pipeline, and each step names the stage that owns it.
- Parse
eqnwithparse_equation, givinglcs, oneParsingResultper equation. Each result carries the equation in canonical form. - Expand every group name of
lcsinto its members withreplace_group_by_assets, giving results written in names of the universe.bl_flagselects which of the two expansions runs. - Assemble the coefficient matrices and the right-hand sides from
lcswithget_linear_constraints, which resolves each name against the universekeynames and separates the equality rows from the inequality rows. - Return what
get_linear_constraintsgives, which is aLinearConstraint, ornothingwhen no row survived.
Arguments
eqn: A single constraint equation (asAbstractStringorExpr), or a vector of such equations.sets: AUniverseSetsobject specifying the asset universe and groupings.ops1: Tuple of valid comparison operators as strings.ops2: Tuple of valid comparison operators as expression heads.datatype: Numeric type for coefficients and right-hand side.strict: Iftrue, a variable name that the universe does not hold throws. Iffalse, it warns.bl_flag: Iftrue, a group expands to the mean over its members, as the Black-Litterman views need. Iffalse, it expands to the sum.key: Key naming the universe the variables resolve against. Defaults tosets.xkey.rr: Loadings to re-base through, ornothingfor an ordinary asset-space constraint.
Validation
- Every stage validates its own input:
parse_equationthe equation text,replace_group_by_assetsthe flag grammar, andget_linear_constraintsthe names against the universe.
Returns
lcs::LinearConstraint: An object containing the assembled equality and inequality constraints, ornothingif no constraints are present.
Examples
julia> sets = UniverseSets(; xkey = "nx", dict = Dict("nx" => ["w_A", "w_B", "w_C"]));julia> linear_constraints(["w_A + w_B == 1", "w_A >= 0.1"], sets)LinearConstraint ineq ┼ PartialLinearConstraint │ A ┼ 1×3 LinearAlgebra.Transpose{Float64, Matrix{Float64}} │ B ┴ Vector{Float64}: [-0.1] eq ┼ PartialLinearConstraint │ A ┼ 1×3 LinearAlgebra.Transpose{Float64, Matrix{Float64}} │ B ┴ Vector{Float64}: [1.0]Related
linear_constraints(lcs::LinearConstraintEstimator, sets::UniverseSets;
datatype::DataType = Float64, strict::Bool = false,
bl_flag::Bool = false,
rr::Option{<:AbstractLoadingsRegressionResult} = nothing,
rd::Option{<:ReturnsResult} = nothing)
linear_constraints(lcs::VecLcE, sets::UniverseSets;
datatype::DataType = Float64, strict::Bool = false,
bl_flag::Bool = false,
rr::Option{<:AbstractLoadingsRegressionResult} = nothing,
rd::Option{<:ReturnsResult} = nothing)Parse the equations a LinearConstraintEstimator carries, against the universe key that estimator names.
The method reads val and key from the estimator and gives both to the equation method, so a single estimator and a vector of estimators have one interface. A vector gives a vector of the same length, one result per element.
The method takes rr, so that a caller that holds loadings, such as processed_jump_optimiser_attributes, can pass them to the value of lcse with no check of its type. A bare LinearConstraintEstimator ignores them. The asset frame is the absence of a re-basis, and an estimator that re-based itself because loadings were available makes the space depend on the prior and not on what the user wrote. Only a wrapper ExposureConstraintEstimator asks for a re-basis. The method takes rd for the same reason and ignores it for a stronger one. Only a constraint space can ask for a refit, and a bare estimator has no space.
Algorithm
- Read
valandkeyfromlcs. - Ignore
rrandrd, for the reason that the paragraph above gives. - Return the
LinearConstraintthat the equation method builds fromval,setsandkey. - Apply steps 1 to 3 to each element, and return the vector of results, when
lcsis a vector.rrandrdreach every element, and every element ignores them.
Arguments
lcs: TheLinearConstraintEstimatorto parse, or a vector of them.sets: AUniverseSetsobject specifying the asset universe and groupings.datatype: Numeric type for coefficients and right-hand side.strict: Iftrue, a variable name that the universe does not hold throws. Iffalse, it warns.bl_flag: Iftrue, a group expands to the mean over its members, as the Black-Litterman views need. Iffalse, it expands to the sum.rr: Accepted and dropped. A bare estimator never re-bases.rd: Accepted and dropped. A bare estimator never asks for a refit.
Returns
lcs::Option{<:LinearConstraint}: The assembled constraint, ornothingwhen no row survived. A vector input gives one such value per element.
Related
linear_constraints(lcs::ExposureConstraintEstimator, sets::UniverseSets;
datatype::DataType = Float64, strict::Bool = false,
bl_flag::Bool = false,
rr::Option{<:AbstractLoadingsRegressionResult} = nothing,
rd::Option{<:ReturnsResult} = nothing)Generate the asset-space constraint a re-based one is equivalent to.
Validates the space's basis once via constraint_space_basis, then re-bases the wrapped shape. What comes back is an ordinary LinearConstraint — or a vector of them, when a vector was wrapped — indistinguishable from one written in asset names, which is why nothing downstream of constraint generation needs to know a re-basis happened.
rd is the returns a space may refit its basis from. It is nothing here, which is the standalone route: a space whose re is an estimator throws rather than refitting, and the message names the fixes. See factor_space_regression.
Arguments
lcs: TheExposureConstraintEstimatorwhose rows are re-based.sets: The declared universe, carrying the factor axisrrnames, undersets.tfkeyorsets.cfkey.datatype: Data type of the assembled row.strict: Iftrue, a name the universe does not resolve throws; iffalse, it warns and the term is dropped.bl_flag: Iftrue, enables Black-Litterman-style group expansion.rr: The loadings, when the caller holds them and the space states none.rd: Returns the space may refit from.nothingon this route.
Returns
lc: An asset-spaceLinearConstraint,nothingwhen every row was dropped, or a vector of either when a vector was wrapped.
Related
linear_constraints(lcs::VecEcE_LcE_Lc, sets::UniverseSets; datatype::DataType = Float64,
strict::Bool = false, bl_flag::Bool = false,
rr::Option{<:AbstractLoadingsRegressionResult} = nothing,
rd::Option{<:ReturnsResult} = nothing)Broadcast over a vector that may mix re-based and asset-space constraints, forwarding the loadings and the returns to each. The narrower VecLcE method still wins for a vector that holds only LinearConstraintEstimators.
Each element resolves its own basis, so a vector may mix a space that reads the prior with one that states or refits its own.
Arguments
lcs: The vector of shapes, re-based and asset-space mixed.sets: The declared universe the names resolve against.datatype: Data type of the assembled rows.strict: Iftrue, a name the universe does not resolve throws; iffalse, it warns and the term is dropped.bl_flag: Iftrue, enables Black-Litterman-style group expansion.rr: The loadings, forwarded to every element.rd: Returns an element's space may refit from, forwarded to every element.
Returns
lcs: One result per entry of the input, in the order of the input. An entry is aLinearConstraintornothing.
Related
PortfolioOptimisers.port_opt_view — Method
port_opt_view(lc::LinearConstraint, i, args...) -> LinearConstraintReturn a precomputed LinearConstraint unchanged under an asset sub-selection.
A row over the full universe does not mean the same thing over a subset. The method returns the constraint unchanged all the same, because a slice of A changes what each row states, and a matrix holds no names that a view can re-resolve. A NestedClustered inner solve refuses a bare precomputed constraint for this reason. Stacking and SubsetResampling have no such guard.
A constraint that reaches a meta-optimiser through an ExposureConstraintEstimator is a different case. Its A has one column per factor, and the optimiser projects it again against the loadings of the viewed prior, so the view applies to the basis and not to the row.
Algorithm
- Return
lc. The method reads neither the index nor the tail that follows it.
Arguments
lc: The precomputedLinearConstraint.::Any: The asset index selection (ignored).args...: Additional positional arguments (ignored).
Returns
lc::LinearConstraint: The input, unchanged.
Related
References
- [5]
- D. Cajas. Advanced Portfolio Optimization: A Cutting-edge Quantitative Approach (Springer Nature Switzerland, 2025).