Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ RESET := \033[0m

##@ Development Setup

venv:
venv: ## Create a Python virtual environment using uv
@printf "$(BLUE)Creating virtual environment...$(RESET)\n"
@curl -LsSf https://astral.sh/uv/install.sh | sh
@uv venv --python 3.12
@curl -LsSf https://astral.sh/uv/install.sh | sh # Install uv package manager
@uv venv --python 3.12 # Create a virtual environment with Python 3.12

install: venv ## Install all dependencies using uv
@printf "$(BLUE)Installing dependencies...$(RESET)\n"
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# [cvxmarkowitz](http://www.cvxgrp.org/cvxmarkowitz/book)
# 📊 [cvxmarkowitz](http://www.cvxgrp.org/cvxmarkowitz/book)

[![PyPI version](https://badge.fury.io/py/cvxmarkowitz.svg)](https://badge.fury.io/py/cvxmarkowitz)
[![Apache 2.0 License](https://img.shields.io/badge/License-APACHEv2-brightgreen.svg)](https://github.com/cvxgrp/cvxmarkowitz/blob/master/LICENSE)
Expand All @@ -7,7 +7,7 @@

[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/cvxgrp/cvxmarkowitz)

## Motivation
## 🎯 Motivation

We stand on the shoulders of [CVXPY](https://www.cvxpy.org).

Expand Down Expand Up @@ -43,15 +43,15 @@ the problem and compile it.
For injecting values for data and parameter into the problem,
we use the [update](cvxmarkowitz/markowitz/builder.py#L19) method.

## Installation
## 🚀 Installation

You can install the package via [PyPI](https://pypi.org/project/cvxmarkowitz/):

```bash
pip install cvxmarkowitz
```

## uv
## 🛠️ uv

You need to install [task](https://taskfile.dev).
Starting with
Expand All @@ -64,7 +64,7 @@ will install [uv](https://github.com/astral-sh/uv) and create
the virtual environment defined in
pyproject.toml and locked in uv.lock.

## marimo
## 🔬 marimo

We install [marimo](https://marimo.io) on the fly within the aforementioned
virtual environment. Executing
Expand Down
171 changes: 167 additions & 4 deletions cvx/markowitz/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Portfolio optimization problem builder module.

This module provides classes for building and solving portfolio optimization problems
using convex optimization. It includes a Builder class for constructing problems
and a _Problem class for solving and analyzing the results.

The Builder class is designed to be extended by specific portfolio optimization
strategies (like minimum variance, maximum Sharpe ratio, etc.) by implementing
the abstract objective method.
"""

from __future__ import annotations

import pickle
Expand All @@ -35,18 +47,51 @@
def deserialize(
problem_file: str | bytes | PathLike[str] | PathLike[bytes] | int,
) -> Any:
"""
Deserialize a problem from a file.

Args:
problem_file: Path to the file containing the serialized problem.

Returns:
The deserialized problem object.
"""
with open(problem_file, "rb") as infile:
return pickle.load(infile)


@dataclass(frozen=True)
class _Problem:
"""
Internal class representing a built optimization problem.

This class encapsulates a CVXPY problem and its associated models,
providing methods to update parameters, solve the problem, and
extract results.

Attributes:
problem: The CVXPY problem object.
model: Dictionary mapping model names to Model objects.
"""

problem: cp.Problem
model: dict[str, Model] = field(default_factory=dict)

def update(self, **kwargs: Matrix) -> _Problem:
"""
Update the problem
Update the problem with new data.

This method updates all models in the problem with the provided data.

Args:
**kwargs: Dictionary of matrices containing the data to update.
Each key should correspond to a data key in one of the models.

Returns:
The updated problem instance (self).

Raises:
CvxError: If any required data key is missing from kwargs.
"""
for name, model in self.model.items():
for key in model.data.keys():
Expand All @@ -63,7 +108,17 @@ def update(self, **kwargs: Matrix) -> _Problem:

def solve(self, solver: str = cp.CLARABEL, **kwargs: Any) -> float:
"""
Solve the problem
Solve the optimization problem.

Args:
solver: The CVXPY solver to use (default: CLARABEL).
**kwargs: Additional keyword arguments to pass to the solver.

Returns:
The optimal value of the objective function.

Raises:
CvxError: If the problem status is not OPTIMAL after solving.
"""
value = self.problem.solve(solver=solver, **kwargs)

Expand All @@ -74,40 +129,105 @@ def solve(self, solver: str = cp.CLARABEL, **kwargs: Any) -> float:

@property
def value(self) -> float:
"""
Get the optimal value of the objective function.

Returns:
The optimal value as a float.
"""
return float(self.problem.value)

def is_dpp(self) -> bool:
"""
Check if the problem is DPP (Disciplined Parametrized Programming) compliant.

Returns:
True if the problem is DPP compliant, False otherwise.
"""
return bool(self.problem.is_dpp())

@property
def data(self) -> Generator[tuple[tuple[str, str], Matrix]]:
"""
Get all data used in the problem's models.

Returns:
A generator yielding tuples of ((model_name, data_key), data_value).
"""
for name, model in self.model.items():
for key, value in model.data.items():
yield (name, key), value

@property
def parameter(self) -> Parameter:
"""
Get all parameters in the problem.

Returns:
A dictionary mapping parameter names to parameter objects.
"""
return dict(self.problem.param_dict.items())

@property
def variables(self) -> Variables:
"""
Get all variables in the problem.

Returns:
A dictionary mapping variable names to variable objects.
"""
return dict(self.problem.var_dict.items())

@property
def weights(self) -> Matrix:
"""
Get the optimal portfolio weights.

Returns:
A numpy array containing the optimal weights for each asset.
"""
return np.array(self.variables[D.WEIGHTS].value)

@property
def factor_weights(self) -> Matrix:
"""
Get the optimal factor weights (for factor models).

Returns:
A numpy array containing the optimal weights for each factor.
"""
return np.array(self.variables[D.FACTOR_WEIGHTS].value)

def serialize(self, problem_file: File) -> None:
"""
Serialize the problem to a file.

Args:
problem_file: Path to the file where the problem will be serialized.
"""
with open(problem_file, "wb") as outfile:
pickle.dump(self, outfile)


@dataclass(frozen=True)
class Builder:
"""
Abstract base class for building portfolio optimization problems.

This class provides the foundation for constructing portfolio optimization
problems using either sample covariance or factor models for risk estimation.
Concrete subclasses must implement the objective method to define the
specific optimization objective.

Attributes:
assets: Number of assets in the portfolio.
factors: Number of factors for factor models (None for sample covariance).
model: Dictionary mapping model names to Model objects.
constraints: Dictionary mapping constraint names to CVXPY constraints.
variables: Dictionary mapping variable names to CVXPY variables.
parameter: Dictionary mapping parameter names to CVXPY parameters.
"""

assets: int = 0
factors: int | None = None
model: dict[str, Model] = field(default_factory=dict)
Expand All @@ -116,6 +236,13 @@ class Builder:
parameter: Parameter = field(default_factory=dict)

def __post_init__(self) -> None:
"""
Initialize the builder after instance creation.

This method sets up the appropriate risk model (factor or sample covariance)
based on whether factors are specified, creates the necessary variables,
and adds default constraints.
"""
# pick the correct risk model
if self.factors is not None:
self.model[M.RISK] = FactorModel(assets=self.assets, factors=self.factors)
Expand Down Expand Up @@ -143,12 +270,30 @@ def __post_init__(self) -> None:
@abstractmethod
def objective(self) -> cp.Expression:
"""
Return the objective function
Define the objective function for the optimization problem.

This abstract method must be implemented by concrete subclasses to
define the specific optimization objective (e.g., minimize risk,
maximize return, etc.).

Returns:
A CVXPY expression representing the objective function.
"""
pass

def build(self) -> _Problem:
"""
Build the cvxpy problem
Build the complete CVXPY optimization problem.

This method collects all constraints from the models, creates a CVXPY
Problem with the objective function, and verifies that the problem
is DPP (Disciplined Parametrized Programming) compliant.

Returns:
A _Problem object encapsulating the built optimization problem.

Raises:
AssertionError: If the problem is not DPP compliant.
"""
for name_model, model in self.model.items():
for name_constraint, constraint in model.constraints(self.variables).items():
Expand All @@ -161,12 +306,30 @@ def build(self) -> _Problem:

@property
def weights(self) -> cp.Variable:
"""
Get the portfolio weights variable.

Returns:
The CVXPY variable representing portfolio weights.
"""
return self.variables[D.WEIGHTS]

@property
def risk(self) -> Model:
"""
Get the risk model.

Returns:
The Model object used for risk estimation.
"""
return self.model[M.RISK]

@property
def factor_weights(self) -> cp.Variable:
"""
Get the factor weights variable (for factor models).

Returns:
The CVXPY variable representing factor weights.
"""
return self.variables[D.FACTOR_WEIGHTS]