Coverage for src/cvxcla/first.py: 70%
23 statements
« prev ^ index » next coverage.py v7.10.4, created at 2025-08-19 05:48 +0000
« prev ^ index » next coverage.py v7.10.4, created at 2025-08-19 05:48 +0000
1# Copyright 2023 Stanford University Convex Optimization Group
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14"""First turning point computation for the Critical Line Algorithm.
16This module provides functions to compute the first turning point on the efficient frontier,
17which is the portfolio with the highest expected return that satisfies the constraints.
18Two implementations are provided: a direct algorithm and a linear programming approach.
19"""
21from __future__ import annotations
23import numpy as np
24from numpy.typing import NDArray
26from .types import TurningPoint
29#
30def init_algo(
31 mean: NDArray[np.float64], lower_bounds: NDArray[np.float64], upper_bounds: NDArray[np.float64]
32) -> TurningPoint:
33 """Compute the first turning point.
35 The key insight behind Markowitz’s CLA is to find first the
36 turning point associated with the highest expected return, and then
37 compute the sequence of turning points, each with a lower expected
38 return than the previous. That first turning point consists in the
39 smallest subset of assets with highest return such that the sum of
40 their upper boundaries equals or exceeds one.
42 We sort the expected returns in descending order.
43 This gives us a sequence for searching for the
44 first free asset. All weights are initially set to their lower bounds,
45 and following the sequence from the previous step, we move those
46 weights from the lower to the upper bound until the sum of weights
47 exceeds one. If possible the last iterated weight is then reduced
48 to comply with the constraint that the sum of weights equals one.
49 This last weight is the first free asset,
50 and the resulting vector of weights the first turning point.
51 """
52 if np.any(lower_bounds > upper_bounds):
53 raise ValueError("Lower bounds must be less than or equal to upper bounds")
55 # Initialize weights to lower bounds
56 weights = np.copy(lower_bounds)
57 free = np.full_like(mean, False, dtype=np.bool_)
59 # Move weights from lower to upper bound
60 # until sum of weights hits or exceeds 1
61 for index in np.argsort(-mean):
62 weights[index] += np.min([upper_bounds[index] - lower_bounds[index], 1.0 - np.sum(weights)])
63 if np.sum(weights) >= 1:
64 free[index] = True
65 break
67 # free = _free(weights, lower_bounds, upper_bounds)
69 if not np.any(free):
70 # # We have not reached the sum of weights of 1...
71 raise ValueError("Could not construct a fully invested portfolio")
73 # Return first turning point, the point with the highest expected return.
74 return TurningPoint(free=free, weights=weights)
77def _free(
78 w: NDArray[np.float64], lower_bounds: NDArray[np.float64], upper_bounds: NDArray[np.float64]
79) -> NDArray[np.bool_]:
80 """Determine which asset should be free in the turning point.
82 This helper function identifies the asset that should be marked as free
83 in the turning point. It selects the asset that is furthest from its bounds,
84 which helps ensure numerical stability in the algorithm.
86 Args:
87 w: Vector of portfolio weights.
88 lower_bounds: Vector of lower bounds for asset weights.
89 upper_bounds: Vector of upper bounds for asset weights.
91 Returns:
92 A boolean vector indicating which asset is free (True) and which are blocked (False).
94 """
95 # Calculate the distance from each weight to its nearest bound
96 distance = np.min(np.array([np.abs(w - lower_bounds), np.abs(upper_bounds - w)]), axis=0)
98 # Find the index of the asset furthest from its bounds
99 index = np.argmax(distance)
101 # Create a boolean vector with only that asset marked as free
102 free = np.full_like(w, False, dtype=np.bool_)
103 free[index] = True
104 return free