skfolio 0.5.2__py3-none-any.whl → 0.7.0__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/__init__.py +5 -5
- skfolio/cluster/__init__.py +1 -1
- skfolio/cluster/_hierarchical.py +1 -1
- skfolio/datasets/__init__.py +2 -2
- skfolio/distance/__init__.py +3 -3
- skfolio/distance/_distance.py +7 -6
- skfolio/exceptions.py +2 -2
- skfolio/measures/__init__.py +23 -23
- skfolio/model_selection/__init__.py +2 -2
- skfolio/moments/__init__.py +11 -11
- skfolio/moments/covariance/__init__.py +6 -6
- skfolio/moments/covariance/_denoise_covariance.py +2 -1
- skfolio/moments/covariance/_detone_covariance.py +2 -1
- skfolio/moments/covariance/_empirical_covariance.py +2 -1
- skfolio/moments/covariance/_ew_covariance.py +2 -1
- skfolio/moments/covariance/_gerber_covariance.py +2 -1
- skfolio/moments/covariance/_implied_covariance.py +1 -1
- skfolio/moments/expected_returns/__init__.py +2 -2
- skfolio/moments/expected_returns/_empirical_mu.py +2 -1
- skfolio/moments/expected_returns/_equilibrium_mu.py +2 -1
- skfolio/moments/expected_returns/_ew_mu.py +2 -1
- skfolio/moments/expected_returns/_shrunk_mu.py +2 -1
- skfolio/optimization/__init__.py +10 -10
- skfolio/optimization/cluster/__init__.py +1 -1
- skfolio/optimization/cluster/_nco.py +3 -2
- skfolio/optimization/cluster/hierarchical/__init__.py +1 -1
- skfolio/optimization/cluster/hierarchical/_herc.py +2 -1
- skfolio/optimization/cluster/hierarchical/_hrp.py +2 -1
- skfolio/optimization/convex/__init__.py +3 -3
- skfolio/optimization/convex/_base.py +344 -31
- skfolio/optimization/convex/_distributionally_robust.py +4 -1
- skfolio/optimization/convex/_maximum_diversification.py +4 -2
- skfolio/optimization/convex/_mean_risk.py +125 -17
- skfolio/optimization/convex/_risk_budgeting.py +3 -1
- skfolio/optimization/ensemble/_stacking.py +2 -2
- skfolio/optimization/naive/__init__.py +1 -1
- skfolio/optimization/naive/_naive.py +3 -2
- skfolio/portfolio/__init__.py +1 -1
- skfolio/portfolio/_base.py +1 -0
- skfolio/portfolio/_portfolio.py +1 -0
- skfolio/pre_selection/__init__.py +1 -1
- skfolio/pre_selection/_drop_correlated.py +1 -1
- skfolio/pre_selection/_select_complete.py +6 -4
- skfolio/pre_selection/_select_k_extremes.py +1 -1
- skfolio/pre_selection/_select_non_dominated.py +1 -1
- skfolio/pre_selection/_select_non_expiring.py +6 -4
- skfolio/prior/__init__.py +3 -3
- skfolio/prior/_black_litterman.py +2 -1
- skfolio/prior/_empirical.py +2 -1
- skfolio/prior/_factor_model.py +2 -1
- skfolio/typing.py +6 -6
- skfolio/uncertainty_set/__init__.py +5 -5
- skfolio/uncertainty_set/_base.py +3 -2
- skfolio/utils/equations.py +58 -1
- skfolio/utils/stats.py +8 -8
- skfolio/utils/tools.py +10 -10
- {skfolio-0.5.2.dist-info → skfolio-0.7.0.dist-info}/METADATA +32 -29
- skfolio-0.7.0.dist-info/RECORD +95 -0
- {skfolio-0.5.2.dist-info → skfolio-0.7.0.dist-info}/WHEEL +1 -1
- skfolio-0.5.2.dist-info/RECORD +0 -95
- {skfolio-0.5.2.dist-info → skfolio-0.7.0.dist-info}/LICENSE +0 -0
- {skfolio-0.5.2.dist-info → skfolio-0.7.0.dist-info}/top_level.txt +0 -0
@@ -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,13 +1679,14 @@ 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
|
|
1533
1687
|
risk = covariance_uncertainty_set.k * cp.pnorm(
|
1534
|
-
sc.linalg.sqrtm(covariance_uncertainty_set.sigma)
|
1688
|
+
sc.linalg.sqrtm(covariance_uncertainty_set.sigma)
|
1689
|
+
@ (cp.vec(x, order="F") + cp.vec(y, order="F")),
|
1535
1690
|
2,
|
1536
1691
|
) + cp.trace(prior_model.covariance @ (x + y))
|
1537
1692
|
# semi-definite positive constraints
|
@@ -1972,7 +2127,7 @@ class ConvexOptimization(BaseOptimization, ABC):
|
|
1972
2127
|
ptf_returns * self._scale_constraints
|
1973
2128
|
- ptf_transaction_cost * self._scale_constraints
|
1974
2129
|
- ptf_management_fee * self._scale_constraints
|
1975
|
-
== cp.reshape(z, (observation_nb,)) * self._scale_constraints,
|
2130
|
+
== cp.reshape(z, (observation_nb,), order="F") * self._scale_constraints,
|
1976
2131
|
z @ gmd_w.T <= ones @ x.T + y @ ones.T,
|
1977
2132
|
]
|
1978
2133
|
return risk, constraints
|
@@ -1988,3 +2143,161 @@ class ConvexOptimization(BaseOptimization, ABC):
|
|
1988
2143
|
@abstractmethod
|
1989
2144
|
def fit(self, X: npt.ArrayLike, y: npt.ArrayLike | None = None, **fit_params):
|
1990
2145
|
pass
|
2146
|
+
|
2147
|
+
|
2148
|
+
def _mip_weight_constraints_no_short_threshold(
|
2149
|
+
n_assets: int,
|
2150
|
+
w: cp.Variable,
|
2151
|
+
factor: skt.Factor,
|
2152
|
+
scale_constraints: cp.Constant,
|
2153
|
+
cardinality: int | None,
|
2154
|
+
group_cardinalities: dict[str, int] | None,
|
2155
|
+
max_weights: np.ndarray | None,
|
2156
|
+
groups: np.ndarray | None,
|
2157
|
+
min_weights: np.ndarray | None,
|
2158
|
+
threshold_long: np.ndarray | None,
|
2159
|
+
) -> list[cp.Expression]:
|
2160
|
+
"""
|
2161
|
+
Create a list of MIP constraints for cardinality and threshold conditions
|
2162
|
+
when no short threshold is present. This only requires the creation of a single
|
2163
|
+
boolean variable array.
|
2164
|
+
"""
|
2165
|
+
constraints = []
|
2166
|
+
|
2167
|
+
is_short = np.any(min_weights < 0)
|
2168
|
+
|
2169
|
+
is_invested_bool = cp.Variable(n_assets, boolean=True)
|
2170
|
+
|
2171
|
+
if cardinality is not None and cardinality < n_assets:
|
2172
|
+
constraints.append(cp.sum(is_invested_bool) <= cardinality)
|
2173
|
+
|
2174
|
+
if group_cardinalities is not None:
|
2175
|
+
a_card, b_card = group_cardinalities_to_matrix(
|
2176
|
+
groups=groups,
|
2177
|
+
group_cardinalities=group_cardinalities,
|
2178
|
+
raise_if_group_missing=False,
|
2179
|
+
)
|
2180
|
+
constraints.append(a_card @ is_invested_bool - b_card <= 0)
|
2181
|
+
|
2182
|
+
if isinstance(factor, cp.Variable):
|
2183
|
+
is_invested_factor = cp.Variable(n_assets, nonneg=True)
|
2184
|
+
# We want (w <= cp.multiply(is_invested_short_bool, max_weights) * factor
|
2185
|
+
# but this is not DCP. So we introduce another variable and set
|
2186
|
+
# constraint to ensure its value is equal to is_invested_short_bool * factor
|
2187
|
+
|
2188
|
+
M = 1e3
|
2189
|
+
# Big M method to activate or deactivate constraints
|
2190
|
+
# In the ratio homogenization procedure, the factor has been calibrated
|
2191
|
+
# to be around 0.1-10. By using M=1e3, we ensure that M is large enough while
|
2192
|
+
# not too large for improved MIP convergence.
|
2193
|
+
|
2194
|
+
constraints += [
|
2195
|
+
is_invested_factor <= factor,
|
2196
|
+
is_invested_factor <= M * is_invested_bool,
|
2197
|
+
is_invested_factor >= factor - M * (1 - is_invested_bool),
|
2198
|
+
]
|
2199
|
+
is_invested = is_invested_factor
|
2200
|
+
else:
|
2201
|
+
is_invested = is_invested_bool
|
2202
|
+
|
2203
|
+
if threshold_long is not None:
|
2204
|
+
constraints.append(
|
2205
|
+
w * scale_constraints
|
2206
|
+
>= cp.multiply(is_invested, threshold_long) * scale_constraints
|
2207
|
+
)
|
2208
|
+
|
2209
|
+
constraints.append(
|
2210
|
+
w * scale_constraints
|
2211
|
+
<= cp.multiply(is_invested, max_weights) * scale_constraints
|
2212
|
+
)
|
2213
|
+
|
2214
|
+
if is_short:
|
2215
|
+
constraints.append(
|
2216
|
+
w * scale_constraints
|
2217
|
+
>= cp.multiply(is_invested, min_weights) * scale_constraints
|
2218
|
+
)
|
2219
|
+
|
2220
|
+
return constraints
|
2221
|
+
|
2222
|
+
|
2223
|
+
def _mip_weight_constraints_threshold_short(
|
2224
|
+
n_assets: int,
|
2225
|
+
w: cp.Variable,
|
2226
|
+
factor: skt.Factor,
|
2227
|
+
scale_constraints: cp.Constant,
|
2228
|
+
max_weights: np.ndarray,
|
2229
|
+
min_weights: np.ndarray,
|
2230
|
+
threshold_long: np.ndarray,
|
2231
|
+
threshold_short: np.ndarray,
|
2232
|
+
cardinality: int | None,
|
2233
|
+
group_cardinalities: dict[str, int] | None,
|
2234
|
+
groups: np.ndarray | None,
|
2235
|
+
) -> list[cp.Expression]:
|
2236
|
+
"""
|
2237
|
+
Create a list of MIP constraints for cardinality and threshold constraints
|
2238
|
+
when a short threshold is allowed. This requires the creation of two boolean
|
2239
|
+
variable arrays, one for long positions and one for short positions.
|
2240
|
+
"""
|
2241
|
+
constraints = []
|
2242
|
+
|
2243
|
+
is_invested_short_bool = cp.Variable(n_assets, boolean=True)
|
2244
|
+
is_invested_long_bool = cp.Variable(n_assets, boolean=True)
|
2245
|
+
is_invested_bool = is_invested_short_bool + is_invested_long_bool
|
2246
|
+
|
2247
|
+
if cardinality is not None and cardinality < n_assets:
|
2248
|
+
constraints.append(cp.sum(is_invested_bool) <= cardinality)
|
2249
|
+
|
2250
|
+
if group_cardinalities is not None:
|
2251
|
+
a_card, b_card = group_cardinalities_to_matrix(
|
2252
|
+
groups=groups,
|
2253
|
+
group_cardinalities=group_cardinalities,
|
2254
|
+
raise_if_group_missing=False,
|
2255
|
+
)
|
2256
|
+
constraints.append(a_card @ is_invested_bool - b_card <= 0)
|
2257
|
+
|
2258
|
+
M = 1e3
|
2259
|
+
# Big M method to activate or deactivate constraints
|
2260
|
+
# In the ratio homogenization procedure, the factor has been calibrated
|
2261
|
+
# to be around 0.1-10. By using M=1e3, we ensure that M is large enough while
|
2262
|
+
# not too large for improved MIP convergence.
|
2263
|
+
|
2264
|
+
if isinstance(factor, cp.Variable):
|
2265
|
+
is_invested_short_factor = cp.Variable(n_assets, nonneg=True)
|
2266
|
+
is_invested_long_factor = cp.Variable(n_assets, nonneg=True)
|
2267
|
+
# We want (w <= cp.multiply(is_invested_short_bool, max_weights) * factor
|
2268
|
+
# but this is not DCP. So we introduce another variable and set
|
2269
|
+
# constraint to ensure its value is equal to is_invested_short_bool * factor
|
2270
|
+
|
2271
|
+
constraints += [
|
2272
|
+
is_invested_short_factor <= factor,
|
2273
|
+
is_invested_long_factor <= factor,
|
2274
|
+
is_invested_short_factor <= M * is_invested_short_bool,
|
2275
|
+
is_invested_long_factor <= M * is_invested_long_bool,
|
2276
|
+
is_invested_short_factor >= factor - M * (1 - is_invested_short_bool),
|
2277
|
+
is_invested_long_factor >= factor - M * (1 - is_invested_long_bool),
|
2278
|
+
]
|
2279
|
+
is_invested_short = is_invested_short_factor
|
2280
|
+
is_invested_long = is_invested_long_factor
|
2281
|
+
else:
|
2282
|
+
is_invested_short = is_invested_short_bool
|
2283
|
+
is_invested_long = is_invested_long_bool
|
2284
|
+
|
2285
|
+
constraints += [
|
2286
|
+
is_invested_bool <= 1.0,
|
2287
|
+
w * scale_constraints
|
2288
|
+
<= cp.multiply(is_invested_long, max_weights) * scale_constraints,
|
2289
|
+
w * scale_constraints
|
2290
|
+
>= cp.multiply(is_invested_short, min_weights) * scale_constraints,
|
2291
|
+
# Apply threshold_long if is_invested_long == 1,
|
2292
|
+
# unrestricted if is_invested_long == 0
|
2293
|
+
w * scale_constraints
|
2294
|
+
>= cp.multiply(is_invested_long, threshold_long) * scale_constraints
|
2295
|
+
- M * (1 - is_invested_long_bool) * scale_constraints,
|
2296
|
+
# # Apply threshold_short if is_invested_short == 1,
|
2297
|
+
# # unrestricted if is_invested_short == 0
|
2298
|
+
w * scale_constraints
|
2299
|
+
<= cp.multiply(is_invested_short, threshold_short) * scale_constraints
|
2300
|
+
+ M * (1 - is_invested_short_bool) * scale_constraints,
|
2301
|
+
]
|
2302
|
+
|
2303
|
+
return constraints
|
@@ -8,6 +8,7 @@ import cvxpy as cp
|
|
8
8
|
import numpy as np
|
9
9
|
import numpy.typing as npt
|
10
10
|
import sklearn.utils.metadata_routing as skm
|
11
|
+
import sklearn.utils.validation as skv
|
11
12
|
|
12
13
|
import skfolio.typing as skt
|
13
14
|
from skfolio.measures import RiskMeasure
|
@@ -331,7 +332,9 @@ class DistributionallyRobustCVaR(ConvexOptimization):
|
|
331
332
|
"""
|
332
333
|
routed_params = skm.process_routing(self, "fit", **fit_params)
|
333
334
|
|
334
|
-
|
335
|
+
# `X` is unchanged and only `feature_names_in_` is performed
|
336
|
+
_ = skv.validate_data(self, X, skip_check_array=True)
|
337
|
+
|
335
338
|
# Used to avoid adding multiple times similar constrains linked to identical
|
336
339
|
# risk models
|
337
340
|
self.prior_estimator_ = check_estimator(
|
@@ -6,6 +6,7 @@
|
|
6
6
|
|
7
7
|
import numpy as np
|
8
8
|
import numpy.typing as npt
|
9
|
+
import sklearn.utils.validation as skv
|
9
10
|
|
10
11
|
import skfolio.typing as skt
|
11
12
|
from skfolio.measures import RiskMeasure
|
@@ -364,7 +365,7 @@ class MaximumDiversification(MeanRisk):
|
|
364
365
|
):
|
365
366
|
super().__init__(
|
366
367
|
objective_function=ObjectiveFunction.MAXIMIZE_RATIO,
|
367
|
-
risk_measure=RiskMeasure.
|
368
|
+
risk_measure=RiskMeasure.STANDARD_DEVIATION,
|
368
369
|
prior_estimator=prior_estimator,
|
369
370
|
min_weights=min_weights,
|
370
371
|
max_weights=max_weights,
|
@@ -423,7 +424,8 @@ class MaximumDiversification(MeanRisk):
|
|
423
424
|
self : MaximumDiversification
|
424
425
|
Fitted estimator.
|
425
426
|
"""
|
426
|
-
|
427
|
+
# `X` is unchanged and only `feature_names_in_` is performed
|
428
|
+
_ = skv.validate_data(self, X, skip_check_array=True)
|
427
429
|
|
428
430
|
def func(w, obj):
|
429
431
|
"""weighted volatilities"""
|