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,883 @@
1
+ """ Population module.
2
+ A population is a collection of portfolios.
3
+ """
4
+
5
+ # Author: Hugo Delatte <delatte.hugo@gmail.com>
6
+ # License: BSD 3 clause
7
+
8
+ import inspect
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+ import plotly.express as px
13
+ import plotly.graph_objects as go
14
+ import scipy.interpolate as sci
15
+
16
+ import skfolio.typing as skt
17
+ from skfolio.portfolio import BasePortfolio, MultiPeriodPortfolio, Portfolio
18
+ from skfolio.utils.sorting import non_denominated_sort
19
+ from skfolio.utils.tools import deduplicate_names
20
+
21
+ pd.options.plotting.backend = "plotly"
22
+
23
+
24
+ class Population(list):
25
+ """Population Class.
26
+
27
+ A `Population` is a list of :class:`~skfolio.portfolio.Portfolio` or
28
+ :class:`~skfolio.portfolio.MultiPeriodPortfolio` or both.
29
+
30
+ Parameters
31
+ ----------
32
+ iterable : list[Portfolio | MultiPeriodPortfolio]
33
+ The list of portfolios. Each item can be of type
34
+ :class:`~skfolio.portfolio.Portfolio` and/or
35
+ :class:`~skfolio.portfolio.MultiPeriodPortfolio`.
36
+ Empty list are accepted.
37
+ """
38
+
39
+ def __init__(self, iterable: list[Portfolio | MultiPeriodPortfolio]) -> None:
40
+ super().__init__(self._validate_item(item) for item in iterable)
41
+
42
+ def __repr__(self) -> str:
43
+ return "<Population(" + super().__repr__() + ")>"
44
+
45
+ def __getitem__(
46
+ self, indices: int | list[int] | slice
47
+ ) -> "Portfolio | MultiPeriodPortfolio|Population":
48
+ item = super().__getitem__(indices)
49
+ if isinstance(item, list):
50
+ return self.__class__(item)
51
+ return item
52
+
53
+ def __setitem__(self, index: int, item: Portfolio | MultiPeriodPortfolio) -> None:
54
+ super().__setitem__(index, self._validate_item(item))
55
+
56
+ def __add__(self, other: Portfolio | MultiPeriodPortfolio) -> "Population":
57
+ if not isinstance(other, Population):
58
+ raise TypeError(
59
+ f"Cannot add a Population with an object of type {type(other)}"
60
+ )
61
+ return self.__class__(super().__add__(other))
62
+
63
+ def insert(self, index, item: Portfolio | MultiPeriodPortfolio) -> None:
64
+ """Insert portfolio before index."""
65
+ super().insert(index, self._validate_item(item))
66
+
67
+ def append(self, item: Portfolio | MultiPeriodPortfolio) -> None:
68
+ """Append portfolio to the end of the population list."""
69
+ super().append(self._validate_item(item))
70
+
71
+ def extend(self, other: Portfolio | MultiPeriodPortfolio) -> None:
72
+ """Extend population list by appending elements from the iterable."""
73
+ if isinstance(other, type(self)):
74
+ super().extend(other)
75
+ else:
76
+ super().extend(self._validate_item(item) for item in other)
77
+
78
+ def set_portfolio_params(self, **params: any) -> "Population":
79
+ """Set the parameters of all the portfolios.
80
+
81
+ Parameters
82
+ ----------
83
+ **params : any
84
+ Portfolio parameters.
85
+
86
+ Returns
87
+ -------
88
+ self : Population
89
+ The Population instance.
90
+ """
91
+ if not params:
92
+ return self
93
+ init_signature = inspect.signature(BasePortfolio.__init__)
94
+ # Consider the constructor parameters excluding 'self'
95
+ valid_params = [
96
+ p.name
97
+ for p in init_signature.parameters.values()
98
+ if p.name != "self" and p.kind != p.VAR_KEYWORD
99
+ ]
100
+ for key in params:
101
+ if key not in valid_params:
102
+ raise ValueError(
103
+ f"Invalid parameter {key!r} . "
104
+ f"Valid parameters are: {valid_params!r}."
105
+ )
106
+
107
+ for portfolio in self:
108
+ for key, value in params.items():
109
+ setattr(portfolio, key, value)
110
+
111
+ @staticmethod
112
+ def _validate_item(
113
+ item: Portfolio | MultiPeriodPortfolio,
114
+ ) -> Portfolio | MultiPeriodPortfolio:
115
+ """Validate that items are of type Portfolio or MultiPeriodPortfolio."""
116
+ if isinstance(item, Portfolio | MultiPeriodPortfolio):
117
+ return item
118
+ raise TypeError(
119
+ "Population only accept items of type Portfolio and MultiPeriodPortfolio"
120
+ f", got {type(item).__name__}"
121
+ )
122
+
123
+ def non_denominated_sort(self, first_front_only: bool = False) -> list[list[int]]:
124
+ """Fast non-dominated sorting.
125
+ Sort the portfolios into different non-domination levels.
126
+ Complexity O(MN^2) where M is the number of objectives and N the number of
127
+ portfolios.
128
+
129
+ Parameters
130
+ ----------
131
+ first_front_only : bool, default=False
132
+ If this is set to True, only the first front is sorted and returned.
133
+ The default is `False`.
134
+
135
+ Returns
136
+ -------
137
+ fronts : list[list[int]]
138
+ A list of Pareto fronts (lists), the first list includes
139
+ non-dominated portfolios.
140
+ """
141
+ n = len(self)
142
+ if n > 0 and np.any(
143
+ [
144
+ portfolio.fitness_measures != self[0].fitness_measures
145
+ for portfolio in self
146
+ ]
147
+ ):
148
+ raise ValueError(
149
+ "Cannot compute non denominated sorting with Portfolios "
150
+ "containing mixed `fitness_measures`"
151
+ )
152
+ fitnesses = np.array([portfolio.fitness for portfolio in self])
153
+ fronts = non_denominated_sort(
154
+ fitnesses=fitnesses, first_front_only=first_front_only
155
+ )
156
+ return fronts
157
+
158
+ def filter( # noqa: A003
159
+ self, names: skt.Names | None = None, tags: skt.Tags | None = None
160
+ ) -> "Population":
161
+ """Filter the Population of portfolios by names and tags.
162
+ If both names and tags are provided, the intersection is returned.
163
+
164
+ Parameters
165
+ ----------
166
+ names : str | list[str], optional
167
+ If provided, the population is filtered by portfolio names.
168
+
169
+ tags : str | list[str], optional
170
+ If provided, the population is filtered by portfolio tags.
171
+
172
+ Returns
173
+ -------
174
+ population : Population
175
+ A new population of portfolios filtered by names and tags.
176
+ """
177
+ if tags is None and names is None:
178
+ return self
179
+ if isinstance(names, str):
180
+ names = [names]
181
+ if isinstance(tags, str):
182
+ tags = [tags]
183
+
184
+ if tags is None:
185
+ return self.__class__(
186
+ [portfolio for portfolio in self if portfolio.name in names]
187
+ )
188
+ if names is None:
189
+ return self.__class__(
190
+ [portfolio for portfolio in self if portfolio.tag in tags]
191
+ )
192
+ return self.__class__(
193
+ [
194
+ portfolio
195
+ for portfolio in self
196
+ if portfolio.name in names and portfolio.tag in tags
197
+ ]
198
+ )
199
+
200
+ def measures(
201
+ self,
202
+ measure: skt.Measure,
203
+ names: skt.Names | None = None,
204
+ tags: skt.Tags | None = None,
205
+ ) -> np.ndarray:
206
+ """Vector of portfolios measures for each portfolio from the
207
+ population filtered by names and tags.
208
+
209
+ Parameters
210
+ ----------
211
+ measure : Measure
212
+ The portfolio measure.
213
+
214
+ names : str | list[str], optional
215
+ If provided, the population is filtered by portfolio names.
216
+
217
+ tags : str | list[str], optional
218
+ If provided, the population is filtered by portfolio tags.
219
+
220
+ Returns
221
+ -------
222
+ values : ndarray
223
+ The vector of portfolios measures.
224
+ """
225
+ population = self.filter(names=names, tags=tags)
226
+ return np.array([ptf.__getattribute__(measure.value) for ptf in population])
227
+
228
+ def measures_mean(
229
+ self,
230
+ measure: skt.Measure,
231
+ names: skt.Names | None = None,
232
+ tags: skt.Tags | None = None,
233
+ ) -> float:
234
+ """Mean of portfolios measures for each portfolio from the
235
+ population filtered by names and tags.
236
+
237
+ Parameters
238
+ ----------
239
+ measure : Measure
240
+ The portfolio measure.
241
+
242
+ names : str | list[str], optional
243
+ If provided, the population is filtered by portfolio names.
244
+
245
+ tags : str | list[str], optional
246
+ If provided, the population is filtered by portfolio tags.
247
+
248
+ Returns
249
+ -------
250
+ value : float
251
+ The mean of portfolios measures.
252
+ """
253
+ return self.measures(measure=measure, names=names, tags=tags).mean()
254
+
255
+ def measures_std(
256
+ self,
257
+ measure: skt.Measure,
258
+ names: skt.Names | None = None,
259
+ tags: skt.Tags | None = None,
260
+ ) -> float:
261
+ """Standard-deviation of portfolios measures for each portfolio from the
262
+ population filtered by names and tags.
263
+
264
+ Parameters
265
+ ----------
266
+ measure : Measure
267
+ The portfolio measure.
268
+
269
+ names : str | list[str], optional
270
+ If provided, the population is filtered by portfolio names.
271
+
272
+ tags : str | list[str], optional
273
+ If provided, the population is filtered by portfolio tags.
274
+
275
+ Returns
276
+ -------
277
+ value : float
278
+ The standard-deviation of portfolios measures.
279
+ """
280
+ return self.measures(measure=measure, names=names, tags=tags).std()
281
+
282
+ def sort_measure(
283
+ self,
284
+ measure: skt.Measure,
285
+ reverse: bool = False,
286
+ names: skt.Names | None = None,
287
+ tags: skt.Tags | None = None,
288
+ ) -> "Population":
289
+ """Sort the population by a given portfolio measure and filter the portfolios
290
+ by names and tags.
291
+
292
+ Parameters
293
+ ----------
294
+ measure : Measure
295
+ The portfolio measure.
296
+
297
+ reverse : bool, default=False
298
+ If this is set to True, the order is reversed.
299
+
300
+ names : str | list[str], optional
301
+ If provided, the population is filtered by portfolio names.
302
+
303
+ tags : str | list[str], optional
304
+ If provided, the population is filtered by portfolio tags.
305
+
306
+ Returns
307
+ -------
308
+ values : Populations
309
+ The sorted population.
310
+ """
311
+ population = self.filter(names=names, tags=tags)
312
+ return self.__class__(
313
+ sorted(
314
+ population,
315
+ key=lambda x: x.__getattribute__(measure.value),
316
+ reverse=reverse,
317
+ )
318
+ )
319
+
320
+ def quantile(
321
+ self,
322
+ measure: skt.Measure,
323
+ q: float,
324
+ names: skt.Names | None = None,
325
+ tags: skt.Tags | None = None,
326
+ ) -> Portfolio | MultiPeriodPortfolio:
327
+ """Returns the portfolio corresponding to the `q` quantile for a given portfolio
328
+ measure.
329
+
330
+ Parameters
331
+ ----------
332
+ measure : Measure
333
+ The portfolio measure.
334
+
335
+ q : float
336
+ The quantile value.
337
+
338
+ names : str | list[str], optional
339
+ If provided, the population is filtered by portfolio names.
340
+
341
+ tags : str | list[str], optional
342
+ If provided, the population is filtered by portfolio tags.
343
+
344
+ Returns
345
+ -------
346
+ values : Portfolio | MultiPeriodPortfolio
347
+ Portfolio corresponding to the `q` quantile for the measure.
348
+ """
349
+ if not 0 <= q <= 1:
350
+ raise ValueError("The quantile`q` must be between 0 and 1")
351
+ sorted_portfolios = self.sort_measure(
352
+ measure=measure, reverse=False, names=names, tags=tags
353
+ )
354
+ k = max(0, int(np.round(len(sorted_portfolios) * q)) - 1)
355
+ return sorted_portfolios[k]
356
+
357
+ def min_measure(
358
+ self,
359
+ measure: skt.Measure,
360
+ names: skt.Names | None = None,
361
+ tags: skt.Tags | None = None,
362
+ ) -> Portfolio | MultiPeriodPortfolio:
363
+ """Returns the portfolio with the minimum measure.
364
+
365
+ Parameters
366
+ ----------
367
+ measure : Measure
368
+ The portfolio measure.
369
+
370
+ names : str | list[str], optional
371
+ If provided, the population is filtered by portfolio names.
372
+
373
+ tags : str | list[str], optional
374
+ If provided, the population is filtered by portfolio tags.
375
+
376
+ Returns
377
+ -------
378
+ values : Portfolio | MultiPeriodPortfolio
379
+ The portfolio with minimum measure.
380
+ """
381
+ return self.quantile(measure=measure, q=0, names=names, tags=tags)
382
+
383
+ def max_measure(
384
+ self,
385
+ measure: skt.Measure,
386
+ names: skt.Names | None = None,
387
+ tags: skt.Tags | None = None,
388
+ ) -> Portfolio | MultiPeriodPortfolio:
389
+ """Returns the portfolio with the maximum measure.
390
+
391
+ Parameters
392
+ ----------
393
+ measure: Measure
394
+ The portfolio measure.
395
+
396
+ names : str | list[str], optional
397
+ If provided, the population is filtered by portfolio names.
398
+
399
+ tags : str | list[str], optional
400
+ If provided, the population is filtered by portfolio tags.
401
+
402
+ Returns
403
+ -------
404
+ values : Portfolio | MultiPeriodPortfolio
405
+ The portfolio with maximum measure.
406
+ """
407
+ return self.quantile(measure=measure, q=1, names=names, tags=tags)
408
+
409
+ def summary(
410
+ self,
411
+ formatted: bool = True,
412
+ names: skt.Names | None = None,
413
+ tags: skt.Tags | None = None,
414
+ ) -> pd.DataFrame:
415
+ """Summary of the portfolios in the population
416
+
417
+ Parameters
418
+ ----------
419
+ formatted : bool, default=True
420
+ If this is set to True, the measures are formatted into rounded string with
421
+ units.
422
+ The default is `True`.
423
+
424
+ names : str | list[str], optional
425
+ If provided, the population is filtered by portfolio names.
426
+
427
+ tags : str | list[str], optional
428
+ If provided, the population is filtered by portfolio tags.
429
+
430
+ Returns
431
+ -------
432
+ summary : pandas DataFrame
433
+ The population's portfolios summary
434
+ """
435
+
436
+ portfolios = self.filter(names=names, tags=tags)
437
+ df = pd.concat(
438
+ [p.summary(formatted=formatted) for p in portfolios],
439
+ keys=[p.name for p in portfolios],
440
+ axis=1,
441
+ )
442
+ return df
443
+
444
+ def composition(
445
+ self,
446
+ names: skt.Names | None = None,
447
+ tags: skt.Tags | None = None,
448
+ display_sub_ptf_name: bool = True,
449
+ ) -> pd.DataFrame:
450
+ """Composition of the portfolios in the population.
451
+
452
+ Parameters
453
+ ----------
454
+ names : str | list[str], optional
455
+ If provided, the population is filtered by portfolio names.
456
+
457
+ tags : str | list[str], optional
458
+ If provided, the population is filtered by portfolio tags.
459
+
460
+ display_sub_ptf_name : bool, default=True
461
+ If this is set to True, each sub-portfolio name composing a multi-period
462
+ portfolio is displayed.
463
+
464
+ Returns
465
+ -------
466
+ summary : DataFrame
467
+ Composition of the portfolios in the population.
468
+ """
469
+ portfolios = self.filter(names=names, tags=tags)
470
+ comp_list = []
471
+ for p in portfolios:
472
+ comp = p.composition
473
+ if display_sub_ptf_name:
474
+ if isinstance(p, MultiPeriodPortfolio):
475
+ comp.rename(
476
+ columns={c: f"{p.name}_{c}" for c in comp.columns}, inplace=True
477
+ )
478
+ else:
479
+ comp.rename(columns={c: p.name for c in comp.columns}, inplace=True)
480
+ comp_list.append(comp)
481
+
482
+ df = pd.concat(comp_list, axis=1)
483
+ df.columns = deduplicate_names(list(df.columns))
484
+ df.fillna(0, inplace=True)
485
+ return df
486
+
487
+ def plot_distribution(
488
+ self,
489
+ measure_list: list[skt.Measure],
490
+ tag_list: list[str] | None = None,
491
+ n_bins: int | None = None,
492
+ **kwargs,
493
+ ) -> go.Figure:
494
+ """Plot the population's distribution for each measure provided in the
495
+ measure list.
496
+
497
+ Parameters
498
+ ----------
499
+ measure_list : list[Measure]
500
+ The list of portfolio measures. A different distribution is plotted per
501
+ measure.
502
+
503
+ tag_list : list[str], optional
504
+ If this is provided, an additional distribution is plotted per measure
505
+ for each tag provided.
506
+
507
+ n_bins : int, optional
508
+ Sets the number of bins.
509
+
510
+ Returns
511
+ -------
512
+ plot : Figure
513
+ Returns the plotly Figure object.
514
+ """
515
+ values = []
516
+ labels = []
517
+ for measure in measure_list:
518
+ if tag_list is not None:
519
+ for tag in tag_list:
520
+ values.append(self.measures(measure=measure, tags=tag))
521
+ labels.append(f"{measure} - {tag}")
522
+ else:
523
+ values.append(self.measures(measure=measure))
524
+ labels.append(measure.value)
525
+
526
+ df = pd.DataFrame(np.array(values).T, columns=labels).melt(
527
+ var_name="Population"
528
+ )
529
+ fig = px.histogram(
530
+ df,
531
+ color="Population",
532
+ barmode="overlay",
533
+ marginal="box",
534
+ nbins=n_bins,
535
+ **kwargs,
536
+ )
537
+ fig.update_layout(title_text="Measures Distribution", xaxis_title="measures")
538
+ return fig
539
+
540
+ def plot_cumulative_returns(
541
+ self,
542
+ log_scale: bool = False,
543
+ idx: slice | np.ndarray | None = None,
544
+ names: skt.Names | None = None,
545
+ tags: skt.Tags | None = None,
546
+ ) -> go.Figure:
547
+ """Plot the population's portfolios cumulative returns.
548
+ Non-compounded cumulative returns start at 0.
549
+ Compounded cumulative returns are rescaled to start at 1000.
550
+
551
+ Parameters
552
+ ----------
553
+ log_scale : bool, default=False
554
+ If this is set to True, the cumulative returns are displayed with a
555
+ logarithm scale on the y-axis and rebased at 1000. The cumulative returns
556
+ must be compounded otherwise an exception is raise.
557
+
558
+ idx : slice | array, optional
559
+ Indexes or slice of the observations to plot.
560
+ The default (`None`) is to take all observations.
561
+
562
+ names : str | list[str], optional
563
+ If provided, the population is filtered by portfolio names.
564
+
565
+ tags : str | list[str], optional
566
+ If provided, the population is filtered by portfolio tags.
567
+
568
+ Returns
569
+ -------
570
+ plot : Figure
571
+ Returns the plot Figure object.
572
+ """
573
+ if idx is None:
574
+ idx = slice(None)
575
+ portfolios = self.filter(names=names, tags=tags)
576
+ if not portfolios:
577
+ raise ValueError("No portfolio found")
578
+
579
+ cumulative_returns = []
580
+ names = []
581
+ compounded = []
582
+ for ptf in portfolios:
583
+ cumulative_returns.append(ptf.cumulative_returns_df)
584
+ names.append(f"{ptf.name}_{ptf.tag}" if ptf.tag is not None else ptf.name)
585
+ compounded.append(ptf.compounded)
586
+ compounded = set(compounded)
587
+
588
+ if len(compounded) == 2:
589
+ raise ValueError(
590
+ "Some portfolios cumulative returns are compounded while some "
591
+ "are non-compounded. You can change the compounded with"
592
+ "`population.set_portfolio_params(compounded=False)`",
593
+ )
594
+ title = "Cumulative Returns"
595
+ compounded = compounded.pop()
596
+ if compounded:
597
+ yaxis_title = f"{title} (rebased at 1000)"
598
+ if log_scale:
599
+ title = f"{title} (compounded & log scaled)"
600
+ else:
601
+ title = f"{title} (compounded)"
602
+ else:
603
+ if log_scale:
604
+ raise ValueError(
605
+ "Plotting with logarithm scaling must be done on cumulative "
606
+ "returns that are compounded as opposed to non-compounded."
607
+ "You can change to compounded with "
608
+ "`set_portfolio_params(compounded=True)`"
609
+ )
610
+ yaxis_title = title
611
+ title = f"{title} (non-compounded)"
612
+
613
+ df = pd.concat(cumulative_returns, axis=1).iloc[:, idx]
614
+ df.columns = deduplicate_names(names)
615
+
616
+ fig = df.plot()
617
+ fig.update_layout(
618
+ title=title,
619
+ xaxis_title="Observations",
620
+ yaxis_title=yaxis_title,
621
+ legend_title_text="Portfolios",
622
+ )
623
+ if compounded:
624
+ fig.update_yaxes(tickformat=".0f")
625
+ else:
626
+ fig.update_yaxes(tickformat=".2%")
627
+ if log_scale:
628
+ fig.update_yaxes(type="log")
629
+ return fig
630
+
631
+ def plot_composition(
632
+ self,
633
+ names: skt.Names | None = None,
634
+ tags: skt.Tags | None = None,
635
+ display_sub_ptf_name: bool = True,
636
+ ) -> go.Figure:
637
+ """Plot the compositions of the portfolios in the population.
638
+
639
+ Parameters
640
+ ----------
641
+ names : str | list[str], optional
642
+ If provided, the population is filtered by portfolio names.
643
+
644
+ tags : str | list[str], optional
645
+ If provided, the population is filtered by portfolio tags.
646
+
647
+ display_sub_ptf_name : bool, default=True
648
+ If this is set to True, each sub-portfolio name composing a multi-period
649
+ portfolio is displayed.
650
+
651
+ Returns
652
+ -------
653
+ plot : Figure
654
+ Returns the plotly Figure object.
655
+ """
656
+ df = self.composition(
657
+ names=names, tags=tags, display_sub_ptf_name=display_sub_ptf_name
658
+ ).T
659
+ fig = px.bar(df, x=df.index, y=df.columns)
660
+ fig.update_layout(
661
+ title="Portfolios Composition",
662
+ xaxis={
663
+ "title": "Portfolios",
664
+ },
665
+ yaxis={
666
+ "title": "Weight",
667
+ "tickformat": ",.0%",
668
+ },
669
+ legend=dict(yanchor="top", y=0.99, xanchor="left", x=1.15),
670
+ )
671
+ return fig
672
+
673
+ def plot_measures(
674
+ self,
675
+ x: skt.Measure,
676
+ y: skt.Measure,
677
+ z: skt.Measure = None,
678
+ to_surface: bool = False,
679
+ hover_measures: list[skt.Measure] | None = None,
680
+ show_fronts: bool = False,
681
+ color_scale: skt.Measure | str | None = None,
682
+ names: skt.Names | None = None,
683
+ tags: skt.Tags | None = None,
684
+ title="Portfolios",
685
+ ) -> go.Figure:
686
+ """Plot the 2D (or 3D) scatter points (or surface) of a given set of
687
+ measures for each portfolio in the population.
688
+
689
+ Parameters
690
+ ----------
691
+ x : Measure
692
+ The x-axis measure.
693
+
694
+ y : Measure
695
+ The y-axis measure.
696
+
697
+ z : Measure, optional
698
+ The z-axis measure.
699
+
700
+ to_surface : bool, default=False
701
+ If this is set to True, a surface is estimated.
702
+
703
+ hover_measures : list[Measure], optional
704
+ The list of measure to show on point hover.
705
+
706
+ show_fronts : bool, default=False
707
+ If this is set to True, the pareto fronts are highlighted.
708
+ The default is `False`.
709
+
710
+ color_scale : Measure | str, optional
711
+ If this is provided, a color scale is displayed.
712
+
713
+ title : str, default="Portfolios"
714
+ The graph title. The default value is "Portfolios".
715
+
716
+ names : str | list[str], optional
717
+ If provided, the population is filtered by portfolio names.
718
+
719
+ tags : str | list[str], optional
720
+ If provided, the population is filtered by portfolio tags.
721
+
722
+ Returns
723
+ -------
724
+ plot : Figure
725
+ Returns the plotly Figure object.
726
+ """
727
+ portfolios = self.filter(names=names, tags=tags)
728
+ num_fmt = ":.3f"
729
+ hover_data = {x: num_fmt, y: num_fmt, "tag": True}
730
+
731
+ if z is not None:
732
+ hover_data[z] = num_fmt
733
+
734
+ if hover_measures is not None:
735
+ for measure in hover_measures:
736
+ hover_data[measure] = num_fmt
737
+
738
+ columns = list(hover_data)
739
+ columns.append("name")
740
+ if isinstance(color_scale, skt.Measure):
741
+ hover_data[color_scale] = num_fmt
742
+
743
+ if color_scale is not None and color_scale not in columns:
744
+ columns.append(color_scale)
745
+
746
+ col_values = [e.value if isinstance(e, skt.Measure) else e for e in columns]
747
+ res = [
748
+ [portfolio.__getattribute__(attr) for attr in col_values]
749
+ for portfolio in portfolios
750
+ ]
751
+ # Improved formatting
752
+ columns = [str(e) for e in columns]
753
+ hover_data = {str(k): v for k, v in hover_data.items()}
754
+
755
+ df = pd.DataFrame(res, columns=columns)
756
+ df["tag"] = df["tag"].astype(str).replace("None", "")
757
+
758
+ if show_fronts:
759
+ fronts = self.non_denominated_sort(first_front_only=False)
760
+ if tags is not None:
761
+ ValueError("Cannot plot front with tags selected")
762
+ df["front"] = -1
763
+ for i, front in enumerate(fronts):
764
+ for idx in front:
765
+ df.iloc[idx, -1] = str(i)
766
+ color = df.columns[-1]
767
+ elif color_scale is not None:
768
+ color = str(color_scale)
769
+ else:
770
+ color = "tag"
771
+
772
+ if z is not None:
773
+ if to_surface:
774
+ # estimate the surface
775
+ x_arr = np.array(df[str(x)])
776
+ y_arr = np.array(df[str(y)])
777
+ z_arr = np.array(df[str(z)])
778
+
779
+ xi = np.linspace(start=min(x_arr), stop=max(x_arr), num=100)
780
+ yi = np.linspace(start=min(y_arr), stop=max(y_arr), num=100)
781
+
782
+ X, Y = np.meshgrid(xi, yi)
783
+ Z = sci.griddata(
784
+ points=(x_arr, y_arr), values=z_arr, xi=(X, Y), method="cubic"
785
+ )
786
+ fig = go.Figure(
787
+ go.Surface(
788
+ x=xi,
789
+ y=yi,
790
+ z=Z,
791
+ hovertemplate="<br>".join(
792
+ [
793
+ str(e)
794
+ + ": %{"
795
+ + v
796
+ + ":"
797
+ + (",.3%" if not e.is_ratio else None)
798
+ + "}"
799
+ for e, v in [(x, "x"), (y, "y"), (z, "z")]
800
+ ]
801
+ )
802
+ + "<extra></extra>",
803
+ colorbar=dict(
804
+ title=str(z),
805
+ titleside="top",
806
+ tickformat=",.2%" if not z.is_ratio else None,
807
+ ),
808
+ )
809
+ )
810
+
811
+ fig.update_layout(
812
+ title=title,
813
+ scene=dict(
814
+ xaxis={
815
+ "title": str(x),
816
+ "tickformat": ",.1%" if not x.is_ratio else None,
817
+ },
818
+ yaxis={
819
+ "title": str(y),
820
+ "tickformat": ",.1%" if not y.is_ratio else None,
821
+ },
822
+ zaxis={
823
+ "title": str(z),
824
+ "tickformat": ",.1%" if not z.is_ratio else None,
825
+ },
826
+ ),
827
+ )
828
+ else:
829
+ # plot the points
830
+ fig = px.scatter_3d(
831
+ df,
832
+ x=str(x),
833
+ y=str(y),
834
+ z=str(z),
835
+ hover_name="name",
836
+ hover_data=hover_data,
837
+ color=color,
838
+ symbol="tag",
839
+ )
840
+ fig.update_traces(marker_size=8)
841
+ fig.update_layout(
842
+ title=title,
843
+ scene=dict(
844
+ xaxis={
845
+ "title": str(x),
846
+ "tickformat": ",.1%" if not x.is_ratio else None,
847
+ },
848
+ yaxis={
849
+ "title": str(y),
850
+ "tickformat": ",.1%" if not y.is_ratio else None,
851
+ },
852
+ zaxis={
853
+ "title": str(z),
854
+ "tickformat": ",.1%" if not z.is_ratio else None,
855
+ },
856
+ ),
857
+ legend=dict(yanchor="top", y=0.99, xanchor="left", x=1.15),
858
+ )
859
+
860
+ else:
861
+ fig = px.scatter(
862
+ df,
863
+ x=str(x),
864
+ y=str(y),
865
+ hover_name="name",
866
+ hover_data=hover_data,
867
+ color=color,
868
+ symbol="tag",
869
+ )
870
+ fig.update_traces(marker_size=10)
871
+ fig.update_layout(
872
+ title=title,
873
+ xaxis={
874
+ "title": str(x),
875
+ "tickformat": ",.1%" if not x.is_ratio else None,
876
+ },
877
+ yaxis={
878
+ "title": str(y),
879
+ "tickformat": ",.1%" if not y.is_ratio else None,
880
+ },
881
+ legend=dict(yanchor="top", y=0.96, xanchor="left", x=1.25),
882
+ )
883
+ return fig