Source code for skfolio.population._population

"""Population module.
A population is a collection of portfolios.
"""

# Copyright (c) 2023-2026
# Author: Hugo Delatte <hugo.delatte@skfoliolabs.com>
# SPDX-License-Identifier: BSD-3-Clause

from __future__ import annotations

import inspect
import warnings
from typing import Any

import numpy as np
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import scipy.interpolate as sci

import skfolio.typing as skt
from skfolio.measures import BaseMeasure, RatioMeasure
from skfolio.portfolio import BasePortfolio, FailedPortfolio, MultiPeriodPortfolio
from skfolio.typing import FloatArray, IntArray
from skfolio.utils.figure import kde_trace
from skfolio.utils.sorting import non_dominated_sort
from skfolio.utils.tools import deduplicate_names, optimal_rounding_decimals


[docs] class Population(list): """Population Class. A `Population` is a list of :class:`~skfolio.portfolio.Portfolio` or :class:`~skfolio.portfolio.MultiPeriodPortfolio` or both. Parameters ---------- iterable : list[BasePortfolio] The list of portfolios. Each item can be of type :class:`~skfolio.portfolio.Portfolio` and/or :class:`~skfolio.portfolio.MultiPeriodPortfolio`. Empty list are accepted. """ def __init__(self, iterable: list[BasePortfolio]) -> None: super().__init__(self._validate_item(item) for item in iterable) def __repr__(self) -> str: return "<Population(" + super().__repr__() + ")>" def __getitem__( self, indices: int | list[int] | slice ) -> BasePortfolio | Population: item = super().__getitem__(indices) if isinstance(item, list): return self.__class__(item) return item def __setitem__(self, index: int, item: BasePortfolio) -> None: super().__setitem__(index, self._validate_item(item)) def __add__(self, other: BasePortfolio) -> Population: if not isinstance(other, Population): raise TypeError( f"Cannot add a Population with an object of type {type(other)}" ) return self.__class__(super().__add__(other))
[docs] def insert(self, index, item: BasePortfolio) -> None: """Insert portfolio before index.""" super().insert(index, self._validate_item(item))
[docs] def append(self, item: BasePortfolio) -> None: """Append portfolio to the end of the population list.""" super().append(self._validate_item(item))
[docs] def extend(self, other: BasePortfolio) -> None: """Extend population list by appending elements from the iterable.""" if isinstance(other, type(self)): super().extend(other) else: super().extend(self._validate_item(item) for item in other)
[docs] def set_portfolio_params(self, **params: Any) -> Population: """Set the parameters of all the portfolios. Parameters ---------- **params : Any Portfolio parameters. Returns ------- self : Population The Population instance. """ if not params: return self init_signature = inspect.signature(BasePortfolio.__init__) # Consider the constructor parameters excluding 'self' valid_params = [ p.name for p in init_signature.parameters.values() if p.name != "self" and p.kind != p.VAR_KEYWORD ] for key in params: if key not in valid_params: raise ValueError( f"Invalid parameter {key!r} . " f"Valid parameters are: {valid_params!r}." ) for portfolio in self: for key, value in params.items(): setattr(portfolio, key, value) return self
@staticmethod def _validate_item( item: BasePortfolio, ) -> BasePortfolio: """Validate that items are of type Portfolio or MultiPeriodPortfolio.""" if isinstance(item, BasePortfolio): return item raise TypeError( "Population only accept items that inherit from BasePortfolio such as " "Portfolio or MultiPeriodPortfolio" f", got {type(item).__name__}" ) def _validate_compounded(self) -> bool: """ Determine whether all portfolios in the population use compounded returns. Returns ------- bool True if all portfolios are compounded, False if all are non-compounded. Raises ------ ValueError If the population is empty, or if it mixes compounded and non-compounded portfolios. """ compounded = [ptf.compounded for ptf in self] if not compounded: raise ValueError("Cannot determine compounded status: population is empty.") compounded = set(compounded) if len(compounded) > 1: raise ValueError( "Population contains a mix of compounded and non-compounded portfolios." " Ensure consistency, for example with " "`population.set_portfolio_params(compounded=False)`." ) return compounded.pop()
[docs] def returns_df(self, use_tag_in_column_name: bool = True) -> pd.DataFrame: """DataFrame of returns for each portfolio in the population. Parameters ---------- use_tag_in_column_name : bool, default=True Whether to include the portfolio tag in the DataFrame column names. If True, each column name will use the portfolio name followed by its tag; if False, only the portfolio name will be used. Returns ------- returns : DataFrame Returns DataFrame where each column represents a portfolio's returns time series. """ returns = [] names = [] for ptf in self: returns.append(ptf.returns_df) names.append( _ptf_name_with_tag(ptf) if use_tag_in_column_name else ptf.name ) df = pd.concat(returns, axis=1, sort=False) # Sort index because pd.concat unsort NaNs at the end df.sort_index(inplace=True) df.columns = deduplicate_names(names) return df
[docs] def cumulative_returns_df( self, use_tag_in_column_name: bool = True ) -> pd.DataFrame: """DataFrame of cumulative returns for each portfolio in the population. Non-compounded (arithmetic) cumulative returns start at 0. Compounded (geometric) cumulative returns are expressed as a wealth index, starting at 1.0 (i.e., the value of $1 invested). Parameters ---------- use_tag_in_column_name : bool, default=True Whether to include the portfolio tag in the DataFrame column names. If True, each column name will use the portfolio name followed by its tag; if False, only the portfolio name will be used. Returns ------- cumulative_returns : DataFrame Cumulative returns DataFrame. """ self._validate_compounded() cumulative_returns = [] names = [] for ptf in self: cumulative_returns.append(ptf.cumulative_returns_df) names.append( _ptf_name_with_tag(ptf) if use_tag_in_column_name else ptf.name ) df = pd.concat(cumulative_returns, axis=1, sort=False) # Sort index because pd.concat unsort NaNs at the end df.sort_index(inplace=True) df.columns = deduplicate_names(names) return df
[docs] def drawdowns_df(self, use_tag_in_column_name: bool = True) -> pd.DataFrame: """DataFrame of drawdowns for each portfolio in the population. Parameters ---------- use_tag_in_column_name : bool, default=True Whether to include the portfolio tag in the DataFrame column names. If True, each column name will use the portfolio name followed by its tag; if False, only the portfolio name will be used. Returns ------- drawdowns : DataFrame Drawdowns DataFrame. """ self._validate_compounded() drawdowns = [] names = [] for ptf in self: drawdowns.append(ptf.drawdowns_df) names.append( _ptf_name_with_tag(ptf) if use_tag_in_column_name else ptf.name ) df = pd.concat(drawdowns, axis=1) # Sort index because pd.concat unsort NaNs at the end df.sort_index(inplace=True) df.columns = deduplicate_names(names) return df
[docs] def non_dominated_sort(self, first_front_only: bool = False) -> list[list[int]]: """Fast non-dominated sorting. Sort the portfolios into different non-domination levels. Complexity O(MN^2) where M is the number of objectives and N the number of portfolios. Parameters ---------- first_front_only : bool, default=False If this is set to True, only the first front is sorted and returned. The default is `False`. Returns ------- fronts : list[list[int]] A list of Pareto fronts (lists), the first list includes non-dominated portfolios. """ n = len(self) if n > 0 and np.any( [ portfolio.fitness_measures != self[0].fitness_measures for portfolio in self ] ): raise ValueError( "Cannot compute non-dominated sorting with Portfolios " "containing mixed `fitness_measures`" ) fitnesses = np.array([portfolio.fitness for portfolio in self]) fronts = non_dominated_sort( fitnesses=fitnesses, first_front_only=first_front_only ) return fronts
# TODO remove deprecated non_denominated_sort in v2.0
[docs] def non_denominated_sort(self, first_front_only: bool = False) -> list[list[int]]: """Alias of :meth:`non_dominated_sort`. .. deprecated:: `non_denominated_sort` is deprecated and will be removed in version 2.0. Use :meth:`non_dominated_sort` instead. """ warnings.warn( "`Population.non_denominated_sort` is deprecated and will be removed in " "version 2.0. Use `Population.non_dominated_sort` instead.", FutureWarning, stacklevel=2, ) return self.non_dominated_sort(first_front_only=first_front_only)
[docs] def filter( self, names: skt.Names | None = None, tags: skt.Tags | None = None ) -> Population: """Filter the Population of portfolios by names and tags. If both names and tags are provided, the intersection is returned. Parameters ---------- names : str | list[str], optional If provided, the population is filtered by portfolio names. tags : str | list[str], optional If provided, the population is filtered by portfolio tags. Returns ------- population : Population A new population of portfolios filtered by names and tags. """ if tags is None and names is None: return self if isinstance(names, str): names = [names] if isinstance(tags, str): tags = [tags] if tags is None: return self.__class__( [portfolio for portfolio in self if portfolio.name in names] ) if names is None: return self.__class__( [portfolio for portfolio in self if portfolio.tag in tags] ) return self.__class__( [ portfolio for portfolio in self if portfolio.name in names and portfolio.tag in tags ] )
[docs] def measures( self, measure: skt.Measure, ) -> FloatArray: """Vector of portfolios measures for each portfolio from the population. Parameters ---------- measure : Measure The portfolio measure. Returns ------- values : ndarray The vector of portfolios measures. """ return np.array([ptf.__getattribute__(measure.value) for ptf in self])
[docs] def measures_mean( self, measure: skt.Measure, ) -> float: """Mean of portfolios measures for each portfolio from the population. Parameters ---------- measure : Measure The portfolio measure. Returns ------- value : float The mean of portfolios measures. """ return np.nanmean(self.measures(measure=measure), axis=0)
[docs] def measures_std( self, measure: skt.Measure, ) -> float: """Standard-deviation of portfolios measures for each portfolio from the population. Parameters ---------- measure : Measure The portfolio measure. Returns ------- value : float The standard-deviation of portfolios measures. """ return np.nanstd(self.measures(measure=measure), axis=0)
[docs] def sort_measure(self, measure: skt.Measure, reverse: bool = False) -> Population: """Sort the population by a given portfolio measure. Parameters ---------- measure : Measure The portfolio measure. reverse : bool, default=False If this is set to True, the order is reversed. Returns ------- values : Populations The sorted population. """ return self.__class__( sorted( [x for x in self if not isinstance(x, FailedPortfolio)], key=lambda x: x.__getattribute__(measure.value), reverse=reverse, ) )
[docs] def quantile( self, measure: skt.Measure, q: float, ) -> BasePortfolio: """Return the portfolio corresponding to the `q` quantile for a given portfolio measure. Parameters ---------- measure : Measure The portfolio measure. q : float The quantile value. Returns ------- values : BasePortfolio Portfolio corresponding to the `q` quantile for the measure. """ if not 0 <= q <= 1: raise ValueError("The quantile`q` must be between 0 and 1") sorted_portfolios = self.sort_measure(measure=measure, reverse=False) k = max(0, int(np.round(len(sorted_portfolios) * q)) - 1) return sorted_portfolios[k]
[docs] def min_measure( self, measure: skt.Measure, ) -> BasePortfolio: """Return the portfolio with the minimum measure. Parameters ---------- measure : Measure The portfolio measure. Returns ------- values : BasePortfolio The portfolio with minimum measure. """ return self.quantile(measure=measure, q=0)
[docs] def max_measure( self, measure: skt.Measure, ) -> BasePortfolio: """Return the portfolio with the maximum measure. Parameters ---------- measure: Measure The portfolio measure. Returns ------- values : BasePortfolio The portfolio with maximum measure. """ return self.quantile(measure=measure, q=1)
[docs] def summary( self, formatted: bool = True, ) -> pd.DataFrame: """Summary of the portfolios in the population. Parameters ---------- formatted : bool, default=True If this is set to True, the measures are formatted into rounded string with units. The default is `True`. Returns ------- summary : pandas DataFrame The population's portfolios summary Notes ----- This method returns a static pandas DataFrame. For interactive exploration (e.g., sortable/filterable/clickable tables or visual summaries), you may want to use libraries such as `ipydatagrid`, `D-Tale`, or `Lux` in a Jupyter environment, or `dash_table` / `streamlit.dataframe` when building dashboards. For example, you can explore the summary with D-Tale: `dtale.show(population.summary().T)` """ df = pd.concat( [p.summary(formatted=formatted) for p in self], keys=[p.name for p in self], axis=1, ) return df
[docs] def composition( self, display_sub_ptf_name: bool = True, ) -> pd.DataFrame: """Composition of each portfolio in the population. Parameters ---------- display_sub_ptf_name : bool, default=True If this is set to True, each sub-portfolio name composing a multi-period portfolio is displayed. Returns ------- df : DataFrame Composition of the portfolios in the population. """ res = [] for ptf in self: comp = ptf.composition if display_sub_ptf_name: if isinstance(ptf, MultiPeriodPortfolio): comp.rename( columns={c: f"{ptf.name}_{c}" for c in comp.columns}, inplace=True, ) else: comp.rename(columns={c: ptf.name for c in comp.columns}, inplace=True) res.append(comp) df = pd.concat(res, axis=1) # Leave columns of only NaNs untouched mask = ~df.isna().all(axis=0) df.loc[:, mask] = df.loc[:, mask].fillna(0) df.columns = deduplicate_names(list(df.columns)) return df
[docs] def contribution( self, measure: skt.Measure, spacing: float | None = None, display_sub_ptf_name: bool = True, ) -> pd.DataFrame: r"""Contribution of each asset to a given measure of each portfolio in the population. Parameters ---------- measure : Measure The measure used for the contribution computation. spacing : float, optional Spacing "h" of the finite difference: :math:`contribution(wi)= \frac{measure(wi-h) - measure(wi+h)}{2h}`. display_sub_ptf_name : bool, default=True If this is set to True, each sub-portfolio name composing a multi-period portfolio is displayed. Returns ------- df : DataFrame Contribution of each asset to a given measure of each portfolio in the population. """ res = [] for ptf in self: contribution = ptf.contribution( measure=measure, spacing=spacing, to_df=True ) if display_sub_ptf_name: if isinstance(ptf, MultiPeriodPortfolio): contribution.rename( columns={c: f"{ptf.name}_{c}" for c in contribution.columns}, inplace=True, ) else: contribution.rename( columns={c: ptf.name for c in contribution.columns}, inplace=True ) res.append(contribution) df = pd.concat(res, axis=1) # Leave columns of only NaNs untouched mask = ~df.isna().all(axis=0) df.loc[:, mask] = df.loc[:, mask].fillna(0) df.columns = deduplicate_names(list(df.columns)) return df
[docs] def rolling_measure( self, measure: skt.Measure = RatioMeasure.SHARPE_RATIO, window: int = 30 ) -> pd.DataFrame: """Compute the measure over a rolling window for each portfolio in the population. Parameters ---------- measure : Measure, default=RatioMeasure.SHARPE_RATIO The measure. The default measure is the Sharpe Ratio. window : int, default=30 The window size. The default value is `30` observations. Returns ------- dataframe : pandas DataFrame The rolling measures. """ rolling_measures = [] names = [] for ptf in self: rolling_measures.append(ptf.rolling_measure(measure=measure, window=window)) names.append(_ptf_name_with_tag(ptf)) df = pd.concat(rolling_measures, axis=1) df.columns = deduplicate_names(names) # Sort index because pd.concat unsort NaNs at the end df.sort_index(inplace=True) return df
[docs] def plot_distribution( self, measure_list: list[skt.Measure], tag_list: list[str] | None = None, n_bins: int | None = None, **kwargs, ) -> go.Figure: """Plot the population's distribution for each measure provided in the measure list. Parameters ---------- measure_list : list[Measure] The list of portfolio measures. A different distribution is plotted per measure. tag_list : list[str], optional If this is provided, an additional distribution is plotted per measure for each tag provided. n_bins : int, optional Sets the number of bins. Returns ------- plot : Figure Returns the plotly Figure object. """ n = len(measure_list) if tag_list is None: df = pd.concat( [ pd.DataFrame( { "Population": measure.value, "value": self.measures(measure=measure), } ) for measure in measure_list ], ignore_index=True, ) else: df = pd.concat( [ pd.DataFrame( { "Population": tag if n == 1 else f"{measure} - {tag}", "value": self.filter(tags=tag).measures(measure=measure), } ) for measure in measure_list for tag in tag_list ], ignore_index=True, ) fig = px.histogram( df, color="Population", barmode="overlay", marginal="box", nbins=n_bins, **kwargs, ) title = f"{measure_list[0]} Distribution" if n == 1 else "Measures Distribution" fig.update_layout( title_text=title, xaxis_title=str(measure_list[0]) if n == 1 else "measures" ) return fig
[docs] def boxplot_measure( self, measure: skt.Measure, tag_list: list[str] | None = None, points: str | bool = "all", ) -> go.Figure: """Plot a box plot of a measure's distribution, optionally split by tags. If no tags are provided, the function draws a single box showing the population distribution of `measure`. If `tag_list` is provided, it draws one box per tag using values from the portfolio filtered by each tag. Parameters ---------- measure : Measure The measure to plot. tag_list : list[str], optional For each tag in this list, filter the portfolio by that tag and plot a separate box. If None or empty, plot a single overall distribution. points : {'all', 'outliers', 'suspectedoutliers', False}, default 'all' Passed to `plotly.express.box(..., points=...)` to control which points are shown. Returns ------- go.Figure The Plotly figure. Examples -------- >>> fig = population.boxplot_measure(measure=RiskMeasure.STANDARD_DEVIATION) >>> fig = population.plot_measure_box( ... measure=RatioMeasure.SHARPE_RATIO, ... tag_list=["Benchmark", "Risk Parity Model"] ... ) """ if tag_list is None: y = None df = pd.DataFrame(self.measures(measure=measure), columns=["value"]) else: y = "Population" df = pd.concat( [ pd.DataFrame( { y: tag, "value": self.filter(tags=tag).measures(measure=measure), } ) for tag in tag_list ], ignore_index=True, ) fig = px.box(df, x="value", y=y, color=y, points=points) fig.update_layout(title_text=f"Box plot of {measure}", xaxis_title=str(measure)) return fig
[docs] def plot_cumulative_returns( self, log_scale: bool = False, idx: slice | IntArray | None = None, use_tag_in_legend: bool = True, ) -> go.Figure: """Plot the cumulative returns of the population's portfolios. Non-compounded (arithmetic) cumulative returns start at 0. Compounded (geometric) cumulative returns are expressed as a wealth index, starting at 1.0 (i.e., the value of $1 invested). Parameters ---------- log_scale : bool, default=False If set to True, the cumulative returns are displayed with a logarithm scale on the y-axis. The cumulative returns must be compounded otherwise an exception is raise. idx : slice | array, optional Indexes or slice of the observations to plot. The default (`None`) is to take all observations. use_tag_in_legend : bool, default=True Whether to include the portfolio tag in legend entries. If True, each legend label will show the portfolio name followed by its tag; if False, only the portfolio name will be displayed. Returns ------- plot : Figure Returns the plot Figure object. """ if idx is None: idx = slice(None) compounded = self._validate_compounded() title = "Cumulative Returns" if compounded: if log_scale: title = f"{title} (compounded & log scaled)" else: title = f"{title} (compounded)" else: if log_scale: raise ValueError( "Plotting with logarithm scaling must be done on cumulative " "returns that are compounded as opposed to non-compounded." "You can change to compounded with " "`set_portfolio_params(compounded=True)`" ) title = f"{title} (non-compounded)" df = self.cumulative_returns_df(use_tag_in_column_name=use_tag_in_legend) fig = df.iloc[idx].plot(backend="plotly") fig.update_layout( title=title, xaxis_title="Observations", yaxis_title="Cumulative Returns", legend_title_text="Portfolios", ) if compounded: fig.update_yaxes(tickformat=".2f") else: fig.update_yaxes(tickformat=".2%") if log_scale: fig.update_yaxes(type="log") return fig
[docs] def plot_drawdowns( self, idx: slice | IntArray | None = None, use_tag_in_legend: bool = True, ) -> go.Figure: """Plot the drawdowns of the population's portfolios. Parameters ---------- idx : slice | array, optional Indexes or slice of the observations to plot. The default (`None`) is to take all observations. use_tag_in_legend : bool, default=True Whether to include the portfolio tag in legend entries. If True, each legend label will show the portfolio name followed by its tag; if False, only the portfolio name will be displayed. Returns ------- plot : Figure Returns the plot Figure object. """ if idx is None: idx = slice(None) compounded = self._validate_compounded() title = "Drawdowns" if compounded: title = f"{title} (compounded returns)" else: title = f"{title} (non-compounded returns)" df = self.drawdowns_df(use_tag_in_column_name=use_tag_in_legend) fig = df.iloc[idx].plot(backend="plotly") fig.update_layout( title=title, xaxis_title="Observations", yaxis_title="Drawdowns", legend_title_text="Portfolios", ) fig.update_yaxes(tickformat=".1%") return fig
[docs] def plot_composition(self, display_sub_ptf_name: bool = True) -> go.Figure: """Plot the compositions of the portfolios in the population. Parameters ---------- display_sub_ptf_name : bool, default=True If this is set to True, each sub-portfolio name composing a multi-period portfolio is displayed. Returns ------- plot : Figure Returns the plotly Figure object. """ df = self.composition(display_sub_ptf_name=display_sub_ptf_name).T fig = px.bar(df, x=df.index, y=df.columns) fig.update_layout( title="Portfolios Composition", xaxis_title="Portfolios", yaxis={ "title": "Weight", "tickformat": ",.0%", }, legend=dict(yanchor="top", y=0.99, xanchor="left", x=1.15), ) return fig
[docs] def plot_contribution( self, measure: skt.Measure, spacing: float | None = None, display_sub_ptf_name: bool = True, ) -> go.Figure: r"""Plot the contribution of each asset to a given measure of the portfolios in the population. Parameters ---------- measure : Measure The measure used for the contribution computation. spacing : float, optional Spacing "h" of the finite difference: :math:`contribution(wi)= \frac{measure(wi-h) - measure(wi+h)}{2h}` display_sub_ptf_name : bool, default=True If this is set to True, each sub-portfolio name composing a multi-period portfolio is displayed. Returns ------- plot : Figure Returns the plotly Figure object. """ df = self.contribution( display_sub_ptf_name=display_sub_ptf_name, measure=measure, spacing=spacing ).T fig = px.bar(df, x=df.index, y=df.columns) yaxis = { "title": "Contribution", } if not measure.is_ratio: n = optimal_rounding_decimals(df.sum(axis=1).max()) yaxis["tickformat"] = f",.{n}%" fig.update_layout( title=f"{measure} Contribution", xaxis_title="Portfolios", yaxis=yaxis, legend=dict(yanchor="top", y=0.99, xanchor="left", x=1.15), ) return fig
[docs] def plot_measures( self, x: skt.Measure, y: skt.Measure, z: skt.Measure = None, to_surface: bool = False, hover_measures: list[skt.Measure] | None = None, show_fronts: bool = False, color_scale: skt.Measure | str | None = None, title="Portfolios", ) -> go.Figure: """Plot the 2D (or 3D) scatter points (or surface) of a given set of measures for each portfolio in the population. Parameters ---------- x : Measure The x-axis measure. y : Measure The y-axis measure. z : Measure, optional The z-axis measure. to_surface : bool, default=False If this is set to True, a surface is estimated. hover_measures : list[Measure], optional The list of measure to show on point hover. show_fronts : bool, default=False If this is set to True, the Pareto fronts are highlighted. The default is `False`. color_scale : Measure | str, optional If this is provided, a color scale is displayed. title : str, default="Portfolios" The graph title. The default value is "Portfolios". Returns ------- plot : Figure Returns the plotly Figure object. """ num_fmt = ":.3f" hover_data = {x: num_fmt, y: num_fmt, "tag": True} if z is not None: hover_data[z] = num_fmt if hover_measures is not None: for measure in hover_measures: hover_data[measure] = num_fmt columns = list(hover_data) columns.append("name") if isinstance(color_scale, BaseMeasure): hover_data[color_scale] = num_fmt if color_scale is not None and color_scale not in columns: columns.append(color_scale) col_values = [e.value if isinstance(e, BaseMeasure) else e for e in columns] res = [ [portfolio.__getattribute__(attr) for attr in col_values] for portfolio in self ] # Improved formatting columns = [str(e) for e in columns] hover_data = {str(k): v for k, v in hover_data.items()} df = pd.DataFrame(res, columns=columns) if pd.isnull(df["tag"]).all(): del hover_data["tag"] tag = None else: tag = "tag" df["tag"] = df["tag"].astype(str).replace("None", "") if show_fronts: fronts = self.non_dominated_sort(first_front_only=False) df["front"] = str(-1) for i, front in enumerate(fronts): for idx in front: df.iloc[idx, -1] = str(i) color = df.columns[-1] elif color_scale is not None: color = str(color_scale) else: color = tag if z is not None: if to_surface: # estimate the surface x_arr = np.array(df[str(x)]) y_arr = np.array(df[str(y)]) z_arr = np.array(df[str(z)]) xi = np.linspace(start=min(x_arr), stop=max(x_arr), num=100) yi = np.linspace(start=min(y_arr), stop=max(y_arr), num=100) X, Y = np.meshgrid(xi, yi) Z = sci.griddata( points=(x_arr, y_arr), values=z_arr, xi=(X, Y), method="cubic" ) fig = go.Figure( go.Surface( x=xi, y=yi, z=Z, hovertemplate="<br>".join( [ str(e) + ": %{" + v + ":" + (",.3%" if not e.is_ratio else None) + "}" for e, v in [(x, "x"), (y, "y"), (z, "z")] ] ) + "<extra></extra>", colorbar=dict( title=dict(text=str(z), side="top"), tickformat=",.2%" if not z.is_ratio else None, ), ) ) fig.update_layout( title=title, scene=dict( xaxis={ "title": str(x), "tickformat": ",.1%" if not x.is_ratio else None, }, yaxis={ "title": str(y), "tickformat": ",.1%" if not y.is_ratio else None, }, zaxis={ "title": str(z), "tickformat": ",.1%" if not z.is_ratio else None, }, ), ) else: # plot the points fig = px.scatter_3d( df, x=str(x), y=str(y), z=str(z), hover_name="name", hover_data=hover_data, color=color, symbol=tag, ) fig.update_traces(marker_size=8) fig.update_layout( title=title, scene=dict( xaxis={ "title": str(x), "tickformat": ",.1%" if not x.is_ratio else None, }, yaxis={ "title": str(y), "tickformat": ",.1%" if not y.is_ratio else None, }, zaxis={ "title": str(z), "tickformat": ",.1%" if not z.is_ratio else None, }, ), legend=dict(yanchor="top", y=0.99, xanchor="left", x=1.15), ) else: fig = px.scatter( df, x=str(x), y=str(y), hover_name="name", hover_data=hover_data, color=color, symbol=tag, ) fig.update_traces(marker_size=10) if color_scale is None: legend = dict(title=None, yanchor="top", y=0.98, xanchor="left", x=1.02) else: legend = dict(title=None, yanchor="top", y=0.98, xanchor="left", x=0.02) fig.update_layout( title=title, xaxis={ "title": str(x), "tickformat": ",.1%" if not x.is_ratio else None, }, yaxis={ "title": str(y), "tickformat": ",.1%" if not y.is_ratio else None, }, legend=legend, ) return fig
[docs] def plot_rolling_measure( self, measure: skt.Measure = RatioMeasure.SHARPE_RATIO, window: int = 30, ) -> go.Figure: """Plot the measure over a rolling window for each portfolio in the population. Parameters ---------- measure : Measure, default = RatioMeasure.SHARPE_RATIO The measure. window : int, default=30 The window size. Returns ------- plot : Figure Returns the plot Figure object """ df = self.rolling_measure(measure=measure, window=window) fig = df.plot(backend="plotly") max_val = np.max(df) min_val = np.min(df) if max_val > 0 > min_val: fig.add_hrect( y0=0, y1=max_val * 1.3, line_width=0, fillcolor="green", opacity=0.1 ) fig.add_hrect( y0=min_val * 1.3, y1=0, line_width=0, fillcolor="red", opacity=0.1 ) yaxis = { "title": str(measure), } if not measure.is_ratio: n = optimal_rounding_decimals(max_val) yaxis["tickformat"] = f",.{n}%" fig.update_layout( title=f"Rolling {measure} - {window} observations window", xaxis_title="Observations", yaxis=yaxis, showlegend=False, ) return fig
[docs] def plot_returns_distribution( self, percentile_cutoff: float | None = None ) -> go.Figure: """Plot the Portfolios returns distribution using Gaussian KDE. Parameters ---------- percentile_cutoff : float, default=None Percentile cutoff for tail truncation (percentile), in percent. If a float p is provided, the distribution support is truncated at the p-th and (100 - p)-th percentiles. If None, no truncation is applied (uses full min/max of returns). Returns ------- plot : Figure Returns the plot Figure object """ traces: list[go.Scatter] = [] colors = px.colors.qualitative.Plotly for i, ptf in enumerate(self): if isinstance(ptf, FailedPortfolio): continue color = colors[i % len(colors)] returns = ptf.returns traces.append( kde_trace( x=returns, sample_weight=ptf.sample_weight, percentile_cutoff=percentile_cutoff, name=ptf.name, line_color=color, fill_opacity=0.3, line_dash="solid", line_width=1, visible=True, ) ) fig = go.Figure(traces) fig.update_layout( title="Returns Distribution", xaxis_title="Returns", yaxis_title="Probability Density", ) fig.update_xaxes( tickformat=".0%", ) return fig
def _ptf_name_with_tag(portfolio: BasePortfolio) -> str: if portfolio.tag is None: return portfolio.name return f"{portfolio.name}_{portfolio.tag}"