Coverage for src/jquantstats/_stats/_basic_core.py: 100%

164 statements  

« prev     ^ index     » next       coverage.py v7.17.1, created at 2026-11-03 15:52 +0000

1"""Core descriptive, return, volatility and risk statistics. 

2 

3The `_BasicCoreMixin` here holds the leaf statistics that the composite 

4ratios in `_basic` build on, plus the shared static helpers. 

5""" 

6 

7from __future__ import annotations 

8 

9from typing import TYPE_CHECKING, cast 

10 

11import numpy as np 

12import polars as pl 

13from scipy.stats import norm 

14 

15from ._core import _mean, columnwise_stat 

16from ._internals import _annualization_factor, _comp_return 

17 

18if TYPE_CHECKING: 

19 from ..data import Data 

20 

21# ── Basic statistics mixin ─────────────────────────────────────────────────── 

22 

23 

24class _BasicCoreMixin: 

25 """Mixin providing basic return/risk and win/loss financial statistics. 

26 

27 Covers: basic statistics (skew, kurtosis, avg return/win/loss), volatility, 

28 win/loss metrics (payoff ratio, profit factor), and risk metrics (VaR, CVaR, 

29 win rate, kelly criterion, best/worst, exposure). 

30 """ 

31 

32 _data: Data 

33 all: pl.DataFrame 

34 

35 if TYPE_CHECKING: 

36 from .._protocol import DataLike 

37 

38 data: DataLike 

39 

40 @staticmethod 

41 def _positive(series: pl.Series) -> pl.Series: 

42 """Return only the positive values in *series*.""" 

43 return series.filter(series > 0) 

44 

45 @staticmethod 

46 def _negative(series: pl.Series) -> pl.Series: 

47 """Return only the negative values in *series*.""" 

48 return series.filter(series < 0) 

49 

50 @staticmethod 

51 def _mean_positive_expr(series: pl.Series) -> float: 

52 """Return the mean of all positive values in *series*, or NaN if none exist.""" 

53 return _mean(_BasicCoreMixin._positive(series)) 

54 

55 @staticmethod 

56 def _mean_negative_expr(series: pl.Series) -> float: 

57 """Return the mean of all negative values in *series*, or NaN if none exist.""" 

58 return _mean(_BasicCoreMixin._negative(series)) 

59 

60 @staticmethod 

61 def _gaussian_quantile(alpha: float, mu: float, sigma: float) -> float: 

62 """Gaussian inverse-CDF (``norm.ppf``) returning NaN for a zero-scale input. 

63 

64 ``norm.ppf(alpha, mu, 0.0)`` already returns ``nan`` for a degenerate 

65 (zero-variance) distribution — but it emits an ``invalid value 

66 encountered in multiply`` RuntimeWarning while doing so (``inf * 0`` 

67 internally). Degenerate scale arises for a single observation (undefined 

68 std) or a constant series. Short-circuiting to ``float("nan")`` keeps the 

69 exact same result while suppressing the spurious warning; downstream 

70 masking relies on this NaN (Polars treats ``x < nan`` as ``True``). 

71 """ 

72 return float("nan") if sigma == 0.0 else float(norm.ppf(alpha, mu, sigma)) 

73 

74 # ── Basic statistics ────────────────────────────────────────────────────── 

75 

76 @columnwise_stat 

77 def skew(self, series: pl.Series) -> int | float | None: 

78 """Calculate skewness (asymmetry) for each numeric column. 

79 

80 Args: 

81 series (pl.Series): The series to calculate skewness for. 

82 

83 Returns: 

84 float: The skewness value. 

85 

86 """ 

87 return series.skew(bias=False) 

88 

89 @columnwise_stat 

90 def kurtosis(self, series: pl.Series) -> int | float | None: 

91 """Calculate the kurtosis of returns. 

92 

93 The degree to which a distribution peak compared to a normal distribution. 

94 

95 Args: 

96 series (pl.Series): The series to calculate kurtosis for. 

97 

98 Returns: 

99 float: The kurtosis value. 

100 

101 """ 

102 return series.kurtosis(bias=False) 

103 

104 @columnwise_stat 

105 def avg_return(self, series: pl.Series) -> float: 

106 """Calculate average return per non-zero value. 

107 

108 Args: 

109 series (pl.Series): The series to calculate average return for. 

110 

111 Returns: 

112 float: The average return value. 

113 

114 """ 

115 return _mean(series.filter(series.is_not_null() & (series != 0))) 

116 

117 @columnwise_stat 

118 def avg_win(self, series: pl.Series) -> float: 

119 """Calculate the average winning return/trade for an asset. 

120 

121 Args: 

122 series (pl.Series): The series to calculate average win for. 

123 

124 Returns: 

125 float: The average winning return. 

126 

127 """ 

128 return self._mean_positive_expr(series) 

129 

130 @columnwise_stat 

131 def avg_loss(self, series: pl.Series) -> float: 

132 """Calculate the average loss return/trade for a period. 

133 

134 Args: 

135 series (pl.Series): The series to calculate average loss for. 

136 

137 Returns: 

138 float: The average loss return. 

139 

140 """ 

141 return self._mean_negative_expr(series) 

142 

143 @columnwise_stat 

144 def comp(self, series: pl.Series) -> float: 

145 """Calculate the total compounded return over the full period. 

146 

147 Computed as product(1 + r) - 1. 

148 

149 Args: 

150 series (pl.Series): The series to calculate compounded return for. 

151 

152 Returns: 

153 float: Total compounded return. 

154 

155 """ 

156 return _comp_return(series) 

157 

158 @columnwise_stat 

159 def geometric_mean(self, series: pl.Series, periods: int | float | None = None, annualize: bool = False) -> float: 

160 """Calculate the geometric mean of returns. 

161 

162 Computed as the per-period geometric average: (∏(1 + rᵢ))^(1/n) - 1. 

163 When annualized, raises to the power of periods_per_year instead of 1/n. 

164 

165 Args: 

166 series (pl.Series): The series to calculate geometric mean for. 

167 periods (int | float, optional): Periods per year for annualization. Defaults to periods_per_year. 

168 annualize (bool): Whether to annualize the result. Defaults to False. 

169 

170 Returns: 

171 float: The geometric mean return. 

172 

173 

174 Returns NaN when: 

175 ``float("nan")`` when the series has no non-null observations or the 

176 compounded return ``product(1 + r)`` is non-positive. 

177 """ 

178 clean = series.drop_nulls().cast(pl.Float64) 

179 n = clean.len() 

180 if n == 0: 

181 return float("nan") # indeterminate: no observations 

182 compound = float((1.0 + clean).product()) 

183 if compound <= 0: 

184 return float("nan") # indeterminate: non-positive compound return 

185 exponent = (periods or self._data._periods_per_year) / n if annualize else (1.0 / n) 

186 return float(compound**exponent) - 1.0 

187 

188 # ── Volatility & risk ───────────────────────────────────────────────────── 

189 

190 @columnwise_stat 

191 def volatility(self, series: pl.Series, periods: int | float | None = None, annualize: bool = True) -> float: 

192 """Calculate the volatility of returns. 

193 

194 - Std dev of returns 

195 - Annualized by sqrt(periods) if `annualize` is True. 

196 

197 Args: 

198 series (pl.Series): The series to calculate volatility for. 

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

200 annualize (bool, optional): Whether to annualize the result. Defaults to True. 

201 

202 Returns: 

203 float: The volatility value. 

204 

205 """ 

206 raw_periods = periods or self._data._periods_per_year 

207 

208 # Ensure it's numeric 

209 if not isinstance(raw_periods, int | float): 

210 raise TypeError(f"Expected int or float for periods, got {type(raw_periods).__name__}") # noqa: TRY003 

211 

212 factor = _annualization_factor(raw_periods) if annualize else 1.0 

213 std_val = cast(float, series.std()) 

214 return (std_val if std_val is not None else 0.0) * factor 

215 

216 @columnwise_stat 

217 def mad(self, series: pl.Series, periods: int | float | None = None, annualize: bool = True) -> float: 

218 """Calculate the Mean Absolute Deviation (MAD) of returns. 

219 

220 MAD is the mean of absolute deviations from the mean return: 

221 mean(|r - mean(r)|). It is a robust measure of dispersion less 

222 sensitive to outliers than standard deviation. 

223 

224 - Annualized by sqrt(periods) if `annualize` is True. 

225 

226 Args: 

227 series (pl.Series): The series to calculate MAD for. 

228 periods (int, optional): Number of periods per year. Defaults to periods_per_year. 

229 annualize (bool, optional): Whether to annualize the result. Defaults to True. 

230 

231 Returns: 

232 float: The MAD value. 

233 """ 

234 raw_periods = periods or self._data._periods_per_year 

235 

236 if not isinstance(raw_periods, int | float): 

237 raise TypeError(f"Expected int or float for periods, got {type(raw_periods).__name__}") # noqa: TRY003 

238 

239 factor = _annualization_factor(raw_periods) if annualize else 1.0 

240 mean_val = _mean(series) 

241 mad_val = cast(float, (series - mean_val).abs().mean()) 

242 return (mad_val if mad_val is not None else 0.0) * factor 

243 

244 # ── Win / loss metrics ──────────────────────────────────────────────────── 

245 

246 @columnwise_stat 

247 def payoff_ratio(self, series: pl.Series) -> float: 

248 """Measure the payoff ratio. 

249 

250 The payoff ratio is calculated as average win / abs(average loss). 

251 

252 Args: 

253 series (pl.Series): The series to calculate payoff ratio for. 

254 

255 Returns: 

256 float: The payoff ratio value. 

257 

258 """ 

259 avg_win = self._mean_positive_expr(series) 

260 avg_loss = float(np.abs(self._mean_negative_expr(series))) 

261 return avg_win / avg_loss 

262 

263 @columnwise_stat 

264 def profit_ratio(self, series: pl.Series) -> float: 

265 """Measure the profit ratio. 

266 

267 The profit ratio is calculated as win ratio / loss ratio. 

268 

269 Args: 

270 series (pl.Series): The series to calculate profit ratio for. 

271 

272 Returns: 

273 float: The profit ratio value. 

274 

275 

276 Returns NaN when: 

277 ``float("nan")`` when the series has no wins or no losses. 

278 """ 

279 wins = series.filter(series >= 0) 

280 losses = self._negative(series) 

281 

282 # Filtering can legitimately leave no wins or no losses for one-sided return series. 

283 if wins.is_empty() or losses.is_empty(): 

284 return float("nan") # indeterminate: no wins or no losses 

285 

286 win_mean = _mean(wins) 

287 loss_mean = _mean(losses) 

288 win_ratio = float(np.abs(win_mean / wins.count())) 

289 loss_ratio = float(np.abs(loss_mean / losses.count())) 

290 

291 return win_ratio / loss_ratio 

292 

293 @columnwise_stat 

294 def profit_factor(self, series: pl.Series) -> float: 

295 """Measure the profit factor. 

296 

297 The profit factor is calculated as wins / loss. 

298 

299 Args: 

300 series (pl.Series): The series to calculate profit factor for. 

301 

302 Returns: 

303 float: The profit factor value. 

304 

305 """ 

306 wins = self._positive(series) 

307 losses = self._negative(series) 

308 wins_sum = wins.sum() 

309 losses_sum = losses.sum() 

310 

311 return float(np.abs(float(wins_sum) / float(losses_sum))) 

312 

313 # ── Risk metrics ────────────────────────────────────────────────────────── 

314 

315 @columnwise_stat 

316 def value_at_risk(self, series: pl.Series, sigma: float = 1.0, alpha: float = 0.05) -> float: 

317 """Calculate the daily value-at-risk. 

318 

319 Uses variance-covariance calculation with confidence level. 

320 

321 Args: 

322 series (pl.Series): The series to calculate value at risk for. 

323 alpha (float, optional): Confidence level. Defaults to 0.05. 

324 sigma (float, optional): Standard deviation multiplier. Defaults to 1.0. 

325 

326 Returns: 

327 float: The value at risk. 

328 

329 """ 

330 mean_val = _mean(series) 

331 std_val = cast(float, series.std()) 

332 mu = mean_val 

333 sigma *= std_val if std_val is not None else 0.0 

334 

335 return self._gaussian_quantile(alpha, mu, sigma) 

336 

337 @staticmethod 

338 def _gaussian_expected_shortfall(alpha: float, mu: float, sigma: float) -> float: 

339 """Closed-form expected shortfall ``E[X | X <= VaR_alpha]`` of ``N(mu, sigma)``. 

340 

341 This is the estimator that belongs with `value_at_risk`'s 

342 variance-covariance method. A degenerate (zero-scale) distribution has 

343 no tail beyond its single point, so the shortfall is ``mu`` itself. 

344 """ 

345 if sigma == 0.0: 

346 return mu 

347 return float(mu - sigma * norm.pdf(norm.ppf(alpha)) / alpha) 

348 

349 @columnwise_stat 

350 def _conditional_value_at_risk_impl( 

351 self, series: pl.Series, sigma: float = 1.0, alpha: float = 0.05, method: str = "parametric" 

352 ) -> float: 

353 """Inner per-series implementation of conditional value-at-risk.""" 

354 series = series.drop_nulls().drop_nans() 

355 if series.is_empty(): 

356 return float("nan") 

357 if method == "historical": 

358 threshold = cast(float, series.quantile(alpha, interpolation="linear")) 

359 return _mean(series.filter(series <= threshold)) 

360 std_val = cast(float | None, series.std()) 

361 if std_val is None: 

362 return float("nan") # indeterminate: a single observation has no spread 

363 return self._gaussian_expected_shortfall(alpha, _mean(series), sigma * std_val) 

364 

365 def conditional_value_at_risk( 

366 self, 

367 sigma: float = 1.0, 

368 confidence: float | None = None, 

369 alpha: float | None = None, 

370 method: str = "parametric", 

371 ) -> dict[str, float]: 

372 """Calculate the conditional value-at-risk (CVaR / Expected Shortfall). 

373 

374 Also known as CVaR or expected shortfall, calculated for each numeric column. 

375 

376 The tail can be specified either way round: ``confidence`` matches the 

377 QuantStats spelling, ``alpha`` matches `value_at_risk` on this same 

378 object. They are two names for one quantity (``alpha = 1 - confidence``), 

379 so passing both is an error rather than a silent preference. 

380 

381 Args: 

382 sigma (float, optional): Standard deviation multiplier. Defaults to 1.0. 

383 confidence (float, optional): Confidence level (e.g. 0.95 for 95 %). 

384 Converted internally to ``alpha = 1 - confidence``. Mutually 

385 exclusive with ``alpha``. 

386 alpha (float, optional): Tail probability in the *loss* tail (e.g. 0.05 

387 for 95 % confidence). Mutually exclusive with ``confidence``. 

388 Both defaulting to ``None`` selects ``alpha = 0.05``. 

389 method (str, optional): ``"parametric"`` (default) is the closed-form 

390 normal expected shortfall, consistent with `value_at_risk`. 

391 ``"historical"`` averages the observations at or below the 

392 empirical ``alpha`` quantile, which captures fat tails but needs 

393 enough data to be meaningful. 

394 

395 Returns: 

396 dict[str, float]: The conditional value at risk per asset column. 

397 

398 Raises: 

399 ValueError: If both ``confidence`` and ``alpha`` are given, or 

400 ``method`` is not ``"parametric"`` or ``"historical"``. 

401 

402 """ 

403 if method not in ("parametric", "historical"): 

404 raise ValueError(f"method must be 'parametric' or 'historical', got {method!r}") # noqa: TRY003 

405 if confidence is not None and alpha is not None: 

406 raise ValueError( # noqa: TRY003 

407 f"Pass either confidence or alpha, not both " 

408 f"(got confidence={confidence!r}, alpha={alpha!r}); alpha = 1 - confidence" 

409 ) 

410 if confidence is not None: 

411 alpha = 1.0 - confidence 

412 elif alpha is None: 

413 alpha = 0.05 

414 

415 return self._conditional_value_at_risk_impl(sigma=sigma, alpha=alpha, method=method) 

416 

417 @staticmethod 

418 def _drawdown_with_baseline(series: pl.Series) -> pl.Series: 

419 """Compute drawdown series with a phantom zero-return baseline prepended. 

420 

421 Matches the quantstats convention: a negative first return is treated as 

422 a drawdown from the initial capital of 1.0, not as the new high-water mark. 

423 """ 

424 extended = pl.concat([pl.Series([0.0]), series.cast(pl.Float64)]) 

425 nav = (1.0 + extended).cum_prod() 

426 hwm = nav.cum_max() 

427 # The phantom baseline pins nav[0] = 1.0, so hwm >= 1.0 throughout and 

428 # the 1e-10 floor is purely defensive (unreachable); a -100 % return 

429 # correctly reports as a full drawdown of 1.0 here. 

430 dd = ((hwm - nav) / hwm.clip(lower_bound=1e-10)).clip(lower_bound=0.0) 

431 return dd[1:] # drop phantom point 

432 

433 @staticmethod 

434 def _ulcer_index_series(series: pl.Series) -> float: 

435 """Compute ulcer index for a single returns series.""" 

436 dd = _BasicCoreMixin._drawdown_with_baseline(series) 

437 n = series.len() 

438 return float(np.sqrt(float((dd**2).sum()) / (n - 1))) 

439 

440 @columnwise_stat 

441 def ulcer_index(self, series: pl.Series) -> float: 

442 """Calculate the Ulcer Index (downside risk measurement). 

443 

444 Measures the depth and duration of drawdowns as the root mean square 

445 of squared drawdowns: sqrt(sum(dd²) / (n - 1)). 

446 

447 Args: 

448 series (pl.Series): The series to calculate ulcer index for. 

449 

450 Returns: 

451 float: Ulcer Index value. 

452 

453 """ 

454 return self._ulcer_index_series(series) 

455 

456 @columnwise_stat 

457 def ulcer_performance_index(self, series: pl.Series, rf: float = 0.0) -> float: 

458 """Calculate the Ulcer Performance Index (UPI). 

459 

460 Risk-adjusted return using Ulcer Index as the risk measure: 

461 (compounded_return - rf) / ulcer_index. 

462 

463 Args: 

464 series (pl.Series): The series to calculate UPI for. 

465 rf (float): Risk-free rate. Defaults to 0. 

466 

467 Returns: 

468 float: Ulcer Performance Index. 

469 

470 

471 Returns NaN when: 

472 ``float("nan")`` when the ulcer index is zero (no drawdowns). 

473 """ 

474 comp = _comp_return(series) 

475 ui = self._ulcer_index_series(series) 

476 return float("nan") if ui == 0 else (comp - rf) / ui 

477 

478 @columnwise_stat 

479 def serenity_index(self, series: pl.Series, rf: float = 0.0) -> float: 

480 """Calculate the Serenity Index. 

481 

482 Combines the Ulcer Index with a CVaR-based pitfall measure: 

483 (sum_returns - rf) / (ulcer_index * pitfall), where 

484 pitfall = -CVaR(drawdowns) / std(returns), using the parametric 

485 (Gaussian) CVaR as QuantStats does. 

486 

487 Args: 

488 series (pl.Series): The series to calculate serenity index for. 

489 rf (float): Risk-free rate. Defaults to 0. 

490 

491 Returns: 

492 float: Serenity Index. 

493 

494 

495 Returns NaN when: 

496 ``float("nan")`` when the returns have zero (or undefined) standard 

497 deviation or the denominator ``ulcer_index * pitfall`` is zero. 

498 """ 

499 std_val = cast(float, series.std()) 

500 if not std_val: 

501 return float("nan") # indeterminate: zero variance 

502 

503 # Negate drawdowns to match quantstats sign convention (negative = below peak) 

504 dd_neg = -self._drawdown_with_baseline(series) 

505 cvar_val = self._gaussian_expected_shortfall(0.05, _mean(dd_neg), cast(float, dd_neg.std())) 

506 

507 pitfall = -cvar_val / std_val 

508 ui = self._ulcer_index_series(series) 

509 denominator = ui * pitfall 

510 return float("nan") if denominator == 0 else (float(series.sum()) - rf) / denominator 

511 

512 @columnwise_stat 

513 def win_rate(self, series: pl.Series) -> float: 

514 """Calculate the win ratio for a period. 

515 

516 Args: 

517 series (pl.Series): The series to calculate win rate for. 

518 

519 Returns: 

520 float: The win rate value. 

521 

522 """ 

523 num_pos = self._positive(series).count() 

524 num_nonzero = series.filter(series != 0).count() 

525 return float(num_pos / num_nonzero)