Coverage for src/jquantstats/_reports/_metrics.py: 100%
153 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-06 04:52 +0000
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-06 04:52 +0000
1"""Metric-row builders for the performance metrics table.
3These helpers assemble the ordered ``(label, values)`` rows that
4``Reports.metrics`` turns into a tidy DataFrame, and the period-window
5calculations (CAGR/compounding since a cutoff) they rely on.
6"""
8from __future__ import annotations
10import datetime
11import math
12from typing import Any, cast
14import polars as pl
16from ._formatting import _is_finite
18# ── Private helpers ───────────────────────────────────────────────────────────
21def _safe(fn: Any, *args: Any, **kwargs: Any) -> dict[str, float]:
22 """Call ``fn(*args, **kwargs)`` and return ``{}`` on any exception."""
23 try:
24 result: dict[str, float] = fn(*args, **kwargs)
25 except Exception:
26 return {}
27 return result
30def _pct(d: dict[str, float]) -> dict[str, float]:
31 """Multiply every finite value in *d* by 100."""
32 return {k: v * 100.0 if _is_finite(v) else float("nan") for k, v in d.items()}
35# ── Period-return helpers ─────────────────────────────────────────────────────
38def _comp_since(all_df: pl.DataFrame, date_col: str, asset_cols: list[str], cutoff: Any) -> dict[str, float]:
39 """Compounded return for each asset from *cutoff* to the last date."""
40 filtered = all_df.filter(pl.col(date_col) >= cutoff)
41 result: dict[str, float] = {}
42 for col in asset_cols:
43 s = filtered[col].drop_nulls().cast(pl.Float64)
44 result[col] = float((1.0 + s).product()) - 1.0 if len(s) > 0 else float("nan")
45 return result
48def _cagr_since(
49 all_df: pl.DataFrame,
50 date_col: str,
51 asset_cols: list[str],
52 cutoff: Any,
53 periods_per_year: float,
54) -> dict[str, float]:
55 """Annualised CAGR for each asset from *cutoff* to the last date."""
56 filtered = all_df.filter(pl.col(date_col) >= cutoff)
57 result: dict[str, float] = {}
58 for col in asset_cols:
59 s = filtered[col].drop_nulls().cast(pl.Float64)
60 n = len(s)
61 if n < 2:
62 result[col] = float("nan")
63 continue
64 total = float((1.0 + s).product()) - 1.0
65 years = n / periods_per_year
66 result[col] = float(abs(1.0 + total) ** (1.0 / years) - 1.0)
67 return result
70# ── Metrics-row helpers ───────────────────────────────────────────────────────
73def _cutoff_months(today: Any, n: int) -> Any:
74 """Return the date *n* calendar months before *today*.
76 Args:
77 today: Reference date (must support ``.year``, ``.month``, ``.day``).
78 n: Number of calendar months to subtract.
80 Returns:
81 A `datetime.date` exactly *n* months before *today*.
83 """
84 import calendar
85 from datetime import date as _date
87 y = today.year
88 m = today.month
89 for _ in range(n):
90 m -= 1
91 if m == 0:
92 m = 12
93 y -= 1
94 d = min(today.day, calendar.monthrange(y, m)[1])
95 return _date(y, m, d)
98def _add_overview_rows(rows: list[tuple[str, dict[str, Any]]], s: Any, ppy: float) -> None:
99 """Append overview metric rows to *rows*.
101 Args:
102 rows: Accumulator list of ``(label, values)`` tuples.
103 s: Stats object providing the metric methods.
104 ppy: Periods per year for annualisation.
106 """
107 rows.append(("Time in Market", _pct(_safe(s.exposure))))
108 rows.append(("Cumulative Return", _pct(_safe(s.comp))))
109 rows.append(("CAGR", _pct(_safe(s.cagr, periods=ppy))))
112def _add_risk_adjusted_rows(rows: list[tuple[str, dict[str, Any]]], s: Any, ppy: float) -> None:
113 """Append risk-adjusted ratio rows to *rows*.
115 Args:
116 rows: Accumulator list of ``(label, values)`` tuples.
117 s: Stats object providing the metric methods.
118 ppy: Periods per year for annualisation.
120 """
121 rows.append(("Sharpe", _safe(s.sharpe, periods=ppy)))
122 rows.append(("Prob. Sharpe Ratio", _pct(_safe(s.probabilistic_sharpe_ratio))))
123 rows.append(("Sortino", _safe(s.sortino, periods=ppy)))
124 rows.append(("Sortino / √2", _safe(s.adjusted_sortino, periods=ppy)))
125 rows.append(("Omega", _safe(s.omega, periods=ppy)))
128def _add_drawdown_rows(rows: list[tuple[str, dict[str, Any]]], s: Any) -> None:
129 """Append drawdown metric rows to *rows*.
131 Args:
132 rows: Accumulator list of ``(label, values)`` tuples.
133 s: Stats object providing the metric methods.
135 """
136 rows.append(("Max Drawdown", _pct(_safe(s.max_drawdown))))
137 rows.append(("Max DD Duration", _safe(s.max_drawdown_duration)))
138 rows.append(("Avg Drawdown", _pct(_safe(s.avg_drawdown))))
139 rows.append(("Recovery Factor", _safe(s.recovery_factor)))
140 rows.append(("Ulcer Index", _safe(s.ulcer_index)))
141 rows.append(("Serenity Index", _safe(s.serenity_index)))
144def _add_trading_rows(rows: list[tuple[str, dict[str, Any]]], s: Any) -> None:
145 """Append trading metric rows to *rows*.
147 Args:
148 rows: Accumulator list of ``(label, values)`` tuples.
149 s: Stats object providing the metric methods.
151 """
152 rows.append(("Gain/Pain Ratio", _safe(s.gain_to_pain_ratio)))
153 rows.append(("Gain/Pain (1M)", _safe(s.gain_to_pain_ratio, aggregate="ME")))
154 rows.append(("Payoff Ratio", _safe(s.payoff_ratio)))
155 rows.append(("Profit Factor", _safe(s.profit_factor)))
156 rows.append(("Common Sense Ratio", _safe(s.common_sense_ratio)))
157 rows.append(("CPC Index", _safe(s.cpc_index)))
158 rows.append(("Tail Ratio", _safe(s.tail_ratio)))
159 rows.append(("Outlier Win Ratio", _safe(s.outlier_win_ratio)))
160 rows.append(("Outlier Loss Ratio", _safe(s.outlier_loss_ratio)))
163def _add_recent_returns_rows(
164 rows: list[tuple[str, dict[str, Any]]],
165 all_df: pl.DataFrame,
166 date_col: str,
167 asset_cols: list[str],
168 ppy: float,
169 s: Any,
170) -> None:
171 """Append date-filtered recent return rows to *rows*.
173 Args:
174 rows: Accumulator list of ``(label, values)`` tuples.
175 all_df: Combined DataFrame containing date and return columns.
176 date_col: Name of the date column in *all_df*.
177 asset_cols: Names of asset return columns in *all_df*.
178 ppy: Periods per year for annualisation.
179 s: Stats object used for the all-time CAGR.
181 """
182 today = cast(datetime.date, all_df[date_col].max())
183 mtd_start = today.replace(day=1)
184 ytd_start = today.replace(month=1, day=1)
186 rows.append(("MTD", _pct(_comp_since(all_df, date_col, asset_cols, mtd_start))))
187 rows.append(("3M", _pct(_comp_since(all_df, date_col, asset_cols, _cutoff_months(today, 3)))))
188 rows.append(("6M", _pct(_comp_since(all_df, date_col, asset_cols, _cutoff_months(today, 6)))))
189 rows.append(("YTD", _pct(_comp_since(all_df, date_col, asset_cols, ytd_start))))
190 rows.append(("1Y", _pct(_comp_since(all_df, date_col, asset_cols, _cutoff_months(today, 12)))))
191 rows.append(("3Y (ann.)", _pct(_cagr_since(all_df, date_col, asset_cols, _cutoff_months(today, 36), ppy))))
192 rows.append(("5Y (ann.)", _pct(_cagr_since(all_df, date_col, asset_cols, _cutoff_months(today, 60), ppy))))
193 rows.append(("All-time (ann.)", _pct(_safe(s.cagr, periods=ppy))))
196def _add_full_mode_rows(
197 rows: list[tuple[str, dict[str, Any]]],
198 s: Any,
199 ppy: float,
200 data: Any,
201 all_df: pl.DataFrame | None,
202 date_col: str | None,
203 asset_cols: list[str],
204) -> None:
205 """Append all full-mode extension rows to *rows*.
207 Covers smart ratios, extended risk, averages, expected returns, tail risk,
208 streaks, best/worst periods, and benchmark metrics.
210 Args:
211 rows: Accumulator list of ``(label, values)`` tuples.
212 s: Stats object providing the metric methods.
213 ppy: Periods per year for annualisation.
214 data: The DataLike object (used for benchmark access).
215 all_df: Combined DataFrame or ``None`` if unavailable.
216 date_col: Name of the date column or ``None`` if unavailable.
217 asset_cols: Asset column names.
219 """
220 # Smart ratios
221 rows.append(("Smart Sharpe", _safe(s.smart_sharpe, periods=ppy)))
222 ss = _safe(s.smart_sortino, periods=ppy)
223 rows.append(("Smart Sortino", ss))
224 rows.append(("Smart Sortino / √2", {k: v / math.sqrt(2) for k, v in ss.items() if _is_finite(v)}))
226 # Risk
227 rows.append(("Volatility (ann.)", _pct(_safe(s.volatility, periods=ppy))))
228 rows.append(("Calmar", _safe(s.calmar, periods=ppy)))
229 rows.append(("Risk-Adjusted Return", _pct(_safe(s.rar, periods=ppy))))
230 rows.append(("Risk-Return Ratio", _safe(s.risk_return_ratio)))
231 rows.append(("Ulcer Performance Index", _safe(s.ulcer_performance_index)))
232 rows.append(("Skew", _safe(s.skew)))
233 rows.append(("Kurtosis", _safe(s.kurtosis)))
235 # Averages
236 rows.append(("Avg. Return", _pct(_safe(s.avg_return))))
237 rows.append(("Avg. Win", _pct(_safe(s.avg_win))))
238 rows.append(("Avg. Loss", _pct(_safe(s.avg_loss))))
239 rows.append(("Win/Loss Ratio", _safe(s.payoff_ratio)))
240 rows.append(("Profit Ratio", _safe(s.profit_ratio)))
241 rows.append(("Win Rate", _pct(_safe(s.win_rate))))
242 rows.append(("Monthly Win Rate", _pct(_safe(s.monthly_win_rate))))
244 # Expected returns
245 rows.append(("Expected Daily", _pct(_safe(s.expected_return))))
246 rows.append(("Expected Monthly", _pct(_safe(s.expected_return, aggregate="monthly"))))
247 rows.append(("Expected Yearly", _pct(_safe(s.expected_return, aggregate="yearly"))))
249 # Tail risk
250 rows.append(("Kelly Criterion", _pct(_safe(s.kelly_criterion))))
251 rows.append(("Risk of Ruin", _pct(_safe(s.risk_of_ruin))))
252 rows.append(("Daily VaR", _pct(_safe(s.value_at_risk))))
253 rows.append(("Expected Shortfall (cVaR)", _pct(_safe(s.conditional_value_at_risk))))
255 # Streaks & best / worst
256 rows.append(("Max Consecutive Wins", _safe(s.consecutive_wins)))
257 rows.append(("Max Consecutive Losses", _safe(s.consecutive_losses)))
258 rows.append(("Best Day", _pct(_safe(s.best))))
259 rows.append(("Worst Day", _pct(_safe(s.worst))))
261 _append_benchmark_greeks(rows, s)
262 _append_correlation(rows, data, all_df, date_col, asset_cols)
264 rows.append(("R²", _safe(s.r_squared)))
265 rows.append(("Treynor Ratio", _safe(s.treynor_ratio, periods=ppy)))
268def _append_benchmark_greeks(rows: list[tuple[str, dict[str, Any]]], s: Any) -> None:
269 """Append benchmark Beta/Alpha rows when a benchmark is present.
271 Without a benchmark, ``s.greeks()`` reaches ``benchmark_data.columns`` on a
272 ``None`` benchmark and raises ``AttributeError``; we tolerate only that case
273 and omit the rows. Any other error (malformed column, polars schema/
274 arithmetic bug) propagates so genuine bugs are not silently swallowed.
276 Args:
277 rows: Accumulator list of ``(label, values)`` tuples.
278 s: Stats object providing ``greeks()``.
280 """
281 try:
282 greeks = s.greeks()
283 if greeks: # pragma: no branch — greeks() raises without a benchmark, so falsy is unreachable
284 beta = {k: v["beta"] for k, v in greeks.items()}
285 alpha = {k: v["alpha"] * 100.0 for k, v in greeks.items()}
286 rows.append(("Beta", beta))
287 rows.append(("Alpha", alpha))
288 except AttributeError:
289 pass
292def _append_correlation(
293 rows: list[tuple[str, dict[str, Any]]],
294 data: Any,
295 all_df: pl.DataFrame | None,
296 date_col: str | None,
297 asset_cols: list[str],
298) -> None:
299 """Append the per-asset correlation-against-benchmark row when possible.
301 When the benchmark column is absent (no benchmark configured, or it is
302 missing from the joined frame) polars raises ``ColumnNotFoundError`` and
303 ``None.columns`` raises ``AttributeError`` — both mean "no benchmark to
304 correlate against", so the row is omitted. Any other error propagates.
306 Args:
307 rows: Accumulator list of ``(label, values)`` tuples.
308 data: The DataLike object (used for benchmark access).
309 all_df: Combined DataFrame or ``None`` if unavailable.
310 date_col: Name of the date column or ``None`` if unavailable.
311 asset_cols: Asset column names.
313 """
314 try:
315 bench_obj = getattr(data, "benchmark", None)
316 if bench_obj is not None and all_df is not None and date_col is not None: # pragma: no branch
317 bench_col = bench_obj.columns[0]
318 corr_dict: dict[str, float] = {}
319 for ac in asset_cols:
320 if ac == bench_col:
321 continue
322 sub = all_df.select([date_col, ac, bench_col]).drop_nulls()
323 corr_val = float(sub.select(pl.corr(ac, bench_col))[0, 0])
324 corr_dict[ac] = corr_val * 100.0
325 rows.append(("Correlation", corr_dict))
326 except (AttributeError, pl.exceptions.ColumnNotFoundError):
327 pass
330def _build_metrics_df(rows: list[tuple[str, dict[str, Any]]]) -> pl.DataFrame:
331 """Build a metrics `pl.DataFrame` from accumulated row data.
333 Args:
334 rows: List of ``(label, values)`` tuples where *values* maps asset
335 names to numeric results.
337 Returns:
338 A DataFrame with a leading ``"Metric"`` column and one column per
339 asset, preserving the insertion order of both metrics and assets.
341 """
342 all_assets: list[str] = []
343 seen: set[str] = set()
344 for _, vals in rows:
345 for k in vals:
346 if k not in seen:
347 all_assets.append(k)
348 seen.add(k)
349 return pl.DataFrame([{"Metric": label, **{a: vals.get(a) for a in all_assets}} for label, vals in rows])