Coverage for src/jquantstats/_stats/_performance.py: 100%
230 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-23 06:13 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-23 06:13 +0000
1"""Performance and risk-adjusted return metrics for financial data."""
3from __future__ import annotations
5from collections.abc import Callable
6from typing import TYPE_CHECKING, cast
8import numpy as np
9import polars as pl
10from scipy.stats import norm
12from ._core import _mean, _std_is_negligible, _to_float, columnwise_stat
13from ._internals import _annualization_factor, _comp_return, _downside_deviation
15if TYPE_CHECKING:
16 from ..data import Data
18# ── Risk statistics mixin ────────────────────────────────────────────────────
21class _RiskStatsMixin:
22 """Mixin providing risk-adjusted return and benchmark/factor metrics.
24 Covers: Sharpe ratio, Sortino ratio, adjusted Sortino, probabilistic ratios,
25 concentration (HHI), R-squared, information ratio, and Greeks (alpha/beta).
27 Cross-mixin dependencies:
28 - _BasicStatsMixin: geometric_mean, autocorr_penalty
30 **Concentration metrics (intentionally public, optional use)**
32 ``hhi_positive`` and ``hhi_negative`` implement the
33 Herfindahl-Hirschman Index applied to the signed distribution of returns.
34 They measure *temporal* concentration of gains and losses respectively —
35 a value near 0 means returns are spread evenly across periods; a value
36 near 1 means a single period dominates. These metrics are not included
37 in ``summary()`` by default because they are supplemental diagnostics
38 rather than standard risk-adjusted-return measures, but they are fully
39 supported as part of the public ``Stats`` API.
40 """
42 _data: Data
43 all: pl.DataFrame
45 if TYPE_CHECKING:
46 from .._protocol import DataLike
48 data: DataLike
50 def autocorr_penalty(self) -> dict[str, float]:
51 """Defined on _BasicStatsMixin."""
53 def geometric_mean(self) -> dict[str, float]:
54 """Defined on _BasicStatsMixin."""
56 # ── Sharpe & Sortino ──────────────────────────────────────────────────────
58 @columnwise_stat
59 def sharpe(self, series: pl.Series, periods: int | float | None = None) -> float:
60 """Calculate the Sharpe ratio of asset returns.
62 Args:
63 series (pl.Series): The series to calculate Sharpe ratio for.
64 periods (int, optional): Number of periods per year. Defaults to 252.
66 Returns:
67 float: The Sharpe ratio value.
70 Returns NaN when:
71 ``float("nan")`` when the standard deviation is missing (fewer than two
72 observations) or numerically negligible.
73 """
74 periods = periods or self._data._periods_per_year
76 std_val = cast(float | None, series.std(ddof=1))
77 mean_val = series.mean()
78 mean_f = cast(float, mean_val) if mean_val is not None else 0.0
80 if _std_is_negligible(std_val, mean_f):
81 return float("nan")
83 res = mean_f / cast(float, std_val)
84 factor = periods or 1
85 return float(res * _annualization_factor(factor))
87 @columnwise_stat
88 def sharpe_variance(self, series: pl.Series, periods: int | float | None = None) -> float:
89 r"""Calculate the asymptotic variance of the Sharpe Ratio.
91 .. math::
92 \text{Var}(SR) = \frac{1 + \frac{S \cdot SR}{2} + \frac{(K - 3) \cdot SR^2}{4}}{T}
94 where:
95 - \(S\) is the skewness of returns
96 - \(K\) is the kurtosis of returns
97 - \(SR\) is the Sharpe ratio (unannualized)
98 - \(T\) is the number of observations
100 Args:
101 series (pl.Series): The series to calculate Sharpe ratio variance for.
102 periods (int | float, optional): Number of periods per year. Defaults to data periods.
104 Returns:
105 float: The asymptotic variance of the Sharpe ratio.
106 If number of periods per year is provided or inferred from the data, the result is annualized.
109 Returns NaN when:
110 ``float("nan")`` when the standard deviation is zero/missing or
111 skewness/kurtosis cannot be computed.
112 """
113 t = series.count()
114 mean_val = _mean(series)
115 std_val = cast(float, series.std(ddof=1))
116 if std_val is None or std_val == 0:
117 return float("nan") # indeterminate: zero or missing standard deviation
118 sr = mean_val / std_val
120 skew_val = series.skew(bias=False)
121 kurt_val = series.kurtosis(bias=False)
123 if skew_val is None or kurt_val is None:
124 return float("nan") # indeterminate: missing moments
125 # Base variance calculation using unannualized Sharpe ratio
126 # Formula: (1 + skew*SR/2 + (kurt-3)*SR²/4) / T
127 base_variance = (1 + (float(skew_val) * sr) / 2 + ((float(kurt_val) - 3) / 4) * sr**2) / t
128 # Annualize by scaling with the number of periods
129 periods = periods or self._data._periods_per_year
130 factor = periods or 1
131 return float(base_variance * _annualization_factor(factor, sqrt=False))
133 @columnwise_stat
134 def probabilistic_sharpe_ratio(self, series: pl.Series) -> float:
135 r"""Calculate the probabilistic sharpe ratio (PSR).
137 Args:
138 series (pl.Series): The series to calculate probabilistic Sharpe ratio for.
140 Returns:
141 float: Probabilistic Sharpe Ratio.
143 Note:
144 PSR is the probability that the observed Sharpe ratio is greater than a
145 given benchmark Sharpe ratio.
148 Returns NaN when:
149 ``float("nan")`` when the standard deviation is zero/missing, moments
150 are missing, or the estimated Sharpe variance is non-positive.
151 """
152 t = series.count()
154 # Calculate observed unannualized Sharpe ratio
155 mean_val = _mean(series)
156 std_val = cast(float, series.std(ddof=1))
157 if std_val is None or std_val == 0:
158 return float("nan") # indeterminate: zero or missing standard deviation
159 # Unannualized observed Sharpe ratio
160 observed_sr = mean_val / std_val
162 skew_val = series.skew(bias=False)
163 kurt_val = series.kurtosis(bias=False)
165 if skew_val is None or kurt_val is None:
166 return float("nan") # indeterminate: missing moments
168 benchmark_sr = 0.0
169 # Calculate variance using unannualized benchmark Sharpe ratio
170 var_bench_sr = (1 + (float(skew_val) * benchmark_sr) / 2 + ((float(kurt_val) - 3) / 4) * benchmark_sr**2) / t
172 if var_bench_sr <= 0:
173 return float("nan") # pragma: no cover # indeterminate: non-positive variance
174 return float(norm.cdf((observed_sr - benchmark_sr) / np.sqrt(var_bench_sr)))
176 # ── Concentration metrics (HHI) ───────────────────────────────────────────
178 @columnwise_stat
179 def hhi_positive(self, series: pl.Series) -> float:
180 r"""Calculate the Herfindahl-Hirschman Index (HHI) for positive returns.
182 This quantifies how concentrated the positive returns are in a series.
184 .. math::
185 w^{\plus} = \frac{r_{t}^{\plus}}{\sum{r_{t}^{\plus}}} \\
186 HHI^{\plus} = \frac{N_{\plus} \sum{(w^{\plus})^2} - 1}{N_{\plus} - 1}
188 where:
189 - \(r_{t}^{\plus}\) are the positive returns
190 - \(N_{\plus}\) is the number of positive returns
191 - \(w^{\plus}\) are the weights of positive returns
193 Args:
194 series (pl.Series): The series to calculate HHI for.
196 Returns:
197 float: The HHI value for positive returns. Returns NaN if fewer than 3
198 positive returns are present.
200 Note:
201 Values range from 0 (perfectly diversified gains) to 1 (all gains
202 concentrated in a single period).
203 """
204 positive_returns = series.filter(series > 0).drop_nans()
205 if positive_returns.len() <= 2:
206 return float("nan") # indeterminate: fewer than 3 positive returns
207 weight = positive_returns / positive_returns.sum()
208 return float((weight.len() * (weight**2).sum() - 1) / (weight.len() - 1))
210 @columnwise_stat
211 def hhi_negative(self, series: pl.Series) -> float:
212 r"""Calculate the Herfindahl-Hirschman Index (HHI) for negative returns.
214 This quantifies how concentrated the negative returns are in a series.
216 .. math::
217 w^{\minus} = \frac{r_{t}^{\minus}}{\sum{r_{t}^{\minus}}} \\
218 HHI^{\minus} = \frac{N_{\minus} \sum{(w^{\minus})^2} - 1}{N_{\minus} - 1}
220 where:
221 - \(r_{t}^{\minus}\) are the negative returns
222 - \(N_{\minus}\) is the number of negative returns
223 - \(w^{\minus}\) are the weights of negative returns
225 Args:
226 series (pl.Series): The returns series to calculate HHI for.
228 Returns:
229 float: The HHI value for negative returns. Returns NaN if fewer than 3
230 negative returns are present.
232 Note:
233 Values range from 0 (perfectly diversified losses) to 1 (all losses
234 concentrated in a single period).
235 """
236 negative_returns = series.filter(series < 0).drop_nans()
237 if negative_returns.len() <= 2:
238 return float("nan") # indeterminate: fewer than 3 negative returns
239 weight = negative_returns / negative_returns.sum()
240 return float((weight.len() * (weight**2).sum() - 1) / (weight.len() - 1))
242 @columnwise_stat
243 def sortino(self, series: pl.Series, periods: int | float | None = None) -> float:
244 """Calculate the Sortino ratio.
246 The Sortino ratio is the mean return divided by downside deviation.
247 Based on Red Rock Capital's Sortino ratio paper.
249 Args:
250 series (pl.Series): The series to calculate Sortino ratio for.
251 periods (int, optional): Number of periods per year. Defaults to 252.
253 Returns:
254 float: The Sortino ratio value.
257 Returns NaN when:
258 ``float("nan")`` when both the mean return and the downside deviation
259 are zero.
260 """
261 periods = periods or self._data._periods_per_year
262 downside_deviation = _downside_deviation(series)
263 mean_f = _mean(series)
264 if downside_deviation == 0.0:
265 if mean_f > 0:
266 return float("inf")
267 elif mean_f < 0: # pragma: no cover # unreachable: no negatives ⟹ mean ≥ 0
268 return float("-inf")
269 else:
270 return float("nan") # indeterminate: zero mean and zero downside deviation
271 ratio = mean_f / downside_deviation
272 return float(ratio * _annualization_factor(periods))
274 @columnwise_stat
275 def omega(
276 self,
277 series: pl.Series,
278 rf: float = 0.0,
279 required_return: float = 0.0,
280 periods: int | float | None = None,
281 ) -> float:
282 """Calculate the Omega ratio.
284 The Omega ratio is the probability-weighted ratio of gains to losses
285 relative to a threshold return. It is computed as the sum of returns
286 above the threshold divided by the absolute sum of returns below it.
288 Args:
289 series (pl.Series): The series to calculate Omega ratio for.
290 rf (float): Annualised risk-free rate. Defaults to 0.0.
291 required_return (float): Annualised minimum acceptable return
292 threshold. Defaults to 0.0.
293 periods (int | float | None): Number of periods per year. Defaults
294 to the value inferred from the data.
296 Returns:
297 float: The Omega ratio, or NaN when the denominator is zero or
298 when ``required_return <= -1``.
300 Note:
301 See https://en.wikipedia.org/wiki/Omega_ratio for details.
303 """
304 if required_return <= -1:
305 return float("nan")
307 periods = periods or self._data._periods_per_year
309 # Subtract per-period risk-free rate from returns when rf is non-zero.
310 if rf != 0.0:
311 rf_per_period = float((1.0 + rf) ** (1.0 / periods) - 1.0)
312 series = series - rf_per_period
314 # Convert annualised required return to a per-period threshold.
315 return_threshold = float((1.0 + required_return) ** (1.0 / periods) - 1.0)
317 returns_less_thresh = series - return_threshold
319 numer = float(returns_less_thresh.filter(returns_less_thresh > 0.0).sum())
320 denom = float(-returns_less_thresh.filter(returns_less_thresh < 0.0).sum())
322 if denom <= 0.0:
323 return float("nan")
324 return numer / denom
326 @staticmethod
327 def _probabilistic_ratio_from_base(base: float, series: pl.Series) -> float:
328 """Compute the probabilistic ratio given an observed unannualized base ratio.
330 Uses the formula: norm.cdf(base / sigma), where
331 sigma = sqrt((1 + 0.5·base² - skew·base + (kurt-3)/4·base²) / (n-1)).
333 Args:
334 base (float): Unannualized observed ratio (e.g. Sortino).
335 series (pl.Series): The original returns series (for moments and n).
337 Returns:
338 float: Probabilistic ratio in [0, 1].
341 Returns NaN when:
342 ``float("nan")`` when moments are missing, there are fewer than two
343 observations, or the estimated variance is non-positive.
344 """
345 n = series.count()
346 skew_val = series.skew(bias=False)
347 kurt_val = series.kurtosis(bias=False)
348 if skew_val is None or kurt_val is None or n <= 1:
349 return float("nan") # indeterminate: missing moments or insufficient data
350 variance = (1 + 0.5 * base**2 - float(skew_val) * base + ((float(kurt_val) - 3) / 4) * base**2) / (n - 1)
351 if variance <= 0:
352 return float("nan") # indeterminate: non-positive variance
353 return float(norm.cdf(base / np.sqrt(variance)))
355 @columnwise_stat
356 def probabilistic_sortino_ratio(self, series: pl.Series, periods: int | float | None = None) -> float:
357 """Calculate the Probabilistic Sortino Ratio.
359 The probability that the observed Sortino ratio is greater than zero,
360 accounting for estimation uncertainty via skewness and kurtosis.
362 Args:
363 series (pl.Series): The series to calculate the ratio for.
364 periods (int | float, optional): Accepted for API compatibility; has no effect
365 since the base ratio is un-annualized.
367 Returns:
368 float: Probabilistic Sortino ratio in [0, 1].
371 Returns NaN when:
372 ``float("nan")`` when the downside deviation is zero, moments are
373 missing, or the estimated variance is non-positive.
374 """
375 downside_deviation = _downside_deviation(series)
376 mean_f = _mean(series)
377 if downside_deviation == 0.0:
378 return float("nan") # indeterminate: zero downside deviation
379 base = float(mean_f / downside_deviation)
380 return self._probabilistic_ratio_from_base(base, series)
382 @columnwise_stat
383 def probabilistic_adjusted_sortino_ratio(self, series: pl.Series, periods: int | float | None = None) -> float:
384 """Calculate the Probabilistic Adjusted Sortino Ratio.
386 The probability that the observed adjusted Sortino ratio (divided by sqrt(2)
387 for Sharpe comparability) is greater than zero, accounting for estimation
388 uncertainty via skewness and kurtosis.
390 Args:
391 series (pl.Series): The series to calculate the ratio for.
392 periods (int | float, optional): Accepted for API compatibility; has no effect
393 since the base ratio is un-annualized.
395 Returns:
396 float: Probabilistic adjusted Sortino ratio in [0, 1].
399 Returns NaN when:
400 ``float("nan")`` when the downside deviation is zero, moments are
401 missing, or the estimated variance is non-positive.
402 """
403 downside_deviation = _downside_deviation(series)
404 mean_f = _mean(series)
405 if downside_deviation == 0.0:
406 return float("nan") # indeterminate: zero downside deviation
407 base = float(mean_f / downside_deviation) / np.sqrt(2)
408 return self._probabilistic_ratio_from_base(base, series)
410 def probabilistic_ratio(
411 self,
412 base: str | Callable[[pl.Series], float] = "sharpe",
413 ) -> dict[str, float]:
414 r"""Generic probabilistic ratio for any base metric.
416 Computes the probability that the observed ratio is greater than zero,
417 accounting for estimation uncertainty via skewness and kurtosis using
418 the Lopez de Prado (2018) framework.
420 Args:
421 base: Base ratio to use. Either:
423 - A string: ``'sharpe'``, ``'sortino'``, ``'adjusted_sortino'``.
424 - A callable ``(series: pl.Series) -> float`` returning the
425 **unannualized** ratio for a single series.
427 Returns:
428 dict[str, float]: Probabilistic ratio in ``[0, 1]`` per asset.
430 Raises:
431 ValueError: If *base* is an unrecognised string.
434 Returns NaN when:
435 Entries are ``float("nan")`` when the base ratio is undefined (zero
436 standard deviation / zero downside deviation), moments are missing, or
437 the estimated variance is non-positive.
438 """
440 def _sharpe_base(s: pl.Series) -> float:
441 """Return the per-period Sharpe ratio (mean / std, ddof=1) of *s*."""
442 mean_val = _mean(s)
443 std_val = cast(float, s.std(ddof=1))
444 if not std_val or std_val == 0:
445 return float("nan")
446 return mean_val / std_val
448 def _sortino_base(s: pl.Series) -> float:
449 """Return the per-period Sortino ratio (mean / downside_dev) of *s*."""
450 downside_sum = _to_float((s.filter(s < 0) ** 2).sum())
451 downside_dev = float(np.sqrt(downside_sum / s.count()))
452 if downside_dev == 0.0:
453 return float("nan")
454 return _mean(s) / downside_dev
456 _builtin: dict[str, Callable[[pl.Series], float]] = {
457 "sharpe": _sharpe_base,
458 "sortino": _sortino_base,
459 "adjusted_sortino": lambda s: _sortino_base(s) / float(np.sqrt(2)),
460 }
462 if isinstance(base, str):
463 if base not in _builtin:
464 raise ValueError(f"base must be one of {list(_builtin)}, got {base!r}") # noqa: TRY003
465 base_fn = _builtin[base]
466 else:
467 base_fn = base
469 result: dict[str, float] = {}
470 for col, series in self._data.items():
471 base_val = base_fn(series)
472 if np.isnan(base_val):
473 result[col] = float("nan")
474 else:
475 result[col] = _RiskStatsMixin._probabilistic_ratio_from_base(base_val, series)
476 return result
478 def smart_sharpe(self, periods: int | float | None = None) -> dict[str, float]:
479 """Calculate the Smart Sharpe ratio (Sharpe with autocorrelation penalty).
481 Divides the Sharpe ratio by the autocorrelation penalty to account for
482 return autocorrelation that can artificially inflate risk-adjusted metrics.
484 Args:
485 periods (int | float, optional): Number of periods per year. Defaults to periods_per_year.
487 Returns:
488 dict[str, float]: Dictionary mapping asset names to Smart Sharpe ratios.
490 """
491 sharpe_data = self.sharpe(periods=periods)
492 penalty_data = self.autocorr_penalty()
493 return {k: sharpe_data[k] / penalty_data[k] for k in sharpe_data}
495 def smart_sortino(self, periods: int | float | None = None) -> dict[str, float]:
496 """Calculate the Smart Sortino ratio (Sortino with autocorrelation penalty).
498 Divides the Sortino ratio by the autocorrelation penalty to account for
499 return autocorrelation that can artificially inflate risk-adjusted metrics.
501 Args:
502 periods (int | float, optional): Number of periods per year. Defaults to periods_per_year.
504 Returns:
505 dict[str, float]: Dictionary mapping asset names to Smart Sortino ratios.
507 """
508 sortino_data = self.sortino(periods=periods)
509 penalty_data = self.autocorr_penalty()
510 return {k: sortino_data[k] / penalty_data[k] for k in sortino_data}
512 def adjusted_sortino(self, periods: int | float | None = None) -> dict[str, float]:
513 """Calculate Jack Schwager's adjusted Sortino ratio.
515 This adjustment allows for direct comparison to Sharpe ratio.
516 See: https://archive.is/wip/2rwFW.
518 Args:
519 periods (int, optional): Number of periods per year. Defaults to 252.
521 Returns:
522 dict[str, float]: Dictionary mapping asset names to adjusted Sortino ratios.
524 """
525 sortino_data = self.sortino(periods=periods)
526 return {k: v / np.sqrt(2) for k, v in sortino_data.items()}
528 # ── Benchmark & factor ────────────────────────────────────────────────────
530 @columnwise_stat
531 def r_squared(self, series: pl.Series, benchmark: str | None = None) -> float:
532 """Measure the straight line fit of the equity curve.
534 Args:
535 series (pl.Series): The series to calculate R-squared for.
536 benchmark (str, optional): The benchmark column name. Defaults to None.
538 Returns:
539 float: The R-squared value.
541 Raises:
542 AttributeError: If no benchmark data is available.
544 """
545 if self._data.benchmark is None:
546 raise AttributeError("No benchmark data available") # noqa: TRY003
548 benchmark_col = benchmark or self._data.benchmark.columns[0]
550 # Evaluate both series and benchmark as Series
551 all_data = self.all
552 dframe = all_data.select([series, pl.col(benchmark_col).alias("benchmark")]).drop_nulls()
554 matrix = dframe.to_numpy()
555 # Get actual Series
557 strategy_np = matrix[:, 0]
558 benchmark_np = matrix[:, 1]
560 corr_matrix = np.corrcoef(strategy_np, benchmark_np)
561 r = corr_matrix[0, 1]
562 return float(r**2)
564 @columnwise_stat
565 def information_ratio(
566 self,
567 series: pl.Series,
568 periods_per_year: int | float | None = None,
569 benchmark: str | None = None,
570 annualise: bool = False,
571 ) -> float:
572 """Calculate the information ratio.
574 This is essentially the risk return ratio of the net profits.
576 Args:
577 series (pl.Series): The series to calculate information ratio for.
578 periods_per_year (int, optional): Number of periods per year. Defaults to 252.
579 benchmark (str, optional): The benchmark column name. Defaults to None.
580 annualise (bool, optional): Whether to annualise the ratio by multiplying by
581 ``sqrt(periods_per_year)``. Defaults to ``True``. Set to ``False`` to
582 obtain the raw (non-annualised) information ratio, which matches the value
583 returned by ``qs.stats.information_ratio``.
585 Returns:
586 float: The information ratio value.
588 """
589 if self._data.benchmark is None:
590 raise AttributeError("No benchmark data available") # noqa: TRY003
592 ppy = periods_per_year or self._data._periods_per_year
594 benchmark_col = benchmark or self._data.benchmark.columns[0]
595 all_series = self.all
596 valid_pairs = pl.DataFrame({"strategy": series, "benchmark": all_series[benchmark_col]}).drop_nulls()
597 active = valid_pairs["strategy"] - valid_pairs["benchmark"]
599 mean_f = _mean(active)
600 std_val = cast(float, active.std())
602 try:
603 std_f = std_val if std_val is not None else 1.0
604 ir = mean_f / std_f
605 return float(ir * (ppy**0.5) if annualise else ir)
606 except ZeroDivisionError:
607 return 0.0
609 @columnwise_stat
610 def greeks(
611 self, series: pl.Series, periods_per_year: int | float | None = None, benchmark: str | None = None
612 ) -> dict[str, float]:
613 """Calculate alpha and beta of the portfolio.
615 Args:
616 series (pl.Series): The series to calculate greeks for.
617 periods_per_year (int, optional): Number of periods per year. Defaults to 252.
618 benchmark (str, optional): The benchmark column name. Defaults to None.
620 Returns:
621 dict[str, float]: Dictionary containing alpha and beta values.
624 Returns NaN when:
625 Both alpha and beta are ``float("nan")`` when the benchmark variance
626 is zero.
627 """
628 ppy = periods_per_year or self._data._periods_per_year
630 benchmark_data = cast(pl.DataFrame, self._data.benchmark)
631 benchmark_col = benchmark or benchmark_data.columns[0]
633 # Evaluate both series and benchmark as Series
634 all_data = self.all
635 dframe = all_data.select([series, pl.col(benchmark_col).alias("benchmark")]).drop_nulls()
636 matrix = dframe.to_numpy()
638 # Get actual Series
639 strategy_np = matrix[:, 0]
640 benchmark_np = matrix[:, 1]
642 # 2x2 covariance matrix: [[var_strategy, cov], [cov, var_benchmark]]
643 cov_matrix = np.cov(strategy_np, benchmark_np)
645 cov = cov_matrix[0, 1]
646 var_benchmark = cov_matrix[1, 1]
648 beta = float(cov / var_benchmark) if var_benchmark != 0 else float("nan")
649 alpha = float(np.mean(strategy_np) - beta * np.mean(benchmark_np))
651 return {"alpha": float(alpha * ppy), "beta": beta}
653 @columnwise_stat
654 def treynor_ratio(
655 self,
656 series: pl.Series,
657 periods: int | float | None = None,
658 benchmark: str | None = None,
659 ) -> float:
660 """Treynor ratio: annualised excess return divided by beta.
662 Measures return per unit of systematic (market) risk. Unlike the Sharpe
663 ratio, which divides by total volatility, the Treynor ratio divides by
664 beta — making it most meaningful for well-diversified portfolios.
666 Args:
667 series (pl.Series): The returns series for one asset.
668 periods (int | float, optional): Periods per year for CAGR
669 annualisation. Defaults to the value inferred from the data.
670 benchmark (str, optional): Benchmark column name. Defaults to the
671 first benchmark column.
673 Returns:
674 float: Treynor ratio, or ``nan`` when beta is zero or the benchmark
675 is unavailable.
677 Raises:
678 AttributeError: If no benchmark data is attached.
680 Returns NaN when:
681 ``float("nan")`` when the benchmark variance or beta is zero, the
682 series is empty, or the compounded NAV is non-positive.
683 """
684 if self._data.benchmark is None:
685 raise AttributeError("No benchmark data available") # noqa: TRY003
687 ppy = periods or self._data._periods_per_year
689 benchmark_data = self._data.benchmark
690 benchmark_col = benchmark or benchmark_data.columns[0]
692 all_data = self.all
693 dframe = all_data.select([series, pl.col(benchmark_col).alias("_bench")]).drop_nulls()
694 matrix = dframe.to_numpy()
695 strategy_np = matrix[:, 0]
696 benchmark_np = matrix[:, 1]
698 cov_matrix = np.cov(strategy_np, benchmark_np)
699 var_benchmark = cov_matrix[1, 1]
700 if var_benchmark == 0:
701 return float("nan")
702 beta = float(cov_matrix[0, 1] / var_benchmark)
703 if beta == 0:
704 return float("nan")
706 n = len(series)
707 if n == 0:
708 return float("nan") # pragma: no cover
709 nav_final = 1.0 + _comp_return(series)
710 if nav_final <= 0:
711 return float("nan")
712 cagr = float(nav_final ** (ppy / n) - 1.0)
713 return cagr / beta