Source code for skfolio.prior._model._factor_model

"""Factor Model Dataclass."""

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

from __future__ import annotations

import warnings
from dataclasses import dataclass
from functools import cached_property
from typing import Literal, NamedTuple

import numpy as np
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import scipy.stats as scs

from skfolio._constants import (
    _CURRENCY,
    _EXPOSURES,
    _IDIO_RETURNS,
    _IDIO_VARIANCES,
    _REGRESSION_WEIGHTS,
)
from skfolio.attribution import (
    Attribution,
    predicted_factor_attribution,
    realized_factor_attribution,
    rolling_realized_factor_attribution,
)
from skfolio.containers import AssetPanel, AssetPanelView, InactivePolicy
from skfolio.prior._model._covariance_sqrt import CovarianceSqrt
from skfolio.prior._model._family_constraint_basis import FamilyConstraintBasis
from skfolio.typing import (
    AnyArray,
    ArrayLike,
    BoolArray,
    FloatArray,
    IntArray,
    StrArray,
)
from skfolio.utils._factor_tools import _resolve_factor_subset
from skfolio.utils.figure import format_plot_label, format_plot_labels
from skfolio.utils.stats import (
    CSWeighting,
    CorrelationMethod,
    _forward_mean_return,
    cov_to_corr,
    cs_pearson_correlation,
    cs_spearman_correlation,
    safe_cholesky,
    safe_divide,
)

__all__ = ["CorrelationMethod", "FactorModel"]


[docs] @dataclass(frozen=True, eq=False) class FactorModel: r"""Factor model decomposition of asset returns. Holds the loading matrix, factor moments and idiosyncratic covariance, together with the optional time series of exposures, factor returns and idiosyncratic returns. Exposes a factor-structured covariance square root, plus cross-sectional regression diagnostics, idiosyncratic-calibration metrics and factor attribution when the relevant fields are populated. Produced by factor-model prior estimators: * :class:`~skfolio.prior.TimeSeriesFactorModel`, * :class:`~skfolio.prior.CharacteristicsFactorModel` and consumed downstream via :attr:`~skfolio.prior.ReturnDistribution.factor_model`. Method groups ------------- Factor structure and factor-return methods use the stored loading matrix, factor moments and, the factor return time series. They are available for both time-series and characteristics-based factor models. These include `factor_forecast_correlation`, `plot_factor_forecast_correlation`, `plot_factor_forecast_volatilities`, `plot_factor_cumulative_returns`, and `predicted_attribution`. Cross-sectional regression diagnostics are prefixed with `cs_regression_`. They require point-in-time exposures, estimated factor returns and idiosyncratic returns, as in characteristics-based models. These include `cs_regression_scores`, `cs_regression_t_stats`, `cs_regression_t_stat_exceedance_rate`, and their plotting methods.They are not available for time-series factor models that only store a static loading matrix. Exposure diagnostics are prefixed with `exposure_`. They describe the point-in-time exposure panel or its regression design and are available only when `exposures` is populated. These include `exposure_correlation`, `exposure_vif`, `exposure_condition_number`, `exposure_ic_summary`, and their plotting methods. Idiosyncratic diagnostics are prefixed with `idio_`. Diagnostics based on standardized idiosyncratic returns additionally require `idio_variances`. Attributes ---------- observations : ndarray of shape (n_observations,) Time index labels. asset_names : ndarray of shape (n_assets,) Asset names. factor_names : ndarray of shape (n_factors,) Factor names (e.g. `"value"`, `"momentum"`). factor_families : ndarray of shape (n_factors,) or None Family label for each factor (e.g. `"style"`, `"industry"`). Populated by cross-sectional factor models. loading_matrix : ndarray of shape (n_assets, n_factors) Asset-by-factor loading (exposure) matrix. Time-invariant for time-series factor models; the most recent point-in-time loadings for cross-sectional factor models (full history in `exposures`). exposures : ndarray of shape (n_observations, n_assets, n_factors) or None Full historical time series of asset-by-factor exposure (loading) matrices following the as-of time-indexing convention. Populated for cross-sectional factor models. `None` for time-series factor models, which use the single time-invariant `loading_matrix`. factor_covariance : ndarray of shape (n_factors, n_factors) Factor return covariance matrix. Under family constraints this full-basis matrix is rank-deficient; use :attr:`effective_factor_covariance` (paired with :attr:`effective_loading_matrix`) for decompositions such as Cholesky. factor_mu : ndarray of shape (n_factors,) Expected factor returns. factor_returns : ndarray of shape (n_observations, n_factors) or None Per-period factor returns. For time-series factor models, this is the input factor return series; for cross-sectional factor models, this is the per-period factor returns estimated from the cross-sectional regression. idio_covariance : ndarray of shape (n_assets, n_assets) or (n_assets,) Idiosyncratic covariance (diagonal vector or full matrix). idio_mu : ndarray of shape (n_assets,) or None Factor-orthogonal expected return for each asset, also called orthogonal alpha. With the default weighted least-squares projection, it satisfies :math:`B^\top W\,\text{idio\_mu}=0`. Custom robust or regularized cross-sectional regressors may produce a component that is only approximately orthogonal. Distinct from the time-series mean of `idio_returns`, which is not enforced to be factor-orthogonal. Populated by cross-sectional factor models. idio_returns : ndarray of shape (n_observations, n_assets) or None Per-period idiosyncratic returns, obtained from the corresponding factor regression. For time-series factor models, these are :math:`r - a - Bf`, where :math:`a` is the vector of time-series regression intercepts. For cross-sectional factor models, these are :math:`R(t) - B(t-\ell)f(t)`. idio_variances : ndarray of shape (n_observations, n_assets) or None Time-varying per-asset predicted idiosyncratic variances :math:`\hat\sigma^2_{i,t}`. Populated by cross-sectional factor models. exposure_lag : int, default=1 Lag applied to time-varying exposures under the as-of time-indexing convention. The default value of `1` aligns exposures at :math:`t-1` with returns over :math:`(t-1, t]`. Meaningful only when `exposures` is populated; ignored by time-series factor models, where the loading matrix is constant. regression_weights : ndarray of shape (n_observations, n_assets) or None Cross-sectional WLS regression weights. Non-negative. Assets with zero weight are excluded from the estimation universe. Row :math:`t` holds the weights used by the regression at date :math:`t`; like the lagged exposures, they are built from market caps at :math:`t - \text{lag}` and idiosyncratic variances estimated up to :math:`t - 1`. `None` for time-series factor models. benchmark_weights : ndarray of shape (n_observations, n_assets) or None Benchmark weights used for weighted cross-sectional diagnostics. Non-negative. `None` for time-series factor models. family_constraint_basis : FamilyConstraintBasis or None Compact basis encoding the family-constraint change of coordinates. Used by cross-sectional factor models with linear constraints across factor families (e.g. industry sum-to-zero). When present, diagnostics (t-statistics, VIF, condition number) and adjusted :math:`R^2` are computed in the reduced basis where constrained families are full-rank. """ observations: StrArray # (n_observations,) asset_names: StrArray # (n_assets,) factor_names: StrArray # (n_factors,) factor_families: StrArray | None # (n_factors,) loading_matrix: FloatArray # (n_assets, n_factors) exposures: FloatArray | None # (n_observations, n_assets, n_factors) # Factors factor_covariance: FloatArray # (n_factors, n_factors) factor_mu: FloatArray # (n_factors,) factor_returns: FloatArray | None # (n_observations, n_factors) # Idio idio_covariance: FloatArray # (n_assets, n_assets) or (n_assets,) idio_mu: FloatArray | None # (n_assets,) idio_returns: FloatArray | None # (n_observations, n_assets) idio_variances: FloatArray | None # (n_observations, n_assets) exposure_lag: int = 1 regression_weights: FloatArray | None = None # (n_observations, n_assets) benchmark_weights: FloatArray | None = None # (n_observations, n_assets) family_constraint_basis: FamilyConstraintBasis | None = None def __post_init__(self) -> None: """Validate optional weight arrays.""" self._validate_weights(self.regression_weights, name="regression_weights") self._validate_weights(self.benchmark_weights, name="benchmark_weights") # General utilities
[docs] def summary( self, factors: list[str] | None = None, families: str | list[str] | None = None, annualization_factor: float = 252.0, stability_step: int = 21, stability_cs_weighting: CSWeighting = CSWeighting.BENCHMARK, t_stat_threshold: float = 2.0, ) -> pd.DataFrame: r"""Summary statistics for the factor model. Combines factor-return statistics, Gram-matrix diagnostics, and exposure-quality metrics: * `annualized_mean`: factor annualized mean return. * `annualized_vol`: factor annualized volatility. * `annualized_sharpe`: factor annualized Sharpe ratio. * `autocorrelation`: factor return lag-1 autocorrelation. * `mean_abs_t_stat`: factor mean absolute cross-sectional t-statistic. * `t_stat_exceedance_rate`: fraction of observations where :math:`|t| > \text{threshold}`. * `mean_vif`: factor mean Variance Inflation Factor. * `stability`: factor median exposure stability coefficient over the chosen step. * `coverage`: average fraction of estimation-universe assets (positive regression weight) with non-missing factor exposure. For characteristics-based models, `annualized_mean` and `annualized_vol` are computed from model-native factor returns: the cross-sectional regression coefficients per one unit of exposure. Equivalently, each factor return is the WLS factor-mimicking portfolio return with unit exposure to that factor and zero exposure to the other regression factors, without additional rescaling to fixed gross exposure or volatility. The sign follows the exposure convention; for example, a size factor built from log market capitalization is large-minus-small, the opposite sign of the Fama-French SMB convention. Parameters ---------- factors : list of str, optional Explicit subset of factor names. Takes precedence over `families` when specified. families : str, list of str, optional Factor families to include. `None` includes all factors. Ignored when `factors` is given or when `factor_families` is `None`. annualization_factor : float, default=252.0 Number of observations per year (e.g., 252 for daily data) to annualize mean, volatility and sharpe ratio. stability_step : int, default=21 Number of observations between the two cross-sections used for the exposure stability coefficient (e.g. 21 for approximately monthly stability with daily data). stability_cs_weighting : CSWeighting, default=CSWeighting.BENCHMARK Cross-sectional weights for the stability computation. Falls back to `CSWeighting.IDENTITY` with a warning when unavailable. t_stat_threshold : float, default=2.0 Absolute t-statistic threshold for the exceedance rate. """ self._require("factor_returns", "summary") factor_indices, factor_names = self._resolve_factor_subset(factors, families) factor_returns = self.factor_returns[:, factor_indices] n_observations = factor_returns.shape[0] n_selected = len(factor_names) # Factor-return statistics mean = np.nanmean(factor_returns, axis=0) * annualization_factor vol = np.nanstd(factor_returns, axis=0, ddof=1) * np.sqrt(annualization_factor) sharpe = safe_divide(mean, vol, fill_value=np.nan) if n_observations > 1: autocorr = _lag1_autocorr(factor_returns) else: autocorr = np.full(n_selected, np.nan) data = { "annualized_mean": mean, "annualized_vol": vol, "annualized_sharpe": sharpe, "autocorrelation": autocorr, } # Gram-matrix diagnostics (reduced-basis aware) if ( self.exposures is None or self.factor_returns is None or self.idio_returns is None ): for metric_name in ( "mean_abs_t_stat", "t_stat_exceedance_rate", "mean_vif", ): data[metric_name] = np.full(n_selected, np.nan) else: diagnostics = self._gram_diagnostics t_stat_exceedance_rate = self.cs_regression_t_stat_exceedance_rate( threshold=t_stat_threshold ).values reduced_names = list(self._reduced_regression_factor_names) reduced_idx = {name: i for i, name in enumerate(reduced_names)} def _map_to_selected(values: FloatArray) -> FloatArray: mapped = np.full(n_selected, np.nan) for i, name in enumerate(factor_names): j = reduced_idx.get(name) if j is not None: mapped[i] = values[j] return mapped data["mean_abs_t_stat"] = _map_to_selected( np.nanmean(np.abs(diagnostics.t_stats), axis=0) ) data["t_stat_exceedance_rate"] = _map_to_selected(t_stat_exceedance_rate) data["mean_vif"] = _map_to_selected(np.nanmean(diagnostics.vif, axis=0)) # Exposure diagnostics if self.exposures is None: for metric_name in ("stability", "coverage"): data[metric_name] = np.full(n_selected, np.nan) else: if self.regression_weights is not None: in_universe = self.regression_weights > 0 else: in_universe = np.ones(self.exposures.shape[:2], dtype=bool) n_eligible = in_universe.sum(axis=1, keepdims=True) n_covered = (np.isfinite(self.exposures) & in_universe[..., None]).sum( axis=1 ) data["coverage"] = np.nanmean( safe_divide(n_covered, n_eligible, fill_value=0.0), axis=0 )[factor_indices] exposures = self.exposures[:, :, factor_indices] cs_var = np.nanvar(exposures, axis=1) is_constant = np.nanmax(cs_var, axis=0) < 1e-12 if n_observations > stability_step: stability_ts = self._exposure_stability( exposures, step=stability_step, cs_weighting=stability_cs_weighting ) stability_ts[:, is_constant] = 1.0 stability = np.nanmedian(stability_ts, axis=0) else: stability = np.where(is_constant, 1.0, np.nan) data["stability"] = stability return pd.DataFrame(data, index=factor_names)
@property def factor_returns_df(self) -> pd.DataFrame: """Factor returns DataFrame of shape (n_observations, n_factors).""" self._require("factor_returns", "factor_returns_df") return pd.DataFrame( self.factor_returns, index=self.observations, columns=self.factor_names ) @property def idio_returns_df(self) -> pd.DataFrame: """Idiosyncratic returns DataFrame of shape (n_observations, n_assets).""" self._require("idio_returns", "idio_returns_df") return pd.DataFrame( self.idio_returns, index=self.observations, columns=self.asset_names ) @property def exposures_df(self) -> pd.DataFrame: """Exposures as a MultiIndex DataFrame of shape (n_observations, n_factors * n_assets). """ self._require("exposures", "exposures_df") cols = pd.MultiIndex.from_product( (self.factor_names, self.asset_names), names=["factor", "asset"] ) exposures = self.exposures.transpose(0, 2, 1).reshape( len(self.observations), -1 ) return pd.DataFrame(exposures, index=self.observations, columns=cols)
[docs] def factor_forecast_correlation( self, factors: list[str] | None = None, families: str | list[str] | None = None ) -> FloatArray: """Factor return correlation forecast from :attr:`factor_covariance`. Parameters ---------- factors : list of str, optional Explicit subset of factor names. Takes precedence over `families` when specified. families : str, list of str, optional Factor families to include. `None` includes all factors. Ignored when `factors` is given or when `factor_families` is `None`. Returns ------- corr : ndarray of shape (n_selected_factors, n_selected_factors) Symmetric factor return correlation matrix with diagonal entries fixed to 1. """ factor_indices, _ = self._resolve_factor_subset(factors, families) if factor_indices == slice(None): cov = self.factor_covariance else: cov = self.factor_covariance[np.ix_(factor_indices, factor_indices)] corr, _ = cov_to_corr(cov) return corr
@property def effective_loading_matrix(self) -> FloatArray: r"""Full-rank loading matrix, reduced when family constraints are present. When the factor model uses family constraints, the full-basis loading matrix is rank-deficient because constrained factor families introduce linear dependencies among columns. This property converts it to the reduced (full-rank) basis so that downstream computations (e.g. orthogonal projectors) correctly identify the factor span. When :attr:`family_constraint_basis` is `None`, the loading matrix is returned unchanged. Returns ------- loading : ndarray of shape (n_assets, n_reduced_factors) Full-rank loading matrix. """ if self.family_constraint_basis is None: return self.loading_matrix return self.family_constraint_basis.reduce_loading_matrix(self.loading_matrix) @property def effective_exposures(self) -> FloatArray: r"""Full-rank historical exposures, reduced when family constraints are present. When the factor model uses family constraints, the full-basis exposure tensor is rank-deficient because constrained factor families introduce linear dependencies among columns. This property converts the historical exposures to the same reduced full-rank basis as :attr:`effective_loading_matrix`. When :attr:`family_constraint_basis` is `None`, the historical exposures are returned unchanged. Returns ------- exposures : ndarray of shape (n_observations, n_assets, n_reduced_factors) Historical full-rank exposure tensor. """ self._require("exposures", "effective_exposures") if self.family_constraint_basis is None: return self.exposures return self.family_constraint_basis.reduce_exposures(self.exposures) @property def effective_factor_names(self) -> StrArray: """Factor names aligned with the effective reduced basis.""" if self.family_constraint_basis is None: return self.factor_names return self.family_constraint_basis.reduced_factor_names(self.factor_names) @property def effective_factor_families(self) -> StrArray | None: """Factor families aligned with the effective reduced basis.""" if self.factor_families is None: return None if self.family_constraint_basis is None: return self.factor_families return self.family_constraint_basis.reduced_factor_names(self.factor_families) @property def effective_factor_covariance(self) -> FloatArray: r"""Full-rank factor covariance, reduced when family constraints are present. When the factor model uses family constraints, the full-basis factor covariance :math:`R\,\Sigma_f^{\mathrm{red}}\,R^\top` is rank-deficient. This property returns the reduced full-rank covariance aligned with :attr:`effective_loading_matrix`, so that decompositions (e.g. Cholesky) and SOC-based optimizers operate on a positive definite matrix. When :attr:`family_constraint_basis` is `None`, the covariance is returned unchanged. Returns ------- factor_covariance : ndarray of shape (n_reduced_factors, n_reduced_factors) Full-rank factor covariance. """ if self.family_constraint_basis is None: return self.factor_covariance return self.family_constraint_basis.reduce_factor_covariance( self.factor_covariance )
[docs] @cached_property def covariance_sqrt(self) -> CovarianceSqrt: r"""Covariance square root exploiting the factor structure. Decomposes the asset covariance :math:`\Sigma = B\,\Sigma_f\,B^\top + D` into a :class:`~skfolio.prior.CovarianceSqrt` that separates the systematic and idiosyncratic contributions, allowing SOC-based optimizers to work with smaller matrices. When idiosyncratic covariance is diagonal, the decomposition avoids an :math:`(n \times n)` Cholesky entirely and represents the idiosyncratic part as an element-wise multiply. When family constraints are present, the full-basis factor covariance :math:`R\,\Sigma_f^{\mathrm{red}}\,R^\top` is rank-deficient. The systematic square root is then built from the full-rank :attr:`effective_loading_matrix` and :attr:`effective_factor_covariance`, which keeps the Cholesky exact and the systematic component minimal. Returns ------- CovarianceSqrt """ systematic = self.effective_loading_matrix @ safe_cholesky( self.effective_factor_covariance ) if self.idio_covariance.ndim == 1: return CovarianceSqrt( components=(systematic,), diagonal=np.sqrt(self.idio_covariance), ) return CovarianceSqrt( components=(systematic, safe_cholesky(self.idio_covariance)), )
[docs] def enrich_asset_panel( self, panel: AssetPanel | AssetPanelView, copy: bool = True ) -> AssetPanel | AssetPanelView: """Add factor-model fields to an :class:`~skfolio.containers.AssetPanel`. The returned panel contains the fields required by alpha estimators: `idio_returns`, `idio_variances`, `regression_weights` and `exposures`. Observations and assets are aligned by label. Panel observations that are not present in the factor model are kept and filled with missing values, except `regression_weights`, which is filled with zero. The asset set must match exactly, although the order may differ. If `panel` is an :class:`~skfolio.containers.AssetPanelView`, enriched fields are added as view-local fields. When family constraints are present, `exposures` are added in the reduced full-rank basis used by the cross-sectional regression and factor covariance estimator. Parameters ---------- panel : AssetPanel or AssetPanelView Panel or observation view to enrich. copy : bool, default=True If `True`, enrich a shallow copy of `panel`. If `False`, mutate `panel`. Returns ------- enriched_panel : AssetPanel or AssetPanelView Panel or view containing the factor-model fields. Raises ------ TypeError If `panel` is not an :class:`~skfolio.containers.AssetPanel` or :class:`~skfolio.containers.AssetPanelView`. ValueError If required factor-model histories are unavailable, if labels cannot be aligned, or if any target field already exists. """ if not isinstance(panel, (AssetPanel, AssetPanelView)): raise TypeError( "`panel` must be an AssetPanel or AssetPanelView, " f"got {type(panel).__name__!r}." ) field_names = { _IDIO_RETURNS, _IDIO_VARIANCES, _REGRESSION_WEIGHTS, _EXPOSURES, } existing = field_names.intersection(panel.fields) if existing: raise ValueError( "Cannot enrich AssetPanel because it already contains " f"{sorted(existing)}." ) obs_idx = pd.Index(self.observations).get_indexer(panel.observations) valid_obs = obs_idx >= 0 if not np.any(valid_obs): raise ValueError( "The FactorModel and AssetPanel observations do not overlap." ) asset_idx = pd.Index(self.asset_names).get_indexer(panel.asset_names) if np.any(asset_idx < 0) or len(panel.asset_names) != len(self.asset_names): raise ValueError( "FactorModel asset names must match AssetPanel asset names exactly." ) self._require( ("idio_returns", "idio_variances", "regression_weights", "exposures"), "enrich_asset_panel", ) if copy: if isinstance(panel, AssetPanelView): enriched_panel = panel.copy(deep=False, copy_owner=False) else: enriched_panel = panel.copy(deep=False) else: enriched_panel = panel def align_2d(values: FloatArray, fill_value: float) -> FloatArray: out = np.full( (panel.n_observations, panel.n_assets), fill_value, dtype=float ) out[valid_obs] = np.asarray(values, dtype=float)[obs_idx[valid_obs]][ :, asset_idx ] return out def align_3d(values: FloatArray) -> FloatArray: values = np.asarray(values, dtype=float) out = np.full( (panel.n_observations, panel.n_assets, values.shape[2]), np.nan, dtype=float, ) out[valid_obs] = values[obs_idx[valid_obs]][:, asset_idx] return out enriched_panel[_IDIO_RETURNS] = align_2d(self.idio_returns, np.nan) enriched_panel[_IDIO_VARIANCES] = align_2d(self.idio_variances, np.nan) enriched_panel.add_2d_field( name=_REGRESSION_WEIGHTS, values=align_2d(self.regression_weights, 0.0), inactive_policy=InactivePolicy.ZERO, ) enriched_panel.add_3d_field( name=_EXPOSURES, values=align_3d(self.effective_exposures), third_axis_name="factors", third_axis_labels=self.effective_factor_names, third_axis_groups=self.effective_factor_families, ) return enriched_panel
[docs] def select_assets( self, assets: ArrayLike | slice | None = None, slim: bool = False ) -> FactorModel: """Return a new `FactorModel` restricted to selected assets. Per-asset fields (`asset_names`, `loading_matrix`, `exposures`, `idio_covariance`, `idio_mu`, `idio_returns`, `idio_variances`, `regression_weights`, `benchmark_weights`) are subsetted along the asset axis. Per-factor and time-only fields (`factor_names`, `factor_families`, `factor_covariance`, `factor_mu`, `factor_returns`, `observations`) and `family_constraint_basis` are passed through by reference. When `assets` keeps every asset in order and `slim` is `False`, `self` is returned directly. Parameters ---------- assets : array-like, slice , optional Assets to keep. Boolean arrays are treated as masks, integer arrays and slices are positional selectors and other arrays are matched against `asset_names`. The selection must be duplicate-free. If `None`, keep all assets. slim : bool, default=False When `True`, heavy time-series fields not used by downstream portfolio optimization (`exposures`, `idio_returns`, `idio_variances`, `benchmark_weights`) are set to `None` to save memory. Returns ------- subset : FactorModel """ all_assets = assets is None if all_assets and not slim: return self if all_assets: positions = None asset_indexer = None else: positions = _selector_to_positions( assets, self.asset_names, axis_name="assets" ) if len(np.unique(positions)) != len(positions): raise ValueError("`assets` must be a duplicate-free selector.") if len(positions) == len(self.asset_names) and np.array_equal( positions, np.arange(len(self.asset_names)) ): if not slim: return self asset_indexer = None else: asset_indexer = _positions_to_indexer(positions) def _subset(arr: AnyArray | None, axis: int = 0) -> AnyArray | None: if arr is None: return None if asset_indexer is None: return arr indexer = [slice(None)] * arr.ndim indexer[axis] = asset_indexer return arr[tuple(indexer)] idio_cov = self.idio_covariance if idio_cov is not None and asset_indexer is not None: if idio_cov.ndim == 1: idio_cov = idio_cov[asset_indexer] else: idio_cov = idio_cov[np.ix_(positions, positions)] if slim: exposures = None idio_returns = None idio_variances = None benchmark_weights = None else: exposures = _subset(self.exposures, axis=1) idio_returns = _subset(self.idio_returns, axis=1) idio_variances = _subset(self.idio_variances, axis=1) benchmark_weights = _subset(self.benchmark_weights, axis=1) return FactorModel( observations=self.observations, asset_names=_subset(self.asset_names), factor_names=self.factor_names, factor_families=self.factor_families, loading_matrix=_subset(self.loading_matrix), exposures=exposures, factor_covariance=self.factor_covariance, factor_mu=self.factor_mu, factor_returns=self.factor_returns, idio_covariance=idio_cov, idio_mu=_subset(self.idio_mu), idio_returns=idio_returns, idio_variances=idio_variances, exposure_lag=self.exposure_lag, regression_weights=_subset(self.regression_weights, axis=1), benchmark_weights=benchmark_weights, family_constraint_basis=self.family_constraint_basis, )
[docs] def select_observations(self, observations: ArrayLike | slice) -> FactorModel: r"""Return a new `FactorModel` restricted to selected observations. Slices all time-varying fields (`factor_returns`, `exposures`, `idio_returns`, `idio_variances`, `regression_weights`, `benchmark_weights`) to match `observations` while passing through all static fields (`loading_matrix`, `factor_covariance`, `idio_covariance`, `factor_mu`, `idio_mu`) unchanged. When the target observations map to a contiguous range inside the model's observation axis, numpy views are used to avoid copies. .. note:: Static fields are shared by reference. In particular, `loading_matrix` is **not** updated to `exposures[-1]` of the sliced model. It retains the value set by the estimator that produced this `FactorModel`. Parameters ---------- observations : array-like or slice Observations to keep. Boolean arrays are treated as masks, integer arrays and slices are positional selectors, and other arrays are matched against `self.observations`. The selection must be duplicate-free and preserve the original observation order. Returns ------- subset : FactorModel A `FactorModel` whose time-varying arrays cover only the requested observations. If `observations` already matches `self.observations`, `self` is returned directly (zero-cost no-op). Raises ------ ValueError If any element of `observations` is not found in `self.observations`, or if the requested labels are repeated or not in increasing order relative to `self.observations`. """ indices = _selector_to_positions( observations, self.observations, axis_name="observations" ) if len(indices) > 1 and np.any(np.diff(indices) <= 0): raise ValueError( "`observations` must be a duplicate-free subset of " "`self.observations` in the same relative order." ) if len(indices) == len(self.observations) and np.array_equal( indices, np.arange(len(self.observations)) ): return self observation_indexer = _positions_to_indexer(indices) def _slice(arr: AnyArray | None) -> AnyArray | None: return arr[observation_indexer] if arr is not None else None return FactorModel( observations=_slice(self.observations), asset_names=self.asset_names, factor_names=self.factor_names, factor_families=self.factor_families, loading_matrix=self.loading_matrix, exposures=_slice(self.exposures), factor_covariance=self.factor_covariance, factor_mu=self.factor_mu, factor_returns=_slice(self.factor_returns), idio_covariance=self.idio_covariance, idio_mu=self.idio_mu, idio_returns=_slice(self.idio_returns), idio_variances=_slice(self.idio_variances), exposure_lag=self.exposure_lag, regression_weights=_slice(self.regression_weights), benchmark_weights=_slice(self.benchmark_weights), family_constraint_basis=_slice(self.family_constraint_basis), )
[docs] def plot_factor_forecast_correlation( self, factors: list[str] | None = None, families: str | list[str] | None = None, title: str | None = None, ) -> go.Figure: """Factor return correlation forecast heatmap from :attr:`factor_covariance`. Parameters ---------- factors : list of str, optional Explicit subset of factor names. Takes precedence over `families` when specified. families : str, list of str, or None, default="style" Factor families to include. `None` includes all factors. Ignored when `factors` is given or when `factor_families` is `None`. title : str, optional Custom figure title. Returns ------- fig : go.Figure """ factor_indices, factor_names = self._resolve_factor_subset(factors, families) corr = self.factor_forecast_correlation(factors=factors, families=families) fig = _heatmap( corr, labels=format_plot_labels(factor_names), title=title or "Factor Forecast Correlation", zmin=-1, zmax=1, ) _add_family_outlines(fig, self.factor_families, factor_indices) return fig
[docs] def plot_factor_forecast_volatilities( self, factors: list[str] | None = None, families: str | list[str] | None = None, annualization_factor: float = 252.0, title: str | None = None, ) -> go.Figure: r"""Bar chart of annualized factor volatility forecasts. Computes annualized volatility as :math:`\sqrt{\mathrm{diag}(\Sigma_F) \cdot \text{annualization\_factor}}` from :attr:`factor_covariance`. Distinct from realized historical volatility in :meth:`summary`. Parameters ---------- factors : list of str, optional Explicit subset of factor names. Takes precedence over `families` when specified. families : str, list of str, or None, default="style" Factor families to include. `None` includes all factors. Ignored when `factors` is given or when `factor_families` is `None`. annualization_factor : float, default=252.0 Number of observations per year. title : str, optional Custom figure title. Returns ------- fig : go.Figure """ factor_indices, factor_names = self._resolve_factor_subset(factors, families) factor_vols = np.sqrt( np.diag(self.factor_covariance)[factor_indices] * annualization_factor ) sort_order = np.argsort(factor_vols) factor_vols = factor_vols[sort_order] sorted_factor_names = format_plot_labels( [str(factor_names[index]) for index in sort_order] ) fig = go.Figure( go.Bar( x=factor_vols, y=sorted_factor_names, orientation="h", marker_color="rgb(31, 119, 180)", ) ) fig.update_xaxes(tickformat=".2%") fig.update_layout( title=title or "Factor Forecast Volatility", xaxis_title="Annualized Volatility", yaxis_title="Factor", ) return fig
[docs] def plot_factor_cumulative_returns( self, factors: list[str] | None = None, families: str | list[str] | None = None, title: str | None = None, ) -> go.Figure: r"""Cumulative (non-compounded) factor returns over time. Parameters ---------- factors : list of str, optional Explicit subset of factor names. Takes precedence over `families` when specified. families : str, list of str, or None, default="style" Factor families to include. `None` includes all factors. Ignored when `factors` is given or when `factor_families` is `None`. title : str, optional Custom figure title. Returns ------- fig : go.Figure """ self._require("factor_returns", "plot_factor_cumulative_returns") factor_indices, factor_names = self._resolve_factor_subset(factors, families) cum_ret = np.nancumsum(self.factor_returns[:, factor_indices], axis=0) df = pd.DataFrame(cum_ret, index=self.observations, columns=factor_names) fig = _multi_line_plot( df, title=title or "Factor Cumulative Returns (Non-Compounded)", yaxis_title="Cumulative Return", ) fig.update_yaxes(tickformat=".2%") return fig
# Idiosyncratic diagnostics
[docs] def idio_calibration_summary(self) -> pd.Series: r"""Summary statistics for the calibration quality of standardized idiosyncratic returns. Computes time-aggregated statistics of the cross-sectional distribution of standardized idiosyncratic returns :math:`z_{it} = \epsilon_{it} / \hat\sigma_{i,t}`. Under a Gaussian assumption, the expected values are :math:`\text{std}(z) = 1`, excess kurtosis :math:`= 0`, skewness :math:`= 0`, and the 3-:math:`\sigma` tail rate :math:`\approx 0.27\%`. In practice, standardized idiosyncratic returns exhibit fat tails, so the tail rate is typically well above 0.27% (values around 1--3% are common for equity factor models). - `mean_cs_std` close to 1.0 indicates correctly scaled specific risk. Values persistently above 1 suggest underestimated risk; below 1 suggests overestimated risk. - `mean_tail_rate_3sigma` is expected to exceed the Gaussian reference due to fat tails. - `mean_cs_excess_kurtosis` > 0 (fat tails) and moderate `mean_cs_skewness` are typical. Returns ------- summary : Series Index: `mean_cs_std`, `median_cs_std`, `mean_cs_excess_kurtosis`, `mean_cs_skewness`, `mean_tail_rate_3sigma`. """ cs_std = self.idio_calibration.values return pd.Series( { "mean_cs_std": np.nanmean(cs_std), "median_cs_std": np.nanmedian(cs_std), "mean_cs_excess_kurtosis": np.nanmean(self.idio_kurtosis.values), "mean_cs_skewness": np.nanmean(self.idio_skewness.values), "mean_tail_rate_3sigma": np.nanmean(self.idio_tail_rate().values), }, name="idio_calibration", )
[docs] @cached_property def idio_vol_ic(self) -> pd.Series: r"""Information Coefficient of idiosyncratic volatility estimates. Computes the cross-sectional rank correlation (Spearman) between the predicted specific volatility :math:`\hat\sigma_{i,t}` and the next-period absolute idiosyncratic return :math:`|\epsilon_{i,t+1}|`. If the model captures the cross-sectional scale of idiosyncratic shocks, then assets with larger :math:`\hat\sigma_{i,t}` should tend to realize larger absolute moves at :math:`t + 1`. * High positive values indicate that the model ranks cross-sectional differences in idiosyncratic volatility well. * This diagnostic can also pick up broad cross-sectional scale effects such as size or liquidity, so it should be read together with :attr:`idio_vol_residual_dependence` which checks whether the standardized idiosyncratic return magnitude :math:`|z_{i,t+1}|` still depends on the predicted volatility level. """ self._require(("idio_returns", "idio_variances"), "idio_vol_ic") predicted_vol = np.sqrt(np.maximum(self.idio_variances[:-1], 0.0)) abs_idio_next = np.abs(self.idio_returns[1:]) corr = cs_spearman_correlation( predicted_vol, abs_idio_next, axis=1, min_count=5 ) return pd.Series( corr, index=self.observations[1:], name="Idio Vol IC (Spearman)" )
[docs] @cached_property def idio_vol_residual_dependence(self) -> pd.Series: r"""Residual dependence of standardized idiosyncratic returns on predicted idiosyncratic volatility. Computes the cross-sectional rank correlation (Spearman) between the predicted specific volatility :math:`\hat\sigma_{i,t}` and the next-period standardized absolute idiosyncratic return :math:`|\epsilon_{i,t+1}| / \hat\sigma_{i,t} = |z_{i,t+1}|`. If the volatility forecast is well calibrated, this standardized magnitude should be roughly independent of :math:`\hat\sigma_{i,t}`, so the correlation should be close to 0. Read together with :attr:`idio_vol_ic`, this diagnostic helps separate ranking power from calibration. A desirable pattern is a high :attr:`idio_vol_ic` combined with residual dependence near 0. """ self._require( ("idio_returns", "idio_variances"), "idio_vol_residual_dependence" ) predicted_vol = np.sqrt(np.maximum(self.idio_variances[:-1], 0.0)) abs_idio_next = np.abs(self.idio_returns[1:]) standardized_abs_idio_next = safe_divide( abs_idio_next, predicted_vol, fill_value=np.nan ) corr = cs_spearman_correlation( predicted_vol, standardized_abs_idio_next, axis=1, min_count=5 ) return pd.Series( corr, index=self.observations[1:], name="Idio Vol Residual Dependence (Spearman)", )
[docs] @cached_property def idio_calibration(self) -> pd.Series: """Cross-sectional std of standardized idiosyncratic returns.""" z = self._standardized_idio_returns() with warnings.catch_warnings(): warnings.simplefilter("ignore", RuntimeWarning) cs_std = np.nanstd(z, axis=1, ddof=1) return pd.Series( cs_std, index=self.observations, name="Standardised Idio Return Std" )
[docs] def idio_tail_rate(self, threshold: float = 3.0) -> pd.Series: r"""Fraction of assets with extreme standardized idiosyncratic returns. For each observation, computes the cross-sectional fraction of available standardized idiosyncratic returns whose absolute value exceeds `threshold`: .. math:: \frac{1}{n_t}\sum_i \mathbf{1}\{|z_{i,t}| > c\}, where :math:`z_{i,t}` is the standardized idiosyncratic return, :math:`c` is `threshold`, and :math:`n_t` is the number of finite standardized idiosyncratic returns at observation :math:`t`. Under a Gaussian reference model, the expected rate is :math:`2\Phi(-c)`. Higher realized rates indicate that the standardized residuals have heavier tails than implied by the idiosyncratic volatility estimates. In equity factor models, standardized idiosyncratic returns are often fat-tailed, so rates above the Gaussian reference are common. Parameters ---------- threshold : float, default=3.0 Absolute standardized-return threshold :math:`c`. Returns ------- tail_rate : Series of shape (n_observations,) Time series of cross-sectional tail exceedance rates, indexed by `observations`. """ z = self._standardized_idio_returns() n_valid = np.sum(np.isfinite(z), axis=1) n_exceed = np.sum(np.abs(z) > threshold, axis=1) rate = safe_divide(n_exceed, n_valid, fill_value=np.nan) return pd.Series(rate, index=self.observations, name="Tail Rate")
[docs] @cached_property def idio_kurtosis(self) -> pd.Series: """Cross-sectional excess kurtosis of standardized idiosyncratic returns.""" z = self._standardized_idio_returns() cs_kurt = _cs_kurtosis(z) return pd.Series(cs_kurt, index=self.observations, name="Excess Kurtosis")
[docs] @cached_property def idio_skewness(self) -> pd.Series: """Cross-sectional skewness of standardized idiosyncratic returns.""" z = self._standardized_idio_returns() cs_skew = _cs_skewness(z) return pd.Series(cs_skew, index=self.observations, name="Skewness")
[docs] def plot_idio_calibration( self, window: int | None = None, title: str | None = None ) -> go.Figure: r"""Cross-sectional std of standardized idiosyncratic returns over time. Under correct calibration, :math:`\text{std}(z_t) \approx 1`. Persistent deviations indicate mis-specified specific risk. Parameters ---------- window : int, optional Rolling-mean smoothing window. title : str, optional Custom figure title. Returns ------- fig : go.Figure """ return _plot_single_ts( self.idio_calibration, title=title or _rolling_title("Idiosyncratic Calibration", window), yaxis_title="Cross-Sectional Std of Standardised Idio Returns", window=window, ref_value=1.0, ref_label="Ideal = 1.0", mean_fmt=".3f", )
[docs] def plot_idio_tail_rate( self, threshold: float = 3.0, window: int | None = None, title: str | None = None, ) -> go.Figure: r"""Plot the idiosyncratic tail exceedance rate over time. For each observation, the plotted value is the fraction of assets whose finite standardized idiosyncratic return satisfies :math:`|z_{i,t}| > \text{threshold}`. When `window` is provided, the rolling mean is plotted to smooth short-lived cross-sectional tail spikes. A dashed reference line shows the Gaussian rate :math:`2\,\Phi(-\text{threshold})`, which is about 0.27% when `threshold = 3`. Persistent values above this reference indicate heavier idiosyncratic residual tails than implied by the volatility estimates. In equity factor models, standardized idiosyncratic returns are often fat-tailed, so observed rates above the Gaussian reference are common. Parameters ---------- threshold : float, default=3.0 Absolute standardized-return threshold. window : int, optional Rolling-mean smoothing window. title : str, optional Custom figure title. Returns ------- fig : go.Figure """ expected_rate = 2 * scs.norm.sf(threshold) return _plot_single_ts( self.idio_tail_rate(threshold=threshold), title=title or _rolling_title( "Idiosyncratic Tail Rate", window, context=f"threshold={threshold}", ), yaxis_title="Fraction of Assets", window=window, ref_value=expected_rate, ref_label=f"Gaussian: {expected_rate:.2%}", mean_fmt=".2%", tick_format=".2%", )
[docs] def plot_idio_kurtosis( self, window: int | None = None, title: str | None = None, ) -> go.Figure: r"""Cross-sectional excess kurtosis of standardised idiosyncratic returns over time. Each point is the excess kurtosis of :math:`z_{it}` computed across assets at a single observation. The Gaussian reference is zero, but positive values are expected because standardised idiosyncratic returns typically have fat tails. Parameters ---------- window : int, optional Rolling-mean smoothing window. title : str, optional Custom figure title. Returns ------- fig : go.Figure """ return _plot_single_ts( self.idio_kurtosis, title=title or _rolling_title("Cross-Sectional Excess Kurtosis", window), yaxis_title="Excess Kurtosis", window=window, ref_value=0.0, ref_label="Gaussian: 0", )
[docs] def plot_idio_skewness( self, window: int | None = None, title: str | None = None, ) -> go.Figure: r"""Cross-sectional skewness of standardised idiosyncratic returns over time. Each point is the skewness of :math:`z_{it}` computed across assets at a single observation. The Gaussian reference is zero. Mild negative skewness is common for equity factor models. Parameters ---------- window : int, optional Rolling-mean smoothing window. title : str, optional Custom figure title. Returns ------- fig : go.Figure """ return _plot_single_ts( self.idio_skewness, title=title or _rolling_title("Cross-Sectional Skewness", window), yaxis_title="Skewness", window=window, ref_value=0.0, ref_label="Gaussian: 0", )
[docs] def plot_idio_vol_ic( self, window: int = 60, title: str | None = None, ) -> go.Figure: r"""Information Coefficient (IC) of idiosyncratic volatility estimates. Plots the cross-sectional rank correlation (Spearman) between the predicted specific volatility :math:`\hat\sigma_{i,t}` and the next-period absolute idiosyncratic return :math:`|\epsilon_{i,t+1}|`. This is a ranking diagnostic: do names predicted to have larger :math:`\hat\sigma_{i,t}` tend to realize larger raw absolute moves. - High positive values indicate that the model ranks cross-sectional differences in idiosyncratic volatility well. - This diagnostic can also pick up broad cross-sectional scale effects such as size or liquidity. This is a ranking diagnostic, not a calibration diagnostic. For the post-standardization check, see :meth:`plot_idio_vol_residual_dependence`. Parameters ---------- window : int, default=60 Rolling window for the smoothed mean. title : str, optional Custom figure title. Returns ------- fig : go.Figure """ return _plot_single_ts( self.idio_vol_ic.rename("Rank Correlation (Spearman)"), title=title or _rolling_title( "Idiosyncratic Volatility IC", window, context="Spearman", ), yaxis_title="Rank Correlation", window=window, show_raw=True, show_mean=False, raw_trace_name="Rank Correlation", )
[docs] def plot_idio_vol_residual_dependence( self, window: int = 60, title: str | None = None, ) -> go.Figure: r"""Residual dependence of standardized idiosyncratic returns on predicted idiosyncratic volatility. Plots the cross-sectional rank correlation (Spearman) between the predicted specific volatility :math:`\hat\sigma_{i,t}` and the next-period standardized absolute idiosyncratic return :math:`|\epsilon_{i,t+1}| / \hat\sigma_{i,t} = |z_{i,t+1}|`. If the volatility forecast is well calibrated, this standardized magnitude should be roughly independent of :math:`\hat\sigma_{i,t}`, so the correlation should be close to 0. Read together with :meth:`plot_idio_vol_ic`, this helps distinguish ranking power from calibration. A desirable pattern is high :meth:`plot_idio_vol_ic` together with residual dependence near 0. Parameters ---------- window : int, default=60 Rolling window for the smoothed mean. title : str, optional Custom figure title. Returns ------- fig : go.Figure """ return _plot_single_ts( self.idio_vol_residual_dependence.rename("Residual Dependence (Spearman)"), title=title or _rolling_title( "Idiosyncratic Volatility Residual Dependence", window, context="Spearman", ), yaxis_title=( "Rank Correlation (Spearman, predicted idio vol vs " "|idio return| / predicted idio vol)" ), window=window, show_raw=True, show_mean=False, ref_value=0.0, raw_trace_name="Residual Dependence", )
# Cross-sectional regression diagnostics
[docs] @cached_property def cs_regression_scores(self) -> pd.DataFrame: r"""Fit diagnostics for each cross-sectional factor regression. This property is available when the model contains point-in-time exposures, estimated factor returns and idiosyncratic returns, as in characteristics-based cross-sectional factor models. It is not available for time-series factor models without point-in-time exposures. * `r2`: cross-sectional :math:`R^2`, .. math:: R^2_t = 1 - \frac{\sum_i w_{ti}\,\varepsilon_{ti}^2} {\sum_i w_{ti}\,(r_{ti} - \bar{r}_t)^2} * `adjusted_r2`: :math:`R^2` adjusted for the effective number of regressors :math:`k`, .. math:: \bar{R}^2_t = 1 - (1 - R^2_t)\,\frac{n_t - 1}{n_t - k - 1} * `aic`: Akaike Information Criterion, .. math:: \mathrm{AIC}_t = n_t \ln\!\left(\frac{\mathrm{RSS}_t}{n_t}\right) + 2k * `bic`: Bayesian Information Criterion, .. math:: \mathrm{BIC}_t = n_t \ln\!\left(\frac{\mathrm{RSS}_t}{n_t}\right) + k \ln(n_t) Here :math:`n_t` is the number of valid samples at observation :math:`t` and :math:`k = \text{n\_regressors}` is the effective number of regressors (reduced dimension when family constraints are active). Lower AIC/BIC indicate a better fit-complexity trade-off; BIC penalises complexity more heavily than AIC for large cross-sections. Returns ------- scores : DataFrame of shape (n_observations - exposure_lag, 4) Index aligned with the lagged regression observations. Columns: `r2`, `adjusted_r2`, `aic`, `bic`. """ self._require( ["exposures", "factor_returns", "idio_returns"], "cs_regression_scores" ) regression_data = self._regression_data lagged_exposures = regression_data.exposures factor_returns = regression_data.factor_returns idio_returns = regression_data.idio_returns regression_weights = regression_data.regression_weights systematic_returns = ( lagged_exposures @ factor_returns[:, :, np.newaxis] ).squeeze(-1) asset_returns = systematic_returns + idio_returns finite_mask = np.isfinite(asset_returns) estimation_mask, estimation_weights = self._estimation_mask_and_weights( finite_mask, regression_weights ) n_valid = estimation_mask.sum(axis=1) weight_sum = estimation_weights.sum(axis=1) normalized_weights = safe_divide( estimation_weights, weight_sum[:, None], fill_value=0.0 ) asset_returns = np.where(estimation_mask, asset_returns, 0.0) idio_returns = np.where(estimation_mask, idio_returns, 0.0) rss = (normalized_weights * idio_returns**2).sum(axis=1) mean = (normalized_weights * asset_returns).sum(axis=1) tss = (normalized_weights * (asset_returns - mean[:, None]) ** 2).sum(axis=1) r2 = 1.0 - safe_divide(rss, tss, fill_value=np.nan) k = self._n_regressors valid_aic = n_valid > k valid_adj = n_valid > k + 1 with np.errstate(divide="ignore", invalid="ignore"): log_msr = np.where(valid_aic, np.log(rss), np.nan) adjusted_r2 = np.where( valid_adj, 1.0 - (1.0 - r2) * (n_valid - 1) / (n_valid - k - 1), np.nan, ) aic = np.where(valid_aic, n_valid * log_msr + 2 * k, np.nan) bic = np.where(valid_aic, n_valid * log_msr + k * np.log(n_valid), np.nan) return pd.DataFrame( {"r2": r2, "adjusted_r2": adjusted_r2, "aic": aic, "bic": bic}, index=self._aligned("observations"), )
@property def cs_regression_t_stats(self) -> pd.DataFrame: r"""Cross-sectional regression coefficient t-statistics. .. math:: t_{tj} = \frac{\hat{\beta}_{tj}}{\mathrm{SE}(\hat{\beta}_{tj})} where :math:`\hat{\beta}_{tj}` is the estimated coefficient of factor :math:`j` at observation :math:`t`. In a cross-sectional factor model, this coefficient is the per-observation factor return. The standard error is derived from :math:`\hat\sigma^2_t (X^\top W X)^{-1}`. A common rule of thumb is that :math:`|t| > 2` suggests significance at approximately the 5 % level. When :attr:`family_constraint_basis` is set, the design matrix and factor returns are projected into the reduced (full-rank) basis, so the columns are the reduced-basis factor names rather than the full `factor_names`. Returns ------- cs_regression_t_stats : DataFrame Time-indexed t-statistics of shape `(n_observations - exposure_lag, n_reduced_factors)`. """ return pd.DataFrame( self._gram_diagnostics.t_stats, index=self._aligned("observations"), columns=self._reduced_regression_factor_names, )
[docs] def cs_regression_t_stat_exceedance_rate(self, threshold: float = 2.0) -> pd.Series: r"""Fraction of observations with significant cross-sectional regression t-statistics. The t-statistic exceedance rate measures how often a factor's cross-sectional t-statistic exceeds the absolute threshold: :math:`|t| > \text{threshold}`. With `threshold=2.0`, a factor whose true cross-sectional coefficient is zero and whose t-statistics are approximately Gaussian would exceed the threshold about 5 % of the time. Rates above this reference level indicate that the factor is repeatedly significant across observations. Parameters ---------- threshold : float, default=2.0 Absolute t-statistic threshold for significance. Returns ------- cs_regression_t_stat_exceedance_rate : Series Shape `(n_reduced_factors,)`. Fraction of significant observations per factor. """ t_stats = self._gram_diagnostics.t_stats significant = np.abs(t_stats) > threshold n_valid = np.sum(np.isfinite(t_stats), axis=0) rates = safe_divide(np.nansum(significant, axis=0), n_valid, fill_value=0.0) return pd.Series( rates, index=self._reduced_regression_factor_names, name="cs_regression_t_stat_exceedance_rate", )
[docs] def plot_cs_regression_scores( self, score: Literal["adjusted_r2", "r2", "aic", "bic"] = "adjusted_r2", window: int = 30, title: str | None = None, ) -> go.Figure: """Plot a cross-sectional regression score over time. Draws the selected per-observation score as a faded line and overlays its rolling mean over `window` observations to highlight changes in fit quality. A horizontal line marks the full-sample average and is annotated with its numerical value. Parameters ---------- score : str, default="adjusted_r2" Score to plot. Must be one of `"r2"`, `"adjusted_r2"`, `"aic"`, or `"bic"`. window : int, default=30 Number of observations required for the rolling mean. title : str, optional Custom title. Returns ------- fig : go.Figure """ score_labels = { "r2": "R\u00b2", "adjusted_r2": "Adjusted R\u00b2", "aic": "AIC", "bic": "BIC", } if score not in score_labels: raise ValueError( f"`score` must be one of {list(score_labels)}, got {score!r}." ) series = self.cs_regression_scores[score] label = score_labels[score] return _plot_single_ts( series.rename(label), title=title or _rolling_title(label, window), yaxis_title=label, window=window, show_raw=True, mean_fmt=".4f", )
[docs] def plot_cs_regression_t_stats( self, factors: list[str] | None = None, families: str | list[str] | None = None, window: int | None = None, title: str | None = None, ) -> go.Figure: r"""Plot absolute cross-sectional regression t-statistics over time per factor. When `window` is provided, plots the rolling mean of :math:`|t|` over `window` observations instead of the raw values. A horizontal reference line at :math:`|t| = 2` marks the conventional significance threshold. Parameters ---------- factors : list of str, optional Subset of factor names to include. families : str, list of str, optional Factor families to include. Ignored when `factors` is given. window : int, optional If provided, plot the rolling mean of :math:`|t|`. title : str, optional Custom title. Returns ------- fig : go.Figure """ factor_indices, factor_names = self._resolve_factor_subset( factors, families, reduced_basis=True, regression_only=True ) abs_t_stats = np.abs(self._gram_diagnostics.t_stats[:, factor_indices]) df = pd.DataFrame( abs_t_stats, index=self._aligned("observations"), columns=factor_names ) if window is not None: df = df.rolling(window=window).mean() default_title = ( "|t|-statistic per Factor" if window is None else f"Rolling Mean |t|-statistic ({window} observations)" ) fig = _multi_line_plot(df, title=title or default_title, yaxis_title="|t|") fig.add_hline( y=2, line_width=1, line_dash="dash", line_color="gray", annotation_text="|t| = 2", annotation_position="top left", ) return fig
[docs] def plot_cs_regression_t_stat_exceedance_rate( self, factors: list[str] | None = None, families: str | list[str] | None = None, threshold: float = 2.0, title: str | None = None, ) -> go.Figure: r"""Bar chart of the cross-sectional regression t-statistic exceedance rate. The t-statistic exceedance rate is the fraction of observations where :math:`|t| >` `threshold`. A vertical reference line at 5% marks the conventional null-rate benchmark used at `threshold = 2`; for other thresholds it is only an approximate guide and the exact Gaussian null rate is :math:`2\,\Phi(-\text{threshold})`. Parameters ---------- factors : list of str, optional Subset of factor names to include. Takes precedence over `families` when specified. families : str, list of str, optional Factor families to include. Ignored when `factors` is given. threshold : float, default=2.0 Absolute t-statistic threshold. title : str, optional Custom title. Returns ------- fig : go.Figure """ _, factor_names = self._resolve_factor_subset( factors, families, reduced_basis=True, regression_only=True ) rates = self.cs_regression_t_stat_exceedance_rate(threshold=threshold) rates = rates.loc[factor_names] order = np.argsort(rates.values) sorted_values = rates.values[order] sorted_names = format_plot_labels([str(rates.index[i]) for i in order]) fig = go.Figure( go.Bar( x=sorted_values, y=sorted_names, orientation="h", marker_color="rgb(31, 119, 180)", ) ) fig.add_vline( x=0.05, line_width=1, line_dash="dash", line_color="gray", ) x_max = max(float(np.max(sorted_values)), 0.05) tick_step = 0.1 tickvals = np.sort( np.unique( np.concatenate([np.arange(0, x_max + tick_step, tick_step), [0.05]]) ) ) fig.update_layout( title=title or f"Cross-sectional Regression t-Statistic Exceedance Rate (|t| > {threshold})", xaxis_title="Exceedance Rate", yaxis_title="Factor", ) fig.update_xaxes(tickmode="array", tickvals=tickvals, tickformat=".0%") return fig
# Exposure diagnostics
[docs] def exposure_correlation( self, factors: list[str] | None = None, families: str | list[str] | None = None, cs_weighting: CSWeighting = CSWeighting.BENCHMARK, ) -> FloatArray: """Time-average pairwise correlation matrix of factor exposures. Highly correlated exposures indicate redundant factors. They are a cross-sectional analogue of multicollinearity diagnostics used in regression, where redundant predictors can inflate variance inflation factors (VIFs). Pairs involving a factor with degenerate cross-sectional variance (e.g. the constant global factor exposure) have an undefined correlation and are reported as zero by convention. When two factors are never finite on at least 3 common assets at any observation, their correlation cannot be estimated and is reported as NaN. Parameters ---------- factors : list of str, optional Explicit subset of factor names. Takes precedence over `families` when specified. families : str, list of str, or None, optional Factor families to include. `None` includes all factors. Ignored when `factors` is given or when `factor_families` is `None`. cs_weighting : CSWeighting, default=CSWeighting.BENCHMARK Cross-sectional weights for the correlation computation. Falls back to `CSWeighting.IDENTITY` with a warning when unavailable. Returns ------- corr : ndarray of shape (n_selected_factors, n_selected_factors) Time-average correlation matrix. """ self._require("exposures", "exposure_correlation") factor_indices, _ = self._resolve_factor_subset(factors, families) exposures = self.exposures[:, :, factor_indices] weights = self._resolve_cs_weighting( cs_weighting, latest=False, fallback_cs_weighting=CSWeighting.IDENTITY, ) min_count = 3 eps = 1e-12 # The weighted variance is computed by cancellation of terms of the order # of the weighted square sum, so degenerate (constant) exposures must be # detected with a tolerance relative to that scale rel_tol = 1e-9 finite = np.isfinite(exposures) mask = finite.astype(float) clean_exposures = np.where(finite, exposures, 0.0) mask_t = mask.transpose(0, 2, 1) clean_exposures_t = clean_exposures.transpose(0, 2, 1) n_valid = mask_t @ mask if weights is None: weight_sum = n_valid weighted_sum = clean_exposures_t @ mask weighted_square_sum = (clean_exposures**2).transpose(0, 2, 1) @ mask weighted_cross_sum = clean_exposures_t @ clean_exposures else: weights_3d = weights[:, :, np.newaxis] weighted_mask = weights_3d * mask weighted_exposures = weights_3d * clean_exposures weight_sum = weighted_mask.transpose(0, 2, 1) @ mask weighted_sum = weighted_exposures.transpose(0, 2, 1) @ mask weighted_square_sum = (weighted_exposures * clean_exposures).transpose( 0, 2, 1 ) @ mask weighted_cross_sum = weighted_exposures.transpose(0, 2, 1) @ clean_exposures weighted_sum_t = weighted_sum.swapaxes(1, 2) weighted_square_sum_t = weighted_square_sum.swapaxes(1, 2) covariance = weighted_cross_sum - safe_divide( weighted_sum * weighted_sum_t, weight_sum, fill_value=np.nan ) variance = weighted_square_sum - safe_divide( weighted_sum**2, weight_sum, fill_value=np.nan ) variance_t = weighted_square_sum_t - safe_divide( weighted_sum_t**2, weight_sum, fill_value=np.nan ) variance = np.maximum(variance, 0.0) variance_t = np.maximum(variance_t, 0.0) denom = np.sqrt(variance * variance_t) pairwise_corr = safe_divide(covariance, denom, fill_value=np.nan, atol=eps) insufficient = n_valid < min_count degenerate = (variance <= eps + rel_tol * weighted_square_sum) | ( variance_t <= eps + rel_tol * weighted_square_sum_t ) pairwise_corr[insufficient | degenerate] = np.nan with warnings.catch_warnings(): # All-NaN slices are expected for degenerate pairs and handled below warnings.simplefilter("ignore", category=RuntimeWarning) corr = np.nanmean(pairwise_corr, axis=0) # A pair that is degenerate at every observation with sufficient joint # coverage (e.g. any pair involving the constant global factor exposure) # has an undefined correlation and is reported as zero by convention zero_by_convention = np.isnan(corr) & (degenerate & ~insufficient).any(axis=0) corr[zero_by_convention] = 0.0 np.fill_diagonal(corr, 1.0) return corr
@property def exposure_vif(self) -> pd.DataFrame: r"""Variance Inflation Factor of the exposure design per observation. VIF measures how much the variance of a cross-sectional regression coefficient is inflated due to collinearity among factor exposures: .. math:: \mathrm{VIF}_k = (X^\top W X)_{kk} \cdot [(X^\top W X)^{-1}]_{kk} A VIF of 1 indicates no collinearity; values above 5-10 suggest problematic multicollinearity. When :attr:`family_constraint_basis` is set, VIFs are computed in the reduced (full-rank) basis. Returns ------- exposure_vif : DataFrame Time-indexed VIF values of shape `(n_observations - exposure_lag, n_reduced_factors)`. """ return pd.DataFrame( self._gram_diagnostics.vif, index=self._aligned("observations"), columns=self._reduced_regression_factor_names, ) @property def exposure_condition_number(self) -> pd.Series: r"""Condition number of the exposure Gram matrix per observation. The condition number :math:`\kappa(X^\top W X)` is the ratio of the largest to smallest singular value. Large values indicate near-singular design matrices and numerically unstable coefficient estimates. When :attr:`family_constraint_basis` is set, the Gram matrix is built in the reduced (full-rank) basis. Returns ------- exposure_condition_number : Series Time-indexed condition numbers of shape `(n_observations - exposure_lag,)`. """ return pd.Series( self._gram_diagnostics.condition_number, index=self._aligned("observations"), name="exposure_condition_number", )
[docs] def exposure_ic_summary( self, correlation_method: CorrelationMethod = CorrelationMethod.SPEARMAN, horizon: int = 1, factors: list[str] | None = None, families: str | list[str] | None = None, ) -> pd.DataFrame: r"""Summary statistics for exposure Information Coefficients (ICs). Measures the cross-sectional correlation between factor exposures at :math:`t` and the forward mean asset return from :math:`t + 1` to :math:`t + h`, where :math:`h` is the forecast *horizon*. .. note:: The IC quantifies **return-predictive** power. In a **risk model**, factors are designed to explain covariance structure, not to predict expected returns. A factor can be an excellent risk factor even when :math:`\mathbb{E}[\text{IC}] \approx 0`. Do not discard a risk factor solely because its IC is low: use exposure stability, bias statistics, and variance contribution instead. Parameters ---------- correlation_method : CorrelationMethod, default=CorrelationMethod.SPEARMAN Correlation method used for the exposure IC. `SPEARMAN` computes Spearman rank IC. `PEARSON` computes Pearson IC, weighted by `regression_weights` when available. horizon : int, default=1 Forward window in number of observations. The mean return from :math:`t + 1` to :math:`t + h` is used. factors : list of str, optional Explicit subset of factor names. Takes precedence over `families` when specified. families : str, list of str, optional Factor families to include. `None` includes all factors. Returns ------- summary : DataFrame of shape (n_selected_factors, 4) Columns: `mean_ic`, `std_ic`, `ic_ir`, `hit_rate`. """ _check_correlation_method(correlation_method) factor_indices, factor_names = self._resolve_factor_subset(factors, families) ic, _ = self._ic( correlation_method=correlation_method, horizon=horizon, factor_indices=factor_indices, ) with warnings.catch_warnings(): warnings.simplefilter("ignore", RuntimeWarning) mean_ic = np.nanmean(ic, axis=0) std_ic = np.nanstd(ic, axis=0, ddof=1) ic_ir = safe_divide(mean_ic, std_ic, fill_value=np.nan) hit_rate = np.nanmean(ic > 0, axis=0) return pd.DataFrame( { "mean_ic": mean_ic, "std_ic": std_ic, "ic_ir": ic_ir, "hit_rate": hit_rate, }, index=factor_names, )
[docs] def plot_exposure_vif( self, factors: list[str] | None = None, families: str | list[str] | None = None, window: int | None = None, title: str | None = None, ) -> go.Figure: r"""Plot exposure Variance Inflation Factors over time per factor. When `window` is provided, plots the rolling mean over `window` observations instead of raw per-observation values. A horizontal reference line at VIF = 5 marks the conventional collinearity threshold. Parameters ---------- factors : list of str, optional Subset of factor names to include. families : str, list of str, optional Factor families to include. Ignored when `factors` is given. window : int, optional If provided, plot the rolling mean. title : str, optional Custom title. Returns ------- fig : go.Figure """ factor_indices, factor_names = self._resolve_factor_subset( factors, families, reduced_basis=True, regression_only=True ) vif = self._gram_diagnostics.vif[:, factor_indices] df = pd.DataFrame( vif, index=self._aligned("observations"), columns=factor_names ) if window is not None: df = df.rolling(window=window).mean() default_title = ( "Exposure Variance Inflation Factor" if window is None else f"Rolling Mean Exposure VIF ({window} observations)" ) fig = _multi_line_plot(df, title=title or default_title, yaxis_title="VIF") fig.add_hline( y=5, line_width=1, line_dash="dash", line_color="gray", annotation_text="VIF = 5", annotation_position="top left", ) return fig
[docs] def plot_exposure_condition_number( self, window: int = 30, title: str | None = None ) -> go.Figure: """Plot the exposure Gram-matrix condition number over time. Draws the per-observation condition number as a faded line and overlays its rolling mean over `window` observations. Large values indicate near-collinear exposures and less stable coefficient estimates. Parameters ---------- window : int, default=30 Number of observations required for the rolling mean. title : str, optional Custom title. Returns ------- fig : go.Figure """ label = "Exposure Condition Number" return _plot_single_ts( self.exposure_condition_number.rename(label), title=title or _rolling_title(label, window), yaxis_title=label, window=window, show_raw=True, mean_fmt=".4f", )
[docs] def plot_cumulative_exposure_ic( self, correlation_method: CorrelationMethod = CorrelationMethod.SPEARMAN, factors: list[str] | None = None, families: str | list[str] | None = None, title: str | None = None, ) -> go.Figure: r"""Cumulative exposure Information Coefficient (IC) over time. Plots the cumulative sum of the single-period cross-sectional correlation between factor exposures at :math:`t` and asset returns at :math:`t + 1`. - A monotonically rising curve indicates persistent predictive power (positive alpha signal). - A flat curve means the factor carries no return-predictive information. - A declining curve indicates a contrarian signal (negative alpha). For IC decay analysis across different holding periods, use :meth:`exposure_ic_summary` with varying `horizon` values instead. .. note:: The IC quantifies **return-predictive** power. In a **risk model**, factors are designed to explain covariance structure, not to predict expected returns. A factor can be an excellent risk factor even when :math:`\mathbb{E}[\text{IC}] \approx 0`. Parameters ---------- correlation_method : CorrelationMethod, default=CorrelationMethod.SPEARMAN Correlation method used for the exposure IC. `SPEARMAN` computes Spearman rank IC. `PEARSON` computes Pearson IC, weighted by `regression_weights` when available. factors : list of str, optional Explicit subset of factor names. Takes precedence over `families` when specified. families : str, list of str, optional Factor families to include. `None` includes all factors. title : str, optional Custom figure title. Returns ------- fig : go.Figure """ _check_correlation_method(correlation_method) factor_indices, factor_names = self._resolve_factor_subset( factors, families, reduced_basis=True ) ic, obs_offset = self._ic( correlation_method=correlation_method, horizon=1, factor_indices=factor_indices, reduced_basis=True, ) cum_ic = np.nancumsum(ic, axis=0) method_label = format_plot_label(correlation_method.value) default_title = f"Cumulative Exposure IC ({method_label})" df = pd.DataFrame( cum_ic, index=self.observations[obs_offset : obs_offset + cum_ic.shape[0]], columns=factor_names, ) return _multi_line_plot( df, title=title or default_title, yaxis_title="Cumulative IC" )
[docs] def plot_exposure_distribution( self, factor: str, observation_idx: int | None = None, n_bins: int | None = None, title: str | None = None, ) -> go.Figure: """Cross-sectional histogram of exposures for a single factor. When `observation` is `None` (default), all observations are pooled into one histogram showing the typical distribution. When an integer index is provided, only the exposures at that observation are plotted. Parameters ---------- factor : str Name of the factor to plot. observation_idx : int , optional Observation index. `None` pools all dates, `-1` selects the last observation, `0` the first, etc. n_bins : int , optional Number of histogram bins. `None` lets Plotly choose automatically. title : str, optional Custom figure title. Returns ------- fig : go.Figure """ self._require("exposures", "plot_exposure_distribution") factor_indices, _ = self._resolve_factor_subset( factor_names_to_keep=[factor], family_names_to_keep=None ) factor_idx = factor_indices[0] if observation_idx is not None: obs_label = str(self.observations[observation_idx]) values = self.exposures[observation_idx, :, factor_idx] default_title = ( f"Exposure Distribution: {format_plot_label(factor)} ({obs_label})" ) else: values = self.exposures[:, :, factor_idx].ravel() default_title = ( f"Exposure Distribution: {format_plot_label(factor)} (all observations)" ) values = values[np.isfinite(values)] fig = go.Figure( go.Histogram( x=values, nbinsx=n_bins, marker_color="rgb(31, 119, 180)", opacity=0.75, ) ) fig.update_layout( title=title or default_title, xaxis_title="Exposure", yaxis_title="Count", ) return fig
[docs] def plot_exposure_dispersion( self, factors: list[str] | None = None, families: str | list[str] | None = "style", cs_weighting: CSWeighting = CSWeighting.BENCHMARK, title: str | None = None, ) -> go.Figure: """Cross-sectional standard deviation of exposures over time. The absolute level depends on how exposures were standardized upstream. When the model uses weighted-mean centering or a different variance normalization, the equal-weighted cross-sectional std computed here will not be 1.0. Focus on temporal stability rather than the absolute level: a collapse may signal data-feed issues and an explosion may indicate an outlier. Parameters ---------- factors : list of str, optional Explicit subset of factor names. Takes precedence over `families` when specified. families : str, list of str, or None, default="style" Factor families to include. `None` includes all factors. Ignored when `factors` is given or when `factor_families` is `None`. cs_weighting : CSWeighting, default=CSWeighting.BENCHMARK Cross-sectional weights for the std computation. Falls back to `CSWeighting.IDENTITY` with a warning when unavailable. title : str, optional Custom figure title. Returns ------- fig : go.Figure """ self._require("exposures", "plot_exposure_dispersion") factor_indices, factor_names = self._resolve_factor_subset(factors, families) selected_exposures = self.exposures[:, :, factor_indices] weights = self._resolve_cs_weighting( cs_weighting, latest=False, fallback_cs_weighting=CSWeighting.IDENTITY ) finite_mask = np.isfinite(selected_exposures) if weights is None: weights_3d = finite_mask.astype(float) else: weights_3d = weights[:, :, None] * finite_mask.astype(float) weight_sum = np.sum(weights_3d, axis=1, keepdims=True) normalized_weights = safe_divide(weights_3d, weight_sum, fill_value=0.0) weighted_mean = np.sum( np.where(finite_mask, selected_exposures, 0.0) * normalized_weights, axis=1, keepdims=True, ) centered_exposures = np.where( finite_mask, selected_exposures - weighted_mean, 0.0 ) cs_var = np.sum(normalized_weights * centered_exposures**2, axis=1) cs_std = np.sqrt(cs_var) cs_std[weight_sum[:, 0, 0] == 0] = np.nan df = pd.DataFrame(cs_std, index=self.observations, columns=factor_names) fig = _multi_line_plot( df, title=title or "Exposure Cross-Sectional Std", yaxis_title="Std" ) fig.update_yaxes(rangemode="tozero") return fig
[docs] def plot_exposure_stability( self, factors: list[str] | None = None, families: str | list[str] | None = "style", step: int = 21, cs_weighting: CSWeighting = CSWeighting.BENCHMARK, title: str | None = None, ) -> go.Figure: r"""Weighted cross-sectional correlation of exposures between observation :math:`t` and :math:`t + \text{step}` over time. Measures whether the cross-sectional exposures are stable across the chosen horizon. The expected level depends on the factor's investment horizon. Slow-moving factors (e.g. value, size) should maintain high correlation at the default monthly step and values consistently below 0.80 may indicate noisy or poorly constructed exposures. Fast-turnover factors (e.g. reversal, short-term momentum) are designed to reshuffle quickly and will naturally show low monthly stability. For these factors, use a shorter `step` (e.g., 1-5 for daily data) to assess stability at the relevant horizon. Parameters ---------- factors : list of str, optional Explicit subset of factor names. Takes precedence over `families` when specified. families : str, list of str, or None, default="style" Factor families to include. `None` includes all factors. Ignored when `factors` is given or when `factor_families` is `None`. step : int, default=21 Number of observations between the two cross-sections being compared (e.g., 21 for approximately monthly stability with daily data). cs_weighting : CSWeighting, default=CSWeighting.BENCHMARK Cross-sectional weights for the correlation computation. Falls back to `CSWeighting.IDENTITY` with a warning when unavailable. title : str, optional Custom figure title. Returns ------- fig : go.Figure """ self._require("exposures", "plot_exposure_stability") factor_indices, factor_names = self._resolve_factor_subset(factors, families) exposures = self.exposures[:, :, factor_indices] if exposures.shape[0] <= step: raise ValueError( f"Not enough observations ({exposures.shape[0]}) for " f"step={step}. Need at least {step + 1}." ) stability = self._exposure_stability( exposures, step=step, cs_weighting=cs_weighting ) df = pd.DataFrame( stability, index=self.observations[step:], columns=factor_names ) fig = _multi_line_plot( df, title=title or "Exposure Stability (Weighted Cross-sectional Correlation)", yaxis_title="Correlation", ) fig.update_yaxes(range=[-0.05, 1.05]) return fig
[docs] def plot_exposure_correlation( self, factors: list[str] | None = None, families: str | list[str] | None = None, cs_weighting: CSWeighting = CSWeighting.BENCHMARK, title: str | None = None, ) -> go.Figure: """Time-average pairwise correlation heatmap of factor exposures. Highly correlated exposures indicate redundant factors and may inflate VIF. Parameters ---------- factors : list of str, optional Explicit subset of factor names. Takes precedence over `families` when specified. families : str, list of str, or None, default="style" Factor families to include. `None` includes all factors. Ignored when `factors` is given or when `factor_families` is `None`. cs_weighting : CSWeighting, default=CSWeighting.BENCHMARK Cross-sectional weights for the correlation computation. Falls back to `CSWeighting.IDENTITY` with a warning when unavailable. title : str, optional Custom figure title. Returns ------- fig : go.Figure """ factor_indices, factor_names = self._resolve_factor_subset(factors, families) corr_avg = self.exposure_correlation( factors=factors, families=families, cs_weighting=cs_weighting ) fig = _heatmap( corr_avg, labels=format_plot_labels(factor_names), title=title or "Time-Average Exposure Correlation", zmin=-1, zmax=1, ) _add_family_outlines(fig, self.factor_families, factor_indices) return fig
# Attribution
[docs] def predicted_attribution( self, weights: ArrayLike, annualization_factor: float = 252.0, compute_asset_breakdowns: bool = True, ) -> Attribution: r"""Compute ex-ante (predicted) factor volatility and return attribution. Decomposes portfolio volatility using the exposure-volatility-correlation framework (:math:`x`-:math:`\sigma`-:math:`\rho`) and, when `factor_mu` is available, decomposes expected return into factor-spanned and factor-orthogonal components. See :func:`~skfolio.attribution.predicted_factor_attribution` for the full mathematical description. Parameters ---------- weights : array-like of shape (n_assets,) Portfolio weights vector. annualization_factor : float, default=252.0 Annualization factor applied to variances and expected returns (volatilities are scaled by :math:`\sqrt{\text{annualization\_factor}}`). Use 1.0 to disable annualization. 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. """ return predicted_factor_attribution( weights=weights, loading_matrix=self.loading_matrix, factor_covariance=self.factor_covariance, idio_covariance=self.idio_covariance, asset_names=self.asset_names, factor_names=self.factor_names, factor_families=self.factor_families, factor_mu=self.factor_mu, idio_mu=self.idio_mu, annualization_factor=annualization_factor, compute_asset_breakdowns=compute_asset_breakdowns, )
[docs] def realized_attribution( self, weights: ArrayLike, portfolio_returns: ArrayLike, annualization_factor: float = 252.0, compute_asset_breakdowns: bool = True, compute_uncertainty: bool = True, ) -> Attribution: r"""Compute realized (ex-post) factor volatility and return attribution. Decomposes realized portfolio risk and return into contributions from individual factors and idiosyncratic sources using actual historical data rather than model-predicted covariances. See :func:`~skfolio.attribution.realized_factor_attribution` for the full mathematical description. Parameters ---------- weights : array-like of shape (n_assets,) or (n_observations, n_assets) Portfolio weights. If 1D, the same weights are used for all observations. If 2D, time-varying weights are used. portfolio_returns : array-like of shape (n_observations,) Portfolio return time series. annualization_factor : float, default=252.0 Annualization factor applied to variances and mean returns (volatilities are scaled by :math:`\sqrt{\text{annualization\_factor}}`). Use 1.0 to disable annualization. compute_asset_breakdowns : bool, default=True If `True`, compute per-asset attribution breakdowns. 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/idiosyncratic PnL split). Requires both `regression_weights` and `idio_variances` to be available in this factor model; raises `ValueError` otherwise. Returns ------- attribution : Attribution Component-level, factor-level, and optionally asset-level attribution results. Raises ------ ValueError If `factor_returns`, `exposures`, or `idio_returns` is not available, or if `compute_uncertainty=True` but `regression_weights` or `idio_variances` is missing. """ regression_weights, idio_variances = self._attribution_inputs( "realized_attribution", compute_uncertainty ) return realized_factor_attribution( factor_returns=self.factor_returns, portfolio_returns=portfolio_returns, exposures=self.exposures, weights=weights, idio_returns=self.idio_returns, asset_names=self.asset_names, factor_names=self.factor_names, factor_families=self.factor_families, annualization_factor=annualization_factor, compute_asset_breakdowns=compute_asset_breakdowns, exposure_lag=self.exposure_lag, regression_weights=regression_weights, idio_variances=idio_variances, compute_uncertainty=regression_weights is not None, family_constraint_basis=self.family_constraint_basis, )
[docs] def rolling_realized_attribution( self, weights: ArrayLike, portfolio_returns: ArrayLike, annualization_factor: float = 252.0, window_size: int = 60, step: int = 21, compute_asset_breakdowns: bool = True, compute_asset_factor_contribs: bool = False, compute_uncertainty: bool = True, ) -> Attribution: r"""Compute rolling realized (ex-post) factor attribution. Runs :func:`~skfolio.attribution.rolling_realized_factor_attribution` over rolling windows of the factor model's time-varying data. See :func:`~skfolio.attribution.rolling_realized_factor_attribution` for the full mathematical description. Parameters ---------- weights : array-like of shape (n_assets,) or (n_observations, n_assets) Portfolio weights. If 1D, the same weights are used for all observations. If 2D, time-varying weights are used. portfolio_returns : array-like of shape (n_observations,) Portfolio return time series. annualization_factor : float, default=252.0 Annualization factor applied to variances and mean returns (volatilities are scaled by :math:`\sqrt{\text{annualization\_factor}}`). Use 1.0 to disable annualization. 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 breakdowns for each window. compute_asset_factor_contribs : bool, default=False If `True`, compute asset-by-factor contributions for each window. compute_uncertainty : bool, default=True If `True`, compute per-window attribution uncertainty (standard errors on the factor/idiosyncratic PnL split). Requires both `regression_weights` and `idio_variances` to be available in this factor model; raises `ValueError` otherwise. Returns ------- attribution : Attribution Rolling attribution results with an additional leading dimension for the number of windows. Raises ------ ValueError If `factor_returns`, `exposures`, or `idio_returns` are not available, or if `window_size` exceeds `n_observations`. """ regression_weights, idio_variances = self._attribution_inputs( "rolling_realized_attribution", compute_uncertainty ) return rolling_realized_factor_attribution( factor_returns=self.factor_returns, portfolio_returns=portfolio_returns, exposures=self.exposures, weights=weights, idio_returns=self.idio_returns, factor_names=self.factor_names, asset_names=self.asset_names, observations=self.observations, factor_families=self.factor_families, annualization_factor=annualization_factor, window_size=window_size, step=step, compute_asset_breakdowns=compute_asset_breakdowns, compute_asset_factor_contribs=compute_asset_factor_contribs, exposure_lag=self.exposure_lag, regression_weights=regression_weights, idio_variances=idio_variances, compute_uncertainty=regression_weights is not None, family_constraint_basis=self.family_constraint_basis, )
# Private helpers @cached_property def _regression_data(self) -> _RegressionData: """Lag-aligned data for cross-sectional regression diagnostics.""" self._require( ("exposures", "factor_returns", "idio_returns"), "_regression_data" ) ( lagged_exposures, factor_returns, idio_returns, regression_weights, ) = self._aligned( ["exposures", "factor_returns", "idio_returns", "regression_weights"] ) family_basis = self.family_constraint_basis if family_basis is not None: exposure_basis = ( family_basis[: -self.exposure_lag] if self.exposure_lag > 0 else family_basis ) lagged_exposures = exposure_basis.reduce_exposures(lagged_exposures) factor_returns = family_basis.reduce_factor_returns(factor_returns) factor_names = self._reduced_factor_names regression_factor_mask = ~self._reduced_factor_is_currency else: factor_names = self.factor_names regression_factor_mask = ~self._factor_is_currency if not np.all(regression_factor_mask): lagged_exposures = lagged_exposures[:, :, regression_factor_mask] factor_returns = factor_returns[:, regression_factor_mask] factor_names = factor_names[regression_factor_mask] if len(factor_names) == 0: raise ValueError( "No cross-sectional regression factors are available. Currency factors " "are direct factors and do not have regression diagnostics." ) return _RegressionData( exposures=lagged_exposures, factor_returns=factor_returns, idio_returns=idio_returns, regression_weights=regression_weights, factor_names=factor_names, ) @cached_property def _gram_diagnostics(self) -> _GramDiagnostics: r"""Per-observation t-statistics, VIF, and condition number. When a :attr:`family_constraint_basis` is present, the design matrix and factor returns are projected into the reduced (full-rank) basis before building the Gram matrix. This avoids the rank-deficient :math:`X^\top W X` that arises from collinear constrained families (e.g. industry dummies). The three outputs share a single weighted least-squares pass and are memoised together so any downstream property pays the cost only once. """ self._require( ("exposures", "factor_returns", "idio_returns"), "_gram_diagnostics" ) regression_data = self._regression_data lagged_exposures = regression_data.exposures factor_returns = regression_data.factor_returns idio_returns = regression_data.idio_returns regression_weights = regression_data.regression_weights n_observations, _, n_factors = lagged_exposures.shape finite_mask = np.isfinite(idio_returns) & np.all( np.isfinite(lagged_exposures), axis=2 ) estimation_mask, estimation_weights = self._estimation_mask_and_weights( finite_mask, regression_weights ) n_valid = estimation_mask.sum(axis=1) idio_returns = np.where(estimation_mask, idio_returns, 0.0) rss = (estimation_weights * idio_returns**2).sum(axis=1) weighted_exposures = np.where(estimation_mask[..., None], lagged_exposures, 0.0) weighted_exposures *= np.sqrt(estimation_weights)[..., None] gram_matrices = weighted_exposures.transpose(0, 2, 1) @ weighted_exposures degrees_of_freedom = (n_valid - n_factors).astype(float) valid_design = degrees_of_freedom > 0 valid_t_stats = valid_design & np.all(np.isfinite(factor_returns), axis=1) t_stats = np.full((n_observations, n_factors), np.nan) vif = np.full((n_observations, n_factors), np.nan) condition_numbers = np.full(n_observations, np.nan) if np.any(valid_design): gram_matrices = gram_matrices[valid_design] identity = np.broadcast_to(np.eye(n_factors), gram_matrices.shape).copy() try: gram_inverse = np.linalg.solve(gram_matrices, identity) except np.linalg.LinAlgError: gram_inverse = np.linalg.pinv(gram_matrices) gram_diagonal = np.einsum("tii->ti", gram_matrices, optimize=True) gram_inverse_diagonal = np.einsum("tii->ti", gram_inverse, optimize=True) vif[valid_design] = gram_diagonal * gram_inverse_diagonal condition_numbers[valid_design] = np.linalg.cond(gram_matrices) if np.any(valid_t_stats): t_stats_mask = valid_t_stats[valid_design] residual_variance = ( rss[valid_t_stats] / degrees_of_freedom[valid_t_stats] ) standard_error = np.sqrt( np.maximum( residual_variance[:, None] * gram_inverse_diagonal[t_stats_mask], 0.0, ) ) with np.errstate(divide="ignore", invalid="ignore"): t_stats[valid_t_stats] = np.where( standard_error > 0, factor_returns[valid_t_stats] / standard_error, np.nan, ) return _GramDiagnostics( t_stats=t_stats, vif=vif, condition_number=condition_numbers ) @property def _reduced_factor_names(self) -> StrArray: """Factor names aligned with reduced-basis computations. Returns the reduced-basis names when a family-constraint basis is present, otherwise the full factor names. """ family_basis = self.family_constraint_basis if family_basis is not None: return family_basis.reduced_factor_names(self.factor_names) return self.factor_names @property def _factor_is_currency(self) -> BoolArray: """Mask of direct currency factors in the full factor basis.""" if self.factor_families is None: return np.zeros(len(self.factor_names), dtype=bool) return self.factor_families == _CURRENCY @property def _reduced_factor_families(self) -> StrArray | None: """Factor families aligned with reduced-basis computations.""" if self.factor_families is None: return None family_basis = self.family_constraint_basis if family_basis is not None: return family_basis.reduced_factor_names(self.factor_families) return self.factor_families @property def _reduced_factor_is_currency(self) -> BoolArray: """Mask of direct currency factors in the reduced factor basis.""" factor_families = self._reduced_factor_families if factor_families is None: return np.zeros(len(self._reduced_factor_names), dtype=bool) return factor_families == _CURRENCY @property def _reduced_regression_factor_names(self) -> StrArray: """Reduced-basis factor names estimated by cross-sectional regression.""" return self._reduced_factor_names[~self._reduced_factor_is_currency] @property def _n_regressors(self) -> int: """Effective number of independent regressors.""" return len(self._reduced_regression_factor_names) def _resolve_cs_weighting( self, cs_weighting: CSWeighting, *, latest: bool, fallback_cs_weighting: CSWeighting | None = None, ) -> FloatArray | None: """Return cross-sectional weights, or `None` for equal weights.""" if not isinstance(cs_weighting, CSWeighting): raise TypeError("`cs_weighting` must be a `CSWeighting`.") if fallback_cs_weighting is not None and not isinstance( fallback_cs_weighting, CSWeighting ): raise TypeError("`fallback_cs_weighting` must be a `CSWeighting`.") if fallback_cs_weighting == cs_weighting: fallback_cs_weighting = None if cs_weighting == CSWeighting.IDENTITY: return None if cs_weighting == CSWeighting.BENCHMARK: weights = self.benchmark_weights name = "benchmark_weights" elif cs_weighting == CSWeighting.REGRESSION: weights = self.regression_weights name = "regression_weights" else: if latest: if self.idio_covariance.ndim == 1: idio_variance = self.idio_covariance else: idio_variance = np.diag(self.idio_covariance) else: idio_variance = self.idio_variances if idio_variance is None: raise ValueError( "`cs_weighting=CSWeighting.INVERSE_IDIO_VARIANCE` with " "`latest=False` requires `idio_variances`." ) if np.any(idio_variance <= 0): raise ValueError( "Idiosyncratic variances must be positive for " "`cs_weighting=CSWeighting.INVERSE_IDIO_VARIANCE`." ) weights = 1.0 / idio_variance name = "idio_covariance" if latest else "idio_variances" if weights is None: if fallback_cs_weighting is not None: warnings.warn( f"`cs_weighting=CSWeighting.{cs_weighting.name}` requires " f"`{name}`, which is not available. Falling back to " f"`CSWeighting.{fallback_cs_weighting.name}`.", stacklevel=2, ) return self._resolve_cs_weighting(fallback_cs_weighting, latest=latest) raise ValueError( f"`cs_weighting=CSWeighting.{cs_weighting.name}` requires `{name}`." ) weights = np.asarray(weights, dtype=float) if latest and weights.ndim == 2: weights = weights[-1] elif not latest and weights.ndim == 1: weights = np.broadcast_to( weights, (len(self.observations), len(self.asset_names)) ) return weights def _ic( self, correlation_method: CorrelationMethod = CorrelationMethod.SPEARMAN, horizon: int = 1, factor_indices: slice | list[int] = slice(None), reduced_basis: bool = False, ) -> tuple[FloatArray, int]: r"""Predictive Information Coefficient per factor over time. Computes the cross-sectional correlation between exposures at :math:`t` and the cumulative asset return from :math:`t + 1` to :math:`t + h`, where :math:`h` is the forecast *horizon*. Parameters ---------- correlation_method : CorrelationMethod, default=CorrelationMethod.SPEARMAN Correlation method used for the exposure IC. `SPEARMAN` computes Spearman rank IC. `PEARSON` computes Pearson IC, weighted by `regression_weights` when available. horizon : int, default=1 Forward window in number of observations. The cumulative return from :math:`t + 1` to :math:`t + h` is used. factor_indices : slice or list of int, default=slice(None) Factor columns to evaluate. Use `slice(None)` to compute IC for all factors without copying the exposure tensor. reduced_basis : bool, default=False If `True`, compute ICs on reduced-basis exposures when a `family_constraint_basis` is present. Returns ------- ic : ndarray of shape (n_pairs, n_factors) obs_offset : int Starting index into `self.observations` for the IC values. `ic[i]` corresponds to `observations[obs_offset + i]`. """ _check_correlation_method(correlation_method) self._require(("exposures", "factor_returns", "idio_returns"), "_ic") if horizon < 1: raise ValueError("`horizon` must be >= 1.") lagged_exposures, factor_returns, idio_returns, _ = self._aligned( ["exposures", "factor_returns", "idio_returns", "regression_weights"] ) systematic_returns = ( lagged_exposures @ factor_returns[:, :, np.newaxis] ).squeeze(-1) asset_returns = systematic_returns + idio_returns n_observations = self.exposures.shape[0] start_t = max(0, self.exposure_lag - 1) n_pairs = n_observations - horizon - start_t if n_pairs < 1: raise ValueError( f"Not enough observations ({n_observations}) for horizon={horizon} " f"with exposure_lag={self.exposure_lag}." ) forward_returns = _forward_mean_return( asset_returns, horizon=horizon, lag=max(1 - self.exposure_lag, 0) )[:n_pairs] exposures = self.exposures if reduced_basis and self.family_constraint_basis is not None: exposures = self.family_constraint_basis.reduce_exposures(exposures) exposure_window = exposures[start_t : start_t + n_pairs, :, factor_indices] if correlation_method is CorrelationMethod.SPEARMAN: ic = cs_spearman_correlation( forward_returns[:, :, np.newaxis], exposure_window, axis=1, ) else: regression_weights = self.regression_weights if regression_weights is not None: regression_weights = regression_weights[start_t : start_t + n_pairs] ic = cs_pearson_correlation( forward_returns, exposure_window, weights=regression_weights, axis=1, ) return ic, start_t def _exposure_stability( self, exposures: FloatArray, step: int = 21, cs_weighting: CSWeighting = CSWeighting.BENCHMARK, ) -> FloatArray: r"""Weighted cross-sectional correlation of exposures between observation :math:`t` and :math:`t + \text{step}`. Parameters ---------- exposures : ndarray of shape (n_observations, n_assets, n_selected_factors) Subset of exposures already sliced to the desired factors. step : int, default=21 Number of observations between the two cross-sections. cs_weighting : CSWeighting, default=CSWeighting.BENCHMARK Cross-sectional weights passed to :func:`cs_pearson_correlation`. Falls back to `CSWeighting.IDENTITY` with a warning when unavailable. Returns ------- stability : ndarray of shape (n_observations - step, n_selected_factors) """ weights = self._resolve_cs_weighting( cs_weighting, latest=False, fallback_cs_weighting=CSWeighting.IDENTITY ) if weights is not None: weights = weights[:-step] return cs_pearson_correlation( exposures[:-step], exposures[step:], weights=weights, axis=1 ) def _attribution_inputs( self, name: str, compute_uncertainty: bool ) -> tuple[FloatArray | None, FloatArray | None]: """Validate inputs for realized/rolling attribution.""" self._require(("factor_returns", "exposures", "idio_returns"), name) if not compute_uncertainty: return None, None if self.regression_weights is None or self.idio_variances is None: raise ValueError( "`compute_uncertainty=True` requires both " "`regression_weights` and `idio_variances` to be " "available in the factor model." ) return self.regression_weights, self.idio_variances def _require(self, fields: str | list[str] | tuple[str, ...], name: str) -> None: """Validate that one or more `FactorModel` attributes are populated.""" if isinstance(fields, str): fields = (fields,) missing = [f for f in fields if getattr(self, f) is None] if not missing: return joined = ", ".join(f"`{f}`" for f in missing) raise ValueError( f"`{name}` requires {joined} which is not available in this " f"FactorModel. The prior estimator used to fit this model does " f"not populate {joined}. Check that your estimator supports " f"these attributes." ) def _aligned( self, fields: str | list[str] ) -> AnyArray | list[AnyArray | None] | None: r"""Apply `exposure_lag` to one or several time-indexed fields. The `exposures` field is the predictor side of the cross-sectional regression and is trimmed at the tail. Return-like fields are trimmed at the head so that predetermined exposures :math:`B_{t-\ell}` align row-wise with returns at :math:`t`. With `exposure_lag = 0` the underlying array is returned unchanged. Returns `None` when the requested attribute is `None`. When a list of field names is provided, the result is a tuple matching the requested order. """ lag = self.exposure_lag def align(field: str) -> AnyArray | None: arr = getattr(self, field) if arr is None: return None if lag == 0: return arr return arr[:-lag] if field == "exposures" else arr[lag:] if isinstance(fields, str): return align(fields) return tuple(align(field) for field in fields) @staticmethod def _estimation_mask_and_weights( finite_mask: BoolArray, regression_weights: FloatArray | None ) -> tuple[BoolArray, FloatArray]: """Combine a finite-data mask with optional regression weights. When `regression_weights` is `None`, every finite observation gets unit weight. Otherwise, observations are kept only where the weight is strictly positive and zeroed out elsewhere. """ if regression_weights is None: return finite_mask, finite_mask.astype(float) estimation_mask = finite_mask & (regression_weights > 0) estimation_weights = np.where(estimation_mask, regression_weights, 0.0) return estimation_mask, estimation_weights def _resolve_factor_subset( self, factor_names_to_keep: list[str] | None, family_names_to_keep: str | list[str] | None, reduced_basis: bool = False, regression_only: bool = False, ) -> tuple[slice | list[int], StrArray | list[str]]: """Resolve a factor subset from explicit names or family labels. Parameters ---------- factor_names_to_keep : list of str or None Explicit factor names to keep. family_names_to_keep : str, list of str, or None Family labels to keep. Ignored when `factor_names_to_keep` is given. reduced_basis : bool, default=False Use True for diagnostics computed in the reduced basis. regression_only : bool, default=False Restrict available factors to factors estimated by cross-sectional regression, excluding direct currency factors. Returns ------- indices : slice or list of int Column selector into *factor_names*. The unfiltered case returns `slice(None)` so callers can index arrays without copying. names : list of str Selected factor names in the same order as `indices`. """ factor_names = ( self._reduced_factor_names if reduced_basis else self.factor_names ) factor_families = ( self._reduced_factor_families if reduced_basis else self.factor_families ) if regression_only: currency_mask = ( self._reduced_factor_is_currency if reduced_basis else self._factor_is_currency ) factor_names = factor_names[~currency_mask] if factor_families is not None: factor_families = factor_families[~currency_mask] return _resolve_factor_subset( factor_names=factor_names, factor_families=factor_families, factor_names_to_keep=factor_names_to_keep, family_names_to_keep=family_names_to_keep, ) def _standardized_idio_returns(self) -> FloatArray: r"""Compute :math:`z_{it} = \epsilon_{it} / \hat\sigma_{i,t}`.""" self._require(("idio_returns", "idio_variances"), "standardized_idio_returns") idio_vol = np.sqrt(np.maximum(self.idio_variances, 0.0)) return safe_divide(self.idio_returns, idio_vol, fill_value=np.nan) def _validate_weights(self, weights: FloatArray | None, name: str) -> None: """Return validated optional weights.""" if weights is None: return None expected_shape = (len(self.observations), len(self.asset_names)) if weights.shape != expected_shape: raise ValueError( f"`{name}` must have shape {expected_shape}, got {weights.shape}." ) if not np.all(np.isfinite(weights)): raise ValueError(f"`{name}` must contain only finite values.") if np.any(weights < 0): raise ValueError(f"`{name}` must be non-negative.")
class _GramDiagnostics(NamedTuple): """Per-observation Gram-matrix diagnostics.""" t_stats: FloatArray # (n_observations - exposure_lag, n_reduced_factors) vif: FloatArray # (n_observations - exposure_lag, n_reduced_factors) condition_number: FloatArray # (n_observations - exposure_lag,) class _RegressionData(NamedTuple): """Lag-aligned data used by cross-sectional regression diagnostics.""" exposures: FloatArray factor_returns: FloatArray idio_returns: FloatArray regression_weights: FloatArray | None factor_names: StrArray def _multi_line_plot(df: pd.DataFrame, title: str, yaxis_title: str) -> go.Figure: """Create a multi-line time series plot with a toggleable legend.""" colors = px.colors.qualitative.Plotly fig = go.Figure() for i, col in enumerate(df.columns): fig.add_trace( go.Scatter( x=df.index, y=df[col].values, mode="lines", name=format_plot_label(col), line=dict(color=colors[i % len(colors)], width=1.5), ) ) fig.update_layout( title=title, xaxis_title="Observation", yaxis_title=yaxis_title, ) return fig def _rolling_title( metric: str, window: int | None, *, context: str | None = None, ) -> str: """Format a default title for raw or rolling time-series plots.""" if window is None: return f"{metric} ({context})" if context is not None else metric suffix = ( f"{window} observations" if context is None else f"{context}, {window} observations" ) return f"Rolling {metric} ({suffix})" def _plot_single_ts( series: pd.Series, title: str, yaxis_title: str, *, window: int | None = None, show_raw: bool = False, show_mean: bool = True, ref_value: float | None = None, ref_label: str | None = None, mean_fmt: str = ".2f", tick_format: str | None = None, raw_trace_name: str | None = None, ) -> go.Figure: """Single time-series plot with optional rolling mean and reference lines. Parameters ---------- series : pd.Series Raw time series (DatetimeIndex). title : str Figure title. yaxis_title : str Y-axis label. window : int, optional If given, smooth with a rolling mean once a complete window is available. show_raw : bool, default=False When `True` and `window` is set, also plot the raw series as a faded line behind the smoothed one. The raw trace is added first (`fig.data[0]`) so callers and tests can rely on its position. show_mean : bool, default=True Whether to overlay a horizontal line at the full-sample mean with a side annotation. ref_value : float, optional Y-value for a dashed reference line (e.g. Gaussian expectation). ref_label : str, optional Annotation text for the reference line. mean_fmt : str, default=".2f" Format string for the mean annotation value. tick_format : str, optional Y-axis tick format (e.g. `".2%"`). raw_trace_name : str, optional Trace name used for the raw series when `show_raw` is `True`. """ raw = series.values.copy() smoothed = series.rolling(window=window).mean() if window is not None else series mean_val = np.nanmean(raw) fig = go.Figure() if show_raw and window is not None: fig.add_trace( go.Scatter( x=series.index, y=raw, mode="lines", name=raw_trace_name or series.name, line=dict(color="rgba(31, 119, 180, 0.35)", width=1), ) ) fig.add_trace( go.Scatter( x=smoothed.index, y=smoothed.values, mode="lines", name="Rolling Mean", line=dict(color="rgb(31, 119, 180)", width=2), ) ) else: fig.add_trace( go.Scatter( x=smoothed.index, y=smoothed.values, mode="lines", name=series.name, line=dict(color="rgb(31, 119, 180)", width=1.5), ) ) if ref_value is not None: fig.add_hline( y=ref_value, line_width=1, line_dash="dash", line_color="gray", ) if ref_label is not None: ref_yanchor = ( "middle" if not show_mean else ("bottom" if ref_value >= mean_val else "top") ) fig.add_annotation( xref="paper", yref="y", x=1.0, y=ref_value, text=ref_label, showarrow=False, xanchor="left", yanchor=ref_yanchor, xshift=8, ) if show_mean: fig.add_hline( y=mean_val, line_width=1, line_dash="dot", line_color="rgb(255, 127, 14)", ) mean_yanchor = ( "middle" if ref_value is None else ("bottom" if mean_val >= ref_value else "top") ) fig.add_annotation( xref="paper", yref="y", x=1.0, y=mean_val, text=f"Mean: {mean_val:{mean_fmt}}", showarrow=False, xanchor="left", yanchor=mean_yanchor, xshift=8, ) fig.update_layout( title=title, xaxis_title="Observation", yaxis_title=yaxis_title, margin=dict(r=120), ) if tick_format is not None: fig.update_yaxes(tickformat=tick_format) return fig def _heatmap( matrix: FloatArray, labels: list[str], title: str, zmin: float | None = None, zmax: float | None = None, ) -> go.Figure: """Create an annotated correlation-style heatmap.""" text = [ [f"{matrix[i, j]:.2f}" for j in range(len(labels))] for i in range(len(labels)) ] fig = go.Figure( go.Heatmap( z=matrix, x=labels, y=labels, text=text, texttemplate="%{text}", colorscale="RdBu_r", zmin=zmin, zmax=zmax, ) ) fig.update_layout( title=title, xaxis_title="", yaxis_title="", yaxis=dict(autorange="reversed"), ) return fig def _add_family_outlines( fig: go.Figure, families: StrArray | None, idx: slice | list[int], ) -> None: """Draw rectangles around contiguous family blocks on a heatmap.""" if families is None: return if idx == slice(None): fam_labels = [str(family) for family in families] else: fam_labels = [str(families[i]) for i in idx] unique_families = dict.fromkeys(fam_labels) if len(unique_families) <= 1: return blocks = [] start = 0 for end in range(1, len(fam_labels) + 1): if end == len(fam_labels) or fam_labels[end] != fam_labels[start]: blocks.append((start, end)) start = end if len(blocks) > len(unique_families): return for start, end in blocks: fig.add_shape( type="rect", x0=start - 0.5, y0=start - 0.5, x1=end - 0.5, y1=end - 0.5, line=dict(color="gold", width=2), ) def _cs_kurtosis(z: FloatArray) -> FloatArray: """Cross-sectional excess kurtosis per observation. Uses the bias-corrected (Fisher) estimator matching scipy defaults. Observations with fewer than 4 valid assets return NaN. Parameters ---------- z : ndarray of shape (n_observations, n_assets) Returns ------- kurtosis : ndarray of shape (n_observations,) """ n = np.sum(np.isfinite(z), axis=1).astype(float) with warnings.catch_warnings(): warnings.simplefilter("ignore", RuntimeWarning) d = z - np.nanmean(z, axis=1, keepdims=True) with np.errstate(divide="ignore", invalid="ignore"): m2 = np.nansum(d**2, axis=1) / n m4 = np.nansum(d**4, axis=1) / n raw_kurt = m4 / m2**2 - 3.0 kurt = np.full(z.shape[0], np.nan) ok = n >= 4 if np.any(ok): adj = (n[ok] - 1) / ((n[ok] - 2) * (n[ok] - 3)) kurt[ok] = ((n[ok] + 1) * raw_kurt[ok] + 6) * adj return kurt def _cs_skewness(z: FloatArray) -> FloatArray: """Cross-sectional skewness per observation. Uses the bias-corrected (Fisher) estimator matching scipy defaults. Observations with fewer than 3 valid assets return NaN. Parameters ---------- z : ndarray of shape (n_observations, n_assets) Returns ------- skewness : ndarray of shape (n_observations,) """ n = np.sum(np.isfinite(z), axis=1).astype(float) with warnings.catch_warnings(): warnings.simplefilter("ignore", RuntimeWarning) d = z - np.nanmean(z, axis=1, keepdims=True) with np.errstate(divide="ignore", invalid="ignore"): m2 = np.nansum(d**2, axis=1) / n m3 = np.nansum(d**3, axis=1) / n raw_skew = m3 / m2**1.5 skew = np.full(z.shape[0], np.nan) ok = n >= 3 if np.any(ok): skew[ok] = raw_skew[ok] * np.sqrt(n[ok] * (n[ok] - 1)) / (n[ok] - 2) return skew def _check_correlation_method(correlation_method: CorrelationMethod) -> None: """Check that `correlation_method` is a `CorrelationMethod`.""" if not isinstance(correlation_method, CorrelationMethod): raise TypeError("correlation_method must be a `CorrelationMethod`.") def _exceedance_agg(threshold: float): """Return an aggregation function for t-stat exceedance rate.""" def _agg(raw_t: FloatArray) -> FloatArray: significant = np.abs(raw_t) > threshold n_valid = np.sum(np.isfinite(raw_t), axis=0) return safe_divide(np.nansum(significant, axis=0), n_valid, fill_value=0.0) return _agg def _lag1_autocorr(x: FloatArray) -> FloatArray: """Column-wise lag-1 Pearson autocorrelation.""" a, b = x[:-1], x[1:] a = a - a.mean(0) b = b - b.mean(0) return (a * b).sum(0) / np.sqrt((a * a).sum(0) * (b * b).sum(0)) def _selector_to_positions( selector: ArrayLike | slice | None, labels: StrArray, *, axis_name: str ) -> IntArray: """Resolve an axis selector to positional indices. Parameters ---------- selector : array-like, slice or None Axis selector. Boolean arrays are interpreted as masks, integer arrays as positional selectors, slices as positional slices, and other arrays as labels matched against `labels`. Negative integer positions follow NumPy indexing rules. If `None`, all positions are kept. labels : ndarray of shape (n_labels,) Labels available on the selected axis. axis_name : str Axis name used in validation error messages. Returns ------- positions : ndarray of shape (n_selected,) Positional indices corresponding to `selector`. Raises ------ ValueError If `selector` is not one-dimensional, if a boolean mask has the wrong length, if an integer selector is out of bounds, or if a label is not present in `labels`. """ n_labels = len(labels) if selector is None: return np.arange(n_labels, dtype=np.intp) if isinstance(selector, slice): start, stop, step = selector.indices(n_labels) return np.arange(start, stop, step, dtype=np.intp) arr = np.asarray(selector) if arr.ndim == 0: arr = arr.reshape(1) if arr.ndim != 1: raise ValueError(f"`{axis_name}` must be a 1D selector.") if np.issubdtype(arr.dtype, np.bool_): if arr.shape[0] != n_labels: raise ValueError( f"Boolean `{axis_name}` selector must have length {n_labels}, " f"got {arr.shape[0]}." ) return np.flatnonzero(arr).astype(np.intp, copy=False) if np.issubdtype(arr.dtype, np.integer): positions = arr.astype(np.intp, copy=False) if np.any((positions < -n_labels) | (positions >= n_labels)): raise ValueError( f"Integer `{axis_name}` selector contains out-of-bounds positions." ) if np.any(positions < 0): positions = positions.copy() positions[positions < 0] += n_labels return positions missing_sentinel = object() label_lookup = {label: idx for idx, label in enumerate(labels)} positions = np.empty(arr.shape[0], dtype=np.intp) missing = [] for target_index, label in enumerate(arr): position = label_lookup.get(label, missing_sentinel) if position is missing_sentinel: missing.append(label) else: positions[target_index] = position if missing: raise ValueError( f"{len(missing)} {axis_name} label(s) not found in FactorModel. " f"First five: {missing[:5]}" ) return positions def _positions_to_indexer(positions: IntArray) -> IntArray | slice: """Convert contiguous positional indices to an indexer. Parameters ---------- positions : ndarray of shape (n_selected,) Positional indices on a single axis. Returns ------- indexer : ndarray or slice A `slice` when `positions` is contiguous, otherwise the input integer positions. The slice form lets NumPy return views for contiguous selections. """ if len(positions) > 0 and (len(positions) == 1 or np.all(np.diff(positions) == 1)): return slice(int(positions[0]), int(positions[-1]) + 1) return positions