From ed33d7bd3725d811f0e6c39def440e16a531ade5 Mon Sep 17 00:00:00 2001 From: LO <46852948+losterbr@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:58:56 +0100 Subject: [PATCH 1/7] Fix covariance label alignment --- .../efficient_frontier/efficient_frontier.py | 21 +++++++++++++++++++ tests/test_efficient_frontier.py | 17 +++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/pypfopt/efficient_frontier/efficient_frontier.py b/pypfopt/efficient_frontier/efficient_frontier.py index 04e9d627..a90f99a7 100644 --- a/pypfopt/efficient_frontier/efficient_frontier.py +++ b/pypfopt/efficient_frontier/efficient_frontier.py @@ -90,6 +90,27 @@ def __init__( TypeError if ``cov_matrix`` is not a dataframe or array """ + # Only pandas inputs carry asset labels; arrays and lists are positional. + # When both inputs are labeled, align their positions before conversion. + if isinstance(expected_returns, pd.Series) and isinstance( + cov_matrix, pd.DataFrame + ): + expected_tickers = expected_returns.index + labels_match = ( + expected_tickers.is_unique + and cov_matrix.index.is_unique + and cov_matrix.columns.is_unique + and len(expected_tickers) == len(cov_matrix.index) + and len(expected_tickers) == len(cov_matrix.columns) + and expected_tickers.isin(cov_matrix.index).all() + and expected_tickers.isin(cov_matrix.columns).all() + ) + if not labels_match: + raise ValueError( + "Covariance matrix labels do not match expected returns" + ) + cov_matrix = cov_matrix.loc[expected_tickers, expected_tickers] + # Inputs self.cov_matrix = self._validate_cov_matrix(cov_matrix) self.expected_returns = self._validate_expected_returns(expected_returns) diff --git a/tests/test_efficient_frontier.py b/tests/test_efficient_frontier.py index 038788e0..f7e9725d 100644 --- a/tests/test_efficient_frontier.py +++ b/tests/test_efficient_frontier.py @@ -107,6 +107,23 @@ def test_min_volatility(): ) +def test_min_volatility_aligns_covariance_labels(): + mean_returns = pd.Series({"A": 0.1, "B": 0.2}) + cov_matrix = pd.DataFrame( + [[0.01, 0.0], [0.0, 1.0]], + index=["A", "B"], + columns=["A", "B"], + ) + + expected = EfficientFrontier(mean_returns, cov_matrix).min_volatility() + reordered_cov = cov_matrix.loc[["B", "A"], ["B", "A"]] + actual = EfficientFrontier(mean_returns, reordered_cov).min_volatility() + + pd.testing.assert_series_equal( + pd.Series(actual), pd.Series(expected), check_names=False + ) + + @pytest.mark.skipif( not _check_soft_dependencies(["ecos"], severity="none"), reason="skip test if ecos is not installed in environment", From d67e52bcdb76bf5d4a65e6d1b9713f03e5b0cf72 Mon Sep 17 00:00:00 2001 From: LO <46852948+losterbr@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:39:44 +0100 Subject: [PATCH 2/7] Add EfficientFrontier constructor types --- pypfopt/efficient_frontier/efficient_frontier.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/pypfopt/efficient_frontier/efficient_frontier.py b/pypfopt/efficient_frontier/efficient_frontier.py index a90f99a7..3c87fc46 100644 --- a/pypfopt/efficient_frontier/efficient_frontier.py +++ b/pypfopt/efficient_frontier/efficient_frontier.py @@ -3,6 +3,7 @@ classical mean-variance optimal portfolios for a variety of objectives and constraints """ +from typing import Any import warnings import cvxpy as cp @@ -56,13 +57,13 @@ class EfficientFrontier(BaseConvexOptimizer): def __init__( self, - expected_returns, - cov_matrix, - weight_bounds=(0, 1), - solver=None, - verbose=False, - solver_options=None, - ): + expected_returns: pd.Series | list | np.ndarray | None, + cov_matrix: pd.DataFrame | np.ndarray, + weight_bounds: tuple | list = (0, 1), + solver: str | None = None, + verbose: bool = False, + solver_options: dict[str, Any] | None = None, + ) -> None: """ Parameters ---------- @@ -91,6 +92,7 @@ def __init__( if ``cov_matrix`` is not a dataframe or array """ # Only pandas inputs carry asset labels; arrays and lists are positional. + # Mixing labeled and unlabeled inputs is ambiguous, so no alignment is possible. # When both inputs are labeled, align their positions before conversion. if isinstance(expected_returns, pd.Series) and isinstance( cov_matrix, pd.DataFrame From 8ee2a327f7ba0f86d0c0ad41b15e5caabddb5954 Mon Sep 17 00:00:00 2001 From: LO <46852948+losterbr@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:50:32 +0100 Subject: [PATCH 3/7] Validate covariance matrix dimensions --- .../efficient_frontier/efficient_frontier.py | 35 +++++++++------ tests/test_efficient_frontier.py | 45 +++++++++++++++++++ 2 files changed, 67 insertions(+), 13 deletions(-) diff --git a/pypfopt/efficient_frontier/efficient_frontier.py b/pypfopt/efficient_frontier/efficient_frontier.py index 3c87fc46..01a8e423 100644 --- a/pypfopt/efficient_frontier/efficient_frontier.py +++ b/pypfopt/efficient_frontier/efficient_frontier.py @@ -93,17 +93,22 @@ def __init__( """ # Only pandas inputs carry asset labels; arrays and lists are positional. # Mixing labeled and unlabeled inputs is ambiguous, so no alignment is possible. + # One might consider to either expect both to be pd or none. # When both inputs are labeled, align their positions before conversion. + validated_cov_matrix = self._validate_cov_matrix(cov_matrix) + validated_expected_returns = self._validate_expected_returns(expected_returns) + if ( + validated_expected_returns is not None + and validated_cov_matrix.shape[0] != len(validated_expected_returns) + ): + raise ValueError("Covariance matrix does not match expected returns") + if isinstance(expected_returns, pd.Series) and isinstance( cov_matrix, pd.DataFrame ): expected_tickers = expected_returns.index labels_match = ( expected_tickers.is_unique - and cov_matrix.index.is_unique - and cov_matrix.columns.is_unique - and len(expected_tickers) == len(cov_matrix.index) - and len(expected_tickers) == len(cov_matrix.columns) and expected_tickers.isin(cov_matrix.index).all() and expected_tickers.isin(cov_matrix.columns).all() ) @@ -111,18 +116,20 @@ def __init__( raise ValueError( "Covariance matrix labels do not match expected returns" ) - cov_matrix = cov_matrix.loc[expected_tickers, expected_tickers] + validated_cov_matrix = cov_matrix.loc[ + expected_tickers, expected_tickers + ].values # Inputs - self.cov_matrix = self._validate_cov_matrix(cov_matrix) - self.expected_returns = self._validate_expected_returns(expected_returns) + self.cov_matrix = validated_cov_matrix + self.expected_returns = validated_expected_returns self._max_return_value = None self._market_neutral = None if self.expected_returns is None: - num_assets = len(cov_matrix) + num_assets = self.cov_matrix.shape[0] else: - num_assets = len(expected_returns) + num_assets = len(self.expected_returns) # Labels if isinstance(expected_returns, pd.Series): @@ -132,10 +139,6 @@ def __init__( else: # use integer labels tickers = list(range(num_assets)) - if expected_returns is not None and cov_matrix is not None: - if cov_matrix.shape != (num_assets, num_assets): - raise ValueError("Covariance matrix does not match expected returns") - super().__init__( len(tickers), tickers, @@ -163,8 +166,14 @@ def _validate_cov_matrix(cov_matrix): if cov_matrix is None: raise ValueError("cov_matrix must be provided") elif isinstance(cov_matrix, pd.DataFrame): + if cov_matrix.shape[0] != cov_matrix.shape[1]: + raise ValueError("cov_matrix must be a square matrix") + if not cov_matrix.index.is_unique or not cov_matrix.columns.is_unique: + raise ValueError("Covariance matrix labels must be unique") return cov_matrix.values elif isinstance(cov_matrix, np.ndarray): + if cov_matrix.ndim != 2 or cov_matrix.shape[0] != cov_matrix.shape[1]: + raise ValueError("cov_matrix must be a square matrix") return cov_matrix else: raise TypeError("cov_matrix is not a dataframe or array") diff --git a/tests/test_efficient_frontier.py b/tests/test_efficient_frontier.py index f7e9725d..aefbcb44 100644 --- a/tests/test_efficient_frontier.py +++ b/tests/test_efficient_frontier.py @@ -124,6 +124,51 @@ def test_min_volatility_aligns_covariance_labels(): ) +@pytest.mark.parametrize( + "index, columns", + [ + (["A", "A"], ["A", "B"]), + (["A", "B"], ["A", "A"]), + ], +) +@pytest.mark.parametrize( + "mean_returns", + [pd.Series({"A": 0.1, "B": 0.2}), np.array([0.1, 0.2])], +) +def test_covariance_dataframe_labels_must_be_unique( + index, columns, mean_returns +): + cov_matrix = pd.DataFrame(np.eye(2), index=index, columns=columns) + + with pytest.raises(ValueError, match="Covariance matrix labels must be unique"): + EfficientFrontier(mean_returns, cov_matrix) + + +@pytest.mark.parametrize( + "cov_matrix", + [np.ones((2, 3)), pd.DataFrame(np.ones((2, 3)))], +) +def test_covariance_matrix_must_be_square(cov_matrix): + with pytest.raises(ValueError, match="cov_matrix must be a square matrix"): + EfficientFrontier(None, cov_matrix) + + +@pytest.mark.parametrize( + "mean_returns", + [pd.Series({"A": 0.1, "B": 0.2, "C": 0.3}), np.array([0.1, 0.2, 0.3])], +) +@pytest.mark.parametrize( + "cov_matrix", + [ + pd.DataFrame(np.eye(2), index=["A", "B"], columns=["A", "B"]), + np.eye(2), + ], +) +def test_covariance_matrix_must_match_expected_returns(mean_returns, cov_matrix): + with pytest.raises(ValueError, match="Covariance matrix does not match"): + EfficientFrontier(mean_returns, cov_matrix) + + @pytest.mark.skipif( not _check_soft_dependencies(["ecos"], severity="none"), reason="skip test if ecos is not installed in environment", From 20b8e1545e20916784d2804bd507dc5154561925 Mon Sep 17 00:00:00 2001 From: LO <46852948+losterbr@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:20:30 +0100 Subject: [PATCH 4/7] Refine EfficientFrontier input validation --- .../efficient_frontier/efficient_frontier.py | 121 +++++++++--------- tests/test_efficient_frontier.py | 23 ++++ 2 files changed, 80 insertions(+), 64 deletions(-) diff --git a/pypfopt/efficient_frontier/efficient_frontier.py b/pypfopt/efficient_frontier/efficient_frontier.py index 01a8e423..2f84b22a 100644 --- a/pypfopt/efficient_frontier/efficient_frontier.py +++ b/pypfopt/efficient_frontier/efficient_frontier.py @@ -91,54 +91,12 @@ def __init__( TypeError if ``cov_matrix`` is not a dataframe or array """ - # Only pandas inputs carry asset labels; arrays and lists are positional. - # Mixing labeled and unlabeled inputs is ambiguous, so no alignment is possible. - # One might consider to either expect both to be pd or none. - # When both inputs are labeled, align their positions before conversion. - validated_cov_matrix = self._validate_cov_matrix(cov_matrix) - validated_expected_returns = self._validate_expected_returns(expected_returns) - if ( - validated_expected_returns is not None - and validated_cov_matrix.shape[0] != len(validated_expected_returns) - ): - raise ValueError("Covariance matrix does not match expected returns") - - if isinstance(expected_returns, pd.Series) and isinstance( - cov_matrix, pd.DataFrame - ): - expected_tickers = expected_returns.index - labels_match = ( - expected_tickers.is_unique - and expected_tickers.isin(cov_matrix.index).all() - and expected_tickers.isin(cov_matrix.columns).all() - ) - if not labels_match: - raise ValueError( - "Covariance matrix labels do not match expected returns" - ) - validated_cov_matrix = cov_matrix.loc[ - expected_tickers, expected_tickers - ].values - - # Inputs - self.cov_matrix = validated_cov_matrix - self.expected_returns = validated_expected_returns + self.expected_returns, self.cov_matrix, tickers = ( + self._validate_and_format_inputs(expected_returns, cov_matrix) + ) self._max_return_value = None self._market_neutral = None - if self.expected_returns is None: - num_assets = self.cov_matrix.shape[0] - else: - num_assets = len(self.expected_returns) - - # Labels - if isinstance(expected_returns, pd.Series): - tickers = list(expected_returns.index) - elif isinstance(cov_matrix, pd.DataFrame): - tickers = list(cov_matrix.columns) - else: # use integer labels - tickers = list(range(num_assets)) - super().__init__( len(tickers), tickers, @@ -149,35 +107,70 @@ def __init__( ) @staticmethod - def _validate_expected_returns(expected_returns): - if expected_returns is None: - return None - elif isinstance(expected_returns, pd.Series): - return expected_returns.values - elif isinstance(expected_returns, list): - return np.array(expected_returns) - elif isinstance(expected_returns, np.ndarray): - return expected_returns.ravel() - else: - raise TypeError("expected_returns is not a series, list or array") - - @staticmethod - def _validate_cov_matrix(cov_matrix): - if cov_matrix is None: - raise ValueError("cov_matrix must be provided") - elif isinstance(cov_matrix, pd.DataFrame): + def _validate_and_format_inputs( + expected_returns: pd.Series | list | np.ndarray | None, + cov_matrix: pd.DataFrame | np.ndarray | None, + ) -> tuple[np.ndarray | None, np.ndarray, list[Any]]: + cov_tickers = None + if isinstance(cov_matrix, pd.DataFrame): if cov_matrix.shape[0] != cov_matrix.shape[1]: raise ValueError("cov_matrix must be a square matrix") if not cov_matrix.index.is_unique or not cov_matrix.columns.is_unique: raise ValueError("Covariance matrix labels must be unique") - return cov_matrix.values + if not cov_matrix.index.isin(cov_matrix.columns).all(): + raise ValueError( + "Covariance matrix index and columns must contain the same labels" + ) + cov_tickers = cov_matrix.columns + cov_matrix = cov_matrix.loc[cov_tickers, cov_tickers] + cov_array = cov_matrix.values elif isinstance(cov_matrix, np.ndarray): if cov_matrix.ndim != 2 or cov_matrix.shape[0] != cov_matrix.shape[1]: raise ValueError("cov_matrix must be a square matrix") - return cov_matrix + cov_array = cov_matrix + elif cov_matrix is None: + raise ValueError("cov_matrix must be provided") else: raise TypeError("cov_matrix is not a dataframe or array") + expected_tickers = None + if expected_returns is None: + expected_array = None + elif isinstance(expected_returns, pd.Series): + expected_tickers = expected_returns.index + expected_array = expected_returns.values + elif isinstance(expected_returns, list): + expected_array = np.array(expected_returns) + elif isinstance(expected_returns, np.ndarray): + expected_array = expected_returns.ravel() + else: + raise TypeError("expected_returns is not a series, list or array") + + if expected_array is not None and cov_array.shape[0] != len(expected_array): + raise ValueError("Covariance matrix does not match expected returns") + + # Mixed pandas/array inputs remain positional because their alignment cannot + # be verified. A future API revision could consider rejecting this combination. + if expected_tickers is not None and cov_tickers is not None: + labels_match = ( + expected_tickers.is_unique + and expected_tickers.isin(cov_tickers).all() + ) + if not labels_match: + raise ValueError( + "Covariance matrix labels do not match expected returns" + ) + cov_array = cov_matrix.loc[expected_tickers, expected_tickers].values + + if expected_tickers is not None: + tickers = list(expected_tickers) + elif cov_tickers is not None: + tickers = list(cov_tickers) + else: + tickers = list(range(cov_array.shape[0])) + + return expected_array, cov_array, tickers + def _validate_returns(self, returns): """ Helper method to validate daily returns (needed for some efficient frontiers) diff --git a/tests/test_efficient_frontier.py b/tests/test_efficient_frontier.py index aefbcb44..ba499a8c 100644 --- a/tests/test_efficient_frontier.py +++ b/tests/test_efficient_frontier.py @@ -144,6 +144,29 @@ def test_covariance_dataframe_labels_must_be_unique( EfficientFrontier(mean_returns, cov_matrix) +def test_covariance_dataframe_aligns_index_to_columns(): + cov_matrix = pd.DataFrame( + [[0.01, 0.002], [0.002, 1.0]], + index=["A", "B"], + columns=["A", "B"], + ) + reordered_index = cov_matrix.loc[["B", "A"]] + + ef = EfficientFrontier(None, reordered_index) + + assert ef.tickers == ["A", "B"] + np.testing.assert_array_equal(ef.cov_matrix, cov_matrix.values) + + +def test_covariance_dataframe_axes_must_have_same_labels(): + cov_matrix = pd.DataFrame( + np.eye(2), index=["A", "B"], columns=["A", "C"] + ) + + with pytest.raises(ValueError, match="must contain the same labels"): + EfficientFrontier(None, cov_matrix) + + @pytest.mark.parametrize( "cov_matrix", [np.ones((2, 3)), pd.DataFrame(np.ones((2, 3)))], From 673ad2240b452d698ce35d2b2503117bb625dc5b Mon Sep 17 00:00:00 2001 From: LO <46852948+losterbr@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:52:57 +0100 Subject: [PATCH 5/7] Fix EfficientFrontier input label alignment --- .../efficient_frontier/efficient_frontier.py | 96 ++++--- tests/test_efficient_frontier.py | 249 ++++++++++++++---- 2 files changed, 258 insertions(+), 87 deletions(-) diff --git a/pypfopt/efficient_frontier/efficient_frontier.py b/pypfopt/efficient_frontier/efficient_frontier.py index 2f84b22a..605ef212 100644 --- a/pypfopt/efficient_frontier/efficient_frontier.py +++ b/pypfopt/efficient_frontier/efficient_frontier.py @@ -90,6 +90,13 @@ def __init__( if ``expected_returns`` is not a series, list or array TypeError if ``cov_matrix`` is not a dataframe or array + + Notes + ----- + Asset labels are aligned automatically only when ``expected_returns`` is a + Series and ``cov_matrix`` is a DataFrame. When pandas and array-like inputs + are mixed, their values are matched positionally, so they must use the same + asset order. """ self.expected_returns, self.cov_matrix, tickers = ( self._validate_and_format_inputs(expected_returns, cov_matrix) @@ -111,46 +118,19 @@ def _validate_and_format_inputs( expected_returns: pd.Series | list | np.ndarray | None, cov_matrix: pd.DataFrame | np.ndarray | None, ) -> tuple[np.ndarray | None, np.ndarray, list[Any]]: - cov_tickers = None - if isinstance(cov_matrix, pd.DataFrame): - if cov_matrix.shape[0] != cov_matrix.shape[1]: - raise ValueError("cov_matrix must be a square matrix") - if not cov_matrix.index.is_unique or not cov_matrix.columns.is_unique: - raise ValueError("Covariance matrix labels must be unique") - if not cov_matrix.index.isin(cov_matrix.columns).all(): - raise ValueError( - "Covariance matrix index and columns must contain the same labels" - ) - cov_tickers = cov_matrix.columns - cov_matrix = cov_matrix.loc[cov_tickers, cov_tickers] - cov_array = cov_matrix.values - elif isinstance(cov_matrix, np.ndarray): - if cov_matrix.ndim != 2 or cov_matrix.shape[0] != cov_matrix.shape[1]: - raise ValueError("cov_matrix must be a square matrix") - cov_array = cov_matrix - elif cov_matrix is None: - raise ValueError("cov_matrix must be provided") - else: - raise TypeError("cov_matrix is not a dataframe or array") - - expected_tickers = None - if expected_returns is None: - expected_array = None - elif isinstance(expected_returns, pd.Series): - expected_tickers = expected_returns.index - expected_array = expected_returns.values - elif isinstance(expected_returns, list): - expected_array = np.array(expected_returns) - elif isinstance(expected_returns, np.ndarray): - expected_array = expected_returns.ravel() - else: - raise TypeError("expected_returns is not a series, list or array") + """Validate inputs and align labeled data to a common asset order.""" + cov_array, cov_tickers = EfficientFrontier._validate_and_format_cov_matrix( + cov_matrix + ) + expected_array, expected_tickers = ( + EfficientFrontier._validate_and_format_expected_returns(expected_returns) + ) if expected_array is not None and cov_array.shape[0] != len(expected_array): raise ValueError("Covariance matrix does not match expected returns") - # Mixed pandas/array inputs remain positional because their alignment cannot - # be verified. A future API revision could consider rejecting this combination. + # Labeled pandas inputs can be aligned safely. Mixed pandas/array inputs + # remain positional because their alignment cannot be verified. if expected_tickers is not None and cov_tickers is not None: labels_match = ( expected_tickers.is_unique @@ -160,7 +140,9 @@ def _validate_and_format_inputs( raise ValueError( "Covariance matrix labels do not match expected returns" ) - cov_array = cov_matrix.loc[expected_tickers, expected_tickers].values + # Reorder both covariance axes to match the expected-return order. + positions = cov_tickers.get_indexer(expected_tickers) + cov_array = cov_array[np.ix_(positions, positions)] if expected_tickers is not None: tickers = list(expected_tickers) @@ -171,6 +153,46 @@ def _validate_and_format_inputs( return expected_array, cov_array, tickers + @staticmethod + def _validate_and_format_cov_matrix( + cov_matrix: pd.DataFrame | np.ndarray | None, + ) -> tuple[np.ndarray, pd.Index | None]: + """Validate a covariance matrix and normalize DataFrame row order.""" + if cov_matrix is None: + raise ValueError("cov_matrix must be provided") + if not isinstance(cov_matrix, (pd.DataFrame, np.ndarray)): + raise TypeError("cov_matrix is not a dataframe or array") + if cov_matrix.ndim != 2 or cov_matrix.shape[0] != cov_matrix.shape[1]: + raise ValueError("cov_matrix must be a square matrix") + + if isinstance(cov_matrix, np.ndarray): + return cov_matrix, None + if not cov_matrix.index.is_unique or not cov_matrix.columns.is_unique: + raise ValueError("Covariance matrix labels must be unique") + if not cov_matrix.index.isin(cov_matrix.columns).all(): + raise ValueError( + "Covariance matrix index and columns must contain the same labels" + ) + + cov_tickers = cov_matrix.columns + cov_array = cov_matrix.loc[cov_tickers, cov_tickers].values + return cov_array, cov_tickers + + @staticmethod + def _validate_and_format_expected_returns( + expected_returns: pd.Series | list | np.ndarray | None, + ) -> tuple[np.ndarray | None, pd.Index | None]: + """Convert expected returns to a flat array and preserve Series labels.""" + if expected_returns is None: + return None, None + if isinstance(expected_returns, pd.Series): + return expected_returns.values, expected_returns.index + if isinstance(expected_returns, list): + return np.array(expected_returns), None + if isinstance(expected_returns, np.ndarray): + return expected_returns.ravel(), None + raise TypeError("expected_returns is not a series, list or array") + def _validate_returns(self, returns): """ Helper method to validate daily returns (needed for some efficient frontiers) diff --git a/tests/test_efficient_frontier.py b/tests/test_efficient_frontier.py index ba499a8c..318ce01e 100644 --- a/tests/test_efficient_frontier.py +++ b/tests/test_efficient_frontier.py @@ -82,6 +82,205 @@ def test_efficient_frontier_expected_returns_list(): ) +class TestValidateAndFormatExpectedReturns: + # Direct access is intentional: these tests define the private helper's contract. + # pylint: disable=protected-access + + def test_accepts_none(self): + formatted_returns, tickers = ( + EfficientFrontier._validate_and_format_expected_returns(None) + ) + + assert formatted_returns is None + assert tickers is None + + def test_converts_list_to_array(self): + formatted_returns, tickers = ( + EfficientFrontier._validate_and_format_expected_returns([0.1, 0.2]) + ) + + np.testing.assert_array_equal(formatted_returns, np.array([0.1, 0.2])) + assert tickers is None + + def test_flattens_numpy_array(self): + formatted_returns, tickers = ( + EfficientFrontier._validate_and_format_expected_returns( + np.array([[0.1, 0.2]]) + ) + ) + + np.testing.assert_array_equal(formatted_returns, np.array([0.1, 0.2])) + assert tickers is None + + def test_extracts_series_values_and_tickers(self): + returns_series = pd.Series([0.1, 0.2], index=["A", "B"]) + + formatted_returns, tickers = ( + EfficientFrontier._validate_and_format_expected_returns(returns_series) + ) + + np.testing.assert_array_equal(formatted_returns, np.array([0.1, 0.2])) + pd.testing.assert_index_equal(tickers, pd.Index(["A", "B"])) + + def test_rejects_unsupported_type(self): + with pytest.raises( + TypeError, match="expected_returns is not a series, list or array" + ): + EfficientFrontier._validate_and_format_expected_returns(0.02) + + +class TestValidateAndFormatCovMatrix: + # Direct access is intentional: these tests define the private helper's contract. + # pylint: disable=protected-access + + def test_accepts_numpy_array_without_tickers(self): + cov_matrix = np.array([[0.1, 0.2], [0.3, 0.4]]) + + formatted_matrix, tickers = ( + EfficientFrontier._validate_and_format_cov_matrix(cov_matrix) + ) + + assert formatted_matrix is cov_matrix + assert tickers is None + + def test_aligns_dataframe_rows_to_column_order(self): + cov_matrix = pd.DataFrame( + [[0.3, 0.4], [0.1, 0.2]], + index=["B", "A"], + columns=["A", "B"], + ) + + formatted_matrix, tickers = ( + EfficientFrontier._validate_and_format_cov_matrix(cov_matrix) + ) + + np.testing.assert_array_equal( + formatted_matrix, np.array([[0.1, 0.2], [0.3, 0.4]]) + ) + pd.testing.assert_index_equal(tickers, pd.Index(["A", "B"])) + + def test_rejects_none(self): + with pytest.raises(ValueError, match="cov_matrix must be provided"): + EfficientFrontier._validate_and_format_cov_matrix(None) + + def test_rejects_unsupported_type(self): + with pytest.raises( + TypeError, match="cov_matrix is not a dataframe or array" + ): + EfficientFrontier._validate_and_format_cov_matrix(0.01) + + def test_rejects_one_dimensional_array(self): + with pytest.raises(ValueError, match="cov_matrix must be a square matrix"): + EfficientFrontier._validate_and_format_cov_matrix(np.ones(2)) + + @pytest.mark.parametrize( + "cov_matrix", + [np.ones((2, 3)), pd.DataFrame(np.ones((2, 3)))], + ) + def test_rejects_non_square_matrix(self, cov_matrix): + with pytest.raises(ValueError, match="cov_matrix must be a square matrix"): + EfficientFrontier._validate_and_format_cov_matrix(cov_matrix) + + @pytest.mark.parametrize( + "index, columns", + [ + (["A", "A"], ["A", "B"]), + (["A", "B"], ["A", "A"]), + ], + ) + def test_rejects_duplicate_dataframe_labels(self, index, columns): + cov_matrix = pd.DataFrame(np.eye(2), index=index, columns=columns) + + with pytest.raises(ValueError, match="Covariance matrix labels must be unique"): + EfficientFrontier._validate_and_format_cov_matrix(cov_matrix) + + def test_rejects_different_dataframe_axis_labels(self): + cov_matrix = pd.DataFrame( + np.eye(2), index=["A", "B"], columns=["A", "C"] + ) + + with pytest.raises(ValueError, match="must contain the same labels"): + EfficientFrontier._validate_and_format_cov_matrix(cov_matrix) + + +class TestValidateAndFormatInputs: + # Direct access is intentional: these tests define the private helper's contract. + # pylint: disable=protected-access + + def test_aligns_covariance_to_expected_return_order(self): + expected_returns = pd.Series([0.2, 0.1], index=["B", "A"]) + cov_matrix = pd.DataFrame( + [[0.1, 0.2], [0.3, 0.4]], + index=["A", "B"], + columns=["A", "B"], + ) + + formatted_returns, formatted_covariance, tickers = ( + EfficientFrontier._validate_and_format_inputs( + expected_returns, cov_matrix + ) + ) + + np.testing.assert_array_equal(formatted_returns, np.array([0.2, 0.1])) + np.testing.assert_array_equal( + formatted_covariance, np.array([[0.4, 0.3], [0.2, 0.1]]) + ) + assert tickers == ["B", "A"] + + def test_uses_expected_return_tickers_with_array_covariance(self): + expected_returns = pd.Series([0.1, 0.2], index=["A", "B"]) + + _, _, tickers = EfficientFrontier._validate_and_format_inputs( + expected_returns, np.eye(2) + ) + + assert tickers == ["A", "B"] + + def test_uses_covariance_tickers_without_expected_returns(self): + cov_matrix = pd.DataFrame( + np.eye(2), index=["A", "B"], columns=["A", "B"] + ) + + _, _, tickers = EfficientFrontier._validate_and_format_inputs(None, cov_matrix) + + assert tickers == ["A", "B"] + + def test_uses_positional_tickers_for_array_inputs(self): + _, _, tickers = EfficientFrontier._validate_and_format_inputs( + np.array([0.1, 0.2]), np.eye(2) + ) + + assert tickers == [0, 1] + + def test_rejects_dimension_mismatch(self): + with pytest.raises(ValueError, match="does not match expected returns"): + EfficientFrontier._validate_and_format_inputs( + np.array([0.1, 0.2, 0.3]), np.eye(2) + ) + + def test_rejects_different_labels(self): + expected_returns = pd.Series([0.1, 0.2], index=["A", "C"]) + cov_matrix = pd.DataFrame( + np.eye(2), index=["A", "B"], columns=["A", "B"] + ) + + with pytest.raises(ValueError, match="labels do not match expected returns"): + EfficientFrontier._validate_and_format_inputs( + expected_returns, cov_matrix + ) + + def test_rejects_duplicate_expected_return_labels(self): + expected_returns = pd.Series([0.1, 0.2], index=["A", "A"]) + cov_matrix = pd.DataFrame( + np.eye(2), index=["A", "B"], columns=["A", "B"] + ) + + with pytest.raises(ValueError, match="labels do not match expected returns"): + EfficientFrontier._validate_and_format_inputs( + expected_returns, cov_matrix + ) + + def test_portfolio_performance(): ef = setup_efficient_frontier() with pytest.raises(ValueError): @@ -124,26 +323,6 @@ def test_min_volatility_aligns_covariance_labels(): ) -@pytest.mark.parametrize( - "index, columns", - [ - (["A", "A"], ["A", "B"]), - (["A", "B"], ["A", "A"]), - ], -) -@pytest.mark.parametrize( - "mean_returns", - [pd.Series({"A": 0.1, "B": 0.2}), np.array([0.1, 0.2])], -) -def test_covariance_dataframe_labels_must_be_unique( - index, columns, mean_returns -): - cov_matrix = pd.DataFrame(np.eye(2), index=index, columns=columns) - - with pytest.raises(ValueError, match="Covariance matrix labels must be unique"): - EfficientFrontier(mean_returns, cov_matrix) - - def test_covariance_dataframe_aligns_index_to_columns(): cov_matrix = pd.DataFrame( [[0.01, 0.002], [0.002, 1.0]], @@ -158,24 +337,6 @@ def test_covariance_dataframe_aligns_index_to_columns(): np.testing.assert_array_equal(ef.cov_matrix, cov_matrix.values) -def test_covariance_dataframe_axes_must_have_same_labels(): - cov_matrix = pd.DataFrame( - np.eye(2), index=["A", "B"], columns=["A", "C"] - ) - - with pytest.raises(ValueError, match="must contain the same labels"): - EfficientFrontier(None, cov_matrix) - - -@pytest.mark.parametrize( - "cov_matrix", - [np.ones((2, 3)), pd.DataFrame(np.ones((2, 3)))], -) -def test_covariance_matrix_must_be_square(cov_matrix): - with pytest.raises(ValueError, match="cov_matrix must be a square matrix"): - EfficientFrontier(None, cov_matrix) - - @pytest.mark.parametrize( "mean_returns", [pd.Series({"A": 0.1, "B": 0.2, "C": 0.3}), np.array([0.1, 0.2, 0.3])], @@ -1170,18 +1331,6 @@ def test_efficient_return_error(): ef.efficient_return(max_ret + 0.01) -def test_efficient_frontier_error(): - ef = setup_efficient_frontier() - with pytest.raises(ValueError): - EfficientFrontier(ef.expected_returns[:-1], ef.cov_matrix) - with pytest.raises(TypeError): - EfficientFrontier(0.02, ef.cov_matrix) - with pytest.raises(ValueError): - EfficientFrontier(ef.expected_returns, None) - with pytest.raises(TypeError): - EfficientFrontier(ef.expected_returns, 0.01) - - @pytest.mark.skipif( not _check_soft_dependencies(["ecos"], severity="none"), reason="skip test if ecos is not installed in environment", From 3db125441d60e2ccf38391921b13a4479213852c Mon Sep 17 00:00:00 2001 From: LO <46852948+losterbr@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:04:00 +0100 Subject: [PATCH 6/7] Clean up EfficientFrontier validation logic --- pypfopt/efficient_frontier/efficient_frontier.py | 16 ++++++---------- tests/test_efficient_frontier.py | 12 ++++++++++++ 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/pypfopt/efficient_frontier/efficient_frontier.py b/pypfopt/efficient_frontier/efficient_frontier.py index 605ef212..9655ab68 100644 --- a/pypfopt/efficient_frontier/efficient_frontier.py +++ b/pypfopt/efficient_frontier/efficient_frontier.py @@ -264,7 +264,7 @@ def _max_return(self, return_value=True): Returns ------- OrderedDict - asset weights for the return-minimising portfolio + asset weights for the return-maximising portfolio """ if self.expected_returns is None: raise ValueError("no expected returns provided") @@ -279,8 +279,7 @@ def _max_return(self, return_value=True): if return_value: return -self._opt.value - else: - return res + return res def max_sharpe(self, risk_free_rate=0.0): """ @@ -324,7 +323,7 @@ def max_sharpe(self, risk_free_rate=0.0): # Note: objectives are not scaled by k. Hence there are subtle differences # between how these objectives work for max_sharpe vs min_volatility - if len(self._additional_objectives) > 0: + if self._additional_objectives: warnings.warn( "max_sharpe transforms the optimization problem so additional objectives may not work as expected." ) @@ -422,8 +421,6 @@ def efficient_risk(self, target_volatility, market_neutral=False): if ``target_volatility`` is not a positive float ValueError if no portfolio can be found with volatility equal to ``target_volatility`` - ValueError - if ``risk_free_rate`` is non-numeric Returns ------- @@ -437,9 +434,8 @@ def efficient_risk(self, target_volatility, market_neutral=False): if target_volatility < global_min_volatility: raise ValueError( - "The minimum volatility is {:.3f}. Please use a higher target_volatility".format( - global_min_volatility - ) + f"The minimum volatility is {global_min_volatility:.3f}. " + "Please use a higher target_volatility" ) update_existing_parameter = self.is_parameter_defined("target_variance") @@ -488,7 +484,7 @@ def efficient_return(self, target_return, market_neutral=False): """ if not isinstance(target_return, float): raise ValueError("target_return should be a float") - if not self._max_return_value: + if self._max_return_value is None: a = self.deepcopy() self._max_return_value = a._max_return() if target_return > self._max_return_value: diff --git a/tests/test_efficient_frontier.py b/tests/test_efficient_frontier.py index 318ce01e..67efca84 100644 --- a/tests/test_efficient_frontier.py +++ b/tests/test_efficient_frontier.py @@ -1331,6 +1331,18 @@ def test_efficient_return_error(): ef.efficient_return(max_ret + 0.01) +def test_efficient_return_reuses_zero_max_return(monkeypatch): + ef = setup_efficient_frontier() + monkeypatch.setattr(ef, "_max_return_value", 0.0) + monkeypatch.setattr( + ef, + "_max_return", + lambda: pytest.fail("cached maximum return was recomputed"), + ) + + ef.efficient_return(0.0) + + @pytest.mark.skipif( not _check_soft_dependencies(["ecos"], severity="none"), reason="skip test if ecos is not installed in environment", From 06b158bc6c07c3b7b469fcc51c4df78ef5eceb87 Mon Sep 17 00:00:00 2001 From: LO <46852948+losterbr@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:26:04 +0100 Subject: [PATCH 7/7] Address EfficientFrontier lint warnings --- .../efficient_frontier/efficient_frontier.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/pypfopt/efficient_frontier/efficient_frontier.py b/pypfopt/efficient_frontier/efficient_frontier.py index 9655ab68..47628488 100644 --- a/pypfopt/efficient_frontier/efficient_frontier.py +++ b/pypfopt/efficient_frontier/efficient_frontier.py @@ -283,11 +283,13 @@ def _max_return(self, return_value=True): def max_sharpe(self, risk_free_rate=0.0): """ - Maximise the Sharpe Ratio. The result is also referred to as the tangency portfolio, - as it is the portfolio for which the capital market line is tangent to the efficient frontier. + Maximise the Sharpe Ratio. The result is also referred to as the tangency + portfolio, as it is the portfolio for which the capital market line is tangent + to the efficient frontier. - This is a convex optimization problem after making a certain variable substitution. See - `Cornuejols and Tutuncu (2006) `_ for more. + This is a convex optimization problem after making a certain variable + substitution. See `Cornuejols and Tutuncu (2006) + `_ for more. Parameters ---------- @@ -311,7 +313,8 @@ def max_sharpe(self, risk_free_rate=0.0): if max(self.expected_returns) <= risk_free_rate: raise ValueError( - "at least one of the assets must have an expected return exceeding the risk-free rate" + "at least one of the assets must have an expected return exceeding " + "the risk-free rate" ) self._risk_free_rate = risk_free_rate @@ -325,7 +328,8 @@ def max_sharpe(self, risk_free_rate=0.0): # between how these objectives work for max_sharpe vs min_volatility if self._additional_objectives: warnings.warn( - "max_sharpe transforms the optimization problem so additional objectives may not work as expected." + "max_sharpe transforms the optimization problem so additional " + "objectives may not work as expected." ) for obj in self._additional_objectives: self._objective += obj @@ -486,7 +490,7 @@ def efficient_return(self, target_return, market_neutral=False): raise ValueError("target_return should be a float") if self._max_return_value is None: a = self.deepcopy() - self._max_return_value = a._max_return() + self._max_return_value = a._max_return() # pylint: disable=protected-access if target_return > self._max_return_value: raise ValueError( "target_return must be lower than the maximum possible return"