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