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
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-06 04:52 +0000
1"""Up- and down-market capture ratios.
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"""
9from __future__ import annotations
11from typing import TYPE_CHECKING
13import polars as pl
15if TYPE_CHECKING:
16 from ..data import Data
19class _CaptureStatsMixin:
20 """Mixin providing up-market and down-market capture ratios."""
22 _data: Data
23 all: pl.DataFrame
25 if TYPE_CHECKING:
26 from .._protocol import DataLike
28 data: DataLike
30 @staticmethod
31 def _geometric_mean(series: pl.Series) -> float:
32 """Geometric mean return of *series*: ``prod(1 + r)^(1/n) - 1``.
34 Args:
35 series: A non-empty return series.
37 Returns:
38 The per-period geometric mean return.
39 """
40 return float(float((series + 1.0).product()) ** (1.0 / len(series)) - 1.0)
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*.
45 Shared by `up_capture` and `down_capture`, which differ only in the
46 sign of the benchmark periods they select.
48 Args:
49 benchmark: Benchmark return series aligned row-by-row with the data.
50 mask: Boolean series selecting the periods to measure over.
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()}
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
75 def up_capture(self, benchmark: pl.Series) -> dict[str, float]:
76 """Up-market capture ratio relative to an explicit benchmark series.
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.
82 Args:
83 benchmark: Benchmark return series aligned row-by-row with the data.
85 Returns:
86 dict[str, float]: Up capture ratio per asset.
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)
95 def down_capture(self, benchmark: pl.Series) -> dict[str, float]:
96 """Down-market capture ratio relative to an explicit benchmark series.
98 A value less than 1.0 means the strategy lost less than the benchmark
99 in falling markets (a desirable property).
101 Args:
102 benchmark: Benchmark return series aligned row-by-row with the data.
104 Returns:
105 dict[str, float]: Down capture ratio per asset.
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)