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,842 @@
1
+ """Portfolio module.
2
+ `Portfolio` is returned by the `predict` method of Optimization estimators.
3
+ It needs to be homogenous to the convex optimization problems meaning that `Portfolio`
4
+ is the dot product of the assets weights with the assets returns.
5
+ """
6
+
7
+ # Author: Hugo Delatte <delatte.hugo@gmail.com>
8
+ # License: BSD 3 clause
9
+
10
+
11
+ import numbers
12
+ from typing import ClassVar
13
+
14
+ import numpy as np
15
+ import numpy.typing as npt
16
+ import pandas as pd
17
+ import plotly.express as px
18
+
19
+ import skfolio.typing as skt
20
+ from skfolio.measures import RiskMeasure
21
+ from skfolio.portfolio._base import _ZERO_THRESHOLD, BasePortfolio
22
+ from skfolio.utils.tools import (
23
+ args_names,
24
+ cached_property_slots,
25
+ default_asset_names,
26
+ input_to_array,
27
+ )
28
+
29
+ pd.options.plotting.backend = "plotly"
30
+
31
+
32
+ class Portfolio(BasePortfolio):
33
+ r"""
34
+ Portfolio class.
35
+
36
+ `Portfolio` is returned by the `predict` method of Optimization estimators.
37
+ It is homogenous to the convex optimization problems meaning that `Portfolio` is
38
+ the dot product of the assets weights with the assets returns.
39
+
40
+ Parameters
41
+ ----------
42
+ X : array-like of shape (n_observations, n_assets)
43
+ Price returns of the assets.
44
+ If `X` is a DataFrame or another array containers that implements 'columns'
45
+ and 'index', the columns will be considered as assets names and the
46
+ indices will be considered as observations.
47
+ Otherwise, we use `["x0", "x1", ..., "x(n_assets - 1)"]` as asset names
48
+ and `[0, 1, ..., n_observations]` as observations.
49
+
50
+ weights : array-like of shape (n_assets,) | dict[str, float]
51
+ Portfolio weights.
52
+ If a dictionary is provided, its (key/value) pair must be the
53
+ (asset name/asset weight) and `X` must be a DataFrame with assets names
54
+ in columns.
55
+
56
+ transaction_costs : float | dict[str, float] | array-like of shape (n_assets, ), optional
57
+ Linear transaction costs of the assets. The Portfolio total transaction cost
58
+ is:
59
+
60
+ .. math:: total\_cost = \sum_{i=1}^{N} c_{i} \times |w_{i} - w\_prev_{i}|
61
+
62
+ with :math:`c_{i}` the transaction cost of asset i, :math:`w_{i}` its weight
63
+ and :math:`w\_prev_{i}` its previous weight (defined in `previous_weights`).
64
+ The float :math:`total\_cost` is used in the portfolio returns:
65
+
66
+ .. math:: ptf\_returns = R \cdot w - total\_cost
67
+
68
+ with :math:`R` the matrix af assets returns and :math:`w` the vector of
69
+ assets weights.
70
+
71
+ If a float is provided, it is applied to each asset.
72
+ If a dictionary is provided, its (key/value) pair must be the
73
+ (asset name/asset weight) and `X` must be a DataFrame with assets names
74
+ in columns.
75
+ The default (`None`) means no transaction costs.
76
+
77
+ .. warning::
78
+
79
+ To be homogenous to the optimization problems, the periodicity of the
80
+ transaction costs needs to be homogenous to the periodicity of the
81
+ returns `X`. For example, if `X` is composed of **daily** returns,
82
+ the `transaction_costs` need to be expressed in **daily** transaction costs.
83
+
84
+ management_fees : float | dict[str, float] | array-like of shape (n_assets, ), optional
85
+ Linear management fees of the assets. The Portfolio total management cost
86
+ is:
87
+
88
+ .. math:: total\_fee = \sum_{i=1}^{N} f_{i} \times w_{i}
89
+
90
+ with :math:`f_{i}` the management fee of asset i and :math:`w_{i}` its weight.
91
+ The float :math:`total\_fee` is used in the portfolio returns:
92
+
93
+ .. math:: ptf\_returns = R \cdot w - total\_fee
94
+
95
+ with :math:`R` the matrix af assets returns and :math:`w` the vector of
96
+ assets weights.
97
+
98
+ If a float is provided, it is applied to each asset.
99
+ If a dictionary is provided, its (key/value) pair must be the
100
+ (asset name/asset weight) and `X` must be a DataFrame with assets names
101
+ in columns.
102
+ The default (`None`) means no management fees.
103
+
104
+ .. warning::
105
+
106
+ To be homogenous to the optimization problems, the periodicity of the
107
+ management fees needs to be homogenous to the periodicity of the
108
+ returns `X`. For example, if `X` is composed of **daily** returns,
109
+ the `management_fees` need to be expressed in **daily** fees.
110
+
111
+ previous_weights : float | dict[str, float] | array-like of shape (n_assets, ), optional
112
+ Previous portfolio weights.
113
+ Previous weights are used to compute the total portfolio cost.
114
+ If `transaction_costs` is 0, `previous_weights` will have no impact.
115
+ If a float is provided, it is applied to each asset.
116
+ If a dictionary is provided, its (key/value) pair must be the
117
+ (asset name/asset previous weight) and `X` must be a DataFrame with assets names
118
+ in columns.
119
+ The default (`None`) means no previous weights.
120
+
121
+ name : str, optional
122
+ Name of the portfolio.
123
+ The default (`None`) is to use the object id.
124
+
125
+ tag : str, optional
126
+ Tag given to the portfolio.
127
+ Tags are used to manipulate groups of Portfolios from a `Population`.
128
+
129
+ fitness_measures : list[measures], optional
130
+ List of fitness measures.
131
+ Fitness measures are used to compute the portfolio fitness which is used to
132
+ compute domination.
133
+ The default (`None`) is to use the list [PerfMeasure.MEAN, RiskMeasure.VARIANCE]
134
+
135
+ annualized_factor : float, default=255.0
136
+ Factor used to annualize the below measures using the square-root rule:
137
+
138
+ * Annualized Mean = Mean * factor
139
+ * Annualized Variance = Variance * factor
140
+ * Annualized Semi-Variance = Semi-Variance * factor
141
+ * Annualized Standard-Deviation = Standard-Deviation * sqrt(factor)
142
+ * Annualized Semi-Deviation = Semi-Deviation * sqrt(factor)
143
+ * Annualized Sharpe Ratio = Sharpe Ratio * sqrt(factor)
144
+ * Annualized Sortino Ratio = Sortino Ratio * sqrt(factor)
145
+
146
+ risk_free_rate : float, default=0.0
147
+ Risk-free rate. The default value is `0.0`.
148
+
149
+ compounded : bool, default=False
150
+ If this is set to True, cumulative returns are compounded.
151
+ The default is `False`.
152
+
153
+ min_acceptable_return : float, optional
154
+ The minimum acceptable return used to distinguish "downside" and "upside"
155
+ returns for the computation of lower partial moments:
156
+
157
+ * First Lower Partial Moment
158
+ * Semi-Variance
159
+ * Semi-Deviation
160
+
161
+ The default (`None`) is to use the mean.
162
+
163
+ value_at_risk_beta : float, default=0.95
164
+ The confidence level of the Portfolio VaR (Value At Risk) which represents
165
+ the return on the worst (1-beta)% observations.
166
+ The default value is `0.95`.
167
+
168
+ entropic_risk_measure_theta : float, default=1.0
169
+ The risk aversion level of the Portfolio Entropic Risk Measure.
170
+ The default value is `1.0`.
171
+
172
+ entropic_risk_measure_beta : float, default=0.95
173
+ The confidence level of the Portfolio Entropic Risk Measure.
174
+ The default value is `0.95`.
175
+
176
+ cvar_beta : float, default=0.95
177
+ The confidence level of the Portfolio CVaR (Conditional Value at Risk) which
178
+ represents the expected VaR on the worst (1-beta)% observations.
179
+ The default value is `0.95`.
180
+
181
+ evar_beta : float, default=0.95
182
+ The confidence level of the Portfolio EVaR (Entropic Value at Risk).
183
+ The default value is `0.95`.
184
+
185
+ drawdown_at_risk_beta : float, default=0.95
186
+ The confidence level of the Portfolio Drawdown at Risk (DaR) which represents
187
+ the drawdown on the worst (1-beta)% observations.
188
+ The default value is `0.95`.
189
+
190
+ cdar_beta : float, default=0.95
191
+ The confidence level of the Portfolio CDaR (Conditional Drawdown at Risk) which
192
+ represents the expected drawdown on the worst (1-beta)% observations.
193
+ The default value is `0.95`.
194
+
195
+ edar_beta : float, default=0.95
196
+ The confidence level of the Portfolio EDaR (Entropic Drawdown at Risk).
197
+ The default value is `0.95`.
198
+
199
+ Attributes
200
+ ----------
201
+ n_observations : float
202
+ Number of observations.
203
+
204
+ mean : float
205
+ Mean of the portfolio returns.
206
+
207
+ annualized_mean : float
208
+ Mean annualized by :math:`mean \times annualization\_factor`
209
+
210
+ mean_absolute_deviation : float
211
+ Mean Absolute Deviation. The deviation is the difference between the
212
+ return and a minimum acceptable return (`min_acceptable_return`).
213
+
214
+ first_lower_partial_moment : float
215
+ First Lower Partial Moment. The First Lower Partial Moment is the mean of the
216
+ returns below a minimum acceptable return (`min_acceptable_return`).
217
+
218
+ variance : float
219
+ Variance (Second Moment)
220
+
221
+ annualized_variance : float
222
+ Variance annualized by :math:`variance \times annualization\_factor`
223
+
224
+ semi_variance : float
225
+ Semi-variance (Second Lower Partial Moment).
226
+ The semi-variance is the variance of the returns below a minimum acceptable
227
+ return (`min_acceptable_return`).
228
+
229
+ annualized_semi_variance : float
230
+ Semi-variance annualized by
231
+ :math:`semi\_variance \times annualization\_factor`
232
+
233
+ standard_deviation : float
234
+ Standard Deviation (Square Root of the Second Moment).
235
+
236
+ annualized_standard_deviation : float
237
+ Standard Deviation annualized by
238
+ :math:`standard\_deviation \times \sqrt{annualization\_factor}`
239
+
240
+ semi_deviation : float
241
+ Semi-deviation (Square Root of the Second Lower Partial Moment).
242
+ The Semi Standard Deviation is the Standard Deviation of the returns below a
243
+ minimum acceptable return (`min_acceptable_return`).
244
+
245
+ annualized_semi_deviation : float
246
+ Semi-deviation annualized by
247
+ :math:`semi\_deviation \times \sqrt{annualization\_factor}`
248
+
249
+ skew : float
250
+ Skew. The Skew is a measure of the lopsidedness of the distribution.
251
+ A symmetric distribution have a Skew of zero.
252
+ Higher Skew corresponds to longer right tail.
253
+
254
+ kurtosis : float
255
+ Kurtosis. It is a measure of the heaviness of the tail of the distribution.
256
+ Higher Kurtosis corresponds to greater extremity of deviations (fat tails).
257
+
258
+ fourth_central_moment : float
259
+ Fourth Central Moment.
260
+
261
+ fourth_lower_partial_moment : float
262
+ Fourth Lower Partial Moment. It is a measure of the heaviness of the downside
263
+ tail of the returns below a minimum acceptable return (`min_acceptable_return`).
264
+ Higher Fourth Lower Partial Moment corresponds to greater extremity of downside
265
+ deviations (downside fat tail).
266
+
267
+ worst_realization : float
268
+ Worst Realization which is the worst return.
269
+
270
+ value_at_risk : float
271
+ Historical VaR (Value at Risk).
272
+ The VaR is the maximum loss at a given confidence level (`value_at_risk_beta`).
273
+
274
+ cvar : float
275
+ Historical CVaR (Conditional Value at Risk). The CVaR (or Tail VaR) represents
276
+ the mean shortfall at a specified confidence level (`cvar_beta`).
277
+
278
+ entropic_risk_measure : float
279
+ Historical Entropic Risk Measure. It is a risk measure which depends on the
280
+ risk aversion defined by the investor (`entropic_risk_measure_theta`) through
281
+ the exponential utility function at a given confidence level
282
+ (`entropic_risk_measure_beta`).
283
+
284
+ evar : float
285
+ Historical EVaR (Entropic Value at Risk). It is a coherent risk measure which
286
+ is an upper bound for the VaR and the CVaR, obtained from the Chernoff
287
+ inequality at a given confidence level (`evar_beta`). The EVaR can be
288
+ represented by using the concept of relative entropy.
289
+
290
+ drawdown_at_risk : float
291
+ Historical Drawdown at Risk. It is the maximum drawdown at a given
292
+ confidence level (`drawdown_at_risk_beta`).
293
+
294
+ cdar : float
295
+ Historical CDaR (Conditional Drawdown at Risk) at a given confidence level
296
+ (`cdar_beta`).
297
+
298
+ max_drawdown : float
299
+ Maximum Drawdown.
300
+
301
+ average_drawdown : float
302
+ Average Drawdown.
303
+
304
+ edar : float
305
+ EDaR (Entropic Drawdown at Risk). It is a coherent risk measure which is an
306
+ upper bound for the Drawdown at Risk and the CDaR, obtained from the Chernoff
307
+ inequality at a given confidence level (`edar_beta`). The EDaR can be
308
+ represented by using the concept of relative entropy.
309
+
310
+ ulcer_index : float
311
+ Ulcer Index
312
+
313
+ gini_mean_difference : float
314
+ Gini Mean Difference (GMD). It is the expected absolute difference between two
315
+ realizations. The GMD is a superior measure of variability for non-normal
316
+ distribution than the variance. It can be used to form necessary conditions
317
+ for second-degree stochastic dominance, while the variance cannot.
318
+
319
+ mean_absolute_deviation_ratio : float
320
+ Mean Absolute Deviation ratio.
321
+ It is the excess mean (mean - risk_free_rate) divided by the MaD.
322
+
323
+ first_lower_partial_moment_ratio : float
324
+ First Lower Partial Moment ratio.
325
+ It is the excess mean (mean - risk_free_rate) divided by the First Lower
326
+ Partial Moment.
327
+
328
+ sharpe_ratio : float
329
+ Sharpe ratio.
330
+ It is the excess mean (mean - risk_free_rate) divided by the standard-deviation.
331
+
332
+ annualized_sharpe_ratio : float
333
+ Sharpe ratio annualized by
334
+ :math:`sharpe\_ratio \times \sqrt{annualization\_factor}`.
335
+
336
+ sortino_ratio : float
337
+ Sortino ratio.
338
+ It is the excess mean (mean - risk_free_rate) divided by the semi
339
+ standard-deviation.
340
+
341
+ annualized_sortino_ratio : float
342
+ Sortino ratio annualized by
343
+ :math:`sortino\_ratio \times \sqrt{annualization\_factor}`.
344
+
345
+ value_at_risk_ratio : float
346
+ VaR ratio.
347
+ It is the excess mean (mean - risk_free_rate) divided by the Value at Risk
348
+ (VaR).
349
+
350
+ cvar_ratio : float
351
+ CVaR ratio.
352
+ It is the excess mean (mean - risk_free_rate) divided by the Conditional Value
353
+ at Risk (CVaR).
354
+
355
+ entropic_risk_measure_ratio : float
356
+ Entropic risk measure ratio.
357
+ It is the excess mean (mean - risk_free_rate) divided by the Entropic risk
358
+ measure.
359
+
360
+ evar_ratio : float
361
+ EVaR ratio.
362
+ It is the excess mean (mean - risk_free_rate) divided by the EVaR (Entropic
363
+ Value at Risk).
364
+
365
+ worst_realization_ratio : float
366
+ Worst Realization ratio.
367
+ It is the excess mean (mean - risk_free_rate) divided by the Worst Realization
368
+ (worst return).
369
+
370
+ drawdown_at_risk_ratio : float
371
+ Drawdown at Risk ratio.
372
+ It is the excess mean (mean - risk_free_rate) divided by the drawdown at
373
+ risk.
374
+
375
+ cdar_ratio : float
376
+ CDaR ratio.
377
+ It is the excess mean (mean - risk_free_rate) divided by the CDaR (conditional
378
+ drawdown at risk).
379
+
380
+ calmar_ratio : float
381
+ Calmar ratio.
382
+ It is the excess mean (mean - risk_free_rate) divided by the Maximum Drawdown.
383
+
384
+ average_drawdown_ratio : float
385
+ Average Drawdown ratio.
386
+ It is the excess mean (mean - risk_free_rate) divided by the Average Drawdown.
387
+
388
+ edar_ratio : float
389
+ EDaR ratio.
390
+ It is the excess mean (mean - risk_free_rate) divided by the EDaR (Entropic
391
+ Drawdown at Risk).
392
+
393
+ ulcer_index_ratio : float
394
+ Ulcer Index ratio.
395
+ It is the excess mean (mean - risk_free_rate) divided by the Ulcer Index.
396
+
397
+ gini_mean_difference_ratio : float
398
+ Gini Mean Difference ratio.
399
+ It is the excess mean (mean - risk_free_rate) divided by the Gini Mean
400
+ Difference.
401
+ """
402
+
403
+ _read_only_attrs: ClassVar[set] = BasePortfolio._read_only_attrs.copy()
404
+ _read_only_attrs.update(
405
+ {
406
+ "X",
407
+ "assets",
408
+ "weights",
409
+ "previous_weights",
410
+ "transaction_costs",
411
+ "management_fees",
412
+ "n_assets",
413
+ "total_cost",
414
+ "total_fee",
415
+ }
416
+ )
417
+
418
+ __slots__ = {
419
+ # read-only
420
+ "X",
421
+ "weights",
422
+ "previous_weights",
423
+ "transaction_costs",
424
+ "management_fees",
425
+ "assets",
426
+ "n_assets",
427
+ "total_cost",
428
+ "total_fee",
429
+ # custom getter (read-only and cached)
430
+ "_nonzero_assets",
431
+ "_nonzero_assets_index",
432
+ }
433
+
434
+ def __init__(
435
+ self,
436
+ X: npt.ArrayLike,
437
+ weights: skt.MultiInput,
438
+ previous_weights: skt.MultiInput = None,
439
+ transaction_costs: skt.MultiInput = None,
440
+ management_fees: skt.MultiInput = None,
441
+ risk_free_rate: float = 0,
442
+ name: str | None = None,
443
+ tag: str | None = None,
444
+ annualized_factor: float = 255,
445
+ fitness_measures: list[skt.Measure] | None = None,
446
+ compounded: bool = False,
447
+ min_acceptable_return: float | None = None,
448
+ value_at_risk_beta: float = 0.95,
449
+ entropic_risk_measure_theta: float = 1,
450
+ entropic_risk_measure_beta: float = 0.95,
451
+ cvar_beta: float = 0.95,
452
+ evar_beta: float = 0.95,
453
+ drawdown_at_risk_beta: float = 0.95,
454
+ cdar_beta: float = 0.95,
455
+ edar_beta: float = 0.95,
456
+ ):
457
+ # extract assets names from X
458
+ assets = None
459
+ observations = None
460
+ if hasattr(X, "columns"):
461
+ assets = np.asarray(X.columns, dtype=object)
462
+ observations = np.asarray(X.index)
463
+
464
+ # We don't perform extensive checks (like in check_X) for faster instantiation.
465
+ rets = np.asarray(X)
466
+ if rets.ndim != 2:
467
+ raise ValueError("`X` must be a 2D array-like")
468
+
469
+ n_observations, n_assets = rets.shape
470
+
471
+ weights = input_to_array(
472
+ items=weights,
473
+ n_assets=n_assets,
474
+ fill_value=0,
475
+ dim=1,
476
+ assets_names=assets,
477
+ name="weights",
478
+ )
479
+
480
+ if previous_weights is None:
481
+ previous_weights = np.zeros(n_assets)
482
+ else:
483
+ previous_weights = input_to_array(
484
+ items=previous_weights,
485
+ n_assets=n_assets,
486
+ fill_value=0,
487
+ dim=1,
488
+ assets_names=assets,
489
+ name="previous_weights",
490
+ )
491
+
492
+ if transaction_costs is None:
493
+ transaction_costs = 0
494
+ elif not np.isscalar(transaction_costs):
495
+ transaction_costs = input_to_array(
496
+ items=transaction_costs,
497
+ n_assets=n_assets,
498
+ fill_value=0,
499
+ dim=1,
500
+ assets_names=assets,
501
+ name="transaction_costs",
502
+ )
503
+
504
+ if management_fees is None:
505
+ management_fees = 0
506
+ elif not np.isscalar(management_fees):
507
+ management_fees = input_to_array(
508
+ items=management_fees,
509
+ n_assets=n_assets,
510
+ fill_value=0,
511
+ dim=1,
512
+ assets_names=assets,
513
+ name="management_fees",
514
+ )
515
+
516
+ # Default observations and assets if X is not a DataFrame
517
+ if observations is None or len(observations) == 0:
518
+ observations = np.arange(n_observations)
519
+
520
+ if assets is None or len(assets) == 0:
521
+ assets = default_asset_names(n_assets=n_assets)
522
+
523
+ # Computing portfolio returns
524
+ if np.isscalar(transaction_costs) and transaction_costs == 0:
525
+ total_cost = 0
526
+ else:
527
+ total_cost = (transaction_costs * abs(previous_weights - weights)).sum()
528
+
529
+ if np.isscalar(management_fees) and management_fees == 0:
530
+ total_fee = 0
531
+ else:
532
+ total_fee = (management_fees * weights).sum()
533
+
534
+ returns = weights @ rets.T - total_cost - total_fee
535
+
536
+ if np.any(np.isnan(returns)):
537
+ raise ValueError("NaN found in `returns`")
538
+
539
+ super().__init__(
540
+ returns=returns,
541
+ observations=observations,
542
+ name=name,
543
+ tag=tag,
544
+ fitness_measures=fitness_measures,
545
+ compounded=compounded,
546
+ risk_free_rate=risk_free_rate,
547
+ annualized_factor=annualized_factor,
548
+ min_acceptable_return=min_acceptable_return,
549
+ value_at_risk_beta=value_at_risk_beta,
550
+ cvar_beta=cvar_beta,
551
+ entropic_risk_measure_theta=entropic_risk_measure_theta,
552
+ entropic_risk_measure_beta=entropic_risk_measure_beta,
553
+ evar_beta=evar_beta,
554
+ drawdown_at_risk_beta=drawdown_at_risk_beta,
555
+ cdar_beta=cdar_beta,
556
+ edar_beta=edar_beta,
557
+ )
558
+ self._loaded = False
559
+ # We save the original array-like object and not the numpy copy for improved
560
+ # memory
561
+ self.X = X
562
+ self.assets = assets
563
+ self.n_assets = n_assets
564
+ self.weights = weights
565
+ self.transaction_costs = transaction_costs
566
+ self.management_fees = management_fees
567
+ self.previous_weights = previous_weights
568
+ self.total_cost = total_cost
569
+ self.total_fee = total_fee
570
+ self._loaded = True
571
+
572
+ def __neg__(self):
573
+ args = {arg: getattr(self, arg) for arg in args_names(self.__init__)}
574
+ args["weights"] = -self.weights
575
+ return self.__class__(**args)
576
+
577
+ def __abs__(self):
578
+ args = {arg: getattr(self, arg) for arg in args_names(self.__init__)}
579
+ args["weights"] = np.abs(self.weights)
580
+ return self.__class__(**args)
581
+
582
+ def __round__(self, n: int):
583
+ args = {arg: getattr(self, arg) for arg in args_names(self.__init__)}
584
+ args["weights"] = np.round(self.weights, n)
585
+ return self.__class__(**args)
586
+
587
+ def __add__(self, other):
588
+ if not isinstance(other, Portfolio):
589
+ raise TypeError(
590
+ f"Cannot add a Portfolio with an object of type {type(other)}"
591
+ )
592
+ args = args_names(self.__init__)
593
+ for arg in args:
594
+ if arg not in ["weights", "name", "tag"] and not np.array_equal(
595
+ getattr(self, arg), getattr(other, arg)
596
+ ):
597
+ raise ValueError(f"Cannot add two Portfolios with different `{arg}`")
598
+ args = {arg: getattr(self, arg) for arg in args}
599
+ args["weights"] = self.weights + other.weights
600
+ return self.__class__(**args)
601
+
602
+ def __sub__(self, other):
603
+ if not isinstance(other, Portfolio):
604
+ raise TypeError(
605
+ f"Cannot add a Portfolio with an object of type {type(other)}"
606
+ )
607
+ args = args_names(self.__init__)
608
+ for arg in args:
609
+ if arg not in ["weights", "name", "tag"] and not np.array_equal(
610
+ getattr(self, arg), getattr(other, arg)
611
+ ):
612
+ raise ValueError(
613
+ f"Cannot subtract two Portfolios with different `{arg}`"
614
+ )
615
+ args = {arg: getattr(self, arg) for arg in args}
616
+ args["weights"] = self.weights - other.weights
617
+ return self.__class__(**args)
618
+
619
+ def __mul__(self, other: numbers.Number):
620
+ if not isinstance(other, numbers.Number):
621
+ raise TypeError(
622
+ "Portfolio can only be multiplied by a number, but received a"
623
+ f" {type(other)}"
624
+ )
625
+ args = {arg: getattr(self, arg) for arg in args_names(self.__init__)}
626
+ args["weights"] = other * self.weights
627
+ return self.__class__(**args)
628
+
629
+ __rmul__ = __mul__
630
+
631
+ def __floordiv__(self, other: numbers.Number):
632
+ if not isinstance(other, numbers.Number):
633
+ raise TypeError(
634
+ "Portfolio can only be floor divided by a number, but received a"
635
+ f" {type(other)}"
636
+ )
637
+ args = {arg: getattr(self, arg) for arg in args_names(self.__init__)}
638
+ args["weights"] = np.floor_divide(self.weights, other)
639
+ return self.__class__(**args)
640
+
641
+ def __truediv__(self, other: numbers.Number):
642
+ if not isinstance(other, numbers.Number):
643
+ raise TypeError(
644
+ "Portfolio can only be divided by a number, but received a"
645
+ f" {type(other)}"
646
+ )
647
+ args = {arg: getattr(self, arg) for arg in args_names(self.__init__)}
648
+ args["weights"] = self.weights / other
649
+ return self.__class__(**args)
650
+
651
+ # Custom attribute getter (read-only and cached)
652
+ @cached_property_slots
653
+ def nonzero_assets(self) -> np.ndarray:
654
+ """Invested asset :math:`abs(weights) > 0.001%`"""
655
+ return self.assets[self.nonzero_assets_index]
656
+
657
+ @cached_property_slots
658
+ def nonzero_assets_index(self) -> np.ndarray:
659
+ """Indices of invested asset :math:`abs(weights) > 0.001%`"""
660
+ return np.flatnonzero(abs(self.weights) > _ZERO_THRESHOLD)
661
+
662
+ @property
663
+ def composition(self) -> pd.DataFrame:
664
+ """DataFrame of the Portfolio composition."""
665
+ weights = self.weights[self.nonzero_assets_index]
666
+ df = pd.DataFrame({"asset": self.nonzero_assets, "weight": weights})
667
+ df.sort_values(by="weight", ascending=False, inplace=True)
668
+ df.rename(columns={"weight": self.name}, inplace=True)
669
+ df.set_index("asset", inplace=True)
670
+ return df
671
+
672
+ @property
673
+ def diversification(self):
674
+ """Weighted average of volatility divided by the portfolio volatility."""
675
+ return (
676
+ self.weights @ np.std(np.asarray(self.X), axis=0) / self.standard_deviation
677
+ )
678
+
679
+ @property
680
+ def sric(self) -> float:
681
+ """Sharpe Ratio Information Criterion (SRIC).
682
+
683
+ It is an unbiased estimator of the Sharpe Ratio adjusting for both sources of
684
+ bias which are noise fit and estimation error [1]_.
685
+
686
+ References
687
+ ----------
688
+ .. [1] "Noise Fit, Estimation Error and a Sharpe Information Criterion",
689
+ Dirk Paulsen (2019)
690
+ """
691
+ return self.sharpe_ratio - self.n_assets / (
692
+ self.n_observations * self.sharpe_ratio
693
+ )
694
+
695
+ # Public methods
696
+ def expected_returns_from_assets(
697
+ self, assets_expected_returns: np.ndarray
698
+ ) -> float:
699
+ """Compute the Portfolio expected returns from the assets expected returns,
700
+ weights, management costs and transaction fees.
701
+
702
+ Parameters
703
+ ----------
704
+ assets_expected_returns : ndarray of shape (n_assets,)
705
+ The vector of assets expected returns.
706
+
707
+ Returns
708
+ -------
709
+ value : float
710
+ The Portfolio expected returns.
711
+ """
712
+ return (
713
+ self.weights @ assets_expected_returns.T - self.total_cost - self.total_fee
714
+ )
715
+
716
+ def variance_from_assets(self, assets_covariance: np.ndarray) -> float:
717
+ """Compute the Portfolio variance expectation from the assets covariance and
718
+ weights.
719
+
720
+ Parameters
721
+ ----------
722
+ assets_covariance : ndarray of shape (n_assets,n_assets)
723
+ The matrix of assets covariance expectation.
724
+
725
+ Returns
726
+ -------
727
+ value : float
728
+ The Portfolio variance from the assets covariance.
729
+ """
730
+ return float(self.weights @ assets_covariance @ self.weights.T)
731
+
732
+ def contribution(
733
+ self, measure: skt.Measure, spacing: float | None = None
734
+ ) -> np.ndarray:
735
+ r"""Compute the contribution of each asset to a given measure.
736
+
737
+ Parameters
738
+ ----------
739
+ measure : Measure
740
+ The measure used for the contribution computation.
741
+
742
+ spacing : float, optional
743
+ Spacing "h" of the finite difference:
744
+ :math:`contribution(wi)= \frac{measure(wi-h) - measure(wi+h)}{2h}`
745
+
746
+ Returns
747
+ -------
748
+ values : ndrray of shape (n_assets,)
749
+ The measure contribution of each asset.
750
+ """
751
+ if spacing is None:
752
+ if measure in [
753
+ RiskMeasure.MAX_DRAWDOWN,
754
+ RiskMeasure.AVERAGE_DRAWDOWN,
755
+ RiskMeasure.CDAR,
756
+ RiskMeasure.EDAR,
757
+ ]:
758
+ spacing = 1e-1
759
+ else:
760
+ spacing = 1e-5
761
+ args = {arg: getattr(self, arg) for arg in args_names(self.__init__)}
762
+
763
+ def get_risk(i: int, h: float) -> float:
764
+ a = args.copy()
765
+ w = a["weights"].copy()
766
+ w[i] += h
767
+ a["weights"] = w
768
+ return getattr(Portfolio(**a), measure.value)
769
+
770
+ cont = [
771
+ (get_risk(i, h=spacing) - get_risk(i, h=-spacing))
772
+ / (2 * spacing)
773
+ * self.weights[i]
774
+ for i in range(len(self.weights))
775
+ ]
776
+ return np.array(cont)
777
+
778
+ def plot_contribution(self, measure: skt.Measure, spacing: float | None = None):
779
+ r"""Plot the contribution of each asset to a given measure.
780
+
781
+ Parameters
782
+ ----------
783
+ measure : Measure
784
+ The measure used for the contribution computation.
785
+
786
+ spacing : float, optional
787
+ Spacing "h" of the finite difference:
788
+ :math:`contribution(wi)= \frac{measure(wi-h) - measure(wi+h)}{2h}`
789
+
790
+ Returns
791
+ -------
792
+ plot : Figure
793
+ The plotly Figure of assets contribution to the measure.
794
+ """
795
+ cont = self.contribution(measure=measure, spacing=spacing)
796
+ df = pd.DataFrame(cont, index=self.assets, columns=["contribution"])
797
+ fig = px.bar(df, x=df.index, y=df.columns)
798
+ fig.update_layout(
799
+ title=f"{measure} contribution",
800
+ xaxis_title="Asset",
801
+ yaxis_title=f"{measure} contribution",
802
+ )
803
+ return fig
804
+
805
+ def summary(self, formatted: bool = True) -> pd.Series:
806
+ """Portfolio summary of all its measures.
807
+
808
+ Parameters
809
+ ----------
810
+ formatted : bool, default=True
811
+ If this is set to True, the measures are formatted into rounded string
812
+ with units.
813
+
814
+ Returns
815
+ -------
816
+ summary : series
817
+ Portfolio summary.
818
+ """
819
+ df = super().summary(formatted=formatted)
820
+ assets_number = self.n_assets
821
+ if formatted:
822
+ assets_number = str(self.n_assets)
823
+ df["Assets number"] = assets_number
824
+ return df
825
+
826
+ def get_weight(self, asset: str) -> float:
827
+ """Get the weight of a given asset.
828
+
829
+ Parameters
830
+ ----------
831
+ asset : str
832
+ Name of the asset.
833
+
834
+ Returns
835
+ -------
836
+ weight : float
837
+ Weight of the asset.
838
+ """
839
+ try:
840
+ return self.weights[np.where(self.assets == asset)[0][0]]
841
+ except IndexError:
842
+ raise IndexError("{asset} is not a valid asset name.") from None