Skip to content

risk

Functions for measuring portfolio risk from multiple perspectives. These metrics quantify volatility, downside risk, tail risk, drawdown severity, distribution shape, and benchmark-relative sensitivity to help evaluate the uncertainty and stability of investment returns.

risk

Risk (dispersion / tail-risk) metrics.

rf, mar (minimum acceptable return), and similar rate parameters are annual rates throughout PortPy, de-annualized internally via geometric compounding (utils.validation.periodic_rate_from_annual). This differs from empyrical, whose equivalent parameters (risk_free, required_return) are already-periodic rates. See the validation tests for a worked comparison.

variance

variance(
    returns: Series, as_result: bool = False
) -> float | MetricResult

Sample variance of per-period returns (ddof=1, not annualized).

Parameters:

Name Type Description Default
returns Series

Per-period returns.

required
as_result bool

If True, return a MetricResult instead of a plain float.

False

Returns:

Type Description
float | MetricResult

The sample variance, in squared-return units.

Source code in src/portpy/metrics/risk.py
def variance(returns: pd.Series, as_result: bool = False) -> float | MetricResult:
    """
    Sample variance of per-period returns (ddof=1, not annualized).

    Args:
        returns: Per-period returns.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The sample variance, in squared-return units.
    """
    r = returns.dropna()
    ensure_min_observations(r, 2, "returns")
    value = float(r.var(ddof=1))
    return MetricResult(value, "variance") if as_result else value

volatility

volatility(
    returns: Series,
    annualized: bool = True,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    as_result: bool = False,
) -> float | MetricResult

Standard deviation of returns (ddof=1), annualized by sqrt(periods_per_year) by default.

Parameters:

Name Type Description Default
returns Series

Per-period returns.

required
annualized bool

If True (default), scale by sqrt(periods_per_year).

True
periods_per_year int

Number of periods in a year, used to annualize.

TRADING_DAYS_PER_YEAR
as_result bool

If True, return a MetricResult instead of a plain float.

False

Returns:

Type Description
float | MetricResult

The (optionally annualized) standard deviation of returns.

Source code in src/portpy/metrics/risk.py
def volatility(
    returns: pd.Series,
    annualized: bool = True,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    as_result: bool = False,
) -> float | MetricResult:
    """
    Standard deviation of returns (ddof=1), annualized by sqrt(periods_per_year) by default.

    Args:
        returns: Per-period returns.
        annualized: If True (default), scale by sqrt(periods_per_year).
        periods_per_year: Number of periods in a year, used to annualize.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The (optionally annualized) standard deviation of returns.
    """
    r = returns.dropna()
    ensure_min_observations(r, 2, "returns")
    value = float(r.std(ddof=1))
    if annualized:
        value *= np.sqrt(periods_per_year)
    return MetricResult(value, "volatility", unit="%/yr" if annualized else "%") if as_result else value

downside_deviation

downside_deviation(
    returns: Series,
    mar: float = DEFAULT_MAR,
    annualized: bool = True,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    as_result: bool = False,
) -> float | MetricResult

Root-mean-square of returns falling short of mar, dividing by the full sample size N.

Parameters:

Name Type Description Default
returns Series

Per-period returns.

required
mar float

Minimum acceptable return, as an annual rate. Defaults to DEFAULT_MAR.

DEFAULT_MAR
annualized bool

If True (default), scale by sqrt(periods_per_year).

True
periods_per_year int

Number of periods in a year, used to de-annualize mar and to annualize the result.

TRADING_DAYS_PER_YEAR
as_result bool

If True, return a MetricResult instead of a plain float.

False

Returns:

Type Description
float | MetricResult

The (optionally annualized) downside deviation.

Source code in src/portpy/metrics/risk.py
def downside_deviation(
    returns: pd.Series,
    mar: float = DEFAULT_MAR,
    annualized: bool = True,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    as_result: bool = False,
) -> float | MetricResult:
    """
    Root-mean-square of returns falling short of `mar`, dividing by the full sample size N.

    Args:
        returns: Per-period returns.
        mar: Minimum acceptable return, as an annual rate. Defaults to
            DEFAULT_MAR.
        annualized: If True (default), scale by sqrt(periods_per_year).
        periods_per_year: Number of periods in a year, used to de-annualize
            `mar` and to annualize the result.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The (optionally annualized) downside deviation.
    """
    r = returns.dropna()
    ensure_min_observations(r, 1, "returns")
    mar_period = periodic_rate_from_annual(mar, periods_per_year)
    shortfall = np.minimum(r - mar_period, 0.0)
    value = float(np.sqrt(np.mean(np.square(shortfall))))
    if annualized:
        value *= np.sqrt(periods_per_year)
    return MetricResult(value, "downside_deviation", unit="%/yr" if annualized else "%") if as_result else value

semi_variance

semi_variance(
    returns: Series,
    mar: float = DEFAULT_MAR,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    as_result: bool = False,
) -> float | MetricResult

Mean squared shortfall below mar, dividing by the full sample size N.

Parameters:

Name Type Description Default
returns Series

Per-period returns.

required
mar float

Minimum acceptable return, as an annual rate. Defaults to DEFAULT_MAR.

DEFAULT_MAR
periods_per_year int

Number of periods in a year, used to de-annualize mar.

TRADING_DAYS_PER_YEAR
as_result bool

If True, return a MetricResult instead of a plain float.

False

Returns:

Type Description
float | MetricResult

The mean squared shortfall, in squared-return units.

Source code in src/portpy/metrics/risk.py
def semi_variance(
    returns: pd.Series,
    mar: float = DEFAULT_MAR,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    as_result: bool = False,
) -> float | MetricResult:
    """
    Mean squared shortfall below `mar`, dividing by the full sample size N.

    Args:
        returns: Per-period returns.
        mar: Minimum acceptable return, as an annual rate. Defaults to
            DEFAULT_MAR.
        periods_per_year: Number of periods in a year, used to de-annualize `mar`.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The mean squared shortfall, in squared-return units.
    """
    r = returns.dropna()
    ensure_min_observations(r, 1, "returns")
    mar_period = periodic_rate_from_annual(mar, periods_per_year)
    shortfall = np.minimum(r - mar_period, 0.0)
    value = float(np.mean(np.square(shortfall)))
    return MetricResult(value, "semi_variance") if as_result else value

value_at_risk

value_at_risk(
    returns: Series,
    method: str = "historical",
    confidence: float = DEFAULT_CONFIDENCE_LEVEL,
    as_result: bool = False,
) -> float | MetricResult

Per-period loss threshold exceeded only (1 - confidence) of the time.

Reported in return units and signed like a return (a negative number means a loss); e.g. -0.03 at 95% confidence means "there's a 5% chance of losing more than 3% in a single period."

Parameters:

Name Type Description Default
returns Series

Per-period returns.

required
method str

One of "historical" (empirical percentile), "parametric" (assumes Normally-distributed returns), or "cornish_fisher" (parametric VaR adjusted for the sample's skewness and kurtosis).

'historical'
confidence float

Confidence level in (0, 1), e.g. 0.95. Defaults to DEFAULT_CONFIDENCE_LEVEL.

DEFAULT_CONFIDENCE_LEVEL
as_result bool

If True, return a MetricResult instead of a plain float.

False

Returns:

Type Description
float | MetricResult

The per-period VaR at the given confidence level.

Raises:

Type Description
ValueError

If method is not one of the recognized methods.

Source code in src/portpy/metrics/risk.py
def value_at_risk(
    returns: pd.Series,
    method: str = "historical",
    confidence: float = DEFAULT_CONFIDENCE_LEVEL,
    as_result: bool = False,
) -> float | MetricResult:
    """
    Per-period loss threshold exceeded only (1 - confidence) of the time.

    Reported in return units and signed like a return (a negative number
    means a loss); e.g. -0.03 at 95% confidence means "there's a 5% chance of
    losing more than 3% in a single period."

    Args:
        returns: Per-period returns.
        method: One of "historical" (empirical percentile), "parametric"
            (assumes Normally-distributed returns), or "cornish_fisher"
            (parametric VaR adjusted for the sample's skewness and kurtosis).
        confidence: Confidence level in (0, 1), e.g. 0.95. Defaults to
            DEFAULT_CONFIDENCE_LEVEL.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The per-period VaR at the given confidence level.

    Raises:
        ValueError: If `method` is not one of the recognized methods.
    """
    validate_confidence(confidence)
    r = returns.dropna()
    ensure_min_observations(r, 2, "returns")
    alpha = 1.0 - confidence

    if method == "historical":
        value = float(np.percentile(r, 100 * alpha))
    elif method == "parametric":
        z = scipy_stats.norm.ppf(alpha)
        value = float(r.mean() + z * r.std(ddof=1))
    elif method == "cornish_fisher":
        z = scipy_stats.norm.ppf(alpha)
        s = scipy_stats.skew(r)
        k = scipy_stats.kurtosis(r)  # excess kurtosis
        z_cf = (
            z
            + (z**2 - 1) * s / 6
            + (z**3 - 3 * z) * k / 24
            - (2 * z**3 - 5 * z) * s**2 / 36
        )
        value = float(r.mean() + z_cf * r.std(ddof=1))
    else:
        raise ValueError(f"method must be one of {_VAR_METHODS}, got {method!r}.")

    return MetricResult(value, "value_at_risk", unit="%") if as_result else value

conditional_var

conditional_var(
    returns: Series,
    confidence: float = DEFAULT_CONFIDENCE_LEVEL,
    as_result: bool = False,
) -> float | MetricResult

Conditional VaR / Expected Shortfall: the average loss in the worst (1 - confidence) fraction of periods.

Always at least as severe as value_at_risk at the same confidence. It answers "given that we're in the bad tail, how bad is it on average?" rather than just where the tail starts.

Parameters:

Name Type Description Default
returns Series

Per-period returns.

required
confidence float

Confidence level in (0, 1), e.g. 0.95. Defaults to DEFAULT_CONFIDENCE_LEVEL.

DEFAULT_CONFIDENCE_LEVEL
as_result bool

If True, return a MetricResult instead of a plain float.

False

Returns:

Type Description
float | MetricResult

The average per-period loss in the tail beyond the VaR cutoff.

Source code in src/portpy/metrics/risk.py
def conditional_var(
    returns: pd.Series,
    confidence: float = DEFAULT_CONFIDENCE_LEVEL,
    as_result: bool = False,
) -> float | MetricResult:
    """
    Conditional VaR / Expected Shortfall: the average loss in the worst
    (1 - confidence) fraction of periods.

    Always at least as severe as value_at_risk at the same confidence. It
    answers "given that we're in the bad tail, how bad is it on average?"
    rather than just where the tail starts.

    Args:
        returns: Per-period returns.
        confidence: Confidence level in (0, 1), e.g. 0.95. Defaults to
            DEFAULT_CONFIDENCE_LEVEL.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The average per-period loss in the tail beyond the VaR cutoff.
    """
    validate_confidence(confidence)
    r = returns.dropna().to_numpy()
    ensure_min_observations(r, 2, "returns")
    alpha = 1.0 - confidence
    cutoff_index = int((len(r) - 1) * alpha)
    worst = np.partition(r, cutoff_index)[: cutoff_index + 1]
    value = float(np.mean(worst))
    return MetricResult(value, "conditional_var", unit="%") if as_result else value

tail_ratio

tail_ratio(
    returns: Series, as_result: bool = False
) -> float | MetricResult

Ratio of the size of the right tail (95th percentile) to the left tail (5th percentile).

Parameters:

Name Type Description Default
returns Series

Per-period returns.

required
as_result bool

If True, return a MetricResult instead of a plain float.

False

Returns:

Type Description
float | MetricResult

The tail ratio.

Source code in src/portpy/metrics/risk.py
def tail_ratio(returns: pd.Series, as_result: bool = False) -> float | MetricResult:
    """
    Ratio of the size of the right tail (95th percentile) to the left tail (5th percentile).

    Args:
        returns: Per-period returns.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The tail ratio.
    """
    r = returns.dropna()
    ensure_min_observations(r, 2, "returns")
    value = float(np.abs(np.percentile(r, 95)) / np.abs(np.percentile(r, 5)))
    return MetricResult(value, "tail_ratio") if as_result else value

skewness

skewness(
    returns: Series, as_result: bool = False
) -> float | MetricResult

Sample skewness (adjusted Fisher-Pearson) of the return distribution.

Parameters:

Name Type Description Default
returns Series

Per-period returns.

required
as_result bool

If True, return a MetricResult instead of a plain float.

False

Returns:

Type Description
float | MetricResult

The sample skewness.

Source code in src/portpy/metrics/risk.py
def skewness(returns: pd.Series, as_result: bool = False) -> float | MetricResult:
    """
    Sample skewness (adjusted Fisher-Pearson) of the return distribution.

    Args:
        returns: Per-period returns.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The sample skewness.
    """
    r = returns.dropna()
    ensure_min_observations(r, 3, "returns")
    value = float(r.skew())
    return MetricResult(value, "skewness") if as_result else value

kurtosis

kurtosis(
    returns: Series, as_result: bool = False
) -> float | MetricResult

Sample excess kurtosis (0 = Normal-like tails; positive = fatter tails than Normal).

Parameters:

Name Type Description Default
returns Series

Per-period returns.

required
as_result bool

If True, return a MetricResult instead of a plain float.

False

Returns:

Type Description
float | MetricResult

The sample excess kurtosis.

Source code in src/portpy/metrics/risk.py
def kurtosis(returns: pd.Series, as_result: bool = False) -> float | MetricResult:
    """
    Sample excess kurtosis (0 = Normal-like tails; positive = fatter tails than Normal).

    Args:
        returns: Per-period returns.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The sample excess kurtosis.
    """
    r = returns.dropna()
    ensure_min_observations(r, 4, "returns")
    value = float(r.kurtosis())
    return MetricResult(value, "kurtosis") if as_result else value

ulcer_index

ulcer_index(
    prices: Series, as_result: bool = False
) -> float | MetricResult

Root-mean-square of percentage drawdowns. Penalizes both deep and prolonged drawdowns.

Parameters:

Name Type Description Default
prices Series

Price series.

required
as_result bool

If True, return a MetricResult instead of a plain float.

False

Returns:

Type Description
float | MetricResult

The ulcer index.

Source code in src/portpy/metrics/risk.py
def ulcer_index(prices: pd.Series, as_result: bool = False) -> float | MetricResult:
    """
    Root-mean-square of percentage drawdowns. Penalizes both deep and prolonged drawdowns.

    Args:
        prices: Price series.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The ulcer index.
    """
    from portpy.metrics.drawdowns import drawdown_series

    dd = drawdown_series(prices)
    value = float(np.sqrt(np.mean(np.square(dd))))
    return MetricResult(value, "ulcer_index") if as_result else value

pain_index

pain_index(
    prices: Series, as_result: bool = False
) -> float | MetricResult

Mean absolute drawdown across the whole period (a.k.a. average drawdown).

Parameters:

Name Type Description Default
prices Series

Price series.

required
as_result bool

If True, return a MetricResult instead of a plain float.

False

Returns:

Type Description
float | MetricResult

The pain index.

Source code in src/portpy/metrics/risk.py
def pain_index(prices: pd.Series, as_result: bool = False) -> float | MetricResult:
    """
    Mean absolute drawdown across the whole period (a.k.a. average drawdown).

    Args:
        prices: Price series.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The pain index.
    """
    from portpy.metrics.drawdowns import drawdown_series

    dd = drawdown_series(prices)
    value = float(np.mean(np.abs(dd)))
    return MetricResult(value, "pain_index") if as_result else value

beta

beta(
    returns: Series,
    benchmark: Series,
    as_result: bool = False,
) -> float | MetricResult

Sensitivity of returns to a benchmark.

Formula: Cov(r, b) / Var(b)

Parameters:

Name Type Description Default
returns Series

Per-period returns.

required
benchmark Series

Per-period benchmark returns.

required
as_result bool

If True, return a MetricResult instead of a plain float.

False

Returns:

Type Description
float | MetricResult

The beta coefficient.

Source code in src/portpy/metrics/risk.py
def beta(returns: pd.Series, benchmark: pd.Series, as_result: bool = False) -> float | MetricResult:
    """
    Sensitivity of returns to a benchmark.

    Formula: Cov(r, b) / Var(b)

    Args:
        returns: Per-period returns.
        benchmark: Per-period benchmark returns.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The beta coefficient.
    """
    r, b = align_pair(returns, benchmark)
    ensure_min_observations(r, 2, "returns")
    cov = float(np.cov(r, b, ddof=1)[0, 1])
    var_b = float(np.var(b, ddof=1))
    value = cov / var_b if var_b > 1e-30 else np.nan
    return MetricResult(value, "beta") if as_result else value

tracking_error

tracking_error(
    returns: Series,
    benchmark: Series,
    annualized: bool = True,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    as_result: bool = False,
) -> float | MetricResult

Standard deviation of the return difference (returns - benchmark), i.e. active risk.

Parameters:

Name Type Description Default
returns Series

Per-period returns.

required
benchmark Series

Per-period benchmark returns.

required
annualized bool

If True (default), scale by sqrt(periods_per_year).

True
periods_per_year int

Number of periods in a year, used to annualize.

TRADING_DAYS_PER_YEAR
as_result bool

If True, return a MetricResult instead of a plain float.

False

Returns:

Type Description
float | MetricResult

The (optionally annualized) tracking error.

Source code in src/portpy/metrics/risk.py
def tracking_error(
    returns: pd.Series,
    benchmark: pd.Series,
    annualized: bool = True,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    as_result: bool = False,
) -> float | MetricResult:
    """
    Standard deviation of the return difference (returns - benchmark), i.e. active risk.

    Args:
        returns: Per-period returns.
        benchmark: Per-period benchmark returns.
        annualized: If True (default), scale by sqrt(periods_per_year).
        periods_per_year: Number of periods in a year, used to annualize.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The (optionally annualized) tracking error.
    """
    r, b = align_pair(returns, benchmark)
    ensure_min_observations(r, 2, "returns")
    diff = r - b
    value = float(diff.std(ddof=1))
    if annualized:
        value *= np.sqrt(periods_per_year)
    return MetricResult(value, "tracking_error") if as_result else value