skfolio 0.3.1__py3-none-any.whl → 0.4.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.
@@ -16,9 +16,10 @@ import plotly.graph_objects as go
16
16
  import scipy.interpolate as sci
17
17
 
18
18
  import skfolio.typing as skt
19
+ from skfolio.measures import RatioMeasure
19
20
  from skfolio.portfolio import BasePortfolio, MultiPeriodPortfolio
20
21
  from skfolio.utils.sorting import non_denominated_sort
21
- from skfolio.utils.tools import deduplicate_names
22
+ from skfolio.utils.tools import deduplicate_names, optimal_rounding_decimals
22
23
 
23
24
 
24
25
  class Population(list):
@@ -201,94 +202,62 @@ class Population(list):
201
202
  def measures(
202
203
  self,
203
204
  measure: skt.Measure,
204
- names: skt.Names | None = None,
205
- tags: skt.Tags | None = None,
206
205
  ) -> np.ndarray:
207
206
  """Vector of portfolios measures for each portfolio from the
208
- population filtered by names and tags.
207
+ population.
209
208
 
210
209
  Parameters
211
210
  ----------
212
211
  measure : Measure
213
212
  The portfolio measure.
214
213
 
215
- names : str | list[str], optional
216
- If provided, the population is filtered by portfolio names.
217
-
218
- tags : str | list[str], optional
219
- If provided, the population is filtered by portfolio tags.
220
-
221
214
  Returns
222
215
  -------
223
216
  values : ndarray
224
217
  The vector of portfolios measures.
225
218
  """
226
- population = self.filter(names=names, tags=tags)
227
- return np.array([ptf.__getattribute__(measure.value) for ptf in population])
219
+ return np.array([ptf.__getattribute__(measure.value) for ptf in self])
228
220
 
229
221
  def measures_mean(
230
222
  self,
231
223
  measure: skt.Measure,
232
- names: skt.Names | None = None,
233
- tags: skt.Tags | None = None,
234
224
  ) -> float:
235
225
  """Mean of portfolios measures for each portfolio from the
236
- population filtered by names and tags.
226
+ population.
237
227
 
238
228
  Parameters
239
229
  ----------
240
230
  measure : Measure
241
231
  The portfolio measure.
242
232
 
243
- names : str | list[str], optional
244
- If provided, the population is filtered by portfolio names.
245
-
246
- tags : str | list[str], optional
247
- If provided, the population is filtered by portfolio tags.
248
-
249
233
  Returns
250
234
  -------
251
235
  value : float
252
236
  The mean of portfolios measures.
253
237
  """
254
- return self.measures(measure=measure, names=names, tags=tags).mean()
238
+ return self.measures(measure=measure).mean()
255
239
 
256
240
  def measures_std(
257
241
  self,
258
242
  measure: skt.Measure,
259
- names: skt.Names | None = None,
260
- tags: skt.Tags | None = None,
261
243
  ) -> float:
262
244
  """Standard-deviation of portfolios measures for each portfolio from the
263
- population filtered by names and tags.
245
+ population.
264
246
 
265
247
  Parameters
266
248
  ----------
267
249
  measure : Measure
268
250
  The portfolio measure.
269
251
 
270
- names : str | list[str], optional
271
- If provided, the population is filtered by portfolio names.
272
-
273
- tags : str | list[str], optional
274
- If provided, the population is filtered by portfolio tags.
275
-
276
252
  Returns
277
253
  -------
278
254
  value : float
279
255
  The standard-deviation of portfolios measures.
280
256
  """
281
- return self.measures(measure=measure, names=names, tags=tags).std()
257
+ return self.measures(measure=measure).std()
282
258
 
283
- def sort_measure(
284
- self,
285
- measure: skt.Measure,
286
- reverse: bool = False,
287
- names: skt.Names | None = None,
288
- tags: skt.Tags | None = None,
289
- ) -> "Population":
290
- """Sort the population by a given portfolio measure and filter the portfolios
291
- by names and tags.
259
+ def sort_measure(self, measure: skt.Measure, reverse: bool = False) -> "Population":
260
+ """Sort the population by a given portfolio measure.
292
261
 
293
262
  Parameters
294
263
  ----------
@@ -298,21 +267,14 @@ class Population(list):
298
267
  reverse : bool, default=False
299
268
  If this is set to True, the order is reversed.
300
269
 
301
- names : str | list[str], optional
302
- If provided, the population is filtered by portfolio names.
303
-
304
- tags : str | list[str], optional
305
- If provided, the population is filtered by portfolio tags.
306
-
307
270
  Returns
308
271
  -------
309
272
  values : Populations
310
273
  The sorted population.
311
274
  """
312
- population = self.filter(names=names, tags=tags)
313
275
  return self.__class__(
314
276
  sorted(
315
- population,
277
+ self,
316
278
  key=lambda x: x.__getattribute__(measure.value),
317
279
  reverse=reverse,
318
280
  )
@@ -322,8 +284,6 @@ class Population(list):
322
284
  self,
323
285
  measure: skt.Measure,
324
286
  q: float,
325
- names: skt.Names | None = None,
326
- tags: skt.Tags | None = None,
327
287
  ) -> BasePortfolio:
328
288
  """Returns the portfolio corresponding to the `q` quantile for a given portfolio
329
289
  measure.
@@ -336,12 +296,6 @@ class Population(list):
336
296
  q : float
337
297
  The quantile value.
338
298
 
339
- names : str | list[str], optional
340
- If provided, the population is filtered by portfolio names.
341
-
342
- tags : str | list[str], optional
343
- If provided, the population is filtered by portfolio tags.
344
-
345
299
  Returns
346
300
  -------
347
301
  values : BasePortfolio
@@ -349,17 +303,13 @@ class Population(list):
349
303
  """
350
304
  if not 0 <= q <= 1:
351
305
  raise ValueError("The quantile`q` must be between 0 and 1")
352
- sorted_portfolios = self.sort_measure(
353
- measure=measure, reverse=False, names=names, tags=tags
354
- )
306
+ sorted_portfolios = self.sort_measure(measure=measure, reverse=False)
355
307
  k = max(0, int(np.round(len(sorted_portfolios) * q)) - 1)
356
308
  return sorted_portfolios[k]
357
309
 
358
310
  def min_measure(
359
311
  self,
360
312
  measure: skt.Measure,
361
- names: skt.Names | None = None,
362
- tags: skt.Tags | None = None,
363
313
  ) -> BasePortfolio:
364
314
  """Returns the portfolio with the minimum measure.
365
315
 
@@ -368,24 +318,16 @@ class Population(list):
368
318
  measure : Measure
369
319
  The portfolio measure.
370
320
 
371
- names : str | list[str], optional
372
- If provided, the population is filtered by portfolio names.
373
-
374
- tags : str | list[str], optional
375
- If provided, the population is filtered by portfolio tags.
376
-
377
321
  Returns
378
322
  -------
379
323
  values : BasePortfolio
380
324
  The portfolio with minimum measure.
381
325
  """
382
- return self.quantile(measure=measure, q=0, names=names, tags=tags)
326
+ return self.quantile(measure=measure, q=0)
383
327
 
384
328
  def max_measure(
385
329
  self,
386
330
  measure: skt.Measure,
387
- names: skt.Names | None = None,
388
- tags: skt.Tags | None = None,
389
331
  ) -> BasePortfolio:
390
332
  """Returns the portfolio with the maximum measure.
391
333
 
@@ -394,24 +336,16 @@ class Population(list):
394
336
  measure: Measure
395
337
  The portfolio measure.
396
338
 
397
- names : str | list[str], optional
398
- If provided, the population is filtered by portfolio names.
399
-
400
- tags : str | list[str], optional
401
- If provided, the population is filtered by portfolio tags.
402
-
403
339
  Returns
404
340
  -------
405
341
  values : BasePortfolio
406
342
  The portfolio with maximum measure.
407
343
  """
408
- return self.quantile(measure=measure, q=1, names=names, tags=tags)
344
+ return self.quantile(measure=measure, q=1)
409
345
 
410
346
  def summary(
411
347
  self,
412
348
  formatted: bool = True,
413
- names: skt.Names | None = None,
414
- tags: skt.Tags | None = None,
415
349
  ) -> pd.DataFrame:
416
350
  """Summary of the portfolios in the population
417
351
 
@@ -422,69 +356,135 @@ class Population(list):
422
356
  units.
423
357
  The default is `True`.
424
358
 
425
- names : str | list[str], optional
426
- If provided, the population is filtered by portfolio names.
427
-
428
- tags : str | list[str], optional
429
- If provided, the population is filtered by portfolio tags.
430
-
431
359
  Returns
432
360
  -------
433
361
  summary : pandas DataFrame
434
362
  The population's portfolios summary
435
363
  """
436
364
 
437
- portfolios = self.filter(names=names, tags=tags)
438
365
  df = pd.concat(
439
- [p.summary(formatted=formatted) for p in portfolios],
440
- keys=[p.name for p in portfolios],
366
+ [p.summary(formatted=formatted) for p in self],
367
+ keys=[p.name for p in self],
441
368
  axis=1,
442
369
  )
443
370
  return df
444
371
 
445
372
  def composition(
446
373
  self,
447
- names: skt.Names | None = None,
448
- tags: skt.Tags | None = None,
449
374
  display_sub_ptf_name: bool = True,
450
375
  ) -> pd.DataFrame:
451
- """Composition of the portfolios in the population.
376
+ """Composition of each portfolio in the population.
452
377
 
453
378
  Parameters
454
379
  ----------
455
- names : str | list[str], optional
456
- If provided, the population is filtered by portfolio names.
457
-
458
- tags : str | list[str], optional
459
- If provided, the population is filtered by portfolio tags.
460
-
461
380
  display_sub_ptf_name : bool, default=True
462
381
  If this is set to True, each sub-portfolio name composing a multi-period
463
382
  portfolio is displayed.
464
383
 
465
384
  Returns
466
385
  -------
467
- summary : DataFrame
386
+ df : DataFrame
468
387
  Composition of the portfolios in the population.
469
388
  """
470
- portfolios = self.filter(names=names, tags=tags)
471
- comp_list = []
472
- for p in portfolios:
473
- comp = p.composition
389
+ res = []
390
+ for ptf in self:
391
+ comp = ptf.composition
474
392
  if display_sub_ptf_name:
475
- if isinstance(p, MultiPeriodPortfolio):
393
+ if isinstance(ptf, MultiPeriodPortfolio):
476
394
  comp.rename(
477
- columns={c: f"{p.name}_{c}" for c in comp.columns}, inplace=True
395
+ columns={c: f"{ptf.name}_{c}" for c in comp.columns},
396
+ inplace=True,
397
+ )
398
+ else:
399
+ comp.rename(columns={c: ptf.name for c in comp.columns}, inplace=True)
400
+ res.append(comp)
401
+
402
+ df = pd.concat(res, axis=1)
403
+ df.columns = deduplicate_names(list(df.columns))
404
+ df.fillna(0, inplace=True)
405
+ return df
406
+
407
+ def contribution(
408
+ self,
409
+ measure: skt.Measure,
410
+ spacing: float | None = None,
411
+ display_sub_ptf_name: bool = True,
412
+ ) -> pd.DataFrame:
413
+ r"""Contribution of each asset to a given measure of each portfolio in the
414
+ population.
415
+
416
+ Parameters
417
+ ----------
418
+ measure : Measure
419
+ The measure used for the contribution computation.
420
+
421
+ spacing : float, optional
422
+ Spacing "h" of the finite difference:
423
+ :math:`contribution(wi)= \frac{measure(wi-h) - measure(wi+h)}{2h}`.
424
+
425
+ display_sub_ptf_name : bool, default=True
426
+ If this is set to True, each sub-portfolio name composing a multi-period
427
+ portfolio is displayed.
428
+
429
+ Returns
430
+ -------
431
+ df : DataFrame
432
+ Contribution of each asset to a given measure of each portfolio in the
433
+ population.
434
+ """
435
+ res = []
436
+ for ptf in self:
437
+ contribution = ptf.contribution(
438
+ measure=measure, spacing=spacing, to_df=True
439
+ )
440
+ if display_sub_ptf_name:
441
+ if isinstance(ptf, MultiPeriodPortfolio):
442
+ contribution.rename(
443
+ columns={c: f"{ptf.name}_{c}" for c in contribution.columns},
444
+ inplace=True,
478
445
  )
479
446
  else:
480
- comp.rename(columns={c: p.name for c in comp.columns}, inplace=True)
481
- comp_list.append(comp)
447
+ contribution.rename(
448
+ columns={c: ptf.name for c in contribution.columns}, inplace=True
449
+ )
450
+ res.append(contribution)
482
451
 
483
- df = pd.concat(comp_list, axis=1)
452
+ df = pd.concat(res, axis=1)
484
453
  df.columns = deduplicate_names(list(df.columns))
485
454
  df.fillna(0, inplace=True)
486
455
  return df
487
456
 
457
+ def rolling_measure(
458
+ self, measure: skt.Measure = RatioMeasure.SHARPE_RATIO, window: int = 30
459
+ ) -> pd.DataFrame:
460
+ """Compute the measure over a rolling window for each portfolio in the
461
+ population.
462
+
463
+ Parameters
464
+ ----------
465
+ measure : ct.Measure, default=RatioMeasure.SHARPE_RATIO
466
+ The measure. The default measure is the Sharpe Ratio.
467
+
468
+ window : int, default=30
469
+ The window size. The default value is `30` observations.
470
+
471
+ Returns
472
+ -------
473
+ dataframe : pandas DataFrame
474
+ The rolling measures.
475
+ """
476
+
477
+ rolling_measures = []
478
+ names = []
479
+ for ptf in self:
480
+ rolling_measures.append(ptf.rolling_measure(measure=measure, window=window))
481
+ names.append(_ptf_name_with_tag(ptf))
482
+ df = pd.concat(rolling_measures, axis=1)
483
+ df.columns = deduplicate_names(names)
484
+ # Sort index because pd.concat unsort NaNs at the end
485
+ df.sort_index(inplace=True)
486
+ return df
487
+
488
488
  def plot_distribution(
489
489
  self,
490
490
  measure_list: list[skt.Measure],
@@ -518,7 +518,7 @@ class Population(list):
518
518
  for measure in measure_list:
519
519
  if tag_list is not None:
520
520
  for tag in tag_list:
521
- values.append(self.measures(measure=measure, tags=tag))
521
+ values.append(self.filter(tags=tag).measures(measure=measure))
522
522
  labels.append(f"{measure} - {tag}")
523
523
  else:
524
524
  values.append(self.measures(measure=measure))
@@ -542,8 +542,6 @@ class Population(list):
542
542
  self,
543
543
  log_scale: bool = False,
544
544
  idx: slice | np.ndarray | None = None,
545
- names: skt.Names | None = None,
546
- tags: skt.Tags | None = None,
547
545
  ) -> go.Figure:
548
546
  """Plot the population's portfolios cumulative returns.
549
547
  Non-compounded cumulative returns start at 0.
@@ -560,12 +558,6 @@ class Population(list):
560
558
  Indexes or slice of the observations to plot.
561
559
  The default (`None`) is to take all observations.
562
560
 
563
- names : str | list[str], optional
564
- If provided, the population is filtered by portfolio names.
565
-
566
- tags : str | list[str], optional
567
- If provided, the population is filtered by portfolio tags.
568
-
569
561
  Returns
570
562
  -------
571
563
  plot : Figure
@@ -573,16 +565,13 @@ class Population(list):
573
565
  """
574
566
  if idx is None:
575
567
  idx = slice(None)
576
- portfolios = self.filter(names=names, tags=tags)
577
- if not portfolios:
578
- raise ValueError("No portfolio found")
579
568
 
580
569
  cumulative_returns = []
581
570
  names = []
582
571
  compounded = []
583
- for ptf in portfolios:
572
+ for ptf in self:
584
573
  cumulative_returns.append(ptf.cumulative_returns_df)
585
- names.append(f"{ptf.name}_{ptf.tag}" if ptf.tag is not None else ptf.name)
574
+ names.append(_ptf_name_with_tag(ptf))
586
575
  compounded.append(ptf.compounded)
587
576
  compounded = set(compounded)
588
577
 
@@ -631,22 +620,11 @@ class Population(list):
631
620
  fig.update_yaxes(type="log")
632
621
  return fig
633
622
 
634
- def plot_composition(
635
- self,
636
- names: skt.Names | None = None,
637
- tags: skt.Tags | None = None,
638
- display_sub_ptf_name: bool = True,
639
- ) -> go.Figure:
623
+ def plot_composition(self, display_sub_ptf_name: bool = True) -> go.Figure:
640
624
  """Plot the compositions of the portfolios in the population.
641
625
 
642
626
  Parameters
643
627
  ----------
644
- names : str | list[str], optional
645
- If provided, the population is filtered by portfolio names.
646
-
647
- tags : str | list[str], optional
648
- If provided, the population is filtered by portfolio tags.
649
-
650
628
  display_sub_ptf_name : bool, default=True
651
629
  If this is set to True, each sub-portfolio name composing a multi-period
652
630
  portfolio is displayed.
@@ -656,15 +634,11 @@ class Population(list):
656
634
  plot : Figure
657
635
  Returns the plotly Figure object.
658
636
  """
659
- df = self.composition(
660
- names=names, tags=tags, display_sub_ptf_name=display_sub_ptf_name
661
- ).T
637
+ df = self.composition(display_sub_ptf_name=display_sub_ptf_name).T
662
638
  fig = px.bar(df, x=df.index, y=df.columns)
663
639
  fig.update_layout(
664
640
  title="Portfolios Composition",
665
- xaxis={
666
- "title": "Portfolios",
667
- },
641
+ xaxis_title="Portfolios",
668
642
  yaxis={
669
643
  "title": "Weight",
670
644
  "tickformat": ",.0%",
@@ -673,6 +647,53 @@ class Population(list):
673
647
  )
674
648
  return fig
675
649
 
650
+ def plot_contribution(
651
+ self,
652
+ measure: skt.Measure,
653
+ spacing: float | None = None,
654
+ display_sub_ptf_name: bool = True,
655
+ ) -> go.Figure:
656
+ """Plot the contribution of each asset to a given measure of the portfolios
657
+ in the population.
658
+
659
+ Parameters
660
+ ----------
661
+ measure : Measure
662
+ The measure used for the contribution computation.
663
+
664
+ spacing : float, optional
665
+ Spacing "h" of the finite difference:
666
+ :math:`contribution(wi)= \frac{measure(wi-h) - measure(wi+h)}{2h}`
667
+
668
+ display_sub_ptf_name : bool, default=True
669
+ If this is set to True, each sub-portfolio name composing a multi-period
670
+ portfolio is displayed.
671
+
672
+ Returns
673
+ -------
674
+ plot : Figure
675
+ Returns the plotly Figure object.
676
+ """
677
+ df = self.contribution(
678
+ display_sub_ptf_name=display_sub_ptf_name, measure=measure, spacing=spacing
679
+ ).T
680
+ fig = px.bar(df, x=df.index, y=df.columns)
681
+
682
+ yaxis = {
683
+ "title": "Contribution",
684
+ }
685
+ if not measure.is_ratio:
686
+ n = optimal_rounding_decimals(df.sum(axis=1).max())
687
+ yaxis["tickformat"] = f",.{n}%"
688
+
689
+ fig.update_layout(
690
+ title=f"{measure} Contribution",
691
+ xaxis_title="Portfolios",
692
+ yaxis=yaxis,
693
+ legend=dict(yanchor="top", y=0.99, xanchor="left", x=1.15),
694
+ )
695
+ return fig
696
+
676
697
  def plot_measures(
677
698
  self,
678
699
  x: skt.Measure,
@@ -682,8 +703,6 @@ class Population(list):
682
703
  hover_measures: list[skt.Measure] | None = None,
683
704
  show_fronts: bool = False,
684
705
  color_scale: skt.Measure | str | None = None,
685
- names: skt.Names | None = None,
686
- tags: skt.Tags | None = None,
687
706
  title="Portfolios",
688
707
  ) -> go.Figure:
689
708
  """Plot the 2D (or 3D) scatter points (or surface) of a given set of
@@ -716,18 +735,11 @@ class Population(list):
716
735
  title : str, default="Portfolios"
717
736
  The graph title. The default value is "Portfolios".
718
737
 
719
- names : str | list[str], optional
720
- If provided, the population is filtered by portfolio names.
721
-
722
- tags : str | list[str], optional
723
- If provided, the population is filtered by portfolio tags.
724
-
725
738
  Returns
726
739
  -------
727
740
  plot : Figure
728
741
  Returns the plotly Figure object.
729
742
  """
730
- portfolios = self.filter(names=names, tags=tags)
731
743
  num_fmt = ":.3f"
732
744
  hover_data = {x: num_fmt, y: num_fmt, "tag": True}
733
745
 
@@ -749,7 +761,7 @@ class Population(list):
749
761
  col_values = [e.value if isinstance(e, skt.Measure) else e for e in columns]
750
762
  res = [
751
763
  [portfolio.__getattribute__(attr) for attr in col_values]
752
- for portfolio in portfolios
764
+ for portfolio in self
753
765
  ]
754
766
  # Improved formatting
755
767
  columns = [str(e) for e in columns]
@@ -760,8 +772,6 @@ class Population(list):
760
772
 
761
773
  if show_fronts:
762
774
  fronts = self.non_denominated_sort(first_front_only=False)
763
- if tags is not None:
764
- ValueError("Cannot plot front with tags selected")
765
775
  df["front"] = str(-1)
766
776
  for i, front in enumerate(fronts):
767
777
  for idx in front:
@@ -884,3 +894,56 @@ class Population(list):
884
894
  legend=dict(yanchor="top", y=0.96, xanchor="left", x=1.25),
885
895
  )
886
896
  return fig
897
+
898
+ def plot_rolling_measure(
899
+ self,
900
+ measure: skt.Measure = RatioMeasure.SHARPE_RATIO,
901
+ window: int = 30,
902
+ ) -> go.Figure:
903
+ """Plot the measure over a rolling window for each portfolio in the population.
904
+
905
+ Parameters
906
+ ----------
907
+ measure : ct.Measure, default = RatioMeasure.SHARPE_RATIO
908
+ The measure.
909
+
910
+ window : int, default=30
911
+ The window size.
912
+
913
+ Returns
914
+ -------
915
+ plot : Figure
916
+ Returns the plot Figure object
917
+ """
918
+ df = self.rolling_measure(measure=measure, window=window)
919
+ fig = df.plot(backend="plotly")
920
+ max_val = np.max(df)
921
+ min_val = np.min(df)
922
+ if max_val > 0 > min_val:
923
+ fig.add_hrect(
924
+ y0=0, y1=max_val * 1.3, line_width=0, fillcolor="green", opacity=0.1
925
+ )
926
+ fig.add_hrect(
927
+ y0=min_val * 1.3, y1=0, line_width=0, fillcolor="red", opacity=0.1
928
+ )
929
+
930
+ yaxis = {
931
+ "title": str(measure),
932
+ }
933
+ if not measure.is_ratio:
934
+ n = optimal_rounding_decimals(max_val)
935
+ yaxis["tickformat"] = f",.{n}%"
936
+
937
+ fig.update_layout(
938
+ title=f"Rolling {measure} - {window} observations window",
939
+ xaxis_title="Observations",
940
+ yaxis=yaxis,
941
+ showlegend=False,
942
+ )
943
+ return fig
944
+
945
+
946
+ def _ptf_name_with_tag(portfolio: BasePortfolio) -> str:
947
+ if portfolio.tag is None:
948
+ return portfolio.name
949
+ return f"{portfolio.name}_{portfolio.tag}"