Linear Constraints: private API
PortfolioOptimisers.VecLc — Type
const VecLc = AbstractVector{<:LinearConstraint}Every abstract vector whose elements are LinearConstraints. The group exists so that one method signature accepts a whole block of assembled constraints, which is what a caller holds after several constraint steps have each produced one.
Related
PortfolioOptimisers.Lc_VecLc — Type
const Lc_VecLc = Union{<:LinearConstraint, <:VecLc}One assembled LinearConstraint, or a vector of them. The group exists because a caller that ran one constraint step and a caller that ran several reach the same slot, so every method that reads that slot must accept both arities.
Related
PortfolioOptimisers.LcE_Lc — Type
const LcE_Lc = Union{<:LinearConstraintEstimator, <:LinearConstraint}An unparsed LinearConstraintEstimator, or an assembled LinearConstraint. The group exists because a constraint slot accepts both: linear_constraints parses the first and passes the second through untouched, so a caller may hand over equations or a block it built earlier.
Related
PortfolioOptimisers.VecLcE — Type
const VecLcE = AbstractVector{<:LinearConstraintEstimator}Every abstract vector whose elements are LinearConstraintEstimators. The group is narrower than VecLcE_Lc on purpose: every element still has to be parsed, so linear_constraints is broadcast over it and answers one constraint per element.
Related
PortfolioOptimisers.LcE_VecLcE — Type
const LcE_VecLcE = Union{<:LinearConstraintEstimator, <:VecLcE}One LinearConstraintEstimator, or a vector of them. The group excludes an assembled LinearConstraint, so a method that dispatches on it knows that every element still carries equations to parse.
Related
PortfolioOptimisers.VecLcE_Lc — Type
const VecLcE_Lc = AbstractVector{<:LcE_Lc}Every abstract vector whose elements are LcE_Lcs. The group exists so that one slot may hold a mixed list of equations still to parse and constraints already assembled.
Related
PortfolioOptimisers.LcE_Lc_VecLcE_Lc — Type
const LcE_Lc_VecLcE_Lc = Union{<:LcE_Lc, <:VecLcE_Lc}One LcE_Lc, or a vector of them. The group is the widest linear-constraint slot the library declares: it names every shape a user may write into such a field, so it is what the type bound of that field is written against.
Related
PortfolioOptimisers.VecPR — Type
const VecPR = AbstractVector{<:ParsingResult}Every abstract vector whose elements are ParsingResults. The group exists because parse_equation answers a vector of equations with a vector of results, and every stage after it is broadcast over that vector.
Related
PortfolioOptimisers.PR_VecPR — Type
const PR_VecPR = Union{<:ParsingResult, <:VecPR}One ParsingResult, or a vector of them. The group exists because an equation may be written singly or in a list, and every stage after parse_equation carries whichever arity it was given through to get_linear_constraints.
Related
PortfolioOptimisers.AbstractParsingResult — Type
abstract type AbstractParsingResult <: AbstractConstraintResultAbstract supertype for all equation parsing result types.
All concrete and/or abstract types representing parsing results should be subtypes of AbstractParsingResult. Every member carries one parsed equation in canonical form — the variable names, their coefficients, the comparison operator and the right-hand side — so that the stages after parse_equation read one shape whatever the equation was written in.
Related
PortfolioOptimisers.allowed_functions — Constant
allowed_functions = Dict{Symbol, Function}(:+ => +, :- => -, :* => *, :/ => /,
:^ => ^, :sqrt => sqrt, :cbrt => cbrt,
:exp => exp, :exp2 => exp2, :exp10 => exp10,
:log => log, :log2 => log2, :log10 => log10,
:abs => abs, :min => min, :max => max)Enumerated table of the functions permitted in equation parsing, mapping each allowed name directly to its function object. Evaluating constraint/view strings crosses a trust boundary (config files, spreadsheets, UI), so the parser must be able to call only these 16 mathematical functions. Using an explicit Symbol => Function table — rather than resolving a name against Base with getfield(Base, fname) — bounds that capability to exactly this table: a name absent from the keys fails closed with a Meta.ParseError, and the set of callable functions cannot drift from the set of allowed names, because they are the same list. See docs/adr/0025-enumerated-parser-allowlist.md.
The prior(...) marker is deliberately absent from this table: it names assets/groups (not numbers) and is expanded structurally by eval_numeric_functions/replace_group_by_assets, never evaluated numerically.
Related
PortfolioOptimisers.merge_partial_linear_constraints — Function
merge_partial_linear_constraints(
ps
) -> Union{Nothing, PartialLinearConstraint{var"#s185", <:AbstractVector{var"#s137"}} where {var"#s137"<:(Union{var"#s136", var"#s53"} where {var"#s136"<:Number, var"#s53"<:AbstractJuMPScalar}), var"#s185"<:AbstractMatrix{var"#s137"}, var"#s137"<:(Union{var"#s136", var"#s53"} where {var"#s136"<:Number, var"#s53"<:AbstractJuMPScalar})}}
Concatenate the rows of the same half of several PartialLinearConstraints, skipping the absent ones.
Algorithm
- Collect the entries of
psthat are notnothing, givingkept. - Return
nothingwhenkeptis empty, because the half is absent from every input. - Read the row width of the first entry of
kept, givingN, and check every other entry against it. - Stack the
Amatrices ofkeptin input order, and stack theirBvectors the same way. - Return the
PartialLinearConstraintbuilt from the two stacks.
Arguments
ps: The halves to concatenate, each aPartialLinearConstraintornothing.
Validation
- Every kept half is written over the same number of variables,
size(p.A, 2) == N. ADimensionMismatchis thrown otherwise.
Returns
- A
PartialLinearConstraint, ornothingwhen every input was absent.
Related
PortfolioOptimisers.merge_linear_constraints — Function
merge_linear_constraints(
lcs::AbstractVector{<:LinearConstraint}
) -> LinearConstraint
Combine several LinearConstraints into the single one that holds all their rows.
A LinearConstraint is a block of rows, and applying two blocks is the same as applying the block that stacks them — the inequality halves concatenate, the equality halves concatenate, and an absent half contributes nothing. This is exactly what generation already does when it is handed several estimators at once: centrality_constraints over a vector of CentralityConstraints appends every row into one result rather than returning one result per estimator.
That equivalence is what this function exists to preserve. A caller that computes its constraints separately — a Pipeline running one step per estimator — can merge them here and reach the optimiser with the value it would have had from the vector form.
Algorithm
- Return the one element unchanged when
lcsholds a single constraint. - Merge the
ineqhalf of every element withmerge_partial_linear_constraints, giving the inequality half of the result. - Merge the
eqhalf of every element the same way, giving the equality half. - Return the
LinearConstraintbuilt from the two halves.
Arguments
lcs: The constraints to merge.
Validation
lcsis non-empty.- Every merged half is written over the same number of variables.
Returns
lc::LinearConstraint: One constraint carrying every row, in input order.
Examples
julia> lc1 = LinearConstraint(; ineq = PartialLinearConstraint(; A = [1.0 0.0], B = [0.5]));julia> lc2 = LinearConstraint(; ineq = PartialLinearConstraint(; A = [0.0 1.0], B = [0.25]));julia> PortfolioOptimisers.merge_linear_constraints([lc1, lc2])LinearConstraint ineq ┼ PartialLinearConstraint │ A ┼ 2×2 Matrix{Float64} │ B ┴ Vector{Float64}: [0.5, 0.25] eq ┴ nothingRelated
PortfolioOptimisers.get_linear_constraints — Function
get_linear_constraints(lcs::PR_VecPR, sets::UniverseSets,
key::Option{<:AbstractString} = nothing;
datatype::DataType = Float64, strict::Bool = false,
rr::Option{<:AbstractLoadingsRegressionResult} = nothing)Convert parsed linear constraint equations into a LinearConstraint object.
get_linear_constraints takes one or more ParsingResult objects (as produced by parse_equation), expands variable names using the provided UniverseSets, and assembles the corresponding constraint matrices and right-hand side vectors. The result is a LinearConstraint object containing both equality and inequality constraints, suitable for use in portfolio optimisation routines.
A row takes one of two shapes. Without rr it runs over the universe the names resolve against. With rr it runs over the assets, because the loadings re-base each term as the row is assembled and what leaves the function is an ordinary asset-space row.
A row is the unit of a drop. A row is a joint statement over several names with one right-hand side, so a name this function cannot resolve takes the whole row with it rather than only its own term: a + c == 0.05 assembled without c would fit a == 0.05, a different and stronger claim than the caller wrote. What the name's failure was decides only whether the drop is reported. A name on the counterpart axis — read with counterpart_axis_names, and in practice the Non-Investable Axis a door minted — is dropped in silence under both settings of strict, because it was a correct name over the universe the caller was handed and the data moved it; the departure is announced once, by the door. A name on neither axis is a typo, and is reported exactly as before.
Algorithm
- Take
kaskey, orsets.xkeywhenkeyisnothing, read the universenxfromsets.dictunder it, name the axis withuniverse_axis, and read the counterpart axis withcounterpart_axis_names. - Take
N, the row length, fromconstraint_row_length, and allocate the working rowAtof that length. - Zero
Atfor each parsing result, and start that result not dropped. - Build the indicator of each variable name of the result over
nx. A name that matches no entry marks the row dropped, and is reported throughstrict_diagnosticunless it names the counterpart axis. Every name of the row is still visited, so a row carrying two typos names both. - Add the contribution
constraint_row_termgives for the name and its coefficient toAt. Withrrthe contribution arrives already projected, soAtis asset-length while it is accumulated. - Move to the next result when the row was marked dropped.
- Report the row through
strict_diagnosticand drop it whenAtis still zero. Every name resolved to get here, so the message says the row was annihilated — by the loadings underrr, by its own cancelling coefficients otherwise, or by there being no name in it at all — and never that a name was mistyped. - Read the sign and the inequality flag of the operator from
comparison_sign_ineq_flag, and scale the row and its right-hand side by the sign. That negates a>=row, so both senses of an inequality are written in the<=sense, which is the conventionLinearConstraintstates. - Append the row to the inequality accumulator when the flag is
true, and to the equality accumulator when it isfalse. - Reshape each accumulator that holds a row into a matrix of
Ncolumns, and build thePartialLinearConstraintof that half. - Return the
LinearConstraintholding the halves that were built, ornothingwhen neither half holds a row.
Arguments
lcs: A singleParsingResultor a vector of such objects, representing parsed constraint equations.sets: AUniverseSetsobject specifying the universes and groupings.key: Key naming the universe the variables resolve against. Defaults tosets.xkey; a re-based constraint passessets.tfkey.datatype: Numeric type for coefficients and right-hand side.strict: Iftrue, throws an error if a variable or group is not found insets; iffalse, issues a warning.rr: Loadings to re-base through, ornothingfor an ordinary asset-space constraint. SeeExposureConstraintEstimator— callers do not pass this directly.ledger: The door's ledger of departure casualties, ornothingwhen nobody is collecting. A row dropped for a name on the counterpart axis is recorded into it throughrecord_non_investable_drop!.
Validation
lcsis non-empty, when it is a vector.- A variable name that matches no entry of the universe and none of the counterpart axis raises when
strictistrue, and issues a warning otherwise. The row is dropped either way. - A variable name on the counterpart axis drops its row in silence, under both settings of
strict. - A row whose terms all fall away raises when
strictistrue, and issues a warning otherwise. The row is dropped either way. - Each
opis one of"==","<="or">=", whichcomparison_sign_ineq_flagenforces.
Returns
lcs::LinearConstraint: An object containing the assembled equality and inequality constraints, ornothingif no constraints are present.
Related
PortfolioOptimisers.prefixed_sets_keys — Function
prefixed_sets_keys(
dict::AbstractDict,
prefix::AbstractString
) -> Vector{String}
Collect the dict keys that start with prefix, as the candidate pool of a suggest_declared_key suggestion inside UniverseSets.
A missing partition key is reported by the group that asked for it, so the whole key set is the wrong pool: the nearest neighbour of nx_sector in Dict("ux_sector" => …) is ux_sector, the very key under validation, and the caller would be told to rename the one thing that is correct. Narrowing the pool to the prefix the missing key must carry leaves only keys that could genuinely have been meant.
Algorithm
- Return the keys of
dictthat start withprefix, as strings, in the orderdictiterates in.
Arguments
dict: TheUniverseSetsdictionary being validated.prefix: The axis prefix the missing key must carry,xkey,tfkeyorcfkey.
Returns
candidates::Vector{String}: The keys ofdictthat start withprefix.
Related
PortfolioOptimisers.unclaimed_sets_keys — Function
unclaimed_sets_keys(
dict::AbstractDict,
claimed
) -> Vector{String}
Collect the dict keys that no axis in claimed has taken, as the candidate pool of the missing-xkey suggestion inside UniverseSets.
The counterpart of prefixed_sets_keys for the one key with no prefix of its own. The asset universe is whichever key holds the asset names, so it cannot be found by a prefix; what can be ruled out is every key another declared axis already speaks for. Without that, a dict carrying only a feature axis answers a mistyped xkey with the feature key, which is a different axis and never the right fix.
Algorithm
- Return the keys of
dictthat start with no entry ofclaimed, as strings, in the orderdictiterates in.
Arguments
dict: TheUniverseSetsdictionary being validated.claimed: The other declared axis prefixes,uxkey,tfkey,utfkey,cfkeyanducfkey.
Returns
candidates::Vector{String}: The keys ofdictthat start with no entry ofclaimed.
Related
PortfolioOptimisers.assert_factor_partition — Function
assert_factor_partition(dict::AbstractDict, k::AbstractString, fkey::AbstractString,
axis::AbstractString) -> NothingAssert that the factor partition k names a declared factor axis fkey, and that the two agree on how many factors there are.
UniverseSets carries two factor axes, and both obey this one rule, so the rule is written once and called twice. axis names the axis in both messages — a caller who declared the time-series axis and wrote a cross-sectional partition is told which of the two is missing, which the key value alone does not say.
Arguments
dict: TheUniverseSetsdictionary being validated.k: Thefkey-prefixed key under validation.fkey: The factor axis key the prefix belongs to,tfkeyorcfkey.axis: Names the axis in both diagnostic messages, for example"time-series factor".
Validation
haskey(dict, fkey). AKeyErrornamingaxisis thrown otherwise.length(dict[k]) == length(dict[fkey]). ADimensionMismatchnamingaxisis thrown otherwise.
Returns
nothing.
Related
PortfolioOptimisers.assert_factor_unique_group — Function
assert_factor_unique_group(dict::AbstractDict, k::AbstractString, fkey::AbstractString,
ufkey::AbstractString, axis::AbstractString) -> NothingAssert that the unique-entry factor group k names a declared factor axis fkey, that the partition it draws its entries from exists, and that the partition has the length of the axis.
The sibling of assert_factor_partition, and written for the same reason: UniverseSets carries two factor axes and both obey this one rule, so the rule is written once and called twice.
Arguments
dict: TheUniverseSetsdictionary being validated.k: Theufkey-prefixed key under validation.fkey: The factor axis key the group summarises,tfkeyorcfkey.ufkey: The unique-entry prefixkcarries,utfkeyorucfkey.axis: Names the axis in every diagnostic message, for example"cross-sectional factor".
Validation
haskey(dict, fkey). AKeyErrornamingaxisis thrown otherwise.haskey(dict, fkey * chopprefix(k, ufkey)). AKeyErrorcarrying a spelling suggestion is thrown otherwise.length(dict[fkey * chopprefix(k, ufkey)]) == length(dict[fkey]). ADimensionMismatchis thrown otherwise.
Returns
nothing.
Related
PortfolioOptimisers.universe_axis — Function
universe_axis(sets::UniverseSets, key::AbstractString) -> StringName of the axis the universe stored under key belongs to, read off the key itself: "factor" for anything carrying the tfkey prefix, "asset" otherwise. It exists only so unknown_variable_msg and empty_row_msg can name the axis the user wrote in.
The key is the evidence, for both callers, and the reason is that both resolve names against sets.dict[key] and nothing else: whatever axis that universe belongs to is the axis a failed lookup failed on. get_black_litterman_views takes the key from the estimator that owns the views, and get_linear_constraints from the constraint space — FactorSpace resolving at sets.tfkey. Reading it off the re-basis instead would be a second encoding of the same fact, and a worse one: a wrapped estimator carrying its own key overrides the space's, so a re-based row can legitimately resolve against a universe the loadings are not written in, and the message must name the universe that was searched.
The prefix rather than equality is what makes a factor group key ("nf_sector") resolve as the factor axis too, and the disjoint-prefix rule UniverseSets enforces at construction is what makes that unambiguous.
Algorithm
- Return
"factor"whenkeystarts withsets.tfkey. - Return
"asset"in every other case.
Arguments
sets: TheUniverseSetswhosetfkeynames the factor axis.key: The key the names were resolved against.
Returns
axis::String:"factor"or"asset", the word a diagnostic message uses to name the axis.
Related
PortfolioOptimisers.constraint_row_length — Function
constraint_row_length(rr, nx::VecStr) -> IntLength of the assembled constraint row. Without a re-basis this is the size of the universe the names resolve against; with one it is the number of assets the loadings project onto, because the projection is applied while the row is assembled and what leaves is an ordinary asset-space row.
Algorithm
The method that Julia selects is the algorithm, and the re-basis selects it.
rrisnothing: return the length ofnx, the universe the names resolve against.rris a regression result: return the number of rows ofrr.M, which is the number of assets the loadings project onto.
Arguments
rr: Loadings to re-base through, ornothingfor an ordinary asset-space row.nx: The universe the names resolve against.
Returns
N::Int: The number of entries one assembled row has.
Related
PortfolioOptimisers.constraint_row_term — Function
constraint_row_term(::Nothing, Ai, c)
constraint_row_term(rr::AbstractLoadingsRegressionResult, Ai, c)Contribution of one matched variable to a constraint row.
Without a re-basis the contribution is the indicator Ai scaled by the coefficient c. With one it is the columns of the loadings that Ai selects, summed and scaled. The columns are summed rather than indexed by findfirst, so a factor universe carrying a duplicated name contributes every column bearing it, matching how the asset path treats a duplicated asset name.
rr.M is used, never 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. Risk decomposition wants L and is right to.
Mathematical definition
\[\begin{align} \boldsymbol{a}^\intercal \boldsymbol{w}_f &= \boldsymbol{a}^\intercal \mathbf{M}^\intercal \boldsymbol{w}_a = (\mathbf{M} \boldsymbol{a})^\intercal \boldsymbol{w}_a\,. \end{align}\]
Where:
- $\boldsymbol{a}$: A constraint row written in factor names.
- $\boldsymbol{w}_f$: The factor weights that row is written against.
- $\boldsymbol{w}_a$: The asset weights the optimiser holds.
- $\mathbf{M}$: The factor loadings, one column per named factor and one row per asset.
The identity is what lets a row written in factor names bind asset weights with no change of variables: the re-based row is an ordinary asset-space row over $\boldsymbol{w}_a$.
Algorithm
The method that Julia selects is the algorithm, and the re-basis selects it.
rrisnothing: returnAiscaled byc, one entry per name of the universe.rris a regression result: sum the columns ofrr.MthatAiselects, scale the sum byc, and return it. The value is asset-length whatever the row was written in.
Arguments
rr: Loadings to re-base through, ornothingfor an ordinary asset-space row.Ai: The indicator of the matched name over the universe the names resolve against.c: The coefficient the matched name carries.
Returns
- The contribution of this term to the row, of the length
constraint_row_lengthgives.
Related
PortfolioOptimisers.name_to_val! — Function
name_to_val!(nx::VecStr, sdict::AbstractDict, key::Any, val::Number,
arr::VecNum, strict::Bool, nxkey::AbstractString,
other::VecStr = String[])Set values in a vector for the asset or the group of assets that key names.
name_to_val! resolves key through resolve_axis_name — an asset name resolves to itself, a group name expands to its members — maps the result to indices in the asset universe nx, and sets the corresponding entries of arr to val. If key names neither, the function either throws an error or issues a warning, depending on the strict flag. Every diagnostic message names the size of the universe and never the universe itself or the input value dictionary, because each is routed through a shared message builder in 01_Base/06_Messages.jl.
other is the counterpart axis: the asset names that this call is not resolving against, but that the same UniverseSets declares. A name found there is skipped in silence, under strict or not, and that is the whole of what strict gives up. strict exists to catch a caller's typo, and a name on the counterpart axis is the opposite of a typo: it was a correct name over the universe the caller was given, and the data moved it. A caller cannot know in advance which asset a prior will fail to estimate, so refusing them — or even warning, once per constraint, per window of a walk-forward — reports something no one can act on. The departure itself is announced once, by the door that derived the mask.
The two asset axes are counterparts of each other, and the relation is symmetric. Resolving on the asset universe, other is the Non-Investable Axis, so a bound stated for an asset that left is dropped. Resolving on the Non-Investable Axis — which is how a forced-liquidation rate is priced — other is the asset universe, so a liquidation rate stated for an asset that stayed is dropped by the same rule. A factor axis has no counterpart, and other is then empty.
Algorithm
- Resolve
keythroughresolve_axis_name, givingmembers. An asset name resolves to itself, and a group name expands to a copy of its member list. An asset name takes precedence over a group name of the same spelling. - Return in silence when
membersisnothingandkeynames an entry ofother, because the name is on the counterpart axis: it is known-good, and this axis has no entry to write it into. - Report through
strict_diagnosticand return whenmembersisnothing, becausekeynames neither an asset nor a group. The suggestion pool is widened fromnxtonxtogether with the keys ofsdict, because a missing name may be a mistyped asset or a mistyped group. - Map
membersto positions innxwithaxis_name_indices, givingidx. Members that miss the universe are dropped. Those onotherare struck from the report by the same rule as step 2, and any that remain are reported once throughstrict_diagnostic— so a group whose departed members are all accounted for is silent, and one holding a genuine typo still names it. - Set the entries of
arratidxtoval.
Arguments
nx: Vector of asset names.sdict: Dictionary mapping group names to vectors of asset names. It is never modified, becauseresolve_axis_namereturns a copy of the member list.key: Name of the asset or the group of assets to set values for.val: The value to assign.arr: The array to be modified in-place.strict: Iftrue, throws an error ifkeyresolves to nothing; iffalse, issues a warning.nxkey: Name of the asset-universe key insets.dict(e.g."nx"), used only to name the universe in the diagnostic message — seeunknown_variable_msg/missing_group_assets_msg.other: The counterpart asset axis, whose names are skipped in silence rather than reported.
Validation
keynames an asset ofnx, a group ofsdict, or an entry ofother. AnArgumentErroris thrown whenstrictistrue, and a warning is issued otherwise.- Every member of a resolved group names an entry of
nxor ofother. A member that misses both is dropped, and the drop raises whenstrictistrueand issues a warning otherwise.
Returns
nothing. The operation is performed in-place onarr.
Related
PortfolioOptimisers._parse_equation — Function
_parse_equation(lhs, opstr::AbstractString, rhs; datatype::DataType = Float64)Parse and canonicalise a linear constraint equation from Julia expressions.
_parse_equation takes the left-hand side (lhs) and right-hand side (rhs) of a constraint equation, both as Julia expressions, and a comparison operator string (opstr). It evaluates numeric functions, moves all terms to the left-hand side, collects coefficients and variables, and returns a ParsingResult with the canonicalised equation.
Algorithm
- Fold the constant subexpressions of both sides with
eval_numeric_functions, givinglexprandrexpr, and check each withrethrow_parse_error. - Build
diff_expr, the expressionlexpr - (rexpr). This moves every term of the equation to the left-hand side. - Walk
diff_exprwith_collect_terms, givingterms, one(coefficient, variable)pair per term. - Accumulate
termsintovarmap, which holds the summed coefficient of each variable name, and intoconstant, the sum of the coefficients that carry no variable. - Read
variablesandcoefficientsoffvarmap, and takerhs_valas the negatedconstant. This moves the constant to the right-hand side. - Render each pair with
format_term, join the renderings with+, and fold+ -into-, giving the canonical stringformatted. - Return the
ParsingResultbuilt fromvariables,coefficients,opstr,rhs_valandformatted.
Arguments
lhs: Left-hand side of the equation as a Julia expression.opstr: Comparison operator as a string.rhs: Right-hand side of the equation as a Julia expression.datatype: Numeric type for coefficients and right-hand side.
Returns
res::ParsingResult: Structured result with canonicalised variables, coefficients, operator, right-hand side, and formatted equation. The order ofvarsis the order the variable map iterates in, and it is not the order the equation was written in.
Related
PortfolioOptimisers.rethrow_parse_error — Function
rethrow_parse_error(expr; side = :lhs)Internal utility for error handling during equation parsing.
rethrow_parse_error is used to detect and handle incomplete or invalid expressions encountered while parsing constraint equations. It is called on both sides of an equation during parsing to ensure that the expressions are valid and complete. If an incomplete expression is detected, a Meta.ParseError is thrown; otherwise, the function returns nothing. The parser fails closed on an empty side rather than assuming zero, because a silently assumed zero is a constraint the author never wrote. A caller who means zero writes it.
Algorithm
The method that Julia selects is the algorithm, and one method answers each shape a parsed side can take.
exprisNothing, which is what an empty side gives: raise, and namesidein the message.expris anExpr: raise when its head is:incomplete, and returnnothingotherwise.expris anything else, a number or a symbol among them: returnnothing.
Arguments
expr: The parsed Julia expression to check. Can be anExpr,Nothing, or any other type.side: Symbol indicating which side of the equation is being checked (:lhsor:rhs). Used for error messages.
Validation
expris notNothing. AMeta.ParseErrornamingsideis thrown otherwise.expr.head != :incomplete. AMeta.ParseErrornamingsideand the expression is thrown otherwise.
Returns
nothing.
Related
PortfolioOptimisers.format_term — Function
format_term(coeff, var)Format a single term in a linear constraint equation as a string.
format_term takes a coefficient and a variable name and returns a string representation suitable for display in a canonicalised linear constraint equation. Handles special cases for coefficients of 1 and -1 to avoid redundant notation.
Algorithm
- Return the variable name alone when
coeffis one. - Return the variable name behind a minus sign when
coeffis minus one. - Return the coefficient, a
*, and the variable name, in every other case.
Arguments
coeff: Numeric coefficient for the variable.var: Variable name as a string.
Returns
term_str::String: The formatted term as a string.
Related
PortfolioOptimisers.collect_terms! — Function
collect_terms!(expr, coeff, terms)Recursively collect and expand terms from a Julia expression for linear constraint parsing.
collect_terms! traverses a Julia expression tree representing a linear equation, expanding and collecting all terms into a vector of (coefficient, variable) pairs. It handles numeric constants, variables, and arithmetic operations (+, -, *, /), supporting canonicalisation of linear constraint equations for further processing.
Algorithm
- Append
(coeff * expr, nothing)whenexpris aNumber, so a constant carries the coefficient and no variable. - Append
(coeff, string(expr))whenexpris aSymbol, so a bare variable carries the coefficient it arrived with. - For a multiplication
a * b, recurse into the side that is not a number, withcoeffmultiplied by the side that is. A product of two non-numeric sides is opaque, so append it whole as(coeff, string(expr)). - For a division
a / b, recurse intoawithcoeffdivided byb, whenbis a number. A division by a denominator that is not a number is opaque, so append it whole. - For an addition, recurse into every argument with
coeffunchanged. - For a subtraction, recurse into every argument but the last with
coeff, and into the last with-coeff. A unary minus holds no argument but the last, so this negates its one operand. - Append any other expression whole, as
(coeff, string(expr)). This is what makes a term such assqrt(x)opaque: it becomes one variable named by its own text, and the row builder resolves that text against the universe like any other name.
Arguments
expr: The Julia expression to traverse.coeff: The current numeric coefficient to apply.terms: A vector to which(coefficient, variable)pairs are appended in-place. Each pair is of the form(typeof(coeff), Option{<:String}), whereNothingindicates a constant term.
Returns
nothing. The function modifiestermsin-place.
Related
PortfolioOptimisers._collect_terms — Function
_collect_terms(expr::Union{Symbol, Expr, <:Number}, datatype::DataType = Float64)Expand and collect all terms from a Julia expression representing a linear constraint equation.
_collect_terms takes a Julia expression (such as the left-hand side of a constraint equation), recursively traverses its structure, and returns a vector of (coefficient, variable) pairs. It supports numeric constants, variables, and arithmetic operations (+, -, *, /), and is used to canonicalise linear constraint equations for further processing.
The starting coefficient is one(datatype), so every coefficient the walk builds is of that type. The caller asked for the numeric domain the optimiser works in, and the coefficients belong to it as much as the right-hand side does.
Algorithm
- Open an empty vector
terms. - Walk
exprwithcollect_terms!, from the starting coefficientone(datatype). The walk appends one pair totermsper term it reaches: a constant as(coefficient, nothing), and anything else as(coefficient, name). - Return
terms.
Arguments
expr: The Julia expression to expand.datatype: Numeric type of the coefficients the walk builds.
Returns
terms::Vector{Tuple{datatype, Option{<:String}}}: A vector of(coefficient, variable)pairs, wherevariableis a string for variable terms ornothingfor constant terms.
Related
PortfolioOptimisers.eval_numeric_functions — Function
eval_numeric_functions(expr, datatype::DataType = Float64)Recursively evaluate numeric functions and constants in a Julia expression.
eval_numeric_functions traverses a Julia expression tree and evaluates any sub-expressions that are purely numeric, including standard mathematical functions and constants (such as Inf). This is used to simplify constraint equations before further parsing and canonicalisation.
When an allowlisted function is actually evaluated (all its arguments are numeric), its arguments are coerced to datatype (a float type) first, so the arithmetic happens in the same numeric domain the optimiser will use rather than in machine Int64. This prevents integer literals from combining and wrapping — e.g. 2^64 yields 1.8446744073709552e19 rather than silently wrapping to 0, and 2^-1 yields 0.5 rather than a DomainError. Numeric literals that survive inside an unevaluated (nonlinear) subexpression are left untouched, so 2^z still renders as 2 ^ z.
Only the functions enumerated in allowed_functions may be evaluated; any other call head fails closed with a Meta.ParseError. The prior(...) marker is handled structurally (see replace_group_by_assets) and throws a Meta.ParseError if given purely numeric arguments.
Algorithm
- Fold every argument of
exprfirst, by applying this function to each, whenexpris anExpr. A node whose head is not:callis rebuilt from its folded arguments and returned. - Rebuild a
:callnode whose head ispriorfrom its folded arguments, and return it. The marker names assets or groups, so it is never folded to a number. - Look the head of any other
:callnode up inallowed_functions, givingf. A head that the table does not hold raises. - Rebuild the call and return it when any folded argument is not a
Number, so a nonlinear subexpression keeps its own literals untouched. - Coerce every folded argument to
datatype, applyfto them, and return the value that comes back. - Return the value
Infwhenexpris the symbol:Inf. Returnexpritself in every other case, so a number stands and a variable name survives as a symbol.
Arguments
expr: The Julia expression to evaluate. Can be aNumber,Symbol, orExpr.datatype: Float type into which numeric arguments are coerced before an allowlisted function is evaluated.
Validation
- The head of a
:callnode is a key ofallowed_functions, or thepriormarker. AMeta.ParseErrornaming the head is thrown otherwise. prior(...)carries at least one argument that is not a number. AMeta.ParseErroris thrown otherwise.
Returns
- The evaluated expression, with all numeric sub-expressions replaced by their computed values. Non-numeric or symbolic expressions are returned in their original or partially simplified form.
Related
PortfolioOptimisers.has_invalid_plus — Function
has_invalid_plus(expr)Check whether a Julia expression contains an invalid + operator in a constraint context.
Internal helper used during linear constraint parsing to detect unsupported + operator usage in constraint expressions. It is the Expr counterpart of the ++ check the string form of parse_equation runs on the raw text.
Algorithm
- Return
falsewhenexpris not a call, because only a call can carry the head this function refuses. - Return
truewhen the head of the call is the++operator. - Apply this function to every argument of the call that is itself an expression, and return
truewhen any of them does.
Arguments
expr: Julia expression to check.
Returns
Bool:trueif the expression contains an invalid+,falseotherwise.
Related
PortfolioOptimisers.factor_universe — Function
factor_universe(sets::UniverseSets, key::AbstractString, K::Integer,
need::AbstractString, source::AbstractString) -> VecStrRead the declared factor universe sets.dict[key], checking that it exists and that it agrees with source — the observations × factors matrix whose K columns it must name — on how many factors there are.
A factor axis is optional on UniverseSets but is not optional for a consumer written against it, so the failure has to be diagnosed at the point of need. Both messages name key and the matrix, because the two are what a caller has to reconcile: a user arriving from the pre-declaration shape put the factor names under xkey and would otherwise be told about an asset universe they never wrote in.
key is stated rather than read off sets, because UniverseSets declares two factor axes and this helper cannot tell which one a caller means. A caller that holds a loadings result reads the key from it with factor_axis_key; a caller written against the returns data's own F states sets.tfkey, the axis those columns live on.
One helper therefore serves every consumer of either axis, and none of them re-encodes the checks.
Arguments
sets: TheUniverseSetswhose factor axis is read.key: The factor axis key to read,sets.tfkeyorsets.cfkey.K: The number of columns ofsource, which the declared axis must name.need: Names the consumer in both diagnostic messages, for example"a FactorSpace constraint".source: Names the matrix in both diagnostic messages, for example"rr.M"or"F".
Validation
haskey(sets.dict, key). AKeyErrornamingneedis thrown otherwise.length(sets.dict[key]) == K. ADimensionMismatchnamingsourceis thrown otherwise.
Returns
nf::VecStr: The declared factor names, in the column order ofsource.
Related
PortfolioOptimisers.factor_axis_key — Function
factor_axis_key(sets::UniverseSets, rr::Regression) -> AbstractString
factor_axis_key(sets::UniverseSets, rr::CrossSectionalFactorModel) -> AbstractString
factor_axis_key(sets::UniverseSets,
re::AbstractTimeSeriesRegressionEstimator) -> AbstractStringReturn the UniverseSets key naming the factor axis that rr's loadings are written on.
UniverseSets declares two factor axes, so a consumer that resolves factor names has to say which one it means. It never says so by hand. The key follows the block that carries M: a Regression is fitted per asset over the observations, so its columns are the columns of rd.F and it answers sets.tfkey; a CrossSectionalFactorModel is fitted per observation across the assets, so its columns are the exposures the fit was built from and it answers sets.cfkey. A caller therefore cannot name the wrong axis, and no consumer gains a field to state it in.
The third method serves a consumer that holds an unfitted specification rather than a result. Only the time-series family names a specification here, because RegE_Reg admits an AbstractTimeSeriesRegressionEstimator and no other estimator, and every result that family fits is a Regression. The three methods therefore cover RegE_Reg exactly.
There is deliberately no fallback on AbstractLoadingsRegressionResult. A future member of the root would silently inherit whichever axis the fallback named, and half the time that is the wrong list of names with the right length — a constraint written against it would still solve and would constrain the wrong factors. A missing method is a MethodError that names the type.
Arguments
sets: TheUniverseSetswhose factor axis key is read.rr/re: The loadings result, or the specification whose verb produces one.
Returns
key::AbstractString:sets.tfkeyfor the time-series family,sets.cfkeyfor the cross-sectional one.
Related
PortfolioOptimisers._expr_depth_exceeds — Function
_expr_depth_exceeds(x, limit::Integer) -> BoolReturn true if the expression tree x is deeper than limit.
Guards the Expr form of parse_equation against a deeply nested AST that no string length cap covers. The check itself recurses at most limit + 1 frames deep and short-circuits the moment the limit is breached, so it cannot exhaust the stack it protects. docs/adr/0027-cap-equation-parser-recursion.md owns the limit this function is called with.
Algorithm
- Return
truewhenlimitis negative, because the walk has already gone one level past the cap. - Return
falsewhenxis not an expression, because a leaf adds no depth. - Apply this function to every argument of
x, withlimitlowered by one, and returntruewhen any of them does. The scan stops at the first argument that answerstrue.
Arguments
x: The expression tree to measure.limit: The greatest depth the tree may have.
Returns
Bool:truewhen the tree is deeper thanlimit,falseotherwise.
Related
PortfolioOptimisers.assert_investable_constraint_width — Function
assert_investable_constraint_width(lcs::Nothing, N::Integer, slot::AbstractString)
assert_investable_constraint_width(lc::LinearConstraint, N::Integer,
slot::AbstractString)
assert_investable_constraint_width(lcs::VecLc, N::Integer, slot::AbstractString)Refuse a precomputed LinearConstraint whose rows are wider than the investable universe, and say why.
A name-keyed estimator survives a reduction to the Investable Mask: it resolves against the UniverseSets the door hands it, and a name that left resolves on the Non-Investable Axis instead of being refused. A precomputed constraint cannot. Its A is a matrix, and position is the only link between a column and an asset, so there is no name to re-resolve and no honest way to narrow it — dropping a column silently changes what Ax ≤ B means, and port_opt_view(::LinearConstraint, i) is deliberately the identity for that reason.
So the row survives the door at its original width and meets a shorter weight vector. Left alone that surfaces inside the model as a bare DimensionMismatch between two numbers, with nothing to connect either to the asset that delisted. This says it once, at the seam, in terms of what the caller did and what they can do instead.
The repair is always the same: state the constraint as a LinearConstraintEstimator. A name-keyed constraint is re-resolved over whatever universe the door leaves, which is the whole point of stating it by name.
Algorithm
- Return when there is nothing to check: a
nothingslot, or anothinghalf of aLinearConstraint. - Otherwise compare
size(A, 2)of each half againstNand throw aDimensionMismatchnaming the slot, the two widths and the repair when they disagree.
Arguments
lcs: The resolved constraint, a vector of them, ornothing.N: The number of investable assets the optimisation runs over.slot: Names the field the constraint came from in the message, for example"lcse".
Validation
- Every half of every precomputed constraint has one column per investable asset.
Returns
nothing.
Related
PortfolioOptimisers.non_investable_sets — Function
non_investable_sets(sets::Nothing, ni) -> Nothing
non_investable_sets(sets::UniverseSets, ni::VecStr) -> UniverseSetsMint the Non-Investable Axis on sets: declare ni, the names the Investable Mask left out, under sets.nikey.
This is the only way the axis comes to exist. A door calls it after it has reduced an optimiser to the Investable Mask, so a UniverseSets that carries the axis was reduced by exactly one door, for exactly one problem, and port_opt_view drops it rather than pass it to a sub-problem that did not earn it.
A caller may still declare the axis by hand, and outside a door that is the only way to resolve a forced-liquidation rate — fees_constraints called directly, with no optimisation around it. Inside a door the mask is the truth, so a hand-authored entry is overwritten here rather than merged: the two can only disagree, and the mask is the one derived from the data.
An empty ni returns sets untouched. Nothing left the universe, so nothing is owed, and declaring an empty axis would make fees_constraints resolve a carrier that prices no position.
Algorithm
- Return
setsunchanged whenniis empty. - Otherwise copy
sets.dict, writeniundersets.nikey, and rebuild theUniverseSetsfrom it and the seven unchanged key prefixes, which revalidates uniqueness and disjointness over the minted axis.
Arguments
sets: TheUniverseSetsto mint the axis on, ornothing.ni: The names the Investable Mask left out, in the order the complement of the mask visits them.
Returns
sets: TheUniverseSetscarrying the Non-Investable Axis, ornothing.
Related
PortfolioOptimisers.non_investable_names — Function
non_investable_names(nx::Nothing, imsk::BitVector) -> VecStr
non_investable_names(nx::VecStr, imsk::BitVector) -> VecStrRead the names the Investable Mask leaves out, in the order the complement of the mask visits them.
The order is the whole point. A forced-liquidation carrier is sliced to the complement of the mask by index, and the rate that prices it is resolved against these names by position, so the two must walk the complement the same way. Both do: this indexes nx with .!imsk, which is ascending, and port_opt_view(::Fees, i, X) takes the complement of i over the width of X, which is ascending too.
Unnamed returns data answers an empty vector rather than throwing. Names are what the axis is made of, so a problem with no names has no Non-Investable Axis to mint — and no name-keyed constraint to resolve against one either.
It lives here, beside non_investable_sets, rather than beside the optimisation door that first needed it, because a wrapping prior mints the axis at its own entry too and loads seven directories earlier. The vocabulary of the Non-Investable Axis is therefore one file, and no layer reaches it by a back reference.
Arguments
nx: The asset names of the unreduced returns data, ornothing.imsk: The Investable Mask the optimisation reduced on:trueat every asset whose prior moments were finite. It isnothingwhen every asset was investable, and that sentinel is what skips both the reduction and the expansion.investable_maskderives it once from the full-universe prior result, and the result carries it, because the reduced prior can no longer yield it.
Returns
ni::VecStr: The names the mask leaves out, or an empty vector.
Related
PortfolioOptimisers.record_non_investable_drop! — Function
record_non_investable_drop!(ledger::Nothing, what::AbstractString) -> Nothing
record_non_investable_drop!(ledger::AbstractVector, what::AbstractString) -> NothingRecord, for the door to report, one thing a departure cost.
A departed name is dropped where it is met — a view row here, a group member there — and each of those places is far from the door that derived the mask and knows the departure happened as an event. Reporting at the site would say the same thing once per row per window of a walk-forward, and that repetition is refused. Reporting nothing leaves a caller who wrote three views and got one fitted with no way to learn it. So the site writes what it dropped into a ledger, and the door reads the ledger once and says both things together, through announce_non_investable.
A nothing ledger is the no-collection path, and it is the default everywhere: a caller who assembles constraints outside a door has no door to report to, and pays nothing for the ledger it does not keep. The branch is dispatch rather than a condition, as it is throughout the reduction machinery.
what is a noun phrase naming the casualty, not a sentence: the door joins them into one message and supplies the verb.
Arguments
ledger: The door's ledger, ornothingwhen nobody is collecting.what: A noun phrase naming what was dropped, for example $"the view row `a + c == 0.05`"$.
Returns
nothing. A vector ledger is appended to in place.
Related
PortfolioOptimisers.record_group_shed! — Function
record_group_shed!(ledger::Option{<:AbstractVector}, group::AbstractString,
shed::Integer, kept::Integer, eqn::AbstractString) -> NothingRecord what a group shed to a departure, for the door to report through announce_non_investable.
A group that loses some of its members still describes the rest, so its row survives at a coefficient spread over the survivors; a group that loses all of them describes nothing, and its row goes with it. The two are different news to a caller, so they are phrased differently, and this is the one place either sentence is written. replace_group_by_assets is the only caller, at each of its four expansion branches.
Counts, not names: the departed assets are named once by the door, and repeating them per group would make the message longer than what it reports.
A shed of nothing records nothing, so the all-investable path costs one comparison.
Arguments
ledger: The door's ledger, ornothingwhen nobody is collecting.group: The group name as the caller wrote it, or the pair"(a, b)"for a correlation view.shed: How many members the group lost.kept: How many members survived.eqn: The row the group appears in, as the caller wrote it.
Returns
nothing.
Related
PortfolioOptimisers.announce_non_investable — Function
announce_non_investable(ni::VecStr, drops::VecStr = String[],
process::AbstractString = "optimisation",
consequence::AbstractString = "…";
warn::Bool = false) -> NothingAnnounce, once per door, the assets that left the investable universe and what their leaving cost.
The door is the only place that knows a departure happened as an event rather than as a shape. Downstream, a departed asset is simply absent: a bound stated for it resolves on the Non-Investable Axis and is skipped, a view row naming it is dropped whole. Reporting each of those where it happens would say the same thing once per row per window of a walk-forward, so each site writes its casualty into a ledger with record_non_investable_drop! and the door says everything once, here.
process names the work the departure is excluded from, because more than one kind of door mints the axis: an optimisation reduces at its entry, and a wrapping prior reduces at its own before it builds a view. Hard-coding "optimisation" made the message wrong for the second. consequence states what a departure means to this door — a forced-liquidation carrier is priced by an optimisation and by nothing else — and both are ordinary defaults, so the optimisation door reads as it always did.
It is @info by default, not a warning and not a strict_diagnostic. Nothing is wrong: the data moved, and the work is proceeding correctly over what is left. Making it raise under strict would put back the refusal this whole path exists to remove. warn raises it to @warn for the one case that is not routine — a departure that took the last of something the caller asked for, such as the final view of a view set, because handing back the unconditioned answer changes the result and the caller has no other way to learn it.
An empty ni says nothing at all, which is the all-investable path and the unnamed-data path alike. An empty drops says who left and stops there, which is the door that has not yet resolved anything over them.
Arguments
ni: The names the Investable Mask left out.drops: The ledger of casualties, asrecord_non_investable_drop!filled it.process: Noun phrase naming the work, for example"optimisation"or"entropy pooling fit".consequence: Sentence stating what a departure means to this door.warn: Raise the message to@warn, for a departure that changed the model rather than trimming it.
Returns
nothing.
Related
PortfolioOptimisers.counterpart_axis_names — Function
counterpart_axis_names(sets::UniverseSets, nxkey::AbstractString) -> VecStrReturn the asset axis that nxkey is the counterpart of, or an empty vector when it has none.
UniverseSets declares two axes over assets: the investable universe under xkey, and the Non-Investable Axis under nikey, which a door mints from the complement of the Investable Mask. A name-keyed estimator resolves against one of them, and a name it does not find there may still be a perfectly good name on the other — a bound stated for an asset that has since left, or a forced-liquidation rate stated for one that stayed. name_to_val! needs that list to tell such a name from a typo, and this is where the pairing is written down.
The relation is symmetric and covers only these two. A factor axis names factors, so no asset name is ever a departed factor and the answer is empty; a caller-supplied key naming some other list is treated the same way.
An axis that sets does not declare answers empty, which is the common case: a problem in which every asset is investable carries no nikey entry at all.
Arguments
sets: TheUniverseSetswhose axes are read.nxkey: The key of the axis being resolved against.
Returns
other::VecStr: The counterpart axis, or an empty vector.
Related
PortfolioOptimisers.shed_departed_members — Function
shed_departed_members(members::AbstractVector, other::VecStr,
ledger::Option{<:AbstractVector}, group::AbstractString,
eqn::AbstractString) -> AbstractVector
shed_departed_members(members1::AbstractVector, members2::AbstractVector,
other::VecStr, ledger::Option{<:AbstractVector},
group::AbstractString, eqn::AbstractString) -> TupleStrike from a group's member list the names that sit on the counterpart axis, and tell the door's ledger what went.
A group is a description the data resolves, not a term the caller chose: "tech" means the technology assets of this problem, and when one of them delists the description still names the rest. replace_group_by_assets therefore sheds the departed members before it spreads the group's coefficient, so a Black–Litterman mean divides by the surviving count and an entropy pooling sum runs over the survivors — the row still computes what its right-hand side asserts. Striking a member afterwards would leave k - 1 legs of c/k against an unchanged target. That is why a group differs from a written-out name, which takes its row with it.
A group that loses every member keeps the first of them rather than answering empty, because a group that describes nobody is a row naming a departed asset, and saying so is what makes it drop by the counterpart rule one door later — whole, and in silence. Answering empty would leave a row with no variable in it, which is what a caller writing 1 == 0.004 produces, and that one still has to be diagnosed.
The second method is the pair form, for a correlation view written over two groups. The two lists are walked together, and a position is kept only when both of its names survived: a pair is one correlation, so a pair that has lost either side has nothing left to measure, and shedding jointly is also what keeps the two lists the same length, which replace_group_by_assets has already checked. The all-lost case keeps the first pair, on the same reasoning.
An empty other is the all-investable path, and both methods then return their arguments untouched and record nothing, so a problem with no departure pays one comparison and no allocation.
The recording lives here rather than at the four call sites so that replace_group_by_assets spends one line per branch on the whole of it. The four branches are otherwise identical, and four copies of the shed, the record and the all-lost fallback is where they would drift.
Arguments
members/members1,members2: The group's member names, assets.dictholds them.other: The counterpart axis, read withcounterpart_axis_names. Usually the Non-Investable Axis.ledger: The door's ledger, ornothingwhen nobody is collecting.group: The group name as the caller wrote it, for the ledger.eqn: The row the group appears in, for the ledger.
Returns
members::AbstractVector: The members that are not onother, in their original order, or the first departed member when none survived.(members1, members2)::Tuple: The pair form, restricted to the positions both lists survived, or the first pair when none did.
Related