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

1"""Shared computational helpers for statistics mixin modules. 

2 

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

8 

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. 

19 

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

24 

25from __future__ import annotations 

26 

27import math 

28 

29import polars as pl 

30 

31 

32def _comp_return(series: pl.Series) -> float: 

33 """Compute the total compounded return over a full period. 

34 

35 Computed as ``∏(1 + rᵢ) - 1`` after dropping null values and casting 

36 to ``Float64``. 

37 

38 Args: 

39 series: A Polars Series of per-period returns. 

40 

41 Returns: 

42 float: Total compounded return. 

43 

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 

49 

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 

54 

55 

56def _nav_series(series: pl.Series) -> pl.Series: 

57 """Convert a returns series to a cumulative NAV (price) series. 

58 

59 Computed as ``cum_prod(1 + rᵢ)``, which gives the value of one unit of 

60 currency invested at the start of the series. 

61 

62 Args: 

63 series: A Polars Series of per-period returns. 

64 

65 Returns: 

66 pl.Series: Float64 series of cumulative NAV values (starts at ``1 + r₁``). 

67 

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

75 

76 

77def _annualization_factor(periods: int | float, sqrt: bool = True) -> float: 

78 """Return the annualization factor for a given number of periods per year. 

79 

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. 

83 

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

89 

90 Returns: 

91 float: ``sqrt(periods)`` when *sqrt* is ``True``, else ``periods``. 

92 

93 Raises: 

94 ValueError: If *periods* is not a positive finite number. 

95 

96 Examples: 

97 >>> _annualization_factor(252) 

98 15.874507866387544 

99 

100 >>> _annualization_factor(252, sqrt=False) 

101 252.0 

102 

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) 

109 

110 

111def _downside_deviation(series: pl.Series) -> float: 

112 r"""Compute the downside semi-deviation of a returns series. 

113 

114 Calculates the root-mean-square of all *negative* returns: 

115 

116 .. math:: 

117 

118 \text{downside\_dev} = \sqrt{\frac{\sum_{r_t < 0} r_t^2}{N}} 

119 

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. 

123 

124 Args: 

125 series: A Polars Series of per-period returns. 

126 

127 Returns: 

128 float: Downside semi-deviation (always non-negative). 

129 

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 

135 

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)