Coverage for src/jquantstats/_stats/_concentration.py: 100%
18 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-06 04:52 +0000
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-06 04:52 +0000
1"""Concentration metrics for the signed distribution of returns.
3Split out of :mod:`jquantstats._stats._performance`, which had grown to 714 lines
4across four unrelated concerns. This module owns the Herfindahl-Hirschman Index
5applied to returns; the risk-adjusted-ratio family stays in ``_performance`` and
6the benchmark-relative metrics live in ``_benchmark``.
7"""
9from __future__ import annotations
11import polars as pl
13from ._core import columnwise_stat
15# ── Concentration metrics (HHI) mixin ────────────────────────────────────────
18class _ConcentrationStatsMixin:
19 """Mixin providing temporal concentration metrics for gains and losses.
21 ``hhi_positive`` and ``hhi_negative`` apply the Herfindahl-Hirschman Index to
22 the signed distribution of returns, measuring *temporal* concentration of
23 gains and losses respectively — a value near 0 means returns are spread evenly
24 across periods; a value near 1 means a single period dominates.
26 **Intentionally public, optional use.** These are not included in
27 ``summary()`` by default because they are supplemental diagnostics rather than
28 standard risk-adjusted-return measures, but they are fully supported as part
29 of the public ``Stats`` API.
31 Both metrics depend only on the passed series — no benchmark, no cross-mixin
32 state.
33 """
35 @columnwise_stat
36 def hhi_positive(self, series: pl.Series) -> float:
37 r"""Calculate the Herfindahl-Hirschman Index (HHI) for positive returns.
39 This quantifies how concentrated the positive returns are in a series.
41 .. math::
42 w^{\plus} = \frac{r_{t}^{\plus}}{\sum{r_{t}^{\plus}}} \\
43 HHI^{\plus} = \frac{N_{\plus} \sum{(w^{\plus})^2} - 1}{N_{\plus} - 1}
45 where:
46 - \(r_{t}^{\plus}\) are the positive returns
47 - \(N_{\plus}\) is the number of positive returns
48 - \(w^{\plus}\) are the weights of positive returns
50 Args:
51 series (pl.Series): The series to calculate HHI for.
53 Returns:
54 float: The HHI value for positive returns. Returns NaN if fewer than 3
55 positive returns are present.
57 Note:
58 Values range from 0 (perfectly diversified gains) to 1 (all gains
59 concentrated in a single period).
60 """
61 positive_returns = series.filter(series > 0).drop_nans()
62 if positive_returns.len() <= 2:
63 return float("nan") # indeterminate: fewer than 3 positive returns
64 weight = positive_returns / positive_returns.sum()
65 return float((weight.len() * (weight**2).sum() - 1) / (weight.len() - 1))
67 @columnwise_stat
68 def hhi_negative(self, series: pl.Series) -> float:
69 r"""Calculate the Herfindahl-Hirschman Index (HHI) for negative returns.
71 This quantifies how concentrated the negative returns are in a series.
73 .. math::
74 w^{\minus} = \frac{r_{t}^{\minus}}{\sum{r_{t}^{\minus}}} \\
75 HHI^{\minus} = \frac{N_{\minus} \sum{(w^{\minus})^2} - 1}{N_{\minus} - 1}
77 where:
78 - \(r_{t}^{\minus}\) are the negative returns
79 - \(N_{\minus}\) is the number of negative returns
80 - \(w^{\minus}\) are the weights of negative returns
82 Args:
83 series (pl.Series): The returns series to calculate HHI for.
85 Returns:
86 float: The HHI value for negative returns. Returns NaN if fewer than 3
87 negative returns are present.
89 Note:
90 Values range from 0 (perfectly diversified losses) to 1 (all losses
91 concentrated in a single period).
92 """
93 negative_returns = series.filter(series < 0).drop_nans()
94 if negative_returns.len() <= 2:
95 return float("nan") # indeterminate: fewer than 3 negative returns
96 weight = negative_returns / negative_returns.sum()
97 return float((weight.len() * (weight**2).sum() - 1) / (weight.len() - 1))