skfolio 0.5.2__tar.gz → 0.6.0__tar.gz
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-0.5.2/src/skfolio.egg-info → skfolio-0.6.0}/PKG-INFO +3 -1
- {skfolio-0.5.2 → skfolio-0.6.0}/pyproject.toml +3 -1
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/optimization/convex/_base.py +342 -30
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/optimization/convex/_maximum_diversification.py +1 -1
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/optimization/convex/_mean_risk.py +120 -15
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/utils/equations.py +58 -1
- {skfolio-0.5.2 → skfolio-0.6.0/src/skfolio.egg-info}/PKG-INFO +3 -1
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio.egg-info/requires.txt +2 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/LICENSE +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/MANIFEST.in +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/README.rst +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/setup.cfg +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/__init__.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/cluster/__init__.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/cluster/_hierarchical.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/datasets/__init__.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/datasets/_base.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/datasets/data/__init__.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/datasets/data/factors_dataset.csv.gz +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/datasets/data/sp500_dataset.csv.gz +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/datasets/data/sp500_index.csv.gz +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/distance/__init__.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/distance/_base.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/distance/_distance.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/exceptions.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/measures/__init__.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/measures/_enums.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/measures/_measures.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/metrics/__init__.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/metrics/_scorer.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/model_selection/__init__.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/model_selection/_combinatorial.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/model_selection/_validation.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/model_selection/_walk_forward.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/moments/__init__.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/moments/covariance/__init__.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/moments/covariance/_base.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/moments/covariance/_denoise_covariance.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/moments/covariance/_detone_covariance.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/moments/covariance/_empirical_covariance.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/moments/covariance/_ew_covariance.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/moments/covariance/_gerber_covariance.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/moments/covariance/_graphical_lasso_cv.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/moments/covariance/_implied_covariance.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/moments/covariance/_ledoit_wolf.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/moments/covariance/_oas.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/moments/covariance/_shrunk_covariance.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/moments/expected_returns/__init__.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/moments/expected_returns/_base.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/moments/expected_returns/_empirical_mu.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/moments/expected_returns/_equilibrium_mu.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/moments/expected_returns/_ew_mu.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/moments/expected_returns/_shrunk_mu.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/optimization/__init__.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/optimization/_base.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/optimization/cluster/__init__.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/optimization/cluster/_nco.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/optimization/cluster/hierarchical/__init__.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/optimization/cluster/hierarchical/_base.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/optimization/cluster/hierarchical/_herc.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/optimization/cluster/hierarchical/_hrp.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/optimization/convex/__init__.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/optimization/convex/_distributionally_robust.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/optimization/convex/_risk_budgeting.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/optimization/ensemble/__init__.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/optimization/ensemble/_base.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/optimization/ensemble/_stacking.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/optimization/naive/__init__.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/optimization/naive/_naive.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/population/__init__.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/population/_population.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/portfolio/__init__.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/portfolio/_base.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/portfolio/_multi_period_portfolio.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/portfolio/_portfolio.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/pre_selection/__init__.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/pre_selection/_drop_correlated.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/pre_selection/_select_complete.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/pre_selection/_select_k_extremes.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/pre_selection/_select_non_dominated.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/pre_selection/_select_non_expiring.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/preprocessing/__init__.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/preprocessing/_returns.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/prior/__init__.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/prior/_base.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/prior/_black_litterman.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/prior/_empirical.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/prior/_factor_model.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/typing.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/uncertainty_set/__init__.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/uncertainty_set/_base.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/uncertainty_set/_bootstrap.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/uncertainty_set/_empirical.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/utils/__init__.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/utils/bootstrap.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/utils/sorting.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/utils/stats.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio/utils/tools.py +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio.egg-info/SOURCES.txt +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio.egg-info/dependency_links.txt +0 -0
- {skfolio-0.5.2 → skfolio-0.6.0}/src/skfolio.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: skfolio
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.6.0
|
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>
|
@@ -64,10 +64,12 @@ Requires-Dist: scikit-learn>=1.5.0
|
|
64
64
|
Requires-Dist: joblib>=1.3.2
|
65
65
|
Requires-Dist: plotly>=5.22.0
|
66
66
|
Provides-Extra: tests
|
67
|
+
Requires-Dist: cvxpy[SCIP]; extra == "tests"
|
67
68
|
Requires-Dist: pytest; extra == "tests"
|
68
69
|
Requires-Dist: pytest-cov; extra == "tests"
|
69
70
|
Requires-Dist: ruff; extra == "tests"
|
70
71
|
Provides-Extra: docs
|
72
|
+
Requires-Dist: cvxpy[SCIP]; extra == "docs"
|
71
73
|
Requires-Dist: Sphinx; extra == "docs"
|
72
74
|
Requires-Dist: sphinx-gallery; extra == "docs"
|
73
75
|
Requires-Dist: sphinx-design; extra == "docs"
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
4
4
|
|
5
5
|
[project]
|
6
6
|
name = "skfolio"
|
7
|
-
version = "0.
|
7
|
+
version = "0.6.0"
|
8
8
|
maintainers = [
|
9
9
|
{ name = "Hugo Delatte", email = "delatte.hugo@gmail.com" },
|
10
10
|
]
|
@@ -60,11 +60,13 @@ classifiers = [
|
|
60
60
|
|
61
61
|
[project.optional-dependencies]
|
62
62
|
tests = [
|
63
|
+
"cvxpy[SCIP]",
|
63
64
|
"pytest",
|
64
65
|
"pytest-cov",
|
65
66
|
"ruff"
|
66
67
|
]
|
67
68
|
docs = [
|
69
|
+
"cvxpy[SCIP]",
|
68
70
|
"Sphinx",
|
69
71
|
"sphinx-gallery",
|
70
72
|
"sphinx-design",
|
@@ -18,6 +18,7 @@ import numpy.typing as npt
|
|
18
18
|
import scipy as sc
|
19
19
|
import scipy.sparse.linalg as scl
|
20
20
|
import sklearn.utils.metadata_routing as skm
|
21
|
+
from cvxpy.reductions.solvers.defines import MI_SOLVERS
|
21
22
|
|
22
23
|
import skfolio.typing as skt
|
23
24
|
from skfolio.measures import RiskMeasure, owa_gmd_weights
|
@@ -28,7 +29,7 @@ from skfolio.uncertainty_set import (
|
|
28
29
|
BaseMuUncertaintySet,
|
29
30
|
UncertaintySet,
|
30
31
|
)
|
31
|
-
from skfolio.utils.equations import equations_to_matrix
|
32
|
+
from skfolio.utils.equations import equations_to_matrix, group_cardinalities_to_matrix
|
32
33
|
from skfolio.utils.tools import AutoEnum, cache_method, input_to_array
|
33
34
|
|
34
35
|
INSTALLED_SOLVERS = cp.installed_solvers()
|
@@ -169,6 +170,36 @@ class ConvexOptimization(BaseOptimization, ABC):
|
|
169
170
|
weights.
|
170
171
|
The default (`None`) means no maximum long position.
|
171
172
|
|
173
|
+
cardinality : int, optional
|
174
|
+
Specifies the cardinality constraint to limit the number of invested assets
|
175
|
+
(non-zero weights). This feature requires a mixed-integer solver. For an
|
176
|
+
open-source option, we recommend using SCIP by setting `solver="SCIP"`.
|
177
|
+
To install it, use: `pip install cvxpy[SCIP]`. For commercial solvers,
|
178
|
+
supported options include MOSEK, GUROBI, or CPLEX.
|
179
|
+
|
180
|
+
group_cardinalities : dict[str, int], optional
|
181
|
+
A dictionary specifying cardinality constraints for specific groups of assets.
|
182
|
+
The keys represent group names (strings), and the values specify the maximum
|
183
|
+
number of assets allowed in each group. You must provide the groups using the
|
184
|
+
`groups` parameter. This requires a mixed-integer solver (see `cardinality`
|
185
|
+
for more details).
|
186
|
+
|
187
|
+
threshold_long : float | dict[str, float] | array-like of shape (n_assets, ), optional
|
188
|
+
Specifies the minimum weight threshold for assets in the portfolio to be
|
189
|
+
considered as a long position. Assets with weights below this threshold
|
190
|
+
will not be included as part of the portfolio's long positions. This
|
191
|
+
constraint can help eliminate insignificant allocations.
|
192
|
+
This requires a mixed-integer solver (see `cardinality` for more details).
|
193
|
+
It follows the same format as `min_weights` and `max_weights`.
|
194
|
+
|
195
|
+
threshold_short : float | dict[str, float] | array-like of shape (n_assets, ), optional
|
196
|
+
Specifies the maximum weight threshold for assets in the portfolio to be
|
197
|
+
considered as a short position. Assets with weights above this threshold
|
198
|
+
will not be included as part of the portfolio's short positions. This
|
199
|
+
constraint can help control the magnitude of short positions.
|
200
|
+
This requires a mixed-integer solver (see `cardinality` for more details).
|
201
|
+
It follows the same format as `min_weights` and `max_weights`.
|
202
|
+
|
172
203
|
transaction_costs : float | dict[str, float] | array-like of shape (n_assets, ), default=0.0
|
173
204
|
Transaction costs of the assets. It is used to add linear transaction costs to
|
174
205
|
the optimization problem:
|
@@ -382,7 +413,7 @@ class ConvexOptimization(BaseOptimization, ABC):
|
|
382
413
|
The default (`None`) is use `{"tol_gap_abs": 1e-9, "tol_gap_rel": 1e-9}`
|
383
414
|
for the solver "CLARABEL" and the CVXPY default otherwise.
|
384
415
|
For more details about solver arguments, check the CVXPY documentation:
|
385
|
-
https://www.cvxpy.org/tutorial/
|
416
|
+
https://www.cvxpy.org/tutorial/solvers
|
386
417
|
|
387
418
|
scale_objective : float, optional
|
388
419
|
Scale each objective element by this value.
|
@@ -453,6 +484,10 @@ class ConvexOptimization(BaseOptimization, ABC):
|
|
453
484
|
max_budget: float | None = None,
|
454
485
|
max_short: float | None = None,
|
455
486
|
max_long: float | None = None,
|
487
|
+
cardinality: int | None = None,
|
488
|
+
group_cardinalities: dict[str, int] | None = None,
|
489
|
+
threshold_long: skt.MultiInput | None = None,
|
490
|
+
threshold_short: skt.MultiInput | None = None,
|
456
491
|
transaction_costs: skt.MultiInput = 0.0,
|
457
492
|
management_fees: skt.MultiInput = 0.0,
|
458
493
|
previous_weights: skt.MultiInput | None = None,
|
@@ -502,6 +537,10 @@ class ConvexOptimization(BaseOptimization, ABC):
|
|
502
537
|
self.max_budget = max_budget
|
503
538
|
self.max_short = max_short
|
504
539
|
self.max_long = max_long
|
540
|
+
self.cardinality = cardinality
|
541
|
+
self.group_cardinalities = group_cardinalities
|
542
|
+
self.threshold_long = threshold_long
|
543
|
+
self.threshold_short = threshold_short
|
505
544
|
self.min_acceptable_return = min_acceptable_return
|
506
545
|
self.transaction_costs = transaction_costs
|
507
546
|
self.management_fees = management_fees
|
@@ -648,32 +687,92 @@ class ConvexOptimization(BaseOptimization, ABC):
|
|
648
687
|
"""
|
649
688
|
constraints = []
|
650
689
|
|
651
|
-
|
690
|
+
# Clean and convert to array
|
691
|
+
min_weights = self.min_weights
|
692
|
+
max_weights = self.max_weights
|
693
|
+
threshold_long = self.threshold_long
|
694
|
+
threshold_short = self.threshold_short
|
695
|
+
groups = self.groups
|
696
|
+
|
697
|
+
if min_weights is not None:
|
652
698
|
min_weights = self._clean_input(
|
653
|
-
|
699
|
+
min_weights,
|
654
700
|
n_assets=n_assets,
|
655
701
|
fill_value=0,
|
656
702
|
name="min_weights",
|
657
703
|
)
|
658
704
|
|
705
|
+
if max_weights is not None:
|
706
|
+
max_weights = self._clean_input(
|
707
|
+
max_weights,
|
708
|
+
n_assets=n_assets,
|
709
|
+
fill_value=1,
|
710
|
+
name="max_weights",
|
711
|
+
)
|
712
|
+
|
713
|
+
if threshold_long is not None:
|
714
|
+
threshold_long = self._clean_input(
|
715
|
+
threshold_long,
|
716
|
+
n_assets=n_assets,
|
717
|
+
fill_value=0,
|
718
|
+
name="threshold_long",
|
719
|
+
)
|
720
|
+
if np.all(threshold_long == 0):
|
721
|
+
threshold_long = None
|
722
|
+
|
723
|
+
if threshold_short is not None:
|
724
|
+
threshold_short = self._clean_input(
|
725
|
+
threshold_short,
|
726
|
+
n_assets=n_assets,
|
727
|
+
fill_value=0,
|
728
|
+
name="threshold_short",
|
729
|
+
)
|
730
|
+
if np.all(threshold_short == 0):
|
731
|
+
threshold_short = None
|
732
|
+
|
733
|
+
if groups is not None:
|
734
|
+
groups = input_to_array(
|
735
|
+
items=groups,
|
736
|
+
n_assets=n_assets,
|
737
|
+
fill_value="",
|
738
|
+
dim=2,
|
739
|
+
assets_names=(
|
740
|
+
self.feature_names_in_
|
741
|
+
if hasattr(self, "feature_names_in_")
|
742
|
+
else None
|
743
|
+
),
|
744
|
+
name="groups",
|
745
|
+
)
|
746
|
+
|
747
|
+
is_mip = (
|
748
|
+
(self.cardinality is not None and self.cardinality < n_assets)
|
749
|
+
or (self.group_cardinalities is not None)
|
750
|
+
or self.threshold_long is not None
|
751
|
+
or self.threshold_short is not None
|
752
|
+
)
|
753
|
+
|
754
|
+
if is_mip and self.solver not in MI_SOLVERS:
|
755
|
+
raise ValueError(
|
756
|
+
"You are using constraints that require a mixed-integer solver and "
|
757
|
+
f"{self.solver} doesn't support MIP problems. For an open-source "
|
758
|
+
"option, we recommend using SCIP by setting `solver='SCIP'`. "
|
759
|
+
"To install it, use: `pip install cvxpy[SCIP]`. For commercial "
|
760
|
+
"solvers, supported options include MOSEK, GUROBI, or CPLEX."
|
761
|
+
)
|
762
|
+
|
763
|
+
# Constraints
|
764
|
+
if min_weights is not None:
|
659
765
|
if not allow_negative_weights and np.any(min_weights < 0):
|
660
766
|
raise ValueError(
|
661
767
|
f"{self.__class__.__name__} must have non negative `min_weights` "
|
662
768
|
f"constraint otherwise the problem becomes non-convex."
|
663
769
|
)
|
664
|
-
|
665
770
|
constraints.append(
|
666
771
|
w * self._scale_constraints
|
667
772
|
>= min_weights * factor * self._scale_constraints
|
668
773
|
)
|
669
774
|
|
670
|
-
if
|
671
|
-
max_weights = self._clean_input(
|
672
|
-
self.max_weights,
|
673
|
-
n_assets=n_assets,
|
674
|
-
fill_value=1,
|
675
|
-
name="max_weights",
|
676
|
-
)
|
775
|
+
if max_weights is not None:
|
677
776
|
constraints.append(
|
678
777
|
w * self._scale_constraints
|
679
778
|
<= max_weights * factor * self._scale_constraints
|
@@ -723,27 +822,80 @@ class ConvexOptimization(BaseOptimization, ABC):
|
|
723
822
|
== float(self.budget) * factor * self._scale_constraints
|
724
823
|
)
|
725
824
|
|
825
|
+
if is_mip:
|
826
|
+
is_short = np.any(min_weights < 0)
|
827
|
+
|
828
|
+
if max_weights is None or min_weights is None:
|
829
|
+
raise ValueError(
|
830
|
+
"'max_weights' and 'min_weights' must be provided with cardinality "
|
831
|
+
"constraint"
|
832
|
+
)
|
833
|
+
if np.all(min_weights > 0):
|
834
|
+
raise ValueError(
|
835
|
+
"Cardinality and Threshold constraint can only be applied "
|
836
|
+
"if 'min_weights' are not all strictly positive (you allow some "
|
837
|
+
"weights to be 0)"
|
838
|
+
)
|
839
|
+
|
840
|
+
if self.group_cardinalities is not None and groups is None:
|
841
|
+
raise ValueError(
|
842
|
+
"When 'group_cardinalities' is provided, you must also "
|
843
|
+
"also provide 'groups'"
|
844
|
+
)
|
845
|
+
|
846
|
+
if (
|
847
|
+
self.threshold_long is not None
|
848
|
+
and self.threshold_short is None
|
849
|
+
and is_short
|
850
|
+
):
|
851
|
+
raise ValueError(
|
852
|
+
"When 'threshold_long' is provided and 'min_weights' can be negative "
|
853
|
+
"(short position are allowed), then 'threshold_short' must also be "
|
854
|
+
"provided"
|
855
|
+
)
|
856
|
+
|
857
|
+
if threshold_short is not None and threshold_long is None:
|
858
|
+
raise ValueError(
|
859
|
+
"When 'threshold_short' is provided, 'threshold_long' must also be "
|
860
|
+
"provided"
|
861
|
+
)
|
862
|
+
|
863
|
+
if self.threshold_short is not None and is_short:
|
864
|
+
constraints += _mip_weight_constraints_threshold_short(
|
865
|
+
n_assets=n_assets,
|
866
|
+
w=w,
|
867
|
+
factor=factor,
|
868
|
+
scale_constraints=self._scale_constraints,
|
869
|
+
cardinality=self.cardinality,
|
870
|
+
group_cardinalities=self.group_cardinalities,
|
871
|
+
max_weights=max_weights,
|
872
|
+
groups=groups,
|
873
|
+
min_weights=min_weights,
|
874
|
+
threshold_long=threshold_long,
|
875
|
+
threshold_short=threshold_short,
|
876
|
+
)
|
877
|
+
else:
|
878
|
+
constraints += _mip_weight_constraints_no_short_threshold(
|
879
|
+
n_assets=n_assets,
|
880
|
+
w=w,
|
881
|
+
factor=factor,
|
882
|
+
scale_constraints=self._scale_constraints,
|
883
|
+
cardinality=self.cardinality,
|
884
|
+
group_cardinalities=self.group_cardinalities,
|
885
|
+
max_weights=max_weights,
|
886
|
+
groups=groups,
|
887
|
+
min_weights=min_weights,
|
888
|
+
threshold_long=threshold_long,
|
889
|
+
)
|
890
|
+
|
726
891
|
if self.linear_constraints is not None:
|
727
|
-
if
|
892
|
+
if groups is None:
|
728
893
|
if not hasattr(self, "feature_names_in_"):
|
729
894
|
raise ValueError(
|
730
895
|
"If `linear_constraints` is provided you must provide either"
|
731
896
|
" `groups` or `X` as a DataFrame with asset names in columns"
|
732
897
|
)
|
733
898
|
groups = np.asarray([self.feature_names_in_])
|
734
|
-
else:
|
735
|
-
groups = input_to_array(
|
736
|
-
items=self.groups,
|
737
|
-
n_assets=n_assets,
|
738
|
-
fill_value="",
|
739
|
-
dim=2,
|
740
|
-
assets_names=(
|
741
|
-
self.feature_names_in_
|
742
|
-
if hasattr(self, "feature_names_in_")
|
743
|
-
else None
|
744
|
-
),
|
745
|
-
name="groups",
|
746
|
-
)
|
747
899
|
a_eq, b_eq, a_ineq, b_ineq = equations_to_matrix(
|
748
900
|
groups=groups,
|
749
901
|
equations=self.linear_constraints,
|
@@ -975,6 +1127,8 @@ class ConvexOptimization(BaseOptimization, ABC):
|
|
975
1127
|
weights = w.value / factor.value
|
976
1128
|
problem_values = {
|
977
1129
|
name: expression.value / factor.value
|
1130
|
+
if name != "factor"
|
1131
|
+
else expression.value
|
978
1132
|
for name, expression in expressions.items()
|
979
1133
|
}
|
980
1134
|
problem_values["objective"] = (
|
@@ -1000,7 +1154,7 @@ class ConvexOptimization(BaseOptimization, ABC):
|
|
1000
1154
|
if len(params_string) != 0:
|
1001
1155
|
params_string = f" with parameters {params_string}"
|
1002
1156
|
msg = (
|
1003
|
-
f"Solver '{self.solver}' failed
|
1157
|
+
f"Solver '{self.solver}' failed{params_string}. Try another"
|
1004
1158
|
" solver, or solve with solver_params=dict(verbose=True) for more"
|
1005
1159
|
" information"
|
1006
1160
|
)
|
@@ -1525,8 +1679,8 @@ class ConvexOptimization(BaseOptimization, ABC):
|
|
1525
1679
|
n_assets = prior_model.returns.shape[1]
|
1526
1680
|
x = cp.Variable((n_assets, n_assets), symmetric=True)
|
1527
1681
|
y = cp.Variable((n_assets, n_assets), symmetric=True)
|
1528
|
-
w_reshaped = cp.reshape(w, (n_assets, 1))
|
1529
|
-
factor_reshaped = cp.reshape(factor, (1, 1))
|
1682
|
+
w_reshaped = cp.reshape(w, (n_assets, 1), order="F")
|
1683
|
+
factor_reshaped = cp.reshape(factor, (1, 1), order="F")
|
1530
1684
|
z1 = cp.vstack([x, w_reshaped.T])
|
1531
1685
|
z2 = cp.vstack([w_reshaped, factor_reshaped])
|
1532
1686
|
|
@@ -1972,7 +2126,7 @@ class ConvexOptimization(BaseOptimization, ABC):
|
|
1972
2126
|
ptf_returns * self._scale_constraints
|
1973
2127
|
- ptf_transaction_cost * self._scale_constraints
|
1974
2128
|
- ptf_management_fee * self._scale_constraints
|
1975
|
-
== cp.reshape(z, (observation_nb,)) * self._scale_constraints,
|
2129
|
+
== cp.reshape(z, (observation_nb,), order="F") * self._scale_constraints,
|
1976
2130
|
z @ gmd_w.T <= ones @ x.T + y @ ones.T,
|
1977
2131
|
]
|
1978
2132
|
return risk, constraints
|
@@ -1988,3 +2142,161 @@ class ConvexOptimization(BaseOptimization, ABC):
|
|
1988
2142
|
@abstractmethod
|
1989
2143
|
def fit(self, X: npt.ArrayLike, y: npt.ArrayLike | None = None, **fit_params):
|
1990
2144
|
pass
|
2145
|
+
|
2146
|
+
|
2147
|
+
def _mip_weight_constraints_no_short_threshold(
|
2148
|
+
n_assets: int,
|
2149
|
+
w: cp.Variable,
|
2150
|
+
factor: skt.Factor,
|
2151
|
+
scale_constraints: cp.Constant,
|
2152
|
+
cardinality: int | None,
|
2153
|
+
group_cardinalities: dict[str, int] | None,
|
2154
|
+
max_weights: np.ndarray | None,
|
2155
|
+
groups: np.ndarray | None,
|
2156
|
+
min_weights: np.ndarray | None,
|
2157
|
+
threshold_long: np.ndarray | None,
|
2158
|
+
) -> list[cp.Expression]:
|
2159
|
+
"""
|
2160
|
+
Create a list of MIP constraints for cardinality and threshold conditions
|
2161
|
+
when no short threshold is present. This only requires the creation of a single
|
2162
|
+
boolean variable array.
|
2163
|
+
"""
|
2164
|
+
constraints = []
|
2165
|
+
|
2166
|
+
is_short = np.any(min_weights < 0)
|
2167
|
+
|
2168
|
+
is_invested_bool = cp.Variable(n_assets, boolean=True)
|
2169
|
+
|
2170
|
+
if cardinality is not None and cardinality < n_assets:
|
2171
|
+
constraints.append(cp.sum(is_invested_bool) <= cardinality)
|
2172
|
+
|
2173
|
+
if group_cardinalities is not None:
|
2174
|
+
a_card, b_card = group_cardinalities_to_matrix(
|
2175
|
+
groups=groups,
|
2176
|
+
group_cardinalities=group_cardinalities,
|
2177
|
+
raise_if_group_missing=False,
|
2178
|
+
)
|
2179
|
+
constraints.append(a_card @ is_invested_bool - b_card <= 0)
|
2180
|
+
|
2181
|
+
if isinstance(factor, cp.Variable):
|
2182
|
+
is_invested_factor = cp.Variable(n_assets, nonneg=True)
|
2183
|
+
# We want (w <= cp.multiply(is_invested_short_bool, max_weights) * factor
|
2184
|
+
# but this is not DCP. So we introduce another variable and set
|
2185
|
+
# constraint to ensure its value is equal to is_invested_short_bool * factor
|
2186
|
+
|
2187
|
+
M = 1e3
|
2188
|
+
# Big M method to activate or deactivate constraints
|
2189
|
+
# In the ratio homogenization procedure, the factor has been calibrated
|
2190
|
+
# to be around 0.1-10. By using M=1e3, we ensure that M is large enough while
|
2191
|
+
# not too large for improved MIP convergence.
|
2192
|
+
|
2193
|
+
constraints += [
|
2194
|
+
is_invested_factor <= factor,
|
2195
|
+
is_invested_factor <= M * is_invested_bool,
|
2196
|
+
is_invested_factor >= factor - M * (1 - is_invested_bool),
|
2197
|
+
]
|
2198
|
+
is_invested = is_invested_factor
|
2199
|
+
else:
|
2200
|
+
is_invested = is_invested_bool
|
2201
|
+
|
2202
|
+
if threshold_long is not None:
|
2203
|
+
constraints.append(
|
2204
|
+
w * scale_constraints
|
2205
|
+
>= cp.multiply(is_invested, threshold_long) * scale_constraints
|
2206
|
+
)
|
2207
|
+
|
2208
|
+
constraints.append(
|
2209
|
+
w * scale_constraints
|
2210
|
+
<= cp.multiply(is_invested, max_weights) * scale_constraints
|
2211
|
+
)
|
2212
|
+
|
2213
|
+
if is_short:
|
2214
|
+
constraints.append(
|
2215
|
+
w * scale_constraints
|
2216
|
+
>= cp.multiply(is_invested, min_weights) * scale_constraints
|
2217
|
+
)
|
2218
|
+
|
2219
|
+
return constraints
|
2220
|
+
|
2221
|
+
|
2222
|
+
def _mip_weight_constraints_threshold_short(
|
2223
|
+
n_assets: int,
|
2224
|
+
w: cp.Variable,
|
2225
|
+
factor: skt.Factor,
|
2226
|
+
scale_constraints: cp.Constant,
|
2227
|
+
max_weights: np.ndarray,
|
2228
|
+
min_weights: np.ndarray,
|
2229
|
+
threshold_long: np.ndarray,
|
2230
|
+
threshold_short: np.ndarray,
|
2231
|
+
cardinality: int | None,
|
2232
|
+
group_cardinalities: dict[str, int] | None,
|
2233
|
+
groups: np.ndarray | None,
|
2234
|
+
) -> list[cp.Expression]:
|
2235
|
+
"""
|
2236
|
+
Create a list of MIP constraints for cardinality and threshold constraints
|
2237
|
+
when a short threshold is allowed. This requires the creation of two boolean
|
2238
|
+
variable arrays, one for long positions and one for short positions.
|
2239
|
+
"""
|
2240
|
+
constraints = []
|
2241
|
+
|
2242
|
+
is_invested_short_bool = cp.Variable(n_assets, boolean=True)
|
2243
|
+
is_invested_long_bool = cp.Variable(n_assets, boolean=True)
|
2244
|
+
is_invested_bool = is_invested_short_bool + is_invested_long_bool
|
2245
|
+
|
2246
|
+
if cardinality is not None and cardinality < n_assets:
|
2247
|
+
constraints.append(cp.sum(is_invested_bool) <= cardinality)
|
2248
|
+
|
2249
|
+
if group_cardinalities is not None:
|
2250
|
+
a_card, b_card = group_cardinalities_to_matrix(
|
2251
|
+
groups=groups,
|
2252
|
+
group_cardinalities=group_cardinalities,
|
2253
|
+
raise_if_group_missing=False,
|
2254
|
+
)
|
2255
|
+
constraints.append(a_card @ is_invested_bool - b_card <= 0)
|
2256
|
+
|
2257
|
+
M = 1e3
|
2258
|
+
# Big M method to activate or deactivate constraints
|
2259
|
+
# In the ratio homogenization procedure, the factor has been calibrated
|
2260
|
+
# to be around 0.1-10. By using M=1e3, we ensure that M is large enough while
|
2261
|
+
# not too large for improved MIP convergence.
|
2262
|
+
|
2263
|
+
if isinstance(factor, cp.Variable):
|
2264
|
+
is_invested_short_factor = cp.Variable(n_assets, nonneg=True)
|
2265
|
+
is_invested_long_factor = cp.Variable(n_assets, nonneg=True)
|
2266
|
+
# We want (w <= cp.multiply(is_invested_short_bool, max_weights) * factor
|
2267
|
+
# but this is not DCP. So we introduce another variable and set
|
2268
|
+
# constraint to ensure its value is equal to is_invested_short_bool * factor
|
2269
|
+
|
2270
|
+
constraints += [
|
2271
|
+
is_invested_short_factor <= factor,
|
2272
|
+
is_invested_long_factor <= factor,
|
2273
|
+
is_invested_short_factor <= M * is_invested_short_bool,
|
2274
|
+
is_invested_long_factor <= M * is_invested_long_bool,
|
2275
|
+
is_invested_short_factor >= factor - M * (1 - is_invested_short_bool),
|
2276
|
+
is_invested_long_factor >= factor - M * (1 - is_invested_long_bool),
|
2277
|
+
]
|
2278
|
+
is_invested_short = is_invested_short_factor
|
2279
|
+
is_invested_long = is_invested_long_factor
|
2280
|
+
else:
|
2281
|
+
is_invested_short = is_invested_short_bool
|
2282
|
+
is_invested_long = is_invested_long_bool
|
2283
|
+
|
2284
|
+
constraints += [
|
2285
|
+
is_invested_bool <= 1.0,
|
2286
|
+
w * scale_constraints
|
2287
|
+
<= cp.multiply(is_invested_long, max_weights) * scale_constraints,
|
2288
|
+
w * scale_constraints
|
2289
|
+
>= cp.multiply(is_invested_short, min_weights) * scale_constraints,
|
2290
|
+
# Apply threshold_long if is_invested_long == 1,
|
2291
|
+
# unrestricted if is_invested_long == 0
|
2292
|
+
w * scale_constraints
|
2293
|
+
>= cp.multiply(is_invested_long, threshold_long) * scale_constraints
|
2294
|
+
- M * (1 - is_invested_long_bool) * scale_constraints,
|
2295
|
+
# # Apply threshold_short if is_invested_short == 1,
|
2296
|
+
# # unrestricted if is_invested_short == 0
|
2297
|
+
w * scale_constraints
|
2298
|
+
<= cp.multiply(is_invested_short, threshold_short) * scale_constraints
|
2299
|
+
+ M * (1 - is_invested_short_bool) * scale_constraints,
|
2300
|
+
]
|
2301
|
+
|
2302
|
+
return constraints
|
@@ -364,7 +364,7 @@ class MaximumDiversification(MeanRisk):
|
|
364
364
|
):
|
365
365
|
super().__init__(
|
366
366
|
objective_function=ObjectiveFunction.MAXIMIZE_RATIO,
|
367
|
-
risk_measure=RiskMeasure.
|
367
|
+
risk_measure=RiskMeasure.STANDARD_DEVIATION,
|
368
368
|
prior_estimator=prior_estimator,
|
369
369
|
min_weights=min_weights,
|
370
370
|
max_weights=max_weights,
|
@@ -1,11 +1,12 @@
|
|
1
1
|
"""Mean Risk Optimization estimator."""
|
2
2
|
|
3
|
+
import warnings
|
4
|
+
|
3
5
|
# Copyright (c) 2023
|
4
6
|
# Author: Hugo Delatte <delatte.hugo@gmail.com>
|
5
7
|
# License: BSD 3 clause
|
6
8
|
# The optimization features are derived
|
7
9
|
# from Riskfolio-Lib, Copyright (c) 2020-2023, Dany Cajas, Licensed under BSD 3 clause.
|
8
|
-
|
9
10
|
import cvxpy as cp
|
10
11
|
import numpy as np
|
11
12
|
import numpy.typing as npt
|
@@ -144,6 +145,11 @@ class MeanRisk(ConvexOptimization):
|
|
144
145
|
returns and Cholesky decomposition of the covariance.
|
145
146
|
The default (`None`) is to use :class:`~skfolio.prior.EmpiricalPrior`.
|
146
147
|
|
148
|
+
efficient_frontier_size : int, optional
|
149
|
+
If provided, it represents the number of Pareto-optimal portfolios along the
|
150
|
+
efficient frontier to be computed. This parameter can only be used with
|
151
|
+
`objective_function = ObjectiveFunction.MINIMIZE_RISK`.
|
152
|
+
|
147
153
|
min_weights : float | dict[str, float] | array-like of shape (n_assets, ) | None, default=0.0
|
148
154
|
Minimum assets weights (weights lower bounds).
|
149
155
|
If a float is provided, it is applied to each asset.
|
@@ -213,6 +219,36 @@ class MeanRisk(ConvexOptimization):
|
|
213
219
|
weights.
|
214
220
|
The default (`None`) means no maximum long position.
|
215
221
|
|
222
|
+
cardinality : int, optional
|
223
|
+
Specifies the cardinality constraint to limit the number of invested assets
|
224
|
+
(non-zero weights). This feature requires a mixed-integer solver. For an
|
225
|
+
open-source option, we recommend using SCIP by setting `solver="SCIP"`.
|
226
|
+
To install it, use: `pip install cvxpy[SCIP]`. For commercial solvers,
|
227
|
+
supported options include MOSEK, GUROBI, or CPLEX.
|
228
|
+
|
229
|
+
group_cardinalities : dict[str, int], optional
|
230
|
+
A dictionary specifying cardinality constraints for specific groups of assets.
|
231
|
+
The keys represent group names (strings), and the values specify the maximum
|
232
|
+
number of assets allowed in each group. You must provide the groups using the
|
233
|
+
`groups` parameter. This requires a mixed-integer solver (see `cardinality`
|
234
|
+
for more details).
|
235
|
+
|
236
|
+
threshold_long : float | dict[str, float] | array-like of shape (n_assets, ), optional
|
237
|
+
Specifies the minimum weight threshold for assets in the portfolio to be
|
238
|
+
considered as a long position. Assets with weights below this threshold
|
239
|
+
will not be included as part of the portfolio's long positions. This
|
240
|
+
constraint can help eliminate insignificant allocations.
|
241
|
+
This requires a mixed-integer solver (see `cardinality` for more details).
|
242
|
+
It follows the same format as `min_weights` and `max_weights`.
|
243
|
+
|
244
|
+
threshold_short : float | dict[str, float] | array-like of shape (n_assets, ), optional
|
245
|
+
Specifies the maximum weight threshold for assets in the portfolio to be
|
246
|
+
considered as a short position. Assets with weights above this threshold
|
247
|
+
will not be included as part of the portfolio's short positions. This
|
248
|
+
constraint can help control the magnitude of short positions.
|
249
|
+
This requires a mixed-integer solver (see `cardinality` for more details).
|
250
|
+
It follows the same format as `min_weights` and `max_weights`.
|
251
|
+
|
216
252
|
transaction_costs : float | dict[str, float] | array-like of shape (n_assets, ), default=0.0
|
217
253
|
Transaction costs of the assets. It is used to add linear transaction costs to
|
218
254
|
the optimization problem:
|
@@ -486,9 +522,10 @@ class MeanRisk(ConvexOptimization):
|
|
486
522
|
solver_params : dict, optional
|
487
523
|
Solver parameters. For example, `solver_params=dict(verbose=True)`.
|
488
524
|
The default (`None`) is use `{"tol_gap_abs": 1e-9, "tol_gap_rel": 1e-9}`
|
489
|
-
for
|
525
|
+
for "CLARABEL", `{"numerics/feastol": 1e-8, "limits/gap": 1e-8}` for SCIP
|
526
|
+
and the solver default otherwise.
|
490
527
|
For more details about solver arguments, check the CVXPY documentation:
|
491
|
-
https://www.cvxpy.org/tutorial/
|
528
|
+
https://www.cvxpy.org/tutorial/solvers
|
492
529
|
|
493
530
|
scale_objective : float, optional
|
494
531
|
Scale each objective element by this value.
|
@@ -511,7 +548,7 @@ class MeanRisk(ConvexOptimization):
|
|
511
548
|
portfolio_params : dict, optional
|
512
549
|
Portfolio parameters passed to the portfolio evaluated by the `predict` and
|
513
550
|
`score` methods. If not provided, the `name`, `transaction_costs`,
|
514
|
-
`management_fees`, `previous_weights` and `risk_free_rate` are copied from the
|
551
|
+
`management_fees`, `previous_weights` and `risk_free_rate` are copied from the
|
515
552
|
optimization model and passed to the portfolio.
|
516
553
|
|
517
554
|
Attributes
|
@@ -557,6 +594,10 @@ class MeanRisk(ConvexOptimization):
|
|
557
594
|
max_budget: float | None = None,
|
558
595
|
max_short: float | None = None,
|
559
596
|
max_long: float | None = None,
|
597
|
+
cardinality: int | None = None,
|
598
|
+
group_cardinalities: dict[str, int] | None = None,
|
599
|
+
threshold_long: skt.MultiInput | None = None,
|
600
|
+
threshold_short: skt.MultiInput | None = None,
|
560
601
|
transaction_costs: skt.MultiInput = 0.0,
|
561
602
|
management_fees: skt.MultiInput = 0.0,
|
562
603
|
previous_weights: skt.MultiInput | None = None,
|
@@ -617,6 +658,10 @@ class MeanRisk(ConvexOptimization):
|
|
617
658
|
max_budget=max_budget,
|
618
659
|
max_short=max_short,
|
619
660
|
max_long=max_long,
|
661
|
+
cardinality=cardinality,
|
662
|
+
group_cardinalities=group_cardinalities,
|
663
|
+
threshold_long=threshold_long,
|
664
|
+
threshold_short=threshold_short,
|
620
665
|
transaction_costs=transaction_costs,
|
621
666
|
management_fees=management_fees,
|
622
667
|
previous_weights=previous_weights,
|
@@ -734,13 +779,42 @@ class MeanRisk(ConvexOptimization):
|
|
734
779
|
n_observations, n_assets = prior_model.returns.shape
|
735
780
|
|
736
781
|
# set solvers params
|
737
|
-
|
738
|
-
|
739
|
-
|
740
|
-
|
782
|
+
match self.solver:
|
783
|
+
case "CLARABEL":
|
784
|
+
self._set_solver_params(
|
785
|
+
default={"tol_gap_abs": 1e-9, "tol_gap_rel": 1e-9}
|
786
|
+
)
|
787
|
+
case "SCIP":
|
788
|
+
self._set_solver_params(
|
789
|
+
default={"numerics/feastol": 1e-8, "limits/gap": 1e-8}
|
790
|
+
)
|
791
|
+
case _:
|
792
|
+
self._set_solver_params(default=None)
|
741
793
|
|
742
|
-
# set scales
|
794
|
+
# set scales and check measure
|
743
795
|
if self.objective_function == ObjectiveFunction.MAXIMIZE_RATIO:
|
796
|
+
if self.overwrite_expected_return is not None:
|
797
|
+
if self.risk_measure == RiskMeasure.VARIANCE:
|
798
|
+
warnings.warn(
|
799
|
+
"When selecting 'MAXIMIZE_RATIO' with 'VARIANCE', the "
|
800
|
+
"optimization will return the maximum Sharpe Ratio portfolio. "
|
801
|
+
"This is because the mean/variance ratio is not a "
|
802
|
+
"1-homogeneous function, unlike the mean/std. To suppress this"
|
803
|
+
"warning, replace 'VARIANCE' by 'STANDARD_DEVIATION'",
|
804
|
+
stacklevel=2,
|
805
|
+
)
|
806
|
+
|
807
|
+
elif self.risk_measure == RiskMeasure.SEMI_VARIANCE:
|
808
|
+
warnings.warn(
|
809
|
+
"When selecting 'MAXIMIZE_RATIO' with 'SEMI_VARIANCE', the "
|
810
|
+
"optimization will return the maximum Sortino Ratio portfolio. "
|
811
|
+
"This is because the mean/semi-variance ratio is not a "
|
812
|
+
"1-homogeneous function, unlike the mean/semi-std ratio. To "
|
813
|
+
"suppress this warning, replace 'SEMI_VARIANCE' by "
|
814
|
+
"'SEMI_DEVIATION'",
|
815
|
+
stacklevel=2,
|
816
|
+
)
|
817
|
+
|
744
818
|
self._set_scale_objective(default=1)
|
745
819
|
self._set_scale_constraints(default=1)
|
746
820
|
else:
|
@@ -959,31 +1033,38 @@ class MeanRisk(ConvexOptimization):
|
|
959
1033
|
+ custom_objective * self._scale_objective
|
960
1034
|
)
|
961
1035
|
case ObjectiveFunction.MAXIMIZE_RATIO:
|
1036
|
+
homogenization_factor = _optimal_homogenization_factor(
|
1037
|
+
mu=prior_model.mu
|
1038
|
+
)
|
1039
|
+
|
962
1040
|
if expected_return.is_affine():
|
963
1041
|
# Charnes-Cooper's variable transformation for Fractional
|
964
|
-
# Programming problem
|
1042
|
+
# Programming problem Max(f1/f2) with f2 linear and with
|
1043
|
+
# 1-homogeneous function (homogeneous technique)
|
965
1044
|
constraints += [
|
966
1045
|
expected_return * self._scale_constraints
|
967
1046
|
- cp.Constant(self.risk_free_rate)
|
968
1047
|
* factor
|
969
1048
|
* self._scale_constraints
|
970
|
-
== cp.Constant(
|
1049
|
+
== cp.Constant(homogenization_factor) * self._scale_constraints
|
971
1050
|
]
|
972
1051
|
else:
|
973
1052
|
# Schaible's generalization of Charnes-Cooper's variable
|
974
1053
|
# transformation for Fractional Programming problem :Max(f1/f2)
|
975
|
-
# with f1 concave instead of linear
|
976
|
-
#
|
977
|
-
#
|
1054
|
+
# with f1 concave instead of linear and with 1-homogeneous function.
|
1055
|
+
# (homogeneous technique)
|
1056
|
+
# Schaible,"Parameter-free Convex Equivalent and Dual Programs of
|
1057
|
+
# Fractional Programming Problems".
|
978
1058
|
# The condition to work is f1 >= 0, so we need to raise an user
|
979
1059
|
# warning when it's not the case.
|
980
1060
|
# TODO: raise user warning when f1<0
|
1061
|
+
|
981
1062
|
constraints += [
|
982
1063
|
expected_return * self._scale_constraints
|
983
1064
|
- cp.Constant(self.risk_free_rate)
|
984
1065
|
* factor
|
985
1066
|
* self._scale_constraints
|
986
|
-
>= cp.Constant(
|
1067
|
+
>= cp.Constant(homogenization_factor) * self._scale_constraints
|
987
1068
|
]
|
988
1069
|
objective = cp.Minimize(
|
989
1070
|
risk * self._scale_objective
|
@@ -1014,3 +1095,27 @@ class MeanRisk(ConvexOptimization):
|
|
1014
1095
|
)
|
1015
1096
|
|
1016
1097
|
return self
|
1098
|
+
|
1099
|
+
|
1100
|
+
def _optimal_homogenization_factor(mu: np.ndarray) -> float:
|
1101
|
+
"""
|
1102
|
+
Compute the optimal homogenization factor for ratio optimization based on expected
|
1103
|
+
returns.
|
1104
|
+
|
1105
|
+
While a default value of 1 is commonly used in textbooks for simplicity,
|
1106
|
+
fine-tuning this factor based on the underlying data can enhance convergence.
|
1107
|
+
Additionally, using a data-driven approach to determine this factor can improve the
|
1108
|
+
robustness of certain constraints, such as the calibration of big M methods.
|
1109
|
+
|
1110
|
+
Parameters
|
1111
|
+
----------
|
1112
|
+
mu : ndarray of shape (n_assets,)
|
1113
|
+
Vector of expected returns.
|
1114
|
+
|
1115
|
+
Returns
|
1116
|
+
-------
|
1117
|
+
value : float
|
1118
|
+
Homogenization factor.
|
1119
|
+
"""
|
1120
|
+
|
1121
|
+
return min(1e3, max(1e-3, np.mean(np.abs(mu))))
|
@@ -16,7 +16,7 @@ from skfolio.exceptions import (
|
|
16
16
|
GroupNotFoundError,
|
17
17
|
)
|
18
18
|
|
19
|
-
__all__ = ["equations_to_matrix"]
|
19
|
+
__all__ = ["equations_to_matrix", "group_cardinalities_to_matrix"]
|
20
20
|
|
21
21
|
_EQUALITY_OPERATORS = {"==", "="}
|
22
22
|
_INEQUALITY_OPERATORS = {">=", "<="}
|
@@ -132,6 +132,63 @@ def equations_to_matrix(
|
|
132
132
|
)
|
133
133
|
|
134
134
|
|
135
|
+
def group_cardinalities_to_matrix(
|
136
|
+
groups: npt.ArrayLike,
|
137
|
+
group_cardinalities: dict[str, int],
|
138
|
+
raise_if_group_missing: bool = False,
|
139
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
140
|
+
"""Convert a list of linear equations into the left and right matrices of the
|
141
|
+
inequality A <= B and equality A == B.
|
142
|
+
|
143
|
+
Parameters
|
144
|
+
----------
|
145
|
+
groups : array-like of shape (n_groups, n_assets)
|
146
|
+
2D array of assets groups.
|
147
|
+
|
148
|
+
Examples:
|
149
|
+
groups = np.array(
|
150
|
+
[
|
151
|
+
["Equity", "Equity", "Equity", "Bond"],
|
152
|
+
["US", "Europe", "Japan", "US"],
|
153
|
+
]
|
154
|
+
)
|
155
|
+
|
156
|
+
group_cardinalities : dict[str, int]
|
157
|
+
Dictionary of cardinality constraint per group.
|
158
|
+
Examples: {"Equity": 1, "US": 3}
|
159
|
+
|
160
|
+
raise_if_group_missing : bool, default=False
|
161
|
+
If this is set to True, an error is raised when a group is not found in the
|
162
|
+
groups, otherwise only a warning is shown.
|
163
|
+
The default is False.
|
164
|
+
|
165
|
+
Returns
|
166
|
+
-------
|
167
|
+
left_inequality: ndarray of shape (n_constraints, n_assets)
|
168
|
+
right_inequality: ndarray of shape (n_constraints,)
|
169
|
+
The left and right matrices of the cardinality inequality.
|
170
|
+
"""
|
171
|
+
groups = _validate_groups(groups, name="group")
|
172
|
+
|
173
|
+
a_inequality = []
|
174
|
+
b_inequality = []
|
175
|
+
|
176
|
+
for group, card in group_cardinalities.items():
|
177
|
+
try:
|
178
|
+
arr = _matching_array(values=groups, key=group, sum_to_one=False)
|
179
|
+
a_inequality.append(arr)
|
180
|
+
b_inequality.append(card)
|
181
|
+
|
182
|
+
except GroupNotFoundError as e:
|
183
|
+
if raise_if_group_missing:
|
184
|
+
raise
|
185
|
+
warnings.warn(str(e), stacklevel=2)
|
186
|
+
return (
|
187
|
+
np.array(a_inequality),
|
188
|
+
np.array(b_inequality),
|
189
|
+
)
|
190
|
+
|
191
|
+
|
135
192
|
def _validate_groups(groups: npt.ArrayLike, name: str = "groups") -> np.ndarray:
|
136
193
|
"""Validate groups by checking its dim and if group names don't appear in multiple
|
137
194
|
levels and convert to numpy array.
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: skfolio
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.6.0
|
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>
|
@@ -64,10 +64,12 @@ Requires-Dist: scikit-learn>=1.5.0
|
|
64
64
|
Requires-Dist: joblib>=1.3.2
|
65
65
|
Requires-Dist: plotly>=5.22.0
|
66
66
|
Provides-Extra: tests
|
67
|
+
Requires-Dist: cvxpy[SCIP]; extra == "tests"
|
67
68
|
Requires-Dist: pytest; extra == "tests"
|
68
69
|
Requires-Dist: pytest-cov; extra == "tests"
|
69
70
|
Requires-Dist: ruff; extra == "tests"
|
70
71
|
Provides-Extra: docs
|
72
|
+
Requires-Dist: cvxpy[SCIP]; extra == "docs"
|
71
73
|
Requires-Dist: Sphinx; extra == "docs"
|
72
74
|
Requires-Dist: sphinx-gallery; extra == "docs"
|
73
75
|
Requires-Dist: sphinx-design; extra == "docs"
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|