"""Optimization constraint and utility helpers.
Convenience functions for building constraints commonly used in
portfolio and general optimisation problems.
"""
from __future__ import annotations
from typing import Any
import numpy as np
import numpy.typing as npt
from wraquant.core._coerce import coerce_array
[docs]
def weight_constraint(
n_assets: int,
lb: float = 0.0,
ub: float = 1.0,
) -> dict[str, Any]:
"""Generate uniform bounds for portfolio weights.
Parameters:
n_assets: Number of assets.
lb: Lower bound for each weight.
ub: Upper bound for each weight.
Returns:
Dict with key ``bounds`` — a list of ``(lb, ub)`` tuples, one per
asset.
"""
return {"bounds": [(lb, ub)] * n_assets}
[docs]
def sum_to_one_constraint(n_assets: int) -> dict[str, Any]:
"""Equality constraint requiring weights to sum to one.
Compatible with :func:`scipy.optimize.minimize` constraint format.
Parameters:
n_assets: Number of assets (used only for documentation /
introspection; the constraint function works for any length).
Returns:
Scipy-style constraint dict ``{'type': 'eq', 'fun': ...}``.
"""
return {
"type": "eq",
"fun": lambda w: float(np.sum(w) - 1.0),
"n_assets": n_assets,
}
[docs]
def sector_constraints(
n_assets: int,
sectors: dict[str, list[int]],
sector_limits: dict[str, tuple[float, float]],
) -> list[dict[str, Any]]:
"""Build inequality constraints for sector / group weight limits.
Parameters:
n_assets: Total number of assets.
sectors: Mapping of sector name to list of asset indices belonging
to that sector.
sector_limits: Mapping of sector name to ``(min_weight, max_weight)``
tuple for the aggregate sector weight.
Returns:
List of scipy-style constraint dicts (``'ineq'`` type). Two
constraints per sector — one for the lower bound and one for the
upper bound.
Example:
>>> cons = sector_constraints(
... 5,
... sectors={"tech": [0, 1], "energy": [2, 3, 4]},
... sector_limits={"tech": (0.1, 0.5), "energy": (0.2, 0.6)},
... ) # doctest: +SKIP
"""
result: list[dict[str, Any]] = []
for sector_name, indices in sectors.items():
if sector_name not in sector_limits:
continue
lb, ub = sector_limits[sector_name]
idx = list(indices)
# sum(w[idx]) >= lb => sum(w[idx]) - lb >= 0
result.append(
{
"type": "ineq",
"fun": lambda w, _idx=idx, _lb=lb: float(np.sum(w[_idx]) - _lb),
"sector": sector_name,
"bound": "lower",
}
)
# sum(w[idx]) <= ub => ub - sum(w[idx]) >= 0
result.append(
{
"type": "ineq",
"fun": lambda w, _idx=idx, _ub=ub: float(_ub - np.sum(w[_idx])),
"sector": sector_name,
"bound": "upper",
}
)
return result
[docs]
def turnover_constraint(
current_weights: npt.NDArray[np.floating],
max_turnover: float,
) -> dict[str, Any]:
"""Constraint limiting portfolio turnover.
Turnover is defined as ``0.5 * sum(|w_new - w_old|)``.
Parameters:
current_weights: Current portfolio weights ``(n,)``.
max_turnover: Maximum allowed one-way turnover (e.g. ``0.20``
for 20 %).
Returns:
Scipy-style inequality constraint dict.
"""
w_old = coerce_array(current_weights, "current_weights")
return {
"type": "ineq",
"fun": lambda w, _w_old=w_old, _mt=max_turnover: float(
_mt - 0.5 * np.sum(np.abs(np.asarray(w) - _w_old))
),
}
[docs]
def cardinality_constraint(
n_assets: int,
max_holdings: int,
) -> dict[str, Any]:
"""Information dict describing a cardinality constraint.
Cardinality constraints (limiting the number of non-zero weights)
are not directly expressible as smooth inequality constraints for
gradient-based solvers. This helper returns a description dict
that can be consumed by MILP-based or heuristic optimisers.
Parameters:
n_assets: Total number of assets.
max_holdings: Maximum number of assets with non-zero weight.
Returns:
Dict with keys ``n_assets``, ``max_holdings``, and
``description``.
"""
return {
"n_assets": n_assets,
"max_holdings": max_holdings,
"description": (
f"At most {max_holdings} of {n_assets} assets may have " "non-zero weight."
),
}