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

1"""Concentration metrics for the signed distribution of returns. 

2 

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""" 

8 

9from __future__ import annotations 

10 

11import polars as pl 

12 

13from ._core import columnwise_stat 

14 

15# ── Concentration metrics (HHI) mixin ──────────────────────────────────────── 

16 

17 

18class _ConcentrationStatsMixin: 

19 """Mixin providing temporal concentration metrics for gains and losses. 

20 

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. 

25 

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. 

30 

31 Both metrics depend only on the passed series — no benchmark, no cross-mixin 

32 state. 

33 """ 

34 

35 @columnwise_stat 

36 def hhi_positive(self, series: pl.Series) -> float: 

37 r"""Calculate the Herfindahl-Hirschman Index (HHI) for positive returns. 

38 

39 This quantifies how concentrated the positive returns are in a series. 

40 

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} 

44 

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 

49 

50 Args: 

51 series (pl.Series): The series to calculate HHI for. 

52 

53 Returns: 

54 float: The HHI value for positive returns. Returns NaN if fewer than 3 

55 positive returns are present. 

56 

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)) 

66 

67 @columnwise_stat 

68 def hhi_negative(self, series: pl.Series) -> float: 

69 r"""Calculate the Herfindahl-Hirschman Index (HHI) for negative returns. 

70 

71 This quantifies how concentrated the negative returns are in a series. 

72 

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} 

76 

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 

81 

82 Args: 

83 series (pl.Series): The returns series to calculate HHI for. 

84 

85 Returns: 

86 float: The HHI value for negative returns. Returns NaN if fewer than 3 

87 negative returns are present. 

88 

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))