skfolio 0.0.1__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.
Files changed (79) hide show
  1. skfolio/__init__.py +29 -0
  2. skfolio/cluster/__init__.py +8 -0
  3. skfolio/cluster/_hierarchical.py +387 -0
  4. skfolio/datasets/__init__.py +20 -0
  5. skfolio/datasets/_base.py +389 -0
  6. skfolio/datasets/data/__init__.py +0 -0
  7. skfolio/datasets/data/factors_dataset.csv.gz +0 -0
  8. skfolio/datasets/data/sp500_dataset.csv.gz +0 -0
  9. skfolio/datasets/data/sp500_index.csv.gz +0 -0
  10. skfolio/distance/__init__.py +26 -0
  11. skfolio/distance/_base.py +55 -0
  12. skfolio/distance/_distance.py +574 -0
  13. skfolio/exceptions.py +30 -0
  14. skfolio/measures/__init__.py +76 -0
  15. skfolio/measures/_enums.py +355 -0
  16. skfolio/measures/_measures.py +607 -0
  17. skfolio/metrics/__init__.py +3 -0
  18. skfolio/metrics/_scorer.py +121 -0
  19. skfolio/model_selection/__init__.py +18 -0
  20. skfolio/model_selection/_combinatorial.py +407 -0
  21. skfolio/model_selection/_validation.py +194 -0
  22. skfolio/model_selection/_walk_forward.py +221 -0
  23. skfolio/moments/__init__.py +41 -0
  24. skfolio/moments/covariance/__init__.py +29 -0
  25. skfolio/moments/covariance/_base.py +101 -0
  26. skfolio/moments/covariance/_covariance.py +1108 -0
  27. skfolio/moments/expected_returns/__init__.py +21 -0
  28. skfolio/moments/expected_returns/_base.py +31 -0
  29. skfolio/moments/expected_returns/_expected_returns.py +415 -0
  30. skfolio/optimization/__init__.py +36 -0
  31. skfolio/optimization/_base.py +147 -0
  32. skfolio/optimization/cluster/__init__.py +13 -0
  33. skfolio/optimization/cluster/_nco.py +348 -0
  34. skfolio/optimization/cluster/hierarchical/__init__.py +13 -0
  35. skfolio/optimization/cluster/hierarchical/_base.py +440 -0
  36. skfolio/optimization/cluster/hierarchical/_herc.py +406 -0
  37. skfolio/optimization/cluster/hierarchical/_hrp.py +368 -0
  38. skfolio/optimization/convex/__init__.py +16 -0
  39. skfolio/optimization/convex/_base.py +1944 -0
  40. skfolio/optimization/convex/_distributionally_robust.py +392 -0
  41. skfolio/optimization/convex/_maximum_diversification.py +417 -0
  42. skfolio/optimization/convex/_mean_risk.py +974 -0
  43. skfolio/optimization/convex/_risk_budgeting.py +560 -0
  44. skfolio/optimization/ensemble/__init__.py +6 -0
  45. skfolio/optimization/ensemble/_base.py +87 -0
  46. skfolio/optimization/ensemble/_stacking.py +326 -0
  47. skfolio/optimization/naive/__init__.py +3 -0
  48. skfolio/optimization/naive/_naive.py +173 -0
  49. skfolio/population/__init__.py +3 -0
  50. skfolio/population/_population.py +883 -0
  51. skfolio/portfolio/__init__.py +13 -0
  52. skfolio/portfolio/_base.py +1096 -0
  53. skfolio/portfolio/_multi_period_portfolio.py +610 -0
  54. skfolio/portfolio/_portfolio.py +842 -0
  55. skfolio/pre_selection/__init__.py +7 -0
  56. skfolio/pre_selection/_pre_selection.py +342 -0
  57. skfolio/preprocessing/__init__.py +3 -0
  58. skfolio/preprocessing/_returns.py +114 -0
  59. skfolio/prior/__init__.py +18 -0
  60. skfolio/prior/_base.py +63 -0
  61. skfolio/prior/_black_litterman.py +238 -0
  62. skfolio/prior/_empirical.py +163 -0
  63. skfolio/prior/_factor_model.py +268 -0
  64. skfolio/typing.py +50 -0
  65. skfolio/uncertainty_set/__init__.py +23 -0
  66. skfolio/uncertainty_set/_base.py +108 -0
  67. skfolio/uncertainty_set/_bootstrap.py +281 -0
  68. skfolio/uncertainty_set/_empirical.py +237 -0
  69. skfolio/utils/__init__.py +0 -0
  70. skfolio/utils/bootstrap.py +115 -0
  71. skfolio/utils/equations.py +350 -0
  72. skfolio/utils/sorting.py +117 -0
  73. skfolio/utils/stats.py +466 -0
  74. skfolio/utils/tools.py +567 -0
  75. skfolio-0.0.1.dist-info/LICENSE +29 -0
  76. skfolio-0.0.1.dist-info/METADATA +568 -0
  77. skfolio-0.0.1.dist-info/RECORD +79 -0
  78. skfolio-0.0.1.dist-info/WHEEL +5 -0
  79. skfolio-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1944 @@
1
+ """Base Convex Optimization estimator."""
2
+
3
+ # Author: Hugo Delatte <delatte.hugo@gmail.com>
4
+ # License: BSD 3 clause
5
+
6
+ import warnings
7
+ from abc import ABC, abstractmethod
8
+ from enum import auto
9
+
10
+ import cvxpy as cp
11
+ import cvxpy.constraints.constraint as cpc
12
+ import numpy as np
13
+ import numpy.typing as npt
14
+ import scipy as sc
15
+ import scipy.sparse.linalg as scl
16
+
17
+ import skfolio.typing as skt
18
+ from skfolio.measures import RiskMeasure, owa_gmd_weights
19
+ from skfolio.optimization._base import BaseOptimization
20
+ from skfolio.prior import BasePrior, PriorModel
21
+ from skfolio.uncertainty_set import (
22
+ BaseCovarianceUncertaintySet,
23
+ BaseMuUncertaintySet,
24
+ UncertaintySet,
25
+ )
26
+ from skfolio.utils.equations import equations_to_matrix
27
+ from skfolio.utils.tools import AutoEnum, cache_method, input_to_array
28
+
29
+ INSTALLED_SOLVERS = cp.installed_solvers()
30
+
31
+
32
+ class ObjectiveFunction(AutoEnum):
33
+ r"""Enumeration of objective functions.
34
+
35
+ Attributes
36
+ ----------
37
+ MINIMIZE_RISK : str
38
+ Minimize the risk measure.
39
+
40
+ MAXIMIZE_RETURN : str
41
+ Maximize the expected return.
42
+
43
+ MAXIMIZE_UTILITY : str
44
+ Maximize the utility :math:`w^T\mu - \lambda \times risk(w)`.
45
+
46
+ MAXIMIZE_UTILITY : str
47
+ Maximize the ratio :math:`\frac{w^T\mu - R_{f}}{risk(w)}`.
48
+ """
49
+ MINIMIZE_RISK = auto()
50
+ MAXIMIZE_RETURN = auto()
51
+ MAXIMIZE_UTILITY = auto()
52
+ MAXIMIZE_RATIO = auto()
53
+
54
+
55
+ class ConvexOptimization(BaseOptimization, ABC):
56
+ r"""Base class for all convex optimization estimators in skfolio.
57
+
58
+ All risk measures that have a convex formulation are defined in class methods with
59
+ naming convention: `_{risk_measure}_risk`. That naming convention is used for
60
+ dynamic lookup.
61
+
62
+ CVX expressions that are shared among multiple risk measures are cached in a
63
+ dictionary named `_cvx_cache`.
64
+ This is to avoid cvx expression duplication and improve performance and convergence.
65
+
66
+ Parameters
67
+ ----------
68
+ risk_measure : RiskMeasure, default=RiskMeasure.VARIANCE
69
+ :class:`~skfolio.meta.RiskMeasure` of the optimization.
70
+ Can be any of:
71
+
72
+ * VARIANCE
73
+ * SEMI_VARIANCE
74
+ * STANDARD_DEVIATION
75
+ * SEMI_DEVIATION
76
+ * MEAN_ABSOLUTE_DEVIATION
77
+ * FIRST_LOWER_PARTIAL_MOMENT
78
+ * CVAR
79
+ * EVAR
80
+ * WORST_REALIZATION
81
+ * CDAR
82
+ * MAX_DRAWDOWN
83
+ * AVERAGE_DRAWDOWN
84
+ * EDAR
85
+ * ULCER_INDEX
86
+ * GINI_MEAN_DIFFERENCE_RATIO
87
+
88
+ The default is `RiskMeasure.VARIANCE`.
89
+
90
+ prior_estimator : BasePrior, optional
91
+ :ref:`Prior estimator <prior>`.
92
+ The prior estimator is used to estimate the :class:`~skfolio.prior.PriorModel`
93
+ containing the estimation of assets expected returns, covariance matrix,
94
+ returns and Cholesky decomposition of the covariance.
95
+ The default (`None`) is to use :class:`~skfolio.prior.EmpiricalPrior`.
96
+
97
+ min_weights : float | dict[str, float] | array-like of shape (n_assets, ) | None, default=0.0
98
+ Minimum assets weights (weights lower bounds).
99
+ If a float is provided, it is applied to each asset.
100
+ `None` is equivalent to `-np.Inf` (no lower bound).
101
+ If a dictionary is provided, its (key/value) pair must be the
102
+ (asset name/asset minium weight) and the input `X` of the `fit` methods must
103
+ be a DataFrame with the assets names in columns.
104
+ When using a dictionary, assets values that are not provided are assigned
105
+ a minimum weight of `0.0`.
106
+ The default value is `0.0` (no short selling).
107
+
108
+ Example:
109
+
110
+ * `min_weights = 0` --> long only portfolio (no short selling).
111
+ * `min_weights = None` --> no lower bound (same as `-np.Inf`).
112
+ * `min_weights = -2` --> each weight must be above -200%.
113
+ * `min_weights = {"SX5E": 0, "SPX": -2}`
114
+ * `min_weights = [0, -2]`
115
+
116
+ max_weights : float | dict[str, float] | array-like of shape (n_assets, ) | None, default=1.0
117
+ Maximum assets weights (weights upper bounds).
118
+ If a float is provided, it is applied to each asset.
119
+ `None` is equivalent to `+np.Inf` (no upper bound).
120
+ If a dictionary is provided, its (key/value) pair must be the
121
+ (asset name/asset maximum weight) and the input `X` of the `fit` methods must
122
+ be a DataFrame with the assets names in columns.
123
+ When using a dictionary, assets values that are not provided are assigned
124
+ a minimum weight of `1.0`.
125
+ The default value is `1.0` (each asset is below 100%).
126
+
127
+ Example:
128
+
129
+ * `max_weights = 0` --> no long position (short only portfolio).
130
+ * `max_weights = None` --> no upper bound.
131
+ * `max_weights = 2` --> each weight must be below 200%.
132
+ * `max_weights = {"SX5E": 1, "SPX": 2}`
133
+ * `max_weights = [1, 2]`
134
+
135
+ budget : float | None, default=1.0
136
+ Investment budget. It is the sum of long positions and short positions (sum of
137
+ all weights). `None` means no budget constraints.
138
+ The default value is `1.0` (fully invested portfolio).
139
+
140
+ Examples:
141
+
142
+ * `budget = 1` --> fully invested portfolio.
143
+ * `budget = 0` --> market neutral portfolio.
144
+ * `budget = None` --> no constraints on the sum of weights.
145
+
146
+ min_budget : float, optional
147
+ Minimum budget. It is the lower bound of the sum of long and short positions
148
+ (sum of all weights). If provided, you must set `budget=None`.
149
+ The default (`None`) means no minimum budget constraint.
150
+
151
+ max_budget : float, optional
152
+ Maximum budget. It is the upper bound of the sum of long and short positions
153
+ (sum of all weights). If provided, you must set `budget=None`.
154
+ The default (`None`) means no maximum budget constraint.
155
+
156
+ max_short : float, optional
157
+ Maximum short position. The short position is defined as the sum of negative
158
+ weights (in absolute term).
159
+ The default (`None`) means no maximum short position.
160
+
161
+ max_long : float, optional
162
+ Maximum long position. The long position is defined as the sum of positive
163
+ weights.
164
+ The default (`None`) means no maximum long position.
165
+
166
+ transaction_costs : float | dict[str, float] | array-like of shape (n_assets, ), default=0.0
167
+ Transaction costs of the assets. It is used to add linear transaction costs to
168
+ the optimization problem:
169
+
170
+ .. math:: total\_cost = \sum_{i=1}^{N} c_{i} \times |w_{i} - w\_prev_{i}|
171
+
172
+ with :math:`c_{i}` the transaction cost of asset i, :math:`w_{i}` its weight
173
+ and :math:`w\_prev_{i}` its previous weight (defined in `previous_weights`).
174
+ The float :math:`total\_cost` is used in the portfolio expected return:
175
+
176
+ .. math:: expected\_return = \mu^{T} \cdot w - total\_cost
177
+
178
+ with :math:`\mu` the vector af assets' expected returns and :math:`w` the
179
+ vector of assets weights.
180
+
181
+ If a float is provided, it is applied to each asset.
182
+ If a dictionary is provided, its (key/value) pair must be the
183
+ (asset name/asset cost) and the input `X` of the `fit` methods must be a
184
+ DataFrame with the assets names in columns.
185
+ The default value is `0.0`.
186
+
187
+ .. warning::
188
+
189
+ Based on the above formula, the periodicity of the transaction costs
190
+ needs to be homogenous to the periodicity of :math:`\mu`. For example, if
191
+ the input `X` is composed of **daily** returns, the `transaction_costs` need
192
+ to be expressed in **daily** costs.
193
+ (See :ref:`sphx_glr_auto_examples_1_mean_risk_plot_6_transaction_costs.py`)
194
+
195
+ management_fees : float | dict[str, float] | array-like of shape (n_assets, ), default=0.0
196
+ Management fees of the assets. It is used to add linear management fees to the
197
+ optimization problem:
198
+
199
+ .. math:: total\_fee = \sum_{i=1}^{N} f_{i} \times w_{i}
200
+
201
+ with :math:`f_{i}` the management fee of asset i and :math:`w_{i}` its weight.
202
+ The float :math:`total\_fee` is used in the portfolio expected return:
203
+
204
+ .. math:: expected\_return = \mu^{T} \cdot w - total\_fee
205
+
206
+ with :math:`\mu` the vector af assets expected returns and :math:`w` the vector
207
+ of assets weights.
208
+
209
+ If a float is provided, it is applied to each asset.
210
+ If a dictionary is provided, its (key/value) pair must be the
211
+ (asset name/asset fee) and the input `X` of the `fit` methods must be a
212
+ DataFrame with the assets names in columns.
213
+ The default value is `0.0`.
214
+
215
+ .. warning::
216
+
217
+ Based on the above formula, the periodicity of the management fees needs to
218
+ be homogenous to the periodicity of :math:`\mu`. For example, if the input
219
+ `X` is composed of **daily** returns, the `management_fees` need to be
220
+ expressed in **daily** fees.
221
+
222
+ .. note::
223
+
224
+ Another approach is to directly impact the management fees to the input `X`
225
+ in order to express the returns net of fees. However, when estimating the
226
+ :math:`\mu` parameter using for example Shrinkage estimators, this approach
227
+ would mix a deterministic value with an uncertain one leading to unwanted
228
+ bias in the management fees.
229
+
230
+ previous_weights : float | dict[str, float] | array-like of shape (n_assets, ), optional
231
+ Previous weights of the assets. Previous weights are used to compute the
232
+ portfolio cost and the portfolio turnover.
233
+ If a float is provided, it is applied to each asset.
234
+ If a dictionary is provided, its (key/value) pair must be the
235
+ (asset name/asset previous weight) and the input `X` of the `fit` methods must
236
+ be a DataFrame with the assets names in columns.
237
+ The default (`None`) means no previous weights.
238
+
239
+ l1_coef : float, default=0.0
240
+ L1 regularization coefficient.
241
+ It is used to penalize the objective function by the L1 norm:
242
+
243
+ .. math:: l1\_coef \times \Vert w \Vert_{1} = l1\_coef \times \sum_{i=1}^{N} |w_{i}|
244
+
245
+ Increasing this coefficient will reduce the number of non-zero weights
246
+ (cardinality). It tends to increase robustness (out-of-sample stability) but
247
+ reduces diversification.
248
+ The default value is `0.0`.
249
+
250
+ l2_coef : float, default=0.0
251
+ L2 regularization coefficient.
252
+ It is used to penalize the objective function by the L2 norm:
253
+
254
+ .. math:: l2\_coef \times \Vert w \Vert_{2}^{2} = l2\_coef \times \sum_{i=1}^{N} w_{i}^2
255
+
256
+ It tends to increase robustness (out-of-sample stability).
257
+ The default value is `0.0`.
258
+
259
+ mu_uncertainty_set_estimator : BaseMuUncertaintySet, optional
260
+ :ref:`Mu Uncertainty set estimator <uncertainty_set_estimator>`.
261
+ If provided, the assets expected returns are modelled with an ellipsoidal
262
+ uncertainty set. It is called worst-case optimization and is a class of robust
263
+ optimization. It reduces the instability that arises from the estimation errors
264
+ of the expected returns.
265
+ The worst case portfolio expect return is:
266
+
267
+ .. math:: w^T\hat{\mu} - \kappa_{\mu}\lVert S_{\mu}^\frac{1}{2}w\rVert_{2}
268
+
269
+ with :math:`\kappa` the size of the ellipsoid (confidence region) and
270
+ :math:`S` its shape.
271
+ The default (`None`) means that no uncertainty set is used.
272
+
273
+ covariance_uncertainty_set_estimator : BaseCovarianceUncertaintySet, optional
274
+ :ref:`Covariance Uncertainty set estimator <uncertainty_set_estimator>`.
275
+ If provided, the assets covariance matrix is modelled with an ellipsoidal
276
+ uncertainty set. It is called worst-case optimization and is a class of robust
277
+ optimization. It reduces the instability that arises from the estimation errors
278
+ of the covariance matrix.
279
+ The default (`None`) means that no uncertainty set is used.
280
+
281
+ linear_constraints : array-like of shape (n_constraints,), optional
282
+ Linear constraints.
283
+ The linear constraints must match any of following patterns:
284
+
285
+ * "2.5 * ref1 + 0.10 * ref2 + 0.0013 <= 2.5 * ref3"
286
+ * "ref1 >= 2.9 * ref2"
287
+ * "ref1 <= ref2"
288
+ * "ref1 >= ref1"
289
+
290
+ With "ref1", "ref2" ... the assets names or the groups names provided
291
+ in the parameter `groups`. Assets names can be referenced without the need of
292
+ `groups` if the input `X` of the `fit` methods is a DataFrame with these
293
+ assets names in columns.
294
+
295
+ Examples:
296
+
297
+ * "SPX >= 0.10" --> SPX weight must be greater than 10% (note that you can also use `min_weights`)
298
+ * "SX5E + TLT >= 0.2" --> the sum of SX5E and TLT weights must be greater than 20%
299
+ * "US >= 0.7" --> the sum of all US weights must be greater than 70%
300
+ * "Equity <= 3 * Bond" --> the sum of all Equity weights must be less or equal to 3 times the sum of all Bond weights.
301
+ * "2*SPX + 3*Europe <= Bond + 0.05" --> mixing assets and group constraints
302
+
303
+ groups : dict[str, list[str]] or array-like of shape (n_groups, n_assets), optional
304
+ The assets groups referenced in `linear_constraints`.
305
+ If a dictionary is provided, its (key/value) pair must be the
306
+ (asset name/asset groups) and the input `X` of the `fit` methods must be a
307
+ DataFrame with the assets names in columns.
308
+
309
+ Examples:
310
+
311
+ * groups = {"SX5E": ["Equity", "Europe"], "SPX": ["Equity", "US"], "TLT": ["Bond", "US"]}
312
+ * groups = [["Equity", "Equity", "Bond"], ["Europe", "US", "US"]]
313
+
314
+ left_inequality : array-like of shape (n_constraints, n_assets), optional
315
+ Left inequality matrix :math:`A` of the linear
316
+ constraint :math:`A \cdot w \leq b`.
317
+
318
+ right_inequality : array-like of shape (n_constraints, ), optional
319
+ Right inequality vector :math:`b` of the linear
320
+ constraint :math:`A \cdot w \leq b`.
321
+
322
+ risk_free_rate : float, default=0.0
323
+ Risk-free interest rate.
324
+ The default value is `0.0`.
325
+
326
+ min_acceptable_return : float, optional
327
+ The minimum acceptable return used to distinguish "downside" and "upside"
328
+ returns for the computation of lower partial moments:
329
+
330
+ * First Lower Partial Moment
331
+ * Semi-Variance
332
+ * Semi-Deviation
333
+
334
+ The default (`None`) is to use the mean.
335
+
336
+ cvar_beta : float, default=0.95
337
+ CVaR (Conditional Value at Risk) confidence level.
338
+ The default value is `0.95`.
339
+
340
+ evar_beta : float, default=0
341
+ EVaR (Entropic Value at Risk) confidence level.
342
+ The default value is `0.95`.
343
+
344
+ cdar_beta : float, default=0.95
345
+ CDaR (Conditional Drawdown at Risk) confidence level.
346
+ The default value is `0.95`.
347
+
348
+ edar_beta : float, default=0.95
349
+ EDaR (Entropic Drawdown at Risk) confidence level.
350
+ The default value is `0.95`.
351
+
352
+ add_objective : Callable[[cp.Variable], cp.Expression], optional
353
+ Add a custom objective to the existing objective expression.
354
+ It is a function that must take as argument the weights `w` and returns a
355
+ CVXPY expression.
356
+
357
+ add_constraints : Callable[[cp.Variable], cp.Expression|list[cp.Expression]], optional
358
+ Add a custom constraint or a list of constraints to the existing constraints.
359
+ It is a function that must take as argument the weights `w` and returns a
360
+ CVPXY expression or a list of CVPXY expressions.
361
+
362
+ overwrite_expected_return : Callable[[cp.Variable], cp.Expression], optional
363
+ Overwrite the expected return :math:`\mu \cdot w` with a custom expression.
364
+ It is a function that must take as argument the weights `w` and returns a
365
+ CVPXY expression.
366
+
367
+ solver : str, optional
368
+ The solver to use. For example, "ECOS", "SCS", or "OSQP".
369
+ The default (`None`) is set depending on the problem.
370
+ For more details about available solvers, check the CVXPY documentation:
371
+ https://www.cvxpy.org/tutorial/advanced/index.html#choosing-a-solver
372
+
373
+ solver_params : dict, optional
374
+ Solver parameters. For example, `solver_params=dict(verbose=True)`.
375
+ For more details about solver arguments, check the CVXPY documentation:
376
+ https://www.cvxpy.org/tutorial/advanced/index.html#setting-solver-options
377
+
378
+ scale_objective : float, optional
379
+ Scale each objective element by this value.
380
+ It can be used to increase the optimization accuracies in specific cases.
381
+ The default (`None`) is set depending on the problem.
382
+
383
+ scale_constraints : float, optional
384
+ Scale each constraint element by this value.
385
+ It can be used to increase the optimization accuracies in specific cases.
386
+ The default (`None`) is set depending on the problem.
387
+
388
+ raise_on_failure : bool, default=True
389
+ If this is set to True, an error is raised when the optimization fail otherwise
390
+ it passes with a warning.
391
+
392
+ portfolio_params : dict, optional
393
+ Portfolio parameters passed to the portfolio evaluated by the `predict` and
394
+ `score` methods. If not provided, the `name`, `transaction_costs`,
395
+ `management_fees` and `previous_weights` are copied from the optimization
396
+ model and systematically passed to the portfolio.
397
+
398
+ Attributes
399
+ ----------
400
+ weights_ : ndarray of shape (n_assets,) or (n_optimizations, n_assets)
401
+ Weights of the assets.
402
+
403
+ problem_: cvxpy.Problem
404
+ CVXPY problem used for the optimization.
405
+
406
+ problem_values_ : dict[str, float] | list[dict[str, float]] of size n_optimizations
407
+ Expression values retrieved from the CVXPY problem.
408
+
409
+ prior_estimator_ : BasePrior
410
+ Fitted `prior_estimator`.
411
+
412
+ mu_uncertainty_set_estimator_ : BaseMuUncertaintySet
413
+ Fitted `mu_uncertainty_set_estimator` if provided.
414
+
415
+ covariance_uncertainty_set_estimator_ : BaseCovarianceUncertaintySet
416
+ Fitted `covariance_uncertainty_set_estimator` if provided.
417
+ """
418
+ _solver: str
419
+ _scale_objective: cp.Constant
420
+ _scale_constraints: cp.Constant
421
+
422
+ _cvx_cache = dict
423
+ problem_: cp.Problem
424
+ problem_values_: dict[str, float] | list[dict[str, float]]
425
+ prior_estimator_: BasePrior
426
+ mu_uncertainty_set_estimator_: BaseMuUncertaintySet
427
+ covariance_uncertainty_set_estimator_: BaseCovarianceUncertaintySet
428
+
429
+ @abstractmethod
430
+ def __init__(
431
+ self,
432
+ risk_measure: RiskMeasure = RiskMeasure.VARIANCE,
433
+ prior_estimator: BasePrior | None = None,
434
+ min_weights: skt.MultiInput | None = 0.0,
435
+ max_weights: skt.MultiInput | None = 1.0,
436
+ budget: float | None = 1.0,
437
+ min_budget: float | None = None,
438
+ max_budget: float | None = None,
439
+ max_short: float | None = None,
440
+ max_long: float | None = None,
441
+ transaction_costs: skt.MultiInput = 0.0,
442
+ management_fees: skt.MultiInput = 0.0,
443
+ previous_weights: skt.MultiInput | None = None,
444
+ groups: skt.Groups | None = None,
445
+ linear_constraints: skt.LinearConstraints | None = None,
446
+ left_inequality: skt.Inequality | None = None,
447
+ right_inequality: skt.Inequality | None = None,
448
+ l1_coef: float = 0.0,
449
+ l2_coef: float = 0.0,
450
+ mu_uncertainty_set_estimator: BaseMuUncertaintySet | None = None,
451
+ covariance_uncertainty_set_estimator: BaseCovarianceUncertaintySet
452
+ | None = None,
453
+ risk_free_rate: float = 0.0,
454
+ min_acceptable_return: skt.Target | None = None,
455
+ cvar_beta: float = 0.95,
456
+ evar_beta: float = 0.95,
457
+ cdar_beta: float = 0.95,
458
+ edar_beta: float = 0.95,
459
+ solver: str | None = None,
460
+ solver_params: dict | None = None,
461
+ scale_objective: float | None = None,
462
+ scale_constraints: float | None = None,
463
+ raise_on_failure: bool = True,
464
+ add_objective: skt.ExpressionFunction | None = None,
465
+ add_constraints: skt.ExpressionFunction | None = None,
466
+ overwrite_expected_return: skt.ExpressionFunction | None = None,
467
+ portfolio_params: dict | None = None,
468
+ ):
469
+ super().__init__(portfolio_params=portfolio_params)
470
+ if risk_measure.is_annualized:
471
+ warnings.warn(
472
+ f"The annualized risk measure {risk_measure} will be converted"
473
+ f"to its non-annualized version {risk_measure.non_annualized_measure}",
474
+ stacklevel=2,
475
+ )
476
+ risk_measure = risk_measure.non_annualized_measure
477
+ self.risk_measure = risk_measure
478
+ self.prior_estimator = prior_estimator
479
+ self.mu_uncertainty_set_estimator = mu_uncertainty_set_estimator
480
+ self.covariance_uncertainty_set_estimator = covariance_uncertainty_set_estimator
481
+ self.min_weights = min_weights
482
+ self.max_weights = max_weights
483
+ self.budget = budget
484
+ self.min_budget = min_budget
485
+ self.max_budget = max_budget
486
+ self.max_short = max_short
487
+ self.max_long = max_long
488
+ self.min_acceptable_return = min_acceptable_return
489
+ self.transaction_costs = transaction_costs
490
+ self.management_fees = management_fees
491
+ self.previous_weights = previous_weights
492
+ self.groups = groups
493
+ self.linear_constraints = linear_constraints
494
+ self.left_inequality = left_inequality
495
+ self.right_inequality = right_inequality
496
+ self.l1_coef = l1_coef
497
+ self.l2_coef = l2_coef
498
+ self.risk_free_rate = risk_free_rate
499
+ self.add_objective = add_objective
500
+ self.add_constraints = add_constraints
501
+ self.overwrite_expected_return = overwrite_expected_return
502
+ self.solver = solver
503
+ self.solver_params = solver_params
504
+ self.raise_on_failure = raise_on_failure
505
+ self.scale_objective = scale_objective
506
+ self.scale_constraints = scale_constraints
507
+ self.cvar_beta = cvar_beta
508
+ self.evar_beta = evar_beta
509
+ self.cdar_beta = cdar_beta
510
+ self.edar_beta = edar_beta
511
+
512
+ def _call_custom_func(
513
+ self, func: skt.ExpressionFunction, w: cp.Variable, name: str = "custom_func"
514
+ ) -> cp.Expression | list[cp.Expression]:
515
+ """Call a user specific function, infer arguments and perform validation.
516
+
517
+ Parameters
518
+ ----------
519
+ func : Callable[[cvxpy Variable, any], cvxpy Expression]
520
+ The custom function. Must have one or two positional arguments.
521
+ The first argument is the CVXPY weight variable `w` and the second is
522
+ the reference to the class itself.
523
+
524
+ w : cvxpy Variable
525
+ The CVXPY Variable representing assets weights.
526
+
527
+ Returns
528
+ -------
529
+ result : cvxpy Expression | list[cvxpy Expression]
530
+ Result of calling the custom function.
531
+ """
532
+ try:
533
+ # noinspection PyUnresolvedReferences
534
+ func_code = func.__code__
535
+ except AttributeError as err:
536
+ raise ValueError("Custom functions is invalid") from err
537
+
538
+ if func_code.co_argcount == 1:
539
+ args = (w,)
540
+ elif func_code.co_argcount == 2:
541
+ args = (w, self)
542
+ else:
543
+ raise ValueError(
544
+ "Custom functions must have 1 or 2 positional arguments, got"
545
+ f" {func_code.co_argcount}"
546
+ )
547
+ try:
548
+ return func(*args)
549
+ except Exception as err:
550
+ raise TypeError(
551
+ f"Error while calling {name}. "
552
+ f"{name} must be a function taking as argument "
553
+ "the weight variable OR the weight variable and the estimator object."
554
+ ) from err
555
+
556
+ def _clean_input(
557
+ self,
558
+ value: float | dict | npt.ArrayLike | None,
559
+ n_assets: int,
560
+ fill_value: any,
561
+ name: str,
562
+ ) -> float | np.ndarray:
563
+ """Convert input to cleaned float or ndarray.
564
+
565
+ Parameters
566
+ ----------
567
+ value : float, dict, array-like or None.
568
+ Input value to clean.
569
+
570
+ n_assets : int
571
+ Number of assets. Used to verify the shape of the converted array.
572
+
573
+ fill_value : any
574
+ When `items` is a dictionary, elements that are not in `asset_names` are
575
+ filled with `fill_value` in the converted array.
576
+
577
+ name : str
578
+ Name used for error messages.
579
+
580
+ Returns
581
+ -------
582
+ value : float or ndarray of shape (n_assets,)
583
+ The cleaned float or 1D array.
584
+ """
585
+ if value is None:
586
+ return fill_value
587
+ if np.isscalar(value):
588
+ return float(value)
589
+ return input_to_array(
590
+ items=value,
591
+ n_assets=n_assets,
592
+ fill_value=fill_value,
593
+ dim=1,
594
+ assets_names=(
595
+ self.feature_names_in_ if hasattr(self, "feature_names_in_") else None
596
+ ),
597
+ name=name,
598
+ )
599
+
600
+ def _clear_models_cache(self):
601
+ """CLear the cache of CVX models"""
602
+ self._cvx_cache = {}
603
+
604
+ def _get_weight_constraints(
605
+ self, n_assets: int, w: cp.Variable, factor: skt.Factor
606
+ ) -> list[cpc.Constraint]:
607
+ """Compute weight constraints from input parameters.
608
+
609
+ Parameters
610
+ ----------
611
+ n_assets : int
612
+ Number of assets.
613
+
614
+ w : cvxpy Variable
615
+ The CVXPY Variable representing assets weights.
616
+
617
+ factor : cvxpy Variable | cvxpy Constant
618
+ Cvxpy variable or constant.
619
+
620
+ Returns
621
+ -------
622
+ constrains : list[cvxpy Constrains]
623
+ The list of weights constraints.
624
+ """
625
+ constraints = []
626
+
627
+ if self.min_weights is not None:
628
+ min_weights = self._clean_input(
629
+ self.min_weights,
630
+ n_assets=n_assets,
631
+ fill_value=0,
632
+ name="min_weights",
633
+ )
634
+ constraints.append(
635
+ w * self._scale_constraints
636
+ >= min_weights * factor * self._scale_constraints
637
+ )
638
+
639
+ if self.max_weights is not None:
640
+ max_weights = self._clean_input(
641
+ self.max_weights,
642
+ n_assets=n_assets,
643
+ fill_value=1,
644
+ name="max_weights",
645
+ )
646
+ constraints.append(
647
+ w * self._scale_constraints
648
+ <= max_weights * factor * self._scale_constraints
649
+ )
650
+
651
+ if self.max_long is not None:
652
+ max_long = float(self.max_long)
653
+ if max_long <= 0:
654
+ raise ValueError("`max_long` must be strictly positif")
655
+ constraints.append(
656
+ cp.sum(cp.pos(w)) * self._scale_constraints
657
+ <= max_long * factor * self._scale_constraints
658
+ )
659
+
660
+ if self.max_short is not None:
661
+ max_short = float(self.max_short)
662
+ if max_short <= 0:
663
+ raise ValueError("`max_short` must be strictly positif")
664
+ constraints.append(
665
+ cp.sum(cp.neg(w)) * self._scale_constraints
666
+ <= max_short * factor * self._scale_constraints
667
+ )
668
+
669
+ if self.min_budget is not None:
670
+ constraints.append(
671
+ cp.sum(w) * self._scale_constraints
672
+ >= float(self.min_budget) * factor * self._scale_constraints
673
+ )
674
+
675
+ if self.max_budget is not None:
676
+ constraints.append(
677
+ cp.sum(w) * self._scale_constraints
678
+ <= float(self.max_budget) * factor * self._scale_constraints
679
+ )
680
+
681
+ if self.budget is not None:
682
+ if self.max_budget is not None:
683
+ raise ValueError(
684
+ "`max_budget`and `budget` cannot be provided at the same time"
685
+ )
686
+ if self.min_budget is not None:
687
+ raise ValueError(
688
+ "`min_budget`and `budget` cannot be provided at the same time"
689
+ )
690
+ constraints.append(
691
+ cp.sum(w) * self._scale_constraints
692
+ == float(self.budget) * factor * self._scale_constraints
693
+ )
694
+
695
+ if self.linear_constraints is not None:
696
+ if self.groups is None:
697
+ if not hasattr(self, "feature_names_in_"):
698
+ raise ValueError(
699
+ "If `linear_constraints` is provided you must provide either"
700
+ " `groups` or `X` as a DataFrame with asset names in columns"
701
+ )
702
+ groups = np.asarray([self.feature_names_in_])
703
+ else:
704
+ groups = input_to_array(
705
+ items=self.groups,
706
+ n_assets=n_assets,
707
+ fill_value="",
708
+ dim=2,
709
+ assets_names=(
710
+ self.feature_names_in_
711
+ if hasattr(self, "feature_names_in_")
712
+ else None
713
+ ),
714
+ name="groups",
715
+ )
716
+ a, b = equations_to_matrix(
717
+ groups=groups,
718
+ equations=self.linear_constraints,
719
+ raise_if_group_missing=False,
720
+ )
721
+ if np.any(a != 0):
722
+ constraints.append(
723
+ a @ w * self._scale_constraints
724
+ - b * factor * self._scale_constraints
725
+ <= 0
726
+ )
727
+
728
+ if self.left_inequality is not None and self.right_inequality is not None:
729
+ left_inequality = np.asarray(self.left_inequality)
730
+ right_inequality = np.asarray(self.right_inequality)
731
+ if left_inequality.ndim != 2:
732
+ raise ValueError(
733
+ f"`left_inequality` must be a 2D array, got {left_inequality.ndim}D"
734
+ " array"
735
+ )
736
+ if right_inequality.ndim != 1:
737
+ raise ValueError(
738
+ "`right_inequality` must be a 1D array, got"
739
+ f" {right_inequality.ndim}D array"
740
+ )
741
+ if left_inequality.shape[1] != n_assets:
742
+ raise ValueError(
743
+ "`left_inequality` must be of shape (n_inequalities, n_assets) "
744
+ f"with n_assets={n_assets}, got {left_inequality.shape[1]}"
745
+ )
746
+ if left_inequality.shape[0] != right_inequality.shape[0]:
747
+ raise ValueError(
748
+ "`left_inequality` and `right_inequality` must have same number of"
749
+ f" rows (i.e. n_inequalities) , got {left_inequality.shape[0]} and"
750
+ f" {right_inequality.shape[0]}"
751
+ )
752
+ constraints.append(
753
+ self.left_inequality @ w * self._scale_constraints
754
+ - self.right_inequality * factor * self._scale_constraints
755
+ <= 0
756
+ )
757
+
758
+ return constraints
759
+
760
+ def _set_solver(self, default: str) -> None:
761
+ """Set solver by saving its value in `_solver`.
762
+ It uses `solver` if provided otherwise it uses the `default` solver.
763
+
764
+ Parameters
765
+ ----------
766
+ default : str
767
+ The default solver to use when `solver` is `None`.
768
+ """
769
+ if self.solver is None:
770
+ self._solver = default
771
+ else:
772
+ self._solver = self.solver
773
+ if self._solver not in INSTALLED_SOLVERS:
774
+ raise ValueError(f"The solver {self._solver} is not installed.")
775
+
776
+ def _set_scale_objective(self, default: float) -> None:
777
+ """Set the objective scale by saving its value in `_scale_objective`.
778
+ It uses `scale_objective` if provided otherwise it uses the `default` scale.
779
+
780
+ Parameters
781
+ ----------
782
+ default : float
783
+ The default objective scale to use when `scale_objective` is `None`.
784
+ """
785
+ if self.scale_objective is None:
786
+ self._scale_objective = cp.Constant(default)
787
+ else:
788
+ self._scale_objective = cp.Constant(self.scale_objective)
789
+
790
+ def _set_scale_constraints(self, default: float) -> None:
791
+ """Set the constraints scale by saving its value in `_scale_constraints`.
792
+ It uses `scale_constraints` if provided otherwise it uses the `default` scale.
793
+
794
+ Parameters
795
+ ----------
796
+ default : float
797
+ The default constraints scale to use when `scale_constraints` is `None`.
798
+ """
799
+ if self.scale_constraints is None:
800
+ self._scale_constraints = cp.Constant(default)
801
+ else:
802
+ self._scale_constraints = cp.Constant(self.scale_constraints)
803
+
804
+ def _get_custom_objective(self, w: cp.Variable) -> cp.Expression:
805
+ """Returns the CVXPY expression evaluated by calling the `add_objective`
806
+ function if provided, otherwise returns the CVXPY constant `0`.
807
+
808
+ Parameters
809
+ ----------
810
+ w : cvxpy Variable
811
+ The CVXPY Variable representing assets weights.
812
+
813
+ Returns
814
+ -------
815
+ expression : cvxpy Expression
816
+ The CVXPY expression evaluated by calling the `add_objective`
817
+ function if provided, otherwise returns the CVXPY constant `0`.
818
+ """
819
+ if self.add_objective is None:
820
+ return cp.Constant(0)
821
+ return self._call_custom_func(
822
+ func=self.add_objective, w=w, name="add_objective"
823
+ )
824
+
825
+ def _get_custom_constraints(self, w: cp.Variable) -> list[cp.Expression]:
826
+ """Returns the list of CVXPY expressions evaluated by calling the
827
+ `add_constraint`s function if provided, otherwise returns an empty list.
828
+
829
+ Parameters
830
+ ----------
831
+ w : cvxpy Variable
832
+ The CVXPY Variable representing assets weights.
833
+
834
+ Returns
835
+ -------
836
+ expressions : list of cvxpy Expression
837
+ The list of CVXPY expressions evaluated by calling the
838
+ `add_constraints` function if provided, otherwise returns an empty list.
839
+ """
840
+ if self.add_constraints is None:
841
+ return []
842
+ constraints = self._call_custom_func(
843
+ func=self.add_constraints, w=w, name="add_constraint"
844
+ )
845
+ if isinstance(constraints, list):
846
+ return constraints
847
+ return [constraints]
848
+
849
+ @cache_method("_cvx_cache")
850
+ def _cvx_expected_return(
851
+ self, prior_model: PriorModel, w: cp.Variable
852
+ ) -> cp.Expression:
853
+ """Expected Return expression"""
854
+ if self.overwrite_expected_return is None:
855
+ expected_return = prior_model.mu @ w
856
+ else:
857
+ expected_return = self._call_custom_func(
858
+ func=self.overwrite_expected_return,
859
+ w=w,
860
+ name="overwrite_expected_return",
861
+ )
862
+ return expected_return
863
+
864
+ # Model reused among multiple risk measure
865
+ def _solve_problem(
866
+ self,
867
+ problem: cp.Problem,
868
+ w: cp.Variable,
869
+ factor: skt.Factor,
870
+ parameters_values: skt.ParametersValues = None,
871
+ expressions: dict[str, cp.Expression] | None = None,
872
+ ) -> None:
873
+ """Solve the CVXPY Problem and save the results in `weights_`, `problem_values_`
874
+ and `problem_`.
875
+
876
+ Parameters
877
+ ----------
878
+ problem : cvxpy Problem
879
+ The CVXPY Problem.
880
+
881
+ w : cvxpy Variable
882
+ The CVXPY Variable representing assets weights.
883
+
884
+ expressions : dict[str, cvxpy Expression] | None, optional
885
+ Dictionary of CVXPY Expressions from which values are retrieved and saved
886
+ in `expression_values_`. It is used to save additional information about
887
+ the problem.
888
+
889
+ parameters_values: list[tuple[cvxpy Parameter, float | ndarray]], optional
890
+ A list of tuple of CVXPY Parameter and their values.
891
+ If The values are ndarray instead of float, the optimization is solved for
892
+ each element in the array.
893
+
894
+ factor: cvxpy Variable | cvxpy Constant
895
+ CVXPY Variable or Constant used for RatioMeasure optimization problems.
896
+ """
897
+ if parameters_values is None:
898
+ parameters_values = []
899
+
900
+ if expressions is None:
901
+ expressions = {}
902
+
903
+ n_optimizations = 1
904
+ if len(parameters_values) != 0:
905
+ # If the parameter value is a list, each element is the parameter value of
906
+ # a distinct optimization. Therefore, each list must have same length.
907
+ sizes = [len(v) for p, v in parameters_values if not np.isscalar(v)]
908
+ if not np.all(sizes):
909
+ raise ValueError(
910
+ "All list elements from `parameters_values` should have same length"
911
+ )
912
+ if len(sizes) != 0:
913
+ n_optimizations = sizes[0]
914
+ # Scalar parameter values will be used in each optimization, therefore we
915
+ # transform them to a list.
916
+ parameters_values = [
917
+ (p, [v] * n_optimizations) if np.isscalar(v) else (p, v)
918
+ for p, v in parameters_values
919
+ ]
920
+
921
+ solver_params = self.solver_params
922
+ if solver_params is None:
923
+ solver_params = {}
924
+ all_weights = []
925
+ all_problem_values = []
926
+ optimal = True
927
+ for i in range(n_optimizations):
928
+ for parameter, values in parameters_values:
929
+ parameter.value = values[i]
930
+
931
+ try:
932
+ # We suppress cvxpy warning as it is redundant with our warning
933
+ with warnings.catch_warnings():
934
+ warnings.simplefilter("ignore")
935
+ problem.solve(solver=self._solver, **solver_params)
936
+
937
+ if w.value is None:
938
+ raise cp.SolverError("No solution found")
939
+
940
+ weights = w.value / factor.value
941
+ problem_values = {
942
+ name: expression.value / factor.value
943
+ for name, expression in expressions.items()
944
+ }
945
+ problem_values["objective"] = (
946
+ problem.value / self._scale_objective.value
947
+ )
948
+
949
+ if (
950
+ self.risk_measure
951
+ in [RiskMeasure.VARIANCE, RiskMeasure.SEMI_VARIANCE]
952
+ and "risk" in problem_values
953
+ ):
954
+ problem_values["risk"] /= factor.value
955
+
956
+ all_problem_values.append(problem_values)
957
+ all_weights.append(np.array(weights, dtype=float))
958
+
959
+ if problem.status != cp.OPTIMAL:
960
+ optimal = False
961
+ except (cp.SolverError, scl.ArpackNoConvergence):
962
+ params_string = " ".join(
963
+ [f"{p.value:0g}" for p in problem.parameters()]
964
+ )
965
+ if len(params_string) != 0:
966
+ params_string = f" with parameters {params_string}"
967
+ msg = (
968
+ f"Solver '{self._solver}' failed{params_string}. Try another"
969
+ " solver, or solve with solver_params=dict(verbose=True) for more"
970
+ " information"
971
+ )
972
+ if self.raise_on_failure:
973
+ raise cp.SolverError(msg) from None
974
+ else:
975
+ warnings.warn(msg, stacklevel=2)
976
+
977
+ if not optimal:
978
+ warnings.warn(
979
+ "Solution may be inaccurate. Try changing the solver params or the"
980
+ " scale. For more details, set `solver_params=dict(verbose=True)`",
981
+ stacklevel=2,
982
+ )
983
+
984
+ if n_optimizations == 1:
985
+ self.weights_ = all_weights[0]
986
+ self.problem_values_ = all_problem_values[0]
987
+ else:
988
+ self.weights_ = np.array(all_weights, dtype=float)
989
+ self.problem_values_ = all_problem_values
990
+
991
+ self.problem_ = problem
992
+
993
+ @cache_method("_cvx_cache")
994
+ def _cvx_mu_uncertainty_set(
995
+ self, mu_uncertainty_set: UncertaintySet, w: cp.Variable
996
+ ) -> cp.Expression:
997
+ """Uncertainty Set expression of expected returns.
998
+
999
+ Parameters
1000
+ ----------
1001
+ mu_uncertainty_set : UncertaintySet
1002
+ The uncertainty set model of expected returns.
1003
+
1004
+ w : cvxpy Variable
1005
+ The CVXPY Variable representing assets weights.
1006
+
1007
+ Returns
1008
+ -------
1009
+ expression : cvxpy Expression
1010
+ The CVXPY Expression of the uncertainty set of expected returns.
1011
+ """
1012
+ return mu_uncertainty_set.k * cp.pnorm(
1013
+ sc.linalg.sqrtm(mu_uncertainty_set.sigma) @ w, 2
1014
+ )
1015
+
1016
+ @cache_method("_cvx_cache")
1017
+ def _cvx_regularization(self, w: cp.Variable) -> cp.Expression:
1018
+ """L1 and L2 regularization expression.
1019
+
1020
+ Parameters
1021
+ ----------
1022
+ w : cvxpy Variable
1023
+ The CVXPY Variable representing assets weights.
1024
+
1025
+ Returns
1026
+ -------
1027
+ expression : cvxpy Expression
1028
+ The CVXPY Expression of L1 and L2 regularization.
1029
+ """
1030
+ # Norm L1
1031
+ if self.l1_coef is None or self.l1_coef == 0:
1032
+ l1_reg = cp.Constant(0)
1033
+ else:
1034
+ l1_reg = cp.Constant(self.l1_coef) * cp.norm(w, 1)
1035
+
1036
+ # Norm L2
1037
+ if self.l2_coef is None or self.l2_coef == 0:
1038
+ l2_reg = cp.Constant(0)
1039
+ else:
1040
+ l2_reg = self.l2_coef * cp.sum_squares(w)
1041
+ regularization = l1_reg + l2_reg
1042
+ return regularization
1043
+
1044
+ @cache_method("_cvx_cache")
1045
+ def _cvx_transaction_cost(
1046
+ self, prior_model: PriorModel, w: cp.Variable, factor: skt.Factor
1047
+ ) -> cp.Expression:
1048
+ """Transaction cost expression.
1049
+
1050
+ Parameters
1051
+ ----------
1052
+ prior_model : PriorModel
1053
+ The prior model of the assets distributions.
1054
+
1055
+ w : cvxpy Variable
1056
+ The CVXPY Variable representing assets weights.
1057
+
1058
+ factor : cvxpy Variable | cvxpy Constant
1059
+ Additional variable used for the optimization of some objective function
1060
+ like the ratio maximization.
1061
+
1062
+ Returns
1063
+ -------
1064
+ expression : cvxpy Expression
1065
+ The CVXPY Expression of transaction cost.
1066
+ """
1067
+ n_assets = prior_model.returns.shape[1]
1068
+
1069
+ transaction_costs = self._clean_input(
1070
+ self.transaction_costs,
1071
+ n_assets=n_assets,
1072
+ fill_value=0,
1073
+ name="transaction_costs",
1074
+ )
1075
+ if np.all(transaction_costs == 0):
1076
+ return cp.Constant(0)
1077
+
1078
+ previous_weights = self._clean_input(
1079
+ self.previous_weights,
1080
+ n_assets=n_assets,
1081
+ fill_value=0,
1082
+ name="previous_weights",
1083
+ )
1084
+ if np.isscalar(previous_weights):
1085
+ previous_weights *= np.ones(n_assets)
1086
+
1087
+ if np.isscalar(transaction_costs):
1088
+ return transaction_costs * cp.norm(previous_weights * factor - w, 1)
1089
+ return cp.norm(
1090
+ cp.multiply(transaction_costs, (previous_weights * factor - w)),
1091
+ 1,
1092
+ )
1093
+
1094
+ @cache_method("_cvx_cache")
1095
+ def _cvx_management_fee(
1096
+ self, prior_model: PriorModel, w: cp.Variable
1097
+ ) -> cp.Expression:
1098
+ """Management fee expression.
1099
+
1100
+ Parameters
1101
+ ----------
1102
+ prior_model : PriorModel
1103
+ The prior model of the assets distributions.
1104
+
1105
+ w : cvxpy Variable
1106
+ The CVXPY Variable representing assets weights.
1107
+
1108
+ Returns
1109
+ -------
1110
+ expression : cvxpy Expression
1111
+ The CVXPY Expression of management fee .
1112
+ """
1113
+ n_assets = prior_model.returns.shape[1]
1114
+
1115
+ management_fees = self._clean_input(
1116
+ self.management_fees,
1117
+ n_assets=n_assets,
1118
+ fill_value=0,
1119
+ name="management_fees",
1120
+ )
1121
+ if np.all(management_fees == 0):
1122
+ return cp.Constant(0)
1123
+
1124
+ if np.isscalar(management_fees):
1125
+ management_fees *= np.ones(n_assets)
1126
+ return management_fees @ w
1127
+
1128
+ @cache_method("_cvx_cache")
1129
+ def _cvx_returns(self, prior_model: PriorModel, w: cp.Variable) -> cp.Expression:
1130
+ """Expression of the portfolio returns series.
1131
+
1132
+ Parameters
1133
+ ----------
1134
+ prior_model : PriorModel
1135
+ The prior model of the assets distributions.
1136
+
1137
+ w : cvxpy Variable
1138
+ The CVXPY Variable representing assets weights.
1139
+
1140
+ Returns
1141
+ -------
1142
+ expression : cvxpy Expression
1143
+ The CVXPY Expression the portfolio returns series.
1144
+ """
1145
+ returns = prior_model.returns @ w
1146
+ return returns
1147
+
1148
+ @cache_method("_cvx_cache")
1149
+ def _turnover(
1150
+ self, n_assets: int, w: cp.Variable, factor: skt.Factor
1151
+ ) -> cp.Expression:
1152
+ """Expression of the portfolio turnover.
1153
+
1154
+ Parameters
1155
+ ----------
1156
+ n_assets : int
1157
+ The number of assets.
1158
+
1159
+ w : cvxpy Variable
1160
+ The CVXPY Variable representing assets weights.
1161
+
1162
+ factor : cvxpy Variable | cvxpy Constant
1163
+ Additional variable used for the optimization of some objective function
1164
+ like the ratio maximization.
1165
+
1166
+ Returns
1167
+ -------
1168
+ expression : cvxpy Expression
1169
+ The CVXPY Expression the portfolio turnover.
1170
+ """
1171
+ if self.previous_weights is None:
1172
+ raise ValueError(
1173
+ "If you provide `max_turnover`, you must also provide "
1174
+ " `previous_weights`"
1175
+ )
1176
+ previous_weights = self._clean_input(
1177
+ self.previous_weights,
1178
+ n_assets=n_assets,
1179
+ fill_value=0,
1180
+ name="previous_weights",
1181
+ )
1182
+ if np.isscalar(previous_weights):
1183
+ previous_weights *= np.ones(n_assets)
1184
+ turnover = cp.abs(w - previous_weights * factor)
1185
+ return turnover
1186
+
1187
+ @cache_method("_cvx_cache")
1188
+ def _cvx_min_acceptable_return(
1189
+ self,
1190
+ prior_model: PriorModel,
1191
+ w: cp.Variable,
1192
+ min_acceptable_return: skt.Target = None,
1193
+ ) -> cp.Expression:
1194
+ """Expression of the portfolio Minimum Acceptable Returns.
1195
+
1196
+ Parameters
1197
+ ----------
1198
+ prior_model : PriorModel
1199
+ The prior model of the assets distributions..
1200
+
1201
+ w : cvxpy Variable
1202
+ The CVXPY Variable representing assets weights.
1203
+
1204
+ min_acceptable_return : float | ndarray of shape (n_assets,)
1205
+ The minimum acceptable return used to distinguish "downside" and "upside"
1206
+ returns for the computation of lower partial moments.
1207
+
1208
+ Returns
1209
+ -------
1210
+ expression : cvxpy Expression
1211
+ The CVXPY Expression the portfolio Minimum Acceptable Returns.
1212
+ """
1213
+ if min_acceptable_return is None:
1214
+ min_acceptable_return = prior_model.mu
1215
+ if not np.isscalar(min_acceptable_return) and min_acceptable_return.shape != (
1216
+ len(min_acceptable_return),
1217
+ 1,
1218
+ ):
1219
+ min_acceptable_return = min_acceptable_return[np.newaxis, :]
1220
+ mar = (prior_model.returns - min_acceptable_return) @ w
1221
+ return mar
1222
+
1223
+ @cache_method("_cvx_cache")
1224
+ def __cvx_drawdown(
1225
+ self, prior_model: PriorModel, w: cp.Variable, factor: skt.Factor
1226
+ ) -> tuple[cp.Variable, list[cp.Expression]]:
1227
+ """Expression of the portfolio drawdown.
1228
+
1229
+ Parameters
1230
+ ----------
1231
+ prior_model : PriorModel
1232
+ The prior model of the assets distributions.
1233
+
1234
+ w : cvxpy Variable
1235
+ The CVXPY Variable representing assets weights.
1236
+
1237
+ factor : cvxpy Variable | cvxpy Constant
1238
+ Additional variable used for the optimization of some objective function
1239
+ like the ratio maximization.
1240
+
1241
+ Returns
1242
+ -------
1243
+ expression : cvxpy Expression
1244
+ The CVXPY Expression the portfolio drawdown.
1245
+ """
1246
+ n_observations = prior_model.returns.shape[0]
1247
+ ptf_returns = self._cvx_returns(prior_model=prior_model, w=w)
1248
+ ptf_transaction_cost = self._cvx_transaction_cost(
1249
+ prior_model=prior_model, w=w, factor=factor
1250
+ )
1251
+ ptf_management_fee = self._cvx_management_fee(prior_model=prior_model, w=w)
1252
+ v = cp.Variable(n_observations + 1)
1253
+ constraints = [
1254
+ v[1:] * self._scale_constraints
1255
+ >= v[:-1] * self._scale_constraints
1256
+ - ptf_returns * self._scale_constraints
1257
+ + ptf_transaction_cost * self._scale_constraints
1258
+ + ptf_management_fee * self._scale_constraints,
1259
+ v[1:] * self._scale_constraints >= 0,
1260
+ v[0] * self._scale_constraints == 0,
1261
+ ]
1262
+ return v, constraints
1263
+
1264
+ def _cvx_drawdown(
1265
+ self, prior_model: PriorModel, w: cp.Variable, factor: skt.Factor
1266
+ ) -> tuple[cp.Variable, list[cp.Expression]]:
1267
+ """Expression of the portfolio drawdown.
1268
+ Wrapper around __cvx_drawdown to avoid re-adding the constraints when they
1269
+ have already been included in the problem.
1270
+
1271
+ Parameters
1272
+ ----------
1273
+ prior_model : PriorModel
1274
+ The prior model of the assets distributions.
1275
+
1276
+ w : cvxpy Variable
1277
+ The CVXPY Variable representing assets weights.
1278
+
1279
+ factor : cvxpy Variable | cvxpy Constant
1280
+ Additional variable used for the optimization of some objective function
1281
+ like the ratio maximization.
1282
+
1283
+ Returns
1284
+ -------
1285
+ expression : cvxpy Expression
1286
+ The CVXPY Expression the portfolio drawdown.
1287
+ """
1288
+ if "__cvx_drawdown" in self._cvx_cache:
1289
+ v, _ = self.__cvx_drawdown(prior_model=prior_model, w=w, factor=factor)
1290
+ return v, []
1291
+ return self.__cvx_drawdown(prior_model=prior_model, w=w, factor=factor)
1292
+
1293
+ def _tracking_error(
1294
+ self, prior_model: PriorModel, w: cp.Variable, y: np.ndarray, factor: skt.Factor
1295
+ ) -> cp.Expression:
1296
+ """Expression of the portfolio tracking error.
1297
+
1298
+ Parameters
1299
+ ----------
1300
+ prior_model : PriorModel
1301
+ The prior model of the assets distributions.
1302
+
1303
+ w : cvxpy Variable
1304
+ The CVXPY Variable representing assets weights.
1305
+
1306
+ y : ndarray of shape (n_observations,)
1307
+ Benchmark for the tracking error computation.
1308
+
1309
+ factor : cvxpy Variable | cvxpy Constant
1310
+ Additional variable used for the optimization of some objective function
1311
+ like the ratio maximization.
1312
+
1313
+ Returns
1314
+ -------
1315
+ expression : cvxpy Expression
1316
+ The CVXPY Expression the portfolio tracking error.
1317
+ """
1318
+ n_observations = prior_model.returns.shape[0]
1319
+ ptf_returns = self._cvx_returns(prior_model=prior_model, w=w)
1320
+ tracking_error = cp.norm(ptf_returns - y * factor, "fro") / cp.sqrt(
1321
+ n_observations - 1
1322
+ )
1323
+ return tracking_error
1324
+
1325
+ # Risk Measures risk models
1326
+ # They need to be named f'_{risk_measure}_risk' as they are loaded dynamically in
1327
+ # mean_risk_optimization()
1328
+ def _mean_absolute_deviation_risk(
1329
+ self, prior_model: PriorModel, w: cp.Variable, min_acceptable_return: skt.Target
1330
+ ) -> skt.RiskResult:
1331
+ """Expression and Constraints of the Mean Absolute Deviation risk measure.
1332
+
1333
+ Parameters
1334
+ ----------
1335
+ prior_model : PriorModel
1336
+ The prior model of the assets distributions.
1337
+
1338
+ w : cvxpy Variable
1339
+ The CVXPY Variable representing assets weights.
1340
+
1341
+ min_acceptable_return : float | ndarray of shape (n_assets,)
1342
+ The minimum acceptable return used to distinguish "downside" and "upside"
1343
+ returns for the computation of lower partial moments.
1344
+
1345
+ Returns
1346
+ -------
1347
+ expression : tuple[cvxpy Expression , list[cvxpy Expression]]
1348
+ CVXPY Expression and Constraints of the Mean Absolute Deviation risk
1349
+ measure.
1350
+ """
1351
+ n_observations = prior_model.returns.shape[0]
1352
+ ptf_min_acceptable_return = self._cvx_min_acceptable_return(
1353
+ prior_model=prior_model, w=w, min_acceptable_return=min_acceptable_return
1354
+ )
1355
+ v = cp.Variable(n_observations, nonneg=True)
1356
+ risk = 2 * cp.sum(v) / n_observations
1357
+ constraints = [
1358
+ ptf_min_acceptable_return * self._scale_constraints
1359
+ >= -v * self._scale_constraints
1360
+ ]
1361
+ return risk, constraints
1362
+
1363
+ def _first_lower_partial_moment_risk(
1364
+ self,
1365
+ prior_model: PriorModel,
1366
+ w: cp.Variable,
1367
+ min_acceptable_return: skt.Target,
1368
+ factor: skt.Factor,
1369
+ ) -> skt.RiskResult:
1370
+ """Expression and Constraints of the First Lower Partial Moment risk measure.
1371
+
1372
+ Parameters
1373
+ ----------
1374
+ prior_model : PriorModel
1375
+ The prior model of the assets distributions.
1376
+
1377
+ w : cvxpy Variable
1378
+ The CVXPY Variable representing assets weights.
1379
+
1380
+ min_acceptable_return : float | ndarray of shape (n_assets,)
1381
+ The minimum acceptable return used to distinguish "downside" and "upside"
1382
+ returns for the computation of lower partial moments.
1383
+
1384
+ factor : cvxpy Variable | cvxpy Constant
1385
+ Additional variable used for the optimization of some objective function
1386
+ like the ratio maximization.
1387
+
1388
+ Returns
1389
+ -------
1390
+ expression : tuple[cvxpy Expression , list[cvxpy Expression]]
1391
+ CVXPY Expression and Constraints of the First Lower Partial Moment risk
1392
+ measure.
1393
+ """
1394
+ n_observations = prior_model.returns.shape[0]
1395
+ ptf_min_acceptable_return = self._cvx_min_acceptable_return(
1396
+ prior_model=prior_model, w=w, min_acceptable_return=min_acceptable_return
1397
+ )
1398
+ v = cp.Variable(n_observations, nonneg=True)
1399
+ risk = cp.sum(v) / n_observations
1400
+ constraints = [
1401
+ self.risk_free_rate * factor * self._scale_constraints
1402
+ - ptf_min_acceptable_return * self._scale_constraints
1403
+ <= v * self._scale_constraints
1404
+ ]
1405
+ return risk, constraints
1406
+
1407
+ def _standard_deviation_risk(
1408
+ self, prior_model: PriorModel, w: cp.Variable
1409
+ ) -> skt.RiskResult:
1410
+ """Expression and Constraints of the Standard Deviation risk measure.
1411
+
1412
+ Parameters
1413
+ ----------
1414
+ prior_model : PriorModel
1415
+ The prior model of the assets distributions.
1416
+
1417
+ w : cvxpy Variable
1418
+ The CVXPY Variable representing assets weights.
1419
+
1420
+ Returns
1421
+ -------
1422
+ expression : tuple[cvxpy Expression , list[cvxpy Expression]]
1423
+ CVXPY Expression and Constraints of the Standard Deviation risk measure.
1424
+ """
1425
+ v = cp.Variable(
1426
+ nonneg=True
1427
+ ) # nonneg=True instead of constraint v>=0 is preferred for better DCP analysis
1428
+ if prior_model.cholesky is not None:
1429
+ z = prior_model.cholesky
1430
+ else:
1431
+ z = np.linalg.cholesky(prior_model.covariance)
1432
+ risk = v
1433
+ constraints = [
1434
+ cp.SOC(v * self._scale_constraints, z.T @ w * self._scale_constraints)
1435
+ ]
1436
+ return risk, constraints
1437
+
1438
+ def _variance_risk(self, prior_model: PriorModel, w: cp.Variable) -> skt.RiskResult:
1439
+ """Expression and Constraints of the Variance risk measure.
1440
+
1441
+ Parameters
1442
+ ----------
1443
+ prior_model : PriorModel
1444
+ The prior model of the assets distributions.
1445
+
1446
+ w : cvxpy Variable
1447
+ The CVXPY Variable representing assets weights.
1448
+
1449
+ Returns
1450
+ -------
1451
+ expression : tuple[cvxpy Expression , list[cvxpy Expression]]
1452
+ CVXPY Expression and Constraints the Variance risk measure.
1453
+ """
1454
+ risk, constraints = self._standard_deviation_risk(prior_model=prior_model, w=w)
1455
+ risk = cp.square(risk)
1456
+ return risk, constraints
1457
+
1458
+ def _worst_case_variance_risk(
1459
+ self,
1460
+ prior_model: PriorModel,
1461
+ covariance_uncertainty_set: UncertaintySet,
1462
+ w: cp.Variable,
1463
+ factor: skt.Factor,
1464
+ ) -> skt.RiskResult:
1465
+ """Expression and Constraints of the Worst Case Variance.
1466
+
1467
+ Parameters
1468
+ ----------
1469
+ prior_model : PriorModel
1470
+ The prior model of the assets distributions.
1471
+
1472
+ covariance_uncertainty_set : UncertaintySet
1473
+ :ref:`Covariance Uncertainty set estimator <uncertainty_set_estimator>`.
1474
+
1475
+ w : cvxpy Variable
1476
+ The CVXPY Variable representing assets weights.
1477
+
1478
+ factor : cvxpy Variable | cvxpy Constant
1479
+ Additional variable used for the optimization of some objective function
1480
+ like the ratio maximization.
1481
+
1482
+ Returns
1483
+ -------
1484
+ expression : tuple[cvxpy Expression , list[cvxpy Expression]]
1485
+ CVXPY Expression and Constraints the Worst Case Variance.
1486
+ """
1487
+ n_assets = prior_model.returns.shape[1]
1488
+ x = cp.Variable((n_assets, n_assets), symmetric=True)
1489
+ y = cp.Variable((n_assets, n_assets), symmetric=True)
1490
+ w_reshaped = cp.reshape(w, (n_assets, 1))
1491
+ factor_reshaped = cp.reshape(factor, (1, 1))
1492
+ z1 = cp.vstack([x, w_reshaped.T])
1493
+ z2 = cp.vstack([w_reshaped, factor_reshaped])
1494
+
1495
+ risk = covariance_uncertainty_set.k * cp.pnorm(
1496
+ sc.linalg.sqrtm(covariance_uncertainty_set.sigma) @ (cp.vec(x) + cp.vec(y)),
1497
+ 2,
1498
+ ) + cp.trace(prior_model.covariance @ (x + y))
1499
+ # semi-definite positive constraints
1500
+ # noinspection PyTypeChecker
1501
+ constraints = [
1502
+ cp.hstack([z1, z2]) * self._scale_constraints >> 0,
1503
+ y * self._scale_constraints >> 0,
1504
+ ]
1505
+ return risk, constraints
1506
+
1507
+ def _semi_variance_risk(
1508
+ self,
1509
+ prior_model: PriorModel,
1510
+ w: cp.Variable,
1511
+ min_acceptable_return: skt.Target = None,
1512
+ ) -> skt.RiskResult:
1513
+ """Expression and Constraints of the Semi Variance risk measure.
1514
+
1515
+ Parameters
1516
+ ----------
1517
+ prior_model : PriorModel
1518
+ The prior model of the assets distributions.
1519
+
1520
+ w : cvxpy Variable
1521
+ The CVXPY Variable representing assets weights.
1522
+
1523
+ min_acceptable_return : float | ndarray of shape (n_assets,)
1524
+ The minimum acceptable return used to distinguish "downside" and "upside"
1525
+ returns for the computation of lower partial moments.
1526
+
1527
+ Returns
1528
+ -------
1529
+ expression : tuple[cvxpy Expression , list[cvxpy Expression]]
1530
+ CVXPY Expression and Constraints the Semi Variance risk measure.
1531
+ """
1532
+ n_observations = prior_model.returns.shape[0]
1533
+ ptf_min_acceptable_return = self._cvx_min_acceptable_return(
1534
+ prior_model=prior_model, w=w, min_acceptable_return=min_acceptable_return
1535
+ )
1536
+ v = cp.Variable(n_observations, nonneg=True)
1537
+ risk = cp.sum_squares(v) / (n_observations - 1)
1538
+ constraints = [
1539
+ ptf_min_acceptable_return * self._scale_constraints
1540
+ >= -v * self._scale_constraints
1541
+ ]
1542
+ return risk, constraints
1543
+
1544
+ def _semi_deviation_risk(
1545
+ self,
1546
+ prior_model: PriorModel,
1547
+ w: cp.Variable,
1548
+ min_acceptable_return: skt.Target = None,
1549
+ ) -> skt.RiskResult:
1550
+ """Expression and Constraints of the Semi Standard Deviation risk measure.
1551
+
1552
+ Parameters
1553
+ ----------
1554
+ prior_model : PriorModel
1555
+ The prior model of the assets distributions.
1556
+
1557
+ w : cvxpy Variable
1558
+ The CVXPY Variable representing assets weights.
1559
+
1560
+ min_acceptable_return : float | ndarray of shape (n_assets,)
1561
+ The minimum acceptable return used to distinguish "downside" and "upside"
1562
+ returns for the computation of lower partial moments.
1563
+
1564
+ Returns
1565
+ -------
1566
+ expression : tuple[cvxpy Expression , list[cvxpy Expression]]
1567
+ CVXPY Expression and Constraints the Semi Standard Deviation risk measure.
1568
+ """
1569
+ n_observations = prior_model.returns.shape[0]
1570
+ ptf_min_acceptable_return = self._cvx_min_acceptable_return(
1571
+ prior_model=prior_model, w=w, min_acceptable_return=min_acceptable_return
1572
+ )
1573
+ v = cp.Variable(n_observations, nonneg=True)
1574
+ risk = cp.norm(v, 2) / np.sqrt(n_observations - 1)
1575
+ constraints = [
1576
+ ptf_min_acceptable_return * self._scale_constraints
1577
+ >= -v * self._scale_constraints
1578
+ ]
1579
+ return risk, constraints
1580
+
1581
+ def _fourth_central_moment_risk(self, w: cp.Variable, factor: skt.Factor):
1582
+ raise NotImplementedError
1583
+
1584
+ def _fourth_lower_partial_moment_risk(self, w: cp.Variable, factor: skt.Factor):
1585
+ raise NotImplementedError
1586
+
1587
+ def _worst_realization_risk(
1588
+ self, prior_model: PriorModel, w: cp.Variable, factor: skt.Factor
1589
+ ) -> skt.RiskResult:
1590
+ """Expression and Constraints of the Worst Realization risk measure.
1591
+
1592
+ Parameters
1593
+ ----------
1594
+ prior_model : PriorModel
1595
+ The prior model of the assets distributions.
1596
+
1597
+ w : cvxpy Variable
1598
+ The CVXPY Variable representing assets weights.
1599
+
1600
+ factor : cvxpy Variable | cvxpy Constant
1601
+ Additional variable used for the optimization of some objective function
1602
+ like the ratio maximization.
1603
+
1604
+ Returns
1605
+ -------
1606
+ expression : tuple[cvxpy Expression , list[cvxpy Expression]]
1607
+ CVXPY Expression and Constraints the Worst Realization risk measure.
1608
+ """
1609
+ ptf_returns = self._cvx_returns(prior_model=prior_model, w=w)
1610
+ ptf_transaction_cost = self._cvx_transaction_cost(
1611
+ prior_model=prior_model, w=w, factor=factor
1612
+ )
1613
+ ptf_management_fee = self._cvx_management_fee(prior_model=prior_model, w=w)
1614
+ v = cp.Variable()
1615
+ risk = v
1616
+ constraints = [
1617
+ -ptf_returns * self._scale_constraints
1618
+ + ptf_transaction_cost * self._scale_constraints
1619
+ + ptf_management_fee * self._scale_constraints
1620
+ <= v * self._scale_constraints
1621
+ ]
1622
+ return risk, constraints
1623
+
1624
+ def _cvar_risk(
1625
+ self,
1626
+ prior_model: PriorModel,
1627
+ w: cp.Variable,
1628
+ factor: skt.Factor,
1629
+ ) -> skt.RiskResult:
1630
+ """Expression and Constraints of the CVaR risk measure.
1631
+
1632
+ Parameters
1633
+ ----------
1634
+ prior_model : PriorModel
1635
+ The prior model of the assets distributions.
1636
+
1637
+ w : cvxpy Variable
1638
+ The CVXPY Variable representing assets weights.
1639
+
1640
+ factor : cvxpy Variable | cvxpy Constant
1641
+ Additional variable used for the optimization of some objective function
1642
+ like the ratio maximization.
1643
+
1644
+ Returns
1645
+ -------
1646
+ expression : tuple[cvxpy Expression , list[cvxpy Expression]]
1647
+ CVXPY Expression and Constraints the CVaR risk measure.
1648
+ """
1649
+ n_observations = prior_model.returns.shape[0]
1650
+ ptf_returns = self._cvx_returns(prior_model=prior_model, w=w)
1651
+ ptf_transaction_cost = self._cvx_transaction_cost(
1652
+ prior_model=prior_model, w=w, factor=factor
1653
+ )
1654
+ ptf_management_fee = self._cvx_management_fee(prior_model=prior_model, w=w)
1655
+ alpha = cp.Variable()
1656
+ v = cp.Variable(n_observations, nonneg=True)
1657
+ risk = alpha + 1.0 / (n_observations * (1 - self.cvar_beta)) * cp.sum(v)
1658
+ # noinspection PyTypeChecker
1659
+ constraints = [
1660
+ ptf_returns * self._scale_constraints
1661
+ - ptf_transaction_cost * self._scale_constraints
1662
+ - ptf_management_fee * self._scale_constraints
1663
+ + alpha * self._scale_constraints
1664
+ + v * self._scale_constraints
1665
+ >= 0
1666
+ ]
1667
+ return risk, constraints
1668
+
1669
+ def _evar_risk(
1670
+ self,
1671
+ prior_model: PriorModel,
1672
+ w: cp.Variable,
1673
+ factor: skt.Factor,
1674
+ ) -> skt.RiskResult:
1675
+ """Expression and Constraints of the EVaR risk measure.
1676
+
1677
+ Parameters
1678
+ ----------
1679
+ prior_model : PriorModel
1680
+ The prior model of the assets distributions.
1681
+
1682
+ w : cvxpy Variable
1683
+ The CVXPY Variable representing assets weights.
1684
+
1685
+ factor : cvxpy Variable | cvxpy Constant
1686
+ Additional variable used for the optimization of some objective function
1687
+ like the ratio maximization.
1688
+
1689
+ Returns
1690
+ -------
1691
+ expression : tuple[cvxpy Expression , list[cvxpy Expression]]
1692
+ CVXPY Expression and Constraints the EVaR risk measure.
1693
+ """
1694
+ n_observations = prior_model.returns.shape[0]
1695
+ ptf_returns = self._cvx_returns(prior_model=prior_model, w=w)
1696
+ ptf_transaction_cost = self._cvx_transaction_cost(
1697
+ prior_model=prior_model, w=w, factor=factor
1698
+ )
1699
+ ptf_management_fee = self._cvx_management_fee(prior_model=prior_model, w=w)
1700
+ # We don't include the transaction_cost in the constraint otherwise the problem
1701
+ # is not DCP
1702
+ if not isinstance(ptf_transaction_cost, cp.Constant):
1703
+ warnings.warn(
1704
+ "The EVaR problem will be relaxed by removing the transaction costs"
1705
+ " from the Cone constraint to keep the problem DCP. The solution may"
1706
+ " not be accurate.",
1707
+ stacklevel=2,
1708
+ )
1709
+
1710
+ x = cp.Variable()
1711
+ y = cp.Variable(nonneg=True)
1712
+ z = cp.Variable(n_observations)
1713
+ risk = x + y * np.log(1 / (n_observations * (1 - self.evar_beta)))
1714
+ constraints = [
1715
+ cp.sum(z) * self._scale_constraints <= y * self._scale_constraints,
1716
+ cp.constraints.ExpCone(
1717
+ -ptf_returns * self._scale_constraints
1718
+ + ptf_management_fee * self._scale_constraints
1719
+ - x * self._scale_constraints,
1720
+ np.ones(n_observations) * y * self._scale_constraints,
1721
+ z * self._scale_constraints,
1722
+ ),
1723
+ ]
1724
+ return risk, constraints
1725
+
1726
+ def _max_drawdown_risk(
1727
+ self, prior_model: PriorModel, w: cp.Variable, factor: skt.Factor
1728
+ ) -> skt.RiskResult:
1729
+ """Expression and Constraints of the EVaR risk measure.
1730
+
1731
+ Parameters
1732
+ ----------
1733
+ prior_model : PriorModel
1734
+ The prior model of the assets distributions.
1735
+
1736
+ w : cvxpy Variable
1737
+ The CVXPY Variable representing assets weights.
1738
+
1739
+ factor : cvxpy Variable | cvxpy Constant
1740
+ Additional variable used for the optimization of some objective function
1741
+ like the ratio maximization.
1742
+
1743
+ Returns
1744
+ -------
1745
+ expression : tuple[cvxpy Expression , list[cvxpy Expression]]
1746
+ CVXPY Expression and Constraints the EVaR risk measure.
1747
+ """
1748
+ v, constraints = self._cvx_drawdown(prior_model=prior_model, w=w, factor=factor)
1749
+ u = cp.Variable()
1750
+ risk = u
1751
+ constraints += [u * self._scale_constraints >= v[1:] * self._scale_constraints]
1752
+ return risk, constraints
1753
+
1754
+ def _average_drawdown_risk(
1755
+ self, prior_model: PriorModel, w: cp.Variable, factor: skt.Factor
1756
+ ) -> skt.RiskResult:
1757
+ """Expression and Constraints of the Average Drawdown risk measure.
1758
+
1759
+ Parameters
1760
+ ----------
1761
+ prior_model : PriorModel
1762
+ The prior model of the assets distributions.
1763
+
1764
+ w : cvxpy Variable
1765
+ The CVXPY Variable representing assets weights.
1766
+
1767
+ factor : cvxpy Variable | cvxpy Constant
1768
+ Additional variable used for the optimization of some objective function
1769
+ like the ratio maximization.
1770
+
1771
+ Returns
1772
+ -------
1773
+ expression : tuple[cvxpy Expression , list[cvxpy Expression]]
1774
+ CVXPY Expression and Constraints the Average Drawdown risk measure.
1775
+ """
1776
+ n_observations = prior_model.returns.shape[0]
1777
+ v, constraints = self._cvx_drawdown(prior_model=prior_model, w=w, factor=factor)
1778
+ risk = cp.sum(v[1:]) / n_observations
1779
+ return risk, constraints
1780
+
1781
+ def _cdar_risk(
1782
+ self,
1783
+ prior_model: PriorModel,
1784
+ w: cp.Variable,
1785
+ factor: skt.Factor,
1786
+ ) -> skt.RiskResult:
1787
+ """Expression and Constraints of the CDaR risk measure.
1788
+
1789
+ Parameters
1790
+ ----------
1791
+ prior_model : PriorModel
1792
+ The prior model of the assets distributions.
1793
+
1794
+ w : cvxpy Variable
1795
+ The CVXPY Variable representing assets weights.
1796
+
1797
+ factor : cvxpy Variable | cvxpy Constant
1798
+ Additional variable used for the optimization of some objective function
1799
+ like the ratio maximization.
1800
+
1801
+ Returns
1802
+ -------
1803
+ expression : tuple[cvxpy Expression , list[cvxpy Expression]]
1804
+ CVXPY Expression and Constraints the CDaR risk measure.
1805
+ """
1806
+ n_observations = prior_model.returns.shape[0]
1807
+ v, constraints = self._cvx_drawdown(prior_model=prior_model, w=w, factor=factor)
1808
+ alpha = cp.Variable()
1809
+ z = cp.Variable(n_observations, nonneg=True)
1810
+ risk = alpha + 1.0 / (n_observations * (1 - self.cdar_beta)) * cp.sum(z)
1811
+ constraints += [
1812
+ z * self._scale_constraints
1813
+ >= v[1:] * self._scale_constraints - alpha * self._scale_constraints
1814
+ ]
1815
+ return risk, constraints
1816
+
1817
+ def _edar_risk(
1818
+ self,
1819
+ prior_model: PriorModel,
1820
+ w: cp.Variable,
1821
+ factor: skt.Factor,
1822
+ ) -> skt.RiskResult:
1823
+ """Expression and Constraints of the EDaR risk measure.
1824
+
1825
+ Parameters
1826
+ ----------
1827
+ prior_model : PriorModel
1828
+ The prior model of the assets distributions.
1829
+
1830
+ w : cvxpy Variable
1831
+ The CVXPY Variable representing assets weights.
1832
+
1833
+ factor : cvxpy Variable | cvxpy Constant
1834
+ Additional variable used for the optimization of some objective function
1835
+ like the ratio maximization.
1836
+
1837
+ Returns
1838
+ -------
1839
+ expression : tuple[cvxpy Expression , list[cvxpy Expression]]
1840
+ CVXPY Expression and Constraints the EDaR risk measure.
1841
+ """
1842
+ n_observations = prior_model.returns.shape[0]
1843
+ v, constraints = self._cvx_drawdown(prior_model=prior_model, w=w, factor=factor)
1844
+ x = cp.Variable()
1845
+ y = cp.Variable(nonneg=True)
1846
+ z = cp.Variable(n_observations)
1847
+ risk = x + y * np.log(1 / (n_observations * (1 - self.edar_beta)))
1848
+ constraints += [
1849
+ cp.sum(z) * self._scale_constraints <= y * self._scale_constraints,
1850
+ cp.constraints.ExpCone(
1851
+ v[1:] * self._scale_constraints - x * self._scale_constraints,
1852
+ np.ones(n_observations) * y * self._scale_constraints,
1853
+ z * self._scale_constraints,
1854
+ ),
1855
+ ]
1856
+ return risk, constraints
1857
+
1858
+ def _ulcer_index_risk(
1859
+ self,
1860
+ prior_model: PriorModel,
1861
+ w: cp.Variable,
1862
+ factor: skt.Factor,
1863
+ ) -> skt.RiskResult:
1864
+ """Expression and Constraints of the Ulcer Index risk measure.
1865
+
1866
+ Parameters
1867
+ ----------
1868
+ prior_model : PriorModel
1869
+ The prior model of the assets distributions.
1870
+
1871
+ w : cvxpy Variable
1872
+ The CVXPY Variable representing assets weights.
1873
+
1874
+ factor : cvxpy Variable | cvxpy Constant
1875
+ Additional variable used for the optimization of some objective function
1876
+ like the ratio maximization.
1877
+
1878
+ Returns
1879
+ -------
1880
+ expression : tuple[cvxpy Expression , list[cvxpy Expression]]
1881
+ CVXPY Expression and Constraints the Ulcer Index risk measure.
1882
+ """
1883
+ v, constraints = self._cvx_drawdown(prior_model=prior_model, w=w, factor=factor)
1884
+ n_observations = prior_model.returns.shape[0]
1885
+ risk = cp.norm(v[1:], 2) / (np.sqrt(n_observations))
1886
+ return risk, constraints
1887
+
1888
+ def _gini_mean_difference_risk(
1889
+ self, prior_model: PriorModel, w: cp.Variable, factor: skt.Factor
1890
+ ) -> skt.RiskResult:
1891
+ """Expression and Constraints of the Gini Mean Difference risk measure.
1892
+
1893
+ The Gini mean difference (GMD) is a measure of dispersion introduced in the
1894
+ context of portfolio optimization by Yitzhaki (1982).
1895
+ The initial formulation was not used by practitioners due to the high number of
1896
+ variables that increases proportional to T(T−1)/2 ,
1897
+
1898
+ Cajas (2021) proposed an alternative reformulation based on the ordered weighted
1899
+ averaging (OWA) operator for monotonic weights proposed by Chassein and
1900
+ Goerigk (2015). We implement this formulation which is more efficient for large
1901
+ scale problems.
1902
+
1903
+ Parameters
1904
+ ----------
1905
+ prior_model : PriorModel
1906
+ The prior model of the assets distributions.
1907
+
1908
+ w : cvxpy Variable
1909
+ The CVXPY Variable representing assets weights.
1910
+
1911
+ factor : cvxpy Variable | cvxpy Constant
1912
+ Additional variable used for the optimization of some objective function
1913
+ like the ratio maximization.
1914
+
1915
+ Returns
1916
+ -------
1917
+ expression : tuple[cvxpy Expression , list[cvxpy Expression]]
1918
+ CVXPY Expression and Constraints the Ulcer Index risk measure.
1919
+ """
1920
+ ptf_returns = self._cvx_returns(prior_model=prior_model, w=w)
1921
+ ptf_transaction_cost = self._cvx_transaction_cost(
1922
+ prior_model=prior_model, w=w, factor=factor
1923
+ )
1924
+ ptf_management_fee = self._cvx_management_fee(prior_model=prior_model, w=w)
1925
+ observation_nb = prior_model.returns.shape[0]
1926
+ x = cp.Variable((observation_nb, 1))
1927
+ y = cp.Variable((observation_nb, 1))
1928
+ z = cp.Variable((observation_nb, 1))
1929
+ ones = np.ones((observation_nb, 1))
1930
+ risk = 2 * cp.sum(x + y)
1931
+ gmd_w = np.array(owa_gmd_weights(observation_nb) / 2).reshape(-1, 1)
1932
+ # noinspection PyTypeChecker
1933
+ constraints = [
1934
+ ptf_returns * self._scale_constraints
1935
+ - ptf_transaction_cost * self._scale_constraints
1936
+ - ptf_management_fee * self._scale_constraints
1937
+ == cp.reshape(z, (observation_nb,)) * self._scale_constraints,
1938
+ z @ gmd_w.T <= ones @ x.T + y @ ones.T,
1939
+ ]
1940
+ return risk, constraints
1941
+
1942
+ @abstractmethod
1943
+ def fit(self, X: npt.ArrayLike, y: npt.ArrayLike | None = None):
1944
+ pass