Source code for skfolio.descriptor._volatility._ew_residual_downside_volatility
"""Exponentially weighted downside CAPM residual volatility descriptors."""
# Copyright (c) 2023-2026
# Author: Hugo Delatte <hugo.delatte@skfoliolabs.com>
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
from skfolio.descriptor._volatility._base import _BaseEWResidualVolatility
[docs]
class EWResidualDownsideVolatility(_BaseEWResidualVolatility):
r"""Exponentially weighted downside CAPM residual volatility descriptor.
Computes downside volatility of CAPM residuals with an EWMA variance estimate.
Only residuals below the `min_acceptable_return` threshold contribute:
.. math::
:nowrap:
\[
\begin{aligned}
\epsilon_i(t)
&= r_i(t) - \hat\beta_i(t) \cdot r_m(t) \\[0.75em]
D_i(t)
&= \min(\epsilon_i(t) - \text{mar},\; 0) \\[0.75em]
S_{\text{down},i}(t)
&= \lambda_v \cdot S_{\text{down},i}(t-1)
+ (1 - \lambda_v) \cdot D_i(t)^2 \\[0.75em]
\text{output}_i(t)
&= \sqrt{\frac{S_{\text{down},i}(t)}
{1 - \lambda_v^{n_i(t)}}}
\end{aligned}
\]
where :math:`\hat\beta_i(t)` is the EWMA beta estimated with decay
:math:`\lambda_\beta = \exp(-\ln(2)/\text{beta\_half\_life})`,
:math:`\lambda_v = \exp(-\ln(2)/\text{half\_life})` and :math:`n_i(t)` is the
number of valid returns for asset :math:`i`. The zero-initialized residual variance
accumulator is bias-corrected at output time using each asset's valid observation
count.
This measures stock-specific downside risk after removing market exposure.
Parameters
----------
half_life : float, default=40.0
EWMA half-life in observations for the residual variance estimator.
beta_half_life : float, default=60.0
EWMA half-life in observations for the beta estimator.
min_acceptable_return : float, default=0.0
Threshold below which residuals are considered "downside". The default of `0.0`
defines downside as negative residuals (losses after removing market exposure).
min_periods : int, optional
Minimum number of valid returns required for each asset. Until an asset
reaches this count, its output is NaN. This warm-up period avoids exposing
early EWMA values before the downside residual volatility estimate has
sufficiently converged from its zero initialization. If `None`, defaults to
:math:`\lceil\max(\text{half\_life}, \text{beta\_half\_life})\rceil`, with a
minimum of 1.
eps : float, default=1e-12
Small constant for numerical stability in :math:`1 / \text{Var}(r_m)` when
computing beta.
Attributes
----------
n_assets_ : int
Number of assets seen during fitting.
asset_names_ : ndarray of shape (n_assets,)
Asset names seen during fitting.
residual_volatility_ : ndarray of shape (n_assets,)
Last computed EWMA downside residual volatility. Contains NaN for inactive
assets and assets that have not reached `min_periods` valid returns.
Notes
-----
NaNs are treated as missing observations. Active assets with missing returns
keep their previous asset-specific EWMA state; inactive assets output NaN and
restart their warm-up period when they become active again. Non-missing returns
must be finite.
Market returns are computed from the estimation universe (`estimation_mask` of
:class:`~skfolio.containers.AssetPanel`). If no estimable asset has both finite
returns and finite `market_cap` at an observation, the market return is undefined
and a `ValueError` is raised.
See Also
--------
EWResidualVolatility : Total (non-downside) variant.
Examples
--------
>>> from skfolio.datasets import make_synthetic_characteristics
>>> from skfolio.descriptor import EWResidualDownsideVolatility
>>>
>>> X = make_synthetic_characteristics()
>>>
>>> # Downside residual volatility (losses only)
>>> descriptor = EWResidualDownsideVolatility()
>>> residual_downside_volatility = descriptor.fit_transform(X)
>>>
>>> # Custom threshold
>>> descriptor = EWResidualDownsideVolatility(min_acceptable_return=-0.01)
>>> residual_downside_volatility = descriptor.fit_transform(X)
"""
def __init__(
self,
half_life: float = 40.0,
beta_half_life: float = 60.0,
min_acceptable_return: float = 0.0,
min_periods: int | None = None,
eps: float = 1e-12,
):
super().__init__(
half_life=half_life,
beta_half_life=beta_half_life,
min_acceptable_return=min_acceptable_return,
min_periods=min_periods,
eps=eps,
)