diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml deleted file mode 100644 index 6d15e72..0000000 --- a/.github/workflows/gh-pages.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Update Documentation - -on: - push: - branches: - - main - - '*' - pull_request: - branches: [main] - release: - types: - - published - -jobs: - build-and-deploy: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2.3.1 - - name: Install dependencies 🔧 - run: | - curl -LsSf https://astral.sh/uv/install.sh | sh - uv python install 3.11 - uv venv --python 3.11 - source .venv/bin/activate - uv pip install -e .[docs] - - - name: Build api with pdoc3 - run: uv run pdoc --html -c latex_math=True --output-dir docs --force penfolioop - - - name: Build static HTML pages for Jupyter notebooks - run: uv run jupyter nbconvert --to html --execute --allow-errors docs/*.ipynb - - - name: Deploy - uses: JamesIves/github-pages-deploy-action@4.1.4 - with: - branch: gh-pages # The branch the action should deploy to. - folder: docs # The folder the action should deploy. - token: ${{ secrets.GH_ACTION_PAT }} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 73ae6b7..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: release workflow with tests - -on: - push: - tags: - - 'v*' - release: - types: - - published -jobs: - tests: - name: "py${{ matrix.python-version }} / ${{ matrix.os }}" - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - python-version: ["3.11", "3.12", "3.13"] - os: [ubuntu-latest, macos-latest, windows-latest] - steps: - - name: Clone the repository - uses: actions/checkout@v4 - - name: Install dependencies - shell: bash {0} - run: | - if [ "${{ matrix.os }}" = "windows-latest" ]; then - powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" - export PATH="/c/Users/runneradmin/.local/bin:$PATH" - else - curl -LsSf https://astral.sh/uv/install.sh | sh - fi - uv python install ${{ matrix.python-version }} - uv venv --python ${{ matrix.python-version }} - if [ "${{ matrix.os }}" = "windows-latest" ]; then - .venv\\Scripts\\activate - else - source .venv/bin/activate - fi - uv pip install -e .[tests] - - name: Test with pytest - shell: bash {0} - run: | - uv run pytest --cov=penfolioop --cov-branch --cov-report=term-missing --cov-report=xml:coverage.xml -vv tests - - - # build wheel and deploy to pypi - deploy: - # Needs tests to be run successfully before deploying - needs: [tests] - runs-on: ubuntu-latest - # Run only if there is a published release - if: github.event_name == 'release' && github.event.action == 'published' - steps: - - uses: actions/checkout@v4 - - name: Install project dependencies - run: | - curl -LsSf https://astral.sh/uv/install.sh | sh - uv python install 3.11 - uv venv --python 3.11 - source .venv/bin/activate - uv pip install -e . - - name: Build package - run: | - uv build - - name: Publish package - env: - PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }} - run: | - uv publish --token $PYPI_API_TOKEN diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 7d9499e..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: test workflow - -on: - push: - branches: - - main - - '*' - pull_request: - branches: [main] - release: - types: - - published - -jobs: - tests: - name: "py${{ matrix.python-version }} / ${{ matrix.os }}" - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - python-version: ["3.11", "3.12", "3.13"] - os: [ubuntu-latest, macos-latest, windows-latest] - steps: - - name: Clone the repository - uses: actions/checkout@v4 - - name: Install dependencies - shell: bash {0} - run: | - if [ "${{ matrix.os }}" = "windows-latest" ]; then - powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" - export PATH="/c/Users/runneradmin/.local/bin:$PATH" - else - curl -LsSf https://astral.sh/uv/install.sh | sh - fi - uv python install ${{ matrix.python-version }} - uv venv --python ${{ matrix.python-version }} - if [ "${{ matrix.os }}" = "windows-latest" ]; then - .venv\\Scripts\\activate - else - source .venv/bin/activate - fi - uv pip install -e .[tests] - - name: Test with pytest - shell: bash {0} - run: | - uv run pytest --cov=penfolioop --cov-branch --cov-report=term-missing --cov-report=xml:coverage.xml -vv tests - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - directory: . - env_vars: OS,PYTHON - fail_ci_if_error: true - files: coverage.xml - flags: unittests - name: codecov-umbrella - verbose: true diff --git a/.gitignore b/.gitignore deleted file mode 100644 index fd2d641..0000000 --- a/.gitignore +++ /dev/null @@ -1,47 +0,0 @@ -*/build/* -*.o -*.so -*.d -*.a -*.exe -*.out -*.app -*.class -*.jar -*.war -*.ear -*.dll -*.dylib -*.pdb -*.idb -*.ilk -*.log -*.tmp -*.temp -*.cache -*.swp -*.swo -*.DS_Store -*.vscode/* -*.idea/* -*.history/* -*.coverage -*.pyc -*.pyo -*.pyd -__pycache__/* -*venv/* -*.mypy_cache/* -*.pytest_cache/* -*.ruff_cache/* -*.tox/* -*.dist-info/* -*.egg-info/* -*.egg -*.whl -.python-version -.git/* -*.ipynb_checkpoints/* -*.coverage.* -dev/* -uv.lock \ No newline at end of file diff --git a/Example_Synthetic_Data.html b/Example_Synthetic_Data.html new file mode 100644 index 0000000..44b9256 --- /dev/null +++ b/Example_Synthetic_Data.html @@ -0,0 +1,15126 @@ + + + + + +Example_Synthetic_Data + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+ + + + + + + + + diff --git a/docs/Example_Synthetic_Data.ipynb b/Example_Synthetic_Data.ipynb similarity index 100% rename from docs/Example_Synthetic_Data.ipynb rename to Example_Synthetic_Data.ipynb diff --git a/Example_US_Asset_Classes.html b/Example_US_Asset_Classes.html new file mode 100644 index 0000000..2044d3d --- /dev/null +++ b/Example_US_Asset_Classes.html @@ -0,0 +1,16183 @@ + + + + + +Example_US_Asset_Classes + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+
+
+ + +
+
+
+ + +
+ +
+
+ + +
+ +
+
+
+ + +
+
+
+ + +
+ +
+
+
+ + +
+
+
+ + +
+ +
+ + + + + +
+ +
+
+ + +
+ +
+ + + + + +
+ +
+
+ + +
+ +
+ + + + + +
+ +
+
+
+ + +
+
+
+ + +
+ +
+
+
+ + +
+
+
+ + +
+ +
+
+
+ + +
+
+
+ + +
+ +
+
+ + +
+ +
+ + + + + +
+ +
+
+ + +
+ +
+ + + + + +
+ +
+
+ + +
+ +
+ + + + + +
+ +
+
+
+ + +
+
+
+ + +
+ +
+
+ + +
+ +
+ + + + + +
+ +
+
+ + +
+ +
+ + + + + +
+ +
+ + + + + + + + + diff --git a/docs/Example_US_Asset_Classes.ipynb b/Example_US_Asset_Classes.ipynb similarity index 100% rename from docs/Example_US_Asset_Classes.ipynb rename to Example_US_Asset_Classes.ipynb diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 40ebb9c..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 QuantFinLib - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.md b/README.md deleted file mode 100644 index cc3d2c1..0000000 --- a/README.md +++ /dev/null @@ -1,135 +0,0 @@ -# PenFolioOp -Portfolio Optimizations for *Pension Funds* - -[![codecov](https://codecov.io/gh/quantfinlib/penfolioop/graph/badge.svg?token=Z60B2PYJ44)](https://codecov.io/gh/quantfinlib/penfolioop) -[![tests](https://github.com/quantfinlib/penfolioop/actions/workflows/test.yml/badge.svg)](https://github.com/quantfinlib/penfolioop/actions/workflows/test.yml) -[![docs](https://github.com/quantfinlib/penfolioop/actions/workflows/gh-pages.yml/badge.svg)](https://github.com/quantfinlib/penfolioop/actions/workflows/gh-pages.yml) -[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](https://github.com/quantfinlib/penfolioop/blob/main/LICENSE) -[![PyPI Downloads](https://static.pepy.tech/personalized-badge/penfolioop?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/penfolioop) - -## Conventions - -In this framework, for convenience, we make use of the following conventions: - -`expected_returns` $\mathbf{R}$ is an array of the expected returns of the assets and the liabilities. - -$$ -\mathbf{R} = \begin{bmatrix} -R_1 \\ -R_2 \\ -\vdots \\ -R_n \\ -R_L -\end{bmatrix} = \begin{bmatrix} -\mathbf{R}_{A} \\ -R_L -\end{bmatrix} -$$ - -`covariance_matrix` $\Sigma$ is the covariance matrix of the asset and the liability returns. - -$$ -\Sigma = \begin{bmatrix} -\Sigma_{A} & \Sigma_{AL} \\ -\Sigma_{AL} & \sigma^{2}_{L} -\end{bmatrix}, -$$ - -where $\Sigma_{A}$ is the covariance matrix of the asset returns, $\Sigma_{AL}$ is a vector of the covariance between the asset and liability returns, and $\sigma^{2}_{L}$ is the variance of the liability returns. - -The output of the optimization process is a weight vector $\mathbf{W}$ consisting of the optimal asset weights, and the liability weight is always set to -1. The optimization process aims to find the asset weights that maximize or minimize the chosen objective function while satisfying the specified constraints. - -$$ -\mathbf{W} = \begin{bmatrix} -w_1 \\ -w_2 \\ -\vdots \\ -w_n \\ --1 -\end{bmatrix} = \begin{bmatrix} -\mathbf{W}_{A} \\ --1 -\end{bmatrix} -$$ - -`surplus_return` $\mathbf{R}_{S}$ is the return of the portfolio minus the return of the liabilities. - -$$R_{S} = R_{P} - R_{L} = W_{A} ^ {T}R_{A} - R_{L} = W ^ {T} R$$ - - -`surplus_variance` $\sigma^{2}_{S}$ is the variance of the surplus returns: $`\sigma^{2}_{S} = W_{A}^{T} \Sigma_{A} W_{A} - 2 W_{A}^{T} \Sigma_{AL} + \sigma^{2}_{L} = W^{T} \Sigma W `$ - -## Optimizers - -With the defined surplus return and variance, we can now outline the optimization problems. -All the optimizers are subject to these general constraints: - -$$ - \begin{align*} - (1) &\quad& \sum_{i=1}^{n_{assets}} w_{i} = \mathrm{SUM}(W_{A}) = 1, \\ - (2) &\quad& w_{i} \geq 0, \quad \forall i \in \{1, \ldots, n_{assets}\}, \\ - (3) &\quad& w_{L} = -1 - \end{align*} -$$ - - - -| optimizer | formulation | constraints | -|------------------------------------------|--------------------------------------------------------------------------|-----------------------------------------------------------------| -| `max_surplus_return_optimizer`| $\underset{\mathbf{W}}{\mathrm{maximize}} \quad \mathbf{W}^{T}\mathbf{R}$ | $\mathbf{W}^{T} \mathbf{\Sigma} \mathbf{W} \leq$ `surplus_risk_upper_limit`| -| `min_surplus_variance_optimizer`| $\underset{\mathbf{W}}{\mathrm{minimize}} \quad \mathbf{W}^{T} \mathbf{\Sigma} \mathbf{W}$ | $\mathbf{W}^{T}\mathbf{R} \geq$ `surplus_return_lower_limit`| -| `max_surplus_sharpe_ratio_optimizer`| $\underset{\mathbf{W}}{\mathrm{maximize}} \quad \frac{\mathbf{W}^{T}\mathbf{R}}{\sqrt{\mathbf{W}^{T} \mathbf{\Sigma} \mathbf{W}}}$ | `None` | -| `surplus_mean_variance_optimizer`| $\underset{\mathbf{W}}{\mathrm{maximize}} \\ \quad \mathbf{W}^{T}\mathbf{R} - \frac{\lambda}{2} \mathbf{W}^{T} \mathbf{\Sigma} \mathbf{W}$ | `None` | - -In the above table, $\lambda$ is a risk aversion parameter that balances the trade-off between maximizing surplus returns and minimizing surplus risk. -In addition to the above optimizers, the user can also call the `efficient_frontier` function to compute the weights of the efficient frontier portfolio. -These portfolios can be found by varying the `surplus_return_lower_limit` in the following `min_surplus_variance_optimizer` optimizer. In this case, the user needs to provide a range of values for the `surplus_return_lower_limit` parameter. - - -## Additional Constraints - -Asset weight constraints can be applied to ensure that the portfolio adheres to specific investment guidelines. - - - -## Example - -Usage of the library is straightforward. You can create a portfolio object, define your assets, and then use the optimizers to find the optimal asset weights based on your constraints and objectives. -The first step is to create a `Portfolio` object with your assets, their expected returns, and covariances. The last item in the list of assets should be the liability, which is treated differently ,from the other assets in the optimization process. The optimizaters always set the liability weight to -1 and require the other asset weights to be between 0 and 1 and sum to 1. - -The user can then define additional constraints on the asset weights, such as requiring a minimum or maximum weight for certain assets or limiting the weight of one or more assets to be less than another. - -For a comprehensive description of the constraints, refer to the API documentation. - - -```python - -import numpy as np - -from penfolioop.portfolio import Portfolio -from penfolioop.optimizers import max_surplus_return_optimizer - -names = ['Asset A', 'Asset B', 'Asset C', 'Liability'] -expected_returns = np.array([0.05, 0.07, 0.06, 0.04]) -covariance_matrix = np.array([[0.0001, 0.00005, 0.00002, 0.00003], - [0.00005, 0.0002, 0.00001, 0.00004], - [0.00002, 0.00001, 0.00015, 0.00002], - [0.00003, 0.00004, 0.00002, 0.0001]] - -portfolio = Portfolio(names=names, expected_returns=expected_returns, covariance_matrix=covariance_matrix) - -constraints = [ - { - 'left_indices': ['Asset A', 'Asset B'], - 'operator': '>=', - 'right_value': 0.5 - } - { - 'left_indices': ['Asset C'], - 'operator': '<=', - 'right_index': ['Asset B'] - } -] - -weights = max_surplus_return_optimizer(portfolio=portfolio, asset_constraints=constraints, surplus_risk_upper_limit=0.0001) - diff --git a/docs/penfolioop/optimizers.html b/docs/penfolioop/optimizers.html deleted file mode 100644 index 9479c36..0000000 --- a/docs/penfolioop/optimizers.html +++ /dev/null @@ -1,485 +0,0 @@ - - - - - - -penfolioop.optimizers API documentation - - - - - - - - - - - - - -
-
-
-

Module penfolioop.optimizers

-
-
-

Portfolio optimization algorithms and utilities.

-

This module provides various optimization functions for pension fund portfolio optimization, including: -- max_surplus_sharp_ratio_optimizer: Maximizes the surplus portfolio return to surplus portfolio risk -- surplus_mean_variance_optimizer: Mean-variance optimization for surplus portfolios -- max_surplus_return_optimizer: Maximizes surplus return -- min_surplus_variance_optimizer: Minimizes surplus variance -- efficient_frontier: Find the efficient frontier

-
-
-
-
-
-
-

Functions

-
-
-def efficient_frontier(portfolio: Portfolio,
num_points: int = 100,
asset_constraints: list[dict[str, Any]] | None = None,
lmbd_range: tuple[float, float] = (0, 1)) ‑> tuple[np.ndarray, np.ndarray, np.ndarray]
-
-
-
- -Expand source code - -
def efficient_frontier(
-    portfolio: Portfolio,
-    num_points: int = 100,
-    asset_constraints: list[dict[str, Any]] | None = None,
-    lmbd_range: tuple[float, float] = (0, 1),
-) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
-    """Find the efficient frontier of the portfolio.
-
-    Parameters
-    ----------
-    portfolio : Portfolio
-        The portfolio object containing asset names, covariance matrix, expected returns.
-    num_points : int, optional
-        Number of points to calculate on the efficient frontier. Default is 100.
-    asset_constraints : list[dict[str, Any]], optional
-        Additional constraints for the optimization problem. Default is None.
-    lmbd_range : tuple[float, float], optional
-        Range of lambda values to consider for the optimization. Default is (0, 1).
-
-    Returns
-    -------
-    tuple[np.ndarray, np.ndarray]
-        Tuple containing arrays of surplus returns and surplus variances.
-
-    """
-    if lmbd_range is None:
-        lmbd_range = (0, 1)
-    else:
-        _validate_lmbd_range(lmbd_range)
-    lmbds = np.linspace(lmbd_range[0], lmbd_range[1], num_points)
-    surplus_returns = []
-    surplus_variances = []
-    weights_placeholder = []
-
-    def _optimize_single_lambda(lmbd) -> np.ndarray | None:  # noqa: ANN001
-        """Optimize for a single lambda value."""  # noqa: DOC201
-        try:
-            return surplus_mean_variance_optimizer(
-                portfolio=portfolio, lmbd=lmbd, asset_constraints=asset_constraints
-            )
-        except ValueError:
-            return None
-
-    for lmbd in lmbds:
-        weights = _optimize_single_lambda(lmbd)
-        if weights is not None:
-            weights_placeholder.append(weights)
-            surplus_returns.append(portfolio.surplus_return(weights))
-            surplus_variances.append(portfolio.surplus_variance(weights))
-        else:
-            # If optimization fails for a particular lambda, append NaN
-            weights_placeholder.append(np.nan * np.ones(len(portfolio.names)))
-            surplus_returns.append(np.nan)
-            surplus_variances.append(np.nan)
-    return np.array(weights_placeholder), np.array(surplus_returns), np.array(surplus_variances)
-
-

Find the efficient frontier of the portfolio.

-

Parameters

-
-
portfolio : Portfolio
-
The portfolio object containing asset names, covariance matrix, expected returns.
-
num_points : int, optional
-
Number of points to calculate on the efficient frontier. Default is 100.
-
asset_constraints : list[dict[str, Any]], optional
-
Additional constraints for the optimization problem. Default is None.
-
lmbd_range : tuple[float, float], optional
-
Range of lambda values to consider for the optimization. Default is (0, 1).
-
-

Returns

-
-
tuple[np.ndarray, np.ndarray]
-
Tuple containing arrays of surplus returns and surplus variances.
-
-
-
-def max_surplus_return_optimizer(portfolio: Portfolio, asset_constraints: list[dict[str, Any]] | None = None) ‑> np.ndarray -
-
-
- -Expand source code - -
def max_surplus_return_optimizer(
-    portfolio: Portfolio, asset_constraints: list[dict[str, Any]] | None = None,
-) -> np.ndarray:
-    """Optimize the asset weights to maximize the surplus return over the expected liabilities return.
-
-    Parameters
-    ----------
-    portfolio : Portfolio
-        The portfolio object containing asset names, covariance matrix, expected returns.
-    asset_constraints : list[dict[str, Any]], optional
-        Additional constraints for the optimization problem. Default is None.
-
-
-    Returns
-    -------
-    np.ndarray
-        Optimized asset weights as a numpy array.
-
-    Raises
-    ------
-    ValueError
-        If the optimization fails or constraints are not satisfied.
-
-    """
-    n_assets = len(portfolio.names) - 1
-    weights = cp.Variable(n_assets + 1)
-
-    # Objective function: maximize the surplus return over the expected liabilities return
-    surplus_return = weights.T @ portfolio.expected_returns
-    objective = cp.Maximize(surplus_return)
-    # Constraints
-    constraints = [
-        cp.sum(weights[:n_assets]) == 1,  # Weights must sum to 1
-        weights[:n_assets] >= 0,          # No short selling
-        weights[-1] == -1,                # Last weight is liabilities
-    ]
-    if asset_constraints:
-        constraints += generate_constraints(
-            portfolio_weights=weights, asset_constraints=asset_constraints, asset_names=portfolio.names
-        )
-    # Solve the optimization problem
-    problem = cp.Problem(objective, constraints)
-    problem.solve()
-
-    if weights.value is None or problem.status != cp.OPTIMAL:
-        msg = "Optimization failed."
-        raise ValueError(msg)
-
-    return weights.value
-
-

Optimize the asset weights to maximize the surplus return over the expected liabilities return.

-

Parameters

-
-
portfolio : Portfolio
-
The portfolio object containing asset names, covariance matrix, expected returns.
-
asset_constraints : list[dict[str, Any]], optional
-
Additional constraints for the optimization problem. Default is None.
-
-

Returns

-
-
np.ndarray
-
Optimized asset weights as a numpy array.
-
-

Raises

-
-
ValueError
-
If the optimization fails or constraints are not satisfied.
-
-
-
-def max_surplus_sharp_ratio_optimizer(portfolio: Portfolio, asset_constraints: list[dict[str, Any]] | None = None) ‑> np.ndarray -
-
-
- -Expand source code - -
def max_surplus_sharp_ratio_optimizer(
-    portfolio: Portfolio, asset_constraints: list[dict[str, Any]] | None = None,
-) -> np.ndarray:
-    """Optimize the asset weights to achieve a target excess return over the expected liabilities return.
-
-    Parameters
-    ----------
-    portfolio : Portfolio
-        The portfolio object containing asset names, covariance matrix, expected returns.
-    asset_constraints : list[dict[str, Any]], optional
-        Additional constraints for the optimization problem. Default is None.
-
-
-    Returns
-    -------
-    np.ndarray
-        Optimized asset weights as a numpy array.
-
-    Raises
-    ------
-    ValueError
-        If the optimization fails or constraints are not satisfied.
-
-    """
-    n_assets = len(portfolio.names) - 1
-
-    # Constraints
-    constraints = [
-        {"type": "eq", "fun": lambda w: np.sum(w[:n_assets]) - 1},  # weights sum to 1
-        {"type": "eq", "fun": lambda w: w[-1] + 1},                 # last weight is -1
-    ]
-    if asset_constraints:
-        constraints += generate_scipy_constraints(
-            asset_constraints=asset_constraints, asset_names=portfolio.names,
-        )
-    # Bounds
-    bounds = [(0, 1)] * n_assets + [(None, None)]  # last weight (liability) unbounded
-    # Initial guess
-    initial_weights = np.ones(n_assets + 1) / (n_assets)
-    initial_weights[-1] = -1  # liabilities weight
-
-    # Solve the optimization problem
-    result = minimize(
-        _negative_surplus_sharp_ratio_objective,
-        initial_weights,
-        args=(portfolio.expected_returns, portfolio.covariance_matrix),
-        method="SLSQP",
-        bounds=bounds,
-        constraints=constraints
-    )
-    if not result.success:
-        msg = "Optimization failed."
-        raise ValueError(msg)
-
-    return result.x
-
-

Optimize the asset weights to achieve a target excess return over the expected liabilities return.

-

Parameters

-
-
portfolio : Portfolio
-
The portfolio object containing asset names, covariance matrix, expected returns.
-
asset_constraints : list[dict[str, Any]], optional
-
Additional constraints for the optimization problem. Default is None.
-
-

Returns

-
-
np.ndarray
-
Optimized asset weights as a numpy array.
-
-

Raises

-
-
ValueError
-
If the optimization fails or constraints are not satisfied.
-
-
-
-def min_surplus_variance_optimizer(portfolio: Portfolio, asset_constraints: list[dict[str, Any]] | None = None) ‑> np.ndarray -
-
-
- -Expand source code - -
def min_surplus_variance_optimizer(portfolio: Portfolio, asset_constraints: list[dict[str, Any]] | None = None,
-) -> np.ndarray:
-    """Optimize the asset weights to minimize the surplus variance of the portfolio.
-
-    Parameters
-    ----------
-    portfolio : Portfolio
-        The portfolio object containing asset names, covariance matrix, expected returns.
-    asset_constraints : list[dict[str, Any]], optional
-        Additional constraints for the optimization problem. Default is None.
-
-    Returns
-    -------
-    np.ndarray
-        Optimized asset weights as a numpy array.
-
-    Raises
-    ------
-    ValueError
-        If the optimization fails or constraints are not satisfied.
-
-    """
-    n_assets = len(portfolio.names) - 1
-    weights = cp.Variable(n_assets + 1)
-
-    # Objective function: minimize the surplus variance of the portfolio
-    surplus_variance = cp.quad_form(weights, portfolio.covariance_matrix)
-    objective = cp.Minimize(surplus_variance)
-    # Constraints
-    constraints = [
-        cp.sum(weights[:n_assets]) == 1,  # Weights must sum to 1
-        weights[:n_assets] >= 0,          # No short selling
-        weights[-1] == -1,                # Last weight is liabilities
-    ]
-    if asset_constraints:
-        constraints += generate_constraints(
-            portfolio_weights=weights, asset_constraints=asset_constraints, asset_names=portfolio.names
-        )
-    # Solve the optimization problem
-    problem = cp.Problem(objective, constraints)
-    problem.solve()
-
-    if weights.value is None or problem.status != cp.OPTIMAL:
-        msg = "Optimization failed"
-        raise ValueError(msg)
-
-    return weights.value
-
-

Optimize the asset weights to minimize the surplus variance of the portfolio.

-

Parameters

-
-
portfolio : Portfolio
-
The portfolio object containing asset names, covariance matrix, expected returns.
-
asset_constraints : list[dict[str, Any]], optional
-
Additional constraints for the optimization problem. Default is None.
-
-

Returns

-
-
np.ndarray
-
Optimized asset weights as a numpy array.
-
-

Raises

-
-
ValueError
-
If the optimization fails or constraints are not satisfied.
-
-
-
-def surplus_mean_variance_optimizer(portfolio: Portfolio,
lmbd: float = 1.0,
asset_constraints: list[dict[str, Any]] | None = None) ‑> np.ndarray
-
-
-
- -Expand source code - -
def surplus_mean_variance_optimizer(
-    portfolio: Portfolio, lmbd: float = 1., asset_constraints: list[dict[str, Any]] | None = None,
-) -> np.ndarray:
-    """Optimize the asset weights to maximize the surplus return over the expected liabilities return.
-
-    Parameters
-    ----------
-    portfolio : Portfolio
-        The portfolio object containing asset names, covariance matrix, expected returns.
-    lmbd : float, optional
-        Regularization parameter for the optimization problem. Default is 1.0.
-    asset_constraints : list[dict[str, Any]], optional
-        Additional constraints for the optimization problem. Default is None.
-
-    Returns
-    -------
-    np.ndarray
-        Optimized asset weights as a numpy array.
-
-    Raises
-    ------
-    ValueError
-        If the optimization fails or constraints are not satisfied.
-
-    """
-    n_assets = len(portfolio.names) - 1
-    weights = cp.Variable(n_assets + 1)
-
-    # Objective function: maximize the surplus return over the expected liabilities return
-    surplus_return = weights.T @ portfolio.expected_returns
-    surplus_variance = cp.quad_form(weights, portfolio.covariance_matrix)
-    objective = cp.Maximize(surplus_return - lmbd / 2 * surplus_variance)
-    # Constraints
-    constraints = [
-        cp.sum(weights[:n_assets]) == 1,  # Weights must sum to 1
-        weights[:n_assets] >= 0,          # No short selling
-        weights[-1] == -1,                # Last weight is liabilities
-    ]
-    if asset_constraints:
-        constraints += generate_constraints(
-            portfolio_weights=weights, asset_constraints=asset_constraints, asset_names=portfolio.names
-        )
-    # Solve the optimization problem
-    problem = cp.Problem(objective, constraints)
-    problem.solve()
-
-    if weights.value is None or problem.status != cp.OPTIMAL:
-        msg = "Optimization failed."
-        raise ValueError(msg)
-
-    return weights.value
-
-

Optimize the asset weights to maximize the surplus return over the expected liabilities return.

-

Parameters

-
-
portfolio : Portfolio
-
The portfolio object containing asset names, covariance matrix, expected returns.
-
lmbd : float, optional
-
Regularization parameter for the optimization problem. Default is 1.0.
-
asset_constraints : list[dict[str, Any]], optional
-
Additional constraints for the optimization problem. Default is None.
-
-

Returns

-
-
np.ndarray
-
Optimized asset weights as a numpy array.
-
-

Raises

-
-
ValueError
-
If the optimization fails or constraints are not satisfied.
-
-
-
-
-
-
-
- -
- - - diff --git a/docs/index.md b/index.md similarity index 100% rename from docs/index.md rename to index.md diff --git a/penfolioop/__init__.py b/penfolioop/__init__.py deleted file mode 100644 index 20539e0..0000000 --- a/penfolioop/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Penfolioop: A lightweight Pension Fund Portfolio Optimization Library \ No newline at end of file diff --git a/docs/penfolioop/constraints.html b/penfolioop/constraints.html similarity index 84% rename from docs/penfolioop/constraints.html rename to penfolioop/constraints.html index ddc14d5..95ce5b6 100644 --- a/docs/penfolioop/constraints.html +++ b/penfolioop/constraints.html @@ -1,581 +1,658 @@ - - - - - - -penfolioop.constraints API documentation - - - - - - - - - - - - - -
-
-
-

Module penfolioop.constraints

-
-
-

Portfolio constraint generation module.

-

This module provides functionality for generating optimization constraints -for portfolio management, including asset weight constraints and other -portfolio-specific limitations.

-
-
-
-
-
-
-

Functions

-
-
-def generate_constraints(portfolio_weights: cp.Variable,
asset_names: list[str],
asset_constraints: list[dict[str, Any]]) ‑> list[cvxpy.constraints.constraint.Constraint]
-
-
-
- -Expand source code - -
def generate_constraints(
-    portfolio_weights: cp.Variable,
-    asset_names: list[str],
-    asset_constraints: list[dict[str, Any]],
-) -> list[cp.Constraint]:
-    """Generate constraints for the portfolio optimization problem.
-
-    Parameters
-    ----------
-    portfolio_weights : cp.Variable
-        The variable representing the portfolio weights.
-    asset_names : list[str]
-        List of asset names in the portfolio.
-    asset_constraints : list[dict[str, Any]]
-        List of asset constraints to apply to the portfolio weights.
-
-    Returns
-    -------
-    list[cp.Constraint]
-        List of cvxpy constraints generated from the asset constraints.
-
-    """
-    constraints: list[cp.Constraint] = []
-
-    _check_constraints(constraints=asset_constraints)
-    asset_indices: dict[str, int] = {name: i for i, name in enumerate(asset_names)}
-    for constraint in asset_constraints:
-        # Process left-hand-side of the constraint
-        left_expr = _process_left_side_of_constraint(
-            portfolio_weights=portfolio_weights,
-            left_indices=constraint["left_indices"],
-            asset_indices=asset_indices,
-        )
-        # process right-hand-side of the constraint
-        right_expr: cp.Expression | float = _process_right_side_of_constraint(
-            portfolio_weights=portfolio_weights,
-            right_value=constraint.get("right_value"),
-            right_indices=constraint.get("right_indices"),
-            asset_indices=asset_indices,
-        )
-        # Process the operator
-        cp_constraint: cp.Constraint = _process_operator(
-            operator=constraint["operator"],
-            left_expr=left_expr,
-            right_expr=right_expr,
-        )
-        constraints.append(cp_constraint)
-
-    return constraints
-
-

Generate constraints for the portfolio optimization problem.

-

Parameters

-
-
portfolio_weights : cp.Variable
-
The variable representing the portfolio weights.
-
asset_names : list[str]
-
List of asset names in the portfolio.
-
asset_constraints : list[dict[str, Any]]
-
List of asset constraints to apply to the portfolio weights.
-
-

Returns

-
-
list[cp.Constraint]
-
List of cvxpy constraints generated from the asset constraints.
-
-
-
-def generate_scipy_constraints(asset_constraints: list[dict[str, Any]], asset_names: list[str]) ‑> list[dict[str, typing.Any]] -
-
-
- -Expand source code - -
def generate_scipy_constraints(
-    asset_constraints: list[dict[str, Any]],
-    asset_names: list[str],
-) -> list[dict[str, Any]]:
-    """Generate constraints for scipy optimization.
-
-    Parameters
-    ----------
-    asset_constraints : list[dict[str, Any]]
-        List of asset constraints to apply to the portfolio weights.
-    asset_names : list[str]
-        List of asset names in the portfolio.
-
-    Returns
-    -------
-    list[dict[str, Any]]
-        List of constraints formatted for scipy optimization.
-
-    """
-    _check_constraints(constraints=asset_constraints)
-    asset_indices: dict[str, int] = {name: i for i, name in enumerate(asset_names)}
-    scipy_constraints = []
-
-    for constraint in asset_constraints:
-        def constraint_fun(x, constraint=constraint):  # noqa: ANN001, ANN202
-            # Calculate left side
-            left_value = sum(x[asset_indices[idx]] for idx in constraint["left_indices"])
-
-            # Calculate right side
-            if constraint.get("right_value") is not None:
-                right_value = constraint["right_value"]
-            else:
-                right_value = sum(x[asset_indices[idx]] for idx in constraint["right_indices"])
-
-            # Return constraint value based on operator
-            if constraint["operator"] in {"<=", "<"}:
-                return right_value - left_value
-            if constraint["operator"] in {">=", ">"}:
-                return left_value - right_value
-            if constraint["operator"] == "==":
-                return left_value - right_value
-            msg = f"Unsupported operator: {constraint['operator']}"
-            raise ValueError(msg)
-
-        constraint_type = "ineq" if constraint["operator"] in {"<=", ">=", ">", "<"} else "eq"
-        scipy_constraints.append({
-            "type": constraint_type,
-            "fun": constraint_fun,
-        })
-
-    return scipy_constraints
-
-

Generate constraints for scipy optimization.

-

Parameters

-
-
asset_constraints : list[dict[str, Any]]
-
List of asset constraints to apply to the portfolio weights.
-
asset_names : list[str]
-
List of asset names in the portfolio.
-
-

Returns

-
-
list[dict[str, Any]]
-
List of constraints formatted for scipy optimization.
-
-
-
-
-
-

Classes

-
-
-class AssetConstraint -(**data: Any) -
-
-
- -Expand source code - -
class AssetConstraint(BaseModel):
-    """Model for validating individual asset constraints."""
-
-    left_indices: list[str]
-    operator: Literal["==", "<=", ">=", "<", ">"]
-    right_value: float | None = None
-    right_indices: list[str] | None = None
-
-    # Validator for left_indices
-    @field_validator("left_indices")
-    @classmethod
-    def validate_left_indices(cls, v: list[str]) -> list[str]:
-        """Ensure left_indices is not empty.
-
-        Parameters
-        ----------
-        v : list[str]
-            List of asset names for the left-hand side of the constraint.
-
-        Returns
-        -------
-        list[str]
-            Validated list of asset names.
-
-        Raises
-        ------
-        ValueError
-            If left_indices is empty or contains duplicate names.
-
-        """
-        if not v:
-            msg = "left_indices must not be empty"
-            raise ValueError(msg)
-        if len(v) != len(set(v)):
-            msg = "Asset names in left_indices must be unique"
-            raise ValueError(msg)
-        return v
-
-    # Validator for the operator
-    @field_validator("operator")
-    @classmethod
-    def validate_operator(cls, v: Literal["==", "<=", ">=", "<", ">"]) -> Literal["==", "<=", ">=", "<", ">"]:
-        """Ensure operator is one of the allowed values.
-
-        Parameters
-        ----------
-        v : Literal["==", "<=", ">=", "<", ">"]
-            The operator to validate.
-
-        Returns
-        -------
-        Literal["==", "<=", ">=", "<", ">"]
-            Validated operator.
-
-        Raises
-        ------
-        ValueError
-            If the operator is not one of the allowed values.
-
-        """
-        allowed_operators = {"==", "<=", ">=", "<", ">"}
-        if v not in allowed_operators:
-            msg = f"Operator must be one of {allowed_operators}, got {v}"
-            raise ValueError(msg)
-        return v
-
-    # Validator for the right_indices in the right side of the constraint
-    @field_validator("right_indices")
-    @classmethod
-    def validate_right_indices(cls, v: list[str] | None) -> list[str] | None:
-        """Ensure right_indices is not empty and contains unique names.
-
-        Parameters
-        ----------
-        v : list[str] | None
-            List of asset names for the right-hand side of the constraint.
-
-        Returns
-        -------
-        list[str] | None
-            Validated list of asset names or None if not provided.
-
-        Raises
-        ------
-        ValueError
-            If right_indices is not None and is empty or contains duplicate names.
-
-
-        """
-        if v is not None and not v:
-            msg = "right_indices must not be empty if provided"
-            raise ValueError(msg)
-        if v is not None and len(v) != len(set(v)):
-            msg = "Asset names in right_indices must be unique"
-            raise ValueError(msg)
-        return v
-
-    # Validator for the right_value in the right side of the constraint
-    @field_validator("right_value")
-    @classmethod
-    def validate_right_value(cls, v: float | None) -> float | None:
-        """Ensure right_value is a non-negative number if provided.
-
-        Parameters
-        ----------
-        v : float | None
-            The right-hand side value of the constraint.
-
-        Returns
-        -------
-        float | None
-            Validated right value or None if not provided.
-
-        Raises
-        ------
-        ValueError
-            If right_value is not None and is not a number or is negative.
-
-        """
-        if v is not None and not isinstance(v, (int, float)):
-            msg = "right_value must be a number if provided"
-            raise ValueError(msg)
-        if v is not None and (v < 0 or v > 1):
-            msg = "right_value must be between 0 and 1 if provided"
-            raise ValueError(msg)
-        return float(v) if v is not None else None
-
-    # Validator to ensure either right_value or right_indices is provided, but not both
-    @model_validator(mode="after")
-    def validate_right_side(self) -> Self:
-        """Ensure that either right_value or right_indices is provided, but not both.
-
-        Returns
-        -------
-        Self
-            The validated AssetConstraint instance.
-
-        Raises
-        ------
-        ValueError
-            If neither right_value nor right_indices is provided, or if both are provided.
-
-        """
-        right_value = self.right_value
-        right_indices = self.right_indices
-        if right_value is None and right_indices is None:
-            msg = "Either right_value or right_indices must be provided"
-            raise ValueError(msg)
-        if right_value is not None and right_indices is not None:
-            msg = "Only one of right_value or right_indices can be provided"
-            raise ValueError(msg)
-        return self
-
-

Model for validating individual asset constraints.

-

Create a new model by parsing and validating input data from keyword arguments.

-

Raises [ValidationError][pydantic_core.ValidationError] if the input data cannot be -validated to form a valid model.

-

self is explicitly positional-only to allow self as a field name.

-

Ancestors

-
    -
  • pydantic.main.BaseModel
  • -
-

Class variables

-
-
var left_indices : list[str]
-
-

The type of the None singleton.

-
-
var model_config
-
-

The type of the None singleton.

-
-
var operator : Literal['==', '<=', '>=', '<', '>']
-
-

The type of the None singleton.

-
-
var right_indices : list[str] | None
-
-

The type of the None singleton.

-
-
var right_value : float | None
-
-

The type of the None singleton.

-
-
-

Static methods

-
-
-def validate_left_indices(v: list[str]) ‑> list[str] -
-
-

Ensure left_indices is not empty.

-

Parameters

-
-
v : list[str]
-
List of asset names for the left-hand side of the constraint.
-
-

Returns

-
-
list[str]
-
Validated list of asset names.
-
-

Raises

-
-
ValueError
-
If left_indices is empty or contains duplicate names.
-
-
-
-def validate_operator(v: "Literal['==', '<=', '>=', '<', '>']") ‑> Literal['==', '<=', '>=', '<', '>'] -
-
-

Ensure operator is one of the allowed values.

-

Parameters

-
-
v : Literal["==", "<=", ">=", "<", ">"]
-
The operator to validate.
-
-

Returns

-
-
Literal["==", "<=", ">=", "<", ">"]
-
Validated operator.
-
-

Raises

-
-
ValueError
-
If the operator is not one of the allowed values.
-
-
-
-def validate_right_indices(v: list[str] | None) ‑> list[str] | None -
-
-

Ensure right_indices is not empty and contains unique names.

-

Parameters

-
-
v : list[str] | None
-
List of asset names for the right-hand side of the constraint.
-
-

Returns

-
-
list[str] | None
-
Validated list of asset names or None if not provided.
-
-

Raises

-
-
ValueError
-
If right_indices is not None and is empty or contains duplicate names.
-
-
-
-def validate_right_value(v: float | None) ‑> float | None -
-
-

Ensure right_value is a non-negative number if provided.

-

Parameters

-
-
v : float | None
-
The right-hand side value of the constraint.
-
-

Returns

-
-
float | None
-
Validated right value or None if not provided.
-
-

Raises

-
-
ValueError
-
If right_value is not None and is not a number or is negative.
-
-
-
-

Methods

-
-
-def validate_right_side(self) ‑> Self -
-
-
- -Expand source code - -
@model_validator(mode="after")
-def validate_right_side(self) -> Self:
-    """Ensure that either right_value or right_indices is provided, but not both.
-
-    Returns
-    -------
-    Self
-        The validated AssetConstraint instance.
-
-    Raises
-    ------
-    ValueError
-        If neither right_value nor right_indices is provided, or if both are provided.
-
-    """
-    right_value = self.right_value
-    right_indices = self.right_indices
-    if right_value is None and right_indices is None:
-        msg = "Either right_value or right_indices must be provided"
-        raise ValueError(msg)
-    if right_value is not None and right_indices is not None:
-        msg = "Only one of right_value or right_indices can be provided"
-        raise ValueError(msg)
-    return self
-
-

Ensure that either right_value or right_indices is provided, but not both.

-

Returns

-
-
Self
-
The validated AssetConstraint instance.
-
-

Raises

-
-
ValueError
-
If neither right_value nor right_indices is provided, or if both are provided.
-
-
-
-
-
-
-
- -
- - - + + + + + + +penfolioop.constraints API documentation + + + + + + + + + + + + + +
+
+
+

Module penfolioop.constraints

+
+
+

Portfolio constraint generation module.

+

This module provides functionality for generating optimization constraints +for portfolio management, including asset weight constraints and other +portfolio-specific limitations.

+

User Constraints

+

Users of penfolioop can define additional constraints on the asset classes in their portfolio.

+

A valid constraints is a list of dictionaries, where each dictionary represents a constraint.

+

Each constraint is a python dictionary and must include the following two keys:

+
    +
  • left_indices: A list of asset names on the left-hand side of the constraint.
  • +
  • operator: The comparison operator for the constraint (e.g., "==", "<=", ">=").
  • +
+

Additionally, each dictionary must include one and only one of the following two keys:

+
    +
  • right_value: A numeric value for the right-hand side of the constraint (optional).
  • +
  • right_indices: A list of asset names on the right-hand side of the constraint (optional).
  • +
+

Here are some valid constraints:

+
[
+    {
+        "left_indices": ["asset_1", "asset_2"],
+        "operator": ">=",
+        "right_value": 0.5
+    },
+    {
+        "left_indices": ["asset_3"],
+        "operator": "==",
+        "right_indices": ["asset_4"]
+    }
+]
+
+

In this case, the constraints specify that the combined weight of asset_1 and asset_2 +must be at least 0.5, while the weight of asset_3 must be equal to the weight of asset_4.

+

or

+
[
+    {
+        "left_indices": ["asset_1", "asset_2", "asset_6"],
+        "operator": "<=",
+        "right_value": ["asset_4"]
+    },
+    {
+        "left_indices": ["asset_3", "asset_5"],
+        "operator": ">=",
+        "right_value": 0.1
+    }
+]
+
+

In this case, the constraints specify that the combined weight of asset_1, asset_2, +and asset_6 must be less than or equal to the weight of asset_4, +while the combined weight of asset_3 and asset_5 must be greater than or equal to 0.1.

+

When the value corresponding to the left_indices is a list of assets, +the constraint is applied to the combined weight of those assets. +When the value of left_indices is a single asset, the constraint is applied to that asset's weight.

+

The only allowed values for operator are:

+
    +
  • ==: Equal to
  • +
  • <=: Less than or equal to
  • +
  • >=: Greater than or equal to
  • +
+

The permitted values for right_value and right_indices are as follows:

+
    +
  • right_value: A numeric value which constrains the combined weight of the assets in left_indices.
  • +
  • right_indices: A list of asset names whose combined weight is used for the constraint.
  • +
+

Module Content

+ +

This class is used to validate the constraints defined by the user.

+ +

Converts the user defined constraints into CVXPY constraints.

+ +

Converts the user defined constraints into constraints that can be used by +scipy.optimize.minimize function.

+
+
+
+
+
+
+

Functions

+
+
+def generate_constraints(portfolio_weights: cp.Variable,
asset_names: list[str],
asset_constraints: list[dict[str, Any]]) ‑> list[cvxpy.constraints.constraint.Constraint]
+
+
+
+ +Expand source code + +
def generate_constraints(
+    portfolio_weights: cp.Variable,
+    asset_names: list[str],
+    asset_constraints: list[dict[str, Any]],
+) -> list[cp.Constraint]:
+    """Generate constraints for the portfolio optimization problem.
+
+    Parameters
+    ----------
+    portfolio_weights : cp.Variable
+        The variable representing the portfolio weights.
+    asset_names : list[str]
+        List of asset names in the portfolio.
+    asset_constraints : list[dict[str, Any]]
+        List of asset constraints to apply to the portfolio weights.
+
+    Returns
+    -------
+    list[cp.Constraint]
+        List of cvxpy constraints generated from the asset constraints.
+
+    """
+    constraints: list[cp.Constraint] = []
+
+    _check_constraints(constraints=asset_constraints)
+    asset_indices: dict[str, int] = {name: i for i, name in enumerate(asset_names)}
+    for constraint in asset_constraints:
+        # Process left-hand-side of the constraint
+        left_expr = _process_left_side_of_constraint(
+            portfolio_weights=portfolio_weights,
+            left_indices=constraint["left_indices"],
+            asset_indices=asset_indices,
+        )
+        # process right-hand-side of the constraint
+        right_expr: cp.Expression | float = _process_right_side_of_constraint(
+            portfolio_weights=portfolio_weights,
+            right_value=constraint.get("right_value"),
+            right_indices=constraint.get("right_indices"),
+            asset_indices=asset_indices,
+        )
+        # Process the operator
+        cp_constraint: cp.Constraint = _process_operator(
+            operator=constraint["operator"],
+            left_expr=left_expr,
+            right_expr=right_expr,
+        )
+        constraints.append(cp_constraint)
+
+    return constraints
+
+

Generate constraints for the portfolio optimization problem.

+

Parameters

+
+
portfolio_weights : cp.Variable
+
The variable representing the portfolio weights.
+
asset_names : list[str]
+
List of asset names in the portfolio.
+
asset_constraints : list[dict[str, Any]]
+
List of asset constraints to apply to the portfolio weights.
+
+

Returns

+
+
list[cp.Constraint]
+
List of cvxpy constraints generated from the asset constraints.
+
+
+
+def generate_scipy_constraints(asset_constraints: list[dict[str, Any]], asset_names: list[str]) ‑> list[dict[str, typing.Any]] +
+
+
+ +Expand source code + +
def generate_scipy_constraints(
+    asset_constraints: list[dict[str, Any]],
+    asset_names: list[str],
+) -> list[dict[str, Any]]:
+    """Generate constraints for scipy optimization.
+
+    Parameters
+    ----------
+    asset_constraints : list[dict[str, Any]]
+        List of asset constraints to apply to the portfolio weights.
+    asset_names : list[str]
+        List of asset names in the portfolio.
+
+    Returns
+    -------
+    list[dict[str, Any]]
+        List of constraints formatted for scipy optimization.
+
+    """
+    _check_constraints(constraints=asset_constraints)
+    asset_indices: dict[str, int] = {name: i for i, name in enumerate(asset_names)}
+    scipy_constraints = []
+
+    for constraint in asset_constraints:
+        def constraint_fun(x, constraint=constraint):  # noqa: ANN001, ANN202
+            # Calculate left side
+            left_value = sum(x[asset_indices[idx]] for idx in constraint["left_indices"])
+
+            # Calculate right side
+            if constraint.get("right_value") is not None:
+                right_value = constraint["right_value"]
+            else:
+                right_value = sum(x[asset_indices[idx]] for idx in constraint["right_indices"])
+
+            # Return constraint value based on operator
+            if constraint["operator"] == "<=":
+                return right_value - left_value
+            if constraint["operator"] == ">=":
+                return left_value - right_value
+            if constraint["operator"] == "==":
+                return left_value - right_value
+            msg = f"Unsupported operator: {constraint['operator']}"
+            raise ValueError(msg)
+
+        constraint_type = "ineq" if constraint["operator"] in {"<=", ">="} else "eq"
+        scipy_constraints.append({
+            "type": constraint_type,
+            "fun": constraint_fun,
+        })
+
+    return scipy_constraints
+
+

Generate constraints for scipy optimization.

+

Parameters

+
+
asset_constraints : list[dict[str, Any]]
+
List of asset constraints to apply to the portfolio weights.
+
asset_names : list[str]
+
List of asset names in the portfolio.
+
+

Returns

+
+
list[dict[str, Any]]
+
List of constraints formatted for scipy optimization.
+
+
+
+
+
+

Classes

+
+
+class AssetConstraint +(**data: Any) +
+
+
+ +Expand source code + +
class AssetConstraint(BaseModel):
+    """Model for validating individual asset constraints."""
+
+    left_indices: list[str]
+    operator: Literal["==", "<=", ">=", "<", ">"]
+    right_value: float | None = None
+    right_indices: list[str] | None = None
+
+    # Validator for left_indices
+    @field_validator("left_indices")
+    @classmethod
+    def validate_left_indices(cls, v: list[str]) -> list[str]:
+        """Ensure left_indices is not empty.
+
+        Parameters
+        ----------
+        v : list[str]
+            List of asset names for the left-hand side of the constraint.
+
+        Returns
+        -------
+        list[str]
+            Validated list of asset names.
+
+        Raises
+        ------
+        ValueError
+            If left_indices is empty or contains duplicate names.
+
+        """
+        if not v:
+            msg = "left_indices must not be empty"
+            raise ValueError(msg)
+        if len(v) != len(set(v)):
+            msg = "Asset names in left_indices must be unique"
+            raise ValueError(msg)
+        return v
+
+    # Validator for the operator
+    @field_validator("operator")
+    @classmethod
+    def validate_operator(cls, v: Literal["==", "<=", ">="]) -> Literal["==", "<=", ">="]:
+        """Ensure operator is one of the allowed values.
+
+        Parameters
+        ----------
+        v : Literal["==", "<=", ">="]
+            The operator to validate.
+
+        Returns
+        -------
+        Literal["==", "<=", ">="]
+            Validated operator.
+
+        Raises
+        ------
+        ValueError
+            If the operator is not one of the allowed values.
+
+        """
+        allowed_operators = {"==", "<=", ">="}
+        if v not in allowed_operators:
+            msg = f"Operator must be one of {allowed_operators}, got {v}"
+            raise ValueError(msg)
+        return v
+
+    # Validator for the right_indices in the right side of the constraint
+    @field_validator("right_indices")
+    @classmethod
+    def validate_right_indices(cls, v: list[str] | None) -> list[str] | None:
+        """Ensure right_indices is not empty and contains unique names.
+
+        Parameters
+        ----------
+        v : list[str] | None
+            List of asset names for the right-hand side of the constraint.
+
+        Returns
+        -------
+        list[str] | None
+            Validated list of asset names or None if not provided.
+
+        Raises
+        ------
+        ValueError
+            If right_indices is not None and is empty or contains duplicate names.
+
+
+        """
+        if v is not None and not v:
+            msg = "right_indices must not be empty if provided"
+            raise ValueError(msg)
+        if v is not None and len(v) != len(set(v)):
+            msg = "Asset names in right_indices must be unique"
+            raise ValueError(msg)
+        return v
+
+    # Validator for the right_value in the right side of the constraint
+    @field_validator("right_value")
+    @classmethod
+    def validate_right_value(cls, v: float | None) -> float | None:
+        """Ensure right_value is a non-negative number if provided.
+
+        Parameters
+        ----------
+        v : float | None
+            The right-hand side value of the constraint.
+
+        Returns
+        -------
+        float | None
+            Validated right value or None if not provided.
+
+        Raises
+        ------
+        ValueError
+            If right_value is not None and is not a number or is negative.
+
+        """
+        if v is not None and not isinstance(v, int | float):
+            msg = "right_value must be a number if provided"
+            raise ValueError(msg)
+        if v is not None and (v < 0 or v > 1):
+            msg = "right_value must be between 0 and 1 if provided"
+            raise ValueError(msg)
+        return float(v) if v is not None else None
+
+    # Validator to ensure either right_value or right_indices is provided, but not both
+    @model_validator(mode="after")
+    def validate_right_side(self) -> Self:
+        """Ensure that either right_value or right_indices is provided, but not both.
+
+        Returns
+        -------
+        Self
+            The validated AssetConstraint instance.
+
+        Raises
+        ------
+        ValueError
+            If neither right_value nor right_indices is provided, or if both are provided.
+
+        """
+        right_value = self.right_value
+        right_indices = self.right_indices
+        if right_value is None and right_indices is None:
+            msg = "Either right_value or right_indices must be provided"
+            raise ValueError(msg)
+        if right_value is not None and right_indices is not None:
+            msg = "Only one of right_value or right_indices can be provided"
+            raise ValueError(msg)
+        return self
+
+

Model for validating individual asset constraints.

+

Create a new model by parsing and validating input data from keyword arguments.

+

Raises [ValidationError][pydantic_core.ValidationError] if the input data cannot be +validated to form a valid model.

+

self is explicitly positional-only to allow self as a field name.

+

Ancestors

+
    +
  • pydantic.main.BaseModel
  • +
+

Class variables

+
+
var left_indices : list[str]
+
+
+
+
var model_config
+
+
+
+
var operator : Literal['==', '<=', '>=', '<', '>']
+
+
+
+
var right_indices : list[str] | None
+
+
+
+
var right_value : float | None
+
+
+
+
+

Static methods

+
+
+def validate_left_indices(v: list[str]) ‑> list[str] +
+
+

Ensure left_indices is not empty.

+

Parameters

+
+
v : list[str]
+
List of asset names for the left-hand side of the constraint.
+
+

Returns

+
+
list[str]
+
Validated list of asset names.
+
+

Raises

+
+
ValueError
+
If left_indices is empty or contains duplicate names.
+
+
+
+def validate_operator(v: "Literal['==', '<=', '>=']") ‑> Literal['==', '<=', '>='] +
+
+

Ensure operator is one of the allowed values.

+

Parameters

+
+
v : Literal["==", "<=", ">="]
+
The operator to validate.
+
+

Returns

+
+
Literal["==", "<=", ">="]
+
Validated operator.
+
+

Raises

+
+
ValueError
+
If the operator is not one of the allowed values.
+
+
+
+def validate_right_indices(v: list[str] | None) ‑> list[str] | None +
+
+

Ensure right_indices is not empty and contains unique names.

+

Parameters

+
+
v : list[str] | None
+
List of asset names for the right-hand side of the constraint.
+
+

Returns

+
+
list[str] | None
+
Validated list of asset names or None if not provided.
+
+

Raises

+
+
ValueError
+
If right_indices is not None and is empty or contains duplicate names.
+
+
+
+def validate_right_value(v: float | None) ‑> float | None +
+
+

Ensure right_value is a non-negative number if provided.

+

Parameters

+
+
v : float | None
+
The right-hand side value of the constraint.
+
+

Returns

+
+
float | None
+
Validated right value or None if not provided.
+
+

Raises

+
+
ValueError
+
If right_value is not None and is not a number or is negative.
+
+
+
+

Methods

+
+
+def validate_right_side(self) ‑> Self +
+
+
+ +Expand source code + +
@model_validator(mode="after")
+def validate_right_side(self) -> Self:
+    """Ensure that either right_value or right_indices is provided, but not both.
+
+    Returns
+    -------
+    Self
+        The validated AssetConstraint instance.
+
+    Raises
+    ------
+    ValueError
+        If neither right_value nor right_indices is provided, or if both are provided.
+
+    """
+    right_value = self.right_value
+    right_indices = self.right_indices
+    if right_value is None and right_indices is None:
+        msg = "Either right_value or right_indices must be provided"
+        raise ValueError(msg)
+    if right_value is not None and right_indices is not None:
+        msg = "Only one of right_value or right_indices can be provided"
+        raise ValueError(msg)
+    return self
+
+

Ensure that either right_value or right_indices is provided, but not both.

+

Returns

+
+
Self
+
The validated AssetConstraint instance.
+
+

Raises

+
+
ValueError
+
If neither right_value nor right_indices is provided, or if both are provided.
+
+
+
+
+
+
+
+ +
+ + + diff --git a/penfolioop/constraints.py b/penfolioop/constraints.py deleted file mode 100644 index d157b58..0000000 --- a/penfolioop/constraints.py +++ /dev/null @@ -1,497 +0,0 @@ -# Copyright (c) 2025 Mohammadjavad Vakili. All rights reserved. - -"""Portfolio constraint generation module. - -This module provides functionality for generating optimization constraints -for portfolio management, including asset weight constraints and other -portfolio-specific limitations. - - -User Constraints ----------------- - -Users of `penfolioop` can define additional constraints on the asset classes in their portfolio. - -A valid constraints is a list of dictionaries, where each dictionary represents a constraint. - -Each constraint is a python dictionary and must include the following two keys: - -- `left_indices`: A list of asset names on the left-hand side of the constraint. -- `operator`: The comparison operator for the constraint (e.g., "==", "<=", ">="). - -Additionally, each dictionary must include ***one and only one*** of the following two keys: - -- `right_value`: A numeric value for the right-hand side of the constraint (optional). -- `right_indices`: A list of asset names on the right-hand side of the constraint (optional). - -Here are some valid constraints: - -```python -[ - { - "left_indices": ["asset_1", "asset_2"], - "operator": ">=", - "right_value": 0.5 - }, - { - "left_indices": ["asset_3"], - "operator": "==", - "right_indices": ["asset_4"] - } -] -``` - -In this case, the constraints specify that the combined weight of `asset_1` and `asset_2` -must be at least 0.5, while the weight of `asset_3` must be equal to the weight of `asset_4`. - -or - -```python -[ - { - "left_indices": ["asset_1", "asset_2", "asset_6"], - "operator": "<=", - "right_value": ["asset_4"] - }, - { - "left_indices": ["asset_3", "asset_5"], - "operator": ">=", - "right_value": 0.1 - } -] -``` - -In this case, the constraints specify that the combined weight of `asset_1`, `asset_2`, -and `asset_6` must be less than or equal to the weight of `asset_4`, -while the combined weight of `asset_3` and `asset_5` must be greater than or equal to 0.1. - -When the value corresponding to the `left_indices` is a list of assets, -the constraint is applied to the combined weight of those assets. -When the value of `left_indices` is a single asset, the constraint is applied to that asset's weight. - -The only allowed values for `operator` are: - -- `==`: Equal to -- `<=`: Less than or equal to -- `>=`: Greater than or equal to - -The permitted values for `right_value` and `right_indices` are as follows: - -- `right_value`: A numeric value which constrains the combined weight of the assets in `left_indices`. -- `right_indices`: A list of asset names whose combined weight is used for the constraint. - - -Module content --------------- - - -- `AssetConstraint` - -This class is used to validate the constraints defined by the user. - - - `generate_constraints` - - Converts the user defined constraints into CVXPY constraints. - - - `generate_scipy_constraints` - - Converts the user defined constraints into constraints that can be used by - `scipy.optimize.minimize` function. - -""" - -from __future__ import annotations - -from typing import Any, Literal, Self - -import cvxpy as cp -import numpy as np -from pydantic import BaseModel, ValidationError, field_validator, model_validator - - -class AssetConstraint(BaseModel): - """Model for validating individual asset constraints.""" - - left_indices: list[str] - operator: Literal["==", "<=", ">=", "<", ">"] - right_value: float | None = None - right_indices: list[str] | None = None - - # Validator for left_indices - @field_validator("left_indices") - @classmethod - def validate_left_indices(cls, v: list[str]) -> list[str]: - """Ensure left_indices is not empty. - - Parameters - ---------- - v : list[str] - List of asset names for the left-hand side of the constraint. - - Returns - ------- - list[str] - Validated list of asset names. - - Raises - ------ - ValueError - If left_indices is empty or contains duplicate names. - - """ - if not v: - msg = "left_indices must not be empty" - raise ValueError(msg) - if len(v) != len(set(v)): - msg = "Asset names in left_indices must be unique" - raise ValueError(msg) - return v - - # Validator for the operator - @field_validator("operator") - @classmethod - def validate_operator(cls, v: Literal["==", "<=", ">="]) -> Literal["==", "<=", ">="]: - """Ensure operator is one of the allowed values. - - Parameters - ---------- - v : Literal["==", "<=", ">="] - The operator to validate. - - Returns - ------- - Literal["==", "<=", ">="] - Validated operator. - - Raises - ------ - ValueError - If the operator is not one of the allowed values. - - """ - allowed_operators = {"==", "<=", ">="} - if v not in allowed_operators: - msg = f"Operator must be one of {allowed_operators}, got {v}" - raise ValueError(msg) - return v - - # Validator for the right_indices in the right side of the constraint - @field_validator("right_indices") - @classmethod - def validate_right_indices(cls, v: list[str] | None) -> list[str] | None: - """Ensure right_indices is not empty and contains unique names. - - Parameters - ---------- - v : list[str] | None - List of asset names for the right-hand side of the constraint. - - Returns - ------- - list[str] | None - Validated list of asset names or None if not provided. - - Raises - ------ - ValueError - If right_indices is not None and is empty or contains duplicate names. - - - """ - if v is not None and not v: - msg = "right_indices must not be empty if provided" - raise ValueError(msg) - if v is not None and len(v) != len(set(v)): - msg = "Asset names in right_indices must be unique" - raise ValueError(msg) - return v - - # Validator for the right_value in the right side of the constraint - @field_validator("right_value") - @classmethod - def validate_right_value(cls, v: float | None) -> float | None: - """Ensure right_value is a non-negative number if provided. - - Parameters - ---------- - v : float | None - The right-hand side value of the constraint. - - Returns - ------- - float | None - Validated right value or None if not provided. - - Raises - ------ - ValueError - If right_value is not None and is not a number or is negative. - - """ - if v is not None and not isinstance(v, int | float): - msg = "right_value must be a number if provided" - raise ValueError(msg) - if v is not None and (v < 0 or v > 1): - msg = "right_value must be between 0 and 1 if provided" - raise ValueError(msg) - return float(v) if v is not None else None - - # Validator to ensure either right_value or right_indices is provided, but not both - @model_validator(mode="after") - def validate_right_side(self) -> Self: - """Ensure that either right_value or right_indices is provided, but not both. - - Returns - ------- - Self - The validated AssetConstraint instance. - - Raises - ------ - ValueError - If neither right_value nor right_indices is provided, or if both are provided. - - """ - right_value = self.right_value - right_indices = self.right_indices - if right_value is None and right_indices is None: - msg = "Either right_value or right_indices must be provided" - raise ValueError(msg) - if right_value is not None and right_indices is not None: - msg = "Only one of right_value or right_indices can be provided" - raise ValueError(msg) - return self - - -def _check_constraints(constraints: list[dict[str, Any]]) -> None: - """Check if the constraints are valid. - - Parameters - ---------- - constraints : list[dict[str, Any]] - The asset constraints to validate. - - Raises - ------ - ValueError - If the constraint is invalid. - - """ - try: - for constraint in constraints: - AssetConstraint(**constraint) - except ValidationError as e: - msg = f"Invalid constraint: {e}" - raise ValueError(msg) from e - - -def _process_left_side_of_constraint( - portfolio_weights: cp.Variable, - left_indices: list[str], - asset_indices: dict[str, int], -) -> cp.Expression: - """Process the left-hand side of the constraint. - - Parameters - ---------- - portfolio_weights : cp.Variable - The variable representing the portfolio weights. - left_indices : list[str] - List of asset names for the left-hand side of the constraint. - asset_indices : dict[str, int] - Dictionary mapping asset names to their indices in the portfolio weights. - - Returns - ------- - cp.Expression - The left-hand side expression of the constraint. - - """ - left_vec = np.zeros(len(asset_indices)) - for idx in left_indices: - left_vec[asset_indices[idx]] = 1 - return left_vec @ portfolio_weights - - -def _process_right_side_of_constraint( - portfolio_weights: cp.Variable, - asset_indices: dict[str, int], - right_value: float | None = None, - right_indices: list[str] | None = None, -) -> cp.Expression | float: - """Process the right-hand side of the constraint. - - Parameters - ---------- - portfolio_weights : cp.Variable - The variable representing the portfolio weights. - asset_indices : dict[str, int] - Dictionary mapping asset names to their indices in the portfolio weights. - right_value : float | None, optional - The right-hand side value of the constraint. - right_indices : list[str] | None, optional - List of asset names for the right-hand side of the constraint. - - Returns - ------- - cp.Expression | float - The right-hand side expression of the constraint or a float if right_value is provided. - - Raises - ------ - ValueError - If neither right_value nor right_indices is provided, or if both are provided. - - """ - if right_value is not None: - return right_value - if right_indices is not None: - right_vec = np.zeros(len(asset_indices)) - for idx in right_indices: - right_vec[asset_indices[idx]] = 1 - return right_vec @ portfolio_weights - msg = "Constraint must have either 'right_value' or 'right_indices'." - raise ValueError(msg) - - -def _process_operator( - operator: Literal["==", "<=", ">="], - left_expr: cp.Expression, - right_expr: cp.Expression | float, -) -> cp.Constraint: - """Process the operator and return the corresponding constraint. - - Parameters - ---------- - operator : Literal["==", "<=", ">="] - The operator to apply to the left and right expressions. - - left_expr : cp.Expression - The left-hand side expression of the constraint. - - right_expr : cp.Expression | float - The right-hand side expression of the constraint or a float if right_value is provided. - - Returns - ------- - cp.Constraint - The constraint corresponding to the operator applied to the left and right expressions. - - Raises - ------ - ValueError - If the operator is not one of the allowed values. - - """ - if operator == "==": - return left_expr == right_expr - if operator == "<=": - return left_expr <= right_expr - if operator == ">=": - return left_expr >= right_expr - msg = f"Unknown operator: {operator}" - raise ValueError(msg) - - -def generate_constraints( - portfolio_weights: cp.Variable, - asset_names: list[str], - asset_constraints: list[dict[str, Any]], -) -> list[cp.Constraint]: - """Generate constraints for the portfolio optimization problem. - - Parameters - ---------- - portfolio_weights : cp.Variable - The variable representing the portfolio weights. - asset_names : list[str] - List of asset names in the portfolio. - asset_constraints : list[dict[str, Any]] - List of asset constraints to apply to the portfolio weights. - - Returns - ------- - list[cp.Constraint] - List of cvxpy constraints generated from the asset constraints. - - """ - constraints: list[cp.Constraint] = [] - - _check_constraints(constraints=asset_constraints) - asset_indices: dict[str, int] = {name: i for i, name in enumerate(asset_names)} - for constraint in asset_constraints: - # Process left-hand-side of the constraint - left_expr = _process_left_side_of_constraint( - portfolio_weights=portfolio_weights, - left_indices=constraint["left_indices"], - asset_indices=asset_indices, - ) - # process right-hand-side of the constraint - right_expr: cp.Expression | float = _process_right_side_of_constraint( - portfolio_weights=portfolio_weights, - right_value=constraint.get("right_value"), - right_indices=constraint.get("right_indices"), - asset_indices=asset_indices, - ) - # Process the operator - cp_constraint: cp.Constraint = _process_operator( - operator=constraint["operator"], - left_expr=left_expr, - right_expr=right_expr, - ) - constraints.append(cp_constraint) - - return constraints - - -def generate_scipy_constraints( - asset_constraints: list[dict[str, Any]], - asset_names: list[str], -) -> list[dict[str, Any]]: - """Generate constraints for scipy optimization. - - Parameters - ---------- - asset_constraints : list[dict[str, Any]] - List of asset constraints to apply to the portfolio weights. - asset_names : list[str] - List of asset names in the portfolio. - - Returns - ------- - list[dict[str, Any]] - List of constraints formatted for scipy optimization. - - """ - _check_constraints(constraints=asset_constraints) - asset_indices: dict[str, int] = {name: i for i, name in enumerate(asset_names)} - scipy_constraints = [] - - for constraint in asset_constraints: - def constraint_fun(x, constraint=constraint): # noqa: ANN001, ANN202 - # Calculate left side - left_value = sum(x[asset_indices[idx]] for idx in constraint["left_indices"]) - - # Calculate right side - if constraint.get("right_value") is not None: - right_value = constraint["right_value"] - else: - right_value = sum(x[asset_indices[idx]] for idx in constraint["right_indices"]) - - # Return constraint value based on operator - if constraint["operator"] == "<=": - return right_value - left_value - if constraint["operator"] == ">=": - return left_value - right_value - if constraint["operator"] == "==": - return left_value - right_value - msg = f"Unsupported operator: {constraint['operator']}" - raise ValueError(msg) - - constraint_type = "ineq" if constraint["operator"] in {"<=", ">="} else "eq" - scipy_constraints.append({ - "type": constraint_type, - "fun": constraint_fun, - }) - - return scipy_constraints diff --git a/docs/penfolioop/index.html b/penfolioop/index.html similarity index 97% rename from docs/penfolioop/index.html rename to penfolioop/index.html index 24b52aa..ed0926c 100644 --- a/docs/penfolioop/index.html +++ b/penfolioop/index.html @@ -1,85 +1,85 @@ - - - - - - -penfolioop API documentation - - - - - - - - - - - - - -
-
-
-

Package penfolioop

-
-
-
-
-

Sub-modules

-
-
penfolioop.constraints
-
-

Portfolio constraint generation module …

-
-
penfolioop.optimizers
-
-

Portfolio optimization algorithms and utilities …

-
-
penfolioop.portfolio
-
-

Portfolio optimization module …

-
-
-
-
-
-
-
-
-
-
- -
- - - + + + + + + +penfolioop API documentation + + + + + + + + + + + + + +
+
+
+

Package penfolioop

+
+
+
+
+

Sub-modules

+
+
penfolioop.constraints
+
+

Portfolio constraint generation module …

+
+
penfolioop.optimizers
+
+

Portfolio optimization and objective functions …

+
+
penfolioop.portfolio
+
+

Portfolio optimization module …

+
+
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/penfolioop/optimizers.html b/penfolioop/optimizers.html new file mode 100644 index 0000000..152e4bc --- /dev/null +++ b/penfolioop/optimizers.html @@ -0,0 +1,1183 @@ + + + + + + +penfolioop.optimizers API documentation + + + + + + + + + + + + + +
+
+
+

Module penfolioop.optimizers

+
+
+

Portfolio optimization and objective functions.

+

This module provides various objective functions for pension fund portfolio optimization, including:

+ +

This function Maximizes the surplus portfolio return to surplus portfolio risk.

+ +

Mean-variance optimization for surplus portfolios.

+ +

Maximizes surplus return with the option of an upper limit on the surplus variance.

+ +

Minimizes surplus variance with the option of a lower limit on the surplus return.

+ +

Finds the efficient frontier portfolios.

+

In all these problems, we aim to find the weight vector that optimally allocates assets in the portfolio. +The weight vector is always an array of asset weights plus a liability weight (the last element of the weight vector), +where the liability weight is always set to -1.

+

Let's assume that we have $n_{assets}$ in our portfolio. Therefore, the weight vector +is a $n_{assets} + 1$ dimensional vector, where the first $n_{assets}$ elements are the asset weights +and the last element is the liability weight.

+

+\mathbf{w} = \begin{bmatrix} +w_1 \\ +w_2 \\ +\vdots \\ +w_{n_{assets}} \\ +w_L +\end{bmatrix} = \begin{bmatrix} +w_1 \\ +w_2 \\ +\vdots \\ +w_{n_{assets}} \\ +-1 +\end{bmatrix} +,

+

where w_i is the weight of asset i and w_L is the weight of the liabilities, which is set to -1.

+

In a similar fashion, we define the expected return vector as an array containing the +expected returns of the assets and liabilities. This is a n_{assets} + 1 dimensional vector, +where the first n_{assets} elements are the expected returns of the assets and the +last element is the expected return of the liabilities.

+

+\mathbf{R} = \begin{bmatrix} +r_1 \\ +r_2 \\ +\vdots \\ +r_n \\ +r_L +\end{bmatrix}, +

+

where r_i is the expected return of asset i and r_L is the expected return of the liabilities.

+

The covariance matrix is defined as the covariance matrix of assets and liability returns. +This matrix is a n_{assets} + 1 by n_{assets} + 1 square matrix, where the first n_{assets} +rows and columns correspond to the assets and the last row and column correspond to the liabilities.

+

+\mathbf{\Sigma} = \begin{bmatrix} +\Sigma_{A} , \Sigma_{AL} \\ +\Sigma_{AL} , \sigma^{2}_{L} +\end{bmatrix}, +

+

where \Sigma_{A} is a covariance matrix of the assets, + \Sigma_{AL} is the covariance between the assets and liabilities, +and \sigma^{2}_{L} is the variance of the liabilities. + \Sigma_{A} is a n_{assets} by n_{assets} square matrix, where each element +represents the covariance between the returns of two assets. + \Sigma_{AL} is a n_{assets} dimensional vector, where each element represents the +covariance between the returns of an asset and liability return. + \sigma^{2}_{L} is the variance of the liability return.

+

With these conventions at hand, we can compute the surplus return (return of the portfolio in excess of liabilities) +and the surplus variance (variance of the surplus returns) in the following way.

+

+\begin{align*} +\text{Surplus Return} &= \mathbf{W}^{T} \mathbf{R} += \sum_{i=1}^{n_{assets}} w_{i} r_{i} - r_{L} \\ +\text{Surplus Variance} &= \mathbf{W}^{T} \mathbf{\Sigma} \mathbf{W} += \sum_{i=1}^{n_{assets}} \sum_{j=1}^{n_{assets}} w_{i} w_{j} \big(\Sigma_{A}\big)_{ij} - 2 \sum_{i=1}^{n_{assets}} w_{i} \big(\Sigma_{AL}\big)_{i} + \sigma^{2}_{L} +\end{align*} +

+

For the sake of clarity on the conventions used in this module, we repeat some of these definitions in the documentation of individual functions.

+
+
+
+
+
+
+

Functions

+
+
+def clean_up_weight_decorator(func: Callable[..., np.ndarray]) ‑> Callable[..., numpy.ndarray] +
+
+
+ +Expand source code + +
def clean_up_weight_decorator(func: Callable[..., np.ndarray]) -> Callable[..., np.ndarray]:
+    """Make a decorator to clean up weights after optimization.
+
+    Parameters
+    ----------
+    func : callable
+        The optimization function to decorate.
+
+    Returns
+    -------
+    callable
+        The decorated optimization function.
+    """
+
+    @wraps(func)
+    def wrapper(*args, **kwargs):  # noqa: ANN002, ANN003, ANN202
+        # Call the original optimization function
+        result = func(*args, **kwargs)
+        return _clean_up_weights(result)
+    return wrapper
+
+

Make a decorator to clean up weights after optimization.

+

Parameters

+
+
func : callable
+
The optimization function to decorate.
+
+

Returns

+
+
callable
+
The decorated optimization function.
+
+
+
+def efficient_frontier(portfolio: Portfolio,
asset_constraints: list[dict[str, Any]] | None = None,
surplus_return_range: tuple[float, float] = (0, 1)) ‑> dict[str, np.ndarray]
+
+
+
+ +Expand source code + +
def efficient_frontier(
+    portfolio: Portfolio,
+    asset_constraints: list[dict[str, Any]] | None = None,
+    surplus_return_range: tuple[float, float] = (0, 1),
+) -> dict[str, np.ndarray]:
+    r"""Find the efficient frontier of the portfolio.
+
+    This function calculates the weights of the following optimization problem by
+    varying the surplus return lower limit $\tilde{R}$.
+
+    $$
+    \underset{\mathbf{W}}{\text{minimize}} \quad \mathbf{W}^{T} \mathbf{C} \mathbf{W}
+    $$
+    subject to
+
+    $$
+    \mathbf{W}^{T} \mathbf{R} \geq \tilde{R}.
+    $$
+
+    By varying the surplus return lower limit, we get a different set of weights (different portfolios).
+    The set of all these optimal portfolios forms the efficient frontier.
+
+    Note that
+
+    $$
+    \begin{align*}
+    \mathbf{W} &=& \big[  w_{1}, w_{2}, \ldots, w_{n_{assets}}, -1 \big]^{T}, \\
+    \mathbf{R} &=& \big[  R_{1}, R_{2}, \ldots, R_{n_{assets}}, R_{L} \big]^{T}, \\
+    \end{align*} \\
+    $$
+
+    $$
+    \mathbf{\Sigma} = \begin{bmatrix}
+    \Sigma_{A} & \Sigma_{AL} \\
+    \Sigma_{AL} & \sigma^{2}_{L}
+    \end{bmatrix},
+    $$
+
+    where $\mathbf{W}$ is the vector of assets and liability weights, $\mathbf{R}$ is the vector of expected returns
+    for the assets and liabilities, and $\mathbf{\Sigma}$ is the covariance matrix of the assets and liabilities.
+    The last element of $\mathbf{W}$ corresponds to the liabilities. The liability weight is always set to -1.
+
+    As always, the following general constraints apply to the weights:
+
+    $$
+    \begin{align*}
+    (1) &\quad& \sum_{i=1}^{n_{assets}} w_{i} = 1, \\
+    (2) &\quad& w_{i} \geq 0, \quad \forall i \in \{1, \ldots, n_{assets}\}, \\
+    (3) &\quad& w_{n_{assets} + 1} = w_{L} = -1
+    \end{align*}
+    $$
+
+    If additional asset constraints are provided, they will be incorporated into the optimization problem.
+    See `penfolioop.constraints` for more details.
+
+    Parameters
+    ----------
+    portfolio : Portfolio
+        The portfolio object containing asset names, covariance matrix, expected returns.
+    asset_constraints : list[dict[str, Any]], optional
+        Additional constraints for the optimization problem. Default is None.
+    surplus_return_range : tuple[float, float], optional
+        Range of surplus return values to consider for the optimization. Default is (0, 1).
+
+    Returns
+    -------
+    dict
+        Dictionary containing arrays of weights, surplus returns, and surplus variances.
+    """
+    target_returns = np.linspace(surplus_return_range[0], surplus_return_range[1], 100)
+    weights_placeholder = []
+    surplus_return_place_holder = []
+    surplus_variance_place_holder = []
+    for target_return in target_returns:
+        weights = min_surplus_variance_optimizer(
+            portfolio=portfolio,
+            asset_constraints=asset_constraints,
+            surplus_return_lower_limit=target_return,
+        )
+        weights_placeholder.append(weights)
+        surplus_return_place_holder.append(portfolio.surplus_return(weights))
+        surplus_variance_place_holder.append(portfolio.surplus_variance(weights))
+
+    return {
+        "weights": np.array(weights_placeholder),
+        "surplus_returns": np.array(surplus_return_place_holder),
+        "surplus_variances": np.array(surplus_variance_place_holder)
+    }
+
+

Find the efficient frontier of the portfolio.

+

This function calculates the weights of the following optimization problem by +varying the surplus return lower limit $\tilde{R}$.

+

+\underset{\mathbf{W}}{\text{minimize}} \quad \mathbf{W}^{T} \mathbf{C} \mathbf{W} + +subject to

+

+\mathbf{W}^{T} \mathbf{R} \geq \tilde{R}. +

+

By varying the surplus return lower limit, we get a different set of weights (different portfolios). +The set of all these optimal portfolios forms the efficient frontier.

+

Note that

+

+\begin{align*} +\mathbf{W} &=& \big[ +w_{1}, w_{2}, \ldots, w_{n_{assets}}, -1 \big]^{T}, \\ +\mathbf{R} &=& \big[ +R_{1}, R_{2}, \ldots, R_{n_{assets}}, R_{L} \big]^{T}, \\ +\end{align*} \\ +

+

+\mathbf{\Sigma} = \begin{bmatrix} +\Sigma_{A} & \Sigma_{AL} \\ +\Sigma_{AL} & \sigma^{2}_{L} +\end{bmatrix}, +

+

where $\mathbf{W}$ is the vector of assets and liability weights, $\mathbf{R}$ is the vector of expected returns +for the assets and liabilities, and $\mathbf{\Sigma}$ is the covariance matrix of the assets and liabilities. +The last element of $\mathbf{W}$ corresponds to the liabilities. The liability weight is always set to -1.

+

As always, the following general constraints apply to the weights:

+

+\begin{align*} +(1) &\quad& \sum_{i=1}^{n_{assets}} w_{i} = 1, \\ +(2) &\quad& w_{i} \geq 0, \quad \forall i \in \{1, \ldots, n_{assets}\}, \\ +(3) &\quad& w_{n_{assets} + 1} = w_{L} = -1 +\end{align*} +

+

If additional asset constraints are provided, they will be incorporated into the optimization problem. +See penfolioop.constraints for more details.

+

Parameters

+
+
portfolio : Portfolio
+
The portfolio object containing asset names, covariance matrix, expected returns.
+
asset_constraints : list[dict[str, Any]], optional
+
Additional constraints for the optimization problem. Default is None.
+
surplus_return_range : tuple[float, float], optional
+
Range of surplus return values to consider for the optimization. Default is (0, 1).
+
+

Returns

+
+
dict
+
Dictionary containing arrays of weights, surplus returns, and surplus variances.
+
+
+
+def max_surplus_return_optimizer(portfolio: Portfolio,
asset_constraints: list[dict[str, Any]] | None = None,
surplus_risk_upper_limit: float | None = None) ‑> np.ndarray
+
+
+
+ +Expand source code + +
@clean_up_weight_decorator
+def max_surplus_return_optimizer(
+    portfolio: Portfolio,
+    asset_constraints: list[dict[str, Any]] | None = None,
+    surplus_risk_upper_limit: float | None = None,
+) -> np.ndarray:
+    r"""Optimize the asset weights to maximize the surplus return over the expected liabilities return.
+
+    The optimization problem can be formulated as:
+    $$
+    \underset{\mathbf{W}}{\mathrm{maximize}} \quad \mathbf{W}^{T}\mathbf{R},
+    $$
+
+    $$
+    \begin{align*}
+    \mathbf{W} &=& \big[  w_{1}, w_{2}, \ldots, w_{n_{assets}}, -1 \big]^{T}, \\
+    \mathbf{R} &=& \big[  R_{1}, R_{2}, \ldots, R_{n_{assets}}, R_{L} \big]^{T}, \\
+    \end{align*} \\
+    $$
+
+    $$
+    \mathbf{\Sigma} = \begin{bmatrix}
+    \Sigma_{A} & \Sigma_{AL} \\
+    \Sigma_{AL} & \sigma^{2}_{L}
+    \end{bmatrix},
+    $$
+
+    where $\mathbf{W}$ is the vector of assets and liability weights, $\mathbf{R}$ is the vector of expected returns
+    for the assets and liabilities, and $\mathbf{\Sigma}$ is the covariance matrix of the assets and liabilities.
+
+    The last element of $\mathbf{W}$ corresponds to the liabilities. The liability weight is always set to -1.
+
+
+    The optimization is subject to the following constraints:
+
+    $$
+    \begin{align*}
+    (1) &\quad& \sum_{i=1}^{n_{assets}} w_{i} = 1, \\
+    (2) &\quad& w_{i} \geq 0, \quad \forall i \in \{1, \ldots, n_{assets}\}, \\
+    (3) &\quad& w_{n_{assets} + 1} = w_{L} = -1
+    \end{align*}
+    $$
+
+    Additionally, if the parameter `surplus_risk_upper_limit` is provided by the user, we will add a surplus risk upper limit
+    constraint to the optimization problem:
+
+    $$
+    \mathbf{W}^{T} \mathbf{\Sigma} \mathbf{W} \leq \sigma^2
+    $$, where $\sigma$ is the surplus risk upper limit.
+
+
+    If the `asset_constraints` parameter is provided by the user, the optimization will include these additional
+    constraints. See `penfolioop.constraints` for more details. A valid `asset_constraints` must fulfill a set
+    of properties which are validated by the `penfolioop.constraints.AssetConstraint` class. Users are encouraged
+    to consult with the `penfolioop.constraints` module and in particular the `penfolioop.constraints.AssetConstraint`
+    class for more information on how to properly define asset constraints.
+
+    Parameters
+    ----------
+    portfolio : Portfolio
+        The portfolio object containing asset names, covariance matrix, expected returns.
+    asset_constraints : list[dict[str, Any]], optional
+        Additional constraints for the optimization problem. Default is None.
+    surplus_risk_upper_limit : float, optional
+        The surplus risk upper limit for the optimization problem. Default is None.
+
+    Returns
+    -------
+    np.ndarray
+        Optimized asset weights as a numpy array.
+
+    Raises
+    ------
+    ValueError
+        If the optimization fails or constraints are not satisfied.
+
+    """
+    n_assets = len(portfolio.names) - 1
+    weights = cp.Variable(n_assets + 1)
+
+    # Objective function: maximize the surplus return over the expected liabilities return
+    surplus_return = weights.T @ portfolio.expected_returns
+    objective = cp.Maximize(surplus_return)
+    # Constraints
+    constraints = [
+        cp.sum(weights[:n_assets]) == 1,  # Weights must sum to 1
+        weights[:n_assets] >= 0,          # No short selling
+        weights[-1] == -1,                # Last weight is liabilities
+    ]
+    # Apply asset constraints if provided by user
+    if asset_constraints:
+        constraints += generate_constraints(
+            portfolio_weights=weights, asset_constraints=asset_constraints, asset_names=portfolio.names
+        )
+    # Surplus risk upper limit constraint if provided by user
+    if surplus_risk_upper_limit is not None:
+        constraints.append(cp.quad_form(weights, portfolio.covariance_matrix) <= surplus_risk_upper_limit ** 2.)
+    # Solve the optimization problem
+    problem = cp.Problem(objective, constraints)
+    problem.solve()
+
+    if weights.value is None or problem.status != cp.OPTIMAL:
+        msg = "Optimization failed."
+        raise ValueError(msg)
+
+    return weights.value
+
+

Optimize the asset weights to maximize the surplus return over the expected liabilities return.

+

The optimization problem can be formulated as: + +\underset{\mathbf{W}}{\mathrm{maximize}} \quad \mathbf{W}^{T}\mathbf{R}, +

+

+\begin{align*} +\mathbf{W} &=& \big[ +w_{1}, w_{2}, \ldots, w_{n_{assets}}, -1 \big]^{T}, \\ +\mathbf{R} &=& \big[ +R_{1}, R_{2}, \ldots, R_{n_{assets}}, R_{L} \big]^{T}, \\ +\end{align*} \\ +

+

+\mathbf{\Sigma} = \begin{bmatrix} +\Sigma_{A} & \Sigma_{AL} \\ +\Sigma_{AL} & \sigma^{2}_{L} +\end{bmatrix}, +

+

where $\mathbf{W}$ is the vector of assets and liability weights, $\mathbf{R}$ is the vector of expected returns +for the assets and liabilities, and $\mathbf{\Sigma}$ is the covariance matrix of the assets and liabilities.

+

The last element of $\mathbf{W}$ corresponds to the liabilities. The liability weight is always set to -1.

+

The optimization is subject to the following constraints:

+

+\begin{align*} +(1) &\quad& \sum_{i=1}^{n_{assets}} w_{i} = 1, \\ +(2) &\quad& w_{i} \geq 0, \quad \forall i \in \{1, \ldots, n_{assets}\}, \\ +(3) &\quad& w_{n_{assets} + 1} = w_{L} = -1 +\end{align*} +

+

Additionally, if the parameter surplus_risk_upper_limit is provided by the user, we will add a surplus risk upper limit +constraint to the optimization problem:

+

+\mathbf{W}^{T} \mathbf{\Sigma} \mathbf{W} \leq \sigma^2 +, where $\sigma$ is the surplus risk upper limit.

+

If the asset_constraints parameter is provided by the user, the optimization will include these additional +constraints. See penfolioop.constraints for more details. A valid asset_constraints must fulfill a set +of properties which are validated by the AssetConstraint class. Users are encouraged +to consult with the penfolioop.constraints module and in particular the AssetConstraint +class for more information on how to properly define asset constraints.

+

Parameters

+
+
portfolio : Portfolio
+
The portfolio object containing asset names, covariance matrix, expected returns.
+
asset_constraints : list[dict[str, Any]], optional
+
Additional constraints for the optimization problem. Default is None.
+
surplus_risk_upper_limit : float, optional
+
The surplus risk upper limit for the optimization problem. Default is None.
+
+

Returns

+
+
np.ndarray
+
Optimized asset weights as a numpy array.
+
+

Raises

+
+
ValueError
+
If the optimization fails or constraints are not satisfied.
+
+
+
+def max_surplus_sharpe_ratio_optimizer(portfolio: Portfolio, asset_constraints: list[dict[str, Any]] | None = None) ‑> np.ndarray +
+
+
+ +Expand source code + +
@clean_up_weight_decorator
+def max_surplus_sharpe_ratio_optimizer(
+    portfolio: Portfolio, asset_constraints: list[dict[str, Any]] | None = None,
+) -> np.ndarray:
+    r"""Optimize the asset weights to achieve a target excess return over the expected liabilities return.
+
+    This problem can be formulated as:
+
+    $$
+    \underset{\mathbf{W}}{\mathrm{maximize}} \quad  \frac{\mathbf{W}^{T}\mathbf{R}}{\mathbf{W}^{T} \mathbf{\Sigma} \mathbf{W}},
+    $$
+
+
+    $$
+    \begin{align*}
+    \mathbf{W} &=& \big[  w_{1}, w_{2}, \ldots, w_{n_{assets}}, -1 \big]^{T}, \\
+    \mathbf{R} &=& \big[  R_{1}, R_{2}, \ldots, R_{n_{assets}}, R_{L} \big]^{T}, \\
+    \end{align*} \\
+    $$
+
+    $$
+    \mathbf{\Sigma} = \begin{bmatrix}
+    \Sigma_{A} & \Sigma_{AL} \\
+    \Sigma_{AL} & \sigma^{2}_{L}
+    \end{bmatrix},
+    $$
+
+    where $\mathbf{W}$ is the vector of assets and liability weights, $\mathbf{R}$ is the vector of expected returns
+    for the assets and liabilities, and $\mathbf{\Sigma}$ is the covariance matrix of the assets and liabilities.
+
+    The last element of $\mathbf{W}$ corresponds to the liabilities. The liability weight is always set to -1.
+
+
+    The optimization is subject to the following constraints:
+
+    $$
+    \begin{align*}
+    (1) &\quad& \sum_{i=1}^{n_{assets}} w_{i} = 1, \\
+    (2) &\quad& w_{i} \geq 0, \quad \forall i \in \{1, \ldots, n_{assets}\}, \\
+    (3) &\quad& w_{n_{assets} + 1} = w_{L} = -1
+    \end{align*}
+    $$
+
+    If the `asset_constraints` parameter is provided by the user, the optimization will include these additional constraints.
+    See `penfolioop.constraints` for more details. A valid `asset_constraints` must fullfill a set of properties which are validated
+    by the `penfolioop.constraints.AssetConstraint` class. Users are encouraged to consult with the `penfolioop.constraints`
+    module and in particular the `penfolioop.constraints.AssetConstraint` class for more information on how to properly define asset constraints.
+
+    Parameters
+    ----------
+    portfolio : Portfolio
+        The portfolio object containing asset names, covariance matrix, expected returns.
+    asset_constraints : list[dict[str, Any]], optional
+        Additional constraints for the optimization problem. Default is None.
+
+
+    Returns
+    -------
+    np.ndarray
+        Optimized asset weights as a numpy array.
+
+    Raises
+    ------
+    ValueError
+        If the optimization fails or constraints are not satisfied.
+
+    """  # noqa: E501
+    n_assets = len(portfolio.names) - 1
+
+    # Constraints
+    constraints = [
+        {"type": "eq", "fun": lambda w: np.sum(w[:n_assets]) - 1},  # weights sum to 1
+        {"type": "eq", "fun": lambda w: w[-1] + 1},                 # last weight is -1
+    ]
+    if asset_constraints:
+        constraints += generate_scipy_constraints(
+            asset_constraints=asset_constraints, asset_names=portfolio.names,
+        )
+    # Bounds
+    bounds = [(0, 1)] * n_assets + [(None, None)]  # last weight (liability) unbounded
+    # Initial guess
+    initial_weights = np.ones(n_assets + 1) / (n_assets)
+    initial_weights[-1] = -1  # liabilities weight
+
+    # Solve the optimization problem
+    result = minimize(
+        _negative_surplus_sharpe_ratio_objective,
+        initial_weights,
+        args=(portfolio.expected_returns, portfolio.covariance_matrix),
+        method="SLSQP",
+        bounds=bounds,
+        constraints=constraints
+    )
+    if not result.success:
+        msg = "Optimization failed."
+        raise ValueError(msg)
+
+    return result.x
+
+

Optimize the asset weights to achieve a target excess return over the expected liabilities return.

+

This problem can be formulated as:

+

+\underset{\mathbf{W}}{\mathrm{maximize}} \quad +\frac{\mathbf{W}^{T}\mathbf{R}}{\mathbf{W}^{T} \mathbf{\Sigma} \mathbf{W}}, +

+

+\begin{align*} +\mathbf{W} &=& \big[ +w_{1}, w_{2}, \ldots, w_{n_{assets}}, -1 \big]^{T}, \\ +\mathbf{R} &=& \big[ +R_{1}, R_{2}, \ldots, R_{n_{assets}}, R_{L} \big]^{T}, \\ +\end{align*} \\ +

+

+\mathbf{\Sigma} = \begin{bmatrix} +\Sigma_{A} & \Sigma_{AL} \\ +\Sigma_{AL} & \sigma^{2}_{L} +\end{bmatrix}, +

+

where $\mathbf{W}$ is the vector of assets and liability weights, $\mathbf{R}$ is the vector of expected returns +for the assets and liabilities, and $\mathbf{\Sigma}$ is the covariance matrix of the assets and liabilities.

+

The last element of $\mathbf{W}$ corresponds to the liabilities. The liability weight is always set to -1.

+

The optimization is subject to the following constraints:

+

+\begin{align*} +(1) &\quad& \sum_{i=1}^{n_{assets}} w_{i} = 1, \\ +(2) &\quad& w_{i} \geq 0, \quad \forall i \in \{1, \ldots, n_{assets}\}, \\ +(3) &\quad& w_{n_{assets} + 1} = w_{L} = -1 +\end{align*} +

+

If the asset_constraints parameter is provided by the user, the optimization will include these additional constraints. +See penfolioop.constraints for more details. A valid asset_constraints must fullfill a set of properties which are validated +by the AssetConstraint class. Users are encouraged to consult with the penfolioop.constraints +module and in particular the AssetConstraint class for more information on how to properly define asset constraints.

+

Parameters

+
+
portfolio : Portfolio
+
The portfolio object containing asset names, covariance matrix, expected returns.
+
asset_constraints : list[dict[str, Any]], optional
+
Additional constraints for the optimization problem. Default is None.
+
+

Returns

+
+
np.ndarray
+
Optimized asset weights as a numpy array.
+
+

Raises

+
+
ValueError
+
If the optimization fails or constraints are not satisfied.
+
+
+
+def min_surplus_variance_optimizer(portfolio: Portfolio,
asset_constraints: list[dict[str, Any]] | None = None,
surplus_return_lower_limit: float | None = None) ‑> np.ndarray
+
+
+
+ +Expand source code + +
@clean_up_weight_decorator
+def min_surplus_variance_optimizer(
+    portfolio: Portfolio, 
+    asset_constraints: list[dict[str, Any]] | None = None,
+    surplus_return_lower_limit: float | None = None,
+) -> np.ndarray:
+    r"""Optimize the asset weights to minimize the surplus variance of the portfolio.
+
+    This optimization problem can be formulated as:
+
+    $$
+    \underset{\mathbf{W}}{\mathrm{minimize}} \quad \mathbf{W}^{T} \mathbf{\Sigma} \mathbf{W},
+    $$
+
+    $$
+    \begin{align*}
+    \mathbf{W} &=& \big[  w_{1}, w_{2}, \ldots, w_{n_{assets}}, -1 \big]^{T}, \\
+    \mathbf{R} &=& \big[  R_{1}, R_{2}, \ldots, R_{n_{assets}}, R_{L} \big]^{T}, \\
+    \end{align*} \\
+    $$
+
+    $$
+    \mathbf{\Sigma} = \begin{bmatrix}
+    \Sigma_{A} & \Sigma_{AL} \\
+    \Sigma_{AL} & \sigma^{2}_{L}
+    \end{bmatrix},
+    $$
+
+    where $\mathbf{W}$ is the vector of assets and liability weights, $\mathbf{R}$ is the vector of expected returns
+    for the assets and liabilities, and $\mathbf{\Sigma}$ is the covariance matrix of the assets and liabilities.
+
+    The last element of $\mathbf{W}$ corresponds to the liabilities. The liability weight is always set to -1.
+
+    The optimization is subject to the following general constraints:
+
+    $$
+    \begin{align*}
+    (1) &\quad& \sum_{i=1}^{n_{assets}} w_{i} = 1, \\
+    (2) &\quad& w_{i} \geq 0, \quad \forall i \in \{1, \ldots, n_{assets}\}, \\
+    (3) &\quad& w_{n_{assets} + 1} = w_{L} = -1
+    \end{align*}
+    $$
+
+    Additionally, if the parameter `surplus_return_lower_limit` is provided by the user, 
+    we will add a surplus return lower limit constraint to the optimization problem:
+
+    $$
+    \mathbf{W}^{T} \mathbf{R} \geq \tilde{R}
+    $$,
+
+    where $\tilde{R}$ is the surplus return lower limit.
+
+
+    If the `asset_constraints` parameter is provided by the user, the optimization will include these
+    additional constraints. See `penfolioop.constraints` for more details. A valid `asset_constraints`
+    must fullfill a set of properties which are validated by the `penfolioop.constraints.AssetConstraint` class.
+    Users are encouraged to consult with the `penfolioop.constraints` module and in particular the 
+    `penfolioop.constraints.AssetConstraint` class for more information on how to properly define asset constraints.
+
+
+    Parameters
+    ----------
+    portfolio : Portfolio
+        The portfolio object containing asset names, covariance matrix, expected returns.
+    asset_constraints : list[dict[str, Any]], optional
+        Additional constraints for the optimization problem. Default is None.
+    surplus_return_lower_limit : float, optional
+        The surplus return lower limit for the optimization problem. Default is None.
+
+    Returns
+    -------
+    np.ndarray
+        Optimized asset weights as a numpy array.
+
+    Raises
+    ------
+    ValueError
+        If the optimization fails or constraints are not satisfied.
+
+    """
+    n_assets = len(portfolio.names) - 1
+    weights = cp.Variable(n_assets + 1)
+
+    # Objective function: minimize the surplus variance of the portfolio
+    surplus_variance = cp.quad_form(weights, portfolio.covariance_matrix)
+    objective = cp.Minimize(surplus_variance)
+    # Constraints
+    constraints = [
+        cp.sum(weights[:n_assets]) == 1,  # Weights must sum to 1
+        weights[:n_assets] >= 0,          # No short selling
+        weights[-1] == -1,                # Last weight is liabilities
+    ]
+    # Apply asset constraints if provided by user
+    if asset_constraints:
+        constraints += generate_constraints(
+            portfolio_weights=weights, asset_constraints=asset_constraints, asset_names=portfolio.names
+        )
+    # Apply surplus return lower limit constraint if provided by user
+    if surplus_return_lower_limit is not None:
+        constraints.append(weights.T @ portfolio.expected_returns >= surplus_return_lower_limit)
+    # Solve the optimization problem
+    problem = cp.Problem(objective, constraints)
+    problem.solve()
+
+    if weights.value is None or problem.status != cp.OPTIMAL:
+        msg = "Optimization failed"
+        raise ValueError(msg)
+
+    return weights.value
+
+

Optimize the asset weights to minimize the surplus variance of the portfolio.

+

This optimization problem can be formulated as:

+

+\underset{\mathbf{W}}{\mathrm{minimize}} \quad \mathbf{W}^{T} \mathbf{\Sigma} \mathbf{W}, +

+

+\begin{align*} +\mathbf{W} &=& \big[ +w_{1}, w_{2}, \ldots, w_{n_{assets}}, -1 \big]^{T}, \\ +\mathbf{R} &=& \big[ +R_{1}, R_{2}, \ldots, R_{n_{assets}}, R_{L} \big]^{T}, \\ +\end{align*} \\ +

+

+\mathbf{\Sigma} = \begin{bmatrix} +\Sigma_{A} & \Sigma_{AL} \\ +\Sigma_{AL} & \sigma^{2}_{L} +\end{bmatrix}, +

+

where $\mathbf{W}$ is the vector of assets and liability weights, $\mathbf{R}$ is the vector of expected returns +for the assets and liabilities, and $\mathbf{\Sigma}$ is the covariance matrix of the assets and liabilities.

+

The last element of $\mathbf{W}$ corresponds to the liabilities. The liability weight is always set to -1.

+

The optimization is subject to the following general constraints:

+

+\begin{align*} +(1) &\quad& \sum_{i=1}^{n_{assets}} w_{i} = 1, \\ +(2) &\quad& w_{i} \geq 0, \quad \forall i \in \{1, \ldots, n_{assets}\}, \\ +(3) &\quad& w_{n_{assets} + 1} = w_{L} = -1 +\end{align*} +

+

Additionally, if the parameter surplus_return_lower_limit is provided by the user, +we will add a surplus return lower limit constraint to the optimization problem:

+

+\mathbf{W}^{T} \mathbf{R} \geq \tilde{R} +,

+

where $\tilde{R}$ is the surplus return lower limit.

+

If the asset_constraints parameter is provided by the user, the optimization will include these +additional constraints. See penfolioop.constraints for more details. A valid asset_constraints +must fullfill a set of properties which are validated by the AssetConstraint class. +Users are encouraged to consult with the penfolioop.constraints module and in particular the +AssetConstraint class for more information on how to properly define asset constraints.

+

Parameters

+
+
portfolio : Portfolio
+
The portfolio object containing asset names, covariance matrix, expected returns.
+
asset_constraints : list[dict[str, Any]], optional
+
Additional constraints for the optimization problem. Default is None.
+
surplus_return_lower_limit : float, optional
+
The surplus return lower limit for the optimization problem. Default is None.
+
+

Returns

+
+
np.ndarray
+
Optimized asset weights as a numpy array.
+
+

Raises

+
+
ValueError
+
If the optimization fails or constraints are not satisfied.
+
+
+
+def surplus_mean_variance_optimizer(portfolio: Portfolio,
risk_aversion: float = 1.0,
asset_constraints: list[dict[str, Any]] | None = None) ‑> np.ndarray
+
+
+
+ +Expand source code + +
@clean_up_weight_decorator
+def surplus_mean_variance_optimizer(
+    portfolio: Portfolio, risk_aversion: float = 1., asset_constraints: list[dict[str, Any]] | None = None,
+) -> np.ndarray:
+    r"""Optimize the asset weights to maximize the surplus return over the expected liabilities return.
+
+    This optimization problem can be formulated as:
+
+    $$
+    \underset{\mathbf{W}}{\mathrm{maximize}} \quad  \mathbf{W}^{T}\mathbf{R} -  \frac{\lambda}{2}  \mathbf{W}^{T} \mathbf{\Sigma} \mathbf{W},
+    $$
+
+    $$
+    \begin{align*}
+    \mathbf{W} &=& \big[  w_{1}, w_{2}, \ldots, w_{n_{assets}}, -1 \big]^{T}, \\
+    \mathbf{R} &=& \big[  R_{1}, R_{2}, \ldots, R_{n_{assets}}, R_{L} \big]^{T}, \\
+    \end{align*} \\
+    $$
+
+    $$
+    \mathbf{\Sigma} = \begin{bmatrix}
+    \Sigma_{A} & \Sigma_{AL} \\
+    \Sigma_{AL} & \sigma^{2}_{L}
+    \end{bmatrix},
+    $$
+
+    where $\mathbf{W}$ is the vector of assets and liability weights, $\mathbf{R}$ is the vector of expected returns
+    for the assets and liabilities, $\mathbf{\Sigma}$ is the covariance matrix of the assets and liabilities, and $\lambda$
+    is the risk aversion parameter.
+
+    The last element of $\mathbf{W}$ corresponds to the liabilities. The liability weight is always set to -1.
+
+
+    The optimization is subject to the following constraints:
+
+    $$
+    \begin{align*}
+    (1) &\quad& \sum_{i=1}^{n_{assets}} w_{i} = 1, \\
+    (2) &\quad& w_{i} \geq 0, \quad \forall i \in \{1, \ldots, n_{assets}\}, \\
+    (3) &\quad& w_{n_{assets} + 1} = w_{L} = -1
+    \end{align*}
+    $$
+
+    If the `asset_constraints` parameter is provided by the user, the optimization will include these additional constraints.
+    See `penfolioop.constraints` for more details. A valid `asset_constraints` must fullfill a set of properties which are validated
+    by the `penfolioop.constraints.AssetConstraint` class. Users are encouraged to consult with the `penfolioop.constraints`
+    module and in particular the `penfolioop.constraints.AssetConstraint` class for more information on how to properly define asset constraints.
+
+
+    Parameters
+    ----------
+    portfolio : Portfolio
+        The portfolio object containing asset names, covariance matrix, expected returns.
+    lmbd : float, optional
+        Regularization parameter for the optimization problem. Default is 1.0.
+    asset_constraints : list[dict[str, Any]], optional
+        Additional constraints for the optimization problem. Default is None.
+
+    Returns
+    -------
+    np.ndarray
+        Optimized asset weights as a numpy array.
+
+    Raises
+    ------
+    ValueError
+        If the optimization fails or constraints are not satisfied.
+
+    """  # noqa: E501
+    if risk_aversion < 0:
+        msg = "Risk aversion must be non-negative."
+        raise ValueError(msg)
+
+    n_assets = len(portfolio.names) - 1
+    weights = cp.Variable(n_assets + 1)
+    # Objective function: maximize the surplus return over the expected liabilities return
+    surplus_return = weights.T @ portfolio.expected_returns
+    surplus_variance = cp.quad_form(weights, portfolio.covariance_matrix)
+    objective = cp.Maximize(surplus_return - risk_aversion / 2 * surplus_variance)
+    # Constraints
+    constraints = [
+        cp.sum(weights[:n_assets]) == 1,  # Weights must sum to 1
+        weights[:n_assets] >= 0,          # No short selling
+        weights[-1] == -1,                # Last weight is liabilities
+    ]
+    if asset_constraints:
+        constraints += generate_constraints(
+            portfolio_weights=weights, asset_constraints=asset_constraints, asset_names=portfolio.names
+        )
+    # Solve the optimization problem
+    problem = cp.Problem(objective, constraints)
+    problem.solve()
+
+    if weights.value is None or problem.status != cp.OPTIMAL:
+        msg = "Optimization failed."
+        raise ValueError(msg)
+
+    return weights.value
+
+

Optimize the asset weights to maximize the surplus return over the expected liabilities return.

+

This optimization problem can be formulated as:

+

+\underset{\mathbf{W}}{\mathrm{maximize}} \quad +\mathbf{W}^{T}\mathbf{R} - +\frac{\lambda}{2} +\mathbf{W}^{T} \mathbf{\Sigma} \mathbf{W}, +

+

+\begin{align*} +\mathbf{W} &=& \big[ +w_{1}, w_{2}, \ldots, w_{n_{assets}}, -1 \big]^{T}, \\ +\mathbf{R} &=& \big[ +R_{1}, R_{2}, \ldots, R_{n_{assets}}, R_{L} \big]^{T}, \\ +\end{align*} \\ +

+

+\mathbf{\Sigma} = \begin{bmatrix} +\Sigma_{A} & \Sigma_{AL} \\ +\Sigma_{AL} & \sigma^{2}_{L} +\end{bmatrix}, +

+

where $\mathbf{W}$ is the vector of assets and liability weights, $\mathbf{R}$ is the vector of expected returns +for the assets and liabilities, $\mathbf{\Sigma}$ is the covariance matrix of the assets and liabilities, and $\lambda$ +is the risk aversion parameter.

+

The last element of $\mathbf{W}$ corresponds to the liabilities. The liability weight is always set to -1.

+

The optimization is subject to the following constraints:

+

+\begin{align*} +(1) &\quad& \sum_{i=1}^{n_{assets}} w_{i} = 1, \\ +(2) &\quad& w_{i} \geq 0, \quad \forall i \in \{1, \ldots, n_{assets}\}, \\ +(3) &\quad& w_{n_{assets} + 1} = w_{L} = -1 +\end{align*} +

+

If the asset_constraints parameter is provided by the user, the optimization will include these additional constraints. +See penfolioop.constraints for more details. A valid asset_constraints must fullfill a set of properties which are validated +by the AssetConstraint class. Users are encouraged to consult with the penfolioop.constraints +module and in particular the AssetConstraint class for more information on how to properly define asset constraints.

+

Parameters

+
+
portfolio : Portfolio
+
The portfolio object containing asset names, covariance matrix, expected returns.
+
lmbd : float, optional
+
Regularization parameter for the optimization problem. Default is 1.0.
+
asset_constraints : list[dict[str, Any]], optional
+
Additional constraints for the optimization problem. Default is None.
+
+

Returns

+
+
np.ndarray
+
Optimized asset weights as a numpy array.
+
+

Raises

+
+
ValueError
+
If the optimization fails or constraints are not satisfied.
+
+
+
+
+
+
+
+ +
+ + + diff --git a/penfolioop/optimizers.py b/penfolioop/optimizers.py deleted file mode 100644 index e661a61..0000000 --- a/penfolioop/optimizers.py +++ /dev/null @@ -1,703 +0,0 @@ -# Copyright (c) 2025 Mohammadjavad Vakili. All rights reserved. - -r"""Portfolio optimization and objective functions. - -This module provides various objective functions for pension fund portfolio optimization, including: - -- `max_surplus_sharpe_ratio_optimizer`: - -This function Maximizes the surplus portfolio return to surplus portfolio risk. - -- `surplus_mean_variance_optimizer`: - -Mean-variance optimization for surplus portfolios. - -- `max_surplus_return_optimizer`: - -Maximizes surplus return with the option of an upper limit on the surplus variance. - -- `min_surplus_variance_optimizer`: - -Minimizes surplus variance with the option of a lower limit on the surplus return. - -- `efficient_frontier`: - -Finds the efficient frontier portfolios. - - -In all these problems, we aim to find the weight vector that optimally allocates assets in the portfolio. -The weight vector is always an array of asset weights plus a liability weight (the last element of the weight vector), -where the liability weight is always set to -1. - -Let's assume that we have $n_{assets}$ in our portfolio. Therefore, the weight vector -is a $n_{assets} + 1$ dimensional vector, where the first $n_{assets}$ elements are the asset weights -and the last element is the liability weight. - -$$ -\mathbf{w} = \begin{bmatrix} -w_1 \\ -w_2 \\ -\vdots \\ -w_{n_{assets}} \\ -w_L -\end{bmatrix} = \begin{bmatrix} -w_1 \\ -w_2 \\ -\vdots \\ -w_{n_{assets}} \\ --1 -\end{bmatrix} -$$, - -where \( w_i \) is the weight of asset \( i \) and \( w_L \) is the weight of the liabilities, which is set to -1. - -In a similar fashion, we define the expected return vector as an array containing the -expected returns of the assets and liabilities. This is a \(n_{assets} + 1\) dimensional vector, -where the first \(n_{assets}\) elements are the expected returns of the assets and the -last element is the expected return of the liabilities. - -$$ -\mathbf{R} = \begin{bmatrix} -r_1 \\ -r_2 \\ -\vdots \\ -r_n \\ -r_L -\end{bmatrix}, -$$ - -where \( r_i \) is the expected return of asset \( i \) and \( r_L \) is the expected return of the liabilities. - -The covariance matrix is defined as the covariance matrix of assets and liability returns. -This matrix is a \(n_{assets} + 1\) by \(n_{assets} + 1\) square matrix, where the first \(n_{assets}\) -rows and columns correspond to the assets and the last row and column correspond to the liabilities. - -$$ -\mathbf{\Sigma} = \begin{bmatrix} - \Sigma_{A} , \Sigma_{AL} \\ - \Sigma_{AL} , \sigma^{2}_{L} -\end{bmatrix}, -$$ - -where \( \Sigma_{A} \) is a covariance matrix of the assets, -\( \Sigma_{AL} \) is the covariance between the assets and liabilities, -and \( \sigma^{2}_{L} \) is the variance of the liabilities. -\( \Sigma_{A} \) is a \(n_{assets}\) by \(n_{assets}\) square matrix, where each element -represents the covariance between the returns of two assets. -\( \Sigma_{AL} \) is a \(n_{assets}\) dimensional vector, where each element represents the -covariance between the returns of an asset and liability return. -\( \sigma^{2}_{L} \) is the variance of the liability return. - -With these conventions at hand, we can compute the surplus return (return of the portfolio in excess of liabilities) -and the surplus variance (variance of the surplus returns) in the following way. - -$$ -\begin{align*} -\text{Surplus Return} &= \mathbf{W}^{T} \mathbf{R} = \sum_{i=1}^{n_{assets}} w_{i} r_{i} - r_{L} \\ -\text{Surplus Variance} &= \mathbf{W}^{T} \mathbf{\Sigma} \mathbf{W} = \sum_{i=1}^{n_{assets}} \sum_{j=1}^{n_{assets}} w_{i} w_{j} \big(\Sigma_{A}\big)_{ij} - 2 \sum_{i=1}^{n_{assets}} w_{i} \big(\Sigma_{AL}\big)_{i} + \sigma^{2}_{L} -\end{align*} -$$ - -For the sake of clarity on the conventions used in this module, we repeat some of these definitions in the documentation of individual functions. - -""" -from __future__ import annotations - -from functools import wraps -from typing import TYPE_CHECKING, Any, Callable - -import cvxpy as cp -import numpy as np -from scipy.optimize import minimize - -from penfolioop.constraints import generate_constraints, generate_scipy_constraints - -if TYPE_CHECKING: - from penfolioop.portfolio import Portfolio - - -MINISCULE_WEIGHT_THRESHOLD = 1e-6 - - -def _clean_up_weights(weights: np.ndarray) -> np.ndarray: - """Clean up the weights by ensuring they sum to 1 and the last weight is -1. - - Parameters - ---------- - weights : np.ndarray - The weights to clean up. - - Returns - ------- - np.ndarray - The cleaned-up weights. - """ - weights = weights.copy() - # set small negative weights to zero - weights[weights < 0] = 0 - # set miniscule weights to zero - weights[np.abs(weights) < MINISCULE_WEIGHT_THRESHOLD] = 0 - # make sure the asset weights sum to 1 - weights[:-1] /= weights[:-1].sum() - # make sure the liability weight is -1 - weights[-1] = -1 - return weights - - -def clean_up_weight_decorator(func: Callable[..., np.ndarray]) -> Callable[..., np.ndarray]: - """Make a decorator to clean up weights after optimization. - - Parameters - ---------- - func : callable - The optimization function to decorate. - - Returns - ------- - callable - The decorated optimization function. - """ - - @wraps(func) - def wrapper(*args, **kwargs): # noqa: ANN002, ANN003, ANN202 - # Call the original optimization function - result = func(*args, **kwargs) - return _clean_up_weights(result) - return wrapper - - -def _negative_surplus_sharpe_ratio_objective( - weights: np.ndarray, expected_returns: np.ndarray, covariance_matrix: np.ndarray, -) -> float: - """Construct an objective function to maximize the Sharpe ratio of the portfolio surplus. - - Parameters - ---------- - weights : np.ndarray - Weights of the assets in the portfolio. - expected_returns : np.ndarray - Expected returns of the assets and liabilities. - covariance_matrix : np.ndarray - Covariance matrix of the assets and liabilities. - - Returns - ------- - float - Negative of the Sharpe ratio (to be minimized). - - """ - surplus_return = weights.T @ expected_returns - surplus_variance = np.dot(weights.T, np.dot(covariance_matrix, weights)) - if surplus_variance <= 0: - return np.inf # Avoid division by zero or negative variance - - return -surplus_return / np.sqrt(surplus_variance) - - -@clean_up_weight_decorator -def max_surplus_sharpe_ratio_optimizer( - portfolio: Portfolio, asset_constraints: list[dict[str, Any]] | None = None, -) -> np.ndarray: - r"""Optimize the asset weights to achieve a target excess return over the expected liabilities return. - - This problem can be formulated as: - - $$ - \underset{\mathbf{W}}{\mathrm{maximize}} \quad \frac{\mathbf{W}^{T}\mathbf{R}}{\mathbf{W}^{T} \mathbf{\Sigma} \mathbf{W}}, - $$ - - - $$ - \begin{align*} - \mathbf{W} &=& \big[ w_{1}, w_{2}, \ldots, w_{n_{assets}}, -1 \big]^{T}, \\ - \mathbf{R} &=& \big[ R_{1}, R_{2}, \ldots, R_{n_{assets}}, R_{L} \big]^{T}, \\ - \end{align*} \\ - $$ - - $$ - \mathbf{\Sigma} = \begin{bmatrix} - \Sigma_{A} & \Sigma_{AL} \\ - \Sigma_{AL} & \sigma^{2}_{L} - \end{bmatrix}, - $$ - - where $\mathbf{W}$ is the vector of assets and liability weights, $\mathbf{R}$ is the vector of expected returns - for the assets and liabilities, and $\mathbf{\Sigma}$ is the covariance matrix of the assets and liabilities. - - The last element of $\mathbf{W}$ corresponds to the liabilities. The liability weight is always set to -1. - - - The optimization is subject to the following constraints: - - $$ - \begin{align*} - (1) &\quad& \sum_{i=1}^{n_{assets}} w_{i} = 1, \\ - (2) &\quad& w_{i} \geq 0, \quad \forall i \in \{1, \ldots, n_{assets}\}, \\ - (3) &\quad& w_{n_{assets} + 1} = w_{L} = -1 - \end{align*} - $$ - - If the `asset_constraints` parameter is provided by the user, the optimization will include these additional constraints. - See `penfolioop.constraints` for more details. A valid `asset_constraints` must fullfill a set of properties which are validated - by the `penfolioop.constraints.AssetConstraint` class. Users are encouraged to consult with the `penfolioop.constraints` - module and in particular the `penfolioop.constraints.AssetConstraint` class for more information on how to properly define asset constraints. - - Parameters - ---------- - portfolio : Portfolio - The portfolio object containing asset names, covariance matrix, expected returns. - asset_constraints : list[dict[str, Any]], optional - Additional constraints for the optimization problem. Default is None. - - - Returns - ------- - np.ndarray - Optimized asset weights as a numpy array. - - Raises - ------ - ValueError - If the optimization fails or constraints are not satisfied. - - """ # noqa: E501 - n_assets = len(portfolio.names) - 1 - - # Constraints - constraints = [ - {"type": "eq", "fun": lambda w: np.sum(w[:n_assets]) - 1}, # weights sum to 1 - {"type": "eq", "fun": lambda w: w[-1] + 1}, # last weight is -1 - ] - if asset_constraints: - constraints += generate_scipy_constraints( - asset_constraints=asset_constraints, asset_names=portfolio.names, - ) - # Bounds - bounds = [(0, 1)] * n_assets + [(None, None)] # last weight (liability) unbounded - # Initial guess - initial_weights = np.ones(n_assets + 1) / (n_assets) - initial_weights[-1] = -1 # liabilities weight - - # Solve the optimization problem - result = minimize( - _negative_surplus_sharpe_ratio_objective, - initial_weights, - args=(portfolio.expected_returns, portfolio.covariance_matrix), - method="SLSQP", - bounds=bounds, - constraints=constraints - ) - if not result.success: - msg = "Optimization failed." - raise ValueError(msg) - - return result.x - - -@clean_up_weight_decorator -def surplus_mean_variance_optimizer( - portfolio: Portfolio, risk_aversion: float = 1., asset_constraints: list[dict[str, Any]] | None = None, -) -> np.ndarray: - r"""Optimize the asset weights to maximize the surplus return over the expected liabilities return. - - This optimization problem can be formulated as: - - $$ - \underset{\mathbf{W}}{\mathrm{maximize}} \quad \mathbf{W}^{T}\mathbf{R} - \frac{\lambda}{2} \mathbf{W}^{T} \mathbf{\Sigma} \mathbf{W}, - $$ - - $$ - \begin{align*} - \mathbf{W} &=& \big[ w_{1}, w_{2}, \ldots, w_{n_{assets}}, -1 \big]^{T}, \\ - \mathbf{R} &=& \big[ R_{1}, R_{2}, \ldots, R_{n_{assets}}, R_{L} \big]^{T}, \\ - \end{align*} \\ - $$ - - $$ - \mathbf{\Sigma} = \begin{bmatrix} - \Sigma_{A} & \Sigma_{AL} \\ - \Sigma_{AL} & \sigma^{2}_{L} - \end{bmatrix}, - $$ - - where $\mathbf{W}$ is the vector of assets and liability weights, $\mathbf{R}$ is the vector of expected returns - for the assets and liabilities, $\mathbf{\Sigma}$ is the covariance matrix of the assets and liabilities, and $\lambda$ - is the risk aversion parameter. - - The last element of $\mathbf{W}$ corresponds to the liabilities. The liability weight is always set to -1. - - - The optimization is subject to the following constraints: - - $$ - \begin{align*} - (1) &\quad& \sum_{i=1}^{n_{assets}} w_{i} = 1, \\ - (2) &\quad& w_{i} \geq 0, \quad \forall i \in \{1, \ldots, n_{assets}\}, \\ - (3) &\quad& w_{n_{assets} + 1} = w_{L} = -1 - \end{align*} - $$ - - If the `asset_constraints` parameter is provided by the user, the optimization will include these additional constraints. - See `penfolioop.constraints` for more details. A valid `asset_constraints` must fullfill a set of properties which are validated - by the `penfolioop.constraints.AssetConstraint` class. Users are encouraged to consult with the `penfolioop.constraints` - module and in particular the `penfolioop.constraints.AssetConstraint` class for more information on how to properly define asset constraints. - - - Parameters - ---------- - portfolio : Portfolio - The portfolio object containing asset names, covariance matrix, expected returns. - lmbd : float, optional - Regularization parameter for the optimization problem. Default is 1.0. - asset_constraints : list[dict[str, Any]], optional - Additional constraints for the optimization problem. Default is None. - - Returns - ------- - np.ndarray - Optimized asset weights as a numpy array. - - Raises - ------ - ValueError - If the optimization fails or constraints are not satisfied. - - """ # noqa: E501 - if risk_aversion < 0: - msg = "Risk aversion must be non-negative." - raise ValueError(msg) - - n_assets = len(portfolio.names) - 1 - weights = cp.Variable(n_assets + 1) - # Objective function: maximize the surplus return over the expected liabilities return - surplus_return = weights.T @ portfolio.expected_returns - surplus_variance = cp.quad_form(weights, portfolio.covariance_matrix) - objective = cp.Maximize(surplus_return - risk_aversion / 2 * surplus_variance) - # Constraints - constraints = [ - cp.sum(weights[:n_assets]) == 1, # Weights must sum to 1 - weights[:n_assets] >= 0, # No short selling - weights[-1] == -1, # Last weight is liabilities - ] - if asset_constraints: - constraints += generate_constraints( - portfolio_weights=weights, asset_constraints=asset_constraints, asset_names=portfolio.names - ) - # Solve the optimization problem - problem = cp.Problem(objective, constraints) - problem.solve() - - if weights.value is None or problem.status != cp.OPTIMAL: - msg = "Optimization failed." - raise ValueError(msg) - - return weights.value - - -@clean_up_weight_decorator -def max_surplus_return_optimizer( - portfolio: Portfolio, - asset_constraints: list[dict[str, Any]] | None = None, - surplus_risk_upper_limit: float | None = None, -) -> np.ndarray: - r"""Optimize the asset weights to maximize the surplus return over the expected liabilities return. - - The optimization problem can be formulated as: - $$ - \underset{\mathbf{W}}{\mathrm{maximize}} \quad \mathbf{W}^{T}\mathbf{R}, - $$ - - $$ - \begin{align*} - \mathbf{W} &=& \big[ w_{1}, w_{2}, \ldots, w_{n_{assets}}, -1 \big]^{T}, \\ - \mathbf{R} &=& \big[ R_{1}, R_{2}, \ldots, R_{n_{assets}}, R_{L} \big]^{T}, \\ - \end{align*} \\ - $$ - - $$ - \mathbf{\Sigma} = \begin{bmatrix} - \Sigma_{A} & \Sigma_{AL} \\ - \Sigma_{AL} & \sigma^{2}_{L} - \end{bmatrix}, - $$ - - where $\mathbf{W}$ is the vector of assets and liability weights, $\mathbf{R}$ is the vector of expected returns - for the assets and liabilities, and $\mathbf{\Sigma}$ is the covariance matrix of the assets and liabilities. - - The last element of $\mathbf{W}$ corresponds to the liabilities. The liability weight is always set to -1. - - - The optimization is subject to the following constraints: - - $$ - \begin{align*} - (1) &\quad& \sum_{i=1}^{n_{assets}} w_{i} = 1, \\ - (2) &\quad& w_{i} \geq 0, \quad \forall i \in \{1, \ldots, n_{assets}\}, \\ - (3) &\quad& w_{n_{assets} + 1} = w_{L} = -1 - \end{align*} - $$ - - Additionally, if the parameter `surplus_risk_upper_limit` is provided by the user, we will add a surplus risk upper limit - constraint to the optimization problem: - - $$ - \mathbf{W}^{T} \mathbf{\Sigma} \mathbf{W} \leq \sigma^2 - $$, where $\sigma$ is the surplus risk upper limit. - - - If the `asset_constraints` parameter is provided by the user, the optimization will include these additional - constraints. See `penfolioop.constraints` for more details. A valid `asset_constraints` must fulfill a set - of properties which are validated by the `penfolioop.constraints.AssetConstraint` class. Users are encouraged - to consult with the `penfolioop.constraints` module and in particular the `penfolioop.constraints.AssetConstraint` - class for more information on how to properly define asset constraints. - - Parameters - ---------- - portfolio : Portfolio - The portfolio object containing asset names, covariance matrix, expected returns. - asset_constraints : list[dict[str, Any]], optional - Additional constraints for the optimization problem. Default is None. - surplus_risk_upper_limit : float, optional - The surplus risk upper limit for the optimization problem. Default is None. - - Returns - ------- - np.ndarray - Optimized asset weights as a numpy array. - - Raises - ------ - ValueError - If the optimization fails or constraints are not satisfied. - - """ - n_assets = len(portfolio.names) - 1 - weights = cp.Variable(n_assets + 1) - - # Objective function: maximize the surplus return over the expected liabilities return - surplus_return = weights.T @ portfolio.expected_returns - objective = cp.Maximize(surplus_return) - # Constraints - constraints = [ - cp.sum(weights[:n_assets]) == 1, # Weights must sum to 1 - weights[:n_assets] >= 0, # No short selling - weights[-1] == -1, # Last weight is liabilities - ] - # Apply asset constraints if provided by user - if asset_constraints: - constraints += generate_constraints( - portfolio_weights=weights, asset_constraints=asset_constraints, asset_names=portfolio.names - ) - # Surplus risk upper limit constraint if provided by user - if surplus_risk_upper_limit is not None: - constraints.append(cp.quad_form(weights, portfolio.covariance_matrix) <= surplus_risk_upper_limit ** 2.) - # Solve the optimization problem - problem = cp.Problem(objective, constraints) - problem.solve() - - if weights.value is None or problem.status != cp.OPTIMAL: - msg = "Optimization failed." - raise ValueError(msg) - - return weights.value - - -@clean_up_weight_decorator -def min_surplus_variance_optimizer( - portfolio: Portfolio, - asset_constraints: list[dict[str, Any]] | None = None, - surplus_return_lower_limit: float | None = None, -) -> np.ndarray: - r"""Optimize the asset weights to minimize the surplus variance of the portfolio. - - This optimization problem can be formulated as: - - $$ - \underset{\mathbf{W}}{\mathrm{minimize}} \quad \mathbf{W}^{T} \mathbf{\Sigma} \mathbf{W}, - $$ - - $$ - \begin{align*} - \mathbf{W} &=& \big[ w_{1}, w_{2}, \ldots, w_{n_{assets}}, -1 \big]^{T}, \\ - \mathbf{R} &=& \big[ R_{1}, R_{2}, \ldots, R_{n_{assets}}, R_{L} \big]^{T}, \\ - \end{align*} \\ - $$ - - $$ - \mathbf{\Sigma} = \begin{bmatrix} - \Sigma_{A} & \Sigma_{AL} \\ - \Sigma_{AL} & \sigma^{2}_{L} - \end{bmatrix}, - $$ - - where $\mathbf{W}$ is the vector of assets and liability weights, $\mathbf{R}$ is the vector of expected returns - for the assets and liabilities, and $\mathbf{\Sigma}$ is the covariance matrix of the assets and liabilities. - - The last element of $\mathbf{W}$ corresponds to the liabilities. The liability weight is always set to -1. - - The optimization is subject to the following general constraints: - - $$ - \begin{align*} - (1) &\quad& \sum_{i=1}^{n_{assets}} w_{i} = 1, \\ - (2) &\quad& w_{i} \geq 0, \quad \forall i \in \{1, \ldots, n_{assets}\}, \\ - (3) &\quad& w_{n_{assets} + 1} = w_{L} = -1 - \end{align*} - $$ - - Additionally, if the parameter `surplus_return_lower_limit` is provided by the user, - we will add a surplus return lower limit constraint to the optimization problem: - - $$ - \mathbf{W}^{T} \mathbf{R} \geq \tilde{R} - $$, - - where $\tilde{R}$ is the surplus return lower limit. - - - If the `asset_constraints` parameter is provided by the user, the optimization will include these - additional constraints. See `penfolioop.constraints` for more details. A valid `asset_constraints` - must fullfill a set of properties which are validated by the `penfolioop.constraints.AssetConstraint` class. - Users are encouraged to consult with the `penfolioop.constraints` module and in particular the - `penfolioop.constraints.AssetConstraint` class for more information on how to properly define asset constraints. - - - Parameters - ---------- - portfolio : Portfolio - The portfolio object containing asset names, covariance matrix, expected returns. - asset_constraints : list[dict[str, Any]], optional - Additional constraints for the optimization problem. Default is None. - surplus_return_lower_limit : float, optional - The surplus return lower limit for the optimization problem. Default is None. - - Returns - ------- - np.ndarray - Optimized asset weights as a numpy array. - - Raises - ------ - ValueError - If the optimization fails or constraints are not satisfied. - - """ - n_assets = len(portfolio.names) - 1 - weights = cp.Variable(n_assets + 1) - - # Objective function: minimize the surplus variance of the portfolio - surplus_variance = cp.quad_form(weights, portfolio.covariance_matrix) - objective = cp.Minimize(surplus_variance) - # Constraints - constraints = [ - cp.sum(weights[:n_assets]) == 1, # Weights must sum to 1 - weights[:n_assets] >= 0, # No short selling - weights[-1] == -1, # Last weight is liabilities - ] - # Apply asset constraints if provided by user - if asset_constraints: - constraints += generate_constraints( - portfolio_weights=weights, asset_constraints=asset_constraints, asset_names=portfolio.names - ) - # Apply surplus return lower limit constraint if provided by user - if surplus_return_lower_limit is not None: - constraints.append(weights.T @ portfolio.expected_returns >= surplus_return_lower_limit) - # Solve the optimization problem - problem = cp.Problem(objective, constraints) - problem.solve() - - if weights.value is None or problem.status != cp.OPTIMAL: - msg = "Optimization failed" - raise ValueError(msg) - - return weights.value - - -def efficient_frontier( - portfolio: Portfolio, - asset_constraints: list[dict[str, Any]] | None = None, - surplus_return_range: tuple[float, float] = (0, 1), -) -> dict[str, np.ndarray]: - r"""Find the efficient frontier of the portfolio. - - This function calculates the weights of the following optimization problem by - varying the surplus return lower limit $\tilde{R}$. - - $$ - \underset{\mathbf{W}}{\text{minimize}} \quad \mathbf{W}^{T} \mathbf{C} \mathbf{W} - $$ - subject to - - $$ - \mathbf{W}^{T} \mathbf{R} \geq \tilde{R}. - $$ - - By varying the surplus return lower limit, we get a different set of weights (different portfolios). - The set of all these optimal portfolios forms the efficient frontier. - - Note that - - $$ - \begin{align*} - \mathbf{W} &=& \big[ w_{1}, w_{2}, \ldots, w_{n_{assets}}, -1 \big]^{T}, \\ - \mathbf{R} &=& \big[ R_{1}, R_{2}, \ldots, R_{n_{assets}}, R_{L} \big]^{T}, \\ - \end{align*} \\ - $$ - - $$ - \mathbf{\Sigma} = \begin{bmatrix} - \Sigma_{A} & \Sigma_{AL} \\ - \Sigma_{AL} & \sigma^{2}_{L} - \end{bmatrix}, - $$ - - where $\mathbf{W}$ is the vector of assets and liability weights, $\mathbf{R}$ is the vector of expected returns - for the assets and liabilities, and $\mathbf{\Sigma}$ is the covariance matrix of the assets and liabilities. - The last element of $\mathbf{W}$ corresponds to the liabilities. The liability weight is always set to -1. - - As always, the following general constraints apply to the weights: - - $$ - \begin{align*} - (1) &\quad& \sum_{i=1}^{n_{assets}} w_{i} = 1, \\ - (2) &\quad& w_{i} \geq 0, \quad \forall i \in \{1, \ldots, n_{assets}\}, \\ - (3) &\quad& w_{n_{assets} + 1} = w_{L} = -1 - \end{align*} - $$ - - If additional asset constraints are provided, they will be incorporated into the optimization problem. - See `penfolioop.constraints` for more details. - - Parameters - ---------- - portfolio : Portfolio - The portfolio object containing asset names, covariance matrix, expected returns. - asset_constraints : list[dict[str, Any]], optional - Additional constraints for the optimization problem. Default is None. - surplus_return_range : tuple[float, float], optional - Range of surplus return values to consider for the optimization. Default is (0, 1). - - Returns - ------- - dict - Dictionary containing arrays of weights, surplus returns, and surplus variances. - """ - target_returns = np.linspace(surplus_return_range[0], surplus_return_range[1], 100) - weights_placeholder = [] - surplus_return_place_holder = [] - surplus_variance_place_holder = [] - for target_return in target_returns: - weights = min_surplus_variance_optimizer( - portfolio=portfolio, - asset_constraints=asset_constraints, - surplus_return_lower_limit=target_return, - ) - weights_placeholder.append(weights) - surplus_return_place_holder.append(portfolio.surplus_return(weights)) - surplus_variance_place_holder.append(portfolio.surplus_variance(weights)) - - return { - "weights": np.array(weights_placeholder), - "surplus_returns": np.array(surplus_return_place_holder), - "surplus_variances": np.array(surplus_variance_place_holder) - } diff --git a/docs/penfolioop/portfolio.html b/penfolioop/portfolio.html similarity index 64% rename from docs/penfolioop/portfolio.html rename to penfolioop/portfolio.html index d01c150..a554f56 100644 --- a/docs/penfolioop/portfolio.html +++ b/penfolioop/portfolio.html @@ -1,687 +1,893 @@ - - - - - - -penfolioop.portfolio API documentation - - - - - - - - - - - - - -
-
-
-

Module penfolioop.portfolio

-
-
-

Portfolio optimization module.

-

This module provides: -- Portfolio: A dataclass representing a portfolio of assets and liabilities -with methods for calculating returns, variance, and surplus metrics.

-
-
-
-
-
-
-
-
-

Classes

-
-
-class Portfolio -(**data: Any) -
-
-
- -Expand source code - -
class Portfolio(BaseModel):
-    """Base model for Portfolio, used for validation.
-    
-    Attributes
-    ----------
-    names : list[str]
-        List of asset names in the portfolio.
-    covariance_matrix : np.ndarray
-        Covariance matrix of the asset returns.
-    expected_returns : np.ndarray
-        Expected returns of the assets in the portfolio.
-
-    Properties
-    ----------
-    n_assets : int
-        The number of assets in the portfolio, derived from the length of `names`.
-
-    Methods
-    -------
-    validate_covariance_matrix() -> Self
-        Validates the covariance matrix for shape, symmetry, and positive semi-definiteness.
-    validate_expected_returns() -> Self
-        Validates the expected returns array for shape consistency with the number of assets.
-    validate_weights(weights: np.ndarray) -> None
-        Validates the weights of the assets in the portfolio.
-    surplus_return(weights: np.ndarray) -> float
-        Calculates the surplus return of the portfolio given the asset weights.
-    surplus_variance(weights: np.ndarray) -> float
-        Calculates the surplus variance of the portfolio given the asset weights.
-    portfolio_return(weights: np.ndarray) -> float
-        Calculates the return of the portfolio given the asset weights.
-    portfolio_variance(weights: np.ndarray) -> float
-        Calculates the variance of the portfolio given the weights.
-
-    Raises
-    ------
-    ValueError
-        If the covariance matrix is not square, not symmetric, or not positive semi-definite,
-        or if the expected returns array does not match the number of assets.
-
-    """
-
-    names: list[str]
-    covariance_matrix: np.ndarray
-    expected_returns: np.ndarray
-
-    model_config = {
-        "arbitrary_types_allowed": True,
-    }
-
-
-
-    @property
-    def n_assets(self) -> int:
-        """Get the number of assets in the portfolio.
-
-        Returns
-        -------
-        int
-            The number of assets in the portfolio.
-
-        """
-        return len(self.names)
-
-    @model_validator(mode="after")
-    def validate_covariance_matrix(self) -> Self:
-        """Validate the covariance matrix of the portfolio.
-
-        Returns
-        -------
-        Self
-            The validated PortfolioModel instance.
-
-        Raises
-        ------
-        ValueError
-            If the covariance matrix is not square, not symmetric, or not positive semi-definite.
-
-        """
-        if self.covariance_matrix.shape != (self.n_assets, self.n_assets):
-            msg = "Covariance matrix must be square with dimensions equal to the number of assets."
-            raise ValueError(msg)
-        if self.covariance_matrix.ndim != 2:
-            msg = "Covariance matrix must be a 2D array."
-            raise ValueError(msg)
-        if not np.allclose(self.covariance_matrix, self.covariance_matrix.T):
-            msg = "Covariance matrix must be symmetric."
-            raise ValueError(msg)
-        if not np.all(np.linalg.eigvals(self.covariance_matrix) >= 0):
-            msg = "Covariance matrix must be positive semi-definite."
-            raise ValueError(msg)
-        return self
-
-    @model_validator(mode="after")
-    def validate_expected_returns(self) -> Self:
-        """Validate the expected returns of the portfolio.
-
-        Returns
-        -------
-        Self
-            The validated PortfolioModel instance.
-
-        Raises
-        ------
-        ValueError
-            If the expected returns array does not match the number of assets.
-
-        """
-        if self.expected_returns.shape != (self.n_assets,):
-            msg = "Expected returns must be a 1D array with length equal to the number of assets."
-            raise ValueError(msg)
-        return self
-
-    def validate_weights(self, weights: np.ndarray) -> None:
-        """Validate the weights of the portfolio.
-
-        Parameters
-        ----------
-        weights : np.ndarray
-            The weights of the assets in the portfolio.
-
-        Raises
-        ------
-        ValueError
-            If the weights do not match the number of assets, do not sum to zero,
-            or if the last weight is not -1 (for liabilities).
-
-        """
-        if len(weights) != self.n_assets:
-            msg = "Weights must match the number of assets."
-            raise ValueError(msg)
-        if not np.isclose(np.sum(weights), 0):
-            msg = "Weights must sum to zero."
-            raise ValueError(msg)
-        if weights[-1] != -1:
-            msg = "Last weight must be -1 (for liabilities)."
-            raise ValueError(msg)
-
-    def surplus_return(self, weights: np.ndarray) -> float:
-        """Calculate the surplus return of the portfolio given the asset weights.
-
-        Parameters
-        ----------
-        weights : np.ndarray
-            The weights of the assets in the portfolio.
-
-        Returns
-        -------
-        float
-            The surplus return of the portfolio over the expected liabilities return.
-
-        """
-        self.validate_weights(weights)
-        return float(weights.T @ self.expected_returns)
-
-    def surplus_variance(self, weights: np.ndarray) -> float:
-        """Calculate the surplus variance of the portfolio given the asset weights.
-
-        Parameters
-        ----------
-        weights : np.ndarray
-            The weights of the assets in the portfolio.
-
-        Returns
-        -------
-        float
-            The surplus variance of the portfolio.
-
-        """
-        self.validate_weights(weights)
-        return float(weights.T @ self.covariance_matrix @ weights)
-
-    def portfolio_return(self, weights: np.ndarray) -> float:
-        """Calculate the return of the portfolio given the asset weights.
-
-        Parameters
-        ----------
-        weights : np.ndarray
-            The weights of the assets in the portfolio.
-
-        Returns
-        -------
-        float
-            The return of the portfolio.
-
-        """
-        self.validate_weights(weights)
-        return float(weights[:-1].T @ self.expected_returns[:-1])
-
-    def portfolio_variance(self, weights: np.ndarray) -> float:
-        """Calculate the variance of the portfolio given the weights.
-
-        Parameters
-        ----------
-        weights : np.ndarray
-            The weights of the assets in the portfolio.
-
-        Returns
-        -------
-        float
-            The variance of the portfolio.
-
-        """
-        self.validate_weights(weights)
-        return float(weights[:-1].T @ self.covariance_matrix[:-1, :-1] @ weights[:-1])
-
-

Base model for Portfolio, used for validation.

-

Attributes

-
-
names : list[str]
-
List of asset names in the portfolio.
-
covariance_matrix : np.ndarray
-
Covariance matrix of the asset returns.
-
expected_returns : np.ndarray
-
Expected returns of the assets in the portfolio.
-
-

Properties

-

n_assets : int -The number of assets in the portfolio, derived from the length of names.

-

Methods

-

validate_covariance_matrix() -> Self -Validates the covariance matrix for shape, symmetry, and positive semi-definiteness. -validate_expected_returns() -> Self -Validates the expected returns array for shape consistency with the number of assets. -validate_weights(weights: np.ndarray) -> None -Validates the weights of the assets in the portfolio. -surplus_return(weights: np.ndarray) -> float -Calculates the surplus return of the portfolio given the asset weights. -surplus_variance(weights: np.ndarray) -> float -Calculates the surplus variance of the portfolio given the asset weights. -portfolio_return(weights: np.ndarray) -> float -Calculates the return of the portfolio given the asset weights. -portfolio_variance(weights: np.ndarray) -> float -Calculates the variance of the portfolio given the weights.

-

Raises

-
-
ValueError
-
If the covariance matrix is not square, not symmetric, or not positive semi-definite, -or if the expected returns array does not match the number of assets.
-
-

Create a new model by parsing and validating input data from keyword arguments.

-

Raises [ValidationError][pydantic_core.ValidationError] if the input data cannot be -validated to form a valid model.

-

self is explicitly positional-only to allow self as a field name.

-

Ancestors

-
    -
  • pydantic.main.BaseModel
  • -
-

Class variables

-
-
var covariance_matrix : numpy.ndarray
-
-

The type of the None singleton.

-
-
var expected_returns : numpy.ndarray
-
-

The type of the None singleton.

-
-
var model_config
-
-

The type of the None singleton.

-
-
var names : list[str]
-
-

The type of the None singleton.

-
-
-

Instance variables

-
-
prop n_assets : int
-
-
- -Expand source code - -
@property
-def n_assets(self) -> int:
-    """Get the number of assets in the portfolio.
-
-    Returns
-    -------
-    int
-        The number of assets in the portfolio.
-
-    """
-    return len(self.names)
-
-

Get the number of assets in the portfolio.

-

Returns

-
-
int
-
The number of assets in the portfolio.
-
-
-
-

Methods

-
-
-def portfolio_return(self, weights: numpy.ndarray) ‑> float -
-
-
- -Expand source code - -
def portfolio_return(self, weights: np.ndarray) -> float:
-    """Calculate the return of the portfolio given the asset weights.
-
-    Parameters
-    ----------
-    weights : np.ndarray
-        The weights of the assets in the portfolio.
-
-    Returns
-    -------
-    float
-        The return of the portfolio.
-
-    """
-    self.validate_weights(weights)
-    return float(weights[:-1].T @ self.expected_returns[:-1])
-
-

Calculate the return of the portfolio given the asset weights.

-

Parameters

-
-
weights : np.ndarray
-
The weights of the assets in the portfolio.
-
-

Returns

-
-
float
-
The return of the portfolio.
-
-
-
-def portfolio_variance(self, weights: numpy.ndarray) ‑> float -
-
-
- -Expand source code - -
def portfolio_variance(self, weights: np.ndarray) -> float:
-    """Calculate the variance of the portfolio given the weights.
-
-    Parameters
-    ----------
-    weights : np.ndarray
-        The weights of the assets in the portfolio.
-
-    Returns
-    -------
-    float
-        The variance of the portfolio.
-
-    """
-    self.validate_weights(weights)
-    return float(weights[:-1].T @ self.covariance_matrix[:-1, :-1] @ weights[:-1])
-
-

Calculate the variance of the portfolio given the weights.

-

Parameters

-
-
weights : np.ndarray
-
The weights of the assets in the portfolio.
-
-

Returns

-
-
float
-
The variance of the portfolio.
-
-
-
-def surplus_return(self, weights: numpy.ndarray) ‑> float -
-
-
- -Expand source code - -
def surplus_return(self, weights: np.ndarray) -> float:
-    """Calculate the surplus return of the portfolio given the asset weights.
-
-    Parameters
-    ----------
-    weights : np.ndarray
-        The weights of the assets in the portfolio.
-
-    Returns
-    -------
-    float
-        The surplus return of the portfolio over the expected liabilities return.
-
-    """
-    self.validate_weights(weights)
-    return float(weights.T @ self.expected_returns)
-
-

Calculate the surplus return of the portfolio given the asset weights.

-

Parameters

-
-
weights : np.ndarray
-
The weights of the assets in the portfolio.
-
-

Returns

-
-
float
-
The surplus return of the portfolio over the expected liabilities return.
-
-
-
-def surplus_variance(self, weights: numpy.ndarray) ‑> float -
-
-
- -Expand source code - -
def surplus_variance(self, weights: np.ndarray) -> float:
-    """Calculate the surplus variance of the portfolio given the asset weights.
-
-    Parameters
-    ----------
-    weights : np.ndarray
-        The weights of the assets in the portfolio.
-
-    Returns
-    -------
-    float
-        The surplus variance of the portfolio.
-
-    """
-    self.validate_weights(weights)
-    return float(weights.T @ self.covariance_matrix @ weights)
-
-

Calculate the surplus variance of the portfolio given the asset weights.

-

Parameters

-
-
weights : np.ndarray
-
The weights of the assets in the portfolio.
-
-

Returns

-
-
float
-
The surplus variance of the portfolio.
-
-
-
-def validate_covariance_matrix(self) ‑> Self -
-
-
- -Expand source code - -
@model_validator(mode="after")
-def validate_covariance_matrix(self) -> Self:
-    """Validate the covariance matrix of the portfolio.
-
-    Returns
-    -------
-    Self
-        The validated PortfolioModel instance.
-
-    Raises
-    ------
-    ValueError
-        If the covariance matrix is not square, not symmetric, or not positive semi-definite.
-
-    """
-    if self.covariance_matrix.shape != (self.n_assets, self.n_assets):
-        msg = "Covariance matrix must be square with dimensions equal to the number of assets."
-        raise ValueError(msg)
-    if self.covariance_matrix.ndim != 2:
-        msg = "Covariance matrix must be a 2D array."
-        raise ValueError(msg)
-    if not np.allclose(self.covariance_matrix, self.covariance_matrix.T):
-        msg = "Covariance matrix must be symmetric."
-        raise ValueError(msg)
-    if not np.all(np.linalg.eigvals(self.covariance_matrix) >= 0):
-        msg = "Covariance matrix must be positive semi-definite."
-        raise ValueError(msg)
-    return self
-
-

Validate the covariance matrix of the portfolio.

-

Returns

-
-
Self
-
The validated PortfolioModel instance.
-
-

Raises

-
-
ValueError
-
If the covariance matrix is not square, not symmetric, or not positive semi-definite.
-
-
-
-def validate_expected_returns(self) ‑> Self -
-
-
- -Expand source code - -
@model_validator(mode="after")
-def validate_expected_returns(self) -> Self:
-    """Validate the expected returns of the portfolio.
-
-    Returns
-    -------
-    Self
-        The validated PortfolioModel instance.
-
-    Raises
-    ------
-    ValueError
-        If the expected returns array does not match the number of assets.
-
-    """
-    if self.expected_returns.shape != (self.n_assets,):
-        msg = "Expected returns must be a 1D array with length equal to the number of assets."
-        raise ValueError(msg)
-    return self
-
-

Validate the expected returns of the portfolio.

-

Returns

-
-
Self
-
The validated PortfolioModel instance.
-
-

Raises

-
-
ValueError
-
If the expected returns array does not match the number of assets.
-
-
-
-def validate_weights(self, weights: numpy.ndarray) ‑> None -
-
-
- -Expand source code - -
def validate_weights(self, weights: np.ndarray) -> None:
-    """Validate the weights of the portfolio.
-
-    Parameters
-    ----------
-    weights : np.ndarray
-        The weights of the assets in the portfolio.
-
-    Raises
-    ------
-    ValueError
-        If the weights do not match the number of assets, do not sum to zero,
-        or if the last weight is not -1 (for liabilities).
-
-    """
-    if len(weights) != self.n_assets:
-        msg = "Weights must match the number of assets."
-        raise ValueError(msg)
-    if not np.isclose(np.sum(weights), 0):
-        msg = "Weights must sum to zero."
-        raise ValueError(msg)
-    if weights[-1] != -1:
-        msg = "Last weight must be -1 (for liabilities)."
-        raise ValueError(msg)
-
-

Validate the weights of the portfolio.

-

Parameters

-
-
weights : np.ndarray
-
The weights of the assets in the portfolio.
-
-

Raises

-
-
ValueError
-
If the weights do not match the number of assets, do not sum to zero, -or if the last weight is not -1 (for liabilities).
-
-
-
-
-
-
-
- -
- - - + + + + + + +penfolioop.portfolio API documentation + + + + + + + + + + + + + +
+
+
+

Module penfolioop.portfolio

+
+
+

Portfolio optimization module.

+

This module provides:

+
    +
  • Portfolio: A class representing a portfolio of assets and liabilities
  • +
+

As a convention, we use the following notation:

+
    +
  • $\mathbf{R}$: The vector of expected returns of assets and liabilities. +This is the expected_returns parameter needed to instantiate the Portfolio class.
  • +
+

+\mathbf{R} = \begin{bmatrix} r_1 \\ r_2 \\ \vdots \\ r_n \\ r_L \end{bmatrix}, +

+

where $r_1, r_2, \ldots, r_n$ are the expected returns of the assets and $r_L$ is the expected return of the liabilities. +The last element of $\mathbf{R}$ always corresponds to the liabilities.

+
    +
  • $\Sigma$: The total covariance matrix consisting of the covariance matrix of asset returns, the +covariance between asset returns and liability returns, and the variance of liability returns. +This is the covariance_matrix parameter needed to instantiate the Portfolio class.
  • +
+

+\mathbf{\Sigma} = \begin{bmatrix} +\Sigma_{A} , \Sigma_{AL} \\ +\Sigma_{AL} , \sigma^{2}_{L} +\end{bmatrix}, +

+

where \Sigma_{A} is a covariance matrix of the asset returns, + \Sigma_{AL} is the covariance between the assets and liabilities, +and \sigma^{2}_{L} is the variance of the liabilities.

+
    +
  • $\mathbf{W}$: The vector of weights of the assets and liabilities in the portfolio it is the output of the optimizers. +The last element corresponds to the liability and it is always set to -1:
  • +
+

+\mathbf{W} = \begin{bmatrix} +w_1 \\ +w_2 \\ +\vdots \\ +w_n \\ +-1 +\end{bmatrix}, + +where w_1, w_2, \ldots, w_n are the weights of the assets and -1 is the weight of the liabilities.

+
+
+
+
+
+
+
+
+

Classes

+
+
+class Portfolio +(**data: Any) +
+
+
+ +Expand source code + +
class Portfolio(BaseModel):
+    r"""Portfolio class.
+
+    This class can be used to instantiate a portfolio object with information about its assets and liabilities.
+    Let's assume that we have a portfolio of $n$. The following parameters are required to define the portfolio:
+
+    - `names`: A list with length of $n + 1$ consisting of asset names in the portfolio and the liability.
+    Example: `["Asset 1", "Asset 2", ... , "Asset n", "Liability"]`
+
+    - `expected_returns`: An array of length $n + 1$ consisting of expected returns for the assets.
+
+    $$
+    \mathbf{R} = \begin{bmatrix} r_1 \\ r_2 \\ \vdots \\ r_n \\ r_L \end{bmatrix},
+    $$
+
+    where $r_1, r_2, \ldots, r_n$ are the expected returns of the assets and $r_L$ is the 
+    expected return of the liabilities.
+    Example: `np.array([0.1, 0.2, ... , 0.1, 0.05])`, where the last element is the expected return of the liabilities.
+
+    - `covariance_matrix`: The total covariance matrix of the asset and liability returns.
+    $$
+    \mathbf{\Sigma} = \begin{bmatrix} \Sigma & \Sigma_{AL} \\ \Sigma_{AL} & \sigma_L^2 \end{bmatrix},
+    $$
+    where $\Sigma$ is the $n$ by $n$ covariance matrix of the asset returns, $\Sigma_{AL}$ is an $n$-dimensional vector
+    representing the covariance between the assets and liabilities, and $\sigma_L^2$ is the variance of the liabilities.
+
+    Example: `np.array([[0.1, 0.02, ...], [0.02, 0.1, ...], [...], [0.01, 0.005, ...]])`,
+    where the last row and column correspond to the liabilities.
+
+    Attributes
+    ----------
+    names : list[str]
+        List of asset names in the portfolio.
+    covariance_matrix : np.ndarray
+        Covariance matrix of the asset returns.
+    expected_returns : np.ndarray
+        Expected returns of the assets in the portfolio.
+
+    Properties
+    ----------
+    n_assets : int
+        The number of assets in the portfolio, derived from the length of `names`.
+
+    Methods
+    -------
+    validate_covariance_matrix() -> Self
+        Validates the covariance matrix for shape, symmetry, and positive semi-definiteness.
+
+    validate_expected_returns() -> Self
+        Validates the expected returns array for shape consistency with the number of assets.
+
+    validate_weights(weights: np.ndarray) -> None
+        Validates the weights of the assets in the portfolio.
+
+    surplus_return(weights: np.ndarray) -> float
+        Calculates the surplus return of the portfolio given the asset weights.
+
+    surplus_variance(weights: np.ndarray) -> float
+        Calculates the surplus variance of the portfolio given the asset weights.
+
+    portfolio_return(weights: np.ndarray) -> float
+        Calculates the return of the portfolio given the asset weights.
+
+    portfolio_variance(weights: np.ndarray) -> float
+        Calculates the variance of the portfolio given the weights.
+
+    Raises
+    ------
+    ValueError
+        If the covariance matrix is not square, not symmetric, or not positive semi-definite,
+        or if the expected returns array does not match the number of assets.
+
+    """
+
+    names: list[str]
+    covariance_matrix: np.ndarray
+    expected_returns: np.ndarray
+
+    model_config = {
+        "arbitrary_types_allowed": True,
+    }
+
+    @property
+    def n_assets(self) -> int:
+        """Get the number of assets in the portfolio.
+
+        Returns
+        -------
+        int
+            The number of assets in the portfolio.
+
+        """
+        return len(self.names) - 1
+
+    @model_validator(mode="after")
+    def validate_covariance_matrix(self) -> Self:
+        """Validate the covariance matrix of the portfolio.
+
+        Returns
+        -------
+        Self
+            The validated PortfolioModel instance.
+
+        Raises
+        ------
+        ValueError
+            If the covariance matrix is not square, not symmetric, not positive semi-definite, or when
+            it does not have the right dimensions.
+
+        """
+        if self.covariance_matrix.shape != (self.n_assets + 1, self.n_assets + 1):
+            msg = "Covariance matrix must be square with dimensions equal to the number of assets + 1."
+            raise ValueError(msg)
+        if self.covariance_matrix.ndim != 2:  # noqa: PLR2004
+            msg = "Covariance matrix must be a 2D array."
+            raise ValueError(msg)
+        if not np.allclose(self.covariance_matrix, self.covariance_matrix.T):
+            msg = "Covariance matrix must be symmetric."
+            raise ValueError(msg)
+        if not np.all(np.linalg.eigvals(self.covariance_matrix) >= 0):
+            msg = "Covariance matrix must be positive semi-definite."
+            raise ValueError(msg)
+        return self
+
+    @model_validator(mode="after")
+    def validate_expected_returns(self) -> Self:
+        """Validate the expected returns of the portfolio.
+
+        Returns
+        -------
+        Self
+            The validated PortfolioModel instance.
+
+        Raises
+        ------
+        ValueError
+            If the expected returns array does not match the number of assets.
+
+        """
+        if self.expected_returns.shape != (self.n_assets + 1,):
+            msg = "Expected returns must be a 1D array with length equal to the number of assets + 1."
+            raise ValueError(msg)
+        return self
+
+    def validate_weights(self, weights: np.ndarray) -> None:
+        """Validate the weights of the portfolio.
+
+        Parameters
+        ----------
+        weights : np.ndarray
+            The weights of the assets in the portfolio.
+
+        Raises
+        ------
+        ValueError
+            If the weights do not match the number of assets, do not sum to zero,
+            or if the last weight is not -1 (for liabilities).
+
+        """
+        if len(weights) != self.n_assets + 1:
+            msg = "Weights must match the number of assets."
+            raise ValueError(msg)
+        if not np.isclose(np.sum(weights), 0):
+            msg = "Weights must sum to zero."
+            raise ValueError(msg)
+        if weights[-1] != -1:
+            msg = "Last weight must be -1 (for liabilities)."
+            raise ValueError(msg)
+
+    def surplus_return(self, weights: np.ndarray) -> float:
+        r"""Calculate the surplus return of the portfolio given the asset weights.
+
+        The surplus return is defined as the return of the portfolio - the expected return of the liabilities.
+        $$
+        R_s = R_p - R_L = \sum_{i=1}^{n} w_i R_i - R_L = \mathbf{W}^{T} \mathbf{R},
+        $$
+        where $R_i$ is the expected return of asset $i$, $R_L$ is the expected return of the liabilities,
+        $R = \begin{bmatrix} R_1 \\ R_2 \\ \vdots \\ R_n \\ R_L \end{bmatrix}$ is `self.expected_returns`
+        containing the expected returns of the assets and liabilities,
+        and $\mathbf{W} = \begin{bmatrix} w_1 \\ w_2 \\ \vdots \\ w_n \\ -1 \end{bmatrix}$ is the vector of weights.
+
+        Parameters
+        ----------
+        weights : np.ndarray
+            The weights of the assets in the portfolio.
+
+        Returns
+        -------
+        float
+            The surplus return of the portfolio over the expected liabilities return.
+
+        """
+        self.validate_weights(weights)
+        return float(weights.T @ self.expected_returns)
+
+    def surplus_variance(self, weights: np.ndarray) -> float:
+        r"""Calculate the surplus variance of the portfolio given the asset weights.
+
+        The surplus variance is defined as the variance of the surplus return.
+
+        $$
+        \sigma^{2}_{s} = \mathbf{W}^{T} \mathbf{\Sigma} \mathbf{W} = \sum_{i=1}^{n_{assets}} \sum_{j=1}^{n_{assets}} w_{i} w_{j} \big(\Sigma_{A}\big)_{ij} - 2 \sum_{i=1}^{n_{assets}} w_{i} \big(\Sigma_{AL}\big)_{i} + \sigma^{2}_{L}
+        $$
+        where \( \mathbf{\Sigma} \) is the covariance matrix of the asset returns and liabilities, 
+        \( \mathbf{\Sigma}_{A} \) is the covariance matrix of the asset returns, \( \mathbf{\Sigma}_{AL} \) is the covariance vector between the assets and liabilities, and \( \sigma^{2}_{L} \) is the variance of the liabilities.
+
+        Parameters
+        ----------
+        weights : np.ndarray
+            The weights of the assets in the portfolio.
+
+        Returns
+        -------
+        float
+            The surplus variance of the portfolio.
+
+        """  # noqa: E501
+        self.validate_weights(weights)
+        return float(weights.T @ self.covariance_matrix @ weights)
+
+    def portfolio_return(self, weights: np.ndarray) -> float:
+        r"""Calculate the return of the portfolio given the asset weights.
+
+        The return of the portfolio is defined as the weighted sum of the returns of the assets.
+
+        $$
+        R_p = \sum_{i=1}^{n_{}} w_i R_i = \mathbf{W}_{assets}^{T} \mathbf{R}_{assets},
+        $$
+
+        Parameters
+        ----------
+        weights : np.ndarray
+            The weights of the assets in the portfolio.
+
+        Returns
+        -------
+        float
+            The return of the portfolio.
+
+        """
+        self.validate_weights(weights)
+        return float(weights[:-1].T @ self.expected_returns[:-1])
+
+    def portfolio_variance(self, weights: np.ndarray) -> float:
+        r"""Calculate the variance of the portfolio given the weights.
+
+        The variance of the portfolio is defined as.
+
+        $$
+        \sigma^{2}_{p} = \mathbf{W}_{assets}^{T} \mathbf{\Sigma}_{A} \mathbf{W}_{assets}
+        $$
+
+        Parameters
+        ----------
+        weights : np.ndarray
+            The weights of the assets in the portfolio.
+
+        Returns
+        -------
+        float
+            The variance of the portfolio.
+
+        """
+        self.validate_weights(weights)
+        return float(weights[:-1].T @ self.covariance_matrix[:-1, :-1] @ weights[:-1])
+
+

Portfolio class.

+

This class can be used to instantiate a portfolio object with information about its assets and liabilities. +Let's assume that we have a portfolio of $n$. The following parameters are required to define the portfolio:

+
    +
  • +

    names: A list with length of $n + 1$ consisting of asset names in the portfolio and the liability. +Example: ["Asset 1", "Asset 2", ... , "Asset n", "Liability"]

    +
  • +
  • +

    expected_returns: An array of length $n + 1$ consisting of expected returns for the assets.

    +
  • +
+

+\mathbf{R} = \begin{bmatrix} r_1 \\ r_2 \\ \vdots \\ r_n \\ r_L \end{bmatrix}, +

+

where $r_1, r_2, \ldots, r_n$ are the expected returns of the assets and $r_L$ is the +expected return of the liabilities. +Example: np.array([0.1, 0.2, … , 0.1, 0.05]), where the last element is the expected return of the liabilities.

+
    +
  • covariance_matrix: The total covariance matrix of the asset and liability returns. + +\mathbf{\Sigma} = \begin{bmatrix} \Sigma & \Sigma_{AL} \\ \Sigma_{AL} & \sigma_L^2 \end{bmatrix}, + +where $\Sigma$ is the $n$ by $n$ covariance matrix of the asset returns, $\Sigma_{AL}$ is an $n$-dimensional vector +representing the covariance between the assets and liabilities, and $\sigma_L^2$ is the variance of the liabilities.
  • +
+

Example: np.array([[0.1, 0.02, …], [0.02, 0.1, …], […], [0.01, 0.005, …]]), +where the last row and column correspond to the liabilities.

+

Attributes

+
+
names : list[str]
+
List of asset names in the portfolio.
+
covariance_matrix : np.ndarray
+
Covariance matrix of the asset returns.
+
expected_returns : np.ndarray
+
Expected returns of the assets in the portfolio.
+
+

Properties

+

n_assets : int +The number of assets in the portfolio, derived from the length of names.

+

Methods

+

validate_covariance_matrix() -> Self +Validates the covariance matrix for shape, symmetry, and positive semi-definiteness.

+

validate_expected_returns() -> Self +Validates the expected returns array for shape consistency with the number of assets.

+

validate_weights(weights: np.ndarray) -> None +Validates the weights of the assets in the portfolio.

+

surplus_return(weights: np.ndarray) -> float +Calculates the surplus return of the portfolio given the asset weights.

+

surplus_variance(weights: np.ndarray) -> float +Calculates the surplus variance of the portfolio given the asset weights.

+

portfolio_return(weights: np.ndarray) -> float +Calculates the return of the portfolio given the asset weights.

+

portfolio_variance(weights: np.ndarray) -> float +Calculates the variance of the portfolio given the weights.

+

Raises

+
+
ValueError
+
If the covariance matrix is not square, not symmetric, or not positive semi-definite, +or if the expected returns array does not match the number of assets.
+
+

Create a new model by parsing and validating input data from keyword arguments.

+

Raises [ValidationError][pydantic_core.ValidationError] if the input data cannot be +validated to form a valid model.

+

self is explicitly positional-only to allow self as a field name.

+

Ancestors

+
    +
  • pydantic.main.BaseModel
  • +
+

Class variables

+
+
var covariance_matrix : numpy.ndarray
+
+
+
+
var expected_returns : numpy.ndarray
+
+
+
+
var model_config
+
+
+
+
var names : list[str]
+
+
+
+
+

Instance variables

+
+
prop n_assets : int
+
+
+ +Expand source code + +
@property
+def n_assets(self) -> int:
+    """Get the number of assets in the portfolio.
+
+    Returns
+    -------
+    int
+        The number of assets in the portfolio.
+
+    """
+    return len(self.names) - 1
+
+

Get the number of assets in the portfolio.

+

Returns

+
+
int
+
The number of assets in the portfolio.
+
+
+
+

Methods

+
+
+def portfolio_return(self, weights: numpy.ndarray) ‑> float +
+
+
+ +Expand source code + +
def portfolio_return(self, weights: np.ndarray) -> float:
+    r"""Calculate the return of the portfolio given the asset weights.
+
+    The return of the portfolio is defined as the weighted sum of the returns of the assets.
+
+    $$
+    R_p = \sum_{i=1}^{n_{}} w_i R_i = \mathbf{W}_{assets}^{T} \mathbf{R}_{assets},
+    $$
+
+    Parameters
+    ----------
+    weights : np.ndarray
+        The weights of the assets in the portfolio.
+
+    Returns
+    -------
+    float
+        The return of the portfolio.
+
+    """
+    self.validate_weights(weights)
+    return float(weights[:-1].T @ self.expected_returns[:-1])
+
+

Calculate the return of the portfolio given the asset weights.

+

The return of the portfolio is defined as the weighted sum of the returns of the assets.

+

+R_p = \sum_{i=1}^{n_{}} w_i R_i = \mathbf{W}_{assets}^{T} \mathbf{R}_{assets}, +

+

Parameters

+
+
weights : np.ndarray
+
The weights of the assets in the portfolio.
+
+

Returns

+
+
float
+
The return of the portfolio.
+
+
+
+def portfolio_variance(self, weights: numpy.ndarray) ‑> float +
+
+
+ +Expand source code + +
def portfolio_variance(self, weights: np.ndarray) -> float:
+    r"""Calculate the variance of the portfolio given the weights.
+
+    The variance of the portfolio is defined as.
+
+    $$
+    \sigma^{2}_{p} = \mathbf{W}_{assets}^{T} \mathbf{\Sigma}_{A} \mathbf{W}_{assets}
+    $$
+
+    Parameters
+    ----------
+    weights : np.ndarray
+        The weights of the assets in the portfolio.
+
+    Returns
+    -------
+    float
+        The variance of the portfolio.
+
+    """
+    self.validate_weights(weights)
+    return float(weights[:-1].T @ self.covariance_matrix[:-1, :-1] @ weights[:-1])
+
+

Calculate the variance of the portfolio given the weights.

+

The variance of the portfolio is defined as.

+

+\sigma^{2}_{p} = \mathbf{W}_{assets}^{T} \mathbf{\Sigma}_{A} \mathbf{W}_{assets} +

+

Parameters

+
+
weights : np.ndarray
+
The weights of the assets in the portfolio.
+
+

Returns

+
+
float
+
The variance of the portfolio.
+
+
+
+def surplus_return(self, weights: numpy.ndarray) ‑> float +
+
+
+ +Expand source code + +
def surplus_return(self, weights: np.ndarray) -> float:
+    r"""Calculate the surplus return of the portfolio given the asset weights.
+
+    The surplus return is defined as the return of the portfolio - the expected return of the liabilities.
+    $$
+    R_s = R_p - R_L = \sum_{i=1}^{n} w_i R_i - R_L = \mathbf{W}^{T} \mathbf{R},
+    $$
+    where $R_i$ is the expected return of asset $i$, $R_L$ is the expected return of the liabilities,
+    $R = \begin{bmatrix} R_1 \\ R_2 \\ \vdots \\ R_n \\ R_L \end{bmatrix}$ is `self.expected_returns`
+    containing the expected returns of the assets and liabilities,
+    and $\mathbf{W} = \begin{bmatrix} w_1 \\ w_2 \\ \vdots \\ w_n \\ -1 \end{bmatrix}$ is the vector of weights.
+
+    Parameters
+    ----------
+    weights : np.ndarray
+        The weights of the assets in the portfolio.
+
+    Returns
+    -------
+    float
+        The surplus return of the portfolio over the expected liabilities return.
+
+    """
+    self.validate_weights(weights)
+    return float(weights.T @ self.expected_returns)
+
+

Calculate the surplus return of the portfolio given the asset weights.

+

The surplus return is defined as the return of the portfolio - the expected return of the liabilities. + +R_s = R_p - R_L = \sum_{i=1}^{n} w_i R_i - R_L = \mathbf{W}^{T} \mathbf{R}, + +where $R_i$ is the expected return of asset $i$, $R_L$ is the expected return of the liabilities, +$R = \begin{bmatrix} R_1 \ R_2 \ \vdots \ R_n \ R_L \end{bmatrix}$ is self.expected_returns +containing the expected returns of the assets and liabilities, +and $\mathbf{W} = \begin{bmatrix} w_1 \ w_2 \ \vdots \ w_n \ -1 \end{bmatrix}$ is the vector of weights.

+

Parameters

+
+
weights : np.ndarray
+
The weights of the assets in the portfolio.
+
+

Returns

+
+
float
+
The surplus return of the portfolio over the expected liabilities return.
+
+
+
+def surplus_variance(self, weights: numpy.ndarray) ‑> float +
+
+
+ +Expand source code + +
def surplus_variance(self, weights: np.ndarray) -> float:
+    r"""Calculate the surplus variance of the portfolio given the asset weights.
+
+    The surplus variance is defined as the variance of the surplus return.
+
+    $$
+    \sigma^{2}_{s} = \mathbf{W}^{T} \mathbf{\Sigma} \mathbf{W} = \sum_{i=1}^{n_{assets}} \sum_{j=1}^{n_{assets}} w_{i} w_{j} \big(\Sigma_{A}\big)_{ij} - 2 \sum_{i=1}^{n_{assets}} w_{i} \big(\Sigma_{AL}\big)_{i} + \sigma^{2}_{L}
+    $$
+    where \( \mathbf{\Sigma} \) is the covariance matrix of the asset returns and liabilities, 
+    \( \mathbf{\Sigma}_{A} \) is the covariance matrix of the asset returns, \( \mathbf{\Sigma}_{AL} \) is the covariance vector between the assets and liabilities, and \( \sigma^{2}_{L} \) is the variance of the liabilities.
+
+    Parameters
+    ----------
+    weights : np.ndarray
+        The weights of the assets in the portfolio.
+
+    Returns
+    -------
+    float
+        The surplus variance of the portfolio.
+
+    """  # noqa: E501
+    self.validate_weights(weights)
+    return float(weights.T @ self.covariance_matrix @ weights)
+
+

Calculate the surplus variance of the portfolio given the asset weights.

+

The surplus variance is defined as the variance of the surplus return.

+

+\sigma^{2}_{s} = \mathbf{W}^{T} \mathbf{\Sigma} \mathbf{W} = \sum_{i=1}^{n_{assets}} \sum_{j=1}^{n_{assets}} w_{i} w_{j} \big(\Sigma_{A}\big)_{ij} - 2 \sum_{i=1}^{n_{assets}} w_{i} \big(\Sigma_{AL}\big)_{i} + \sigma^{2}_{L} + +where \mathbf{\Sigma} is the covariance matrix of the asset returns and liabilities, + \mathbf{\Sigma}_{A} is the covariance matrix of the asset returns, \mathbf{\Sigma}_{AL} is the covariance vector between the assets and liabilities, and \sigma^{2}_{L} is the variance of the liabilities.

+

Parameters

+
+
weights : np.ndarray
+
The weights of the assets in the portfolio.
+
+

Returns

+
+
float
+
The surplus variance of the portfolio.
+
+
+
+def validate_covariance_matrix(self) ‑> Self +
+
+
+ +Expand source code + +
@model_validator(mode="after")
+def validate_covariance_matrix(self) -> Self:
+    """Validate the covariance matrix of the portfolio.
+
+    Returns
+    -------
+    Self
+        The validated PortfolioModel instance.
+
+    Raises
+    ------
+    ValueError
+        If the covariance matrix is not square, not symmetric, not positive semi-definite, or when
+        it does not have the right dimensions.
+
+    """
+    if self.covariance_matrix.shape != (self.n_assets + 1, self.n_assets + 1):
+        msg = "Covariance matrix must be square with dimensions equal to the number of assets + 1."
+        raise ValueError(msg)
+    if self.covariance_matrix.ndim != 2:  # noqa: PLR2004
+        msg = "Covariance matrix must be a 2D array."
+        raise ValueError(msg)
+    if not np.allclose(self.covariance_matrix, self.covariance_matrix.T):
+        msg = "Covariance matrix must be symmetric."
+        raise ValueError(msg)
+    if not np.all(np.linalg.eigvals(self.covariance_matrix) >= 0):
+        msg = "Covariance matrix must be positive semi-definite."
+        raise ValueError(msg)
+    return self
+
+

Validate the covariance matrix of the portfolio.

+

Returns

+
+
Self
+
The validated PortfolioModel instance.
+
+

Raises

+
+
ValueError
+
If the covariance matrix is not square, not symmetric, not positive semi-definite, or when +it does not have the right dimensions.
+
+
+
+def validate_expected_returns(self) ‑> Self +
+
+
+ +Expand source code + +
@model_validator(mode="after")
+def validate_expected_returns(self) -> Self:
+    """Validate the expected returns of the portfolio.
+
+    Returns
+    -------
+    Self
+        The validated PortfolioModel instance.
+
+    Raises
+    ------
+    ValueError
+        If the expected returns array does not match the number of assets.
+
+    """
+    if self.expected_returns.shape != (self.n_assets + 1,):
+        msg = "Expected returns must be a 1D array with length equal to the number of assets + 1."
+        raise ValueError(msg)
+    return self
+
+

Validate the expected returns of the portfolio.

+

Returns

+
+
Self
+
The validated PortfolioModel instance.
+
+

Raises

+
+
ValueError
+
If the expected returns array does not match the number of assets.
+
+
+
+def validate_weights(self, weights: numpy.ndarray) ‑> None +
+
+
+ +Expand source code + +
def validate_weights(self, weights: np.ndarray) -> None:
+    """Validate the weights of the portfolio.
+
+    Parameters
+    ----------
+    weights : np.ndarray
+        The weights of the assets in the portfolio.
+
+    Raises
+    ------
+    ValueError
+        If the weights do not match the number of assets, do not sum to zero,
+        or if the last weight is not -1 (for liabilities).
+
+    """
+    if len(weights) != self.n_assets + 1:
+        msg = "Weights must match the number of assets."
+        raise ValueError(msg)
+    if not np.isclose(np.sum(weights), 0):
+        msg = "Weights must sum to zero."
+        raise ValueError(msg)
+    if weights[-1] != -1:
+        msg = "Last weight must be -1 (for liabilities)."
+        raise ValueError(msg)
+
+

Validate the weights of the portfolio.

+

Parameters

+
+
weights : np.ndarray
+
The weights of the assets in the portfolio.
+
+

Raises

+
+
ValueError
+
If the weights do not match the number of assets, do not sum to zero, +or if the last weight is not -1 (for liabilities).
+
+
+
+
+
+
+
+ +
+ + + diff --git a/penfolioop/portfolio.py b/penfolioop/portfolio.py deleted file mode 100644 index ba86766..0000000 --- a/penfolioop/portfolio.py +++ /dev/null @@ -1,323 +0,0 @@ -# Copyright (c) 2025, Mohammadjavad Vakili - -r"""Portfolio optimization module. - -This module provides: - -- `Portfolio`: A class representing a portfolio of assets and liabilities - - -As a convention, we use the following notation: - -- $\mathbf{R}$: The vector of expected returns of assets and liabilities. -This is the `expected_returns` parameter needed to instantiate the `Portfolio` class. - -$$ -\mathbf{R} = \begin{bmatrix} r_1 \\ r_2 \\ \vdots \\ r_n \\ r_L \end{bmatrix}, -$$ - -where $r_1, r_2, \ldots, r_n$ are the expected returns of the assets and $r_L$ is the expected return of the liabilities. -The last element of $\mathbf{R}$ always corresponds to the liabilities. - - -- $\Sigma$: The total covariance matrix consisting of the covariance matrix of asset returns, the -covariance between asset returns and liability returns, and the variance of liability returns. -This is the `covariance_matrix` parameter needed to instantiate the `Portfolio` class. - -$$ -\mathbf{\Sigma} = \begin{bmatrix} - \Sigma_{A} , \Sigma_{AL} \\ - \Sigma_{AL} , \sigma^{2}_{L} -\end{bmatrix}, -$$ - -where \( \Sigma_{A} \) is a covariance matrix of the asset returns, -\( \Sigma_{AL} \) is the covariance between the assets and liabilities, -and \( \sigma^{2}_{L} \) is the variance of the liabilities. - - -- $\mathbf{W}$: The vector of weights of the assets and liabilities in the portfolio it is the output of the optimizers. -The last element corresponds to the liability and it is always set to -1: - -$$ -\mathbf{W} = \begin{bmatrix} - w_1 \\ - w_2 \\ - \vdots \\ - w_n \\ - -1 -\end{bmatrix}, -$$ -where \( w_1, w_2, \ldots, w_n \) are the weights of the assets and -1 is the weight of the liabilities. -""" -from typing import Self - -import numpy as np -from pydantic import BaseModel, model_validator - - -class Portfolio(BaseModel): - r"""Portfolio class. - - This class can be used to instantiate a portfolio object with information about its assets and liabilities. - Let's assume that we have a portfolio of $n$. The following parameters are required to define the portfolio: - - - `names`: A list with length of $n + 1$ consisting of asset names in the portfolio and the liability. - Example: `["Asset 1", "Asset 2", ... , "Asset n", "Liability"]` - - - `expected_returns`: An array of length $n + 1$ consisting of expected returns for the assets. - - $$ - \mathbf{R} = \begin{bmatrix} r_1 \\ r_2 \\ \vdots \\ r_n \\ r_L \end{bmatrix}, - $$ - - where $r_1, r_2, \ldots, r_n$ are the expected returns of the assets and $r_L$ is the - expected return of the liabilities. - Example: `np.array([0.1, 0.2, ... , 0.1, 0.05])`, where the last element is the expected return of the liabilities. - - - `covariance_matrix`: The total covariance matrix of the asset and liability returns. - $$ - \mathbf{\Sigma} = \begin{bmatrix} \Sigma & \Sigma_{AL} \\ \Sigma_{AL} & \sigma_L^2 \end{bmatrix}, - $$ - where $\Sigma$ is the $n$ by $n$ covariance matrix of the asset returns, $\Sigma_{AL}$ is an $n$-dimensional vector - representing the covariance between the assets and liabilities, and $\sigma_L^2$ is the variance of the liabilities. - - Example: `np.array([[0.1, 0.02, ...], [0.02, 0.1, ...], [...], [0.01, 0.005, ...]])`, - where the last row and column correspond to the liabilities. - - Attributes - ---------- - names : list[str] - List of asset names in the portfolio. - covariance_matrix : np.ndarray - Covariance matrix of the asset returns. - expected_returns : np.ndarray - Expected returns of the assets in the portfolio. - - Properties - ---------- - n_assets : int - The number of assets in the portfolio, derived from the length of `names`. - - Methods - ------- - validate_covariance_matrix() -> Self - Validates the covariance matrix for shape, symmetry, and positive semi-definiteness. - - validate_expected_returns() -> Self - Validates the expected returns array for shape consistency with the number of assets. - - validate_weights(weights: np.ndarray) -> None - Validates the weights of the assets in the portfolio. - - surplus_return(weights: np.ndarray) -> float - Calculates the surplus return of the portfolio given the asset weights. - - surplus_variance(weights: np.ndarray) -> float - Calculates the surplus variance of the portfolio given the asset weights. - - portfolio_return(weights: np.ndarray) -> float - Calculates the return of the portfolio given the asset weights. - - portfolio_variance(weights: np.ndarray) -> float - Calculates the variance of the portfolio given the weights. - - Raises - ------ - ValueError - If the covariance matrix is not square, not symmetric, or not positive semi-definite, - or if the expected returns array does not match the number of assets. - - """ - - names: list[str] - covariance_matrix: np.ndarray - expected_returns: np.ndarray - - model_config = { - "arbitrary_types_allowed": True, - } - - @property - def n_assets(self) -> int: - """Get the number of assets in the portfolio. - - Returns - ------- - int - The number of assets in the portfolio. - - """ - return len(self.names) - 1 - - @model_validator(mode="after") - def validate_covariance_matrix(self) -> Self: - """Validate the covariance matrix of the portfolio. - - Returns - ------- - Self - The validated PortfolioModel instance. - - Raises - ------ - ValueError - If the covariance matrix is not square, not symmetric, not positive semi-definite, or when - it does not have the right dimensions. - - """ - if self.covariance_matrix.shape != (self.n_assets + 1, self.n_assets + 1): - msg = "Covariance matrix must be square with dimensions equal to the number of assets + 1." - raise ValueError(msg) - if self.covariance_matrix.ndim != 2: # noqa: PLR2004 - msg = "Covariance matrix must be a 2D array." - raise ValueError(msg) - if not np.allclose(self.covariance_matrix, self.covariance_matrix.T): - msg = "Covariance matrix must be symmetric." - raise ValueError(msg) - if not np.all(np.linalg.eigvals(self.covariance_matrix) >= 0): - msg = "Covariance matrix must be positive semi-definite." - raise ValueError(msg) - return self - - @model_validator(mode="after") - def validate_expected_returns(self) -> Self: - """Validate the expected returns of the portfolio. - - Returns - ------- - Self - The validated PortfolioModel instance. - - Raises - ------ - ValueError - If the expected returns array does not match the number of assets. - - """ - if self.expected_returns.shape != (self.n_assets + 1,): - msg = "Expected returns must be a 1D array with length equal to the number of assets + 1." - raise ValueError(msg) - return self - - def validate_weights(self, weights: np.ndarray) -> None: - """Validate the weights of the portfolio. - - Parameters - ---------- - weights : np.ndarray - The weights of the assets in the portfolio. - - Raises - ------ - ValueError - If the weights do not match the number of assets, do not sum to zero, - or if the last weight is not -1 (for liabilities). - - """ - if len(weights) != self.n_assets + 1: - msg = "Weights must match the number of assets." - raise ValueError(msg) - if not np.isclose(np.sum(weights), 0): - msg = "Weights must sum to zero." - raise ValueError(msg) - if weights[-1] != -1: - msg = "Last weight must be -1 (for liabilities)." - raise ValueError(msg) - - def surplus_return(self, weights: np.ndarray) -> float: - r"""Calculate the surplus return of the portfolio given the asset weights. - - The surplus return is defined as the return of the portfolio - the expected return of the liabilities. - $$ - R_s = R_p - R_L = \sum_{i=1}^{n} w_i R_i - R_L = \mathbf{W}^{T} \mathbf{R}, - $$ - where $R_i$ is the expected return of asset $i$, $R_L$ is the expected return of the liabilities, - $R = \begin{bmatrix} R_1 \\ R_2 \\ \vdots \\ R_n \\ R_L \end{bmatrix}$ is `self.expected_returns` - containing the expected returns of the assets and liabilities, - and $\mathbf{W} = \begin{bmatrix} w_1 \\ w_2 \\ \vdots \\ w_n \\ -1 \end{bmatrix}$ is the vector of weights. - - Parameters - ---------- - weights : np.ndarray - The weights of the assets in the portfolio. - - Returns - ------- - float - The surplus return of the portfolio over the expected liabilities return. - - """ - self.validate_weights(weights) - return float(weights.T @ self.expected_returns) - - def surplus_variance(self, weights: np.ndarray) -> float: - r"""Calculate the surplus variance of the portfolio given the asset weights. - - The surplus variance is defined as the variance of the surplus return. - - $$ - \sigma^{2}_{s} = \mathbf{W}^{T} \mathbf{\Sigma} \mathbf{W} = \sum_{i=1}^{n_{assets}} \sum_{j=1}^{n_{assets}} w_{i} w_{j} \big(\Sigma_{A}\big)_{ij} - 2 \sum_{i=1}^{n_{assets}} w_{i} \big(\Sigma_{AL}\big)_{i} + \sigma^{2}_{L} - $$ - where \( \mathbf{\Sigma} \) is the covariance matrix of the asset returns and liabilities, - \( \mathbf{\Sigma}_{A} \) is the covariance matrix of the asset returns, \( \mathbf{\Sigma}_{AL} \) is the covariance vector between the assets and liabilities, and \( \sigma^{2}_{L} \) is the variance of the liabilities. - - Parameters - ---------- - weights : np.ndarray - The weights of the assets in the portfolio. - - Returns - ------- - float - The surplus variance of the portfolio. - - """ # noqa: E501 - self.validate_weights(weights) - return float(weights.T @ self.covariance_matrix @ weights) - - def portfolio_return(self, weights: np.ndarray) -> float: - r"""Calculate the return of the portfolio given the asset weights. - - The return of the portfolio is defined as the weighted sum of the returns of the assets. - - $$ - R_p = \sum_{i=1}^{n_{}} w_i R_i = \mathbf{W}_{assets}^{T} \mathbf{R}_{assets}, - $$ - - Parameters - ---------- - weights : np.ndarray - The weights of the assets in the portfolio. - - Returns - ------- - float - The return of the portfolio. - - """ - self.validate_weights(weights) - return float(weights[:-1].T @ self.expected_returns[:-1]) - - def portfolio_variance(self, weights: np.ndarray) -> float: - r"""Calculate the variance of the portfolio given the weights. - - The variance of the portfolio is defined as. - - $$ - \sigma^{2}_{p} = \mathbf{W}_{assets}^{T} \mathbf{\Sigma}_{A} \mathbf{W}_{assets} - $$ - - Parameters - ---------- - weights : np.ndarray - The weights of the assets in the portfolio. - - Returns - ------- - float - The variance of the portfolio. - - """ - self.validate_weights(weights) - return float(weights[:-1].T @ self.covariance_matrix[:-1, :-1] @ weights[:-1]) diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 491ba43..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,41 +0,0 @@ -[project] -name = "penfolioop" -version = "0.2.1" -description = "Portfolio Optimization for Pension Funds" -readme = "README.md" -requires-python = ">=3.11" -dependencies = ["numpy", "pandas", "cvxpy","pydantic"] - -[build-system] -requires = ["uv_build>=0.8.13,<0.9.0"] -build-backend = "uv_build" - -[tool.uv] -package = true - -[tool.uv.build-backend] -module-name = "penfolioop" -module-root = "" - - - -[project.optional-dependencies] -dev = [ - "ruff==0.9.10", - "pre-commit==4.1.0", -] -docs = [ - "pdoc3", - "matplotlib", - "plotly", - "seaborn", - "nbformat==5.4.0", - "ipython", - "notebook", - "ipywidgets" -] -tests = [ - "pytest", - "pytest-cov", -] - diff --git a/docs/resources/benchmarks.csv b/resources/benchmarks.csv similarity index 100% rename from docs/resources/benchmarks.csv rename to resources/benchmarks.csv diff --git a/ruff.toml b/ruff.toml deleted file mode 100644 index 1eb9671..0000000 --- a/ruff.toml +++ /dev/null @@ -1,75 +0,0 @@ -# Maximum line length for the entire project -line-length = 120 -# Target Python version -target-version = "py311" - -[lint] -# Available rule sets in Ruff: -# A: flake8-builtins - Check for python builtins being used as variables or parameters -# B: flake8-bugbear - Find likely bugs and design problems -# C4: flake8-comprehensions - Helps write better list/set/dict comprehensions -# D: pydocstyle - Check docstring style -# E: pycodestyle errors - PEP 8 style guide -# ERA: eradicate - Find commented out code -# F: pyflakes - Detect logical errors -# I: isort - Sort imports -# N: pep8-naming - Check PEP 8 naming conventions -# PT: flake8-pytest-style - Check pytest best practices -# RUF: Ruff-specific rules -# S: flake8-bandit - Find security issues -# SIM: flake8-simplify - Simplify code -# T10: flake8-debugger - Check for debugger imports and calls -# UP: pyupgrade - Upgrade syntax for newer Python -# W: pycodestyle warnings - PEP 8 style guide warnings -# ANN: flake8-annotations - Type annotation checks -# ARG: flake8-unused-arguments - Unused arguments -# BLE: flake8-blind-except - Check for blind except statements -# COM: flake8-commas - Trailing comma enforcement -# DTZ: flake8-datetimez - Ensure timezone-aware datetime objects -# EM: flake8-errmsg - Check error message strings -# FBT: flake8-boolean-trap - Boolean argument checks -# ICN: flake8-import-conventions - Import convention enforcement -# ISC: flake8-implicit-str-concat - Implicit string concatenation -# NPY: NumPy-specific rules -# PD: pandas-specific rules -# PGH: pygrep-hooks - Grep-based checks -# PIE: flake8-pie - Miscellaneous rules -# PL: Pylint rules -# Q: flake8-quotes - Quotation style enforcement -# RSE: flake8-raise - Raise statement checks -# RET: flake8-return - Return statement checks -# SLF: flake8-self - Check for self references -# TCH: flake8-type-checking - Type checking imports -# TID: flake8-tidy-imports - Import tidying -# TRY: flake8-try-except-raise - Try/except/raise checks -# YTT: flake8-2020 - Python 2020+ compatibility - -# Selected rule sets to enforce: -# D: pydocstyle - Check docstring style -# E: pycodestyle errors - PEP 8 style guide -# F: pyflakes - Detect logical errors -# I: isort - Sort imports -# N: pep8-naming - Check PEP 8 naming conventions -# W: pycodestyle warnings - PEP 8 style guide warnings -# UP: pyupgrade - Upgrade syntax for newer Python -select = ["D", "E", "F", "I", "N", "W", "UP", "PD"] - -[lint.pydocstyle] -convention = "numpy" # Use NumPy docstring style - -# Formatting configuration -[format] -# Use double quotes for strings -quote-style = "double" -# Use spaces for indentation -indent-style = "space" -# Automatically detect and use the appropriate line ending -line-ending = "auto" - -# File-specific rule exceptions -[lint.per-file-ignores] -"build/**/*.py" = ["ALL"] -"tests/**/*.py" = ["S101"] # Allow assert statements in tests -"docs/examples/*.ipynb" = ["N803", "S101"] # Allow non-lowercase variable names (N803) - - # and assert statements in notebook files \ No newline at end of file diff --git a/tests/test_constraints.py b/tests/test_constraints.py deleted file mode 100644 index 6f7167c..0000000 --- a/tests/test_constraints.py +++ /dev/null @@ -1,307 +0,0 @@ -from penfolioop.constraints import AssetConstraint, _check_constraints, _process_left_side_of_constraint, _process_right_side_of_constraint, generate_constraints, generate_scipy_constraints - -import numpy as np - -import pytest - -import cvxpy as cp - - -VALID_OPERATORS = ['==', '>=', '<='] -INVALID_OPERATORS = ['!=', '==>', '>=<', '>>', '<<', 'gt', 'lt', 'eq', 'geq', 'leq', '>', '<'] -OPERATORS = VALID_OPERATORS + INVALID_OPERATORS - - -@pytest.mark.parametrize("operator", OPERATORS) -def test_asset_constraint_operator(operator): - - # A simple valid constraint if operator is valid - constraint_1 = { - 'left_indices': ['emdc', 'emdh'], - 'operator': operator, - 'right_indices': ['eq'] - } - if operator in VALID_OPERATORS: - constraint = AssetConstraint(**constraint_1) - assert constraint.left_indices == ['emdc', 'emdh'] - assert constraint.operator == operator - assert constraint.right_indices == ['eq'] - else: - with pytest.raises(ValueError): - AssetConstraint(**constraint_1) - - # A simple valid constraint with right value instead of right index - - constraint_2 = { - 'left_indices': ['emdc', 'emdh'], - 'operator': operator, - 'right_value': 0.5 - } - - if operator in VALID_OPERATORS: - constraint = AssetConstraint(**constraint_2) - assert constraint.left_indices == ['emdc', 'emdh'] - assert constraint.operator == operator - assert constraint.right_value == 0.5 - else: - with pytest.raises(ValueError): - AssetConstraint(**constraint_2) - - # A simple invalid constraint with both right indices and right value - constraint_3 = { - 'left_indices': ['emdc', 'emdh'], - 'operator': operator, - 'right_indices': ['eq'], - 'right_value': 0.5 - } - with pytest.raises(ValueError): - AssetConstraint(**constraint_3) - return None - - -VALID_LEFT_SIDES = [['emdc', 'emdh'],['eq']] -INVALID_LEFT_SIDES = [0.5, 'emdc', '==', ['eq', 'eq']] -@pytest.mark.parametrize("left_side, operator", [(ls, op) for ls in VALID_LEFT_SIDES + INVALID_LEFT_SIDES for op in VALID_OPERATORS]) -def test_asset_constraint_left_side(left_side, operator): - # A simple valid constraint if left side is valid - constraint_1 = { - 'left_indices': left_side, - 'operator': operator, - 'right_indices': ['eq'] - } - if left_side in VALID_LEFT_SIDES: - constraint = AssetConstraint(**constraint_1) - assert constraint.left_indices == left_side - assert constraint.operator == operator - assert constraint.right_indices == ['eq'] - else: - with pytest.raises(ValueError): - AssetConstraint(**constraint_1) - return None - - -def test_asset_constraint_right_side(): - # A simple valid constraint with right value - constraint_1 = { - 'left_indices': ['emdc', 'emdh'], - 'operator': '==', - 'right_value': 0.5 - } - constraint = AssetConstraint(**constraint_1) - assert constraint.left_indices == ['emdc', 'emdh'] - assert constraint.operator == '==' - assert constraint.right_value == 0.5 - - # A simple valid constraint with right indices - constraint_2 = { - 'left_indices': ['emdc', 'emdh'], - 'operator': '==', - 'right_indices': ['eq'] - } - constraint = AssetConstraint(**constraint_2) - assert constraint.left_indices == ['emdc', 'emdh'] - assert constraint.operator == '==' - assert constraint.right_indices == ['eq'] - - # A simple invalid constraint with both right indices and right value - constraint_3 = { - 'left_indices': ['emdc', 'emdh'], - 'operator': '==', - 'right_indices': ['eq'], - 'right_value': 0.5 - } - with pytest.raises(ValueError): - AssetConstraint(**constraint_3) - - # A simple invalid constraint with neither right indices nor right value - constraint_4 = { - 'left_indices': ['emdc', 'emdh'], - 'operator': '==' - } - with pytest.raises(ValueError): - AssetConstraint(**constraint_4) - - # A simple invalid constraint with both incorrect right value format - constraint_5 = { - 'left_indices': ['emdc', 'emdh'], - 'operator': '==', - 'right_value': [0.5, 0.6] - } - with pytest.raises(ValueError): - AssetConstraint(**constraint_5) - - # A simple invalid constraint with both incorrect right indices format - constraint_6 = { - 'left_indices': ['emdc', 'emdh'], - 'operator': '==', - 'right_indices': 0.6 - } - with pytest.raises(ValueError): - AssetConstraint(**constraint_6) - - - # Another simeple invalid constraint with right value above 1 - constraint_7 = { - 'left_indices': ['emdc', 'emdh'], - 'operator': '==', - 'right_value': 1.5 - } - with pytest.raises(ValueError): - AssetConstraint(**constraint_7) - - -def test_check_constraints(): - # A simple valid constraint - constraint_valid_1 = { - 'left_indices': ['emdc', 'emdh'], - 'operator': '==', - 'right_indices': ['eq'] - } - # A simple valid constraint with right value - constraint_valid_2 = { - 'left_indices': ['emdc', 'emdh'], - 'operator': '==', - 'right_value': 0.5 - } - # A simple invalid constraint with both right indices and right value - constraint_invalid = { - 'left_indices': ['emdc', 'emdh'], - 'operator': '==', - 'right_indices': ['eq'], - 'right_value': 0.5 - } - _check_constraints([constraint_valid_1, constraint_valid_2]) - with pytest.raises(ValueError): - _check_constraints([constraint_invalid]) - with pytest.raises(ValueError): - _check_constraints([constraint_invalid, constraint_valid_1]) - with pytest.raises(ValueError): - _check_constraints([constraint_invalid, constraint_valid_2]) - - -def test_process_left_side_of_constraint(): - n_assets = 5 - asset_names = [f'asset_{i}' for i in range(n_assets)] - asset_names.append('liabilities') - asset_indices = {name: i for i, name in enumerate(asset_names)} - portfolio_weights = cp.Variable(n_assets + 1) - - valid_choices_for_left_side = [ - ['asset_0', 'asset_1'], - ['asset_2', 'asset_3'], - ['asset_0', 'asset_1', 'asset_2', 'asset_3'], - ['asset_4'], - ] - for left_indices in valid_choices_for_left_side: - left_side_processed = _process_left_side_of_constraint(portfolio_weights=portfolio_weights, left_indices=left_indices, asset_indices=asset_indices) - expected_vec = np.zeros(n_assets + 1) - for idx in left_indices: - expected_vec[asset_indices[idx]] = 1 - expected_expr = expected_vec @ portfolio_weights - assert str(left_side_processed) == str(expected_expr), f"Expected {expected_expr}, got {left_side_processed}" - -def test_process_right_side_of_constraint(): - n_assets = 5 - asset_names = [f'asset_{i}' for i in range(n_assets)] - asset_names.append('liabilities') - asset_indices = {name: i for i, name in enumerate(asset_names)} - portfolio_weights = cp.Variable(n_assets + 1) - valid_choices_for_right_value = [0.5, 1.0, 0.0] - for right_value in valid_choices_for_right_value: - right_side_processed = _process_right_side_of_constraint(portfolio_weights=portfolio_weights, right_value=right_value, asset_indices=asset_indices) - expected_expr = right_value - assert str(right_side_processed) == str(expected_expr), f"Expected {expected_expr}, got {right_side_processed}" - - valid_choices_for_right_indices = [['asset_0'], ['asset_1', 'asset_2'], ['asset_3', 'asset_4']] - for right_indices in valid_choices_for_right_indices: - right_side_processed = _process_right_side_of_constraint(portfolio_weights=portfolio_weights, right_indices=right_indices, asset_indices=asset_indices) - expected_vec = np.zeros(n_assets + 1) - for idx in right_indices: - expected_vec[asset_indices[idx]] = 1 - expected_expr = expected_vec @ portfolio_weights - assert str(right_side_processed) == str(expected_expr), f"Expected {expected_expr}, got {right_side_processed}" - - -def test_generate_constraints(): - n_assets = 5 - asset_names = [f'asset_{i}' for i in range(n_assets)] - asset_names.append('liabilities') - asset_indices = {name: i for i, name in enumerate(asset_names)} - portfolio_weights = cp.Variable(n_assets + 1) - # A simple valid constraint - constraint_valid_1 = { - 'left_indices': ['asset_0', 'asset_1'], - 'operator': '==', - 'right_indices': ['liabilities'] - } - - # A simple valid constraint with right value - constraint_valid_2 = { - 'left_indices': ['asset_2', 'asset_3'], - 'operator': '>=', - 'right_value': 0.5 - } - - constraints = generate_constraints(portfolio_weights=portfolio_weights, asset_names=asset_names, asset_constraints=[constraint_valid_1, constraint_valid_2]) - - assert len(constraints) == 2, "Expected 2 constraints" - - # Check the first constraint - left_side_processed_1 = _process_left_side_of_constraint(portfolio_weights=portfolio_weights, left_indices=constraint_valid_1['left_indices'], asset_indices=asset_indices) - right_side_processed_1 = _process_right_side_of_constraint(portfolio_weights=portfolio_weights, right_indices=constraint_valid_1['right_indices'], asset_indices=asset_indices) - - # Check the second constraint - left_side_processed_2 = _process_left_side_of_constraint(portfolio_weights=portfolio_weights, left_indices=constraint_valid_2['left_indices'], asset_indices=asset_indices) - right_side_processed_2 = _process_right_side_of_constraint(portfolio_weights=portfolio_weights, right_value=constraint_valid_2['right_value'], asset_indices=asset_indices) - - assert str(constraints[0] == str(left_side_processed_1) + ' ' + constraint_valid_1['operator'] + ' ' + str(right_side_processed_1)), \ - f"Expected {left_side_processed_1} {constraint_valid_1['operator']} {right_side_processed_1}, got {constraints[0]}" - assert str(constraints[1] == str(left_side_processed_2) + ' ' + constraint_valid_2['operator'] + ' ' + str(right_side_processed_2)), \ - f"Expected {left_side_processed_2} {constraint_valid_2['operator']} {right_side_processed_2}, got {constraints[1]}" - - -def test_generate_scipy_constraints(): - asset_names = ['asset_0', 'asset_1', 'asset_2'] - asset_constraints = [ - { - 'left_indices': ['asset_0', 'asset_1'], - 'operator': '==', - 'right_value': 0.5 - }, - { - 'left_indices': ['asset_2'], - 'operator': '>=', - 'right_indices': ['asset_1'] - }, - { - 'left_indices': ['asset_0'], - 'operator': '>=', - 'right_value': 0.3 - }, - { - 'left_indices': ['asset_1'], - 'operator': '<=', - 'right_value': 0.3 - } - ] - constraints = generate_scipy_constraints(asset_names=asset_names, asset_constraints=asset_constraints) - assert len(constraints) == 4 - assert constraints[0]['type'] == 'eq' - assert constraints[1]['type'] == 'ineq' - assert constraints[2]['type'] == 'ineq' - assert constraints[3]['type'] == 'ineq' - assert constraints[0]['fun'] is not None - assert constraints[1]['fun'] is not None - assert constraints[2]['fun'] is not None - assert constraints[3]['fun'] is not None - sample_weights = np.array([0.2, 0.3, 0.5]) - assert constraints[0]['fun'](sample_weights) == 0.5 - (0.2 + 0.3) - assert constraints[1]['fun'](sample_weights) == 0.5 - 0.3 - assert constraints[2]['fun'](sample_weights) == 0.2 - 0.3 - assert constraints[3]['fun'](sample_weights) == 0.3 - 0.3 - - for constraint in constraints: - assert callable(constraint['fun']), "Constraint function must be callable" - # Test the constraint function with a sample input - result = constraint['fun'](sample_weights) - assert isinstance(result, float), "Constraint function must return a float value" diff --git a/tests/test_optimizers.py b/tests/test_optimizers.py deleted file mode 100644 index b93bdce..0000000 --- a/tests/test_optimizers.py +++ /dev/null @@ -1,182 +0,0 @@ -from penfolioop.portfolio import Portfolio -from penfolioop.optimizers import ( - min_surplus_variance_optimizer, - surplus_mean_variance_optimizer, - max_surplus_return_optimizer, - max_surplus_sharpe_ratio_optimizer, - efficient_frontier -) - - -import numpy as np - -import pytest - - -@pytest.fixture -def expected_returns(): - return np.array([0.05, 0.07, 0.06, 0.04]) - -@pytest.fixture -def covariance_matrix(): - return np.array([[0.0004, 0.0002, 0.0001, 0.0003], - [0.0002, 0.0005, 0.0003, 0.0004], - [0.0001, 0.0003, 0.0006, 0.0002], - [0.0003, 0.0004, 0.0002, 0.0007]]) - -@pytest.fixture -def names(): - return ["Asset A", "Asset B", "Asset C", "Liability"] - - -@pytest.fixture -def portfolio(expected_returns, covariance_matrix, names): - return Portfolio( - names=names, - covariance_matrix=covariance_matrix, - expected_returns=expected_returns - ) - - -@pytest.fixture -def asset_constraints1(): - return [ - { - 'left_indices': ['Asset A', 'Asset B'], - 'operator': '>=', - 'right_value': 0.1 - } - ] - - -@pytest.fixture -def asset_constraints2(): - return [ - { - 'left_indices': ['Asset C'], - 'operator': '<=', - 'right_value': 0.5 - } - ] - - -@pytest.fixture -def invalid_asset_constraints(): - return [ - { - 'left_indices': ['Asset A', 'Asset B'], - 'operator': '!=', - 'right_value': 0.1 - }, - { - 'left_indices': ['Asset C'], - 'operator': '<=', - 'right': 0.5 - }, - { - 'left_indices': ['Asset D'], # Non-existent asset - 'operator': '<=', - 'right_value': 0.2 - } - ] - - - -def generic_weight_requirements(weights: np.ndarray, expected_length: int): - """Check if the weights meet the basic requirements.""" - if not isinstance(weights, np.ndarray): - raise ValueError("Weights must be a numpy array.") - if weights.ndim != 1: - raise ValueError("Weights must be a one-dimensional array.") - if weights.shape[0] != expected_length: - raise ValueError(f"Weights must have length {expected_length}.") - if not np.isclose(np.sum(weights[:-1]), 1): - raise ValueError("Asset weights must sum to 1.") - if not np.isclose(weights[-1], -1): - raise ValueError("Last weight must be -1 (for liabilities).") - if np.any(weights[:-1] < -1e-6): - raise ValueError("Asset weights must be non-negative.") - if np.any(weights[:-1] > 1): - raise ValueError("Asset weights must not exceed 1.") - - -@pytest.mark.parametrize("optimizer", [ - min_surplus_variance_optimizer, - surplus_mean_variance_optimizer, - max_surplus_return_optimizer, - max_surplus_sharpe_ratio_optimizer]) -def test_optimizer(portfolio, optimizer, asset_constraints1, asset_constraints2, invalid_asset_constraints): - - # Test with asset constraints 1 - weights = optimizer(portfolio, asset_constraints=asset_constraints1) - generic_weight_requirements(weights, len(portfolio.names)) - assert weights[0] + weights[1] >= 0.1, "Asset A and Asset B weights should be at least 0.1 combined according to constraints" - - # Test with asset constraints 2 - weights = optimizer(portfolio, asset_constraints=asset_constraints2) - generic_weight_requirements(weights, len(portfolio.names)) - assert weights[2] <= 0.5, "Asset C weight should be at most 0.5 according to constraints" - - # Test with no asset constraints - weights = optimizer(portfolio) - generic_weight_requirements(weights, len(portfolio.names)) - - # Test with combined asset constraints - combined_constraints = asset_constraints1 + asset_constraints2 - weights_combined = optimizer(portfolio, asset_constraints=combined_constraints) - generic_weight_requirements(weights_combined, len(portfolio.names)) - assert weights_combined[0] + weights_combined[1] >= 0.1, "Asset A and Asset B weights should be at least 0.1 combined according to constraints" - assert weights_combined[2] <= 0.5, "Asset C weight should be at most 0.5 according to constraints" - - with pytest.raises(ValueError): - optimizer(portfolio, asset_constraints=invalid_asset_constraints) - - # Test with no asset constraints - weights_no_constraints = optimizer(portfolio) - assert isinstance(weights_no_constraints, np.ndarray) - assert weights_no_constraints.shape[0] == len(portfolio.names) - - # Test with multiple asset constraints - weights_multiple_constraints = optimizer(portfolio, asset_constraints=asset_constraints2) - assert isinstance(weights_multiple_constraints, np.ndarray) - assert weights_multiple_constraints.shape[0] == len(portfolio.names) - - -@pytest.mark.parametrize('risk_aversion', [0, 0.1, 1.0, 10.0]) -# Test for lambda in mean-variance optimizer -def test_mean_variance_optimizer_lambda(portfolio, risk_aversion): - weights = surplus_mean_variance_optimizer(portfolio, risk_aversion=risk_aversion) - generic_weight_requirements(weights, len(portfolio.names)) - -def test_mean_variance_variance_properties(portfolio): - lmbds = np.linspace(0, 1, 100) - vars = [] - rets = [] - for risk_aversion in lmbds: - weights = surplus_mean_variance_optimizer(portfolio, risk_aversion=risk_aversion) - variance = portfolio.surplus_variance(weights) - vars.append(variance) - return_ = portfolio.surplus_return(weights) - rets.append(return_) - vars = np.array(vars) - rets = np.array(rets) - assert np.all(vars >= 0), "Surplus variance should be non-negative for all lambda values" - assert np.all(vars[1:] >= vars[:-1]), "Surplus variance should be non-decreasing with increasing lambda" - assert np.all(rets[1:] <= rets[:-1]), "Surplus return should be non-increasing with increasing lambda" - - -def test_efficient_frontier(portfolio): - results = efficient_frontier(portfolio, surplus_return_range=(0, 0.01)) - - ws = results["weights"] - srs = results["surplus_returns"] - svs = results["surplus_variances"] - - for w in ws: - generic_weight_requirements(w, len(portfolio.names)) - - # Check if surplus returns and variances are calculated correctly - for i in range(len(srs)): - assert np.isclose(srs[i], portfolio.surplus_return(ws[i])) - assert np.isclose(svs[i], portfolio.surplus_variance(ws[i])) - \ No newline at end of file diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py deleted file mode 100644 index e475447..0000000 --- a/tests/test_portfolio.py +++ /dev/null @@ -1,106 +0,0 @@ -from penfolioop.portfolio import Portfolio - -import numpy as np - -import pytest - - -@pytest.fixture -def expected_returns(): - return np.array([0.05, 0.07, 0.06, 0.04]) - -@pytest.fixture -def covariance_matrix(): - - return np.array([ - [0.0004, 0.0002, 0.0001, 0.0003], - [0.0002, 0.0005, 0.0003, 0.0002], - [0.0001, 0.0003, 0.0006, 0.0001], - [0.0003, 0.0002, 0.0001, 0.0004] - ]) - -@pytest.fixture -def names(): - return ["Asset A", "Asset B", "Asset C", "Liability"] - -@pytest.fixture -def portfolio(expected_returns, covariance_matrix, names): - return Portfolio( - names=names, - covariance_matrix=covariance_matrix, - expected_returns=expected_returns - ) - - -def test_incorrect_covariance(): - with pytest.raises(ValueError): - Portfolio( - names=["Asset A", "Asset B"], - covariance_matrix=np.array([[0.0004, 0.0002], [-0.0002, 0.0005]]), - expected_returns=np.array([0.05, 0.07]) - ) - - with pytest.raises(ValueError): - Portfolio( - names=["Asset A", "Asset B"], - covariance_matrix=np.array([[0.0004], [0.0002]]), - expected_returns=np.array([0.05, 0.07]) - ) - - with pytest.raises(ValueError): - Portfolio( - names=["Asset A", "Asset B"], - covariance_matrix=np.array([[0.0004, 0.0], [0.0, -0.0005]]), - expected_returns=np.array([0.05, 0.07]) - ) - - -def test_incorrect_expected_returns(): - with pytest.raises(ValueError): - Portfolio( - names=["Asset A", "Asset B"], - covariance_matrix=np.array([[0.0001, 0.0], [0.0, 0.0005]]), - expected_returns=np.array([0.05]) - ) - - -def test_portfolio_weights(portfolio): - weights = np.array([0.4, 0.3, 0.2, -1.0]) - with pytest.raises(ValueError): - portfolio.validate_weights(weights) - - weights = np.array([0.4, 0.3, 0.3, 0.0]) - with pytest.raises(ValueError): - portfolio.validate_weights(weights) - - weights = np.array([0.4, 0.3, 0.3, -1.0]) - assert portfolio.validate_weights(weights) is None - - weights = np.array([0.4, 0.6, -1.0]) - with pytest.raises(ValueError): - portfolio.validate_weights(weights) - - -def test_portfolio_calculations(portfolio): - weights = np.array([0.4, 0.3, 0.3, -1.0]) - - surplus_return = portfolio.surplus_return(weights) - assert isinstance(surplus_return, float) - assert surplus_return == weights.T @ portfolio.expected_returns - - surplus_variance = portfolio.surplus_variance(weights) - assert isinstance(surplus_variance, float) - assert surplus_variance == float(weights.T @ portfolio.covariance_matrix @ weights) - - portfolio_return = portfolio.portfolio_return(weights) - assert isinstance(portfolio_return, float) - assert portfolio_return == surplus_return - weights[-1] * portfolio.expected_returns[-1] - - - portfolio_variance = portfolio.portfolio_variance(weights) - assert isinstance(portfolio_variance, float) - assert portfolio_variance == float(weights[:-1].T @ portfolio.covariance_matrix[:-1, :-1] @ weights[:-1]) - - # Check if the calculations are consistent - assert surplus_return == pytest.approx(portfolio_return + float(weights[-1] * portfolio.expected_returns[-1])) - assert surplus_variance == pytest.approx(portfolio_variance + float(weights[-1]**2 * portfolio.covariance_matrix[-1, -1] + 2 * weights[-1] * portfolio.covariance_matrix[:-1, -1].T @ weights[:-1]))