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
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
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 |
required |
sigma
|
DataFrame | ndarray
|
Covariance matrix; either a |
required |
weight_bounds
|
tuple[float | None, float | None]
|
Default |
(0.0, 1.0)
|
solver
|
str
|
CVXPY solver name forwarded to :func: |
'CLARABEL'
|
solver_options
|
dict[str, Any] | None
|
Extra keyword arguments forwarded to the solver backend. |
None
|
regularize
|
float
|
Ridge term added to |
_REGULARIZE_DEFAULT
|
Source code in src/markowitz/optimizer/mean_variance.py
add_constraint(constraint: Constraint) -> MeanVariance
¶
clear_constraints() -> MeanVariance
¶
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
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
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
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
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
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
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
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.
|
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
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
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
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 |
Source code in src/markowitz/optimizer/cornuejols_tutuncu.py
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
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 |
required |
weight_bounds
|
tuple[float | None, float | None] | None
|
Box |
required |
long_only
|
bool
|
If |
True
|
extra_constraint_builders
|
Sequence[Any] | None
|
Optional iterable of callables |
None
|
Source code in src/markowitz/optimizer/cornuejols_tutuncu.py
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 |
'CLARABEL'
|
solver_options
|
dict[str, Any] | None
|
Additional keyword arguments forwarded to the solver backend. |
None
|