Coverage for src/jointview/stats.py: 100%
46 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-17 06:44 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-17 06:44 +0000
1"""Summary statistics for a single price or NAV series, computed by jQuantStats.
3Everything here takes a frame of *levels* — a net asset value, an index, a price —
4alongside the column that carries the period, and lets
5[jQuantStats](https://github.com/jebel-quant/jquantstats) derive the returns and the
6statistics from them.
8Passing the period column rather than a bare series is what buys the accuracy: the
9annualisation factor is read from the actual spacing of the observations, so a weekly
10or monthly series is annualised as one, instead of every series being assumed daily.
11A frame numbered by row rather than dated falls back to 252 periods a year, which is
12what a bare series had to assume in every case.
13"""
15from __future__ import annotations
17import math
18from typing import TYPE_CHECKING
20import polars as pl
21from jquantstats.data import Data
23# The column `aligned` writes is the column these functions read, so the name is taken
24# from where it is written rather than spelled a second time here — two equal literals
25# in two files fail only where the app joins them. It comes from `columns`, which owns
26# the contract, rather than from `plot`: drawing and summarising are two things done
27# to the same frame, and neither should have to import the other to name a column.
28from jointview.columns import PERIOD
30if TYPE_CHECKING: # pragma: no cover
31 from jquantstats._stats import Stats
33MISSING = "—"
36def _stats(frame: pl.DataFrame, column: str, *, date_col: str = PERIOD, rf: float = 0.0) -> Stats:
37 """The jQuantStats view of one column of ``frame``.
39 Raises:
40 KeyError: if ``column`` or ``date_col`` is not in the frame.
41 """
42 for name in (date_col, column):
43 if name not in frame.columns:
44 raise KeyError(f"no column {name!r} in frame") # noqa: TRY003
45 # Gaps are dropped here rather than left to jQuantStats' own `null_strategy`, which
46 # empties the frame outright when the price column carries a null.
47 levels = frame.select(pl.col(date_col), pl.col(column).cast(pl.Float64)).drop_nulls()
48 return Data.from_prices(levels, date_col=date_col, rf=rf).stats
51def returns(frame: pl.DataFrame, column: str, *, date_col: str = PERIOD) -> pl.Series:
52 """Simple period-over-period returns of a level series.
54 >>> import polars as pl
55 >>> frame = pl.DataFrame({"period": [0, 1, 2], "nav": [100.0, 110.0, 99.0]})
56 >>> [round(value, 4) for value in returns(frame, "nav")]
57 [0.1, -0.1]
58 """
59 stats = _stats(frame, column, date_col=date_col)
60 return stats.returns[column]
63def drawdown(frame: pl.DataFrame, column: str, *, date_col: str = PERIOD) -> pl.Series:
64 """Distance below the running maximum, as a fraction — zero or negative.
66 jQuantStats reports the same quantity as a positive depth, so the sign is flipped
67 here: a drawdown is a fall, and every other rate in this module carries its
68 direction in its sign.
70 >>> import polars as pl
71 >>> frame = pl.DataFrame({"period": [0, 1, 2], "nav": [100.0, 120.0, 60.0]})
72 >>> round(drawdown(frame, "nav").min(), 4)
73 -0.5
74 """
75 stats = _stats(frame, column, date_col=date_col)
76 return -stats.drawdown()[column]
79# Every entry is one call on the jQuantStats `Stats` object, in the order the panel
80# shows them. Keeping it as data rather than fifteen lines of dict literal is what
81# lets `metrics` stay a loop, and makes adding a statistic a one-line change.
82STATISTICS: dict[str, str] = {
83 "Total return": "comp",
84 "Annual return": "cagr",
85 "Annual volatility": "volatility",
86 "Sharpe ratio": "sharpe",
87 "Sortino ratio": "sortino",
88 "Calmar ratio": "calmar",
89 "Max drawdown": "max_drawdown",
90 "Ulcer index": "ulcer_index",
91 "Value at risk": "value_at_risk",
92 "Hit rate": "win_rate",
93 "Best period": "best",
94 "Worst period": "worst",
95 "Skew": "skew",
96 "Kurtosis": "kurtosis",
97}
100def metrics(frame: pl.DataFrame, column: str, *, date_col: str = PERIOD, rf: float = 0.0) -> dict[str, float]:
101 """The raw numbers behind the summary table, in natural units (0.07 is 7%).
103 A figure that cannot be formed — a Sharpe ratio for a flat series, a growth rate
104 for a series that touches zero — comes back as NaN or infinity rather than
105 raising, so one odd column never blanks the whole table.
107 Raises:
108 ValueError: if there are too few observations to derive anything.
110 >>> import polars as pl
111 >>> frame = pl.DataFrame({"period": [0, 1, 2, 3], "nav": [100.0, 110.0, 105.0, 120.0]})
112 >>> numbers = metrics(frame, "nav")
113 >>> numbers["Observations"]
114 4.0
115 >>> round(numbers["Total return"], 4)
116 0.2
117 """
118 stats = _stats(frame, column, date_col=date_col, rf=rf)
119 levels = frame.get_column(column).drop_nulls().cast(pl.Float64)
121 numbers: dict[str, float] = {
122 "Observations": float(levels.len()),
123 "Start": float(levels[0]),
124 "End": float(levels[-1]),
125 }
126 for label, name in STATISTICS.items():
127 numbers[label] = _number(stats, name, column)
128 return numbers
131def _number(stats: Stats, name: str, column: str) -> float:
132 """One statistic as a float, with "could not be formed" spelled as NaN.
134 Two things arrive here that are not numbers, and both mean the same thing. A
135 sample too short to support a figure comes back as ``None`` — a kurtosis from four
136 observations, say. And a series that never moves divides by zero on its way to a
137 hit rate, because no period is a winner or a loser.
139 Neither is a defect, and neither should reach the caller as an exception: this
140 module's contract is that an unformable figure is NaN, which :func:`_format`
141 already renders as a dash. A flat series is an ordinary column — a cash line — not
142 an error condition.
143 """
144 try:
145 value = getattr(stats, name)()[column]
146 except ZeroDivisionError:
147 return math.nan
148 return math.nan if value is None else float(value)
151# Levels keep their own units, rates get a sign, and the figures with no direction —
152# volatility, the ratios, the shape statistics — do not.
153FORMATS: dict[str, str] = {
154 "Observations": "{:,.0f}",
155 "Start": "{:,.2f}",
156 "End": "{:,.2f}",
157 "Total return": "{:+.2%}",
158 "Annual return": "{:+.2%}",
159 "Annual volatility": "{:.2%}",
160 "Sharpe ratio": "{:.2f}",
161 "Sortino ratio": "{:.2f}",
162 "Calmar ratio": "{:.2f}",
163 "Max drawdown": "{:.2%}",
164 "Ulcer index": "{:.2%}",
165 "Value at risk": "{:+.2%}",
166 "Hit rate": "{:.1%}",
167 "Best period": "{:+.2%}",
168 "Worst period": "{:+.2%}",
169 "Skew": "{:.2f}",
170 "Kurtosis": "{:.2f}",
171}
174def summary(frame: pl.DataFrame, column: str, *, date_col: str = PERIOD, rf: float = 0.0) -> pl.DataFrame:
175 """The same numbers as :func:`metrics`, formatted for display.
177 >>> import polars as pl
178 >>> frame = pl.DataFrame({"period": [0, 1, 2, 3], "nav": [100.0, 110.0, 105.0, 120.0]})
179 >>> table = summary(frame, "nav")
180 >>> table.columns
181 ['metric', 'value']
182 >>> table.row(by_predicate=pl.col("metric") == "Total return")[1]
183 '+20.00%'
184 """
185 numbers = metrics(frame, column, date_col=date_col, rf=rf)
186 return pl.DataFrame(
187 {
188 "metric": list(numbers),
189 "value": [_format(name, value) for name, value in numbers.items()],
190 }
191 )
194def summary_markdown(
195 frame: pl.DataFrame,
196 column: str,
197 *,
198 title: str | None = None,
199 date_col: str = PERIOD,
200 rf: float = 0.0,
201) -> str:
202 """A two-column markdown table, ready for ``mo.md``.
204 >>> import polars as pl
205 >>> frame = pl.DataFrame({"period": [0, 1, 2, 3], "nav": [100.0, 110.0, 105.0, 120.0]})
206 >>> print(summary_markdown(frame, "nav", title="fund").splitlines()[0])
207 | fund | |
208 """
209 table = summary(frame, column, date_col=date_col, rf=rf)
210 header = f"| {title or column} | |", "|:---|---:|"
211 body = (f"| {row['metric']} | {row['value']} |" for row in table.iter_rows(named=True))
212 return "\n".join((*header, *body))
215def _format(name: str, value: float) -> str:
216 """Render one metric, falling back to ``MISSING`` for anything not finite.
218 A NaN here is a figure that could not be formed rather than a bug — a Sharpe
219 ratio without volatility, a growth rate from a start of zero — so it shows as
220 a dash instead of blanking the row.
222 >>> _format("Total return", 0.25)
223 '+25.00%'
224 >>> _format("Sharpe ratio", float("nan"))
225 '—'
226 """
227 if not math.isfinite(value):
228 return MISSING
229 return FORMATS.get(name, "{:,.4g}").format(value)