Optimiser¶
Constrained portfolio optimisation: constraints, the solver, the efficient frontier and OptimisationResult. See Optimiser.
optimise ¶
Portfolio optimisation.
Constraint classes describing what a portfolio is allowed to be, and a solver that finds the closest feasible portfolio to a target: the tracking problem an index business meets first.
Solving needs scipy, which ships in the optimise extra:
pip install "py-beacon-kit[optimise]"
Importing this package does not need scipy. Constraints, configs and results
are descriptions, and describing a problem is core work: Backtest.run takes
an :class:OptimisationConfig, and the catalogue serialises constraint
configurations, neither of which should cost an optional package. scipy is
required at solve time, where a missing install raises MissingDependencyError
naming the extra.
OptimisationConfig
dataclass
¶
OptimisationConfig(
objective: str = MIN_TRACKING_ERROR,
constraints: Sequence[Constraint] = (),
risk_model: RiskModel | None = None,
)
What an optimised run is asked to do.
Attributes:
| Name | Type | Description |
|---|---|---|
objective |
str
|
What to minimise. Only |
constraints |
Sequence[Constraint]
|
What the answer must satisfy, as
:class: |
risk_model |
RiskModel | None
|
Reserved: carried but unused. The slot exists so covariance-aware optimised runs can arrive without changing this shape. Nothing reads it yet and passing one changes no result, though an optimised index carrying one cannot be cached. |
Cardinality ¶
Bases: Constraint
A limit on how many names may be held.
Unlike every other constraint here this one is not convex: it counts
non-zero positions, and no continuous solver can express that. It is
honoured by a two-stage heuristic (see
:func:beacon.optimise.solver.minimise_tracking_error): solve, keep the
largest positions, then solve again with the rest pinned at zero. The
answer satisfies the limit but is not proven optimal, and the exact problem
is a mixed-integer program that would need a different solver entirely.
Attributes:
| Name | Type | Description |
|---|---|---|
maximum |
The largest number of names that may carry weight. |
solver_conditions ¶
None: counting holdings is a step function.
Its gradient is zero everywhere it is defined, so handing it to SLSQP would say that dropping a name costs nothing and changes nothing. The limit is enforced by restricting the problem instead.
conditions ¶
The count, as an inequality that is only ever checked.
Condition
dataclass
¶
Condition(
label: str,
kind: str,
evaluate: Callable[[Vector], float],
gradient: Callable[[Vector], Vector] | None = None,
)
One scalar condition on a weight vector.
Attributes:
| Name | Type | Description |
|---|---|---|
label |
str
|
Human-readable description, carried through to the binding report so a client can show which rule bit. |
kind |
str
|
EQUALITY or INEQUALITY. |
evaluate |
Callable[[Vector], float]
|
The condition function. Zero when an equality holds, non-negative when an inequality holds. |
gradient |
Callable[[Vector], Vector] | None
|
Derivative with respect to the weights, when one can be written down. Supplying it keeps the solver off finite differences, which on a problem this small is most of the accuracy. |
Constraint ¶
Bases: ABC
Something an optimal weight vector must satisfy.
Subclasses state their rules as conditions, and everything else (solving, binding detection, verification) is derived from those.
Attributes:
| Name | Type | Description |
|---|---|---|
UNIT |
str
|
What this constraint's slack is measured in. :data: |
conditions
abstractmethod
¶
The conditions this constraint imposes over assets, in order.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
assets
|
Sequence[str]
|
The universe, fixing the meaning of each weight position. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
list |
list[Condition]
|
Conditions in scipy's convention. |
solver_conditions ¶
The subset of the conditions that should go to the solver.
Everything by default. A constraint overrides this to nothing when it reaches the solver some other way (as a box, or by restricting the problem) while still reporting its conditions for verification.
bounds ¶
Per-asset box limits, when this constraint is one.
A box handed to the solver as a bound is enforced at every iterate, whereas the same box as an inequality is only approached from outside; for position limits that difference is worth the special case.
Returns:
| Type | Description |
|---|---|
list[tuple[float, float]] | None
|
list or None: One |
list[tuple[float, float]] | None
|
constraint is not a box. |
report ¶
How much room this constraint has left at weights.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
weights
|
Vector
|
A candidate solution, aligned to assets. |
required |
assets
|
Sequence[str]
|
The universe. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
list |
list[Slack]
|
One Slack per condition, each stamped with this constraint's |
list[Slack]
|
attr: |
validate ¶
Raise if this constraint cannot be applied to assets at all.
Separate from feasibility: this catches a constraint that is malformed or refers to names that are not there, which is a caller mistake rather than an over-tight problem. It does nothing by default, and a subclass overrides it only when it has something to check.
ExpectedReturnTarget ¶
Bases: Constraint
The portfolio must be expected to return exactly this much.
The constraint that traces out a frontier: fix the return, minimise the variance, repeat. It is an equality rather than a floor because a frontier point is a specific point, and a floor would let every solve collapse onto the minimum-variance portfolio whenever that portfolio happened to clear the bar.
Attributes:
| Name | Type | Description |
|---|---|---|
expected_returns |
Expected return per asset, in the same units and over the same horizon as the risk model's covariance. Annualised, if the risk model is. |
|
target |
The portfolio return being asked for. |
validate ¶
Every asset must have an expected return.
Treating an absent one as zero would quietly bias the whole frontier towards holding it, since a zero-return asset looks like the safest way to satisfy a low return target.
FullInvestment ¶
Bases: Constraint
The weights must sum to a fixed total, normally one.
Attributes:
| Name | Type | Description |
|---|---|---|
target |
The total. One means fully invested with no leverage and no cash; below one leaves cash, above one is levered. |
GroupBounds ¶
Bases: Constraint
Limits on the combined weight of a set of names.
A sector, a country, a liquidity bucket: anything the client groups by.
Attributes:
| Name | Type | Description |
|---|---|---|
name |
What the group is, used in the binding report. |
|
members |
The names in it. |
|
minimum |
Smallest combined weight the group may take. |
|
maximum |
Largest combined weight the group may take. |
validate ¶
Reject a group with no members in the universe.
Members outside the universe are dropped rather than rejected: a sector map is defined over a whole market and is meant to be reused across indices, so naming companies this index does not hold is normal. A group that matches nothing, though, is a mistake: it would silently constrain an empty sum, which is always satisfied.
PositionBounds ¶
Bases: Constraint
Lower and upper limits on individual weights.
Attributes:
| Name | Type | Description |
|---|---|---|
minimum |
Smallest weight any covered asset may take. Zero forbids short positions, which is the usual index-tracking case. |
|
maximum |
Largest weight any covered asset may take. |
|
assets |
Names this applies to, or None for every name. Several of these compose (a blanket rule plus a tighter one on a few names), and the tightest limit on each name wins. |
validate ¶
Reject a bound on a name that is not in the universe.
Unlike a group, a position bound names one asset explicitly, so a name that is not there is a typo rather than a broader definition being reused, and silently dropping it would return an answer that ignored a limit the caller asked for.
bounds ¶
The box, unlimited on any asset this constraint does not cover.
solver_conditions ¶
None: the solver receives this constraint as a box instead.
conditions ¶
The same box, written as inequalities.
Not given to the solver (:meth:bounds covers that), but used to
report which positions came out at a limit.
Slack
dataclass
¶
How much room a condition has left at a given solution.
Attributes:
| Name | Type | Description |
|---|---|---|
label |
str
|
The condition's description. |
kind |
str
|
EQUALITY or INEQUALITY. |
slack |
float
|
Signed room. For an inequality this is the condition function itself, so zero means the solution sits exactly on the boundary and negative means it has crossed. For an equality it is the negated absolute residual, which makes an exactly-satisfied equality read as zero slack, correctly, since an equality is always binding. |
unit |
str
|
What |
TurnoverBudget ¶
Bases: Constraint
A limit on how far the solution may move from the current holdings.
Turnover here is one-way: half the sum of absolute weight changes, which under full investment is the amount bought, and equally the amount sold. A budget of 5% therefore means what an index methodology means by "5% turnover", not a 2.5% round trip.
Attributes:
| Name | Type | Description |
|---|---|---|
maximum |
The one-way budget, as a fraction of the portfolio. |
|
current_weights |
Where the portfolio is now. Names absent from it are treated as currently unheld. |
turnover ¶
One-way turnover of weights against the current holdings.
conditions ¶
The budget as one inequality.
The absolute value has a kink wherever an asset's weight equals its current weight, so this function is not differentiable everywhere and the gradient used is a subgradient: the sign vector, which is the true derivative away from those kinks and an arbitrary choice of one at them. SLSQP assumes smoothness and can in principle stall on a solution that sits exactly on many kinks at once. In practice it converges, and the verification pass refuses the answer if it does not: an exact formulation needs auxiliary variables for the positive and negative parts, which doubles the problem and is not worth it until a real case demands it.
EfficientFrontier
dataclass
¶
EfficientFrontier(
points: list[FrontierPoint],
minimum_variance: FrontierPoint,
tangency: FrontierPoint,
risk_free_rate: float = 0.0,
)
A traced frontier and the points on it worth naming.
Attributes:
| Name | Type | Description |
|---|---|---|
points |
list[FrontierPoint]
|
The grid, in increasing order of expected return. The first is the minimum-variance portfolio and the last is the highest return the constraints allow. |
minimum_variance |
FrontierPoint
|
The least risky feasible portfolio. |
tangency |
FrontierPoint
|
The highest Sharpe ratio available. |
risk_free_rate |
float
|
The rate the Sharpe ratios were computed against. |
expected_returns
property
¶
Each point's expected return, in grid order.
is_monotonic ¶
Whether risk rises with return across the grid.
The defining property of a frontier. It should always hold (insisting on more return can only cost risk), so a False here means a point failed to solve to optimality rather than that the curve is unusual.
weights_frame ¶
Every point's weights, points on the index and assets on the columns.
FrontierPoint
dataclass
¶
FrontierPoint(
weights: Series,
volatility: float,
expected_return: float | None = None,
sharpe_ratio: float | None = None,
binding: list[str] = list(),
heuristic: bool = False,
)
One portfolio on the frontier.
Attributes:
| Name | Type | Description |
|---|---|---|
weights |
Series
|
The portfolio, indexed by asset id. |
volatility |
float
|
Annualised standard deviation, in the risk model's units. |
expected_return |
float | None
|
Portfolio expected return, or None when no expected returns were supplied, since a return cannot be reported if it was never given. |
sharpe_ratio |
float | None
|
Excess return over volatility, or None when the expected return is unknown or the volatility is negligible. |
binding |
list[str]
|
Labels of the constraints this point sits on. The interesting part of a frontier point: it says which rule is what stops the portfolio from doing better. |
heuristic |
bool
|
Whether a non-convex constraint forced a restricted re-solve, so this point is feasible but not proven optimal. |
BindingConstraint
dataclass
¶
A constraint the solution sits exactly on.
Binding constraints are the interesting part of an answer: they are the rules that actually cost something, and relaxing one of them is the only way to improve the objective.
Attributes:
| Name | Type | Description |
|---|---|---|
label |
str
|
What the constraint was. |
kind |
str
|
EQUALITY or INEQUALITY. |
slack |
float
|
Room left, at or near zero by definition of binding. Carried so a caller can see how tight "tight" was. |
unit |
str
|
What |
OptimisationResult
dataclass
¶
OptimisationResult(
weights: Series,
target_weights: Series,
binding: list[BindingConstraint],
diagnostics: SolverDiagnostics,
slacks: list[Slack] = list(),
heuristic: bool = False,
_risk_model: RiskModel | None = None,
)
Optimal weights, what they cost, and which rules bound.
Attributes:
| Name | Type | Description |
|---|---|---|
weights |
Series
|
The solution, indexed by asset id. |
target_weights |
Series
|
What the solve was tracking, on the same index. |
binding |
list[BindingConstraint]
|
Constraints the solution sits on, tightest first. |
diagnostics |
SolverDiagnostics
|
How the solve went. |
slacks |
list[Slack]
|
Every constraint's room at the solution, binding or not. Kept so a caller can see what nearly bound as well as what did. |
heuristic |
bool
|
Whether a non-convex constraint forced a heuristic stage, in which case the answer satisfies every constraint but is not proven optimal. |
active_weights
property
¶
Solution minus target: the active position.
Sums to zero whenever both sides are fully invested to the same total, which is the usual case and worth checking: a non-zero sum means the solve was allowed to change how much is invested, not just where.
with_risk_model ¶
Bind a risk model for risk-based accessors. Returns self.
tracking_error ¶
Distance from the target, in the metric the solve minimised.
With a risk model this is annualised tracking error, the quantity a tracking mandate is measured on. Without one the objective's identity covariance makes it the Euclidean distance between the two weight vectors: a sensible thing to minimise, but not a volatility, and not comparable to a number produced with a risk model.
turnover ¶
One-way turnover from current_weights to the solution.
Half the summed absolute weight change, matching
:class:~beacon.optimise.constraints.TurnoverBudget. Defaults to
measuring against the target, which answers "how far did the optimiser
move me off the index".
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
current_weights
|
dict[str, float] | None
|
Where the portfolio is now. None measures against the target weights. |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
float |
float
|
One-way turnover as a fraction of the portfolio. |
to_frame ¶
Target, optimal and active weights side by side, largest active first.
SolverDiagnostics
dataclass
¶
SolverDiagnostics(
converged: bool,
iterations: int,
evaluations: int,
objective: float,
status: int,
message: str,
)
What the solver did.
Attributes:
| Name | Type | Description |
|---|---|---|
converged |
bool
|
Whether the solver reported success. An answer is only returned when this is True and the weights pass verification, so a caller seeing this is seeing a solve that worked. |
iterations |
int
|
Major iterations taken. |
evaluations |
int
|
Objective evaluations. |
objective |
float
|
Final objective value. It is a variance, so the tracking error is its square root. |
status |
int
|
The solver's numeric exit code. |
message |
str
|
The solver's own description of how it exited. |
Solution
dataclass
¶
A verified answer to a constrained problem.
Attributes:
| Name | Type | Description |
|---|---|---|
weights |
Vector
|
The solution vector, aligned to the universe. |
outcome |
Any
|
The solver's own result object. |
slacks |
list[Slack]
|
Every constraint's room at the solution. |
heuristic |
bool
|
Whether a non-convex constraint forced a restricted re-solve, in which case the answer is feasible but not proven optimal. |
constraint_from_payload ¶
Rebuild a constraint from a {type, params} payload.
The inverse of :func:constraint_payload, and the builder the server's
constraint rows go through: one code path from a stored document to the
object the solver receives.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
payload
|
Mapping[str, Any]
|
A mapping carrying |
required |
Returns:
| Name | Type | Description |
|---|---|---|
Constraint |
Constraint
|
A fresh instance that solves identically to the one the |
Constraint
|
payload was taken from. |
Raises:
| Type | Description |
|---|---|
CalculationError
|
If the type names nothing registered. A constructor rejecting the params (a missing argument, a minimum above a maximum) propagates untouched, so the class's own message reaches the caller. |
constraint_payload ¶
One constraint as its registered name plus parameter values.
Parameter names come from the catalogue's constructor introspection, each value read off the instance attribute of the same name. Every registered type follows that convention, and the cache fingerprint keys by it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
constraint
|
Constraint
|
The instance to describe. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
dict |
dict[str, Any]
|
|
dict[str, Any]
|
value JSON-serialisable. |
Raises:
| Type | Description |
|---|---|
CalculationError
|
If the class is not registered under the CONSTRAINT kind, keeps no attribute for one of its constructor parameters, or holds a value JSON cannot carry. Refused rather than guessed at: a payload nothing can rebuild is worse than no payload. |
one_way_turnover ¶
Half the summed absolute weight change between two portfolios.
The halving is what makes this one-way: under full investment every unit bought is a unit sold, so the undivided sum counts each trade twice.
default_constraints ¶
Long-only and fully invested.
The frontier's default rather than full investment alone, because with shorting unbounded the maximum-return portfolio does not exist: short the worst asset without limit to fund the best, and the return grows forever. A frontier needs a right-hand end.
efficient_frontier ¶
efficient_frontier(
risk_model: RiskModel,
expected_returns: dict[str, float],
points: int = DEFAULT_POINTS,
constraints: Sequence[Constraint] | None = None,
risk_free_rate: float = 0.0,
) -> EfficientFrontier
Trace the frontier, and locate its minimum-variance and tangency points.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
risk_model
|
RiskModel
|
The covariance. Its asset order defines the universe. |
required |
expected_returns
|
dict[str, float]
|
Expected return per asset, in the same units and over the same horizon as the covariance: annualised, if it is. |
required |
points
|
int
|
How many portfolios to solve for, minimum 2. |
DEFAULT_POINTS
|
constraints
|
Sequence[Constraint] | None
|
What every point must satisfy. None means long-only and fully invested. |
None
|
risk_free_rate
|
float
|
The rate Sharpe ratios are measured against. |
0.0
|
Returns:
| Name | Type | Description |
|---|---|---|
EfficientFrontier |
EfficientFrontier
|
The grid and the two named points. |
Raises:
| Type | Description |
|---|---|
CalculationError
|
If points is below 2, if the constraints cannot be satisfied, or if the problem is unbounded above. |
maximum_return_portfolio ¶
maximum_return_portfolio(
risk_model: RiskModel,
expected_returns: dict[str, float],
constraints: Sequence[Constraint] | None = None,
risk_free_rate: float = 0.0,
) -> FrontierPoint
The highest-returning feasible portfolio.
The frontier's right-hand end. Maximising a linear objective often has many optimal solutions (any mix of the top assets, if they tie), so the return is found first and the variance minimised subject to achieving it, which picks the sensible one out of the tie.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
risk_model
|
RiskModel
|
The covariance, used to break ties and report risk. |
required |
expected_returns
|
dict[str, float]
|
Expected return per asset. |
required |
constraints
|
Sequence[Constraint] | None
|
What the answer must satisfy. None means long-only and fully invested. |
None
|
risk_free_rate
|
float
|
For the reported Sharpe ratio. |
0.0
|
Returns:
| Name | Type | Description |
|---|---|---|
FrontierPoint |
FrontierPoint
|
The portfolio. |
minimum_variance_portfolio ¶
minimum_variance_portfolio(
risk_model: RiskModel,
constraints: Sequence[Constraint] | None = None,
expected_returns: dict[str, float] | None = None,
risk_free_rate: float = 0.0,
) -> FrontierPoint
The least risky feasible portfolio.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
risk_model
|
RiskModel
|
The covariance to minimise against. |
required |
constraints
|
Sequence[Constraint] | None
|
What the answer must satisfy. None means long-only and fully invested. |
None
|
expected_returns
|
dict[str, float] | None
|
Used only to report the point's return and Sharpe ratio; it has no effect on the weights, since minimum variance ignores return by definition. |
None
|
risk_free_rate
|
float
|
For the reported Sharpe ratio. |
0.0
|
Returns:
| Name | Type | Description |
|---|---|---|
FrontierPoint |
FrontierPoint
|
The portfolio, its risk, and which constraints bound. |
Raises:
| Type | Description |
|---|---|
CalculationError
|
If the constraints cannot be satisfied. |
minimise_tracking_error ¶
minimise_tracking_error(
target_weights: Series | dict[str, float],
constraints: Sequence[Constraint] | None = None,
risk_model: RiskModel | None = None,
) -> OptimisationResult
Find the closest feasible portfolio to a target.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
target_weights
|
Series | dict[str, float]
|
What to track, by asset id. Defines the universe: the optimiser allocates over exactly these names, in this order. |
required |
constraints
|
Sequence[Constraint] | None
|
What the answer must satisfy. None means full investment alone, which is the smallest problem that has a unique answer. |
None
|
risk_model
|
RiskModel | None
|
Covariance to measure distance with. None treats every asset as equally risky and uncorrelated, which minimises plain squared weight distance. |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
OptimisationResult |
OptimisationResult
|
Optimal weights, the active position, which |
OptimisationResult
|
constraints bound, and how the solve went. |
Raises:
| Type | Description |
|---|---|
CalculationError
|
If the constraints cannot all be satisfied, if the solver fails to converge, or if the returned weights violate a constraint. Also if a constraint or the risk model refers to assets outside the universe. |
solve_constrained ¶
solve_constrained(
objective: Callable[[Vector], float],
gradient: Callable[[Vector], Vector],
rules: Sequence[Constraint],
assets: Sequence[str],
hint: Vector | None = None,
) -> Solution
Minimise objective over the weights, subject to rules.
The objective-agnostic core. Tracking error is one objective; portfolio variance, expected return and the Sharpe ratio are others, and all of them want the same constraint handling, the same infeasibility messages and the same refusal to return a violating answer.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
objective
|
Callable[[Vector], float]
|
What to minimise, as a function of the weight vector. |
required |
gradient
|
Callable[[Vector], Vector]
|
Its derivative. Required rather than optional: finite differences on a problem this small cost more accuracy than they save effort. |
required |
rules
|
Sequence[Constraint]
|
The constraints. |
required |
assets
|
Sequence[str]
|
The universe, fixing the meaning of each weight position. |
required |
hint
|
Vector | None
|
Where to start the search. None starts from equal weights. |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
Solution |
Solution
|
The verified answer. |
Raises:
| Type | Description |
|---|---|
CalculationError
|
If the constraints cannot all be satisfied, if the solver fails to converge, or if the weights violate a constraint. |
config ¶
The serialisable description of an optimisation.
A constraint round-trips through a {type, params} payload: an instance
becomes a payload of its registered class name and constructor parameter
values, and the payload becomes an instance that solves identically. This is
what lets an optimised index be cached, and it is the same shape the server
uses for constraint rows.
Only constraint classes registered in the catalogue can be serialised. An unregistered class is refused, never guessed at.
:class:OptimisationConfig is the object form of the same description: what
an optimised run is asked to do, held together so Backtest.run can take one
argument rather than a growing list of them.
OptimisationConfig
dataclass
¶
OptimisationConfig(
objective: str = MIN_TRACKING_ERROR,
constraints: Sequence[Constraint] = (),
risk_model: RiskModel | None = None,
)
What an optimised run is asked to do.
Attributes:
| Name | Type | Description |
|---|---|---|
objective |
str
|
What to minimise. Only |
constraints |
Sequence[Constraint]
|
What the answer must satisfy, as
:class: |
risk_model |
RiskModel | None
|
Reserved: carried but unused. The slot exists so covariance-aware optimised runs can arrive without changing this shape. Nothing reads it yet and passing one changes no result, though an optimised index carrying one cannot be cached. |
constraint_payload ¶
One constraint as its registered name plus parameter values.
Parameter names come from the catalogue's constructor introspection, each value read off the instance attribute of the same name. Every registered type follows that convention, and the cache fingerprint keys by it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
constraint
|
Constraint
|
The instance to describe. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
dict |
dict[str, Any]
|
|
dict[str, Any]
|
value JSON-serialisable. |
Raises:
| Type | Description |
|---|---|
CalculationError
|
If the class is not registered under the CONSTRAINT kind, keeps no attribute for one of its constructor parameters, or holds a value JSON cannot carry. Refused rather than guessed at: a payload nothing can rebuild is worse than no payload. |
constraint_from_payload ¶
Rebuild a constraint from a {type, params} payload.
The inverse of :func:constraint_payload, and the builder the server's
constraint rows go through: one code path from a stored document to the
object the solver receives.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
payload
|
Mapping[str, Any]
|
A mapping carrying |
required |
Returns:
| Name | Type | Description |
|---|---|---|
Constraint |
Constraint
|
A fresh instance that solves identically to the one the |
Constraint
|
payload was taken from. |
Raises:
| Type | Description |
|---|---|
CalculationError
|
If the type names nothing registered. A constructor rejecting the params (a missing argument, a minimum above a maximum) propagates untouched, so the class's own message reaches the caller. |
constraints ¶
Constraints an optimisation must respect.
Each class maps to one row a user adds in a constraint editor (position bounds, sector bounds, a turnover budget, a holding count, full investment), so the client can build a problem without translating between two vocabularies.
Every constraint states itself once, as :class:Condition objects in scipy's
own convention: an equality holds when its function is zero, an inequality when
its function is non-negative. That single statement is then used three ways:
it is handed to the solver, it decides which constraints came out binding, and
it verifies the returned weights actually satisfy what was asked. The three can
therefore never drift apart, which is the point: the verification pass exists
to catch a solver that returned something it should not have, and it would be
worthless if it checked a second, separately-written copy of the rules.
Condition
dataclass
¶
Condition(
label: str,
kind: str,
evaluate: Callable[[Vector], float],
gradient: Callable[[Vector], Vector] | None = None,
)
One scalar condition on a weight vector.
Attributes:
| Name | Type | Description |
|---|---|---|
label |
str
|
Human-readable description, carried through to the binding report so a client can show which rule bit. |
kind |
str
|
EQUALITY or INEQUALITY. |
evaluate |
Callable[[Vector], float]
|
The condition function. Zero when an equality holds, non-negative when an inequality holds. |
gradient |
Callable[[Vector], Vector] | None
|
Derivative with respect to the weights, when one can be written down. Supplying it keeps the solver off finite differences, which on a problem this small is most of the accuracy. |
Slack
dataclass
¶
How much room a condition has left at a given solution.
Attributes:
| Name | Type | Description |
|---|---|---|
label |
str
|
The condition's description. |
kind |
str
|
EQUALITY or INEQUALITY. |
slack |
float
|
Signed room. For an inequality this is the condition function itself, so zero means the solution sits exactly on the boundary and negative means it has crossed. For an equality it is the negated absolute residual, which makes an exactly-satisfied equality read as zero slack, correctly, since an equality is always binding. |
unit |
str
|
What |
Constraint ¶
Bases: ABC
Something an optimal weight vector must satisfy.
Subclasses state their rules as conditions, and everything else (solving, binding detection, verification) is derived from those.
Attributes:
| Name | Type | Description |
|---|---|---|
UNIT |
str
|
What this constraint's slack is measured in. :data: |
conditions
abstractmethod
¶
The conditions this constraint imposes over assets, in order.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
assets
|
Sequence[str]
|
The universe, fixing the meaning of each weight position. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
list |
list[Condition]
|
Conditions in scipy's convention. |
solver_conditions ¶
The subset of the conditions that should go to the solver.
Everything by default. A constraint overrides this to nothing when it reaches the solver some other way (as a box, or by restricting the problem) while still reporting its conditions for verification.
bounds ¶
Per-asset box limits, when this constraint is one.
A box handed to the solver as a bound is enforced at every iterate, whereas the same box as an inequality is only approached from outside; for position limits that difference is worth the special case.
Returns:
| Type | Description |
|---|---|
list[tuple[float, float]] | None
|
list or None: One |
list[tuple[float, float]] | None
|
constraint is not a box. |
report ¶
How much room this constraint has left at weights.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
weights
|
Vector
|
A candidate solution, aligned to assets. |
required |
assets
|
Sequence[str]
|
The universe. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
list |
list[Slack]
|
One Slack per condition, each stamped with this constraint's |
list[Slack]
|
attr: |
validate ¶
Raise if this constraint cannot be applied to assets at all.
Separate from feasibility: this catches a constraint that is malformed or refers to names that are not there, which is a caller mistake rather than an over-tight problem. It does nothing by default, and a subclass overrides it only when it has something to check.
FullInvestment ¶
Bases: Constraint
The weights must sum to a fixed total, normally one.
Attributes:
| Name | Type | Description |
|---|---|---|
target |
The total. One means fully invested with no leverage and no cash; below one leaves cash, above one is levered. |
PositionBounds ¶
Bases: Constraint
Lower and upper limits on individual weights.
Attributes:
| Name | Type | Description |
|---|---|---|
minimum |
Smallest weight any covered asset may take. Zero forbids short positions, which is the usual index-tracking case. |
|
maximum |
Largest weight any covered asset may take. |
|
assets |
Names this applies to, or None for every name. Several of these compose (a blanket rule plus a tighter one on a few names), and the tightest limit on each name wins. |
validate ¶
Reject a bound on a name that is not in the universe.
Unlike a group, a position bound names one asset explicitly, so a name that is not there is a typo rather than a broader definition being reused, and silently dropping it would return an answer that ignored a limit the caller asked for.
bounds ¶
The box, unlimited on any asset this constraint does not cover.
solver_conditions ¶
None: the solver receives this constraint as a box instead.
conditions ¶
The same box, written as inequalities.
Not given to the solver (:meth:bounds covers that), but used to
report which positions came out at a limit.
GroupBounds ¶
Bases: Constraint
Limits on the combined weight of a set of names.
A sector, a country, a liquidity bucket: anything the client groups by.
Attributes:
| Name | Type | Description |
|---|---|---|
name |
What the group is, used in the binding report. |
|
members |
The names in it. |
|
minimum |
Smallest combined weight the group may take. |
|
maximum |
Largest combined weight the group may take. |
validate ¶
Reject a group with no members in the universe.
Members outside the universe are dropped rather than rejected: a sector map is defined over a whole market and is meant to be reused across indices, so naming companies this index does not hold is normal. A group that matches nothing, though, is a mistake: it would silently constrain an empty sum, which is always satisfied.
TurnoverBudget ¶
Bases: Constraint
A limit on how far the solution may move from the current holdings.
Turnover here is one-way: half the sum of absolute weight changes, which under full investment is the amount bought, and equally the amount sold. A budget of 5% therefore means what an index methodology means by "5% turnover", not a 2.5% round trip.
Attributes:
| Name | Type | Description |
|---|---|---|
maximum |
The one-way budget, as a fraction of the portfolio. |
|
current_weights |
Where the portfolio is now. Names absent from it are treated as currently unheld. |
turnover ¶
One-way turnover of weights against the current holdings.
conditions ¶
The budget as one inequality.
The absolute value has a kink wherever an asset's weight equals its current weight, so this function is not differentiable everywhere and the gradient used is a subgradient: the sign vector, which is the true derivative away from those kinks and an arbitrary choice of one at them. SLSQP assumes smoothness and can in principle stall on a solution that sits exactly on many kinks at once. In practice it converges, and the verification pass refuses the answer if it does not: an exact formulation needs auxiliary variables for the positive and negative parts, which doubles the problem and is not worth it until a real case demands it.
ExpectedReturnTarget ¶
Bases: Constraint
The portfolio must be expected to return exactly this much.
The constraint that traces out a frontier: fix the return, minimise the variance, repeat. It is an equality rather than a floor because a frontier point is a specific point, and a floor would let every solve collapse onto the minimum-variance portfolio whenever that portfolio happened to clear the bar.
Attributes:
| Name | Type | Description |
|---|---|---|
expected_returns |
Expected return per asset, in the same units and over the same horizon as the risk model's covariance. Annualised, if the risk model is. |
|
target |
The portfolio return being asked for. |
validate ¶
Every asset must have an expected return.
Treating an absent one as zero would quietly bias the whole frontier towards holding it, since a zero-return asset looks like the safest way to satisfy a low return target.
Cardinality ¶
Bases: Constraint
A limit on how many names may be held.
Unlike every other constraint here this one is not convex: it counts
non-zero positions, and no continuous solver can express that. It is
honoured by a two-stage heuristic (see
:func:beacon.optimise.solver.minimise_tracking_error): solve, keep the
largest positions, then solve again with the rest pinned at zero. The
answer satisfies the limit but is not proven optimal, and the exact problem
is a mixed-integer program that would need a different solver entirely.
Attributes:
| Name | Type | Description |
|---|---|---|
maximum |
The largest number of names that may carry weight. |
solver_conditions ¶
None: counting holdings is a step function.
Its gradient is zero everywhere it is defined, so handing it to SLSQP would say that dropping a name costs nothing and changes nothing. The limit is enforced by restricting the problem instead.
conditions ¶
The count, as an inequality that is only ever checked.
one_way_turnover ¶
Half the summed absolute weight change between two portfolios.
The halving is what makes this one-way: under full investment every unit bought is a unit sold, so the undivided sum counts each trade twice.
frontier ¶
The efficient frontier, and the two points on it worth naming.
A frontier is not one optimisation but a sequence of them: fix the expected return, minimise the variance, repeat across a grid. Each point answers "if I insist on earning this much, what is the least risk I can take to do it", and the curve through them is the boundary of what is achievable.
Two points on that curve are singled out because they answer questions people actually ask:
- the minimum-variance portfolio: the least risky feasible portfolio, ignoring return entirely. It is the frontier's left-hand end, and the reason the grid starts there: portfolios with lower return than this exist, but each is beaten by one on the frontier with the same risk and more return.
- the tangency portfolio: the highest Sharpe ratio available, the point where a line from the risk-free rate first touches the frontier.
What constraints do to this¶
Textbook frontiers are drawn with only a budget constraint, which admits a closed form. Every real mandate has more than that, and once position bounds and group limits are in play there is no closed form and the curve has to be traced numerically. Two consequences worth stating plainly:
The frontier can be shorter than the unconstrained one at both ends (a cap limits how much can be put into the highest-returning asset, so the right-hand end stops early), and it lies below it everywhere in between, because every constraint removes portfolios and can only make the best remaining one worse.
Maximising the Sharpe ratio is not a convex problem in general. The solve is warm-started from the best point on the grid, which in the long-only fully invested case is enough for the answer to be the global one; the grid is returned alongside so a caller can see the curve the tangency point sits on.
FrontierPoint
dataclass
¶
FrontierPoint(
weights: Series,
volatility: float,
expected_return: float | None = None,
sharpe_ratio: float | None = None,
binding: list[str] = list(),
heuristic: bool = False,
)
One portfolio on the frontier.
Attributes:
| Name | Type | Description |
|---|---|---|
weights |
Series
|
The portfolio, indexed by asset id. |
volatility |
float
|
Annualised standard deviation, in the risk model's units. |
expected_return |
float | None
|
Portfolio expected return, or None when no expected returns were supplied, since a return cannot be reported if it was never given. |
sharpe_ratio |
float | None
|
Excess return over volatility, or None when the expected return is unknown or the volatility is negligible. |
binding |
list[str]
|
Labels of the constraints this point sits on. The interesting part of a frontier point: it says which rule is what stops the portfolio from doing better. |
heuristic |
bool
|
Whether a non-convex constraint forced a restricted re-solve, so this point is feasible but not proven optimal. |
EfficientFrontier
dataclass
¶
EfficientFrontier(
points: list[FrontierPoint],
minimum_variance: FrontierPoint,
tangency: FrontierPoint,
risk_free_rate: float = 0.0,
)
A traced frontier and the points on it worth naming.
Attributes:
| Name | Type | Description |
|---|---|---|
points |
list[FrontierPoint]
|
The grid, in increasing order of expected return. The first is the minimum-variance portfolio and the last is the highest return the constraints allow. |
minimum_variance |
FrontierPoint
|
The least risky feasible portfolio. |
tangency |
FrontierPoint
|
The highest Sharpe ratio available. |
risk_free_rate |
float
|
The rate the Sharpe ratios were computed against. |
expected_returns
property
¶
Each point's expected return, in grid order.
is_monotonic ¶
Whether risk rises with return across the grid.
The defining property of a frontier. It should always hold (insisting on more return can only cost risk), so a False here means a point failed to solve to optimality rather than that the curve is unusual.
weights_frame ¶
Every point's weights, points on the index and assets on the columns.
default_constraints ¶
Long-only and fully invested.
The frontier's default rather than full investment alone, because with shorting unbounded the maximum-return portfolio does not exist: short the worst asset without limit to fund the best, and the return grows forever. A frontier needs a right-hand end.
minimum_variance_portfolio ¶
minimum_variance_portfolio(
risk_model: RiskModel,
constraints: Sequence[Constraint] | None = None,
expected_returns: dict[str, float] | None = None,
risk_free_rate: float = 0.0,
) -> FrontierPoint
The least risky feasible portfolio.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
risk_model
|
RiskModel
|
The covariance to minimise against. |
required |
constraints
|
Sequence[Constraint] | None
|
What the answer must satisfy. None means long-only and fully invested. |
None
|
expected_returns
|
dict[str, float] | None
|
Used only to report the point's return and Sharpe ratio; it has no effect on the weights, since minimum variance ignores return by definition. |
None
|
risk_free_rate
|
float
|
For the reported Sharpe ratio. |
0.0
|
Returns:
| Name | Type | Description |
|---|---|---|
FrontierPoint |
FrontierPoint
|
The portfolio, its risk, and which constraints bound. |
Raises:
| Type | Description |
|---|---|
CalculationError
|
If the constraints cannot be satisfied. |
maximum_return_portfolio ¶
maximum_return_portfolio(
risk_model: RiskModel,
expected_returns: dict[str, float],
constraints: Sequence[Constraint] | None = None,
risk_free_rate: float = 0.0,
) -> FrontierPoint
The highest-returning feasible portfolio.
The frontier's right-hand end. Maximising a linear objective often has many optimal solutions (any mix of the top assets, if they tie), so the return is found first and the variance minimised subject to achieving it, which picks the sensible one out of the tie.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
risk_model
|
RiskModel
|
The covariance, used to break ties and report risk. |
required |
expected_returns
|
dict[str, float]
|
Expected return per asset. |
required |
constraints
|
Sequence[Constraint] | None
|
What the answer must satisfy. None means long-only and fully invested. |
None
|
risk_free_rate
|
float
|
For the reported Sharpe ratio. |
0.0
|
Returns:
| Name | Type | Description |
|---|---|---|
FrontierPoint |
FrontierPoint
|
The portfolio. |
efficient_frontier ¶
efficient_frontier(
risk_model: RiskModel,
expected_returns: dict[str, float],
points: int = DEFAULT_POINTS,
constraints: Sequence[Constraint] | None = None,
risk_free_rate: float = 0.0,
) -> EfficientFrontier
Trace the frontier, and locate its minimum-variance and tangency points.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
risk_model
|
RiskModel
|
The covariance. Its asset order defines the universe. |
required |
expected_returns
|
dict[str, float]
|
Expected return per asset, in the same units and over the same horizon as the covariance: annualised, if it is. |
required |
points
|
int
|
How many portfolios to solve for, minimum 2. |
DEFAULT_POINTS
|
constraints
|
Sequence[Constraint] | None
|
What every point must satisfy. None means long-only and fully invested. |
None
|
risk_free_rate
|
float
|
The rate Sharpe ratios are measured against. |
0.0
|
Returns:
| Name | Type | Description |
|---|---|---|
EfficientFrontier |
EfficientFrontier
|
The grid and the two named points. |
Raises:
| Type | Description |
|---|---|
CalculationError
|
If points is below 2, if the constraints cannot be satisfied, or if the problem is unbounded above. |
result ¶
OptimisationResult: the output of a solve.
Same shape as IndexResult, BacktestResult and RiskModel: a dataclass of
pandas structures with accessors that answer the questions a caller actually
has, rather than handing back a bare weight vector and leaving everyone to
recompute the active position and the turnover themselves.
BindingConstraint
dataclass
¶
A constraint the solution sits exactly on.
Binding constraints are the interesting part of an answer: they are the rules that actually cost something, and relaxing one of them is the only way to improve the objective.
Attributes:
| Name | Type | Description |
|---|---|---|
label |
str
|
What the constraint was. |
kind |
str
|
EQUALITY or INEQUALITY. |
slack |
float
|
Room left, at or near zero by definition of binding. Carried so a caller can see how tight "tight" was. |
unit |
str
|
What |
SolverDiagnostics
dataclass
¶
SolverDiagnostics(
converged: bool,
iterations: int,
evaluations: int,
objective: float,
status: int,
message: str,
)
What the solver did.
Attributes:
| Name | Type | Description |
|---|---|---|
converged |
bool
|
Whether the solver reported success. An answer is only returned when this is True and the weights pass verification, so a caller seeing this is seeing a solve that worked. |
iterations |
int
|
Major iterations taken. |
evaluations |
int
|
Objective evaluations. |
objective |
float
|
Final objective value. It is a variance, so the tracking error is its square root. |
status |
int
|
The solver's numeric exit code. |
message |
str
|
The solver's own description of how it exited. |
OptimisationResult
dataclass
¶
OptimisationResult(
weights: Series,
target_weights: Series,
binding: list[BindingConstraint],
diagnostics: SolverDiagnostics,
slacks: list[Slack] = list(),
heuristic: bool = False,
_risk_model: RiskModel | None = None,
)
Optimal weights, what they cost, and which rules bound.
Attributes:
| Name | Type | Description |
|---|---|---|
weights |
Series
|
The solution, indexed by asset id. |
target_weights |
Series
|
What the solve was tracking, on the same index. |
binding |
list[BindingConstraint]
|
Constraints the solution sits on, tightest first. |
diagnostics |
SolverDiagnostics
|
How the solve went. |
slacks |
list[Slack]
|
Every constraint's room at the solution, binding or not. Kept so a caller can see what nearly bound as well as what did. |
heuristic |
bool
|
Whether a non-convex constraint forced a heuristic stage, in which case the answer satisfies every constraint but is not proven optimal. |
active_weights
property
¶
Solution minus target: the active position.
Sums to zero whenever both sides are fully invested to the same total, which is the usual case and worth checking: a non-zero sum means the solve was allowed to change how much is invested, not just where.
with_risk_model ¶
Bind a risk model for risk-based accessors. Returns self.
tracking_error ¶
Distance from the target, in the metric the solve minimised.
With a risk model this is annualised tracking error, the quantity a tracking mandate is measured on. Without one the objective's identity covariance makes it the Euclidean distance between the two weight vectors: a sensible thing to minimise, but not a volatility, and not comparable to a number produced with a risk model.
turnover ¶
One-way turnover from current_weights to the solution.
Half the summed absolute weight change, matching
:class:~beacon.optimise.constraints.TurnoverBudget. Defaults to
measuring against the target, which answers "how far did the optimiser
move me off the index".
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
current_weights
|
dict[str, float] | None
|
Where the portfolio is now. None measures against the target weights. |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
float |
float
|
One-way turnover as a fraction of the portfolio. |
to_frame ¶
Target, optimal and active weights side by side, largest active first.
solver ¶
Constrained minimisation, and the tracking-error objective built on it.
:func:solve_constrained is the objective-agnostic core: give it something to
minimise and a list of constraints and it handles the box, the infeasibility
messages, the non-convex cardinality stage and the verification pass. The
frontier objectives in beacon.optimise.frontier use the same core, so the
guarantees below hold for all of them.
The first objective an index business needs is not mean-variance: it is "get me as close as possible to this target, subject to what I am allowed to hold". That is what this solves: minimise
(w - b)ᵀ Σ (w - b)
over the weights w, given a target b and whatever constraints were
supplied. With a risk model, Σ is its covariance and the objective is squared
tracking error. Without one, Σ is the identity and the objective is the squared
distance between the two weight vectors, which is the same problem with every
asset treated as equally risky and uncorrelated. The two share a code path
because they are the same problem; only the metric differs.
The objective is convex (Σ is positive semi-definite by construction), and every constraint but cardinality is convex (linear, apart from the turnover budget), so a local optimum is the global one and SLSQP is an appropriate solver.
Refusing rather than fudging¶
A solve either returns an answer that satisfies every constraint or it raises. There is no third outcome where a violating vector comes back with a warning attached, because a weight vector is the kind of thing a caller will act on and an ignored warning becomes a breached mandate. Two checks enforce that: cheap provable-infeasibility tests before solving, which produce a message naming what is impossible rather than an opaque solver code, and a verification pass afterwards that re-evaluates every constraint against the returned weights.
Non-convergence raises for the same reason. A stalled solve leaves a feasible
but not-necessarily-optimal point, and returning it with converged=False in
the diagnostics would make "this is the best answer" and "this is merely an
answer" look identical to anyone who does not check the flag.
Solution
dataclass
¶
A verified answer to a constrained problem.
Attributes:
| Name | Type | Description |
|---|---|---|
weights |
Vector
|
The solution vector, aligned to the universe. |
outcome |
Any
|
The solver's own result object. |
slacks |
list[Slack]
|
Every constraint's room at the solution. |
heuristic |
bool
|
Whether a non-convex constraint forced a restricted re-solve, in which case the answer is feasible but not proven optimal. |
minimise_tracking_error ¶
minimise_tracking_error(
target_weights: Series | dict[str, float],
constraints: Sequence[Constraint] | None = None,
risk_model: RiskModel | None = None,
) -> OptimisationResult
Find the closest feasible portfolio to a target.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
target_weights
|
Series | dict[str, float]
|
What to track, by asset id. Defines the universe: the optimiser allocates over exactly these names, in this order. |
required |
constraints
|
Sequence[Constraint] | None
|
What the answer must satisfy. None means full investment alone, which is the smallest problem that has a unique answer. |
None
|
risk_model
|
RiskModel | None
|
Covariance to measure distance with. None treats every asset as equally risky and uncorrelated, which minimises plain squared weight distance. |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
OptimisationResult |
OptimisationResult
|
Optimal weights, the active position, which |
OptimisationResult
|
constraints bound, and how the solve went. |
Raises:
| Type | Description |
|---|---|
CalculationError
|
If the constraints cannot all be satisfied, if the solver fails to converge, or if the returned weights violate a constraint. Also if a constraint or the risk model refers to assets outside the universe. |
solve_constrained ¶
solve_constrained(
objective: Callable[[Vector], float],
gradient: Callable[[Vector], Vector],
rules: Sequence[Constraint],
assets: Sequence[str],
hint: Vector | None = None,
) -> Solution
Minimise objective over the weights, subject to rules.
The objective-agnostic core. Tracking error is one objective; portfolio variance, expected return and the Sharpe ratio are others, and all of them want the same constraint handling, the same infeasibility messages and the same refusal to return a violating answer.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
objective
|
Callable[[Vector], float]
|
What to minimise, as a function of the weight vector. |
required |
gradient
|
Callable[[Vector], Vector]
|
Its derivative. Required rather than optional: finite differences on a problem this small cost more accuracy than they save effort. |
required |
rules
|
Sequence[Constraint]
|
The constraints. |
required |
assets
|
Sequence[str]
|
The universe, fixing the meaning of each weight position. |
required |
hint
|
Vector | None
|
Where to start the search. None starts from equal weights. |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
Solution |
Solution
|
The verified answer. |
Raises:
| Type | Description |
|---|---|
CalculationError
|
If the constraints cannot all be satisfied, if the solver fails to converge, or if the weights violate a constraint. |
covariance_matrix ¶
The metric the objective measures distance in.
The identity is not a placeholder standing in for a missing model: it is the honest statement that without one, every unit of active weight is equally costly wherever it is taken.
weight_box ¶
Intersect every position-bound constraint into one box.
Several bounds may cover the same asset (a blanket rule plus a tighter one on a few names), and the answer must satisfy all of them, so the tightest limit on each side wins.
Returns:
| Name | Type | Description |
|---|---|---|
tuple |
Vector
|
The lower and upper bound per asset, aligned to assets. |
Vector
|
Unbounded sides are infinite. |