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

1"""Performance and risk-adjusted return metrics for financial data.""" 

2 

3from __future__ import annotations 

4 

5from collections.abc import Callable 

6from typing import TYPE_CHECKING, cast 

7 

8import numpy as np 

9import polars as pl 

10from scipy.stats import norm 

11 

12from ._core import _mean, _std_is_negligible, _to_float, columnwise_stat 

13from ._internals import _annualization_factor, _comp_return, _downside_deviation 

14 

15if TYPE_CHECKING: 

16 from ..data import Data 

17 

18# ── Risk statistics mixin ──────────────────────────────────────────────────── 

19 

20 

21class _RiskStatsMixin: 

22 """Mixin providing risk-adjusted return and benchmark/factor metrics. 

23 

24 Covers: Sharpe ratio, Sortino ratio, adjusted Sortino, probabilistic ratios, 

25 concentration (HHI), R-squared, information ratio, and Greeks (alpha/beta). 

26 

27 Cross-mixin dependencies: 

28 - _BasicStatsMixin: geometric_mean, autocorr_penalty 

29 

30 **Concentration metrics (intentionally public, optional use)** 

31 

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 """ 

41 

42 _data: Data 

43 all: pl.DataFrame 

44 

45 if TYPE_CHECKING: 

46 from .._protocol import DataLike 

47 

48 data: DataLike 

49 

50 def autocorr_penalty(self) -> dict[str, float]: 

51 """Defined on _BasicStatsMixin.""" 

52 

53 def geometric_mean(self) -> dict[str, float]: 

54 """Defined on _BasicStatsMixin.""" 

55 

56 # ── Sharpe & Sortino ────────────────────────────────────────────────────── 

57 

58 @columnwise_stat 

59 def sharpe(self, series: pl.Series, periods: int | float | None = None) -> float: 

60 """Calculate the Sharpe ratio of asset returns. 

61 

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. 

65 

66 Returns: 

67 float: The Sharpe ratio value. 

68 

69 

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 

75 

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 

79 

80 if _std_is_negligible(std_val, mean_f): 

81 return float("nan") 

82 

83 res = mean_f / cast(float, std_val) 

84 factor = periods or 1 

85 return float(res * _annualization_factor(factor)) 

86 

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. 

90 

91 .. math:: 

92 \text{Var}(SR) = \frac{1 + \frac{S \cdot SR}{2} + \frac{(K - 3) \cdot SR^2}{4}}{T} 

93 

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 

99 

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. 

103 

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. 

107 

108 

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 

119 

120 skew_val = series.skew(bias=False) 

121 kurt_val = series.kurtosis(bias=False) 

122 

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)) 

132 

133 @columnwise_stat 

134 def probabilistic_sharpe_ratio(self, series: pl.Series) -> float: 

135 r"""Calculate the probabilistic sharpe ratio (PSR). 

136 

137 Args: 

138 series (pl.Series): The series to calculate probabilistic Sharpe ratio for. 

139 

140 Returns: 

141 float: Probabilistic Sharpe Ratio. 

142 

143 Note: 

144 PSR is the probability that the observed Sharpe ratio is greater than a 

145 given benchmark Sharpe ratio. 

146 

147 

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() 

153 

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 

161 

162 skew_val = series.skew(bias=False) 

163 kurt_val = series.kurtosis(bias=False) 

164 

165 if skew_val is None or kurt_val is None: 

166 return float("nan") # indeterminate: missing moments 

167 

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 

171 

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))) 

175 

176 # ── Concentration metrics (HHI) ─────────────────────────────────────────── 

177 

178 @columnwise_stat 

179 def hhi_positive(self, series: pl.Series) -> float: 

180 r"""Calculate the Herfindahl-Hirschman Index (HHI) for positive returns. 

181 

182 This quantifies how concentrated the positive returns are in a series. 

183 

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} 

187 

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 

192 

193 Args: 

194 series (pl.Series): The series to calculate HHI for. 

195 

196 Returns: 

197 float: The HHI value for positive returns. Returns NaN if fewer than 3 

198 positive returns are present. 

199 

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)) 

209 

210 @columnwise_stat 

211 def hhi_negative(self, series: pl.Series) -> float: 

212 r"""Calculate the Herfindahl-Hirschman Index (HHI) for negative returns. 

213 

214 This quantifies how concentrated the negative returns are in a series. 

215 

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} 

219 

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 

224 

225 Args: 

226 series (pl.Series): The returns series to calculate HHI for. 

227 

228 Returns: 

229 float: The HHI value for negative returns. Returns NaN if fewer than 3 

230 negative returns are present. 

231 

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)) 

241 

242 @columnwise_stat 

243 def sortino(self, series: pl.Series, periods: int | float | None = None) -> float: 

244 """Calculate the Sortino ratio. 

245 

246 The Sortino ratio is the mean return divided by downside deviation. 

247 Based on Red Rock Capital's Sortino ratio paper. 

248 

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. 

252 

253 Returns: 

254 float: The Sortino ratio value. 

255 

256 

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)) 

273 

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. 

283 

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. 

287 

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. 

295 

296 Returns: 

297 float: The Omega ratio, or NaN when the denominator is zero or 

298 when ``required_return <= -1``. 

299 

300 Note: 

301 See https://en.wikipedia.org/wiki/Omega_ratio for details. 

302 

303 """ 

304 if required_return <= -1: 

305 return float("nan") 

306 

307 periods = periods or self._data._periods_per_year 

308 

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 

313 

314 # Convert annualised required return to a per-period threshold. 

315 return_threshold = float((1.0 + required_return) ** (1.0 / periods) - 1.0) 

316 

317 returns_less_thresh = series - return_threshold 

318 

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()) 

321 

322 if denom <= 0.0: 

323 return float("nan") 

324 return numer / denom 

325 

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. 

329 

330 Uses the formula: norm.cdf(base / sigma), where 

331 sigma = sqrt((1 + 0.5·base² - skew·base + (kurt-3)/4·base²) / (n-1)). 

332 

333 Args: 

334 base (float): Unannualized observed ratio (e.g. Sortino). 

335 series (pl.Series): The original returns series (for moments and n). 

336 

337 Returns: 

338 float: Probabilistic ratio in [0, 1]. 

339 

340 

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))) 

354 

355 @columnwise_stat 

356 def probabilistic_sortino_ratio(self, series: pl.Series, periods: int | float | None = None) -> float: 

357 """Calculate the Probabilistic Sortino Ratio. 

358 

359 The probability that the observed Sortino ratio is greater than zero, 

360 accounting for estimation uncertainty via skewness and kurtosis. 

361 

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. 

366 

367 Returns: 

368 float: Probabilistic Sortino ratio in [0, 1]. 

369 

370 

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) 

381 

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. 

385 

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. 

389 

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. 

394 

395 Returns: 

396 float: Probabilistic adjusted Sortino ratio in [0, 1]. 

397 

398 

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) 

409 

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. 

415 

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. 

419 

420 Args: 

421 base: Base ratio to use. Either: 

422 

423 - A string: ``'sharpe'``, ``'sortino'``, ``'adjusted_sortino'``. 

424 - A callable ``(series: pl.Series) -> float`` returning the 

425 **unannualized** ratio for a single series. 

426 

427 Returns: 

428 dict[str, float]: Probabilistic ratio in ``[0, 1]`` per asset. 

429 

430 Raises: 

431 ValueError: If *base* is an unrecognised string. 

432 

433 

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 """ 

439 

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 

447 

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 

455 

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 } 

461 

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 

468 

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 

477 

478 def smart_sharpe(self, periods: int | float | None = None) -> dict[str, float]: 

479 """Calculate the Smart Sharpe ratio (Sharpe with autocorrelation penalty). 

480 

481 Divides the Sharpe ratio by the autocorrelation penalty to account for 

482 return autocorrelation that can artificially inflate risk-adjusted metrics. 

483 

484 Args: 

485 periods (int | float, optional): Number of periods per year. Defaults to periods_per_year. 

486 

487 Returns: 

488 dict[str, float]: Dictionary mapping asset names to Smart Sharpe ratios. 

489 

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} 

494 

495 def smart_sortino(self, periods: int | float | None = None) -> dict[str, float]: 

496 """Calculate the Smart Sortino ratio (Sortino with autocorrelation penalty). 

497 

498 Divides the Sortino ratio by the autocorrelation penalty to account for 

499 return autocorrelation that can artificially inflate risk-adjusted metrics. 

500 

501 Args: 

502 periods (int | float, optional): Number of periods per year. Defaults to periods_per_year. 

503 

504 Returns: 

505 dict[str, float]: Dictionary mapping asset names to Smart Sortino ratios. 

506 

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} 

511 

512 def adjusted_sortino(self, periods: int | float | None = None) -> dict[str, float]: 

513 """Calculate Jack Schwager's adjusted Sortino ratio. 

514 

515 This adjustment allows for direct comparison to Sharpe ratio. 

516 See: https://archive.is/wip/2rwFW. 

517 

518 Args: 

519 periods (int, optional): Number of periods per year. Defaults to 252. 

520 

521 Returns: 

522 dict[str, float]: Dictionary mapping asset names to adjusted Sortino ratios. 

523 

524 """ 

525 sortino_data = self.sortino(periods=periods) 

526 return {k: v / np.sqrt(2) for k, v in sortino_data.items()} 

527 

528 # ── Benchmark & factor ──────────────────────────────────────────────────── 

529 

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. 

533 

534 Args: 

535 series (pl.Series): The series to calculate R-squared for. 

536 benchmark (str, optional): The benchmark column name. Defaults to None. 

537 

538 Returns: 

539 float: The R-squared value. 

540 

541 Raises: 

542 AttributeError: If no benchmark data is available. 

543 

544 """ 

545 if self._data.benchmark is None: 

546 raise AttributeError("No benchmark data available") # noqa: TRY003 

547 

548 benchmark_col = benchmark or self._data.benchmark.columns[0] 

549 

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() 

553 

554 matrix = dframe.to_numpy() 

555 # Get actual Series 

556 

557 strategy_np = matrix[:, 0] 

558 benchmark_np = matrix[:, 1] 

559 

560 corr_matrix = np.corrcoef(strategy_np, benchmark_np) 

561 r = corr_matrix[0, 1] 

562 return float(r**2) 

563 

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. 

573 

574 This is essentially the risk return ratio of the net profits. 

575 

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``. 

584 

585 Returns: 

586 float: The information ratio value. 

587 

588 """ 

589 if self._data.benchmark is None: 

590 raise AttributeError("No benchmark data available") # noqa: TRY003 

591 

592 ppy = periods_per_year or self._data._periods_per_year 

593 

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"] 

598 

599 mean_f = _mean(active) 

600 std_val = cast(float, active.std()) 

601 

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 

608 

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. 

614 

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. 

619 

620 Returns: 

621 dict[str, float]: Dictionary containing alpha and beta values. 

622 

623 

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 

629 

630 benchmark_data = cast(pl.DataFrame, self._data.benchmark) 

631 benchmark_col = benchmark or benchmark_data.columns[0] 

632 

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() 

637 

638 # Get actual Series 

639 strategy_np = matrix[:, 0] 

640 benchmark_np = matrix[:, 1] 

641 

642 # 2x2 covariance matrix: [[var_strategy, cov], [cov, var_benchmark]] 

643 cov_matrix = np.cov(strategy_np, benchmark_np) 

644 

645 cov = cov_matrix[0, 1] 

646 var_benchmark = cov_matrix[1, 1] 

647 

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)) 

650 

651 return {"alpha": float(alpha * ppy), "beta": beta} 

652 

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. 

661 

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. 

665 

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. 

672 

673 Returns: 

674 float: Treynor ratio, or ``nan`` when beta is zero or the benchmark 

675 is unavailable. 

676 

677 Raises: 

678 AttributeError: If no benchmark data is attached. 

679 

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 

686 

687 ppy = periods or self._data._periods_per_year 

688 

689 benchmark_data = self._data.benchmark 

690 benchmark_col = benchmark or benchmark_data.columns[0] 

691 

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] 

697 

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") 

705 

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