Coverage for src/jquantstats/_stats/_benchmark.py: 100%
82 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-23 04:11 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-23 04:11 +0000
1"""Benchmark-relative and factor metrics.
3Split out of :mod:`jquantstats._stats._performance`, which had grown to 714 lines
4across four unrelated concerns. This module owns the metrics that are only
5meaningful *against a benchmark* — R-squared, information ratio, the CAPM Greeks
6(alpha/beta) and the Treynor ratio — each of which raises
7:class:`~jquantstats.exceptions.NoBenchmarkError` when none is configured.
9The risk-adjusted-ratio family stays in ``_performance``; concentration metrics
10live in ``_concentration``.
11"""
13from __future__ import annotations
15from typing import TYPE_CHECKING, cast
17import numpy as np
18import polars as pl
20from ..exceptions import NoBenchmarkError
21from ._core import _mean, columnwise_stat
22from ._internals import _comp_return
24if TYPE_CHECKING:
25 from ..data import Data
27# ── Benchmark & factor mixin ─────────────────────────────────────────────────
30class _BenchmarkStatsMixin:
31 """Mixin providing benchmark-relative and factor analytics.
33 Covers R-squared, information ratio, the CAPM Greeks (alpha/beta) and the
34 Treynor ratio. Every metric here requires ``self._data.benchmark`` and raises
35 :class:`~jquantstats.exceptions.NoBenchmarkError` when it is absent — that
36 shared precondition is what makes these a coherent unit.
37 """
39 _data: Data
40 all: pl.DataFrame
42 @columnwise_stat
43 def r_squared(self, series: pl.Series, benchmark: str | None = None) -> float:
44 """Measure the straight line fit of the equity curve.
46 Args:
47 series (pl.Series): The series to calculate R-squared for.
48 benchmark (str, optional): The benchmark column name. Defaults to None.
50 Returns:
51 float: The R-squared value.
53 Raises:
54 AttributeError: If no benchmark data is available.
56 """
57 if self._data.benchmark is None:
58 raise NoBenchmarkError
60 benchmark_col = benchmark or self._data.benchmark.columns[0]
62 # Evaluate both series and benchmark as Series
63 all_data = self.all
64 dframe = all_data.select([series, pl.col(benchmark_col).alias("benchmark")]).drop_nulls()
66 matrix = dframe.to_numpy()
67 # Get actual Series
69 strategy_np = matrix[:, 0]
70 benchmark_np = matrix[:, 1]
72 corr_matrix = np.corrcoef(strategy_np, benchmark_np)
73 r = corr_matrix[0, 1]
74 return float(r**2)
76 @columnwise_stat
77 def information_ratio(
78 self,
79 series: pl.Series,
80 periods_per_year: int | float | None = None,
81 benchmark: str | None = None,
82 annualise: bool = False,
83 ) -> float:
84 """Calculate the information ratio.
86 This is essentially the risk return ratio of the net profits.
88 Args:
89 series (pl.Series): The series to calculate information ratio for.
90 periods_per_year (int, optional): Number of periods per year. Defaults to
91 None, which infers the annualisation factor from the data.
92 benchmark (str, optional): The benchmark column name. Defaults to None.
93 annualise (bool, optional): Whether to annualise the ratio by multiplying by
94 ``sqrt(periods_per_year)``. Defaults to ``False``, returning the raw
95 information ratio, which matches the value returned by
96 ``qs.stats.information_ratio``. Set to ``True`` to annualise.
98 Returns:
99 float: The information ratio value.
101 """
102 if self._data.benchmark is None:
103 raise NoBenchmarkError
105 ppy = periods_per_year or self._data._periods_per_year
107 benchmark_col = benchmark or self._data.benchmark.columns[0]
108 all_series = self.all
109 valid_pairs = pl.DataFrame({"strategy": series, "benchmark": all_series[benchmark_col]}).drop_nulls()
110 active = valid_pairs["strategy"] - valid_pairs["benchmark"]
112 mean_f = _mean(active)
113 std_val = cast(float, active.std())
115 try:
116 std_f = std_val if std_val is not None else 1.0
117 ir = mean_f / std_f
118 return float(ir * (ppy**0.5) if annualise else ir)
119 except ZeroDivisionError:
120 return 0.0
122 @columnwise_stat
123 def greeks(
124 self, series: pl.Series, periods_per_year: int | float | None = None, benchmark: str | None = None
125 ) -> dict[str, float]:
126 """Calculate alpha and beta of the portfolio.
128 Args:
129 series (pl.Series): The series to calculate greeks for.
130 periods_per_year (int, optional): Number of periods per year. Defaults to 252.
131 benchmark (str, optional): The benchmark column name. Defaults to None.
133 Returns:
134 dict[str, float]: Dictionary containing alpha and beta values.
137 Returns NaN when:
138 Both alpha and beta are ``float("nan")`` when the benchmark variance
139 is zero.
140 """
141 ppy = periods_per_year or self._data._periods_per_year
143 benchmark_data = cast(pl.DataFrame, self._data.benchmark)
144 benchmark_col = benchmark or benchmark_data.columns[0]
146 # Evaluate both series and benchmark as Series
147 all_data = self.all
148 dframe = all_data.select([series, pl.col(benchmark_col).alias("benchmark")]).drop_nulls()
149 matrix = dframe.to_numpy()
151 # Get actual Series
152 strategy_np = matrix[:, 0]
153 benchmark_np = matrix[:, 1]
155 # 2x2 covariance matrix: [[var_strategy, cov], [cov, var_benchmark]]
156 cov_matrix = np.cov(strategy_np, benchmark_np)
158 cov = cov_matrix[0, 1]
159 var_benchmark = cov_matrix[1, 1]
161 beta = float(cov / var_benchmark) if var_benchmark != 0 else float("nan")
162 alpha = float(np.mean(strategy_np) - beta * np.mean(benchmark_np))
164 return {"alpha": float(alpha * ppy), "beta": beta}
166 @columnwise_stat
167 def treynor_ratio(
168 self,
169 series: pl.Series,
170 periods: int | float | None = None,
171 benchmark: str | None = None,
172 ) -> float:
173 """Treynor ratio: annualised excess return divided by beta.
175 Measures return per unit of systematic (market) risk. Unlike the Sharpe
176 ratio, which divides by total volatility, the Treynor ratio divides by
177 beta — making it most meaningful for well-diversified portfolios.
179 Args:
180 series (pl.Series): The returns series for one asset.
181 periods (int | float, optional): Periods per year for CAGR
182 annualisation. Defaults to the value inferred from the data.
183 benchmark (str, optional): Benchmark column name. Defaults to the
184 first benchmark column.
186 Returns:
187 float: Treynor ratio, or ``nan`` when beta is zero or the benchmark
188 is unavailable.
190 Raises:
191 AttributeError: If no benchmark data is attached.
193 Returns NaN when:
194 ``float("nan")`` when the benchmark variance or beta is zero, the
195 series is empty, or the compounded NAV is non-positive.
196 """
197 if self._data.benchmark is None:
198 raise NoBenchmarkError
200 ppy = periods or self._data._periods_per_year
202 benchmark_data = self._data.benchmark
203 benchmark_col = benchmark or benchmark_data.columns[0]
205 all_data = self.all
206 dframe = all_data.select([series, pl.col(benchmark_col).alias("_bench")]).drop_nulls()
207 matrix = dframe.to_numpy()
208 strategy_np = matrix[:, 0]
209 benchmark_np = matrix[:, 1]
211 cov_matrix = np.cov(strategy_np, benchmark_np)
212 var_benchmark = cov_matrix[1, 1]
213 if var_benchmark == 0:
214 return float("nan")
215 beta = float(cov_matrix[0, 1] / var_benchmark)
216 if beta == 0:
217 return float("nan")
219 n = len(series)
220 if n == 0:
221 return float("nan") # pragma: no cover
222 nav_final = 1.0 + _comp_return(series)
223 if nav_final <= 0:
224 return float("nan")
225 cagr = float(nav_final ** (ppy / n) - 1.0)
226 return cagr / beta