skfolio 0.3.1__py3-none-any.whl → 0.4.0__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.
@@ -59,6 +59,7 @@ from skfolio.utils.tools import (
59
59
  args_names,
60
60
  cached_property_slots,
61
61
  format_measure,
62
+ optimal_rounding_decimals,
62
63
  )
63
64
 
64
65
  _ZERO_THRESHOLD = 1e-5
@@ -614,6 +615,13 @@ class BasePortfolio:
614
615
  """DataFrame of the Portfolio composition"""
615
616
  pass
616
617
 
618
+ @abstractmethod
619
+ def contribution(
620
+ self, measure: skt.Measure, spacing: float | None = None, to_df: bool = True
621
+ ) -> np.ndarray | pd.DataFrame:
622
+ """Compute the contribution of each asset to a given measure"""
623
+ pass
624
+
617
625
  # Custom attribute setter and getter
618
626
  @property
619
627
  def fitness_measures(self) -> list[skt.Measure]:
@@ -811,7 +819,7 @@ class BasePortfolio:
811
819
  The measure. The default measure is the Sharpe Ratio.
812
820
 
813
821
  window : int, default=30
814
- The window size. The default value is `30`.
822
+ The window size. The default value is `30` observations.
815
823
 
816
824
  Returns
817
825
  -------
@@ -925,11 +933,9 @@ class BasePortfolio:
925
933
  key = f"{e!s} at {beta:.0%}"
926
934
  except AttributeError:
927
935
  key = str(e)
928
- if isinstance(e, RatioMeasure) or e in [
936
+ if e.is_ratio or e in [
929
937
  ExtraRiskMeasure.ENTROPIC_RISK_MEASURE,
930
938
  RiskMeasure.ULCER_INDEX,
931
- ExtraRiskMeasure.SKEW,
932
- ExtraRiskMeasure.KURTOSIS,
933
939
  ]:
934
940
  percent = False
935
941
  else:
@@ -1053,19 +1059,18 @@ class BasePortfolio:
1053
1059
  line_dash="dash",
1054
1060
  line_color="blue",
1055
1061
  )
1056
- max_val = rolling.max()
1057
- min_val = rolling.min()
1058
- if max_val > 0:
1062
+ max_val = np.max(rolling)
1063
+ min_val = np.min(rolling)
1064
+ if max_val > 0 > min_val:
1059
1065
  fig.add_hrect(
1060
1066
  y0=0, y1=max_val * 1.3, line_width=0, fillcolor="green", opacity=0.1
1061
1067
  )
1062
- if min_val < 0:
1063
1068
  fig.add_hrect(
1064
1069
  y0=min_val * 1.3, y1=0, line_width=0, fillcolor="red", opacity=0.1
1065
1070
  )
1066
1071
 
1067
1072
  fig.update_layout(
1068
- title=f"rolling {measure} - {window} observations window",
1073
+ title=f"Rolling {measure} - {window} observations window",
1069
1074
  xaxis_title="Observations",
1070
1075
  yaxis_title=str(measure),
1071
1076
  showlegend=False,
@@ -1089,3 +1094,37 @@ class BasePortfolio:
1089
1094
  legend_title_text="Assets",
1090
1095
  )
1091
1096
  return fig
1097
+
1098
+ def plot_contribution(self, measure: skt.Measure, spacing: float | None = None):
1099
+ r"""Plot the contribution of each asset to a given measure.
1100
+
1101
+ Parameters
1102
+ ----------
1103
+ measure : Measure
1104
+ The measure used for the contribution computation.
1105
+
1106
+ spacing : float, optional
1107
+ Spacing "h" of the finite difference:
1108
+ :math:`contribution(wi)= \frac{measure(wi-h) - measure(wi+h)}{2h}`
1109
+
1110
+ Returns
1111
+ -------
1112
+ plot : Figure
1113
+ The plotly Figure of assets contribution to the measure.
1114
+ """
1115
+ df = self.contribution(measure=measure, spacing=spacing, to_df=True).T
1116
+ fig = px.bar(df, x=df.index, y=df.columns)
1117
+ yaxis = {
1118
+ "title": "Contribution",
1119
+ }
1120
+ if not measure.is_ratio:
1121
+ n = optimal_rounding_decimals(df.sum(axis=1).max())
1122
+ yaxis["tickformat"] = f",.{n}%"
1123
+
1124
+ fig.update_layout(
1125
+ title=f"{measure} Contribution",
1126
+ xaxis_title="Portfolio",
1127
+ yaxis=yaxis,
1128
+ legend_title_text="Assets",
1129
+ )
1130
+ return fig
@@ -556,6 +556,51 @@ class MultiPeriodPortfolio(BasePortfolio):
556
556
  df.columns = deduplicate_names(df.columns)
557
557
  return df
558
558
 
559
+ @property
560
+ def weights_per_observation(self) -> pd.DataFrame:
561
+ """DataFrame of the Portfolio weights per observation."""
562
+ return (
563
+ pd.concat([p.weights_per_observation for p in self], axis=0)
564
+ .fillna(0)
565
+ .sort_index()
566
+ )
567
+
568
+ def contribution(
569
+ self, measure: skt.Measure, spacing: float | None = None, to_df: bool = True
570
+ ) -> np.ndarray | pd.DataFrame:
571
+ r"""Compute the contribution of each asset to a given measure for each
572
+ portfolio.
573
+
574
+ Parameters
575
+ ----------
576
+ measure : Measure
577
+ The measure used for the contribution computation.
578
+
579
+ spacing : float, optional
580
+ Spacing "h" of the finite difference:
581
+ :math:`contribution(wi)= \frac{measure(wi-h) - measure(wi+h)}{2h}`
582
+
583
+ to_df : bool, default=False
584
+ If this is set to True, a DataFrame with asset names in index and portfolio
585
+ names in columns is returned, otherwise a list of numpy array is returned.
586
+ When a DataFrame is returned, the assets with zero weights are removed.
587
+
588
+ Returns
589
+ -------
590
+ values : list of numpy array of shape (n_assets,) for each portfolio or a DataFrame
591
+ The measure contribution of each asset for each portfolio.
592
+ """
593
+ contributions = [
594
+ ptf.contribution(measure=measure, spacing=spacing, to_df=to_df)
595
+ for ptf in self
596
+ ]
597
+ if not to_df:
598
+ return contributions
599
+ df = pd.concat(contributions, axis=1)
600
+ df.fillna(0, inplace=True)
601
+ df.columns = deduplicate_names(df.columns)
602
+ return df
603
+
559
604
  def summary(self, formatted: bool = True) -> pd.Series:
560
605
  """Portfolio summary of all its measures.
561
606
 
@@ -14,7 +14,6 @@ from typing import ClassVar
14
14
  import numpy as np
15
15
  import numpy.typing as npt
16
16
  import pandas as pd
17
- import plotly.express as px
18
17
 
19
18
  import skfolio.typing as skt
20
19
  from skfolio.measures import RiskMeasure, effective_number_assets
@@ -668,7 +667,20 @@ class Portfolio(BasePortfolio):
668
667
  return df
669
668
 
670
669
  @property
671
- def diversification(self):
670
+ def weights_per_observation(self) -> pd.DataFrame:
671
+ """DataFrame of the Portfolio weights per observation."""
672
+ idx = self.nonzero_assets_index
673
+ weights = self.weights[idx]
674
+ assets = self.assets[idx]
675
+ df = pd.DataFrame(
676
+ np.ones((len(self.observations), len(assets))) * weights,
677
+ index=self.observations,
678
+ columns=assets,
679
+ )
680
+ return df
681
+
682
+ @property
683
+ def diversification(self) -> float:
672
684
  """Weighted average of volatility divided by the portfolio volatility."""
673
685
  return (
674
686
  self.weights @ np.std(np.asarray(self.X), axis=0) / self.standard_deviation
@@ -750,8 +762,8 @@ class Portfolio(BasePortfolio):
750
762
  return float(self.weights @ assets_covariance @ self.weights.T)
751
763
 
752
764
  def contribution(
753
- self, measure: skt.Measure, spacing: float | None = None
754
- ) -> np.ndarray:
765
+ self, measure: skt.Measure, spacing: float | None = None, to_df: bool = False
766
+ ) -> np.ndarray | pd.DataFrame:
755
767
  r"""Compute the contribution of each asset to a given measure.
756
768
 
757
769
  Parameters
@@ -763,9 +775,15 @@ class Portfolio(BasePortfolio):
763
775
  Spacing "h" of the finite difference:
764
776
  :math:`contribution(wi)= \frac{measure(wi-h) - measure(wi+h)}{2h}`
765
777
 
778
+ to_df : bool, default=False
779
+ If set to True, a DataFrame with asset names in index is returned,
780
+ otherwise a numpy array is returned. When a DataFrame is returned, the
781
+ values are sorted in descending order and assets with zero weights are
782
+ removed.
783
+
766
784
  Returns
767
785
  -------
768
- values : ndrray of shape (n_assets,)
786
+ values : numpy array of shape (n_assets,) or DataFrame
769
787
  The measure contribution of each asset.
770
788
  """
771
789
  if spacing is None:
@@ -778,49 +796,26 @@ class Portfolio(BasePortfolio):
778
796
  spacing = 1e-1
779
797
  else:
780
798
  spacing = 1e-5
781
- args = {arg: getattr(self, arg) for arg in args_names(self.__init__)}
782
-
783
- def get_risk(i: int, h: float) -> float:
784
- a = args.copy()
785
- w = a["weights"].copy()
786
- w[i] += h
787
- a["weights"] = w
788
- return getattr(Portfolio(**a), measure.value)
789
-
790
- cont = [
791
- (get_risk(i, h=spacing) - get_risk(i, h=-spacing))
792
- / (2 * spacing)
793
- * self.weights[i]
794
- for i in range(len(self.weights))
795
- ]
796
- return np.array(cont)
797
-
798
- def plot_contribution(self, measure: skt.Measure, spacing: float | None = None):
799
- r"""Plot the contribution of each asset to a given measure.
800
-
801
- Parameters
802
- ----------
803
- measure : Measure
804
- The measure used for the contribution computation.
805
-
806
- spacing : float, optional
807
- Spacing "h" of the finite difference:
808
- :math:`contribution(wi)= \frac{measure(wi-h) - measure(wi+h)}{2h}`
799
+ args = {
800
+ arg: getattr(self, arg)
801
+ for arg in args_names(self.__init__)
802
+ if arg != "weights"
803
+ }
809
804
 
810
- Returns
811
- -------
812
- plot : Figure
813
- The plotly Figure of assets contribution to the measure.
814
- """
815
- cont = self.contribution(measure=measure, spacing=spacing)
816
- df = pd.DataFrame(cont, index=self.assets, columns=["contribution"])
817
- fig = px.bar(df, x=df.index, y=df.columns)
818
- fig.update_layout(
819
- title=f"{measure} contribution",
820
- xaxis_title="Asset",
821
- yaxis_title=f"{measure} contribution",
805
+ contribution, assets = _compute_contribution(
806
+ args=args,
807
+ weights=self.weights,
808
+ assets=self.assets,
809
+ measure=measure,
810
+ h=spacing,
811
+ drop_zero_weights=to_df,
822
812
  )
823
- return fig
813
+
814
+ if not to_df:
815
+ return np.array(contribution)
816
+ df = pd.DataFrame(contribution, index=assets, columns=[self.name])
817
+ df.sort_values(by=self.name, ascending=False, inplace=True)
818
+ return df
824
819
 
825
820
  def summary(self, formatted: bool = True) -> pd.Series:
826
821
  """Portfolio summary of all its measures.
@@ -863,3 +858,44 @@ class Portfolio(BasePortfolio):
863
858
  return self.weights[np.where(self.assets == asset)[0][0]]
864
859
  except IndexError:
865
860
  raise IndexError("{asset} is not a valid asset name.") from None
861
+
862
+
863
+ def _get_risk(
864
+ args: dict, weights: np.ndarray, measure: skt.Measure, i: int, h: float
865
+ ) -> float:
866
+ """Get the Portfolio risk measure when the weight of asset `i` is increased by `h`."""
867
+ assert "weights" not in args
868
+ weights = weights.copy()
869
+ weights[i] += h
870
+ return getattr(Portfolio(weights=weights, **args), measure.value)
871
+
872
+
873
+ def _compute_contribution(
874
+ args: dict,
875
+ weights: np.ndarray,
876
+ assets: np.ndarray,
877
+ measure: skt.Measure,
878
+ h: float,
879
+ drop_zero_weights: bool,
880
+ ) -> tuple[list[float], list[str]]:
881
+ """Compute the contribution of each asset to a given measure using finite
882
+ difference.
883
+ """
884
+ contributions = []
885
+ _assets = []
886
+ for i, (weight, asset) in enumerate(zip(weights, assets, strict=True)):
887
+ if weight == 0:
888
+ if not drop_zero_weights:
889
+ _assets.append(asset)
890
+ contributions.append(0)
891
+ else:
892
+ _assets.append(asset)
893
+ contributions.append(
894
+ (
895
+ _get_risk(args, weights, measure, i, h)
896
+ - _get_risk(args, weights, measure, i, -h)
897
+ )
898
+ / (2 * h)
899
+ * weight
900
+ )
901
+ return contributions, _assets
skfolio/utils/tools.py CHANGED
@@ -26,6 +26,7 @@ __all__ = [
26
26
  "input_to_array",
27
27
  "args_names",
28
28
  "format_measure",
29
+ "optimal_rounding_decimals",
29
30
  "bisection",
30
31
  "safe_split",
31
32
  "fit_single_estimator",
@@ -465,10 +466,26 @@ def format_measure(x: float, percent: bool = False) -> str:
465
466
  if xn == 0:
466
467
  n = 0
467
468
  else:
468
- n = min(6, max(int(-np.log10(abs(xn))) + 2, 2))
469
+ n = optimal_rounding_decimals(xn)
469
470
  return "{value:{fmt}}".format(value=x, fmt=f".{n}{f}")
470
471
 
471
472
 
473
+ def optimal_rounding_decimals(x: float) -> int:
474
+ """Return the optimal rounding decimal number for a user-friendly formatting.
475
+
476
+ Parameters
477
+ ----------
478
+ x : float
479
+ Number to round.
480
+
481
+ Returns
482
+ -------
483
+ n : int
484
+ Rounding decimal number.
485
+ """
486
+ return min(6, max(int(-np.log10(abs(x))) + 2, 2))
487
+
488
+
472
489
  def bisection(x: list[np.ndarray]) -> Iterator[list[np.ndarray, np.ndarray]]:
473
490
  """Generator to bisect a list of array.
474
491
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: skfolio
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Portfolio optimization built on top of scikit-learn
5
5
  Author-email: Hugo Delatte <delatte.hugo@gmail.com>
6
6
  Maintainer-email: Hugo Delatte <delatte.hugo@gmail.com>
@@ -4,7 +4,7 @@ skfolio/typing.py,sha256=yEZiCZ6UIyfYUqtfj9Kf2KA9mrjUbmxyzpH9uqVboJs,1378
4
4
  skfolio/cluster/__init__.py,sha256=4g-PFB_ld9BhiQ1ZPvvAorpFbRwd_p_DkeRlulDv2Hk,251
5
5
  skfolio/cluster/_hierarchical.py,sha256=16INBe5HB7ALODO3RNI8ZjOYALtMZa3U_7EP1aEIxp8,12819
6
6
  skfolio/datasets/__init__.py,sha256=TKzb3wucwuaBI7V8GSiEIun-oaV0W0Mhl_XJgMjlajU,481
7
- skfolio/datasets/_base.py,sha256=-SPN7BA58PiGu4OMIs-bmOMk8qp9aLiRVsBDyI0zsi4,15903
7
+ skfolio/datasets/_base.py,sha256=Al8YzVsiuas3NMMKSjMhE0C0XFkYE-qjONumgoXwFbo,15902
8
8
  skfolio/datasets/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
9
  skfolio/datasets/data/factors_dataset.csv.gz,sha256=brCJlT25DJo40yg1gnUXAakNtvWZZYR_1ksFeN5JcWE,36146
10
10
  skfolio/datasets/data/sp500_dataset.csv.gz,sha256=7iHKwovvsdCnOanOsiGE-ZU5RyaqDP3pohlB0awErA0,426065
@@ -14,13 +14,13 @@ skfolio/distance/_base.py,sha256=jBgRk6lZrP1woSI9541fTfxBBkp4WCTLlRPmWcmA3j4,132
14
14
  skfolio/distance/_distance.py,sha256=yRyyxMXqKYOiuXdq35aQ2MfS8fp6Xh9BR_ABbUVkaGg,19034
15
15
  skfolio/measures/__init__.py,sha256=9ThQikIAQcfKRLSCoMr-Z5vE2-ThtYe9B-L40b6Ewg0,1631
16
16
  skfolio/measures/_enums.py,sha256=NJcngwg9b2JMMiekwkWU9POfnDvgfUgtYtyV2VSFDVM,8934
17
- skfolio/measures/_measures.py,sha256=VU9tahJKJvt8PaJZNxWkmFkFU4PsN0FfYO32Je6D53E,16829
17
+ skfolio/measures/_measures.py,sha256=Z7XHSyM9xfecDgOqm-lJQJhvZxasF018-oFS4QjC4g0,16829
18
18
  skfolio/metrics/__init__.py,sha256=MomHJ5_bgjq4qUwGS2bfhNmG_ld0oQ4wK6y0Yy_Eonc,75
19
19
  skfolio/metrics/_scorer.py,sha256=h1VuZk-zzn4rIChHl9FvM7RxqVT3b-jR1CEB-cr9F2s,4306
20
20
  skfolio/model_selection/__init__.py,sha256=8j9Z5tpbgBScjFbn8ZsCm_6rZO7RkPQ1QIF8BqYMVA8,507
21
21
  skfolio/model_selection/_combinatorial.py,sha256=cS_W_4uA2aUC0eRbqbRnfezRRxUpm2_HGs1S5ea0q6E,19045
22
22
  skfolio/model_selection/_validation.py,sha256=3eFYzPejjDZljc33vRehDuBQTEKCkrj-mZihMVuGA4s,10034
23
- skfolio/model_selection/_walk_forward.py,sha256=Y3xB7X0zVELBHtn4S_NCDBt5pXLE2xmcdtSJP6bmESk,7529
23
+ skfolio/model_selection/_walk_forward.py,sha256=T57HhdFGjG31mAufujHQuRK1uKfAdkiBx9eucQZ-WG0,15043
24
24
  skfolio/moments/__init__.py,sha256=zwxaRO4TLoPj8qrcYSofNyd3tYhbLLcZWQaErzfDdNg,794
25
25
  skfolio/moments/covariance/__init__.py,sha256=maWkl5Uh0RMgfaxQ0yO-c5zhJg51vm1zrtxZrk_p0pg,1068
26
26
  skfolio/moments/covariance/_base.py,sha256=98o4YDFcOZ4X4hRFlrJAwWifULGzisEyRZaxFYW1qeA,3970
@@ -60,11 +60,11 @@ skfolio/optimization/ensemble/_stacking.py,sha256=ZoICUnc_MwoXDQAR2kewCg-KIezSOI
60
60
  skfolio/optimization/naive/__init__.py,sha256=Dkr55R48urC-jfYN007NTbei16N91Na_EDYLVqzhGgQ,147
61
61
  skfolio/optimization/naive/_naive.py,sha256=AhEyYKEUAm-Fjn4p8SHwhp7yE9iF0tRyDZIjKYV4EeU,6390
62
62
  skfolio/population/__init__.py,sha256=rsPPMUv95aTK7vmpPeQwF8NzFuBwk6RDo5g4HNaPzNM,80
63
- skfolio/population/_population.py,sha256=MNIqIkBQs-ourGYFbV-PgC0vn9qJNqYCINN3ZogWqMM,29222
63
+ skfolio/population/_population.py,sha256=9NKnz_rQYLnauP1Me6tnDwD7lq3MeGnSyCq-sb0fTV0,30424
64
64
  skfolio/portfolio/__init__.py,sha256=YYtcAPmA2zeCxFGTXegg2FXcA7py6CxOX7IMTdYuXl0,586
65
- skfolio/portfolio/_base.py,sha256=XbvCdlhwP-mgnbVppoZxv8gr_IgCv8csx71LigqZ0-M,38282
66
- skfolio/portfolio/_multi_period_portfolio.py,sha256=Zt2khkaeVwZjUKvL0NAk5kLJtfO19hup3YxCGdgk5Mk,22719
67
- skfolio/portfolio/_portfolio.py,sha256=sSDX2HzK2KgatCgQakEhENOLE-3jSfwI_xgbiVrCGUY,31609
65
+ skfolio/portfolio/_base.py,sha256=EFLsvHoxZmDvGPOKePr6hQGXU7y7TWsALvzYP9qt0fQ,39588
66
+ skfolio/portfolio/_multi_period_portfolio.py,sha256=K2JfEwlPD9iGO58lOdk7WUbWuXZDWw2prPT5T7pOdto,24387
67
+ skfolio/portfolio/_portfolio.py,sha256=gqvCKM6ZVfwZrgixiYdahgbQ1DRNW2LkGHkXOpjleb4,32753
68
68
  skfolio/pre_selection/__init__.py,sha256=VtUtDn-U-Mn_xR2k7yfld0Yb0rPhLakEAiBwUyi-4Z8,189
69
69
  skfolio/pre_selection/_pre_selection.py,sha256=w84T14nKmzkgzbw5CW_AIlci741lXYxKUwB5pBjhTTI,12163
70
70
  skfolio/preprocessing/__init__.py,sha256=15A1bzfPsbfxxXgGP1gstf4R0E_347Wn18z5W5jH-hk,94
@@ -83,9 +83,9 @@ skfolio/utils/bootstrap.py,sha256=3zY2kO_GQURKEcQMCasJOSByde9Mt2IAi3KJH0_a4mk,35
83
83
  skfolio/utils/equations.py,sha256=w0HsYjA7cS0mHYsI9MpixHLkof3HN26nc14ZfqFrHlE,11047
84
84
  skfolio/utils/sorting.py,sha256=lSjMvH2L-sSj-06B3MlwBrH1rtjCeGEe4hG894W7TE0,3504
85
85
  skfolio/utils/stats.py,sha256=wuOmSt5panMMTw_pFYizLbmrclsE_4PHQfamkzJ5J2s,13937
86
- skfolio/utils/tools.py,sha256=ADMk7sXiiM97JqGuhzDqv0V33DIDk2dwX7X9337dYmo,20572
87
- skfolio-0.3.1.dist-info/LICENSE,sha256=F6Gi-ZJX5BlVzYK8R9NcvAkAsKa7KO29xB1OScbrH6Q,1526
88
- skfolio-0.3.1.dist-info/METADATA,sha256=Ydlm1DNyhoJOeimNLgJ8txU48mloWILIK223vqhB4A4,19617
89
- skfolio-0.3.1.dist-info/WHEEL,sha256=y4mX-SOX4fYIkonsAGA5N0Oy-8_gI4FXw5HNI1xqvWg,91
90
- skfolio-0.3.1.dist-info/top_level.txt,sha256=NXEaoS9Ms7t32gxkb867nV0OKlU0KmssL7IJBVo0fJs,8
91
- skfolio-0.3.1.dist-info/RECORD,,
86
+ skfolio/utils/tools.py,sha256=4KrmBR9jOLiI6j0hb27gsPC--OHXo4Sp1xl-6i-k9Tg,20925
87
+ skfolio-0.4.0.dist-info/LICENSE,sha256=F6Gi-ZJX5BlVzYK8R9NcvAkAsKa7KO29xB1OScbrH6Q,1526
88
+ skfolio-0.4.0.dist-info/METADATA,sha256=Vh_PqWOdbuuaxurc6k3BL0dZeG7BpKLlis___v8YMv4,19617
89
+ skfolio-0.4.0.dist-info/WHEEL,sha256=5Mi1sN9lKoFv_gxcPtisEVrJZihrm_beibeg5R6xb4I,91
90
+ skfolio-0.4.0.dist-info/top_level.txt,sha256=NXEaoS9Ms7t32gxkb867nV0OKlU0KmssL7IJBVo0fJs,8
91
+ skfolio-0.4.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (70.2.0)
2
+ Generator: setuptools (75.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5