Source code for skfolio.attribution._model._breakdown

"""Breakdown Dataclass."""

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

from __future__ import annotations

from abc import ABC
from dataclasses import dataclass

import numpy as np
import pandas as pd
import scipy.stats as st

from skfolio.attribution._utils import (
    _format_contrib_with_ci_margin,
    _format_decimal,
    _format_percent,
)
from skfolio.typing import AnyArray, FloatArray, StrArray

__all__ = ["AssetBreakdown", "BaseBreakdown", "FactorBreakdown", "FamilyBreakdown"]


[docs] @dataclass(frozen=True) class BaseBreakdown(ABC): r"""Base class for attribution breakdowns. Stores common per-item volatility and return contributions for factor, family and asset attribution breakdowns. For single-point attribution, numeric fields are 1D arrays of shape `(n_items,)`. For rolling attribution (from :func:`rolling_realized_factor_attribution`), numeric fields are 2D arrays of shape `(n_windows, n_items)`. Attributes ---------- names : ndarray of shape (n_items,) Item names: factors, families, or assets. vol_contrib : ndarray of shape (n_items,) or (n_windows, n_items) Volatility contribution to total portfolio volatility. pct_total_variance : ndarray of shape (n_items,) or (n_windows, n_items) Percentage of total portfolio variance. mu_contrib : ndarray of shape (n_items,) or (n_windows, n_items) Return contribution to total portfolio return. """ names: StrArray vol_contrib: FloatArray pct_total_variance: FloatArray mu_contrib: FloatArray @property def _is_rolling(self) -> bool: """Whether this breakdown is from rolling attribution.""" return self.vol_contrib.ndim == 2 @property def _n_rolling_windows(self) -> int: """Number of rolling windows (1 for single-point attribution).""" if self._is_rolling: return self.vol_contrib.shape[0] return 1 def _build_df( self, data: dict, is_realized: bool, formatted: bool = False, observations: AnyArray | None = None, confidence_level: float = 0.95, ) -> pd.DataFrame: r"""Build a pandas DataFrame from breakdown data. Parameters ---------- data : dict Dictionary mapping column names to arrays. is_realized : bool Whether this is realized (ex-post) attribution. formatted : bool, default=False If True, format numeric columns for display. observations : ndarray, optional Observation labels for rolling attribution. confidence_level : float, default=0.95 When `formatted=True` and uncertainty data are present, labels the merged mean return contribution column `(N% CI)` and formats values as :math:`\mu \pm z \cdot SE`. Returns ------- df : pandas.DataFrame Formatted DataFrame with attribution data. """ mu_label = "Mean Return" if is_realized else "Expected Return" mu_contrib_col = f"{mu_label} Contribution" merged_mu_ci_col = f"{mu_contrib_col} ({int(confidence_level * 100)}% CI)" df = pd.DataFrame(data) if self._is_rolling: if observations is None: raise ValueError( "observations must be provided for rolling attribution" ) # For rolling window, we build a MultiIndex DataFrame # Drop the names because will be in Multi Index names_col = df.columns[0] df.drop(columns=df.columns[0], inplace=True) n_items = len(self.names) df.index = pd.MultiIndex.from_arrays( [ np.repeat(observations, n_items), np.tile(self.names, len(observations)), ], names=["Observation", names_col], ) else: names_col = df.columns[0] # Sort by absolute percentage of total variance. df = df.iloc[np.argsort(-np.abs(self.pct_total_variance))].set_index( names_col ) has_uncertainty = "Mean Return Uncertainty" in df.columns if formatted and has_uncertainty: z = float(st.norm.ppf((1 + confidence_level) / 2)) mu_vals = df[mu_contrib_col].to_numpy(dtype=float) se_vals = df["Mean Return Uncertainty"].to_numpy(dtype=float) df[merged_mu_ci_col] = [ _format_contrib_with_ci_margin(m, s, z) for m, s in zip(mu_vals, se_vals, strict=True) ] df.drop( columns=[mu_contrib_col, "Mean Return Uncertainty"], inplace=True, ) # Apply formatting if requested if formatted: pct_cols = [ "Standalone Volatility", f"Standalone {mu_label}", "Volatility Contribution", "% of Total Variance", mu_contrib_col, "Systematic Vol Contribution", "Idiosyncratic Vol Contribution", f"Systematic {mu_label} Contribution", f"Idiosyncratic {mu_label} Contribution", ] decimal_cols = [ "Exposure Mean", "Exposure Std", "Exposure", "Correlation with Portfolio", ] for col in pct_cols: if col in df.columns: df[col] = df[col].map(_format_percent) for col in decimal_cols: if col in df.columns: df[col] = df[col].map(_format_decimal) # Reorder order = [ "Family", "Exposure", "Exposure Mean", "Exposure Std", "Volatility Contribution", "Systematic Vol Contribution", "Idiosyncratic Vol Contribution", "% of Total Variance", merged_mu_ci_col, mu_contrib_col, "Mean Return Uncertainty", f"Systematic {mu_label} Contribution", f"Idiosyncratic {mu_label} Contribution", "Standalone Volatility", f"Standalone {mu_label}", ] df = df[ [c for c in order if c in df.columns] + [c for c in df.columns if c not in order] ] return df def _to_dict( self, is_realized: bool, name: str ) -> dict[str, FloatArray | StrArray]: """Convert breakdown to dictionary for DataFrame construction. Parameters ---------- is_realized : bool Whether this is realized (ex-post) attribution. name : str Name for the identifier column (e.g., "Factor", "Asset", "Family"). Returns ------- data : dict Dictionary mapping column names to arrays. """ mu_label = "Mean Return" if is_realized else "Expected Return" data = { name: np.tile(self.names, self._n_rolling_windows), "Volatility Contribution": self.vol_contrib.ravel(), "% of Total Variance": self.pct_total_variance.ravel(), f"{mu_label} Contribution": self.mu_contrib.ravel(), } return data
[docs] @dataclass(frozen=True) class FactorBreakdown(BaseBreakdown): r"""Per-factor attribution breakdown. Contains per-factor attribution with exposures, standalone factor statistics and volatility/return contributions. For single-point attribution, arrays have shape `(n_factors,)`. For rolling attribution, arrays have shape `(n_windows, n_factors)`. Attributes ---------- names : ndarray of shape (n_factors,) Factor names. Always 1D. family : ndarray of shape (n_factors,) or None Factor family/category labels (e.g., "Style", "Industry"). `None` if families were not provided. exposure : ndarray of shape (n_factors,) or (n_windows, n_factors) Portfolio exposure to each factor. For realized attribution with time-varying inputs, this is the mean exposure over time. exposure_std : ndarray or None Standard deviation of portfolio factor exposures over time. `None` for predicted attribution. vol_contrib : ndarray of shape (n_factors,) or (n_windows, n_factors) Factor volatility contribution to total portfolio volatility. pct_total_variance : ndarray of shape (n_factors,) or (n_windows, n_factors) Percentage of total portfolio variance. mu_contrib : ndarray of shape (n_factors,) or (n_windows, n_factors) Factor return contribution to total portfolio return. vol : ndarray of shape (n_factors,) or (n_windows, n_factors) Standalone factor volatility. mu : ndarray of shape (n_factors,) or (n_windows, n_factors) Standalone factor return: expected return for predicted attribution and mean return for realized attribution. corr_with_ptf : ndarray of shape (n_factors,) or (n_windows, n_factors) Correlation between each factor return and portfolio returns. mu_contrib_uncertainty : ndarray of shape (n_factors,) or (n_windows, n_factors) or None Per-factor standard error of the mean return contribution, reflecting factor return estimation uncertainty. `None` when uncertainty is not computed. """ family: StrArray | None # Exposure exposure: FloatArray exposure_std: FloatArray | None # For realized attrib with time-varying exposure # Standalone stats vol: FloatArray mu: FloatArray corr_with_ptf: FloatArray mu_contrib_uncertainty: FloatArray | None = None def _to_df( self, is_realized: bool, formatted: bool, observations: AnyArray | None = None, confidence_level: float = 0.95, ) -> pd.DataFrame: r"""Convert breakdown dataclass to DataFrame. Parameters ---------- is_realized : bool Whether this is realized (ex-post) attribution. formatted : bool, default=False If True, format volatility, return and variance-share columns as percentages and round other numeric columns to 4 decimal places. observations : ndarray, optional Observation labels for rolling attribution. If provided, returns a MultiIndex DataFrame with (observation, factor/family) index. confidence_level : float, default=0.95 When `formatted=True` and uncertainty data are present, labels the merged mean return contribution column `(N% CI)` and formats values as :math:`\mu \pm z \cdot SE`. Returns ------- df : pandas.DataFrame Pandas DataFrame with columns for exposures, volatilities, correlations, contributions and variance shares. For single-point attribution, indexed by `Factor` and sorted by absolute variance contribution (descending). For rolling attribution, returns a MultiIndex DataFrame. """ data = self._to_dict(is_realized=is_realized, name="Factor") data.update( { "Standalone Volatility": self.vol.ravel(), "Correlation with Portfolio": self.corr_with_ptf.ravel(), } ) if self.family is not None: data["Family"] = np.tile(self.family, self._n_rolling_windows) if is_realized: data.update( { "Exposure Mean": self.exposure.ravel(), "Exposure Std": self.exposure_std.ravel(), "Standalone Mean Return": self.mu.ravel(), } ) else: data.update( { "Exposure": self.exposure.ravel(), "Standalone Expected Return": self.mu.ravel(), } ) if self.mu_contrib_uncertainty is not None: data["Mean Return Uncertainty"] = self.mu_contrib_uncertainty.ravel() df = self._build_df( data=data, is_realized=is_realized, formatted=formatted, observations=observations, confidence_level=confidence_level, ) return df
[docs] @dataclass(frozen=True) class FamilyBreakdown(BaseBreakdown): r"""Family-level attribution breakdown. Aggregates factor attribution by factor family. For single-point attribution, arrays have shape `(n_families,)`. For rolling attribution, arrays have shape `(n_windows, n_families)`. Attributes ---------- names : ndarray of shape (n_families,) Family names. Always 1D. exposure : ndarray of shape (n_families,) or (n_windows, n_families) Sum of portfolio factor exposures within each family. exposure_std : ndarray or None Standard deviation of family exposures over time. `None` for predicted attribution. vol_contrib : ndarray of shape (n_families,) or (n_windows, n_families) Family volatility contribution, equal to the sum of its factor volatility contributions. pct_total_variance : ndarray of shape (n_families,) or (n_windows, n_families) Percentage of total portfolio variance. mu_contrib : ndarray of shape (n_families,) or (n_windows, n_families) Family return contribution, equal to the sum of its factor return contributions. mu_contrib_uncertainty : ndarray of shape (n_families,) or (n_windows, n_families) or None Standard error of the family mean return contribution, accounting for cross-factor estimation correlations within the family. `None` when uncertainty is not computed. """ # Exposure exposure: FloatArray exposure_std: FloatArray | None # For realized attrib with time-varying exposure mu_contrib_uncertainty: FloatArray | None = None def _to_df( self, is_realized: bool, formatted: bool, observations: AnyArray | None = None, confidence_level: float = 0.95, ) -> pd.DataFrame: r"""Convert breakdown dataclass to DataFrame. Parameters ---------- is_realized : bool Whether this is realized (ex-post) attribution. formatted : bool, default=False If True, format volatility, return and variance-share columns as percentages and round other numeric columns to 4 decimal places. observations : ndarray, optional Observation labels for rolling attribution. If provided, returns a MultiIndex DataFrame with (observation, factor/family) index. confidence_level : float, default=0.95 When `formatted=True` and uncertainty data are present, labels the merged mean return contribution column `(N% CI)` and formats values as :math:`\mu \pm z \cdot SE`. Returns ------- df : pandas.DataFrame Pandas DataFrame with columns for exposures, volatilities, correlations, contributions and variance shares. For single-point attribution, indexed by `Family` and sorted by absolute variance contribution (descending). For rolling attribution, returns a MultiIndex DataFrame. """ data = self._to_dict(is_realized=is_realized, name="Family") if is_realized: data.update( { "Exposure Mean": self.exposure.ravel(), "Exposure Std": self.exposure_std.ravel(), } ) else: data.update( { "Exposure": self.exposure, } ) if self.mu_contrib_uncertainty is not None: data["Mean Return Uncertainty"] = self.mu_contrib_uncertainty.ravel() df = self._build_df( data=data, is_realized=is_realized, formatted=formatted, observations=observations, confidence_level=confidence_level, ) return df
[docs] @dataclass(frozen=True) class AssetBreakdown(BaseBreakdown): r"""Per-asset attribution breakdown. Decomposes each asset's volatility and return contribution into systematic and idiosyncratic components. For single-point attribution, arrays have shape `(n_assets,)`. For rolling attribution, arrays have shape `(n_windows, n_assets)`. Attributes ---------- names : ndarray of shape (n_assets,) Asset names. Always 1D. weight : ndarray of shape (n_assets,) or (n_windows, n_assets) Portfolio asset weights. weight_std : ndarray of shape (n_assets,) or (n_windows, n_assets), or None Standard deviation of asset weights over time. `None` when weights are not time-varying. vol_contrib : ndarray of shape (n_assets,) or (n_windows, n_assets) Total asset volatility contribution. Sums to `total.vol`. systematic_vol_contrib : ndarray of shape (n_assets,) or (n_windows, n_assets) Asset volatility contribution attributed to factor exposures. Sums to `systematic.vol_contrib`. idio_vol_contrib : ndarray of shape (n_assets,) or (n_windows, n_assets) Asset volatility contribution not attributed to factor exposures. Sums to `idio.vol_contrib`. mu_contrib : ndarray of shape (n_assets,) or (n_windows, n_assets) Total asset return contribution. Sums to `total.mu`. systematic_mu_contrib : ndarray of shape (n_assets,) or (n_windows, n_assets) Asset return contribution attributed to factor exposures. Sums to`systematic.mu`. idio_mu_contrib : ndarray of shape (n_assets,) or (n_windows, n_assets) Asset return contribution not attributed to factor exposures. Sums to `idio.mu`. pct_total_variance : ndarray of shape (n_assets,) or (n_windows, n_assets) Percentage of total portfolio variance. vol : ndarray of shape (n_assets,) or (n_windows, n_assets) Standalone asset volatility: :math:`\sqrt{(B F B^\top + D)_{ii}}`. mu : ndarray of shape (n_assets,) or (n_windows, n_assets) Standalone asset return: expected return for predicted attribution and mean return for realized attribution. corr_with_ptf : ndarray of shape (n_assets,) or (n_windows, n_assets) Asset correlation with portfolio returns. """ # Weights weight: FloatArray weight_std: FloatArray | None # For realized attrib with time-varying weights # Systematic vs idio contribs systematic_vol_contrib: FloatArray systematic_mu_contrib: FloatArray # Idio contribs idio_vol_contrib: FloatArray idio_mu_contrib: FloatArray # Standalone asset stats vol: FloatArray mu: FloatArray corr_with_ptf: FloatArray def _to_df( self, is_realized: bool, formatted: bool = False, observations: AnyArray | None = None, ) -> pd.DataFrame: """Convert asset breakdown to DataFrame. Parameters ---------- is_realized : bool Whether this is realized (ex-post) attribution. formatted : bool, default=False If True, format numeric columns for display. observations : ndarray, optional Observation labels for rolling attribution. Returns ------- df : pandas.DataFrame Pandas DataFrame with asset-level attribution data. For single-point attribution, indexed by `Asset`. """ mu_label = "Mean Return" if is_realized else "Expected Return" data = self._to_dict(is_realized=is_realized, name="Asset") data.update( { "Weight": self.weight.ravel(), "Standalone Volatility": self.vol.ravel(), f"Standalone {mu_label}": self.mu.ravel(), "Correlation with Portfolio": self.corr_with_ptf.ravel(), "Systematic Vol Contribution": self.systematic_vol_contrib.ravel(), "Idiosyncratic Vol Contribution": self.idio_vol_contrib.ravel(), f"Systematic {mu_label} Contribution": self.systematic_mu_contrib.ravel(), f"Idiosyncratic {mu_label} Contribution": self.idio_mu_contrib.ravel(), } ) df = self._build_df( data=data, is_realized=is_realized, formatted=formatted, observations=observations, ) return df