"""Portfolio module.
`Portfolio` is returned by the `predict` method of Optimization estimators.
It must be consistent with the convex optimization problems, meaning that `Portfolio`
is the dot product of the assets weights with the assets returns.
"""
# Copyright (c) 2023-2026
# Author: Hugo Delatte <hugo.delatte@skfoliolabs.com>
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import numbers
from typing import TYPE_CHECKING, ClassVar
import numpy as np
import pandas as pd
import skfolio.typing as skt
from skfolio._constants import (
_MANAGEMENT_FEES,
_PREVIOUS_WEIGHTS,
_TRANSACTION_COSTS,
)
from skfolio.attribution import Attribution
from skfolio.measures import RiskMeasure, effective_number_assets
from skfolio.portfolio._base import _ZERO_THRESHOLD, BasePortfolio
from skfolio.typing import ArrayLike, FloatArray, IntArray, StrArray
from skfolio.utils.tools import (
args_names,
cached_property_slots,
default_asset_names,
input_to_array,
)
if TYPE_CHECKING:
from skfolio.prior import FactorModel
[docs]
class Portfolio(BasePortfolio):
r"""
Portfolio class.
`Portfolio` is returned by the `predict` method of Optimization estimators.
Its formulation is **consistent** with the convex optimization problems: portfolio
returns are computed as a **dot product** of weights and asset returns, minus costs.
This formulation is **not perfectly replicable** due to weight drift when asset
prices move, except in the ideal case of periodic rebalancing with zero transaction
costs.
This design choice is analogous to using **non-compounded vs compounded returns** to
compare trading strategies. `skfolio` focuses on **allocation skill**, which
corresponds to an **expectation-based (ex-ante) evaluation**, rather than on
**realized capital growth**, which corresponds to a **path-dependent (ex-post)
evaluation** along a single return path.
Weight drift introduces **path dependence**: early winners get larger weights, early
losers shrink, and outcomes depend on return ordering. Two portfolios with the same
expected returns and covariances can end with very different performance due only to
the sequence of returns, which contaminates the comparison. Likewise, a volatile
asset can dominate portfolio results because it moved early, not because it has a
higher expected return.
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.
`NaN` values are treated as zero returns for the portfolio return
computation (e.g. non-investable assets, delisted assets or trading
holidays), while the original `X` is preserved.
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 of 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 consistent with the optimization problems, the periodicity of the
transaction costs must match 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 of 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 consistent with the optimization problems, the periodicity of the
management fees must match 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]
annualization_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`.
sample_weight : ndarray of shape (n_observations,), optional
Sample weights for each observation. If None, equal weights are assumed.
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`.
fallback_chain : list[tuple[str, str]] | None, optional
Sequence describing the optimization fallback attempts. Each element is
a pair `(estimator_repr, outcome)` where:
* `estimator_repr` is the string representation of the primary
estimator or a fallback (e.g. `"EqualWeighted()"`,
`"previous_weights"`).
* `outcome` is `"success"` if that step produced a valid solution,
otherwise the stringified error message.
For successful fits without any fallback, this is `None`. When
fallbacks are provided and the primary fails, the chain starts with
`(primary_repr, primary_error)` and is followed by one entry per
fallback that was attempted, ending with the first `"success"` or the
last error if all fail. This is set by the optimization estimator and
propagated to the resulting portfolio.
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",
# read-write
"fallback_chain",
}
def __init__(
self,
X: ArrayLike,
weights: skt.MultiInput | None,
previous_weights: skt.MultiInput | None = None,
transaction_costs: skt.MultiInput | None = None,
management_fees: skt.MultiInput | None = None,
risk_free_rate: float = 0,
name: str | None = None,
tag: str | None = None,
annualization_factor: float | None = None,
fitness_measures: list[skt.Measure] | None = None,
compounded: bool = False,
sample_weight: FloatArray | None = None,
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,
fallback_chain: list[tuple[str, str]] | None = None,
**kwargs,
):
# 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_provided = weights is not None
if not weights_provided:
weights = np.full(n_assets, np.nan)
else:
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()
if weights_provided:
rets_clean = np.nan_to_num(rets, nan=0.0) if np.isnan(rets).any() else rets
returns = weights @ rets_clean.T - total_cost - total_fee
else:
returns = np.full(n_observations, np.nan)
super().__init__(
returns=returns,
observations=observations,
name=name,
tag=tag,
fitness_measures=fitness_measures,
compounded=compounded,
sample_weight=sample_weight,
risk_free_rate=risk_free_rate,
annualization_factor=annualization_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,
**kwargs,
)
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
# Keep attribute name aligned with Optimization API (fallback_chain_)
self.fallback_chain = fallback_chain
self._loaded = True
@property
def _is_failed_portfolio(self) -> bool:
return self.__class__.__name__ == "FailedPortfolio"
def __neg__(self):
if self._is_failed_portfolio:
return self.copy()
args = {arg: getattr(self, arg) for arg in args_names(self.__init__)}
args["weights"] = -self.weights
return self.__class__(**args)
def __abs__(self):
if self._is_failed_portfolio:
return self.copy()
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):
if self._is_failed_portfolio:
return self.copy()
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)}"
)
if self._is_failed_portfolio:
return self.copy()
if other._is_failed_portfolio:
return other.copy()
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)}"
)
if self._is_failed_portfolio:
return self.copy()
if other._is_failed_portfolio:
return other.copy()
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)}"
)
if self._is_failed_portfolio:
return self.copy()
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)}"
)
if self._is_failed_portfolio:
return self.copy()
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)}"
)
if self._is_failed_portfolio:
return self.copy()
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) -> StrArray:
"""Invested asset :math:`abs(weights) > 0.001%`."""
return self.assets[self.nonzero_assets_index]
@cached_property_slots
def nonzero_assets_index(self) -> IntArray:
"""Indices of invested asset :math:`abs(weights) > 0.001%`."""
return np.flatnonzero(
np.isnan(self.weights) | (abs(self.weights) > _ZERO_THRESHOLD)
)
@property
def composition(self) -> pd.DataFrame:
"""DataFrame of portfolio composition (weights). Rows with zero weights are
filtered out. Use `weights_dict` to access all weights, including zeros.
"""
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_dict(self) -> dict[str, float]:
"""Dict mapping asset name to weight; includes zeros."""
return {
asset: float(weight)
for asset, weight in zip(self.assets, self.weights, strict=True)
}
@property
def previous_weights_dict(self) -> dict[str, float]:
"""Dict mapping asset name to previous weight; includes zeros."""
return {
asset: float(weight)
for asset, weight in zip(self.assets, self.previous_weights, strict=True)
}
@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.
.. 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: FloatArray
) -> float:
"""Compute the portfolio expected return from expected asset returns,
weights, management costs and transaction fees.
Parameters
----------
assets_expected_returns : ndarray of shape (n_assets,)
The vector of expected asset returns.
Returns
-------
value : float
The portfolio expected return.
"""
return (
self.weights @ assets_expected_returns.T - self.total_cost - self.total_fee
)
[docs]
def variance_from_assets(self, assets_covariance: FloatArray) -> 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
) -> FloatArray | 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 self._is_failed_portfolio:
assets = self.assets
contribution = np.full(len(assets), np.nan)
else:
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
[docs]
def predicted_attribution(
self,
factor_model: FactorModel,
compute_asset_breakdowns: bool = True,
) -> Attribution:
r"""Ex-ante (predicted) factor risk and performance attribution.
Decomposes the portfolio's predicted risk and expected return into contributions
from individual factors and an idiosyncratic component using the factor model's
latest forecast estimates (`loading_matrix`, `factor_covariance`,
`idio_covariance`, `factor_mu`, `idio_mu`).
The annualization scaling uses `self.annualization_factor`.
Predicted attribution uses only these latest forecast estimates, so no
observation alignment is required. The `factor_model` may therefore cover a
different observation window than the portfolio.
The portfolio may hold a subset of the assets covered by the factor model and
weights are zero-filled for missing assets.
See :func:`~skfolio.attribution.predicted_factor_attribution`
for the full mathematical description.
Parameters
----------
factor_model : FactorModel
Factor model whose latest forecast estimates are used. Every asset in
`self.assets` must appear in `factor_model.asset_names`.
compute_asset_breakdowns : bool, default=True
If `True`, compute per-asset systematic/idiosyncratic decomposition. Set to
`False` for faster computation when only portfolio-level results are needed.
Returns
-------
attribution : Attribution
Component-level, factor-level, and optionally asset-level attribution
results.
Raises
------
ValueError
If the portfolio is a failed portfolio or if it holds assets not covered by
the factor model.
"""
if self._is_failed_portfolio:
raise ValueError("Cannot compute factor attribution on a failed portfolio.")
aligned_weights = _align_weights(
self.weights, self.assets, factor_model.asset_names
)
return factor_model.predicted_attribution(
weights=aligned_weights,
annualization_factor=self.annualization_factor,
compute_asset_breakdowns=compute_asset_breakdowns,
)
[docs]
def realized_attribution(
self,
factor_model: FactorModel,
compute_asset_breakdowns: bool = True,
compute_uncertainty: bool = True,
) -> Attribution:
r"""Realized (ex-post) factor risk and performance attribution.
Decomposes the portfolio's realized risk and return into contributions from
individual factors and an idiosyncratic component using actual historical factor
returns, exposures, and residuals.
The annualization scaling uses `self.annualization_factor`.
Realized attribution is computed on the overlapping observation window between
the portfolio and the factor model. Portfolio observations outside the factor
model window, commonly caused by factor-model warmup or exposure lag, are
excluded. Missing portfolio observations inside the overlapping window raise
`ValueError`. Time-varying exposures follow the as-of indexing convention
described in
:func:`~skfolio.attribution.realized_factor_attribution`:
when `exposure_lag > 0`, exposures known at observation
:math:`t-\ell` are aligned with returns at observation :math:`t`.
The portfolio may hold a subset of the assets covered by the factor model and
weights are zero-filled for missing assets.
See :func:`~skfolio.attribution.realized_factor_attribution`
for the full mathematical description.
Parameters
----------
factor_model : FactorModel
Factor model containing time-varying fields (`factor_returns`, `exposures`,
`idio_returns`) that overlap with the portfolio's observation period. Every
asset in `self.assets` must appear in `factor_model.asset_names`.
compute_asset_breakdowns : bool, default=True
If `True`, compute per-asset systematic/idiosyncratic attribution. Set to
`False` for faster computation when only portfolio-level results are needed.
compute_uncertainty : bool, default=True
If `True`, compute attribution uncertainty (standard errors on the factor
and idiosyncratic mean-return split).
Returns
-------
attribution : Attribution
Component-level, factor-level, and optionally asset-level attribution
results.
Raises
------
ValueError
If the portfolio is a failed portfolio, if it holds assets not covered by
the factor model, if no portfolio observations overlap with the factor model
or if portfolio observations are missing inside the overlapping window.
"""
aligned_weights, portfolio_returns, aligned_factor_model = (
_prepare_realized_attribution_inputs(self, factor_model)
)
return aligned_factor_model.realized_attribution(
weights=aligned_weights,
portfolio_returns=portfolio_returns,
annualization_factor=self.annualization_factor,
compute_asset_breakdowns=compute_asset_breakdowns,
compute_uncertainty=compute_uncertainty,
)
[docs]
def rolling_realized_attribution(
self,
factor_model: FactorModel,
window_size: int = 60,
step: int = 21,
compute_asset_breakdowns: bool = True,
compute_asset_factor_contribs: bool = False,
compute_uncertainty: bool = True,
) -> Attribution:
r"""Rolling realized (ex-post) factor risk and performance attribution.
Computes :func:`~skfolio.attribution.realized_factor_attribution`
over rolling windows, returning an :class:`~skfolio.attribution.Attribution`
where all numeric fields carry an additional leading dimension for the number
of windows.
Rolling realized attribution is computed on the overlapping observation window
between the portfolio and the factor model. Portfolio observations outside the
factor model window, commonly caused by factor-model warmup or exposure lag, are
excluded. Missing portfolio observations inside the overlapping window raise
`ValueError`. Time-varying exposures follow the as-of indexing convention
described in
:func:`~skfolio.attribution.rolling_realized_factor_attribution`.
The portfolio may hold a subset of the assets covered by the factor model and
weights are zero-filled for missing assets.
See :func:`~skfolio.attribution.rolling_realized_factor_attribution`
for the full mathematical description.
Parameters
----------
factor_model : FactorModel
Factor model containing time-varying fields that overlap with the
portfolio's observation period.
window_size : int, default=60
Number of effective return periods in each rolling window.
step : int, default=21
Number of observations to advance between consecutive windows. The default
of 21 produces approximately monthly output for daily data.
compute_asset_breakdowns : bool, default=True
If `True`, compute per-asset attribution for each window.
compute_asset_factor_contribs : bool, default=False
If `True`, compute asset-factor matrix for each window.
compute_uncertainty : bool, default=True
If `True`, compute per-window attribution uncertainty.
Returns
-------
attribution : Attribution
Rolling attribution results.
Raises
------
ValueError
If the portfolio is a failed portfolio, if it holds assets not covered by
the factor model, if no portfolio observations overlap with the factor model
or if `window_size` exceeds the number of overlapping observations.
"""
aligned_weights, portfolio_returns, aligned_factor_model = (
_prepare_realized_attribution_inputs(self, factor_model)
)
return aligned_factor_model.rolling_realized_attribution(
weights=aligned_weights,
portfolio_returns=portfolio_returns,
annualization_factor=self.annualization_factor,
window_size=window_size,
step=step,
compute_asset_breakdowns=compute_asset_breakdowns,
compute_asset_factor_contribs=compute_asset_factor_contribs,
compute_uncertainty=compute_uncertainty,
)
def _align_weights(
weights: np.ndarray,
portfolio_assets: np.ndarray,
model_assets: np.ndarray,
) -> np.ndarray:
"""Map portfolio weights into the factor model's asset ordering.
Parameters
----------
weights : ndarray of shape (n_portfolio_assets,)
Portfolio weight vector.
portfolio_assets : ndarray of shape (n_portfolio_assets,)
Asset names of the portfolio.
model_assets : ndarray of shape (n_model_assets,)
Asset names of the factor model (the target ordering).
Returns
-------
aligned : ndarray of shape (n_model_assets,)
Weight vector aligned to `model_assets`. Assets present in the portfolio keep
their weight; assets only in the model receive zero.
Raises
------
ValueError
If any asset in `portfolio_assets` is not found in `model_assets`.
"""
if np.array_equal(portfolio_assets, model_assets):
return weights
model_indices = pd.Index(model_assets).get_indexer(portfolio_assets)
missing_mask = model_indices < 0
if np.any(missing_mask):
missing = portfolio_assets[missing_mask]
raise ValueError(
f"Portfolio contains {len(missing)} asset(s) not in the factor "
f"model: {missing[:5].tolist()}{'...' if len(missing) > 5 else ''}."
)
aligned_weights = np.zeros(len(model_assets), dtype=weights.dtype)
aligned_weights[model_indices] = weights
return aligned_weights
def _prepare_realized_attribution_inputs(
portfolio: Portfolio,
factor_model: FactorModel,
) -> tuple[np.ndarray, np.ndarray, FactorModel]:
"""Prepare aligned weights and factor model data for realized attribution.
Parameters
----------
portfolio : Portfolio
The portfolio to prepare.
factor_model : FactorModel
Factor model to restrict.
Returns
-------
aligned_weights : ndarray of shape (n_model_assets,)
Weight vector aligned to the factor model's asset ordering.
portfolio_returns : ndarray of shape (n_observations,)
Portfolio returns restricted to the overlapping factor model window.
aligned_factor_model : FactorModel
Factor model restricted to the overlapping portfolio observation window.
"""
if portfolio._is_failed_portfolio:
raise ValueError("Cannot compute factor attribution on a failed portfolio.")
aligned_weights = _align_weights(
portfolio.weights, portfolio.assets, factor_model.asset_names
)
portfolio_indices, aligned_factor_model = _select_realized_observation_window(
observations=portfolio.observations,
factor_model=factor_model,
)
return aligned_weights, portfolio.returns[portfolio_indices], aligned_factor_model
def _select_realized_observation_window(
observations: np.ndarray,
factor_model: FactorModel,
) -> tuple[np.ndarray, FactorModel]:
"""Select the overlapping realized attribution observation window.
Leading or trailing portfolio observations outside the factor model are excluded.
Missing portfolio observations inside the overlapping window are treated as a data
alignment error.
"""
factor_model_observations = factor_model.observations
portfolio_observation_mask = np.isin(observations, factor_model_observations)
portfolio_indices = np.flatnonzero(portfolio_observation_mask).astype(
np.intp, copy=False
)
if len(portfolio_indices) == 0:
raise ValueError(
"Portfolio observations not found in FactorModel. "
f"First five: {observations[:5].tolist()}"
)
first_index = portfolio_indices[0]
last_index = portfolio_indices[-1]
internal_missing = observations[first_index : last_index + 1][
~portfolio_observation_mask[first_index : last_index + 1]
]
if len(internal_missing) > 0:
raise ValueError(
f"{len(internal_missing)} portfolio observation(s) inside the "
"overlapping factor model window were not found in FactorModel. "
f"First five: {internal_missing[:5].tolist()}"
)
selected_observations = observations[portfolio_indices]
model_observation_mask = np.isin(factor_model_observations, selected_observations)
factor_model_indices = np.flatnonzero(model_observation_mask).astype(
np.intp, copy=False
)
if not np.array_equal(
factor_model_observations[factor_model_indices], selected_observations
):
raise ValueError(
"Portfolio observations inside the overlapping factor model window must be "
"a duplicate-free subset of FactorModel observations in the same relative "
"order."
)
aligned_factor_model = factor_model.select_observations(factor_model_indices)
return portfolio_indices, aligned_factor_model
def _get_risk(
args: dict, weights: FloatArray, 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: FloatArray,
assets: FloatArray,
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