Coverage for src/jquantstats/_stats/_basic.py: 100%
135 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"""Composite ratios, streak, outlier and autocorrelation statistics.
3`_BasicStatsMixin` extends `_BasicCoreMixin` with the higher-level
4metrics that combine the core statistics (e.g. CPC index, common-sense
5ratio, Kelly criterion) together with streak, outlier and
6autocorrelation analytics. It remains the public mixin consumed by
7`Stats`.
8"""
10from __future__ import annotations
12import math
13from typing import cast
15import numpy as np
16import polars as pl
18from ._basic_core import _BasicCoreMixin
19from ._core import _mean, columnwise_stat
22class _BasicStatsMixin(_BasicCoreMixin):
23 """Mixin providing basic return/risk and win/loss financial statistics.
25 Extends `_BasicCoreMixin` with composite ratios (CPC, common-sense,
26 Kelly), streak and outlier analytics, and autocorrelation metrics.
27 It is the public mixin consumed by `Stats`.
28 """
30 @columnwise_stat
31 def autocorr_penalty(self, series: pl.Series) -> float:
32 """Calculate the autocorrelation penalty for risk-adjusted metrics.
34 Computes a penalty factor that accounts for autocorrelation in returns,
35 which can inflate Sharpe and Sortino ratios.
37 Args:
38 series (pl.Series): The series to calculate autocorrelation penalty for.
40 Returns:
41 float: Autocorrelation penalty factor (>= 1).
43 """
44 arr = series.drop_nulls().to_numpy()
45 num = len(arr)
46 coef = float(np.abs(np.corrcoef(arr[:-1], arr[1:])[0, 1]))
47 x = np.arange(1, num)
48 corr = ((num - x) / num) * (coef**x)
49 return float(np.sqrt(1 + 2 * np.sum(corr)))
51 @staticmethod
52 def _max_consecutive(mask: pl.Series) -> int:
53 """Return the longest run of True values in a boolean mask.
55 Args:
56 mask (pl.Series): Boolean series (True = qualifying period).
58 Returns:
59 int: Length of the longest consecutive True run.
61 """
62 group_ids = mask.rle_id()
63 df = pl.DataFrame({"v": mask.cast(pl.Int32), "g": group_ids})
64 result = (
65 df.with_columns((pl.int_range(pl.len()).over("g") + 1).alias("rank"))
66 .select((pl.col("v") * pl.col("rank")).max())
67 .item()
68 )
69 return int(result) if result is not None else 0
71 @columnwise_stat
72 def consecutive_wins(self, series: pl.Series) -> int:
73 """Calculate the maximum number of consecutive winning periods.
75 Args:
76 series (pl.Series): The series to calculate consecutive wins for.
78 Returns:
79 int: Maximum number of consecutive winning periods.
81 """
82 return self._max_consecutive(series > 0)
84 @columnwise_stat
85 def consecutive_losses(self, series: pl.Series) -> int:
86 """Calculate the maximum number of consecutive losing periods.
88 Args:
89 series (pl.Series): The series to calculate consecutive losses for.
91 Returns:
92 int: Maximum number of consecutive losing periods.
94 """
95 return self._max_consecutive(series < 0)
97 @columnwise_stat
98 def risk_of_ruin(self, series: pl.Series) -> float:
99 """Calculate the risk of ruin (probability of losing all capital).
101 Uses the formula: ((1 - win_rate) / (1 + win_rate)) ^ n,
102 where n is the number of periods.
104 Args:
105 series (pl.Series): The series to calculate risk of ruin for.
107 Returns:
108 float: The risk of ruin probability.
110 """
111 num_pos = self._positive(series).count()
112 num_nonzero = series.filter(series != 0).count()
113 wins = float(num_pos / num_nonzero)
114 n = series.len()
115 return ((1 - wins) / (1 + wins)) ** n
117 @columnwise_stat
118 def tail_ratio(self, series: pl.Series, cutoff: float = 0.95) -> float:
119 """Calculate the tail ratio (right tail / left tail).
121 Measures the ratio between the upper and lower tails of the return
122 distribution: abs(quantile(cutoff) / quantile(1 - cutoff)).
124 Args:
125 series (pl.Series): The series to calculate tail ratio for.
126 cutoff (float): Percentile cutoff for tail analysis. Defaults to 0.95.
128 Returns:
129 float: Tail ratio.
132 Returns NaN when:
133 ``float("nan")`` when either quantile is missing or the lower quantile
134 is zero.
135 """
136 upper = cast(float, series.quantile(cutoff, interpolation="linear"))
137 lower = cast(float, series.quantile(1 - cutoff, interpolation="linear"))
138 if upper is None or lower is None or lower == 0:
139 return float("nan") # indeterminate: zero or missing quantile
140 return float(np.abs(upper / lower))
142 def cpc_index(self) -> dict[str, float]:
143 """Calculate the CPC Index (Profit Factor * Win Rate * Win-Loss Ratio).
145 Returns:
146 dict[str, float]: Dictionary mapping asset names to CPC Index values.
148 """
149 pf = self.profit_factor()
150 wr = self.win_rate()
151 wlr = self.payoff_ratio()
152 return {col: pf[col] * wr[col] * wlr[col] for col in pf}
154 def common_sense_ratio(self) -> dict[str, float]:
155 """Calculate the Common Sense Ratio (Profit Factor * Tail Ratio).
157 Returns:
158 dict[str, float]: Dictionary mapping asset names to Common Sense Ratio values.
160 """
161 pf = self.profit_factor()
162 tr = self.tail_ratio()
163 return {col: pf[col] * tr[col] for col in pf}
165 def outliers(self, quantile: float = 0.95) -> dict[str, pl.Series]:
166 """Return only the returns above a quantile threshold.
168 Args:
169 quantile (float): Upper quantile threshold. Defaults to 0.95.
171 Returns:
172 dict[str, pl.Series]: Filtered series per asset containing only
173 returns above the quantile.
175 """
176 result = {}
177 for col, series in self._data.items():
178 threshold = cast(float, series.quantile(quantile, interpolation="linear"))
179 result[col] = series.filter(series > threshold).drop_nulls()
180 return result
182 def remove_outliers(self, quantile: float = 0.95) -> dict[str, pl.Series]:
183 """Return returns with values above a quantile threshold removed.
185 Args:
186 quantile (float): Upper quantile threshold. Defaults to 0.95.
188 Returns:
189 dict[str, pl.Series]: Filtered series per asset containing only
190 returns below the quantile.
192 """
193 result = {}
194 for col, series in self._data.items():
195 threshold = cast(float, series.quantile(quantile, interpolation="linear"))
196 result[col] = series.filter(series < threshold)
197 return result
199 @columnwise_stat
200 def outlier_win_ratio(self, series: pl.Series, quantile: float = 0.99) -> float:
201 """Calculate the outlier winners ratio.
203 Ratio of the high-quantile return to the mean positive return,
204 showing how much outlier wins contribute to overall performance.
206 Args:
207 series (pl.Series): The series to calculate outlier win ratio for.
208 quantile (float): Quantile for the outlier threshold. Defaults to 0.99.
210 Returns:
211 float: Outlier win ratio.
214 Returns NaN when:
215 ``float("nan")`` when the mean of non-negative returns is zero.
216 """
217 positive_mean = _mean(series.filter(series >= 0))
218 if positive_mean == 0:
219 return float("nan") # indeterminate: zero mean of positive returns
220 quantile_val = cast(float, series.quantile(quantile, interpolation="linear"))
221 return float(quantile_val / positive_mean)
223 @columnwise_stat
224 def outlier_loss_ratio(self, series: pl.Series, quantile: float = 0.01) -> float:
225 """Calculate the outlier losers ratio.
227 Ratio of the low-quantile return to the mean negative return,
228 showing how much outlier losses contribute to overall risk.
230 Args:
231 series (pl.Series): The series to calculate outlier loss ratio for.
232 quantile (float): Quantile for the outlier threshold. Defaults to 0.01.
234 Returns:
235 float: Outlier loss ratio.
238 Returns NaN when:
239 ``float("nan")`` when the mean of negative returns is zero.
240 """
241 negative_mean = self._mean_negative_expr(series)
242 if negative_mean == 0: # pragma: no cover
243 return float("nan") # indeterminate: zero mean of negative returns
244 quantile_val = cast(float, series.quantile(quantile, interpolation="linear"))
245 return float(quantile_val / negative_mean)
247 @columnwise_stat
248 def gain_to_pain_ratio(self, series: pl.Series) -> float:
249 """Calculate Jack Schwager's Gain-to-Pain Ratio.
251 The ratio is calculated as total return / sum of losses (in absolute value).
253 Args:
254 series (pl.Series): The series to calculate gain to pain ratio for.
256 Returns:
257 float: The gain to pain ratio value.
260 Returns NaN when:
261 ``float("nan")`` when there are no losses (the denominator is zero).
262 """
263 total_gain = series.sum()
264 total_pain = self._negative(series).abs().sum()
265 try:
266 return float(float(total_gain) / float(total_pain))
267 except ZeroDivisionError:
268 return float("nan") # indeterminate: no losses (denominator is zero)
270 @columnwise_stat
271 def risk_return_ratio(self, series: pl.Series) -> float:
272 """Calculate the return/risk ratio.
274 This is equivalent to the Sharpe ratio without a risk-free rate.
276 Args:
277 series (pl.Series): The series to calculate risk return ratio for.
279 Returns:
280 float: The risk return ratio value.
282 """
283 mean_val = _mean(series)
284 std_val = cast(float, series.std())
285 return mean_val / (std_val if std_val is not None else 1.0)
287 def kelly_criterion(self) -> dict[str, float]:
288 """Calculate the optimal capital allocation per column.
290 Uses the Kelly Criterion formula: f* = [(b * p) - q] / b
291 where:
292 - b = payoff ratio
293 - p = win rate
294 - q = 1 - p.
296 Returns:
297 dict[str, float]: Dictionary mapping asset names to Kelly criterion values.
299 """
300 b = self.payoff_ratio()
301 p = self.win_rate()
303 return {col: ((b[col] * p[col]) - (1 - p[col])) / b[col] for col in b}
305 @columnwise_stat
306 def best(self, series: pl.Series) -> float | None:
307 """Find the maximum return per column (best period).
309 Args:
310 series (pl.Series): The series to find the best return for.
312 Returns:
313 float: The maximum return value.
315 """
316 val = cast(float, series.max())
317 return val if val is not None else None
319 @columnwise_stat
320 def worst(self, series: pl.Series) -> float | None:
321 """Find the minimum return per column (worst period).
323 Args:
324 series (pl.Series): The series to find the worst return for.
326 Returns:
327 float: The minimum return value.
329 """
330 val = cast(float, series.min())
331 return val if val is not None else None
333 @columnwise_stat
334 def exposure(self, series: pl.Series) -> float:
335 """Calculate the market exposure time (returns != 0).
337 Args:
338 series (pl.Series): The series to calculate exposure for.
340 Returns:
341 float: The exposure value.
343 """
344 all_data = self.all
345 ex = series.filter(series != 0).count() / all_data.height
346 return math.ceil(ex * 100) / 100
348 @staticmethod
349 def _pearson_corr_shifted(series: pl.Series, lag: int) -> float:
350 """Compute Pearson correlation between *series* and its lag-*lag* shift.
352 Args:
353 series (pl.Series): The input series.
354 lag (int): Number of positions to shift.
356 Returns:
357 float: Pearson correlation coefficient, or NaN if no valid pairs remain.
359 """
360 shifted = series.shift(lag)
361 paired = pl.DataFrame({"x": series, "y": shifted}).drop_nulls()
362 # Large lags or null-only overlap can leave no aligned observations to correlate.
363 if paired.is_empty():
364 return float("nan")
365 return float(np.corrcoef(paired["x"].to_numpy(), paired["y"].to_numpy())[0, 1])
367 @columnwise_stat
368 def autocorr(self, series: pl.Series, lag: int = 1) -> float:
369 """Compute lag-n autocorrelation of returns.
371 Args:
372 series (pl.Series): The series to calculate autocorrelation for.
373 lag (int): Number of periods to lag. Must be a positive integer.
375 Returns:
376 float: Pearson correlation between returns and their lagged values.
378 Raises:
379 TypeError: If *lag* is not an ``int``.
380 ValueError: If *lag* is not a positive integer (>= 1).
382 """
383 if not isinstance(lag, int):
384 msg = f"lag must be an int, got {type(lag).__name__}"
385 raise TypeError(msg)
386 if lag <= 0:
387 msg = f"lag must be a positive integer, got {lag}"
388 raise ValueError(msg)
389 return self._pearson_corr_shifted(series, lag)
391 def acf(self, nlags: int = 20) -> pl.DataFrame:
392 """Compute the autocorrelation function up to nlags.
394 Args:
395 nlags (int): Maximum number of lags to include. Default is 20.
397 Returns:
398 pl.DataFrame: DataFrame with a ``lag`` column (0..nlags) and one
399 column per asset containing the ACF values.
401 Raises:
402 TypeError: If *nlags* is not an ``int``.
403 ValueError: If *nlags* is negative.
405 """
406 if not isinstance(nlags, int):
407 msg = f"nlags must be an int, got {type(nlags).__name__}"
408 raise TypeError(msg)
409 if nlags < 0:
410 msg = f"nlags must be non-negative, got {nlags}"
411 raise ValueError(msg)
412 result: dict[str, list[float]] = {"lag": list(range(nlags + 1))}
413 for col, series in self._data.items():
414 acf_values: list[float] = [1.0]
415 for k in range(1, nlags + 1):
416 acf_values.append(self._pearson_corr_shifted(series, k))
417 result[col] = acf_values
418 return pl.DataFrame(result)