Source code for skfolio.portfolio._portfolio

"""Portfolio module.
`Portfolio` is returned by the `predict` method of Optimization estimators.
It needs to be homogenous to the convex optimization problems meaning that `Portfolio`
is the dot product of the assets weights with the assets returns.
"""

# Copyright (c) 2023
# Author: Hugo Delatte <delatte.hugo@gmail.com>
# License: BSD 3 clause

import numbers
from typing import ClassVar

import numpy as np
import numpy.typing as npt
import pandas as pd

import skfolio.typing as skt
from skfolio.measures import RiskMeasure, effective_number_assets
from skfolio.portfolio._base import _ZERO_THRESHOLD, BasePortfolio
from skfolio.utils.tools import (
    args_names,
    cached_property_slots,
    default_asset_names,
    input_to_array,
)


[docs] class Portfolio(BasePortfolio): r""" Portfolio class. `Portfolio` is returned by the `predict` method of Optimization estimators. It is homogenous to the convex optimization problems meaning that `Portfolio` is the dot product of the assets weights with the assets returns. Parameters ---------- X : array-like of shape (n_observations, n_assets) Price returns of the assets. If `X` is a DataFrame or another array containers that implements 'columns' and 'index', the columns will be considered as assets names and the indices will be considered as observations. Otherwise, we use `["x0", "x1", ..., "x(n_assets - 1)"]` as asset names and `[0, 1, ..., n_observations]` as observations. weights : array-like of shape (n_assets,) | dict[str, float] Portfolio weights. If a dictionary is provided, its (key/value) pair must be the (asset name/asset weight) and `X` must be a DataFrame with assets names in columns. transaction_costs : float | dict[str, float] | array-like of shape (n_assets, ), optional Linear transaction costs of the assets. The Portfolio total transaction cost is: .. math:: total\_cost = \sum_{i=1}^{N} c_{i} \times |w_{i} - w\_prev_{i}| with :math:`c_{i}` the transaction cost of asset i, :math:`w_{i}` its weight and :math:`w\_prev_{i}` its previous weight (defined in `previous_weights`). The float :math:`total\_cost` is used in the portfolio returns: .. math:: ptf\_returns = R \cdot w - total\_cost with :math:`R` the matrix af assets returns and :math:`w` the vector of assets weights. If a float is provided, it is applied to each asset. If a dictionary is provided, its (key/value) pair must be the (asset name/asset weight) and `X` must be a DataFrame with assets names in columns. The default (`None`) means no transaction costs. .. warning:: To be homogenous to the optimization problems, the periodicity of the transaction costs needs to be homogenous to the periodicity of the returns `X`. For example, if `X` is composed of **daily** returns, the `transaction_costs` need to be expressed in **daily** transaction costs. management_fees : float | dict[str, float] | array-like of shape (n_assets, ), optional Linear management fees of the assets. The Portfolio total management cost is: .. math:: total\_fee = \sum_{i=1}^{N} f_{i} \times w_{i} with :math:`f_{i}` the management fee of asset i and :math:`w_{i}` its weight. The float :math:`total\_fee` is used in the portfolio returns: .. math:: ptf\_returns = R \cdot w - total\_fee with :math:`R` the matrix af assets returns and :math:`w` the vector of assets weights. If a float is provided, it is applied to each asset. If a dictionary is provided, its (key/value) pair must be the (asset name/asset weight) and `X` must be a DataFrame with assets names in columns. The default (`None`) means no management fees. .. warning:: To be homogenous to the optimization problems, the periodicity of the management fees needs to be homogenous to the periodicity of the returns `X`. For example, if `X` is composed of **daily** returns, the `management_fees` need to be expressed in **daily** fees. previous_weights : float | dict[str, float] | array-like of shape (n_assets, ), optional Previous portfolio weights. Previous weights are used to compute the total portfolio cost. If `transaction_costs` is 0, `previous_weights` will have no impact. If a float is provided, it is applied to each asset. If a dictionary is provided, its (key/value) pair must be the (asset name/asset previous weight) and `X` must be a DataFrame with assets names in columns. The default (`None`) means no previous weights. name : str, optional Name of the portfolio. The default (`None`) is to use the object id. tag : str, optional Tag given to the portfolio. Tags are used to manipulate groups of Portfolios from a `Population`. fitness_measures : list[measures], optional List of fitness measures. Fitness measures are used to compute the portfolio fitness which is used to compute domination. The default (`None`) is to use the list [PerfMeasure.MEAN, RiskMeasure.VARIANCE] annualized_factor : float, default=252.0 Factor used to annualize the below measures using the square-root rule: * Annualized Mean = Mean * factor * Annualized Variance = Variance * factor * Annualized Semi-Variance = Semi-Variance * factor * Annualized Standard-Deviation = Standard-Deviation * sqrt(factor) * Annualized Semi-Deviation = Semi-Deviation * sqrt(factor) * Annualized Sharpe Ratio = Sharpe Ratio * sqrt(factor) * Annualized Sortino Ratio = Sortino Ratio * sqrt(factor) risk_free_rate : float, default=0.0 Risk-free rate. The default value is `0.0`. compounded : bool, default=False If this is set to True, cumulative returns are compounded. The default is `False`. min_acceptable_return : float, optional The minimum acceptable return used to distinguish "downside" and "upside" returns for the computation of lower partial moments: * First Lower Partial Moment * Semi-Variance * Semi-Deviation The default (`None`) is to use the mean. value_at_risk_beta : float, default=0.95 The confidence level of the Portfolio VaR (Value At Risk) which represents the return on the worst (1-beta)% observations. The default value is `0.95`. entropic_risk_measure_theta : float, default=1.0 The risk aversion level of the Portfolio Entropic Risk Measure. The default value is `1.0`. entropic_risk_measure_beta : float, default=0.95 The confidence level of the Portfolio Entropic Risk Measure. The default value is `0.95`. cvar_beta : float, default=0.95 The confidence level of the Portfolio CVaR (Conditional Value at Risk) which represents the expected VaR on the worst (1-beta)% observations. The default value is `0.95`. evar_beta : float, default=0.95 The confidence level of the Portfolio EVaR (Entropic Value at Risk). The default value is `0.95`. drawdown_at_risk_beta : float, default=0.95 The confidence level of the Portfolio Drawdown at Risk (DaR) which represents the drawdown on the worst (1-beta)% observations. The default value is `0.95`. cdar_beta : float, default=0.95 The confidence level of the Portfolio CDaR (Conditional Drawdown at Risk) which represents the expected drawdown on the worst (1-beta)% observations. The default value is `0.95`. edar_beta : float, default=0.95 The confidence level of the Portfolio EDaR (Entropic Drawdown at Risk). The default value is `0.95`. Attributes ---------- n_observations : float Number of observations. mean : float Mean of the portfolio returns. annualized_mean : float Mean annualized by :math:`mean \times annualization\_factor` mean_absolute_deviation : float Mean Absolute Deviation. The deviation is the difference between the return and a minimum acceptable return (`min_acceptable_return`). first_lower_partial_moment : float First Lower Partial Moment. The First Lower Partial Moment is the mean of the returns below a minimum acceptable return (`min_acceptable_return`). variance : float Variance (Second Moment) annualized_variance : float Variance annualized by :math:`variance \times annualization\_factor` semi_variance : float Semi-variance (Second Lower Partial Moment). The semi-variance is the variance of the returns below a minimum acceptable return (`min_acceptable_return`). annualized_semi_variance : float Semi-variance annualized by :math:`semi\_variance \times annualization\_factor` standard_deviation : float Standard Deviation (Square Root of the Second Moment). annualized_standard_deviation : float Standard Deviation annualized by :math:`standard\_deviation \times \sqrt{annualization\_factor}` semi_deviation : float Semi-deviation (Square Root of the Second Lower Partial Moment). The Semi Standard Deviation is the Standard Deviation of the returns below a minimum acceptable return (`min_acceptable_return`). annualized_semi_deviation : float Semi-deviation annualized by :math:`semi\_deviation \times \sqrt{annualization\_factor}` skew : float Skew. The Skew is a measure of the lopsidedness of the distribution. A symmetric distribution have a Skew of zero. Higher Skew corresponds to longer right tail. kurtosis : float Kurtosis. It is a measure of the heaviness of the tail of the distribution. Higher Kurtosis corresponds to greater extremity of deviations (fat tails). fourth_central_moment : float Fourth Central Moment. fourth_lower_partial_moment : float Fourth Lower Partial Moment. It is a measure of the heaviness of the downside tail of the returns below a minimum acceptable return (`min_acceptable_return`). Higher Fourth Lower Partial Moment corresponds to greater extremity of downside deviations (downside fat tail). worst_realization : float Worst Realization which is the worst return. value_at_risk : float Historical VaR (Value at Risk). The VaR is the maximum loss at a given confidence level (`value_at_risk_beta`). cvar : float Historical CVaR (Conditional Value at Risk). The CVaR (or Tail VaR) represents the mean shortfall at a specified confidence level (`cvar_beta`). entropic_risk_measure : float Historical Entropic Risk Measure. It is a risk measure which depends on the risk aversion defined by the investor (`entropic_risk_measure_theta`) through the exponential utility function at a given confidence level (`entropic_risk_measure_beta`). evar : float Historical EVaR (Entropic Value at Risk). It is a coherent risk measure which is an upper bound for the VaR and the CVaR, obtained from the Chernoff inequality at a given confidence level (`evar_beta`). The EVaR can be represented by using the concept of relative entropy. drawdown_at_risk : float Historical Drawdown at Risk. It is the maximum drawdown at a given confidence level (`drawdown_at_risk_beta`). cdar : float Historical CDaR (Conditional Drawdown at Risk) at a given confidence level (`cdar_beta`). max_drawdown : float Maximum Drawdown. average_drawdown : float Average Drawdown. edar : float EDaR (Entropic Drawdown at Risk). It is a coherent risk measure which is an upper bound for the Drawdown at Risk and the CDaR, obtained from the Chernoff inequality at a given confidence level (`edar_beta`). The EDaR can be represented by using the concept of relative entropy. ulcer_index : float Ulcer Index gini_mean_difference : float Gini Mean Difference (GMD). It is the expected absolute difference between two realizations. The GMD is a superior measure of variability for non-normal distribution than the variance. It can be used to form necessary conditions for second-degree stochastic dominance, while the variance cannot. mean_absolute_deviation_ratio : float Mean Absolute Deviation ratio. It is the excess mean (mean - risk_free_rate) divided by the MaD. first_lower_partial_moment_ratio : float First Lower Partial Moment ratio. It is the excess mean (mean - risk_free_rate) divided by the First Lower Partial Moment. sharpe_ratio : float Sharpe ratio. It is the excess mean (mean - risk_free_rate) divided by the standard-deviation. annualized_sharpe_ratio : float Sharpe ratio annualized by :math:`sharpe\_ratio \times \sqrt{annualization\_factor}`. sortino_ratio : float Sortino ratio. It is the excess mean (mean - risk_free_rate) divided by the semi standard-deviation. annualized_sortino_ratio : float Sortino ratio annualized by :math:`sortino\_ratio \times \sqrt{annualization\_factor}`. value_at_risk_ratio : float VaR ratio. It is the excess mean (mean - risk_free_rate) divided by the Value at Risk (VaR). cvar_ratio : float CVaR ratio. It is the excess mean (mean - risk_free_rate) divided by the Conditional Value at Risk (CVaR). entropic_risk_measure_ratio : float Entropic risk measure ratio. It is the excess mean (mean - risk_free_rate) divided by the Entropic risk measure. evar_ratio : float EVaR ratio. It is the excess mean (mean - risk_free_rate) divided by the EVaR (Entropic Value at Risk). worst_realization_ratio : float Worst Realization ratio. It is the excess mean (mean - risk_free_rate) divided by the Worst Realization (worst return). drawdown_at_risk_ratio : float Drawdown at Risk ratio. It is the excess mean (mean - risk_free_rate) divided by the drawdown at risk. cdar_ratio : float CDaR ratio. It is the excess mean (mean - risk_free_rate) divided by the CDaR (conditional drawdown at risk). calmar_ratio : float Calmar ratio. It is the excess mean (mean - risk_free_rate) divided by the Maximum Drawdown. average_drawdown_ratio : float Average Drawdown ratio. It is the excess mean (mean - risk_free_rate) divided by the Average Drawdown. edar_ratio : float EDaR ratio. It is the excess mean (mean - risk_free_rate) divided by the EDaR (Entropic Drawdown at Risk). ulcer_index_ratio : float Ulcer Index ratio. It is the excess mean (mean - risk_free_rate) divided by the Ulcer Index. gini_mean_difference_ratio : float Gini Mean Difference ratio. It is the excess mean (mean - risk_free_rate) divided by the Gini Mean Difference. """ _read_only_attrs: ClassVar[set] = BasePortfolio._read_only_attrs.copy() _read_only_attrs.update( { "X", "assets", "weights", "previous_weights", "transaction_costs", "management_fees", "n_assets", "total_cost", "total_fee", } ) __slots__ = { # read-only "X", "weights", "previous_weights", "transaction_costs", "management_fees", "assets", "n_assets", "total_cost", "total_fee", # custom getter (read-only and cached) "_nonzero_assets", "_nonzero_assets_index", } def __init__( self, X: npt.ArrayLike, weights: skt.MultiInput, previous_weights: skt.MultiInput = None, transaction_costs: skt.MultiInput = None, management_fees: skt.MultiInput = None, risk_free_rate: float = 0, name: str | None = None, tag: str | None = None, annualized_factor: float = 252, fitness_measures: list[skt.Measure] | None = None, compounded: bool = False, min_acceptable_return: float | None = None, value_at_risk_beta: float = 0.95, entropic_risk_measure_theta: float = 1, entropic_risk_measure_beta: float = 0.95, cvar_beta: float = 0.95, evar_beta: float = 0.95, drawdown_at_risk_beta: float = 0.95, cdar_beta: float = 0.95, edar_beta: float = 0.95, ): # extract assets names from X assets = None observations = None if hasattr(X, "columns"): assets = np.asarray(X.columns, dtype=object) observations = np.asarray(X.index) # We don't perform extensive checks (like in check_X) for faster instantiation. rets = np.asarray(X) if rets.ndim != 2: raise ValueError("`X` must be a 2D array-like") n_observations, n_assets = rets.shape weights = input_to_array( items=weights, n_assets=n_assets, fill_value=0, dim=1, assets_names=assets, name="weights", ) if previous_weights is None: previous_weights = np.zeros(n_assets) else: previous_weights = input_to_array( items=previous_weights, n_assets=n_assets, fill_value=0, dim=1, assets_names=assets, name="previous_weights", ) if transaction_costs is None: transaction_costs = 0 elif not np.isscalar(transaction_costs): transaction_costs = input_to_array( items=transaction_costs, n_assets=n_assets, fill_value=0, dim=1, assets_names=assets, name="transaction_costs", ) if management_fees is None: management_fees = 0 elif not np.isscalar(management_fees): management_fees = input_to_array( items=management_fees, n_assets=n_assets, fill_value=0, dim=1, assets_names=assets, name="management_fees", ) # Default observations and assets if X is not a DataFrame if observations is None or len(observations) == 0: observations = np.arange(n_observations) if assets is None or len(assets) == 0: assets = default_asset_names(n_assets=n_assets) # Computing portfolio returns if np.isscalar(transaction_costs) and transaction_costs == 0: total_cost = 0 else: total_cost = (transaction_costs * abs(previous_weights - weights)).sum() if np.isscalar(management_fees) and management_fees == 0: total_fee = 0 else: total_fee = (management_fees * weights).sum() returns = weights @ rets.T - total_cost - total_fee if np.any(np.isnan(returns)): raise ValueError("NaN found in `returns`") super().__init__( returns=returns, observations=observations, name=name, tag=tag, fitness_measures=fitness_measures, compounded=compounded, risk_free_rate=risk_free_rate, annualized_factor=annualized_factor, min_acceptable_return=min_acceptable_return, value_at_risk_beta=value_at_risk_beta, cvar_beta=cvar_beta, entropic_risk_measure_theta=entropic_risk_measure_theta, entropic_risk_measure_beta=entropic_risk_measure_beta, evar_beta=evar_beta, drawdown_at_risk_beta=drawdown_at_risk_beta, cdar_beta=cdar_beta, edar_beta=edar_beta, ) self._loaded = False # We save the original array-like object and not the numpy copy for improved # memory self.X = X self.assets = assets self.n_assets = n_assets self.weights = weights self.transaction_costs = transaction_costs self.management_fees = management_fees self.previous_weights = previous_weights self.total_cost = total_cost self.total_fee = total_fee self._loaded = True def __neg__(self): args = {arg: getattr(self, arg) for arg in args_names(self.__init__)} args["weights"] = -self.weights return self.__class__(**args) def __abs__(self): args = {arg: getattr(self, arg) for arg in args_names(self.__init__)} args["weights"] = np.abs(self.weights) return self.__class__(**args) def __round__(self, n: int): args = {arg: getattr(self, arg) for arg in args_names(self.__init__)} args["weights"] = np.round(self.weights, n) return self.__class__(**args) def __add__(self, other): if not isinstance(other, Portfolio): raise TypeError( f"Cannot add a Portfolio with an object of type {type(other)}" ) args = args_names(self.__init__) for arg in args: if arg not in ["weights", "name", "tag"] and not np.array_equal( getattr(self, arg), getattr(other, arg) ): raise ValueError(f"Cannot add two Portfolios with different `{arg}`") args = {arg: getattr(self, arg) for arg in args} args["weights"] = self.weights + other.weights return self.__class__(**args) def __sub__(self, other): if not isinstance(other, Portfolio): raise TypeError( f"Cannot add a Portfolio with an object of type {type(other)}" ) args = args_names(self.__init__) for arg in args: if arg not in ["weights", "name", "tag"] and not np.array_equal( getattr(self, arg), getattr(other, arg) ): raise ValueError( f"Cannot subtract two Portfolios with different `{arg}`" ) args = {arg: getattr(self, arg) for arg in args} args["weights"] = self.weights - other.weights return self.__class__(**args) def __mul__(self, other: numbers.Number): if not isinstance(other, numbers.Number): raise TypeError( "Portfolio can only be multiplied by a number, but received a" f" {type(other)}" ) args = {arg: getattr(self, arg) for arg in args_names(self.__init__)} args["weights"] = other * self.weights return self.__class__(**args) __rmul__ = __mul__ def __floordiv__(self, other: numbers.Number): if not isinstance(other, numbers.Number): raise TypeError( "Portfolio can only be floor divided by a number, but received a" f" {type(other)}" ) args = {arg: getattr(self, arg) for arg in args_names(self.__init__)} args["weights"] = np.floor_divide(self.weights, other) return self.__class__(**args) def __truediv__(self, other: numbers.Number): if not isinstance(other, numbers.Number): raise TypeError( "Portfolio can only be divided by a number, but received a" f" {type(other)}" ) args = {arg: getattr(self, arg) for arg in args_names(self.__init__)} args["weights"] = self.weights / other return self.__class__(**args) # Custom attribute getter (read-only and cached) @cached_property_slots def nonzero_assets(self) -> np.ndarray: """Invested asset :math:`abs(weights) > 0.001%`""" return self.assets[self.nonzero_assets_index] @cached_property_slots def nonzero_assets_index(self) -> np.ndarray: """Indices of invested asset :math:`abs(weights) > 0.001%`""" return np.flatnonzero(abs(self.weights) > _ZERO_THRESHOLD) @property def composition(self) -> pd.DataFrame: """DataFrame of the Portfolio composition.""" weights = self.weights[self.nonzero_assets_index] df = pd.DataFrame({"asset": self.nonzero_assets, "weight": weights}) df.sort_values(by="weight", ascending=False, inplace=True) df.rename(columns={"weight": self.name}, inplace=True) df.set_index("asset", inplace=True) return df @property def weights_per_observation(self) -> pd.DataFrame: """DataFrame of the Portfolio weights per observation.""" idx = self.nonzero_assets_index weights = self.weights[idx] assets = self.assets[idx] df = pd.DataFrame( np.ones((len(self.observations), len(assets))) * weights, index=self.observations, columns=assets, ) return df @property def diversification(self) -> float: """Weighted average of volatility divided by the portfolio volatility.""" return ( self.weights @ np.std(np.asarray(self.X), axis=0) / self.standard_deviation ) @property def sric(self) -> float: """Sharpe Ratio Information Criterion (SRIC). It is an unbiased estimator of the Sharpe Ratio adjusting for both sources of bias which are noise fit and estimation error [1]_. References ---------- .. [1] "Noise Fit, Estimation Error and a Sharpe Information Criterion", Dirk Paulsen (2019) """ return self.sharpe_ratio - self.n_assets / ( self.n_observations * self.sharpe_ratio ) @property def effective_number_assets(self) -> float: r"""Computes the effective number of assets, defined as the inverse of the Herfindahl index [1]_: .. math:: N_{eff} = \frac{1}{\Vert w \Vert_{2}^{2}} It quantifies portfolio concentration, with a higher value indicating a more diversified portfolio. Returns ------- value : float Effective number of assets. References ---------- .. [1] "Banking and Financial Institutions Law in a Nutshell". Lovett, William Anthony (1988) """ return effective_number_assets(weights=self.weights) # Public methods
[docs] def expected_returns_from_assets( self, assets_expected_returns: np.ndarray ) -> float: """Compute the Portfolio expected returns from the assets expected returns, weights, management costs and transaction fees. Parameters ---------- assets_expected_returns : ndarray of shape (n_assets,) The vector of assets expected returns. Returns ------- value : float The Portfolio expected returns. """ return ( self.weights @ assets_expected_returns.T - self.total_cost - self.total_fee )
[docs] def variance_from_assets(self, assets_covariance: np.ndarray) -> float: """Compute the Portfolio variance expectation from the assets covariance and weights. Parameters ---------- assets_covariance : ndarray of shape (n_assets,n_assets) The matrix of assets covariance expectation. Returns ------- value : float The Portfolio variance from the assets covariance. """ return float(self.weights @ assets_covariance @ self.weights.T)
[docs] def contribution( self, measure: skt.Measure, spacing: float | None = None, to_df: bool = False ) -> np.ndarray | pd.DataFrame: r"""Compute the contribution of each asset to a given measure. 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}` to_df : bool, default=False If set to True, a DataFrame with asset names in index is returned, otherwise a numpy array is returned. When a DataFrame is returned, the values are sorted in descending order and assets with zero weights are removed. Returns ------- values : numpy array of shape (n_assets,) or DataFrame The measure contribution of each asset. """ if spacing is None: if measure in [ RiskMeasure.MAX_DRAWDOWN, RiskMeasure.AVERAGE_DRAWDOWN, RiskMeasure.CDAR, RiskMeasure.EDAR, ]: spacing = 1e-1 else: spacing = 1e-5 args = { arg: getattr(self, arg) for arg in args_names(self.__init__) if arg != "weights" } contribution, assets = _compute_contribution( args=args, weights=self.weights, assets=self.assets, measure=measure, h=spacing, drop_zero_weights=to_df, ) if not to_df: return np.array(contribution) df = pd.DataFrame(contribution, index=assets, columns=[self.name]) df.sort_values(by=self.name, ascending=False, inplace=True) return df
[docs] def summary(self, formatted: bool = True) -> pd.Series: """Portfolio summary of all its measures. Parameters ---------- formatted : bool, default=True If this is set to True, the measures are formatted into rounded string with units. Returns ------- summary : series Portfolio summary. """ df = super().summary(formatted=formatted) assets_number = self.n_assets effective_nb_assets = self.effective_number_assets if formatted: assets_number = str(assets_number) effective_nb_assets = str(effective_nb_assets) df["Effective Number of Assets"] = effective_nb_assets df["Assets Number"] = assets_number return df
[docs] def get_weight(self, asset: str) -> float: """Get the weight of a given asset. Parameters ---------- asset : str Name of the asset. Returns ------- weight : float Weight of the asset. """ try: return self.weights[np.where(self.assets == asset)[0][0]] except IndexError: raise IndexError("{asset} is not a valid asset name.") from None
def _get_risk( args: dict, weights: np.ndarray, measure: skt.Measure, i: int, h: float ) -> float: """Get the Portfolio risk measure when the weight of asset `i` is increased by `h`.""" assert "weights" not in args weights = weights.copy() weights[i] += h return getattr(Portfolio(weights=weights, **args), measure.value) def _compute_contribution( args: dict, weights: np.ndarray, assets: np.ndarray, measure: skt.Measure, h: float, drop_zero_weights: bool, ) -> tuple[list[float], list[str]]: """Compute the contribution of each asset to a given measure using finite difference. """ contributions = [] _assets = [] for i, (weight, asset) in enumerate(zip(weights, assets, strict=True)): if weight == 0: if not drop_zero_weights: _assets.append(asset) contributions.append(0) else: _assets.append(asset) contributions.append( ( _get_risk(args, weights, measure, i, h) - _get_risk(args, weights, measure, i, -h) ) / (2 * h) * weight ) return contributions, _assets