Coverage for src/jquantstats/_stats/_capture.py: 100%

25 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-06 04:52 +0000

1"""Up- and down-market capture ratios. 

2 

3Split out of `_reporting.py`: capture ratios are the only metrics in that 

4module that take an explicit benchmark series as an argument rather than 

5reading the benchmark off `Data`, and they share a computation that is worth 

6stating once. 

7""" 

8 

9from __future__ import annotations 

10 

11from typing import TYPE_CHECKING 

12 

13import polars as pl 

14 

15if TYPE_CHECKING: 

16 from ..data import Data 

17 

18 

19class _CaptureStatsMixin: 

20 """Mixin providing up-market and down-market capture ratios.""" 

21 

22 _data: Data 

23 all: pl.DataFrame 

24 

25 if TYPE_CHECKING: 

26 from .._protocol import DataLike 

27 

28 data: DataLike 

29 

30 @staticmethod 

31 def _geometric_mean(series: pl.Series) -> float: 

32 """Geometric mean return of *series*: ``prod(1 + r)^(1/n) - 1``. 

33 

34 Args: 

35 series: A non-empty return series. 

36 

37 Returns: 

38 The per-period geometric mean return. 

39 """ 

40 return float(float((series + 1.0).product()) ** (1.0 / len(series)) - 1.0) 

41 

42 def _capture_ratio(self, benchmark: pl.Series, mask: pl.Series) -> dict[str, float]: 

43 """Ratio of each asset's geometric mean to the benchmark's, over *mask*. 

44 

45 Shared by `up_capture` and `down_capture`, which differ only in the 

46 sign of the benchmark periods they select. 

47 

48 Args: 

49 benchmark: Benchmark return series aligned row-by-row with the data. 

50 mask: Boolean series selecting the periods to measure over. 

51 

52 Returns: 

53 dict[str, float]: Capture ratio per asset; ``float("nan")`` where 

54 the benchmark or the asset has nothing usable in the selected 

55 periods. 

56 """ 

57 bench_selected = benchmark.filter(mask).drop_nulls() 

58 # A benchmark with no periods of this sign makes capture undefined for every asset. 

59 if bench_selected.is_empty(): 

60 return {col: float("nan") for col, _ in self._data.items()} 

61 bench_geom = self._geometric_mean(bench_selected) 

62 if bench_geom == 0.0: # pragma: no cover 

63 return {col: float("nan") for col, _ in self._data.items()} 

64 

65 result: dict[str, float] = {} 

66 for col, series in self._data.items(): 

67 strat_selected = series.filter(mask).drop_nulls() 

68 # An asset may have no usable returns during the selected periods after null filtering. 

69 if strat_selected.is_empty(): 

70 result[col] = float("nan") 

71 else: 

72 result[col] = self._geometric_mean(strat_selected) / bench_geom 

73 return result 

74 

75 def up_capture(self, benchmark: pl.Series) -> dict[str, float]: 

76 """Up-market capture ratio relative to an explicit benchmark series. 

77 

78 Measures the fraction of the benchmark's upside that the strategy 

79 captures. A value greater than 1.0 means the strategy outperformed 

80 the benchmark in rising markets. 

81 

82 Args: 

83 benchmark: Benchmark return series aligned row-by-row with the data. 

84 

85 Returns: 

86 dict[str, float]: Up capture ratio per asset. 

87 

88 Returns NaN when: 

89 Entries are ``float("nan")`` when the benchmark has no positive 

90 periods, its up-market geometric mean is zero, or an asset has no 

91 usable returns during those periods. 

92 """ 

93 return self._capture_ratio(benchmark, benchmark > 0) 

94 

95 def down_capture(self, benchmark: pl.Series) -> dict[str, float]: 

96 """Down-market capture ratio relative to an explicit benchmark series. 

97 

98 A value less than 1.0 means the strategy lost less than the benchmark 

99 in falling markets (a desirable property). 

100 

101 Args: 

102 benchmark: Benchmark return series aligned row-by-row with the data. 

103 

104 Returns: 

105 dict[str, float]: Down capture ratio per asset. 

106 

107 Returns NaN when: 

108 Entries are ``float("nan")`` when the benchmark has no negative 

109 periods, its down-market geometric mean is zero, or an asset has no 

110 usable returns during those periods. 

111 """ 

112 return self._capture_ratio(benchmark, benchmark < 0)