skfolio 0.5.2__py3-none-any.whl → 0.6.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.
@@ -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/advanced/index.html#setting-solver-options
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
- if self.min_weights is not None:
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
- self.min_weights,
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 self.max_weights is not None:
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 self.groups is None:
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 for {params_string}. Try another"
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.VARIANCE,
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 the solver "CLARABEL" and the CVXPY default otherwise.
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/advanced/index.html#setting-solver-options
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
- if self.solver == "CLARABEL":
738
- self._set_solver_params(default={"tol_gap_abs": 1e-9, "tol_gap_rel": 1e-9})
739
- else:
740
- self._set_solver_params(default=None)
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 :Max(f1/f2) with f2 linear
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(1) * self._scale_constraints
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: Schaible,"Parameter-free
976
- # Convex Equivalent and Dual Programs of Fractional Programming
977
- # Problems".
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(1) * self._scale_constraints
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.5.2
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,6 +64,7 @@ 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: docs
67
+ Requires-Dist: cvxpy[scip] ; extra == 'docs'
67
68
  Requires-Dist: Sphinx ; extra == 'docs'
68
69
  Requires-Dist: sphinx-gallery ; extra == 'docs'
69
70
  Requires-Dist: sphinx-design ; extra == 'docs'
@@ -81,6 +82,7 @@ Requires-Dist: jupyterlite-sphinx ; extra == 'docs'
81
82
  Requires-Dist: jupyterlite-pyodide-kernel ; extra == 'docs'
82
83
  Requires-Dist: nbformat ; extra == 'docs'
83
84
  Provides-Extra: tests
85
+ Requires-Dist: cvxpy[scip] ; extra == 'tests'
84
86
  Requires-Dist: pytest ; extra == 'tests'
85
87
  Requires-Dist: pytest-cov ; extra == 'tests'
86
88
  Requires-Dist: ruff ; extra == 'tests'
@@ -49,10 +49,10 @@ skfolio/optimization/cluster/hierarchical/_base.py,sha256=l8rJHCH_79FOPdDL2I0dmA
49
49
  skfolio/optimization/cluster/hierarchical/_herc.py,sha256=LPtUrvyW9G60OZhMWlZH_GHZHdX8mJHksrYGB-WPRVg,20358
50
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=P1rSw1oJAZR_BuOxJeXJrYHlkFD0AwCOaBl3mj54E8U,76413
52
+ skfolio/optimization/convex/_base.py,sha256=6x3W7bk1mxcTQMW1eWZiO-OqF1KbumrPVuzBSHMJoEA,89396
53
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
54
+ skfolio/optimization/convex/_maximum_diversification.py,sha256=TDJN39E6whFxBlUIEanAyTDxNZ6X2rAf17gQ4H_bN60,19510
55
+ skfolio/optimization/convex/_mean_risk.py,sha256=yN15Gjv-JoLrhIta2PQJ9WBzIiohd8ofETSl7ELE7xc,49383
56
56
  skfolio/optimization/convex/_risk_budgeting.py,sha256=VXm6vUeB-BDEn6KhWxg1-9UmjqpFR1E04SM4NLcNuBY,23510
57
57
  skfolio/optimization/ensemble/__init__.py,sha256=8TXxcxH2_gG3C1xtgQj9OHHr0Le8lhdejtlURL6T3ZY,158
58
58
  skfolio/optimization/ensemble/_base.py,sha256=GaNDQu6ivosYuwMrb-b0PhToCsNrmhSYyXkxeM8W4rU,3399
@@ -84,12 +84,12 @@ skfolio/uncertainty_set/_bootstrap.py,sha256=BRD8LhGKULkqqCBjLqU1EtCAMBkLJKEXJyg
84
84
  skfolio/uncertainty_set/_empirical.py,sha256=ACqMVTBKibJm6E3IP4TOi3MYsxKMhiEoix5D_fp9X-w,9364
85
85
  skfolio/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
86
86
  skfolio/utils/bootstrap.py,sha256=3zY2kO_GQURKEcQMCasJOSByde9Mt2IAi3KJH0_a4mk,3550
87
- skfolio/utils/equations.py,sha256=MQ1w3VSM2n_j9bTIKAQA716aWKYyUqtw5yM2bU-9t-M,13745
87
+ skfolio/utils/equations.py,sha256=9XFcRB6_UuxlAR-dWwf1XPxAHO9p5DfcC-bF5onr7Ws,15539
88
88
  skfolio/utils/sorting.py,sha256=lSjMvH2L-sSj-06B3MlwBrH1rtjCeGEe4hG894W7TE0,3504
89
89
  skfolio/utils/stats.py,sha256=mWMpJ_XBy400kx7GlwBvR4Fwo8ValOZ9J3VDLODDaHQ,16995
90
90
  skfolio/utils/tools.py,sha256=4KrmBR9jOLiI6j0hb27gsPC--OHXo4Sp1xl-6i-k9Tg,20925
91
- skfolio-0.5.2.dist-info/LICENSE,sha256=F6Gi-ZJX5BlVzYK8R9NcvAkAsKa7KO29xB1OScbrH6Q,1526
92
- skfolio-0.5.2.dist-info/METADATA,sha256=YCnMzyRfmhzQpJ6P6VySw-DJlYuHBdw4bkcfIrR_Gc8,19906
93
- skfolio-0.5.2.dist-info/WHEEL,sha256=R06PA3UVYHThwHvxuRWMqaGcr-PuniXahwjmQRFMEkY,91
94
- skfolio-0.5.2.dist-info/top_level.txt,sha256=NXEaoS9Ms7t32gxkb867nV0OKlU0KmssL7IJBVo0fJs,8
95
- skfolio-0.5.2.dist-info/RECORD,,
91
+ skfolio-0.6.0.dist-info/LICENSE,sha256=F6Gi-ZJX5BlVzYK8R9NcvAkAsKa7KO29xB1OScbrH6Q,1526
92
+ skfolio-0.6.0.dist-info/METADATA,sha256=BhIndmPyWFZBZCS5pPV62urBCBuF98-s5mPC3z_g8ss,19997
93
+ skfolio-0.6.0.dist-info/WHEEL,sha256=R06PA3UVYHThwHvxuRWMqaGcr-PuniXahwjmQRFMEkY,91
94
+ skfolio-0.6.0.dist-info/top_level.txt,sha256=NXEaoS9Ms7t32gxkb867nV0OKlU0KmssL7IJBVo0fJs,8
95
+ skfolio-0.6.0.dist-info/RECORD,,