Skip to content

markowitz.optimizer

markowitz.optimizer

Numerical convex-optimization layer for mean-variance portfolios.

This sub-package exposes the :class:MeanVariance optimizer (a thin wrapper around CVXPY), a small DSL of pluggable constraints, and the Cornuejols--Tutuncu reformulation used to recover the tangency portfolio as a convex sub-problem.

The public surface is intentionally small; see the individual module docstrings for the full contract.

Constraint

Bases: Protocol

Anything that knows how to attach itself to a CVXPY problem.

ConstraintContext(tickers: tuple[str, ...], n_assets: int, long_only: bool = False, extra: Mapping[str, Any] = dict()) dataclass

Read-only snapshot of optimizer state available to a constraint.

The context is constructed once per solve call and passed to every constraint's :meth:Constraint.apply_cvxpy method, so each constraint can use the canonical ticker ordering without making assumptions about the optimizer's internals.

CustomConstraint(builder: Callable[[cp.Variable, ConstraintContext], list[cp.Constraint]], description: str = 'custom') dataclass

Escape hatch for user-defined CVXPY constraints.

builder is a callable (w, ctx) -> list[cvxpy.Constraint]; it is invoked freshly each solve so it should not close over solver state.

Source code in src/markowitz/optimizer/constraints.py
def __init__(
    self,
    builder: Callable[[cp.Variable, ConstraintContext], list[cp.Constraint]],
    description: str = "custom",
) -> None:
    object.__setattr__(self, "builder", builder)
    object.__setattr__(self, "_description", description)

InfeasibleError(message: str, *, solver_status: str | None = None, solver_message: str | None = None, constraints_violated: list[str] | None = None)

Bases: OptimizationError

Raised when the feasible region is empty.

Either the solver returned infeasible / infeasible_inaccurate, or an upfront sanity check (e.g. max(mu) <= rf for max-Sharpe) detected that no admissible portfolio exists.

Source code in src/markowitz/optimizer/exceptions.py
def __init__(
    self,
    message: str,
    *,
    solver_status: str | None = None,
    solver_message: str | None = None,
    constraints_violated: list[str] | None = None,
) -> None:
    super().__init__(message)
    self.solver_status = solver_status
    self.solver_message = solver_message
    self.constraints_violated = constraints_violated

LeverageCap(max_leverage: float) dataclass

Gross-exposure cap sum(|w_i|) <= max_leverage.

LongOnly() dataclass

No short sales: w_i >= 0 for every asset.

MeanVariance(mu: pd.Series | np.ndarray, sigma: pd.DataFrame | np.ndarray, weight_bounds: tuple[float | None, float | None] = (0.0, 1.0), *, solver: str = 'CLARABEL', solver_options: dict[str, Any] | None = None, regularize: float = _REGULARIZE_DEFAULT)

Convex mean-variance optimizer over a single horizon.

Parameters:

Name Type Description Default
mu Series | ndarray

Expected returns; either a pandas.Series indexed by ticker or a 1-D numpy array. When an ndarray is passed the assets are named "A0", "A1", ....

required
sigma DataFrame | ndarray

Covariance matrix; either a pandas.DataFrame whose row / column index matches mu or a 2-D numpy array.

required
weight_bounds tuple[float | None, float | None]

Default (lower, upper) box constraint applied to every asset. Use (0.0, 1.0) for long-only, (-1.0, 1.0) for unconstrained long-short. Pass (None, None) to disable the box.

(0.0, 1.0)
solver str

CVXPY solver name forwarded to :func:solve_problem.

'CLARABEL'
solver_options dict[str, Any] | None

Extra keyword arguments forwarded to the solver backend.

None
regularize float

Ridge term added to Sigma (Sigma + regularize * I) before building the QP. Helps numerical stability on ill-conditioned covariance matrices; should be left at 0 for parity with analytical baselines.

_REGULARIZE_DEFAULT
Source code in src/markowitz/optimizer/mean_variance.py
def __init__(
    self,
    mu: pd.Series | np.ndarray,
    sigma: pd.DataFrame | np.ndarray,
    weight_bounds: tuple[float | None, float | None] = (0.0, 1.0),
    *,
    solver: str = "CLARABEL",
    solver_options: dict[str, Any] | None = None,
    regularize: float = _REGULARIZE_DEFAULT,
) -> None:
    if not _CVXPY_AVAILABLE:  # pragma: no cover - tested via importorskip
        raise ImportError(
            "MeanVariance requires the optional 'cvxpy' dependency. "
            "Install with: pip install 'markowitz-optimizer[robust]'"
        ) from _CVXPY_IMPORT_ERROR

    tickers, mu_arr = _canonicalise_mu(mu)
    sigma_arr = _canonicalise_sigma(sigma, tickers)

    if regularize < 0:
        raise ValueError("regularize must be non-negative")
    if regularize > 0:
        sigma_arr = sigma_arr + regularize * np.eye(sigma_arr.shape[0])

    self._tickers: tuple[str, ...] = tuple(tickers)
    self._mu: np.ndarray = mu_arr
    self._sigma: np.ndarray = sigma_arr
    self._weight_bounds: tuple[float | None, float | None] = weight_bounds
    self._solver: str = solver
    self._solver_options: dict[str, Any] = dict(solver_options or {})
    self._regularize: float = float(regularize)
    self._constraints: list[Constraint] = []
    self._last_weights: pd.Series | None = None

add_constraint(constraint: Constraint) -> MeanVariance

Append a constraint and return self for chaining.

Source code in src/markowitz/optimizer/mean_variance.py
def add_constraint(self, constraint: Constraint) -> MeanVariance:
    """Append a constraint and return ``self`` for chaining."""
    self._constraints.append(constraint)
    return self

clear_constraints() -> MeanVariance

Drop every previously-added constraint.

Source code in src/markowitz/optimizer/mean_variance.py
def clear_constraints(self) -> MeanVariance:
    """Drop every previously-added constraint."""
    self._constraints.clear()
    return self

efficient_return(target_return: float) -> pd.Series

Minimise variance subject to mu^T w >= target_return.

Source code in src/markowitz/optimizer/mean_variance.py
def efficient_return(self, target_return: float) -> pd.Series:
    """Minimise variance subject to ``mu^T w >= target_return``."""
    w = _cp.Variable(self.n_assets, name="w")
    objective = _cp.Minimize(_cp.quad_form(w, _cp.psd_wrap(self._sigma)))
    constraints = (
        self._base_constraints(w)
        + self._user_constraints(w)
        + [self._mu @ w >= float(target_return)]
    )
    problem = _cp.Problem(objective, constraints)
    try:
        solve_problem(
            problem,
            solver=self._solver,
            solver_options=self._solver_options,
        )
    except OptimizationError as exc:
        if isinstance(exc, InfeasibleError):
            raise InfeasibleError(
                f"No feasible portfolio attains target_return={target_return}.",
                solver_status=exc.solver_status,
            ) from exc
        raise
    return self._finalise(w.value)

efficient_risk(target_volatility: float) -> pd.Series

Maximise mu^T w subject to sqrt(w^T Sigma w) <= target_vol.

Source code in src/markowitz/optimizer/mean_variance.py
def efficient_risk(self, target_volatility: float) -> pd.Series:
    """Maximise ``mu^T w`` subject to ``sqrt(w^T Sigma w) <= target_vol``."""
    if target_volatility <= 0:
        raise ValueError("target_volatility must be strictly positive")
    w = _cp.Variable(self.n_assets, name="w")
    objective = _cp.Maximize(self._mu @ w)
    risk_cap = _cp.quad_form(w, _cp.psd_wrap(self._sigma)) <= (float(target_volatility) ** 2)
    constraints = self._base_constraints(w) + self._user_constraints(w) + [risk_cap]
    problem = _cp.Problem(objective, constraints)
    solve_problem(
        problem,
        solver=self._solver,
        solver_options=self._solver_options,
    )
    return self._finalise(w.value)

max_quadratic_utility(risk_aversion: float = 1.0) -> pd.Series

Maximise mu^T w - (lambda/2) w^T Sigma w.

With risk_aversion = lambda. lambda > 0 is required.

Source code in src/markowitz/optimizer/mean_variance.py
def max_quadratic_utility(self, risk_aversion: float = 1.0) -> pd.Series:
    """Maximise ``mu^T w - (lambda/2) w^T Sigma w``.

    With ``risk_aversion = lambda``.  ``lambda > 0`` is required.
    """
    if risk_aversion <= 0:
        raise ValueError("risk_aversion must be strictly positive")
    w = _cp.Variable(self.n_assets, name="w")
    utility = self._mu @ w - 0.5 * risk_aversion * _cp.quad_form(w, _cp.psd_wrap(self._sigma))
    objective = _cp.Maximize(utility)
    constraints = self._base_constraints(w) + self._user_constraints(w)
    problem = _cp.Problem(objective, constraints)
    solve_problem(
        problem,
        solver=self._solver,
        solver_options=self._solver_options,
    )
    return self._finalise(w.value)

max_sharpe(risk_free_rate: float = 0.0) -> pd.Series

Maximise the Sharpe ratio via the Cornuejols--Tutuncu reformulation.

Source code in src/markowitz/optimizer/mean_variance.py
def max_sharpe(self, risk_free_rate: float = 0.0) -> pd.Series:
    """Maximise the Sharpe ratio via the Cornuejols--Tutuncu reformulation."""
    long_only = bool(self._weight_bounds[0] is not None and self._weight_bounds[0] >= 0)
    detect_degeneracy(self._mu, risk_free_rate, self._weight_bounds, long_only=long_only)

    # Translate the user's linear (in w) constraints into y-space:
    # the substitution w = y / kappa means any "Aw <= b * 1" becomes
    # "Ay <= b * kappa".  Box constraints we already wired in above.
    # For now we only allow user constraints on the *standard* QP path
    # (min-vol / efficient-* / max-quadratic-utility).  If users have
    # added extra constraints we still respect the basic LongOnly /
    # WeightBounds ones via reformulation; richer constraints fall
    # through to a runtime error rather than being silently dropped.
    extra_builders = self._collect_y_space_builders()

    reform = reformulate_max_sharpe(
        self._mu,
        self._sigma,
        risk_free_rate=risk_free_rate,
        weight_bounds=self._weight_bounds,
        long_only=long_only,
        extra_constraint_builders=extra_builders,
    )
    solve_problem(
        reform.problem,
        solver=self._solver,
        solver_options=self._solver_options,
    )
    if reform.y.value is None:  # pragma: no cover - defensive
        raise SolverError(
            "Max-Sharpe reformulation produced no y vector.",
            solver_status="no_solution",
        )
    weights = back_transform(np.asarray(reform.y.value))
    return self._finalise(weights)

min_volatility() -> pd.Series

Minimise w^T Sigma w subject to sum(w) = 1 and constraints.

Source code in src/markowitz/optimizer/mean_variance.py
def min_volatility(self) -> pd.Series:
    """Minimise ``w^T Sigma w`` subject to ``sum(w) = 1`` and constraints."""
    w = _cp.Variable(self.n_assets, name="w")
    objective = _cp.Minimize(_cp.quad_form(w, _cp.psd_wrap(self._sigma)))
    constraints = self._base_constraints(w) + self._user_constraints(w)
    problem = _cp.Problem(objective, constraints)
    solve_problem(
        problem,
        solver=self._solver,
        solver_options=self._solver_options,
    )
    return self._finalise(w.value)

portfolio_performance(weights: pd.Series | np.ndarray | None = None, *, risk_free_rate: float = 0.0) -> tuple[float, float, float]

Return (expected_return, volatility, sharpe_ratio).

If weights is None the most-recently-computed weights are used; an error is raised when no solve has happened yet.

Source code in src/markowitz/optimizer/mean_variance.py
def portfolio_performance(
    self,
    weights: pd.Series | np.ndarray | None = None,
    *,
    risk_free_rate: float = 0.0,
) -> tuple[float, float, float]:
    """Return ``(expected_return, volatility, sharpe_ratio)``.

    If ``weights`` is ``None`` the most-recently-computed weights are
    used; an error is raised when no solve has happened yet.
    """
    if weights is None:
        if self._last_weights is None:
            raise ValueError("No weights available -- call an optimizer first or pass weights.")
        w_arr = self._last_weights.to_numpy(dtype=float)
    else:
        w_arr = _align_weights(weights, self._tickers)

    expected_return = float(self._mu @ w_arr)
    variance = float(w_arr @ self._sigma @ w_arr)
    volatility = float(np.sqrt(max(variance, 0.0)))
    if volatility <= 0.0:
        sharpe = float("nan")
    else:
        sharpe = (expected_return - float(risk_free_rate)) / volatility
    return expected_return, volatility, sharpe

NumericalError(message: str, *, solver_status: str | None = None, solver_message: str | None = None, constraints_violated: list[str] | None = None)

Bases: OptimizationError

Raised when a derived numerical operation cannot proceed.

Used for cases where the solver itself succeeded but a downstream arithmetic step is undefined -- e.g. attempting the Cornuejols--Tutuncu back-transform when sum(y) is effectively zero.

Source code in src/markowitz/optimizer/exceptions.py
def __init__(
    self,
    message: str,
    *,
    solver_status: str | None = None,
    solver_message: str | None = None,
    constraints_violated: list[str] | None = None,
) -> None:
    super().__init__(message)
    self.solver_status = solver_status
    self.solver_message = solver_message
    self.constraints_violated = constraints_violated

OptimizationError(message: str, *, solver_status: str | None = None, solver_message: str | None = None, constraints_violated: list[str] | None = None)

Bases: Exception

Base class for any failure originating in the optimizer layer.

Parameters:

Name Type Description Default
message str

Human-readable description of what went wrong.

required
solver_status str | None

The raw status string returned by the underlying solver (e.g. "infeasible", "unbounded", "solver_error"). May be None when the error is raised before any solver was invoked (e.g. by an upfront feasibility check).

None
solver_message str | None

Free-form additional message produced by the solver, when available.

None
constraints_violated list[str] | None

Names of the constraints that the post-solve verification step flagged as violated, when the error was raised after a solve.

None
Source code in src/markowitz/optimizer/exceptions.py
def __init__(
    self,
    message: str,
    *,
    solver_status: str | None = None,
    solver_message: str | None = None,
    constraints_violated: list[str] | None = None,
) -> None:
    super().__init__(message)
    self.solver_status = solver_status
    self.solver_message = solver_message
    self.constraints_violated = constraints_violated

SectorCap(sector_mapping: Mapping[str, str], caps: Mapping[str, float], floors: Mapping[str, float] | None = None) dataclass

Per-sector exposure caps (and optional floors).

sector_mapping maps each ticker to the name of its sector; caps (and optionally floors) map sector names to scalar weight bounds. Tickers not mentioned in the mapping are simply ignored from the grouping (treated as their own singleton "sector").

SolverError(message: str, *, solver_status: str | None = None, solver_message: str | None = None, constraints_violated: list[str] | None = None)

Bases: OptimizationError

Raised when the solver fails for a reason other than (in)feasibility.

Examples include solver-internal numerical breakdowns, license errors, or a status string the optimizer does not recognise.

Source code in src/markowitz/optimizer/exceptions.py
def __init__(
    self,
    message: str,
    *,
    solver_status: str | None = None,
    solver_message: str | None = None,
    constraints_violated: list[str] | None = None,
) -> None:
    super().__init__(message)
    self.solver_status = solver_status
    self.solver_message = solver_message
    self.constraints_violated = constraints_violated

SolverResult(status: str, objective_value: float, solver_name: str) dataclass

Outcome of a successful solve.

TurnoverCap(prev_weights: pd.Series | np.ndarray, max_turnover: float) dataclass

L1 turnover budget against a previous-period weight vector.

sum(|w - prev|) <= max_turnover.

UnboundedError(message: str, *, solver_status: str | None = None, solver_message: str | None = None, constraints_violated: list[str] | None = None)

Bases: OptimizationError

Raised when the solver reports the objective is unbounded.

This typically signals a missing leverage / box constraint on a problem that permits arbitrarily large weights.

Source code in src/markowitz/optimizer/exceptions.py
def __init__(
    self,
    message: str,
    *,
    solver_status: str | None = None,
    solver_message: str | None = None,
    constraints_violated: list[str] | None = None,
) -> None:
    super().__init__(message)
    self.solver_status = solver_status
    self.solver_message = solver_message
    self.constraints_violated = constraints_violated

WeightBounds(lower: float | Sequence[float], upper: float | Sequence[float]) dataclass

Element-wise box constraint lower <= w_i <= upper.

The bounds may be supplied as scalars (broadcast across all assets) or as iterables of length n_assets.

back_transform(y_value: np.ndarray) -> np.ndarray

Recover the portfolio weights w = y / sum(y).

Raises:

Type Description
NumericalError

If sum(y) is below :data:_BACK_TRANSFORM_EPS; in that regime the back-transform is numerically meaningless.

Source code in src/markowitz/optimizer/cornuejols_tutuncu.py
def back_transform(y_value: np.ndarray) -> np.ndarray:
    """Recover the portfolio weights ``w = y / sum(y)``.

    Raises
    ------
    NumericalError
        If ``sum(y)`` is below :data:`_BACK_TRANSFORM_EPS`; in that regime
        the back-transform is numerically meaningless.
    """
    y_arr = np.asarray(y_value, dtype=float).reshape(-1)
    total = float(np.sum(y_arr))
    if not np.isfinite(total) or abs(total) <= _BACK_TRANSFORM_EPS:
        raise NumericalError(
            f"Cornuejols--Tutuncu back-transform failed: sum(y) is numerically zero ({total!r}).",
            solver_status="degenerate",
        )
    return y_arr / total

detect_degeneracy(mu: np.ndarray, risk_free_rate: float, weight_bounds: tuple[float | None, float | None] | None, long_only: bool) -> None

Raise :class:InfeasibleError if no positive-Sharpe portfolio exists.

The reformulation requires that the equality (mu - rf)^T y = 1 is achievable with the sign structure permitted by the bounds. In the long-only / non-negative-weights case this simply means at least one asset must have a strictly positive excess return.

Source code in src/markowitz/optimizer/cornuejols_tutuncu.py
def detect_degeneracy(
    mu: np.ndarray,
    risk_free_rate: float,
    weight_bounds: tuple[float | None, float | None] | None,
    long_only: bool,
) -> None:
    """Raise :class:`InfeasibleError` if no positive-Sharpe portfolio exists.

    The reformulation requires that the equality ``(mu - rf)^T y = 1`` is
    achievable with the sign structure permitted by the bounds.  In the
    long-only / non-negative-weights case this simply means at least one
    asset must have a strictly positive excess return.
    """
    del weight_bounds  # used by callers for richer diagnostics; not needed here
    excess = np.asarray(mu, dtype=float) - float(risk_free_rate)
    if long_only and float(np.max(excess)) <= 0.0:
        raise InfeasibleError(
            "Max-Sharpe is degenerate: no asset has excess return above the "
            "risk-free rate, so the long-only tangency portfolio is "
            "undefined.",
            solver_status="degenerate",
        )
    if not long_only and float(np.max(np.abs(excess))) <= 0.0:
        raise InfeasibleError(
            "Max-Sharpe is degenerate: all assets have zero excess return.",
            solver_status="degenerate",
        )

reformulate_max_sharpe(mu: np.ndarray, Sigma: np.ndarray, risk_free_rate: float, weight_bounds: tuple[float | None, float | None] | None, *, long_only: bool = True, extra_constraint_builders: Sequence[Any] | None = None) -> ReformulatedProblem

Build the CVXPY problem in (y, kappa) space.

Parameters:

Name Type Description Default
mu ndarray

Expected returns and covariance matrix.

required
Sigma ndarray

Expected returns and covariance matrix.

required
risk_free_rate float

Reference rate rf; the excess returns mu - rf define the Sharpe numerator.

required
weight_bounds tuple[float | None, float | None] | None

Box (lower, upper) applied in weight space. Each bound is translated into a y-space constraint of the form y_i >= lower * kappa / y_i <= upper * kappa. Pass None to omit box constraints entirely.

required
long_only bool

If True, also enforces y >= 0. When weight_bounds already implies lower >= 0 this is redundant but harmless.

True
extra_constraint_builders Sequence[Any] | None

Optional iterable of callables (y, kappa) -> list[cvxpy.Constraint] through which callers can inject additional linear constraints in y-space. Non-linear constraints are not supported by the reformulation and should be applied to the standard QP path instead.

None
Source code in src/markowitz/optimizer/cornuejols_tutuncu.py
def reformulate_max_sharpe(
    mu: np.ndarray,
    Sigma: np.ndarray,
    risk_free_rate: float,
    weight_bounds: tuple[float | None, float | None] | None,
    *,
    long_only: bool = True,
    extra_constraint_builders: Sequence[Any] | None = None,
) -> ReformulatedProblem:
    """Build the CVXPY problem in ``(y, kappa)`` space.

    Parameters
    ----------
    mu, Sigma:
        Expected returns and covariance matrix.
    risk_free_rate:
        Reference rate ``rf``; the excess returns ``mu - rf`` define the
        Sharpe numerator.
    weight_bounds:
        Box ``(lower, upper)`` applied *in weight space*.  Each bound is
        translated into a ``y``-space constraint of the form
        ``y_i >= lower * kappa`` / ``y_i <= upper * kappa``.  Pass ``None``
        to omit box constraints entirely.
    long_only:
        If ``True``, also enforces ``y >= 0``.  When ``weight_bounds`` already
        implies ``lower >= 0`` this is redundant but harmless.
    extra_constraint_builders:
        Optional iterable of callables ``(y, kappa) -> list[cvxpy.Constraint]``
        through which callers can inject additional linear constraints in
        ``y``-space.  Non-linear constraints are not supported by the
        reformulation and should be applied to the standard QP path instead.
    """
    # deferred import - cvxpy is optional
    import cvxpy as cp_  # noqa: PLC0415

    mu = np.asarray(mu, dtype=float).reshape(-1)
    Sigma = np.asarray(Sigma, dtype=float)
    n = mu.shape[0]
    if Sigma.shape != (n, n):
        raise ValueError(f"Sigma shape {Sigma.shape} incompatible with mu length {n}")

    excess = mu - float(risk_free_rate)

    y = cp_.Variable(n, name="y")
    kappa = cp_.Variable(nonneg=True, name="kappa")

    constraints: list[cp.Constraint] = [
        excess @ y == 1.0,
        cp_.sum(y) == kappa,
    ]
    if long_only:
        constraints.append(y >= 0)
    if weight_bounds is not None:
        lower, upper = weight_bounds
        if lower is not None:
            constraints.append(y >= float(lower) * kappa)
        if upper is not None:
            constraints.append(y <= float(upper) * kappa)
    if extra_constraint_builders:
        for builder in extra_constraint_builders:
            constraints.extend(builder(y, kappa))

    objective = cp_.Minimize(cp_.quad_form(y, cp_.psd_wrap(Sigma)))
    problem = cp_.Problem(objective, constraints)
    return ReformulatedProblem(problem=problem, y=y, kappa=kappa)

solve_problem(problem: cp.Problem, *, solver: str = 'CLARABEL', solver_options: dict[str, Any] | None = None) -> SolverResult

Solve problem and translate failures to typed exceptions.

Parameters:

Name Type Description Default
problem Problem

The CVXPY problem to solve.

required
solver str

Solver name passed straight through to Problem.solve. Defaults to CLARABEL which ships with CVXPY 1.5+ and handles QPs and SOCPs without a separate install.

'CLARABEL'
solver_options dict[str, Any] | None

Additional keyword arguments forwarded to the solver backend.

None
Source code in src/markowitz/optimizer/solvers.py
def solve_problem(
    problem: cp.Problem,
    *,
    solver: str = "CLARABEL",
    solver_options: dict[str, Any] | None = None,
) -> SolverResult:
    """Solve ``problem`` and translate failures to typed exceptions.

    Parameters
    ----------
    problem:
        The CVXPY problem to solve.
    solver:
        Solver name passed straight through to ``Problem.solve``.  Defaults
        to ``CLARABEL`` which ships with CVXPY 1.5+ and handles QPs and
        SOCPs without a separate install.
    solver_options:
        Additional keyword arguments forwarded to the solver backend.
    """
    options = dict(solver_options or {})
    try:
        problem.solve(solver=solver, **options)
    except Exception as exc:
        raise SolverError(
            f"Solver {solver!r} raised an exception: {exc}",
            solver_status="solver_error",
            solver_message=str(exc),
        ) from exc

    status = str(problem.status) if problem.status is not None else "unknown"
    objective_value = (
        float(problem.value)
        if problem.value is not None and _is_finite(problem.value)
        else float("nan")
    )

    if status in _OK_STATUSES:
        return SolverResult(
            status=status,
            objective_value=objective_value,
            solver_name=solver,
        )
    if status in _INFEASIBLE_STATUSES:
        raise InfeasibleError(
            f"Problem is infeasible (solver status: {status!r}).",
            solver_status=status,
        )
    if status in _UNBOUNDED_STATUSES:
        raise UnboundedError(
            f"Problem is unbounded (solver status: {status!r}).",
            solver_status=status,
        )
    raise SolverError(
        f"Solver {solver!r} returned unrecognised status {status!r}.",
        solver_status=status,
    )