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 +5 -0
- skfolio/optimization/cluster/hierarchical/_base.py +12 -65
- skfolio/optimization/cluster/hierarchical/_herc.py +75 -26
- skfolio/optimization/cluster/hierarchical/_hrp.py +68 -12
- skfolio/optimization/convex/_base.py +13 -7
- skfolio/optimization/convex/_distributionally_robust.py +3 -3
- skfolio/optimization/convex/_maximum_diversification.py +3 -3
- skfolio/optimization/convex/_mean_risk.py +3 -3
- skfolio/optimization/convex/_risk_budgeting.py +4 -4
- skfolio/prior/_black_litterman.py +4 -1
- skfolio/utils/equations.py +166 -78
- skfolio/utils/stats.py +87 -0
- {skfolio-0.4.1.dist-info → skfolio-0.4.3.dist-info}/METADATA +2 -2
- {skfolio-0.4.1.dist-info → skfolio-0.4.3.dist-info}/RECORD +17 -17
- {skfolio-0.4.1.dist-info → skfolio-0.4.3.dist-info}/WHEEL +1 -1
- {skfolio-0.4.1.dist-info → skfolio-0.4.3.dist-info}/LICENSE +0 -0
- {skfolio-0.4.1.dist-info → skfolio-0.4.3.dist-info}/top_level.txt +0 -0
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.
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
dictionary, assets values that are not provided are assigned a
|
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.
|
100
|
-
|
101
|
-
(
|
102
|
-
|
103
|
-
using a dictionary, assets values that are not provided are assigned a
|
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
|
-
#
|
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.
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
dictionary, assets values that are not provided are assigned a
|
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.
|
118
|
-
|
119
|
-
(
|
120
|
-
|
121
|
-
using a dictionary, assets values that are not provided are assigned a
|
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.
|
104
|
-
|
105
|
-
|
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.
|
120
|
-
|
121
|
-
(
|
122
|
-
|
123
|
-
using a dictionary, assets values that are not provided are assigned a
|
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 =
|
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
|
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
|
306
|
-
* "Equity
|
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
|
-
|
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
|
741
|
+
if len(a_eq) != 0:
|
742
742
|
constraints.append(
|
743
|
-
|
744
|
-
-
|
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
|
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
|
141
|
-
* "Equity
|
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
|
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
|
217
|
-
* "Equity
|
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
|
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
|
350
|
-
* "Equity
|
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
|
-
|
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
|
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
|
233
|
-
* "Equity
|
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(
|
skfolio/utils/equations.py
CHANGED
@@ -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
|
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
|
61
|
+
* "group_1 == number * group_2"
|
48
62
|
* "group_1 <= number"
|
49
|
-
* "group_1
|
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
|
61
|
-
"3*SPX + 5*SX5E
|
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
|
-
|
80
|
-
|
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
|
-
|
83
|
-
|
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 =
|
86
|
-
equations =
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
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
|
-
|
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
|
-
|
108
|
-
|
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
|
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
|
-
|
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
|
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
|
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
|
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
|
-
|
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
|
-
|
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
|
-
|
369
|
+
comparison_sign = _comparison_operator_sign(e)
|
270
370
|
e = next(iterator, None)
|
271
|
-
if e in
|
272
|
-
sign *=
|
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 *=
|
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
|
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 *=
|
349
|
-
right *= -
|
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.
|
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
|
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=
|
49
|
-
skfolio/optimization/cluster/hierarchical/_herc.py,sha256=
|
50
|
-
skfolio/optimization/cluster/hierarchical/_hrp.py,sha256=
|
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
|
53
|
-
skfolio/optimization/convex/_distributionally_robust.py,sha256=
|
54
|
-
skfolio/optimization/convex/_maximum_diversification.py,sha256=
|
55
|
-
skfolio/optimization/convex/_mean_risk.py,sha256=
|
56
|
-
skfolio/optimization/convex/_risk_budgeting.py,sha256=
|
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=
|
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=
|
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=
|
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.
|
88
|
-
skfolio-0.4.
|
89
|
-
skfolio-0.4.
|
90
|
-
skfolio-0.4.
|
91
|
-
skfolio-0.4.
|
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,,
|
File without changes
|
File without changes
|