Coverage for src / jquantstats / _stats / _core.py: 100%
27 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-26 18:44 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-26 18:44 +0000
1"""Module helpers and method decorators for statistical computations.
3Provides:
5- :func:`_drawdown_series` — drawdown series from a returns series.
6- :func:`_to_float` — safe Polars aggregation result → Python float.
7- :func:`columnwise_stat` — decorator: apply a metric to every asset column.
8- :func:`to_frame` — decorator: build a per-column Polars DataFrame result.
10These building blocks are shared across the stats mixin modules
11(:mod:`~jquantstats._stats._basic`, :mod:`~jquantstats._stats._performance`,
12:mod:`~jquantstats._stats._reporting`, :mod:`~jquantstats._stats._rolling`).
13"""
15from __future__ import annotations
17from collections.abc import Callable
18from datetime import timedelta
19from functools import wraps
20from typing import Any, cast
22import polars as pl
24# ── Module helpers ────────────────────────────────────────────────────────────
27def _drawdown_series(series: pl.Series) -> pl.Series:
28 """Compute the drawdown percentage series from a returns series.
30 Treats ``series`` as additive daily returns and builds a normalised NAV
31 starting at 1.0. The high-water mark is the running maximum of that NAV;
32 drawdown is expressed as the fraction below the high-water mark.
34 Args:
35 series: A Polars Series of additive returns (profit / AUM).
37 Returns:
38 A Polars Float64 Series whose values are in [0, 1]. A value of 0
39 means the NAV is at its all-time high; a value of 0.2 means the NAV
40 is 20 % below its previous peak.
42 Examples:
43 >>> import polars as pl
44 >>> s = pl.Series([0.0, -0.1, 0.2])
45 >>> [round(x, 10) for x in _drawdown_series(s).to_list()]
46 [0.0, 0.1, 0.0]
47 """
48 nav = 1.0 + series.cast(pl.Float64).cum_sum()
49 hwm = nav.cum_max()
50 hwm_safe = hwm.clip(lower_bound=1e-10)
51 return ((hwm - nav) / hwm_safe).clip(lower_bound=0.0)
54def _to_float(value: object) -> float:
55 """Safely convert a Polars aggregation result to float.
57 Examples:
58 >>> _to_float(2.0)
59 2.0
60 >>> _to_float(None)
61 0.0
62 """
63 if value is None:
64 return 0.0
65 if isinstance(value, timedelta):
66 return value.total_seconds()
67 return float(cast(float, value))
70# ── Module-level decorators ──────────────────────────────────────────────────
73def columnwise_stat(func: Callable[..., Any]) -> Callable[..., dict[str, float]]:
74 """Apply a column-wise statistical function to all numeric columns.
76 Args:
77 func (Callable): The function to decorate.
79 Returns:
80 Callable: The decorated function.
82 """
84 @wraps(func)
85 def wrapper(self: Any, *args: Any, **kwargs: Any) -> dict[str, float]:
86 """Apply *func* to every column and return a ``{column: value}`` mapping."""
87 return {col: func(self, series, *args, **kwargs) for col, series in self.data.items()}
89 return wrapper
92def to_frame(func: Callable[..., Any]) -> Callable[..., pl.DataFrame]:
93 """Apply per-column expressions and evaluates with .with_columns(...).
95 Args:
96 func (Callable): The function to decorate.
98 Returns:
99 Callable: The decorated function.
101 """
103 @wraps(func)
104 def wrapper(self: Any, *args: Any, **kwargs: Any) -> pl.DataFrame:
105 """Apply *func* per column and return the result as a Polars DataFrame."""
106 return cast(pl.DataFrame, self.all).select(
107 [pl.col(name) for name in self.data.date_col]
108 + [func(self, series, *args, **kwargs).alias(col) for col, series in self.data.items()]
109 )
111 return wrapper