Coverage for src / jquantstats / _stats / _internals.py: 100%
17 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-07 14:28 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-07 14:28 +0000
1"""Shared computational helpers for statistics mixin modules.
3Contains pure, reusable sub-computations that are used by two or more of
4the stats mixin modules (:mod:`~jquantstats._stats._basic`,
5:mod:`~jquantstats._stats._performance`,
6:mod:`~jquantstats._stats._reporting`,
7:mod:`~jquantstats._stats._rolling`).
9Helpers
10-------
11:func:`_comp_return`
12 Total compounded return: ``∏(1 + rᵢ) - 1``.
13:func:`_nav_series`
14 Cumulative NAV (price) series: ``cum_prod(1 + rᵢ)``.
15:func:`_annualization_factor`
16 ``sqrt(periods)`` or ``periods`` for annualizing rates and ratios.
17:func:`_downside_deviation`
18 Downside semi-deviation used by the Sortino ratio family.
20All functions operate on a :class:`polars.Series` of returns and are
21intentionally free of side-effects so that they are easy to test and
22compose.
23"""
25from __future__ import annotations
27import math
29import polars as pl
32def _comp_return(series: pl.Series) -> float:
33 """Compute the total compounded return over a full period.
35 Computed as ``∏(1 + rᵢ) - 1`` after dropping null values and casting
36 to ``Float64``.
38 Args:
39 series: A Polars Series of per-period returns.
41 Returns:
42 float: Total compounded return.
44 Examples:
45 >>> import polars as pl
46 >>> s = pl.Series([0.1, -0.05, 0.2])
47 >>> round(_comp_return(s), 4)
48 0.254
50 >>> _comp_return(pl.Series([], dtype=pl.Float64))
51 0.0
52 """
53 return float((1.0 + series.drop_nulls().cast(pl.Float64)).product()) - 1.0
56def _nav_series(series: pl.Series) -> pl.Series:
57 """Convert a returns series to a cumulative NAV (price) series.
59 Computed as ``cum_prod(1 + rᵢ)``, which gives the value of one unit of
60 currency invested at the start of the series.
62 Args:
63 series: A Polars Series of per-period returns.
65 Returns:
66 pl.Series: Float64 series of cumulative NAV values (starts at ``1 + r₁``).
68 Examples:
69 >>> import polars as pl
70 >>> s = pl.Series([0.0, 0.1, -0.1])
71 >>> [round(x, 10) for x in _nav_series(s).to_list()]
72 [1.0, 1.1, 0.99]
73 """
74 return (1.0 + series.cast(pl.Float64)).cum_prod()
77def _annualization_factor(periods: int | float, sqrt: bool = True) -> float:
78 """Return the annualization factor for a given number of periods per year.
80 When ``sqrt=True`` (the default) returns ``sqrt(periods)``, which is used
81 to annualize ratios such as Sharpe and Sortino. When ``sqrt=False``
82 returns ``periods`` directly, which is used for linear scaling.
84 Args:
85 periods: Number of observations per calendar year (e.g. 252 for daily
86 equity returns).
87 sqrt: Whether to return the square-root scaling factor. Defaults to
88 ``True``.
90 Returns:
91 float: ``sqrt(periods)`` when *sqrt* is ``True``, else ``periods``.
93 Raises:
94 ValueError: If *periods* is not a positive finite number.
96 Examples:
97 >>> _annualization_factor(252)
98 15.874507866387544
100 >>> _annualization_factor(252, sqrt=False)
101 252.0
103 >>> _annualization_factor(12)
104 3.4641016151377544
105 """
106 if periods <= 0 or not math.isfinite(periods):
107 raise ValueError(f"periods must be a positive finite number, got {periods!r}") # noqa: TRY003
108 return math.sqrt(periods) if sqrt else float(periods)
111def _downside_deviation(series: pl.Series) -> float:
112 r"""Compute the downside semi-deviation of a returns series.
114 Calculates the root-mean-square of all *negative* returns:
116 .. math::
118 \text{downside\_dev} = \sqrt{\frac{\sum_{r_t < 0} r_t^2}{N}}
120 where *N* is the total number of observations (not just the negative ones).
121 This is the convention used in the Red Rock Capital Sortino ratio paper and
122 matches quantstats' implementation.
124 Args:
125 series: A Polars Series of per-period returns.
127 Returns:
128 float: Downside semi-deviation (always non-negative).
130 Examples:
131 >>> import polars as pl
132 >>> s = pl.Series([0.05, -0.02, 0.03, -0.01, 0.0])
133 >>> round(_downside_deviation(s), 10)
134 0.01
136 >>> _downside_deviation(pl.Series([0.1, 0.2, 0.3]))
137 0.0
138 """
139 downside_sum = float(((series.filter(series < 0)) ** 2).sum())
140 n = series.count()
141 if n == 0:
142 return 0.0
143 return math.sqrt(downside_sum / n)