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,1096 @@
1
+ """Base Portfolio module"""
2
+
3
+ # Author: Hugo Delatte <delatte.hugo@gmail.com>
4
+ # License: BSD 3 clause
5
+
6
+ # The Portfolio class contains more than 40 measures than can be computationally
7
+ # expensive. The use of __slots__ instead of __dict__ is based on the following
8
+ # consideration:
9
+ # * Fast Portfolio instantiation.
10
+ # * Compute a measure only when needed.
11
+ # * Reuse the measures functions in measures.py module independently of the
12
+ # Portfolio class.
13
+ # * Have the measures as Class attributes and not as Class generic
14
+ # methods for better usability.
15
+ # * Caching of the 40 measures.
16
+ # * DRY by not re-writing @cached_property decorated methods for all the 40 measures.
17
+ #
18
+ # We define 7 types of attributes:
19
+ # * Public (read and right)
20
+ # * Private (read and right for private usage)
21
+ # * Read-only (handled in __setattr__)
22
+ # * Global abd local measures arguments: when they change, we clear the cache of
23
+ # all the measures (handled in __setattr__)
24
+ # * Attributes with custom getter and setter (using @property + private name
25
+ # in __slots__)
26
+ # * Attributes with custom getter without setter (read-only) that caches the result
27
+ # (using custom decorator @cached_property_slots + private name in __slots__)
28
+ # * Measures that are cached (handled in __getattribute__)
29
+ #
30
+ # In order to generate the measures attributes we call the measure functions and their
31
+ # arguments dynamically from the measures.py module. The function arguments are
32
+ # retrieved from the class attributes following the below rules:
33
+ # * Global measures function arguments (defined in GLOBAL_ARGS) need to be defined
34
+ # in the class attributes with identical name.
35
+ # * Local measures function arguments (defined in LOCAL_ARGS) need to be defined in
36
+ # the class attributes with the argument name preceded by the measure name and
37
+ # separated by '_'.
38
+
39
+
40
+ import warnings
41
+ from abc import abstractmethod
42
+ from typing import ClassVar
43
+
44
+ import numpy as np
45
+ import pandas as pd
46
+ import plotly.express as px
47
+ import plotly.graph_objects as go
48
+
49
+ import skfolio.typing as skt
50
+ from skfolio import measures as mt
51
+ from skfolio.measures import (
52
+ ExtraRiskMeasure,
53
+ PerfMeasure,
54
+ RatioMeasure,
55
+ RiskMeasure,
56
+ )
57
+ from skfolio.utils.sorting import dominate
58
+ from skfolio.utils.tools import (
59
+ args_names,
60
+ cached_property_slots,
61
+ format_measure,
62
+ )
63
+
64
+ # TODO: remove and use plotly express
65
+ pd.options.plotting.backend = "plotly"
66
+
67
+
68
+ _ZERO_THRESHOLD = 1e-5
69
+ _MEASURES = {
70
+ e for enu in [PerfMeasure, RiskMeasure, ExtraRiskMeasure, RatioMeasure] for e in enu
71
+ }
72
+ _MEASURES_VALUES = {e.value: e for e in _MEASURES}
73
+
74
+
75
+ class BasePortfolio:
76
+ r"""Base Portfolio class for all portfolios in skfolio.
77
+
78
+ Parameters
79
+ ----------
80
+ returns : array-like of shape (n_observations,)
81
+ Vector of portfolio returns.
82
+
83
+ observations : array-like of shape (n_observations,)
84
+ Vector of portfolio observations.
85
+
86
+ name : str, optional
87
+ Name of the portfolio.
88
+ The default (`None`) is to use the object id.
89
+
90
+ tag : str, optional
91
+ Tag given to the portfolio.
92
+ Tags are used to manipulate groups of Portfolios from a `Population`.
93
+
94
+ fitness_measures : list[measures], optional
95
+ List of fitness measures.
96
+ Fitness measures are used to compute the portfolio fitness which is used to
97
+ compute domination.
98
+ The default (`None`) is to use the list [PerfMeasure.MEAN, RiskMeasure.VARIANCE]
99
+
100
+ annualized_factor : float, default=255.0
101
+ Factor used to annualize the below measures using the square-root rule:
102
+
103
+ * Annualized Mean = Mean * factor
104
+ * Annualized Variance = Variance * factor
105
+ * Annualized Semi-Variance = Semi-Variance * factor
106
+ * Annualized Standard-Deviation = Standard-Deviation * sqrt(factor)
107
+ * Annualized Semi-Deviation = Semi-Deviation * sqrt(factor)
108
+ * Annualized Sharpe Ratio = Sharpe Ratio * sqrt(factor)
109
+ * Annualized Sortino Ratio = Sortino Ratio * sqrt(factor)
110
+
111
+ risk_free_rate : float, default=0.0
112
+ Risk-free rate. The default value is `0.0`.
113
+
114
+ compounded : bool, default=False
115
+ If this is set to True, cumulative returns are compounded.
116
+ The default is `False`.
117
+
118
+ min_acceptable_return : float, optional
119
+ The minimum acceptable return used to distinguish "downside" and "upside"
120
+ returns for the computation of lower partial moments:
121
+
122
+ * First Lower Partial Moment
123
+ * Semi-Variance
124
+ * Semi-Deviation
125
+
126
+ The default (`None`) is to use the mean.
127
+
128
+ value_at_risk_beta : float, default=0.95
129
+ The confidence level of the Portfolio VaR (Value At Risk) which represents
130
+ the return on the worst (1-beta)% observations.
131
+ The default value is `0.95`.
132
+
133
+ entropic_risk_measure_theta : float, default=1.0
134
+ The risk aversion level of the Portfolio Entropic Risk Measure.
135
+ The default value is `1.0`.
136
+
137
+ entropic_risk_measure_beta : float, default=0.95
138
+ The confidence level of the Portfolio Entropic Risk Measure.
139
+ The default value is `0.95`.
140
+
141
+ cvar_beta : float, default=0.95
142
+ The confidence level of the Portfolio CVaR (Conditional Value at Risk) which
143
+ represents the expected VaR on the worst (1-beta)% observations.
144
+ The default value is `0.95`.
145
+
146
+ evar_beta : float, default=0.95
147
+ The confidence level of the Portfolio EVaR (Entropic Value at Risk).
148
+ The default value is `0.95`.
149
+
150
+ drawdown_at_risk_beta : float, default=0.95
151
+ The confidence level of the Portfolio Drawdown at Risk (DaR) which represents
152
+ the drawdown on the worst (1-beta)% observations.
153
+ The default value is `0.95`.
154
+
155
+ cdar_beta : float, default=0.95
156
+ The confidence level of the Portfolio CDaR (Conditional Drawdown at Risk) which
157
+ represents the expected drawdown on the worst (1-beta)% observations.
158
+ The default value is `0.95`.
159
+
160
+ edar_beta : float, default=0.95
161
+ The confidence level of the Portfolio EDaR (Entropic Drawdown at Risk).
162
+ The default value is `0.95`.
163
+
164
+ Attributes
165
+ ----------
166
+ n_observations : float
167
+ Number of observations.
168
+
169
+ mean : float
170
+ Mean of the portfolio returns.
171
+
172
+ annualized_mean : float
173
+ Mean annualized by :math:`mean \times annualization\_factor`
174
+
175
+ mean_absolute_deviation : float
176
+ Mean Absolute Deviation. The deviation is the difference between the
177
+ return and a minimum acceptable return (`min_acceptable_return`).
178
+
179
+ first_lower_partial_moment : float
180
+ First Lower Partial Moment. The First Lower Partial Moment is the mean of the
181
+ returns below a minimum acceptable return (`min_acceptable_return`).
182
+
183
+ variance : float
184
+ Variance (Second Moment)
185
+
186
+ annualized_variance : float
187
+ Variance annualized by :math:`variance \times annualization\_factor`
188
+
189
+ semi_variance : float
190
+ Semi-variance (Second Lower Partial Moment).
191
+ The semi-variance is the variance of the returns below a minimum acceptable
192
+ return (`min_acceptable_return`).
193
+
194
+ annualized_semi_variance : float
195
+ Semi-variance annualized by
196
+ :math:`semi\_variance \times annualization\_factor`
197
+
198
+ standard_deviation : float
199
+ Standard Deviation (Square Root of the Second Moment).
200
+
201
+ annualized_standard_deviation : float
202
+ Standard Deviation annualized by
203
+ :math:`standard\_deviation \times \sqrt{annualization\_factor}`
204
+
205
+ semi_deviation : float
206
+ Semi-deviation (Square Root of the Second Lower Partial Moment).
207
+ The Semi Standard Deviation is the Standard Deviation of the returns below a
208
+ minimum acceptable return (`min_acceptable_return`).
209
+
210
+ annualized_semi_deviation : float
211
+ Semi-deviation annualized by
212
+ :math:`semi\_deviation \times \sqrt{annualization\_factor}`
213
+
214
+ skew : float
215
+ Skew. The Skew is a measure of the lopsidedness of the distribution.
216
+ A symmetric distribution have a Skew of zero.
217
+ Higher Skew corresponds to longer right tail.
218
+
219
+ kurtosis : float
220
+ Kurtosis. It is a measure of the heaviness of the tail of the distribution.
221
+ Higher Kurtosis corresponds to greater extremity of deviations (fat tails).
222
+
223
+ fourth_central_moment : float
224
+ Fourth Central Moment.
225
+
226
+ fourth_lower_partial_moment : float
227
+ Fourth Lower Partial Moment. It is a measure of the heaviness of the downside
228
+ tail of the returns below a minimum acceptable return (`min_acceptable_return`).
229
+ Higher Fourth Lower Partial Moment corresponds to greater extremity of downside
230
+ deviations (downside fat tail).
231
+
232
+ worst_realization : float
233
+ Worst Realization which is the worst return.
234
+
235
+ value_at_risk : float
236
+ Historical VaR (Value at Risk).
237
+ The VaR is the maximum loss at a given confidence level (`value_at_risk_beta`).
238
+
239
+ cvar : float
240
+ Historical CVaR (Conditional Value at Risk). The CVaR (or Tail VaR) represents
241
+ the mean shortfall at a specified confidence level (`cvar_beta`).
242
+
243
+ entropic_risk_measure : float
244
+ Historical Entropic Risk Measure. It is a risk measure which depends on the
245
+ risk aversion defined by the investor (`entropic_risk_measure_theta`) through
246
+ the exponential utility function at a given confidence level
247
+ (`entropic_risk_measure_beta`).
248
+
249
+ evar : float
250
+ Historical EVaR (Entropic Value at Risk). It is a coherent risk measure which
251
+ is an upper bound for the VaR and the CVaR, obtained from the Chernoff
252
+ inequality at a given confidence level (`evar_beta`). The EVaR can be
253
+ represented by using the concept of relative entropy.
254
+
255
+ drawdown_at_risk : float
256
+ Historical Drawdown at Risk. It is the maximum drawdown at a given
257
+ confidence level (`drawdown_at_risk_beta`).
258
+
259
+ cdar : float
260
+ Historical CDaR (Conditional Drawdown at Risk) at a given confidence level
261
+ (`cdar_beta`).
262
+
263
+ max_drawdown : float
264
+ Maximum Drawdown.
265
+
266
+ average_drawdown : float
267
+ Average Drawdown.
268
+
269
+ edar : float
270
+ EDaR (Entropic Drawdown at Risk). It is a coherent risk measure which is an
271
+ upper bound for the Drawdown at Risk and the CDaR, obtained from the Chernoff
272
+ inequality at a given confidence level (`edar_beta`). The EDaR can be
273
+ represented by using the concept of relative entropy.
274
+
275
+ ulcer_index : float
276
+ Ulcer Index
277
+
278
+ gini_mean_difference : float
279
+ Gini Mean Difference (GMD). It is the expected absolute difference between two
280
+ realizations. The GMD is a superior measure of variability for non-normal
281
+ distribution than the variance. It can be used to form necessary conditions
282
+ for second-degree stochastic dominance, while the variance cannot.
283
+
284
+ mean_absolute_deviation_ratio : float
285
+ Mean Absolute Deviation ratio.
286
+ It is the excess mean (mean - risk_free_rate) divided by the MaD.
287
+
288
+ first_lower_partial_moment_ratio : float
289
+ First Lower Partial Moment ratio.
290
+ It is the excess mean (mean - risk_free_rate) divided by the First Lower
291
+ Partial Moment.
292
+
293
+ sharpe_ratio : float
294
+ Sharpe ratio.
295
+ It is the excess mean (mean - risk_free_rate) divided by the standard-deviation.
296
+
297
+ annualized_sharpe_ratio : float
298
+ Sharpe ratio annualized by
299
+ :math:`sharpe\_ratio \times \sqrt{annualization\_factor}`.
300
+
301
+ sortino_ratio : float
302
+ Sortino ratio.
303
+ It is the excess mean (mean - risk_free_rate) divided by the semi
304
+ standard-deviation.
305
+
306
+ annualized_sortino_ratio : float
307
+ Sortino ratio annualized by
308
+ :math:`sortino\_ratio \times \sqrt{annualization\_factor}`.
309
+
310
+ value_at_risk_ratio : float
311
+ VaR ratio.
312
+ It is the excess mean (mean - risk_free_rate) divided by the Value at Risk
313
+ (VaR).
314
+
315
+ cvar_ratio : float
316
+ CVaR ratio.
317
+ It is the excess mean (mean - risk_free_rate) divided by the Conditional Value
318
+ at Risk (CVaR).
319
+
320
+ entropic_risk_measure_ratio : float
321
+ Entropic risk measure ratio.
322
+ It is the excess mean (mean - risk_free_rate) divided by the Entropic risk
323
+ measure.
324
+
325
+ evar_ratio : float
326
+ EVaR ratio.
327
+ It is the excess mean (mean - risk_free_rate) divided by the EVaR (Entropic
328
+ Value at Risk).
329
+
330
+ worst_realization_ratio : float
331
+ Worst Realization ratio.
332
+ It is the excess mean (mean - risk_free_rate) divided by the Worst Realization
333
+ (worst return).
334
+
335
+ drawdown_at_risk_ratio : float
336
+ Drawdown at Risk ratio.
337
+ It is the excess mean (mean - risk_free_rate) divided by the drawdown at
338
+ risk.
339
+
340
+ cdar_ratio : float
341
+ CDaR ratio.
342
+ It is the excess mean (mean - risk_free_rate) divided by the CDaR (conditional
343
+ drawdown at risk).
344
+
345
+ calmar_ratio : float
346
+ Calmar ratio.
347
+ It is the excess mean (mean - risk_free_rate) divided by the Maximum Drawdown.
348
+
349
+ average_drawdown_ratio : float
350
+ Average Drawdown ratio.
351
+ It is the excess mean (mean - risk_free_rate) divided by the Average Drawdown.
352
+
353
+ edar_ratio : float
354
+ EDaR ratio.
355
+ It is the excess mean (mean - risk_free_rate) divided by the EDaR (Entropic
356
+ Drawdown at Risk).
357
+
358
+ ulcer_index_ratio : float
359
+ Ulcer Index ratio.
360
+ It is the excess mean (mean - risk_free_rate) divided by the Ulcer Index.
361
+
362
+ gini_mean_difference_ratio : float
363
+ Gini Mean Difference ratio.
364
+ It is the excess mean (mean - risk_free_rate) divided by the Gini Mean
365
+ Difference.
366
+ """
367
+
368
+ _read_only_attrs: ClassVar[set] = {
369
+ "returns",
370
+ "observations",
371
+ "n_observations",
372
+ }
373
+
374
+ # Arguments globally used in measures computation
375
+ _measure_global_args: ClassVar[set] = {
376
+ "returns",
377
+ "cumulative_returns",
378
+ "drawdowns",
379
+ "min_acceptable_return",
380
+ "compounded",
381
+ "risk_free_rate",
382
+ }
383
+
384
+ # Arguments locally used in measures computation
385
+ _measure_local_args: ClassVar[set] = {
386
+ "value_at_risk_beta",
387
+ "cvar_beta",
388
+ "entropic_risk_measure_theta",
389
+ "entropic_risk_measure_beta",
390
+ "evar_beta",
391
+ "drawdown_at_risk_beta",
392
+ "cdar_beta",
393
+ "edar_beta",
394
+ }
395
+
396
+ __slots__ = {
397
+ # public
398
+ "tag",
399
+ "name",
400
+ # public read-only
401
+ "returns",
402
+ "observations",
403
+ "n_observations",
404
+ # private
405
+ "_loaded",
406
+ # custom getter and setter
407
+ "_fitness_measures",
408
+ "_annualized_factor",
409
+ # custom getter (read-only and cached)
410
+ "_fitness",
411
+ "_cumulative_returns",
412
+ "_drawdowns",
413
+ # global args
414
+ "min_acceptable_return",
415
+ "compounded",
416
+ "risk_free_rate",
417
+ # local args
418
+ "value_at_risk_beta",
419
+ "cvar_beta",
420
+ "entropic_risk_measure_theta",
421
+ "entropic_risk_measure_beta",
422
+ "evar_beta",
423
+ "drawdown_at_risk_beta",
424
+ "cdar_beta",
425
+ "edar_beta",
426
+ # measures
427
+ # perf
428
+ "mean",
429
+ # annualized
430
+ "annualized_mean",
431
+ # risk measure
432
+ "mean_absolute_deviation",
433
+ "first_lower_partial_moment",
434
+ "variance",
435
+ "standard_deviation",
436
+ "semi_variance",
437
+ "semi_deviation",
438
+ "fourth_central_moment",
439
+ "fourth_lower_partial_moment",
440
+ "value_at_risk",
441
+ "cvar",
442
+ "entropic_risk_measure",
443
+ "evar",
444
+ "worst_realization",
445
+ "drawdown_at_risk",
446
+ "cdar",
447
+ "max_drawdown",
448
+ "average_drawdown",
449
+ "edar",
450
+ "ulcer_index",
451
+ "gini_mean_difference",
452
+ "skew",
453
+ "kurtosis",
454
+ # annualized
455
+ "annualized_variance",
456
+ "annualized_semi_variance",
457
+ "annualized_standard_deviation",
458
+ "annualized_semi_deviation",
459
+ # ratio
460
+ "mean_absolute_deviation_ratio",
461
+ "first_lower_partial_moment_ratio",
462
+ "sharpe_ratio",
463
+ "sortino_ratio",
464
+ "value_at_risk_ratio",
465
+ "cvar_ratio",
466
+ "entropic_risk_measure_ratio",
467
+ "evar_ratio",
468
+ "worst_realization_ratio",
469
+ "drawdown_at_risk_ratio",
470
+ "cdar_ratio",
471
+ "calmar_ratio",
472
+ "average_drawdown_ratio",
473
+ "edar_ratio",
474
+ "ulcer_index_ratio",
475
+ "gini_mean_difference_ratio",
476
+ # annualized
477
+ "annualized_sharpe_ratio",
478
+ "annualized_sortino_ratio",
479
+ }
480
+
481
+ def __init__(
482
+ self,
483
+ returns: np.ndarray | list,
484
+ observations: np.ndarray | list,
485
+ name: str | None = None,
486
+ tag: str | None = None,
487
+ annualized_factor: float = 255.0,
488
+ fitness_measures: list[skt.Measure] | None = None,
489
+ risk_free_rate: float = 0.0,
490
+ compounded: bool = False,
491
+ min_acceptable_return: float | None = None,
492
+ value_at_risk_beta: float = 0.95,
493
+ entropic_risk_measure_theta: float = 1.0,
494
+ entropic_risk_measure_beta: float = 0.95,
495
+ cvar_beta: float = 0.95,
496
+ evar_beta: float = 0.95,
497
+ drawdown_at_risk_beta: float = 0.95,
498
+ cdar_beta: float = 0.95,
499
+ edar_beta: float = 0.95,
500
+ ):
501
+ self._loaded = False
502
+ self._annualized_factor = annualized_factor
503
+ self.returns = np.asarray(returns)
504
+ self.observations = np.asarray(observations)
505
+ self.risk_free_rate = risk_free_rate
506
+ self.tag = tag
507
+ self.compounded = compounded
508
+ self.min_acceptable_return = min_acceptable_return
509
+ self.value_at_risk_beta = value_at_risk_beta
510
+ self.entropic_risk_measure_theta = entropic_risk_measure_theta
511
+ self.entropic_risk_measure_beta = entropic_risk_measure_beta
512
+ self.cvar_beta = cvar_beta
513
+ self.evar_beta = evar_beta
514
+ self.drawdown_at_risk_beta = drawdown_at_risk_beta
515
+ self.cdar_beta = cdar_beta
516
+ self.edar_beta = edar_beta
517
+
518
+ self.name = str(id(self)) if name is None else name
519
+ if fitness_measures is None:
520
+ self._fitness_measures = [PerfMeasure.MEAN, RiskMeasure.VARIANCE]
521
+ else:
522
+ self._fitness_measures = fitness_measures
523
+ self.n_observations = len(observations)
524
+ self._loaded = True
525
+
526
+ def __reduce__(self):
527
+ # For fast serialization and deserialization
528
+ # We don't want to serialize generic slots but only init arguments
529
+ return self.__class__, tuple(
530
+ [getattr(self, arg) for arg in args_names(self.__init__)]
531
+ )
532
+
533
+ def __len__(self) -> int:
534
+ return len(self.observations)
535
+
536
+ def __repr__(self) -> str:
537
+ return f"<{type(self).__name__} {self.name}>"
538
+
539
+ def __eq__(self, other) -> bool:
540
+ return isinstance(other, BasePortfolio) and np.array_equal(
541
+ self.fitness, other.fitness
542
+ )
543
+
544
+ def __gt__(self, other) -> bool:
545
+ if not isinstance(other, BasePortfolio):
546
+ raise TypeError(
547
+ "`>` not supported between instances of `Portfolio` and"
548
+ f" `{type(other)}`"
549
+ )
550
+ return self.dominates(other)
551
+
552
+ def __ge__(self, other) -> bool:
553
+ if not isinstance(other, BasePortfolio):
554
+ raise TypeError(
555
+ "`>=` not supported between instances of `Portfolio` and"
556
+ f" `{type(other)}`"
557
+ )
558
+ return self.__eq__(other) or self.__gt__(other)
559
+
560
+ def __copy__(self):
561
+ cls = self.__class__
562
+ result = cls.__new__(cls)
563
+ result._loaded = False
564
+ for attr in self._slots():
565
+ if attr not in _MEASURES_VALUES and attr != "_loaded":
566
+ try:
567
+ setattr(result, attr, getattr(self, attr))
568
+ except AttributeError:
569
+ pass
570
+ result._loaded = True
571
+ return result
572
+
573
+ def __getattribute__(self, name):
574
+ try:
575
+ return object.__getattribute__(self, name)
576
+ except AttributeError as e:
577
+ # The Measures are the only attributes in __slots__ that are not yet
578
+ # assigned.
579
+ # We assign their values dynamically the first time they are called.
580
+ if name not in _MEASURES_VALUES:
581
+ raise AttributeError(e) from None
582
+ measure = _MEASURES_VALUES[name]
583
+ value = self.get_measure(measure=measure)
584
+ setattr(self, name, value)
585
+ return value
586
+
587
+ def __setattr__(self, name, value):
588
+ if name != "_loaded" and self._loaded:
589
+ if name in self._read_only_attrs:
590
+ raise AttributeError(
591
+ f"can't set attribute '{name}' because it is read-only"
592
+ )
593
+ if name in self._measure_global_args or name in self._measure_local_args:
594
+ # When an attribute in GLOBAL_ARGS or LOCAL_ARGS is set, we reset all
595
+ # the measures
596
+ self.clear()
597
+ object.__setattr__(self, name, value)
598
+
599
+ def __delattr__(self, name):
600
+ # We only want to raise an error when the attribute doesn't exist and we don't
601
+ # want to raise an error when it's a valid attribute that has not been assigned
602
+ # a value.
603
+ try:
604
+ object.__delattr__(self, name)
605
+ except AttributeError:
606
+ if name not in self._slots():
607
+ raise AttributeError(
608
+ f"`{type(self).__name__}` object has no attribute '{name}'"
609
+ ) from None
610
+
611
+ def __array__(self) -> np.ndarray:
612
+ return self.returns
613
+
614
+ # Private methods
615
+ def _slots(self) -> set[str]:
616
+ slots = set()
617
+ for s in self.__class__.__mro__:
618
+ slots.update(getattr(s, "__slots__", set()))
619
+ return slots
620
+
621
+ @property
622
+ @abstractmethod
623
+ def composition(self) -> pd.DataFrame:
624
+ """DataFrame of the Portfolio composition"""
625
+ pass
626
+
627
+ # Custom attribute setter and getter
628
+ @property
629
+ def fitness_measures(self) -> list[skt.Measure]:
630
+ """Portfolio fitness measures."""
631
+ return self._fitness_measures
632
+
633
+ @fitness_measures.setter
634
+ def fitness_measures(self, value: list[skt.Measure]) -> None:
635
+ if not isinstance(value, list) or len(value) == 0:
636
+ raise TypeError("`fitness_measures` must be a non-empty list of Measure")
637
+ for val in value:
638
+ if not isinstance(
639
+ val, PerfMeasure | RiskMeasure | ExtraRiskMeasure | RatioMeasure
640
+ ):
641
+ raise TypeError("`fitness_measures` must be a list of Measure")
642
+ self._fitness_measures = value
643
+ delattr(self, "_fitness")
644
+
645
+ @property
646
+ def annualized_factor(self) -> float:
647
+ """Portfolio annualized factor."""
648
+ return self._annualized_factor
649
+
650
+ @annualized_factor.setter
651
+ def annualized_factor(self, value: float) -> None:
652
+ self._annualized_factor = value
653
+ self.clear()
654
+
655
+ # Custom attribute getter (read-only and cached)
656
+ @cached_property_slots
657
+ def fitness(self) -> np.ndarray:
658
+ """The Portfolio fitness."""
659
+ res = []
660
+ for measure in self.fitness_measures:
661
+ if isinstance(measure, PerfMeasure | RatioMeasure):
662
+ sign = 1
663
+ else:
664
+ sign = -1
665
+ res.append(sign * getattr(self, str(measure.value)))
666
+ return np.array(res)
667
+
668
+ @cached_property_slots
669
+ def cumulative_returns(self) -> np.ndarray:
670
+ """Portfolio cumulative returns array."""
671
+ return mt.get_cumulative_returns(
672
+ returns=self.returns, compounded=self.compounded
673
+ )
674
+
675
+ @cached_property_slots
676
+ def drawdowns(self) -> np.ndarray:
677
+ """Portfolio drawdowns array."""
678
+ return mt.get_drawdowns(returns=self.returns, compounded=self.compounded)
679
+
680
+ # Classic property
681
+ @property
682
+ def returns_df(self) -> pd.Series:
683
+ """Portfolio returns DataFrame."""
684
+ return pd.Series(index=self.observations, data=self.returns, name="returns")
685
+
686
+ @property
687
+ def cumulative_returns_df(self) -> pd.Series:
688
+ """Portfolio cumulative returns Series."""
689
+ return pd.Series(
690
+ index=self.observations,
691
+ data=self.cumulative_returns,
692
+ name="cumulative_returns",
693
+ )
694
+
695
+ @property
696
+ def measures_df(self) -> pd.DataFrame:
697
+ """DataFrame of all measures."""
698
+ idx = [e.value for enu in [PerfMeasure, RiskMeasure, RatioMeasure] for e in enu]
699
+ res = [getattr(self, attr) for attr in idx]
700
+ return pd.DataFrame(res, index=idx, columns=["measures"])
701
+
702
+ # Public methods
703
+ def copy(self):
704
+ """Copy the Portfolio attributes without its measures values."""
705
+ return self.__copy__()
706
+
707
+ def clear(self) -> None:
708
+ """CLear all measures, fitness, cumulative returns and drawdowns in slots"""
709
+ attrs = ["_fitness", "_cumulative_returns", "_drawdowns"]
710
+ for attr in attrs + list(_MEASURES_VALUES):
711
+ delattr(self, attr)
712
+
713
+ def get_measure(self, measure: skt.Measure) -> float:
714
+ """Returns the value of a given measure.
715
+
716
+ Parameters
717
+ ----------
718
+ measure : PerfMeasure | RiskMeasure | ExtraRiskMeasure | RatioMeasure
719
+ The input measure.
720
+
721
+ Returns
722
+ -------
723
+ value : float
724
+ The measure value.
725
+ """
726
+ if isinstance(measure, PerfMeasure | RiskMeasure | ExtraRiskMeasure):
727
+ # We call the measure functions and their arguments dynamically.
728
+ # The measure functions are called from the "measures" module.
729
+ # The function arguments are retrieved from the class attributes following
730
+ # the below rules:
731
+ # Global measures function arguments (defined in GLOBAL_ARGS) need to be
732
+ # defined in the class attributes with identical name.
733
+ # Local measures function arguments need to be defined in the class
734
+ # attributes with the argument name preceded by the measure name and
735
+ # separated by "_".
736
+ if measure.is_annualized:
737
+ func = getattr(mt, str(measure.non_annualized_measure.value))
738
+ else:
739
+ func = getattr(mt, str(measure.value))
740
+
741
+ args = {
742
+ arg: (
743
+ getattr(self, arg)
744
+ if arg in self._measure_global_args
745
+ else getattr(self, f"{measure.value}_{arg}")
746
+ )
747
+ for arg in args_names(func)
748
+ }
749
+ try:
750
+ value = func(**args)
751
+ if measure in [
752
+ PerfMeasure.ANNUALIZED_MEAN,
753
+ RiskMeasure.ANNUALIZED_VARIANCE,
754
+ RiskMeasure.ANNUALIZED_SEMI_VARIANCE,
755
+ ]:
756
+ value *= self.annualized_factor
757
+ elif measure in [
758
+ RiskMeasure.ANNUALIZED_STANDARD_DEVIATION,
759
+ RiskMeasure.ANNUALIZED_SEMI_DEVIATION,
760
+ ]:
761
+ value *= np.sqrt(self.annualized_factor)
762
+ except Exception as e:
763
+ warnings.warn(
764
+ f"Unable to calculate the portfolio '{measure.value}' with"
765
+ f" error: {e}",
766
+ stacklevel=2,
767
+ )
768
+ value = np.nan
769
+ elif isinstance(measure, RatioMeasure):
770
+ # ratio
771
+ if measure.is_annualized:
772
+ mean = self.annualized_mean
773
+ else:
774
+ mean = self.mean
775
+ risk = getattr(self, str(measure.linked_risk_measure.value))
776
+ value = (mean - self.risk_free_rate) / risk
777
+ else:
778
+ raise ValueError(f"{measure} is not a Measure.")
779
+ return value
780
+
781
+ def dominates(
782
+ self, other: "BasePortfolio", idx: slice | np.ndarray | None = None
783
+ ) -> bool:
784
+ """Portfolio domination.
785
+
786
+ Returns true if each objective of the current portfolio fitness is not
787
+ strictly worse than the corresponding objective of the other portfolio fitness
788
+ and at least one objective is strictly better.
789
+
790
+ Parameters
791
+ ----------
792
+ other : BasePortfolio
793
+ The other portfolio.
794
+
795
+ idx : slice | array, optional
796
+ Indexes or slice indicating on which objectives the domination is performed.
797
+ The default (`None`) is to use all objectives.
798
+
799
+ Returns
800
+ -------
801
+ value : bool
802
+ Returns True if the Portfolio dominates the other one.
803
+ """
804
+ if idx is None:
805
+ idx = slice(None)
806
+ return dominate(self.fitness[idx], other.fitness[idx])
807
+
808
+ def rolling_measure(
809
+ self, measure: skt.Measure = RatioMeasure.SHARPE_RATIO, window: int = 30
810
+ ) -> pd.Series:
811
+ """Compute the measure over a rolling window.
812
+
813
+ Parameters
814
+ ----------
815
+ measure : ct.Measure, default=RatioMeasure.SHARPE_RATIO
816
+ The measure. The default measure is the Sharpe Ratio.
817
+
818
+ window : int, default=30
819
+ The window size. The default value is `30`.
820
+
821
+ Returns
822
+ -------
823
+ series : pandas Series
824
+ The rolling measure Series.
825
+ """
826
+ if measure.is_annualized:
827
+ non_annualized_measure = measure.non_annualized_measure
828
+ else:
829
+ non_annualized_measure = measure
830
+
831
+ if measure.is_perf:
832
+ perf_measure = non_annualized_measure
833
+ risk_measure = None
834
+ elif measure.is_ratio:
835
+ perf_measure = PerfMeasure.MEAN
836
+ risk_measure = non_annualized_measure.linked_risk_measure
837
+ else:
838
+ perf_measure = None
839
+ risk_measure = non_annualized_measure
840
+
841
+ if risk_measure is not None:
842
+ risk_func = getattr(mt, str(risk_measure.value))
843
+ risk_func_args = {
844
+ arg: (
845
+ getattr(self, arg)
846
+ if arg in self._measure_global_args
847
+ else getattr(self, f"{risk_measure.value}_{arg}")
848
+ )
849
+ for arg in args_names(risk_func)
850
+ }
851
+
852
+ if "drawdowns" in risk_func_args:
853
+ del risk_func_args["drawdowns"]
854
+
855
+ def meta_risk_func(returns):
856
+ drawdowns = mt.get_drawdowns(returns, compounded=self.compounded)
857
+ return risk_func(drawdowns=drawdowns, **risk_func_args)
858
+
859
+ else:
860
+ del risk_func_args["returns"]
861
+
862
+ def meta_risk_func(returns):
863
+ return risk_func(returns=returns, **risk_func_args)
864
+
865
+ if perf_measure is not None:
866
+ perf_func = getattr(mt, str(perf_measure.value))
867
+
868
+ def func(returns):
869
+ return (perf_func(returns) - self.risk_free_rate) / meta_risk_func(
870
+ returns
871
+ )
872
+
873
+ else:
874
+ func = meta_risk_func
875
+ else:
876
+ perf_func = getattr(mt, str(perf_measure.value))
877
+
878
+ def func(returns):
879
+ return perf_func(returns)
880
+
881
+ rolling = (
882
+ pd.Series(self.returns, index=self.observations)
883
+ .rolling(window=window)
884
+ .apply(func)
885
+ )
886
+ if measure.is_annualized:
887
+ if measure in [
888
+ PerfMeasure.ANNUALIZED_MEAN,
889
+ RiskMeasure.ANNUALIZED_VARIANCE,
890
+ RiskMeasure.ANNUALIZED_SEMI_VARIANCE,
891
+ ]:
892
+ rolling *= self.annualized_factor
893
+ elif measure in [
894
+ RiskMeasure.ANNUALIZED_STANDARD_DEVIATION,
895
+ RiskMeasure.ANNUALIZED_SEMI_DEVIATION,
896
+ RatioMeasure.ANNUALIZED_SHARPE_RATIO,
897
+ RatioMeasure.ANNUALIZED_SORTINO_RATIO,
898
+ ]:
899
+ rolling *= np.sqrt(self.annualized_factor)
900
+ return rolling
901
+
902
+ def summary(self, formatted: bool = True) -> pd.Series:
903
+ """Portfolio summary of all its measures.
904
+
905
+ Parameters
906
+ ----------
907
+ formatted : bool, default=True
908
+ If this is set to True, the measures are formatted into rounded string
909
+ with units.
910
+
911
+ Returns
912
+ -------
913
+ summary : pandas Series
914
+ The Portfolio summary.
915
+ """
916
+ measures = (
917
+ e
918
+ for enu in [PerfMeasure, RiskMeasure, ExtraRiskMeasure, RatioMeasure]
919
+ for e in enu
920
+ )
921
+ summary = {}
922
+ for e in measures:
923
+ e: skt.Measure
924
+ try:
925
+ if e.is_ratio:
926
+ base_measure = e.linked_risk_measure
927
+ else:
928
+ base_measure = e
929
+ beta = getattr(self, f"{base_measure.value}_beta")
930
+ key = f"{e!s} at {beta:.0%}"
931
+ except AttributeError:
932
+ key = str(e)
933
+ if isinstance(e, RatioMeasure) or e in [
934
+ ExtraRiskMeasure.ENTROPIC_RISK_MEASURE,
935
+ RiskMeasure.ULCER_INDEX,
936
+ ExtraRiskMeasure.SKEW,
937
+ ExtraRiskMeasure.KURTOSIS,
938
+ ]:
939
+ percent = False
940
+ else:
941
+ percent = True
942
+ if formatted:
943
+ value = format_measure(getattr(self, str(e.value)), percent=percent)
944
+ else:
945
+ value = getattr(self, str(e.value))
946
+ summary[key] = value
947
+ return pd.Series(summary)
948
+
949
+ def plot_cumulative_returns(
950
+ self, log_scale: bool = False, idx: slice | np.ndarray | None = None
951
+ ) -> go.Figure:
952
+ """Plot the Portfolio cumulative returns.
953
+ Non-compounded cumulative returns start at 0.
954
+ Compounded cumulative returns are rescaled to start at 1000.
955
+
956
+ Parameters
957
+ ----------
958
+ log_scale : bool, default=False
959
+ If this is set to True, the cumulative returns are displayed with a
960
+ logarithm scale on the y-axis and rebased at 1000. The cumulative returns
961
+ must be compounded otherwise an exception is raised.
962
+
963
+ idx : slice | array, optional
964
+ Indexes or slice of the observations to plot.
965
+ The default (`None`) is to plot all observations.
966
+
967
+ Returns
968
+ -------
969
+ plot : Figure
970
+ Returns the plot Figure object.
971
+ """
972
+ if idx is None:
973
+ idx = slice(None)
974
+ df = self.cumulative_returns_df.iloc[idx]
975
+ title = "Cumulative Returns"
976
+ if self.compounded:
977
+ yaxis_title = f"{title} (rebased at 1000)"
978
+ if log_scale:
979
+ title = f"{title} (compounded & log scaled)"
980
+ else:
981
+ title = f"{title} (compounded)"
982
+ else:
983
+ if log_scale:
984
+ raise ValueError(
985
+ "Plotting with logarithm scaling must be done on cumulative "
986
+ "returns that are compounded as opposed to non-compounded."
987
+ "You can change to compounded with `compounded=True`"
988
+ )
989
+ yaxis_title = title
990
+ title = f"{title} (non-compounded)"
991
+
992
+ fig = df.plot()
993
+ fig.update_layout(
994
+ title=title,
995
+ xaxis_title="Observations",
996
+ yaxis_title=yaxis_title,
997
+ showlegend=False,
998
+ )
999
+ if self.compounded:
1000
+ fig.update_yaxes(tickformat=".0f")
1001
+ else:
1002
+ fig.update_yaxes(tickformat=".2%")
1003
+ if log_scale:
1004
+ fig.update_yaxes(type="log")
1005
+ return fig
1006
+
1007
+ def plot_returns(self, idx: slice | np.ndarray | None = None) -> go.Figure:
1008
+ """Plot the Portfolio returns
1009
+
1010
+ Parameters
1011
+ ----------
1012
+ idx : slice | array, optional
1013
+ Indexes or slice of the observations to plot.
1014
+ The default (`None`) is to plot all observations.
1015
+
1016
+ Returns
1017
+ -------
1018
+ plot : Figure
1019
+ Returns the plot Figure object
1020
+ """
1021
+ if idx is None:
1022
+ idx = slice(None)
1023
+ fig = self.returns_df.iloc[idx].plot()
1024
+ fig.update_layout(
1025
+ title="Returns",
1026
+ xaxis_title="Observations",
1027
+ yaxis_title="Returns",
1028
+ showlegend=False,
1029
+ )
1030
+ return fig
1031
+
1032
+ def plot_rolling_measure(
1033
+ self,
1034
+ measure: skt.Measure = RatioMeasure.SHARPE_RATIO,
1035
+ window: int = 30,
1036
+ ) -> go.Figure:
1037
+ """Plot the measure over a rolling window.
1038
+
1039
+ Parameters
1040
+ ----------
1041
+ measure : ct.Measure, default = RatioMeasure.SHARPE_RATIO
1042
+ The measure.
1043
+
1044
+ window : int, default=30
1045
+ The window size.
1046
+
1047
+ Returns
1048
+ -------
1049
+ plot : Figure
1050
+ Returns the plot Figure object
1051
+ """
1052
+ rolling = self.rolling_measure(measure=measure, window=window)
1053
+ rolling.name = f"{measure} {window} observations"
1054
+ fig = rolling.plot()
1055
+ fig.add_hline(
1056
+ y=getattr(self, measure.value),
1057
+ line_width=1,
1058
+ line_dash="dash",
1059
+ line_color="blue",
1060
+ )
1061
+ max_val = rolling.max()
1062
+ min_val = rolling.min()
1063
+ if max_val > 0:
1064
+ fig.add_hrect(
1065
+ y0=0, y1=max_val * 1.3, line_width=0, fillcolor="green", opacity=0.1
1066
+ )
1067
+ if min_val < 0:
1068
+ fig.add_hrect(
1069
+ y0=min_val * 1.3, y1=0, line_width=0, fillcolor="red", opacity=0.1
1070
+ )
1071
+
1072
+ fig.update_layout(
1073
+ title=f"rolling {measure} - {window} observations window",
1074
+ xaxis_title="Observations",
1075
+ yaxis_title=str(measure),
1076
+ showlegend=False,
1077
+ )
1078
+ return fig
1079
+
1080
+ def plot_composition(self) -> go.Figure:
1081
+ """Plot the Portfolio composition.
1082
+
1083
+ Returns
1084
+ -------
1085
+ plot : Figure
1086
+ Returns the plot Figure object.
1087
+ """
1088
+ df = self.composition.T
1089
+ fig = px.bar(df, x=df.index, y=df.columns)
1090
+ fig.update_layout(
1091
+ title="Portfolio Composition",
1092
+ xaxis_title="Portfolio",
1093
+ yaxis_title="Weight",
1094
+ legend_title_text="Assets",
1095
+ )
1096
+ return fig