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

55 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-23 06:13 +0000

1"""Module helpers and method decorators for statistical computations. 

2 

3Provides: 

4 

5- `_drawdown_series` — drawdown series from a returns series. 

6- `_to_float` — safe Polars aggregation result → Python float. 

7- `_mean` — series mean with ``None → 0.0`` fallback. 

8- `_std_is_negligible` — shared "is this std numerically zero?" test for 

9 mean/std ratio metrics. 

10- `columnwise_stat` — decorator: apply a metric to every asset column. 

11- `to_frame` — decorator: build a per-column Polars DataFrame result. 

12 

13These building blocks are shared across the stats mixin modules 

14(`_basic`, `_performance`, 

15`_reporting`, `_rolling`). 

16 

17Null-return convention 

18---------------------- 

19- **Scalar metrics** return ``float("nan")`` when the series has no non-null 

20 observations (use ``_mean`` for the ``None → nan`` conversion). 

21- **Ratio metrics** return ``float("nan")`` when the denominator is zero 

22 or indeterminate. 

23- Use ``_mean`` for the ``None → nan`` conversion rather than 

24 ``cast(float, ...)``. 

25""" 

26 

27from __future__ import annotations 

28 

29import sys 

30from collections.abc import Callable 

31from datetime import timedelta 

32from functools import wraps 

33from typing import Any, Concatenate, ParamSpec, TypeVar, cast, overload 

34 

35import polars as pl 

36 

37P = ParamSpec("P") 

38R = TypeVar("R") 

39 

40# ── Module helpers ──────────────────────────────────────────────────────────── 

41 

42 

43def _drawdown_series(series: pl.Series) -> pl.Series: 

44 """Compute the drawdown percentage series from a returns series. 

45 

46 Builds a compound NAV (geometric cumulative product) from the returns 

47 series and expresses drawdown as the fraction below the running high-water 

48 mark. This matches the quantstats convention. 

49 

50 Args: 

51 series: A Polars Series of multiplicative daily returns. 

52 

53 Returns: 

54 A Polars Float64 Series whose values are in [0, 1]. A value of 0 

55 means the NAV is at its all-time high; a value of 0.2 means the NAV 

56 is 20 % below its previous peak. 

57 

58 Numerical edge cases: 

59 The high-water mark can only fall below the ``1e-10`` floor when 

60 *every* NAV value so far is below it, i.e. when the very first 

61 return is (effectively) -100 %. Because ``0 <= nav <= hwm`` always 

62 holds, the result stays within [0, 1] even when the floor is active. 

63 Note that an exact -100 % first return yields ``nav == hwm == 0`` 

64 and therefore a drawdown of 0: with no baseline, the first 

65 observation *is* its own high-water mark. Metrics that need the 

66 quantstats convention (initial capital of 1.0 as the baseline) 

67 should use ``_drawdown_with_baseline`` instead. 

68 

69 Examples: 

70 >>> import polars as pl 

71 >>> s = pl.Series([0.0, -0.1, 0.2]) 

72 >>> [round(x, 10) for x in _drawdown_series(s).to_list()] 

73 [0.0, 0.1, 0.0] 

74 """ 

75 nav = (1.0 + series.cast(pl.Float64)).cum_prod() 

76 hwm = nav.cum_max() 

77 # The floor keeps the division defined after a -100 % return wipes out 

78 # the NAV; since 0 <= nav <= hwm the ratio stays in [0, 1] regardless. 

79 hwm_safe = hwm.clip(lower_bound=1e-10) 

80 return ((hwm - nav) / hwm_safe).clip(lower_bound=0.0) 

81 

82 

83def _to_float(value: Any) -> float: 

84 """Safely convert a Polars aggregation result to float. 

85 

86 Examples: 

87 >>> _to_float(2.0) 

88 2.0 

89 >>> _to_float(None) 

90 0.0 

91 """ 

92 if value is None: 

93 return 0.0 

94 if isinstance(value, timedelta): 

95 return value.total_seconds() 

96 return float(cast(float, value)) 

97 

98 

99def _std_is_negligible(std: float | None, mean: float) -> bool: 

100 """Return True when a sample standard deviation is numerically zero. 

101 

102 Mean/std ratios (Sharpe and friends) are meaningless when the measured 

103 dispersion is smaller than the floating-point rounding noise of the 

104 inputs: a constant series can produce a tiny non-zero ``std`` purely from 

105 accumulated rounding, and dividing by it would report an absurdly large 

106 ratio instead of "no dispersion". The threshold is 10 machine epsilons 

107 scaled by the magnitude of the mean, with an absolute floor of one 

108 epsilon for means at or near zero. Callers map this case to 

109 ``float("nan")``. 

110 

111 Args: 

112 std: Sample standard deviation, or ``None`` when undefined 

113 (fewer than two observations). 

114 mean: Sample mean of the same series, used to scale the threshold. 

115 

116 Examples: 

117 >>> _std_is_negligible(None, 1.0) 

118 True 

119 >>> _std_is_negligible(0.0, 0.05) 

120 True 

121 >>> _std_is_negligible(0.01, 0.05) 

122 False 

123 """ 

124 if std is None: 

125 return True 

126 eps = sys.float_info.epsilon 

127 return float(std) <= eps * max(abs(mean), eps) * 10.0 

128 

129 

130def _mean(series: pl.Series) -> float: 

131 """Return series mean, or ``float("nan")`` if the series is empty or all-null. 

132 

133 Use this instead of ``cast(float, series.mean())`` to avoid ``None`` 

134 leaking into arithmetic — consistent with the scalar-metric convention 

135 that returns ``float("nan")`` when there are no non-null observations. 

136 

137 Examples: 

138 >>> import polars as pl 

139 >>> _mean(pl.Series([1.0, 3.0])) 

140 2.0 

141 >>> import math 

142 >>> math.isnan(_mean(pl.Series([], dtype=pl.Float64))) 

143 True 

144 """ 

145 result = series.mean() 

146 return float(cast(float, result)) if result is not None else float("nan") 

147 

148 

149# ── Module-level decorators ────────────────────────────────────────────────── 

150 

151 

152@overload 

153def columnwise_stat( 

154 func: Callable[Concatenate[Any, pl.Series, P], R], *, data_attr: str = ... 

155) -> Callable[Concatenate[Any, P], dict[str, R]]: ... 

156 

157 

158@overload 

159def columnwise_stat( 

160 func: None = ..., *, data_attr: str = ... 

161) -> Callable[[Callable[Concatenate[Any, pl.Series, P], R]], Callable[Concatenate[Any, P], dict[str, R]]]: ... 

162 

163 

164def columnwise_stat( 

165 func: Callable[Concatenate[Any, pl.Series, P], R] | None = None, *, data_attr: str = "_data" 

166) -> ( 

167 Callable[Concatenate[Any, P], dict[str, R]] 

168 | Callable[[Callable[Concatenate[Any, pl.Series, P], R]], Callable[Concatenate[Any, P], dict[str, R]]] 

169): 

170 """Apply a column-wise statistical function to all numeric columns. 

171 

172 The decorated method must accept ``(self, series, *args, **kwargs)``; the 

173 wrapper drops the ``series`` parameter and preserves the remaining 

174 signature (via ParamSpec), returning ``{column: value}`` with the wrapped 

175 method's return type as the value type. 

176 

177 Args: 

178 func (Callable | None): The function to decorate. 

179 data_attr: Attribute name that holds the column-wise data object. 

180 

181 Returns: 

182 Callable: The decorated function. 

183 

184 """ 

185 

186 def decorator( 

187 inner_func: Callable[Concatenate[Any, pl.Series, P], R], 

188 ) -> Callable[Concatenate[Any, P], dict[str, R]]: 

189 """Wrap *inner_func* to iterate over the configured data attribute columns.""" 

190 

191 @wraps(inner_func) 

192 def wrapper(self: Any, *args: P.args, **kwargs: P.kwargs) -> dict[str, R]: 

193 """Apply *func* to every column and return a ``{column: value}`` mapping.""" 

194 if not hasattr(self, data_attr): 

195 msg = ( 

196 f"columnwise_stat requires host object to define '{data_attr}' " 

197 f"(missing attribute on {type(self).__name__})." 

198 ) 

199 raise AttributeError(msg) 

200 data = getattr(self, data_attr) 

201 return {col: inner_func(self, series, *args, **kwargs) for col, series in data.items()} 

202 

203 return cast("Callable[Concatenate[Any, P], dict[str, R]]", wrapper) 

204 

205 if func is None: 

206 return decorator 

207 return decorator(func) 

208 

209 

210@overload 

211def to_frame( 

212 func: Callable[Concatenate[Any, pl.Series, P], pl.Series], *, data_attr: str = ... 

213) -> Callable[Concatenate[Any, P], pl.DataFrame]: ... 

214 

215 

216@overload 

217def to_frame( 

218 func: None = ..., *, data_attr: str = ... 

219) -> Callable[[Callable[Concatenate[Any, pl.Series, P], pl.Series]], Callable[Concatenate[Any, P], pl.DataFrame]]: ... 

220 

221 

222def to_frame( 

223 func: Callable[Concatenate[Any, pl.Series, P], pl.Series] | None = None, *, data_attr: str = "_data" 

224) -> ( 

225 Callable[Concatenate[Any, P], pl.DataFrame] 

226 | Callable[[Callable[Concatenate[Any, pl.Series, P], pl.Series]], Callable[Concatenate[Any, P], pl.DataFrame]] 

227): 

228 """Apply per-column expressions and evaluates with .with_columns(...). 

229 

230 The decorated method must accept ``(self, series, *args, **kwargs)`` and 

231 return a per-column Polars Series; the wrapper drops the ``series`` 

232 parameter and preserves the remaining signature (via ParamSpec). 

233 

234 Args: 

235 func (Callable | None): The function to decorate. 

236 data_attr: Attribute name that holds the column-wise data object. 

237 

238 Returns: 

239 Callable: The decorated function. 

240 

241 """ 

242 

243 def decorator( 

244 inner_func: Callable[Concatenate[Any, pl.Series, P], pl.Series], 

245 ) -> Callable[Concatenate[Any, P], pl.DataFrame]: 

246 """Wrap *inner_func* to build a per-column frame from the configured data attribute.""" 

247 

248 @wraps(inner_func) 

249 def wrapper(self: Any, *args: P.args, **kwargs: P.kwargs) -> pl.DataFrame: 

250 """Apply *func* per column and return the result as a Polars DataFrame.""" 

251 if not hasattr(self, data_attr): 

252 msg = ( 

253 f"to_frame requires host object to define '{data_attr}' " 

254 f"(missing attribute on {type(self).__name__})." 

255 ) 

256 raise AttributeError(msg) 

257 data = getattr(self, data_attr) 

258 result: pl.DataFrame = self.all.select( 

259 [pl.col(name) for name in data.date_col] 

260 + [inner_func(self, series, *args, **kwargs).alias(col) for col, series in data.items()] 

261 ) 

262 return result 

263 

264 return cast("Callable[Concatenate[Any, P], pl.DataFrame]", wrapper) 

265 

266 if func is None: 

267 return decorator 

268 return decorator(func)