skfolio 0.4.1__py3-none-any.whl → 0.4.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
skfolio/exceptions.py CHANGED
@@ -12,6 +12,7 @@ __all__ = [
12
12
  "EquationToMatrixError",
13
13
  "GroupNotFoundError",
14
14
  "NonPositiveVarianceError",
15
+ "DuplicateGroupsError",
15
16
  ]
16
17
 
17
18
 
@@ -27,5 +28,9 @@ class GroupNotFoundError(Exception):
27
28
  """Group name not found in the groups"""
28
29
 
29
30
 
31
+ class DuplicateGroupsError(Exception):
32
+ """Group name appear in multiple group levels"""
33
+
34
+
30
35
  class NonPositiveVarianceError(Exception):
31
36
  """Variance negative or null"""
@@ -52,8 +52,6 @@ class BaseHierarchicalOptimization(BaseOptimization, ABC):
52
52
  * ENTROPIC_RISK_MEASURE
53
53
  * FOURTH_CENTRAL_MOMENT
54
54
  * FOURTH_LOWER_PARTIAL_MOMENT
55
- * SKEW
56
- * KURTOSIS
57
55
 
58
56
  The default is `RiskMeasure.VARIANCE`.
59
57
 
@@ -80,12 +78,12 @@ class BaseHierarchicalOptimization(BaseOptimization, ABC):
80
78
 
81
79
  min_weights : float | dict[str, float] | array-like of shape (n_assets, ), default=0.0
82
80
  Minimum assets weights (weights lower bounds). Negative weights are not allowed.
83
- If a float is provided, it is applied to each asset. `None` is equivalent to
84
- `-np.Inf` (no lower bound). If a dictionary is provided, its (key/value) pair
85
- must be the (asset name/asset minium weight) and the input `X` of the `fit`
86
- methods must be a DataFrame with the assets names in columns. When using a
87
- dictionary, assets values that are not provided are assigned a minimum weight
88
- of `0.0`. The default is 0.0 (no short selling).
81
+ If a float is provided, it is applied to each asset.
82
+ If a dictionary is provided, its (key/value) pair must be the
83
+ (asset name/asset minium weight) and the input `X` of the `fit` methods must be
84
+ a DataFrame with the assets names in columns.
85
+ When using a dictionary, assets values that are not provided are assigned a
86
+ minimum weight of `0.0`. The default is 0.0 (no short selling).
89
87
 
90
88
  Example:
91
89
 
@@ -96,12 +94,12 @@ class BaseHierarchicalOptimization(BaseOptimization, ABC):
96
94
 
97
95
  max_weights : float | dict[str, float] | array-like of shape (n_assets, ), default=1.0
98
96
  Maximum assets weights (weights upper bounds). Weights above 1.0 are not
99
- allowed. If a float is provided, it is applied to each asset. `None` is
100
- equivalent to `+np.Inf` (no upper bound). If a dictionary is provided, its
101
- (key/value) pair must be the (asset name/asset maximum weight) and the input `X`
102
- of the `fit` method must be a DataFrame with the assets names in columns. When
103
- using a dictionary, assets values that are not provided are assigned a minimum
104
- weight of `1.0`. The default is 1.0 (each asset is below 100%).
97
+ allowed. If a float is provided, it is applied to each asset.
98
+ If a dictionary is provided, its (key/value) pair must be the
99
+ (asset name/asset maximum weight) and the input `X` of the `fit` method must be
100
+ a DataFrame with the assets names in columns.
101
+ When using a dictionary, assets values that are not provided are assigned a
102
+ minimum weight of `1.0`. The default is 1.0 (each asset is below 100%).
105
103
 
106
104
  Example:
107
105
 
@@ -388,57 +386,6 @@ class BaseHierarchicalOptimization(BaseOptimization, ABC):
388
386
 
389
387
  return min_weights, max_weights
390
388
 
391
- @staticmethod
392
- def _apply_weight_constraints_to_alpha(
393
- alpha: float,
394
- max_weights: np.ndarray,
395
- min_weights: np.ndarray,
396
- weights: np.ndarray,
397
- left_cluster: np.ndarray,
398
- right_cluster: np.ndarray,
399
- ) -> float:
400
- """Apply weight constraints to the alpha multiplication factor of the
401
- Hierarchical Tree Clustering algorithm.
402
-
403
- Parameters
404
- ----------
405
- alpha : float
406
- The alpha multiplication factor of the Hierarchical Tree Clustering
407
- algorithm.
408
-
409
- min_weights : ndarray of shape (n_assets,)
410
- The weight lower bound 1D array.
411
-
412
- max_weights : ndarray of shape (n_assets,)
413
- The weight upper bound 1D array.
414
-
415
- weights : np.ndarray of shape (n_assets,)
416
- The assets weights.
417
-
418
- left_cluster : ndarray of shape (n_left_cluster,)
419
- Indices of the left cluster weights.
420
-
421
- right_cluster : ndarray of shape (n_right_cluster,)
422
- Indices of the right cluster weights.
423
-
424
- Returns
425
- -------
426
- value : float
427
- The transformed alpha incorporating the weight constraints.
428
- """
429
- alpha = min(
430
- np.sum(max_weights[left_cluster]) / weights[left_cluster[0]],
431
- max(np.sum(min_weights[left_cluster]) / weights[left_cluster[0]], alpha),
432
- )
433
- alpha = 1 - min(
434
- np.sum(max_weights[right_cluster]) / weights[right_cluster[0]],
435
- max(
436
- np.sum(min_weights[right_cluster]) / weights[right_cluster[0]],
437
- 1 - alpha,
438
- ),
439
- )
440
- return alpha
441
-
442
389
  def get_metadata_routing(self):
443
390
  # noinspection PyTypeChecker
444
391
  router = (
@@ -3,8 +3,7 @@
3
3
  # Copyright (c) 2023
4
4
  # Author: Hugo Delatte <delatte.hugo@gmail.com>
5
5
  # License: BSD 3 clause
6
- # The risk measure generalization and constraint features are derived
7
- # from Riskfolio-Lib, Copyright (c) 2020-2023, Dany Cajas, Licensed under BSD 3 clause.
6
+ # Weight constraints is a novel implementation, see docstring for more details.
8
7
 
9
8
  import numpy as np
10
9
  import numpy.typing as npt
@@ -20,6 +19,7 @@ from skfolio.optimization.cluster.hierarchical._base import (
20
19
  BaseHierarchicalOptimization,
21
20
  )
22
21
  from skfolio.prior import BasePrior, EmpiricalPrior
22
+ from skfolio.utils.stats import minimize_relative_weight_deviation
23
23
  from skfolio.utils.tools import check_estimator
24
24
 
25
25
 
@@ -45,6 +45,32 @@ class HierarchicalEqualRiskContribution(BaseHierarchicalOptimization):
45
45
  which is more stable and has better properties than the single-linkage
46
46
  method [4]_.
47
47
 
48
+ Also, the initial paper does not provide an algorithm for handling weight
49
+ constraints, and no standard solution currently exists.
50
+ In contrast to HRP (Hierarchical Risk Parity), where weight constraints
51
+ can be applied to the split factor at each bisection step, HERC
52
+ (Hierarchical Equal Risk Contribution) cannot incorporate weight constraints
53
+ during the intermediate steps of the allocation. Therefore, in HERC, the
54
+ weight constraints must be enforced after the top-down allocation has been
55
+ completed.
56
+ In skfolio, we minimize the relative deviation of the final weights from
57
+ the initial weights. This is formulated as a convex optimization problem:
58
+
59
+ .. math::
60
+ \begin{cases}
61
+ \begin{aligned}
62
+ &\min_{w} & & \Vert \frac{w - w_{init}}{w_{init}} \Vert_{2}^{2} \\
63
+ &\text{s.t.} & & \sum_{i=1}^{N} w_{i} = 1 \\
64
+ & & & w_{min} \leq w_i \leq w_{max}, \quad \forall i
65
+ \end{aligned}
66
+ \end{cases}
67
+
68
+ The reason for minimizing the relative deviation (as opposed to the absolute
69
+ deviation) is that we want to limit the impact on the risk contribution of
70
+ each asset. Since HERC allocates inversely to risk, adjusting the weights
71
+ based on relative deviation ensures that the assets' risk contributions
72
+ remain proportionally consistent with the initial allocation.
73
+
48
74
  Parameters
49
75
  ----------
50
76
  risk_measure : RiskMeasure or ExtraRiskMeasure, default=RiskMeasure.VARIANCE
@@ -70,8 +96,6 @@ class HierarchicalEqualRiskContribution(BaseHierarchicalOptimization):
70
96
  * ENTROPIC_RISK_MEASURE
71
97
  * FOURTH_CENTRAL_MOMENT
72
98
  * FOURTH_LOWER_PARTIAL_MOMENT
73
- * SKEW
74
- * KURTOSIS
75
99
 
76
100
  The default is `RiskMeasure.VARIANCE`.
77
101
 
@@ -98,12 +122,12 @@ class HierarchicalEqualRiskContribution(BaseHierarchicalOptimization):
98
122
 
99
123
  min_weights : float | dict[str, float] | array-like of shape (n_assets, ), default=0.0
100
124
  Minimum assets weights (weights lower bounds). Negative weights are not allowed.
101
- If a float is provided, it is applied to each asset. `None` is equivalent to
102
- `-np.Inf` (no lower bound). If a dictionary is provided, its (key/value) pair
103
- must be the (asset name/asset minium weight) and the input `X` of the `fit`
104
- methods must be a DataFrame with the assets names in columns. When using a
105
- dictionary, assets values that are not provided are assigned a minimum weight
106
- of `0.0`. The default is 0.0 (no short selling).
125
+ If a float is provided, it is applied to each asset.
126
+ If a dictionary is provided, its (key/value) pair must be the
127
+ (asset name/asset minium weight) and the input `X` of the `fit` methods must be
128
+ a DataFrame with the assets names in columns.
129
+ When using a dictionary, assets values that are not provided are assigned a
130
+ minimum weight of `0.0`. The default is 0.0 (no short selling).
107
131
 
108
132
  Example:
109
133
 
@@ -114,12 +138,12 @@ class HierarchicalEqualRiskContribution(BaseHierarchicalOptimization):
114
138
 
115
139
  max_weights : float | dict[str, float] | array-like of shape (n_assets, ), default=1.0
116
140
  Maximum assets weights (weights upper bounds). Weights above 1.0 are not
117
- allowed. If a float is provided, it is applied to each asset. `None` is
118
- equivalent to `+np.Inf` (no upper bound). If a dictionary is provided, its
119
- (key/value) pair must be the (asset name/asset maximum weight) and the input `X`
120
- of the `fit` method must be a DataFrame with the assets names in columns. When
121
- using a dictionary, assets values that are not provided are assigned a minimum
122
- weight of `1.0`. The default is 1.0 (each asset is below 100%).
141
+ allowed. If a float is provided, it is applied to each asset.
142
+ If a dictionary is provided, its (key/value) pair must be the
143
+ (asset name/asset maximum weight) and the input `X` of the `fit` method must be
144
+ a DataFrame with the assets names in columns.
145
+ When using a dictionary, assets values that are not provided are assigned a
146
+ minimum weight of `1.0`. The default is 1.0 (each asset is below 100%).
123
147
 
124
148
  Example:
125
149
 
@@ -208,6 +232,19 @@ class HierarchicalEqualRiskContribution(BaseHierarchicalOptimization):
208
232
  `management_fees`, `previous_weights` and `risk_free_rate` are copied from the
209
233
  optimization model and passed to the portfolio.
210
234
 
235
+ solver : str, default="CLARABEL"
236
+ The solver used for the weights constraints optimization. The default is
237
+ "CLARABEL" which is written in Rust and has better numerical stability and
238
+ performance than ECOS and SCS.
239
+ For more details about available solvers, check the CVXPY documentation:
240
+ https://www.cvxpy.org/tutorial/advanced/index.html#choosing-a-solver
241
+
242
+ solver_params : dict, optional
243
+ Solver parameters. For example, `solver_params=dict(verbose=True)`.
244
+ The default (`None`) is to use the CVXPY default.
245
+ For more details about solver arguments, check the CVXPY documentation:
246
+ https://www.cvxpy.org/tutorial/advanced/index.html#setting-solver-options
247
+
211
248
  Attributes
212
249
  ----------
213
250
  weights_ : ndarray of shape (n_assets,)
@@ -251,6 +288,8 @@ class HierarchicalEqualRiskContribution(BaseHierarchicalOptimization):
251
288
  hierarchical_clustering_estimator: HierarchicalClustering | None = None,
252
289
  min_weights: skt.MultiInput | None = 0.0,
253
290
  max_weights: skt.MultiInput | None = 1.0,
291
+ solver: str = "CLARABEL",
292
+ solver_params: dict | None = None,
254
293
  transaction_costs: skt.MultiInput = 0.0,
255
294
  management_fees: skt.MultiInput = 0.0,
256
295
  previous_weights: skt.MultiInput | None = None,
@@ -268,6 +307,8 @@ class HierarchicalEqualRiskContribution(BaseHierarchicalOptimization):
268
307
  previous_weights=previous_weights,
269
308
  portfolio_params=portfolio_params,
270
309
  )
310
+ self.solver = solver
311
+ self.solver_params = solver_params
271
312
 
272
313
  def fit(
273
314
  self, X: npt.ArrayLike, y: None = None, **fit_params
@@ -301,6 +342,13 @@ class HierarchicalEqualRiskContribution(BaseHierarchicalOptimization):
301
342
  raise TypeError(
302
343
  "`risk_measure` must be of type `RiskMeasure` or `ExtraRiskMeasure`"
303
344
  )
345
+
346
+ if self.risk_measure in [ExtraRiskMeasure.SKEW, ExtraRiskMeasure.KURTOSIS]:
347
+ # Because Skew and Kurtosis can take negative values
348
+ raise ValueError(
349
+ f"risk_measure {self.risk_measure} currently not supported" f"in HERC"
350
+ )
351
+
304
352
  self.prior_estimator_ = check_estimator(
305
353
  self.prior_estimator,
306
354
  default=EmpiricalPrior(),
@@ -393,21 +441,12 @@ class HierarchicalEqualRiskContribution(BaseHierarchicalOptimization):
393
441
 
394
442
  left_cluster = np.array(left_cluster)
395
443
  right_cluster = np.array(right_cluster)
444
+
396
445
  left_risk = np.sum(cluster_risks[left_cluster])
397
446
  right_risk = np.sum(cluster_risks[right_cluster])
398
447
 
399
448
  alpha = 1 - left_risk / (left_risk + right_risk)
400
449
 
401
- # Weights constraints
402
- alpha = self._apply_weight_constraints_to_alpha(
403
- alpha=alpha,
404
- weights=weights,
405
- max_weights=max_weights,
406
- min_weights=min_weights,
407
- left_cluster=left_cluster,
408
- right_cluster=right_cluster,
409
- )
410
-
411
450
  clusters_weights[left_cluster] *= alpha
412
451
  clusters_weights[right_cluster] *= 1 - alpha
413
452
 
@@ -421,5 +460,15 @@ class HierarchicalEqualRiskContribution(BaseHierarchicalOptimization):
421
460
  for i, cluster_ids in enumerate(clusters):
422
461
  weights[cluster_ids] *= clusters_weights[i]
423
462
 
463
+ # Apply weights constraints
464
+ weights = minimize_relative_weight_deviation(
465
+ weights=weights,
466
+ min_weights=min_weights,
467
+ max_weights=max_weights,
468
+ solver=self.solver,
469
+ solver_params=self.solver_params,
470
+ )
471
+
424
472
  self.weights_ = weights
473
+
425
474
  return self
@@ -72,8 +72,6 @@ class HierarchicalRiskParity(BaseHierarchicalOptimization):
72
72
  * ENTROPIC_RISK_MEASURE
73
73
  * FOURTH_CENTRAL_MOMENT
74
74
  * FOURTH_LOWER_PARTIAL_MOMENT
75
- * SKEW
76
- * KURTOSIS
77
75
 
78
76
  The default is `RiskMeasure.VARIANCE`.
79
77
 
@@ -100,9 +98,9 @@ class HierarchicalRiskParity(BaseHierarchicalOptimization):
100
98
 
101
99
  min_weights : float | dict[str, float] | array-like of shape (n_assets, ), default=0.0
102
100
  Minimum assets weights (weights lower bounds). Negative weights are not allowed.
103
- If a float is provided, it is applied to each asset. `None` is equivalent to
104
- `-np.Inf` (no lower bound). If a dictionary is provided, its (key/value) pair
105
- must be the (asset name/asset minium weight) and the input `X` of the `fit`
101
+ If a float is provided, it is applied to each asset.
102
+ If a dictionary is provided, its (key/value) pair must be the
103
+ (asset name/asset minium weight) and the input `X` of the `fit`
106
104
  methods must be a DataFrame with the assets names in columns. When using a
107
105
  dictionary, assets values that are not provided are assigned a minimum weight
108
106
  of `0.0`. The default is 0.0 (no short selling).
@@ -116,12 +114,12 @@ class HierarchicalRiskParity(BaseHierarchicalOptimization):
116
114
 
117
115
  max_weights : float | dict[str, float] | array-like of shape (n_assets, ), default=1.0
118
116
  Maximum assets weights (weights upper bounds). Weights above 1.0 are not
119
- allowed. If a float is provided, it is applied to each asset. `None` is
120
- equivalent to `+np.Inf` (no upper bound). If a dictionary is provided, its
121
- (key/value) pair must be the (asset name/asset maximum weight) and the input `X`
122
- of the `fit` method must be a DataFrame with the assets names in columns. When
123
- using a dictionary, assets values that are not provided are assigned a minimum
124
- weight of `1.0`. The default is 1.0 (each asset is below 100%).
117
+ allowed. If a float is provided, it is applied to each asset.
118
+ If a dictionary is provided, its (key/value) pair must be the
119
+ (asset name/asset maximum weight) and the input `X` of the `fit` method must
120
+ be a DataFrame with the assets names in columns.
121
+ When using a dictionary, assets values that are not provided are assigned a
122
+ minimum weight of `1.0`. The default is 1.0 (each asset is below 100%).
125
123
 
126
124
  Example:
127
125
 
@@ -296,6 +294,13 @@ class HierarchicalRiskParity(BaseHierarchicalOptimization):
296
294
  raise TypeError(
297
295
  "`risk_measure` must be of type `RiskMeasure` or `ExtraRiskMeasure`"
298
296
  )
297
+
298
+ if self.risk_measure in [ExtraRiskMeasure.SKEW, ExtraRiskMeasure.KURTOSIS]:
299
+ # Because Skew and Kurtosis can take negative values
300
+ raise ValueError(
301
+ f"risk_measure {self.risk_measure} currently not supported" f"in HRP"
302
+ )
303
+
299
304
  self.prior_estimator_ = check_estimator(
300
305
  self.prior_estimator,
301
306
  default=EmpiricalPrior(),
@@ -365,7 +370,7 @@ class HierarchicalRiskParity(BaseHierarchicalOptimization):
365
370
  left_cluster, right_cluster = clusters_ids
366
371
  alpha = 1 - left_risk / (left_risk + right_risk)
367
372
  # Weights constraints
368
- alpha = self._apply_weight_constraints_to_alpha(
373
+ alpha = _apply_weight_constraints_to_split_factor(
369
374
  alpha=alpha,
370
375
  weights=weights,
371
376
  max_weights=max_weights,
@@ -379,3 +384,54 @@ class HierarchicalRiskParity(BaseHierarchicalOptimization):
379
384
 
380
385
  self.weights_ = weights
381
386
  return self
387
+
388
+
389
+ def _apply_weight_constraints_to_split_factor(
390
+ alpha: float,
391
+ max_weights: np.ndarray,
392
+ min_weights: np.ndarray,
393
+ weights: np.ndarray,
394
+ left_cluster: np.ndarray,
395
+ right_cluster: np.ndarray,
396
+ ) -> float:
397
+ """
398
+ Apply weight constraints to the split factor alpha of the ,Hierarchical Tree
399
+ Clustering algorithm.
400
+
401
+ Parameters
402
+ ----------
403
+ alpha : float
404
+ The split factor alpha of the Hierarchical Tree Clustering algorithm.
405
+
406
+ min_weights : ndarray of shape (n_assets,)
407
+ The weight lower bound 1D array.
408
+
409
+ max_weights : ndarray of shape (n_assets,)
410
+ The weight upper bound 1D array.
411
+
412
+ weights : np.ndarray of shape (n_assets,)
413
+ The assets weights.
414
+
415
+ left_cluster : ndarray of shape (n_left_cluster,)
416
+ Indices of the left cluster weights.
417
+
418
+ right_cluster : ndarray of shape (n_right_cluster,)
419
+ Indices of the right cluster weights.
420
+
421
+ Returns
422
+ -------
423
+ value : float
424
+ The transformed split factor alpha incorporating the weight constraints.
425
+ """
426
+ alpha = min(
427
+ np.sum(max_weights[left_cluster]) / weights[left_cluster[0]],
428
+ max(np.sum(min_weights[left_cluster]) / weights[left_cluster[0]], alpha),
429
+ )
430
+ alpha = 1 - min(
431
+ np.sum(max_weights[right_cluster]) / weights[right_cluster[0]],
432
+ max(
433
+ np.sum(min_weights[right_cluster]) / weights[right_cluster[0]],
434
+ 1 - alpha,
435
+ ),
436
+ )
437
+ return alpha
@@ -290,7 +290,7 @@ class ConvexOptimization(BaseOptimization, ABC):
290
290
 
291
291
  * "2.5 * ref1 + 0.10 * ref2 + 0.0013 <= 2.5 * ref3"
292
292
  * "ref1 >= 2.9 * ref2"
293
- * "ref1 <= ref2"
293
+ * "ref1 == ref2"
294
294
  * "ref1 >= ref1"
295
295
 
296
296
  With "ref1", "ref2" ... the assets names or the groups names provided
@@ -302,8 +302,8 @@ class ConvexOptimization(BaseOptimization, ABC):
302
302
 
303
303
  * "SPX >= 0.10" --> SPX weight must be greater than 10% (note that you can also use `min_weights`)
304
304
  * "SX5E + TLT >= 0.2" --> the sum of SX5E and TLT weights must be greater than 20%
305
- * "US >= 0.7" --> the sum of all US weights must be greater than 70%
306
- * "Equity <= 3 * Bond" --> the sum of all Equity weights must be less or equal to 3 times the sum of all Bond weights.
305
+ * "US == 0.7" --> the sum of all US weights must be equal to 70%
306
+ * "Equity == 3 * Bond" --> the sum of all Equity weights must be equal to 3 times the sum of all Bond weights.
307
307
  * "2*SPX + 3*Europe <= Bond + 0.05" --> mixing assets and group constraints
308
308
 
309
309
  groups : dict[str, list[str]] or array-like of shape (n_groups, n_assets), optional
@@ -733,15 +733,21 @@ class ConvexOptimization(BaseOptimization, ABC):
733
733
  ),
734
734
  name="groups",
735
735
  )
736
- a, b = equations_to_matrix(
736
+ a_eq, b_eq, a_ineq, b_ineq = equations_to_matrix(
737
737
  groups=groups,
738
738
  equations=self.linear_constraints,
739
739
  raise_if_group_missing=False,
740
740
  )
741
- if np.any(a != 0):
741
+ if len(a_eq) != 0:
742
742
  constraints.append(
743
- a @ w * self._scale_constraints
744
- - b * factor * self._scale_constraints
743
+ a_eq @ w * self._scale_constraints
744
+ - b_eq * factor * self._scale_constraints
745
+ == 0
746
+ )
747
+ if len(a_ineq) != 0:
748
+ constraints.append(
749
+ a_ineq @ w * self._scale_constraints
750
+ - b_ineq * factor * self._scale_constraints
745
751
  <= 0
746
752
  )
747
753
 
@@ -125,7 +125,7 @@ class DistributionallyRobustCVaR(ConvexOptimization):
125
125
 
126
126
  * "2.5 * ref1 + 0.10 * ref2 + 0.0013 <= 2.5 * ref3"
127
127
  * "ref1 >= 2.9 * ref2"
128
- * "ref1 <= ref2"
128
+ * "ref1 == ref2"
129
129
  * "ref1 >= ref1"
130
130
 
131
131
  With "ref1", "ref2" ... the assets names or the groups names provided
@@ -137,8 +137,8 @@ class DistributionallyRobustCVaR(ConvexOptimization):
137
137
 
138
138
  * "SPX >= 0.10" --> SPX weight must be greater than 10% (note that you can also use `min_weights`)
139
139
  * "SX5E + TLT >= 0.2" --> the sum of SX5E and TLT weights must be greater than 20%
140
- * "US >= 0.7" --> the sum of all US weights must be greater than 70%
141
- * "Equity <= 3 * Bond" --> the sum of all Equity weights must be less or equal to 3 times the sum of all Bond weights.
140
+ * "US == 0.7" --> the sum of all US weights must be equal to 70%
141
+ * "Equity == 3 * Bond" --> the sum of all Equity weights must be equal to 3 times the sum of all Bond weights.
142
142
  * "2*SPX + 3*Europe <= Bond + 0.05" --> mixing assets and group constraints
143
143
 
144
144
  groups : dict[str, list[str]] or array-like of shape (n_groups, n_assets), optional
@@ -201,7 +201,7 @@ class MaximumDiversification(MeanRisk):
201
201
 
202
202
  * "2.5 * ref1 + 0.10 * ref2 + 0.0013 <= 2.5 * ref3"
203
203
  * "ref1 >= 2.9 * ref2"
204
- * "ref1 <= ref2"
204
+ * "ref1 == ref2"
205
205
  * "ref1 >= ref1"
206
206
 
207
207
  With "ref1", "ref2" ... the assets names or the groups names provided
@@ -213,8 +213,8 @@ class MaximumDiversification(MeanRisk):
213
213
 
214
214
  * "SPX >= 0.10" --> SPX weight must be greater than 10% (note that you can also use `min_weights`)
215
215
  * "SX5E + TLT >= 0.2" --> the sum of SX5E and TLT weights must be greater than 20%
216
- * "US >= 0.7" --> the sum of all US weights must be greater than 70%
217
- * "Equity <= 3 * Bond" --> the sum of all Equity weights must be less or equal to 3 times the sum of all Bond weights.
216
+ * "US == 0.7" --> the sum of all US weights must be equal to 70%
217
+ * "Equity == 3 * Bond" --> the sum of all Equity weights must be equal to 3 times the sum of all Bond weights.
218
218
  * "2*SPX + 3*Europe <= Bond + 0.05" --> mixing assets and group constraints
219
219
 
220
220
  groups : dict[str, list[str]] or array-like of shape (n_groups, n_assets), optional
@@ -334,7 +334,7 @@ class MeanRisk(ConvexOptimization):
334
334
 
335
335
  * "2.5 * ref1 + 0.10 * ref2 + 0.0013 <= 2.5 * ref3"
336
336
  * "ref1 >= 2.9 * ref2"
337
- * "ref1 <= ref2"
337
+ * "ref1 == ref2"
338
338
  * "ref1 >= ref1"
339
339
 
340
340
  With "ref1", "ref2" ... the assets names or the groups names provided
@@ -346,8 +346,8 @@ class MeanRisk(ConvexOptimization):
346
346
 
347
347
  * "SPX >= 0.10" --> SPX weight must be greater than 10% (note that you can also use `min_weights`)
348
348
  * "SX5E + TLT >= 0.2" --> the sum of SX5E and TLT weights must be greater than 20%
349
- * "US >= 0.7" --> the sum of all US weights must be greater than 70%
350
- * "Equity <= 3 * Bond" --> the sum of all Equity weights must be less or equal to 3 times the sum of all Bond weights.
349
+ * "US == 0.7" --> the sum of all US weights must be equal to 70%
350
+ * "Equity == 3 * Bond" --> the sum of all Equity weights must be equal to 3 times the sum of all Bond weights.
351
351
  * "2*SPX + 3*Europe <= Bond + 0.05" --> mixing assets and group constraints
352
352
 
353
353
  groups : dict[str, list[str]] or array-like of shape (n_groups, n_assets), optional
@@ -212,12 +212,12 @@ class RiskBudgeting(ConvexOptimization):
212
212
  The default (`None`) means no previous weights.
213
213
 
214
214
  linear_constraints : array-like of shape (n_constraints,), optional
215
- Linear constraints.
215
+ Linear constraints.
216
216
  The linear constraints must match any of following patterns:
217
217
 
218
218
  * "2.5 * ref1 + 0.10 * ref2 + 0.0013 <= 2.5 * ref3"
219
219
  * "ref1 >= 2.9 * ref2"
220
- * "ref1 <= ref2"
220
+ * "ref1 == ref2"
221
221
  * "ref1 >= ref1"
222
222
 
223
223
  With "ref1", "ref2" ... the assets names or the groups names provided
@@ -229,8 +229,8 @@ class RiskBudgeting(ConvexOptimization):
229
229
 
230
230
  * "SPX >= 0.10" --> SPX weight must be greater than 10% (note that you can also use `min_weights`)
231
231
  * "SX5E + TLT >= 0.2" --> the sum of SX5E and TLT weights must be greater than 20%
232
- * "US >= 0.7" --> the sum of all US weights must be greater than 70%
233
- * "Equity <= 3 * Bond" --> the sum of all Equity weights must be less or equal to 3 times the sum of all Bond weights.
232
+ * "US == 0.7" --> the sum of all US weights must be equal to 70%
233
+ * "Equity == 3 * Bond" --> the sum of all Equity weights must be equal to 3 times the sum of all Bond weights.
234
234
  * "2*SPX + 3*Europe <= Bond + 0.05" --> mixing assets and group constraints
235
235
 
236
236
  groups : dict[str, list[str]] or array-like of shape (n_groups, n_assets), optional
@@ -208,7 +208,7 @@ class BlackLitterman(BasePrior):
208
208
  ),
209
209
  name="groups",
210
210
  )
211
- self.picking_matrix_, self.views_ = equations_to_matrix(
211
+ self.picking_matrix_, self.views_, a_ineq, b_ineq = equations_to_matrix(
212
212
  groups=self.groups_,
213
213
  equations=views,
214
214
  sum_to_one=True,
@@ -216,6 +216,9 @@ class BlackLitterman(BasePrior):
216
216
  names=("groups", "views"),
217
217
  )
218
218
 
219
+ if len(a_ineq) != 0:
220
+ raise ValueError("Inequalities (<=, >=) are not supported in views")
221
+
219
222
  if self.view_confidences is None:
220
223
  omega = np.diag(
221
224
  np.diag(
@@ -10,10 +10,24 @@ import warnings
10
10
  import numpy as np
11
11
  import numpy.typing as npt
12
12
 
13
- from skfolio.exceptions import EquationToMatrixError, GroupNotFoundError
13
+ from skfolio.exceptions import (
14
+ DuplicateGroupsError,
15
+ EquationToMatrixError,
16
+ GroupNotFoundError,
17
+ )
14
18
 
15
19
  __all__ = ["equations_to_matrix"]
16
20
 
21
+ _EQUALITY_OPERATORS = {"==", "="}
22
+ _INEQUALITY_OPERATORS = {">=", "<="}
23
+ _COMPARISON_OPERATORS = _EQUALITY_OPERATORS.union(_INEQUALITY_OPERATORS)
24
+ _SUB_ADD_OPERATORS = {"-", "+"}
25
+ _MUL_OPERATORS = {"*"}
26
+ _NON_MUL_OPERATORS = _COMPARISON_OPERATORS.union(_SUB_ADD_OPERATORS)
27
+ _OPERATORS = _NON_MUL_OPERATORS.union(_MUL_OPERATORS)
28
+ _COMPARISON_OPERATOR_SIGNS = {">=": -1, "<=": 1, "==": 1, "=": 1}
29
+ _SUB_ADD_OPERATOR_SIGNS = {"+": 1, "-": -1}
30
+
17
31
 
18
32
  def equations_to_matrix(
19
33
  groups: npt.ArrayLike,
@@ -21,9 +35,9 @@ def equations_to_matrix(
21
35
  sum_to_one: bool = False,
22
36
  raise_if_group_missing: bool = False,
23
37
  names: tuple[str, str] = ("groups", "equations"),
24
- ) -> tuple[np.ndarray, np.ndarray]:
38
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
25
39
  """Convert a list of linear equations into the left and right matrices of the
26
- inequality A <= B.
40
+ inequality A <= B and equality A == B.
27
41
 
28
42
  Parameters
29
43
  ----------
@@ -44,9 +58,9 @@ def equations_to_matrix(
44
58
 
45
59
  Example of valid equation patterns:
46
60
  * "number_1 * group_1 + number_3 <= number_4 * group_3 + number_5"
47
- * "group_1 >= number * group_2"
61
+ * "group_1 == number * group_2"
48
62
  * "group_1 <= number"
49
- * "group_1 >= number"
63
+ * "group_1 == number"
50
64
 
51
65
  "group_1" and "group_2" are the group names defined in `groups`.
52
66
  The second expression means that the sum of all assets in "group_1" should be
@@ -57,8 +71,8 @@ def equations_to_matrix(
57
71
  "Equity <= 3 * Bond",
58
72
  "US >= 1.5",
59
73
  "Europe >= 0.5 * Japan",
60
- "Japan <= 1",
61
- "3*SPX + 5*SX5E <= 2*TLT + 3",
74
+ "Japan == 1",
75
+ "3*SPX + 5*SX5E == 2*TLT + 3",
62
76
  ]
63
77
 
64
78
  sum_to_one : bool
@@ -76,41 +90,104 @@ def equations_to_matrix(
76
90
 
77
91
  Returns
78
92
  -------
79
- left: ndarray of shape (n_equations, n_assets)
80
- right: ndarray of shape (n_equations,)
93
+ left_equality: ndarray of shape (n_equations_equality, n_assets)
94
+ right_equality: ndarray of shape (n_equations_equality,)
81
95
  The left and right matrices of the inequality A <= B.
82
- If none of the group inside the equations are part of the groups, `None` is
83
- returned.
96
+
97
+ left_inequality: ndarray of shape (n_equations_inequality, n_assets)
98
+ right_inequality: ndarray of shape (n_equations_inequality,)
99
+ The left and right matrices of the equality A == B.
84
100
  """
85
- groups = np.asarray(groups)
86
- equations = np.asarray(equations)
87
- if groups.ndim != 2:
88
- raise ValueError(
89
- f"`{names[0]}` must be a 2D array, got {groups.ndim}D array instead."
90
- )
91
- if equations.ndim != 1:
92
- raise ValueError(
93
- f"`{names[1]}` must be a 1D array, got {equations.ndim}D array instead."
94
- )
101
+ groups = _validate_groups(groups, name=names[0])
102
+ equations = _validate_equations(equations, name=names[1])
103
+
104
+ a_equality = []
105
+ b_equality = []
106
+
107
+ a_inequality = []
108
+ b_inequality = []
95
109
 
96
- n_equations = len(equations)
97
- n_assets = groups.shape[1]
98
- a = np.zeros((n_equations, n_assets))
99
- b = np.zeros(n_equations)
100
- for i, string in enumerate(equations):
110
+ for string in equations:
101
111
  try:
102
- left, right = _string_to_equation(
112
+ left, right, is_inequality = _string_to_equation(
103
113
  groups=groups,
104
114
  string=string,
105
115
  sum_to_one=sum_to_one,
106
116
  )
107
- a[i] = left
108
- b[i] = right
117
+ if is_inequality:
118
+ a_inequality.append(left)
119
+ b_inequality.append(right)
120
+ else:
121
+ a_equality.append(left)
122
+ b_equality.append(right)
109
123
  except GroupNotFoundError as e:
110
124
  if raise_if_group_missing:
111
125
  raise
112
126
  warnings.warn(str(e), stacklevel=2)
113
- return a, b
127
+ return (
128
+ np.array(a_equality),
129
+ np.array(b_equality),
130
+ np.array(a_inequality),
131
+ np.array(b_inequality),
132
+ )
133
+
134
+
135
+ def _validate_groups(groups: npt.ArrayLike, name: str = "groups") -> np.ndarray:
136
+ """Validate groups by checking its dim and if group names don't appear in multiple
137
+ levels and convert to numpy array.
138
+
139
+ Parameters
140
+ ----------
141
+ groups : array-like of shape (n_groups, n_assets)
142
+ 2D-array of strings.
143
+
144
+ Returns
145
+ -------
146
+ groups : ndarray of shape (n_groups, n_assets)
147
+ 2D-array of strings.
148
+ """
149
+ groups = np.asarray(groups)
150
+ if groups.ndim != 2:
151
+ raise ValueError(
152
+ f"`{name} must be a 2D array, got {groups.ndim}D array instead."
153
+ )
154
+ n = len(groups)
155
+ group_sets = [set(groups[i]) for i in range(n)]
156
+ for i in range(n - 1):
157
+ for e in group_sets[i]:
158
+ for j in range(i + 1, n):
159
+ if e in group_sets[j]:
160
+ raise DuplicateGroupsError(
161
+ f"'{e}' appear in two levels: {list(groups[i])} "
162
+ f"and {list(groups[i])}. "
163
+ f"{name} must be in only one level."
164
+ )
165
+
166
+ return groups
167
+
168
+
169
+ def _validate_equations(
170
+ equations: npt.ArrayLike, name: str = "equations"
171
+ ) -> np.ndarray:
172
+ """Validate equations by checking its dim and convert to numpy array.
173
+
174
+ Parameters
175
+ ----------
176
+ equations : array-like of shape (n_equations,)
177
+ 1D array of equations.
178
+
179
+ Returns
180
+ -------
181
+ equations : ndarray of shape (n_equations,)
182
+ 1D array of equations.
183
+ """
184
+ equations = np.asarray(equations)
185
+
186
+ if equations.ndim != 1:
187
+ raise ValueError(
188
+ f"`{name}` must be a 1D array, got {equations.ndim}D array instead."
189
+ )
190
+ return equations
114
191
 
115
192
 
116
193
  def _matching_array(values: np.ndarray, key: str, sum_to_one: bool) -> np.ndarray:
@@ -145,11 +222,7 @@ def _matching_array(values: np.ndarray, key: str, sum_to_one: bool) -> np.ndarra
145
222
  return arr / s
146
223
 
147
224
 
148
- _operator_mapping = {">=": -1, "<=": 1, "==": 1, "=": 1}
149
- _operator_signs = {"+": 1, "-": -1}
150
-
151
-
152
- def _inequality_operator_sign(operator: str) -> int:
225
+ def _comparison_operator_sign(operator: str) -> int:
153
226
  """Convert the operators '>=', "==" and '<=' into the corresponding integer
154
227
  values -1, 1 and 1, respectively.
155
228
 
@@ -164,14 +237,14 @@ def _inequality_operator_sign(operator: str) -> int:
164
237
  Operator sign: 1 or -1.
165
238
  """
166
239
  try:
167
- return _operator_mapping[operator]
240
+ return _COMPARISON_OPERATOR_SIGNS[operator]
168
241
  except KeyError:
169
242
  raise EquationToMatrixError(
170
243
  f"operator '{operator}' is not valid. It should be '<=' or '>='"
171
244
  ) from None
172
245
 
173
246
 
174
- def _operator_sign(operator: str) -> int:
247
+ def _sub_add_operator_sign(operator: str) -> int:
175
248
  """Convert the operators '+' and '-' into 1 or -1
176
249
 
177
250
  Parameters
@@ -185,7 +258,7 @@ def _operator_sign(operator: str) -> int:
185
258
  Operator sign: 1 or -1.
186
259
  """
187
260
  try:
188
- return _operator_signs[operator]
261
+ return _SUB_ADD_OPERATOR_SIGNS[operator]
189
262
  except KeyError:
190
263
  raise EquationToMatrixError(
191
264
  f"operator '{operator}' is not valid. It should be be '+' or '-'"
@@ -211,13 +284,41 @@ def _string_to_float(string: str) -> float:
211
284
  raise EquationToMatrixError(f"Unable to convert {string} into float") from None
212
285
 
213
286
 
287
+ def _split_equation_string(string: str) -> list[str]:
288
+ """Split an equation strings by operators"""
289
+ comp_pattern = "(?=" + "|".join([".+\\" + e for e in _COMPARISON_OPERATORS]) + ")"
290
+ if not bool(re.match(comp_pattern, string)):
291
+ raise EquationToMatrixError(
292
+ f"The string must contains a comparison operator: "
293
+ f"{list(_COMPARISON_OPERATORS)}"
294
+ )
295
+
296
+ # Regex to match only '>' and '<' but not '<=' or '>='
297
+ invalid_pattern = r"(?<!<)(?<!<=)>(?!=)|(?<!>)<(?!=)"
298
+ invalid_matches = re.findall(invalid_pattern, string)
299
+
300
+ if len(invalid_matches) > 0:
301
+ raise EquationToMatrixError(
302
+ f"{invalid_matches[0]} is an invalid comparison operator. "
303
+ f"Valid comparison operators are: {list(_COMPARISON_OPERATORS)}"
304
+ )
305
+
306
+ # '==' needs to be before '='
307
+ operators = sorted(_OPERATORS, reverse=True)
308
+ pattern = "((?:" + "|".join(["\\" + e for e in operators]) + "))"
309
+ res = [x.strip() for x in re.split(pattern, string)]
310
+ res = [x for x in res if x != ""]
311
+ return res
312
+
313
+
214
314
  def _string_to_equation(
215
315
  groups: np.ndarray,
216
316
  string: str,
217
317
  sum_to_one: bool,
218
- ) -> tuple[np.ndarray, float]:
318
+ ) -> tuple[np.ndarray, float, bool]:
219
319
  """Convert a string to a left 1D-array and right float of the form:
220
- `groups @ left <= right`.
320
+ `groups @ left <= right` or `groups @ left == right` and return whether it's an
321
+ equality or inequality.
221
322
 
222
323
  Parameters
223
324
  ----------
@@ -232,20 +333,14 @@ def _string_to_equation(
232
333
 
233
334
  Returns
234
335
  -------
235
- left: 1D-array of shape (n_assets,)
236
- right: float
336
+ left : 1D-array of shape (n_assets,)
337
+ right : float
338
+ is_inequality : bool
237
339
  """
238
340
  n = groups.shape[1]
239
- operators = ["-", "+", "*", ">=", "<=", "==", "="]
240
- invalid_operators = [">", "<"]
241
- pattern = re.compile(r"((?:" + "|\\".join(operators) + r"))")
242
- invalid_pattern = re.compile(r"((?:" + "|\\".join(invalid_operators) + r"))")
243
341
  err_msg = f"Wrong pattern encountered while converting the string '{string}'"
244
342
 
245
- res = re.split(pattern, string)
246
- res = [x.strip() for x in res]
247
- res = [x for x in res if x != ""]
248
- iterator = iter(res)
343
+ iterator = iter(_split_equation_string(string))
249
344
  group_names = set(groups.flatten())
250
345
 
251
346
  def is_group(name: str) -> bool:
@@ -254,7 +349,8 @@ def _string_to_equation(
254
349
  left = np.zeros(n)
255
350
  right = 0
256
351
  main_sign = 1
257
- inequality_sign = None
352
+ comparison_sign = None
353
+ is_inequality = None
258
354
  e = next(iterator, None)
259
355
  i = 0
260
356
  while True:
@@ -264,23 +360,27 @@ def _string_to_equation(
264
360
  if e is None:
265
361
  break
266
362
  sign = 1
267
- if e in [">=", "<=", "==", "="]:
363
+ if e in _COMPARISON_OPERATORS:
364
+ if e in _INEQUALITY_OPERATORS:
365
+ is_inequality = True
366
+ else:
367
+ is_inequality = False
268
368
  main_sign = -1
269
- inequality_sign = _inequality_operator_sign(e)
369
+ comparison_sign = _comparison_operator_sign(e)
270
370
  e = next(iterator, None)
271
- if e in ["-", "+"]:
272
- sign *= _operator_sign(e)
371
+ if e in _SUB_ADD_OPERATORS:
372
+ sign *= _sub_add_operator_sign(e)
273
373
  e = next(iterator, None)
274
- elif e in ["-", "+"]:
275
- sign *= _operator_sign(e)
374
+ elif e in _SUB_ADD_OPERATORS:
375
+ sign *= _sub_add_operator_sign(e)
276
376
  e = next(iterator, None)
277
- elif e == "*":
377
+ elif e in _MUL_OPERATORS:
278
378
  raise EquationToMatrixError(
279
379
  f"{err_msg}: the character '{e}' is wrongly positioned"
280
380
  )
281
381
  sign *= main_sign
282
382
  # next can only be a number or a group
283
- if e is None or e in operators:
383
+ if e is None or e in _OPERATORS:
284
384
  raise EquationToMatrixError(
285
385
  f"{err_msg}: the character '{e}' is wrongly positioned"
286
386
  )
@@ -288,20 +388,14 @@ def _string_to_equation(
288
388
  arr = _matching_array(values=groups, key=e, sum_to_one=sum_to_one)
289
389
  # next can only be a '*' or an ['-', '+', '>=', '<=', '==', '='] or None
290
390
  e = next(iterator, None)
291
- if e is None or e in ["-", "+", ">=", "<=", "==", "="]:
391
+ if e is None or e in _NON_MUL_OPERATORS:
292
392
  left += sign * arr
293
- elif e == "*":
393
+ elif e in _MUL_OPERATORS:
294
394
  # next can only a number
295
395
  e = next(iterator, None)
296
396
  try:
297
397
  number = float(e)
298
398
  except ValueError:
299
- invalid_ops = invalid_pattern.findall(e)
300
- if len(invalid_ops) > 0:
301
- raise EquationToMatrixError(
302
- f"{invalid_ops[0]} is an invalid operator. Valid operators"
303
- f" are: {operators}"
304
- ) from None
305
399
  raise GroupNotFoundError(
306
400
  f"{err_msg}: the group '{e}' is missing from the groups"
307
401
  f" {groups}"
@@ -317,18 +411,12 @@ def _string_to_equation(
317
411
  try:
318
412
  number = float(e)
319
413
  except ValueError:
320
- invalid_ops = invalid_pattern.findall(e)
321
- if len(invalid_ops) > 0:
322
- raise EquationToMatrixError(
323
- f"{invalid_ops[0]} is an invalid operator. Valid operators are:"
324
- f" {operators}"
325
- ) from None
326
414
  raise GroupNotFoundError(
327
415
  f"{err_msg}: the group '{e}' is missing from the groups {groups}"
328
416
  ) from None
329
417
  # next can only be a '*' or an operator or None
330
418
  e = next(iterator, None)
331
- if e == "*":
419
+ if e in _MUL_OPERATORS:
332
420
  # next can only a group
333
421
  e = next(iterator, None)
334
422
  if not is_group(e):
@@ -338,14 +426,14 @@ def _string_to_equation(
338
426
  arr = _matching_array(values=groups, key=e, sum_to_one=sum_to_one)
339
427
  left += number * sign * arr
340
428
  e = next(iterator, None)
341
- elif e is None or e in ["-", "+", ">=", "<=", "==", "="]:
429
+ elif e is None or e in _NON_MUL_OPERATORS:
342
430
  right += number * sign
343
431
  else:
344
432
  raise EquationToMatrixError(
345
433
  f"{err_msg}: the character '{e}' is wrongly positioned"
346
434
  )
347
435
 
348
- left *= inequality_sign
349
- right *= -inequality_sign
436
+ left *= comparison_sign
437
+ right *= -comparison_sign
350
438
 
351
- return left, right
439
+ return left, right, is_inequality
skfolio/utils/stats.py CHANGED
@@ -10,9 +10,11 @@ import warnings
10
10
  # Statsmodels, Copyright (C) 2006, Jonathan E. Taylor, Licensed under BSD 3 clause.
11
11
  from enum import auto
12
12
 
13
+ import cvxpy as cp
13
14
  import numpy as np
14
15
  import scipy.cluster.hierarchy as sch
15
16
  import scipy.optimize as sco
17
+ import scipy.sparse.linalg as scl
16
18
  import scipy.spatial.distance as scd
17
19
  import scipy.special as scs
18
20
  from scipy.sparse import csr_matrix
@@ -34,6 +36,7 @@ __all__ = [
34
36
  "compute_optimal_n_clusters",
35
37
  "rand_weights",
36
38
  "rand_weights_dirichlet",
39
+ "minimize_relative_weight_deviation",
37
40
  ]
38
41
 
39
42
 
@@ -488,3 +491,87 @@ def compute_optimal_n_clusters(distance: np.ndarray, linkage_matrix: np.ndarray)
488
491
  # k=0 represents one cluster
489
492
  k = np.argmax(gaps) + 2
490
493
  return k
494
+
495
+
496
+ def minimize_relative_weight_deviation(
497
+ weights: np.ndarray,
498
+ min_weights: np.ndarray,
499
+ max_weights: np.ndarray,
500
+ solver: str = "CLARABEL",
501
+ solver_params: dict | None = None,
502
+ ) -> np.ndarray:
503
+ r"""
504
+ Apply weight constraints to an initial array of weights by minimizing the relative
505
+ weight deviation of the final weights from the initial weights.
506
+
507
+ .. math::
508
+ \begin{cases}
509
+ \begin{aligned}
510
+ &\min_{w} & & \Vert \frac{w - w_{init}}{w_{init}} \Vert_{2}^{2} \\
511
+ &\text{s.t.} & & \sum_{i=1}^{N} w_{i} = 1 \\
512
+ & & & w_{min} \leq w_i \leq w_{max}, \quad \forall i
513
+ \end{aligned}
514
+ \end{cases}
515
+
516
+ Parameters
517
+ ----------
518
+ weights : ndarray of shape (n_assets,)
519
+ Initial weights.
520
+
521
+ min_weights : ndarray of shape (n_assets,)
522
+ Minimum assets weights (weights lower bounds).
523
+
524
+ max_weights : ndarray of shape (n_assets,)
525
+ Maximum assets weights (weights upper bounds).
526
+
527
+ solver : str, default="CLARABEL"
528
+ The solver to use. The default is "CLARABEL" which is written in Rust and has
529
+ better numerical stability and performance than ECOS and SCS.
530
+ For more details about available solvers, check the CVXPY documentation:
531
+ https://www.cvxpy.org/tutorial/advanced/index.html#choosing-a-solver
532
+
533
+ solver_params : dict, optional
534
+ Solver parameters. For example, `solver_params=dict(verbose=True)`.
535
+ The default (`None`) is to use the CVXPY default.
536
+ For more details about solver arguments, check the CVXPY documentation:
537
+ https://www.cvxpy.org/tutorial/advanced/index.html#setting-solver-options
538
+ """
539
+ if not (weights.shape == min_weights.shape == max_weights.shape):
540
+ raise ValueError("`min_weights` and `max_weights` must have same size")
541
+
542
+ if np.any(weights < 0):
543
+ raise ValueError("Initial weights must be strictly positive")
544
+
545
+ if not np.isclose(np.sum(weights), 1.0):
546
+ raise ValueError("Initial weights must sum to one")
547
+
548
+ if np.any(max_weights < min_weights):
549
+ raise ValueError("`min_weights` must be lower or equal to `max_weights`")
550
+
551
+ if np.all((weights >= min_weights) & (weights <= max_weights)):
552
+ return weights
553
+
554
+ if solver_params is None:
555
+ solver_params = {}
556
+
557
+ n = len(weights)
558
+ w = cp.Variable(n)
559
+
560
+ objective = cp.Minimize(cp.norm(w / weights - 1))
561
+ constraints = [cp.sum(w) == 1, w >= min_weights, w <= max_weights]
562
+ problem = cp.Problem(objective, constraints)
563
+
564
+ try:
565
+ problem.solve(solver=solver, **solver_params)
566
+
567
+ if w.value is None:
568
+ raise cp.SolverError("No solution found")
569
+
570
+ except (cp.SolverError, scl.ArpackNoConvergence):
571
+ raise cp.SolverError(
572
+ f"Solver '{solver}' failed. Try another"
573
+ " solver, or solve with solver_params=dict(verbose=True) for more"
574
+ " information"
575
+ ) from None
576
+
577
+ return w.value
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: skfolio
3
- Version: 0.4.1
3
+ Version: 0.4.3
4
4
  Summary: Portfolio optimization built on top of scikit-learn
5
5
  Author-email: Hugo Delatte <delatte.hugo@gmail.com>
6
6
  Maintainer-email: Hugo Delatte <delatte.hugo@gmail.com>
@@ -599,7 +599,7 @@ K-fold Cross-Validation
599
599
  # mmp is the predicted MultiPeriodPortfolio object composed of 5 Portfolios (1 per testing fold)
600
600
 
601
601
  mmp.plot_cumulative_returns()
602
- print(mmp.summary()
602
+ print(mmp.summary())
603
603
 
604
604
 
605
605
  Combinatorial Purged Cross-Validation
@@ -1,5 +1,5 @@
1
1
  skfolio/__init__.py,sha256=5pn5LpTz6v2j2sxGkY97cVRrSPsN3Yav9b6Uw08boEI,618
2
- skfolio/exceptions.py,sha256=-XniKql9QHgfitMgHsE9UXWVPdjWpNGO2dVk2SsdPWE,662
2
+ skfolio/exceptions.py,sha256=3LCxKlxgEaIMPQPCHjo1UiL7rlJnD15dNRMyBeYyKcc,784
3
3
  skfolio/typing.py,sha256=yEZiCZ6UIyfYUqtfj9Kf2KA9mrjUbmxyzpH9uqVboJs,1378
4
4
  skfolio/cluster/__init__.py,sha256=4g-PFB_ld9BhiQ1ZPvvAorpFbRwd_p_DkeRlulDv2Hk,251
5
5
  skfolio/cluster/_hierarchical.py,sha256=16INBe5HB7ALODO3RNI8ZjOYALtMZa3U_7EP1aEIxp8,12819
@@ -45,15 +45,15 @@ skfolio/optimization/_base.py,sha256=LoRONJP70AwbFpdgqVS_g145pCx0JGkazjWvkQzT_iM
45
45
  skfolio/optimization/cluster/__init__.py,sha256=M3xVdYhNKp4e9CB7hzb4yjTxkkNCHh7Mt_KGFFrkOgs,388
46
46
  skfolio/optimization/cluster/_nco.py,sha256=J3pPd9XkrAcWaKPSW5vMdtaFpDshBvOdUudbDGQSoNI,16366
47
47
  skfolio/optimization/cluster/hierarchical/__init__.py,sha256=YnfcPHvjwB6kcG4hoQqc0NqIJKaG7OjBtmXNbOxCq08,405
48
- skfolio/optimization/cluster/hierarchical/_base.py,sha256=ioOBsHA-kRFV_Bvl0-PcqLOytjwy6JhAX1UV454Hfss,18079
49
- skfolio/optimization/cluster/hierarchical/_herc.py,sha256=gFmliW8YJZbbIjHwZ5IqTmTBIt9voLUGCZKdy8RoTvw,17956
50
- skfolio/optimization/cluster/hierarchical/_hrp.py,sha256=nB3W5Zm1TaKTLyRMqN6irAbXD-y-bL2b78d7VFYASa8,16511
48
+ skfolio/optimization/cluster/hierarchical/_base.py,sha256=l8rJHCH_79FOPdDL2I0dmAWcVWnNkcXHtzt0U-L7BN8,16280
49
+ skfolio/optimization/cluster/hierarchical/_herc.py,sha256=LPtUrvyW9G60OZhMWlZH_GHZHdX8mJHksrYGB-WPRVg,20358
50
+ skfolio/optimization/cluster/hierarchical/_hrp.py,sha256=dn6EKiTJ1wkoFhPdst6vlXnSQvXSYsMtB2zaGNVPpyA,18115
51
51
  skfolio/optimization/convex/__init__.py,sha256=F6BPFikTo0B-7JCKazqLGEwM3RkgTNbFm5GAGkaq9Uo,570
52
- skfolio/optimization/convex/_base.py,sha256=-lUwlV5wv4BayzOhB1g4smaJTm4VQ1ZTiaI0JAr_LWo,75806
53
- skfolio/optimization/convex/_distributionally_robust.py,sha256=INm3kyuKSwdSbgVlqJuKM4P1KQ0ImQulqoO4gfh-a4Q,17823
54
- skfolio/optimization/convex/_maximum_diversification.py,sha256=xucjuxiJf46vNwSZ7ICWmoscDvLn3Ts0xngszXfFXC0,19512
55
- skfolio/optimization/convex/_mean_risk.py,sha256=IBtZovEh0FBYBL5gh0KMsBmyOsSbKTAgMK0y3Yem9p4,44188
56
- skfolio/optimization/convex/_risk_budgeting.py,sha256=vOPxluh7yEEX1P4wLD6S2eT6HLyBWLeFmUqAaK_SLSU,23790
52
+ skfolio/optimization/convex/_base.py,sha256=2at6Ll4qHkN_1wvYjl-yXWTbiRJj8fhNS-bfAT88YSw,76055
53
+ skfolio/optimization/convex/_distributionally_robust.py,sha256=tw_UNSDfAXP02khE10hpmcdlz3DQXQD7ttDqFDSHV1E,17811
54
+ skfolio/optimization/convex/_maximum_diversification.py,sha256=IVKVbK7bh4KPkhpNWLLerl-qx9Qcmf2cIIRotP8r8nI,19500
55
+ skfolio/optimization/convex/_mean_risk.py,sha256=H4Ik6vvIETdAZnNCA4Jhk_OTirHJg26KQZ5iLsXgaHo,44176
56
+ skfolio/optimization/convex/_risk_budgeting.py,sha256=ntPK57Ws-_U4QAiZjXFvKUYUELv9EBoJIWqofxx-0rY,23779
57
57
  skfolio/optimization/ensemble/__init__.py,sha256=8TXxcxH2_gG3C1xtgQj9OHHr0Le8lhdejtlURL6T3ZY,158
58
58
  skfolio/optimization/ensemble/_base.py,sha256=GaNDQu6ivosYuwMrb-b0PhToCsNrmhSYyXkxeM8W4rU,3399
59
59
  skfolio/optimization/ensemble/_stacking.py,sha256=ZoICUnc_MwoXDQAR2kewCg-KIezSOIUdDV1fuf7vMyA,14168
@@ -71,7 +71,7 @@ skfolio/preprocessing/__init__.py,sha256=15A1bzfPsbfxxXgGP1gstf4R0E_347Wn18z5W5j
71
71
  skfolio/preprocessing/_returns.py,sha256=oo1Mm-UCHwq4ECjfmsRxWzzK1EPsuv-EEtnimvv_nXo,4345
72
72
  skfolio/prior/__init__.py,sha256=jql8NTiWlykPKJUXTOPdqm531mP8Pul1QAR6hXTXA6c,446
73
73
  skfolio/prior/_base.py,sha256=u9GLCKJl-Txiem5rIO-qkH3VIyem3taD6T9kMzsYPRY,1941
74
- skfolio/prior/_black_litterman.py,sha256=7ikhjV_pc61J_0zyDiW63MNFHIEdMP3rJhmsSq5mM_k,10220
74
+ skfolio/prior/_black_litterman.py,sha256=W3HbpvkViEiD7AOgpdVmNYTlWKSGDgo9Y3BfSrbMIQ4,10347
75
75
  skfolio/prior/_empirical.py,sha256=K3htSj_MGX6wNL-XxkTqFxz8WeqNzek6X4YYwKUmMC4,7207
76
76
  skfolio/prior/_factor_model.py,sha256=xMWyOaJNrCM6NyDQK_-G4wCfREaThI4QvhxxGhsodII,11311
77
77
  skfolio/uncertainty_set/__init__.py,sha256=LlMHtYv9G9fgtM7m4sCSToS9et57Pm2Q2gGchTVrj6c,617
@@ -80,12 +80,12 @@ skfolio/uncertainty_set/_bootstrap.py,sha256=BRD8LhGKULkqqCBjLqU1EtCAMBkLJKEXJyg
80
80
  skfolio/uncertainty_set/_empirical.py,sha256=ACqMVTBKibJm6E3IP4TOi3MYsxKMhiEoix5D_fp9X-w,9364
81
81
  skfolio/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
82
82
  skfolio/utils/bootstrap.py,sha256=3zY2kO_GQURKEcQMCasJOSByde9Mt2IAi3KJH0_a4mk,3550
83
- skfolio/utils/equations.py,sha256=w0HsYjA7cS0mHYsI9MpixHLkof3HN26nc14ZfqFrHlE,11047
83
+ skfolio/utils/equations.py,sha256=MQ1w3VSM2n_j9bTIKAQA716aWKYyUqtw5yM2bU-9t-M,13745
84
84
  skfolio/utils/sorting.py,sha256=lSjMvH2L-sSj-06B3MlwBrH1rtjCeGEe4hG894W7TE0,3504
85
- skfolio/utils/stats.py,sha256=wuOmSt5panMMTw_pFYizLbmrclsE_4PHQfamkzJ5J2s,13937
85
+ skfolio/utils/stats.py,sha256=bzKlF2U7BN2WonwtuwG_cL_16Z3cTAxCAw5pZgbib54,17005
86
86
  skfolio/utils/tools.py,sha256=4KrmBR9jOLiI6j0hb27gsPC--OHXo4Sp1xl-6i-k9Tg,20925
87
- skfolio-0.4.1.dist-info/LICENSE,sha256=F6Gi-ZJX5BlVzYK8R9NcvAkAsKa7KO29xB1OScbrH6Q,1526
88
- skfolio-0.4.1.dist-info/METADATA,sha256=tu4OaUrxQWNbllbaEmek2cPtZv5URQb-w7IY0_i_qUc,19610
89
- skfolio-0.4.1.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
90
- skfolio-0.4.1.dist-info/top_level.txt,sha256=NXEaoS9Ms7t32gxkb867nV0OKlU0KmssL7IJBVo0fJs,8
91
- skfolio-0.4.1.dist-info/RECORD,,
87
+ skfolio-0.4.3.dist-info/LICENSE,sha256=F6Gi-ZJX5BlVzYK8R9NcvAkAsKa7KO29xB1OScbrH6Q,1526
88
+ skfolio-0.4.3.dist-info/METADATA,sha256=PUf5onO29CqsRRaMyrMP3y0RKw6MJ43TNQ_2hMks7n0,19611
89
+ skfolio-0.4.3.dist-info/WHEEL,sha256=OVMc5UfuAQiSplgO0_WdW7vXVGAt9Hdd6qtN4HotdyA,91
90
+ skfolio-0.4.3.dist-info/top_level.txt,sha256=NXEaoS9Ms7t32gxkb867nV0OKlU0KmssL7IJBVo0fJs,8
91
+ skfolio-0.4.3.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.1.0)
2
+ Generator: setuptools (75.2.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5