Coverage for src/jquantstats/_stats/_benchmark.py: 100%
82 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"""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 252.
91 benchmark (str, optional): The benchmark column name. Defaults to None.
92 annualise (bool, optional): Whether to annualise the ratio by multiplying by
93 ``sqrt(periods_per_year)``. Defaults to ``True``. Set to ``False`` to
94 obtain the raw (non-annualised) information ratio, which matches the value
95 returned by ``qs.stats.information_ratio``.
97 Returns:
98 float: The information ratio value.
100 """
101 if self._data.benchmark is None:
102 raise NoBenchmarkError
104 ppy = periods_per_year or self._data._periods_per_year
106 benchmark_col = benchmark or self._data.benchmark.columns[0]
107 all_series = self.all
108 valid_pairs = pl.DataFrame({"strategy": series, "benchmark": all_series[benchmark_col]}).drop_nulls()
109 active = valid_pairs["strategy"] - valid_pairs["benchmark"]
111 mean_f = _mean(active)
112 std_val = cast(float, active.std())
114 try:
115 std_f = std_val if std_val is not None else 1.0
116 ir = mean_f / std_f
117 return float(ir * (ppy**0.5) if annualise else ir)
118 except ZeroDivisionError:
119 return 0.0
121 @columnwise_stat
122 def greeks(
123 self, series: pl.Series, periods_per_year: int | float | None = None, benchmark: str | None = None
124 ) -> dict[str, float]:
125 """Calculate alpha and beta of the portfolio.
127 Args:
128 series (pl.Series): The series to calculate greeks for.
129 periods_per_year (int, optional): Number of periods per year. Defaults to 252.
130 benchmark (str, optional): The benchmark column name. Defaults to None.
132 Returns:
133 dict[str, float]: Dictionary containing alpha and beta values.
136 Returns NaN when:
137 Both alpha and beta are ``float("nan")`` when the benchmark variance
138 is zero.
139 """
140 ppy = periods_per_year or self._data._periods_per_year
142 benchmark_data = cast(pl.DataFrame, self._data.benchmark)
143 benchmark_col = benchmark or benchmark_data.columns[0]
145 # Evaluate both series and benchmark as Series
146 all_data = self.all
147 dframe = all_data.select([series, pl.col(benchmark_col).alias("benchmark")]).drop_nulls()
148 matrix = dframe.to_numpy()
150 # Get actual Series
151 strategy_np = matrix[:, 0]
152 benchmark_np = matrix[:, 1]
154 # 2x2 covariance matrix: [[var_strategy, cov], [cov, var_benchmark]]
155 cov_matrix = np.cov(strategy_np, benchmark_np)
157 cov = cov_matrix[0, 1]
158 var_benchmark = cov_matrix[1, 1]
160 beta = float(cov / var_benchmark) if var_benchmark != 0 else float("nan")
161 alpha = float(np.mean(strategy_np) - beta * np.mean(benchmark_np))
163 return {"alpha": float(alpha * ppy), "beta": beta}
165 @columnwise_stat
166 def treynor_ratio(
167 self,
168 series: pl.Series,
169 periods: int | float | None = None,
170 benchmark: str | None = None,
171 ) -> float:
172 """Treynor ratio: annualised excess return divided by beta.
174 Measures return per unit of systematic (market) risk. Unlike the Sharpe
175 ratio, which divides by total volatility, the Treynor ratio divides by
176 beta — making it most meaningful for well-diversified portfolios.
178 Args:
179 series (pl.Series): The returns series for one asset.
180 periods (int | float, optional): Periods per year for CAGR
181 annualisation. Defaults to the value inferred from the data.
182 benchmark (str, optional): Benchmark column name. Defaults to the
183 first benchmark column.
185 Returns:
186 float: Treynor ratio, or ``nan`` when beta is zero or the benchmark
187 is unavailable.
189 Raises:
190 AttributeError: If no benchmark data is attached.
192 Returns NaN when:
193 ``float("nan")`` when the benchmark variance or beta is zero, the
194 series is empty, or the compounded NAV is non-positive.
195 """
196 if self._data.benchmark is None:
197 raise NoBenchmarkError
199 ppy = periods or self._data._periods_per_year
201 benchmark_data = self._data.benchmark
202 benchmark_col = benchmark or benchmark_data.columns[0]
204 all_data = self.all
205 dframe = all_data.select([series, pl.col(benchmark_col).alias("_bench")]).drop_nulls()
206 matrix = dframe.to_numpy()
207 strategy_np = matrix[:, 0]
208 benchmark_np = matrix[:, 1]
210 cov_matrix = np.cov(strategy_np, benchmark_np)
211 var_benchmark = cov_matrix[1, 1]
212 if var_benchmark == 0:
213 return float("nan")
214 beta = float(cov_matrix[0, 1] / var_benchmark)
215 if beta == 0:
216 return float("nan")
218 n = len(series)
219 if n == 0:
220 return float("nan") # pragma: no cover
221 nav_final = 1.0 + _comp_return(series)
222 if nav_final <= 0:
223 return float("nan")
224 cagr = float(nav_final ** (ppy / n) - 1.0)
225 return cagr / beta