Coverage for src/jquantstats/_reports/_html.py: 100%
66 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"""HTML fragments for the self-contained performance report.
3These helpers turn the metrics table, drawdown summary, and Plotly
4charts assembled by ``Reports.full`` into HTML fragments, which
5``templates/data_report.html`` assembles into a single dark-themed
6document.
7"""
9from __future__ import annotations
11from html import escape
12from typing import Any
14import polars as pl
16from ._formatting import _fmt, _plotly_div, _table_html
18# ── Metrics-table section layout ───────────────────────────────────────────────
20_SECTION_SPANS: list[tuple[str, list[str]]] = [
21 (
22 "Overview",
23 [
24 "Start Period",
25 "End Period",
26 "Time in Market",
27 "Cumulative Return",
28 "CAGR",
29 ],
30 ),
31 (
32 "Risk-Adjusted Ratios",
33 [
34 "Sharpe",
35 "Prob. Sharpe Ratio",
36 "Sortino",
37 "Sortino / √2",
38 "Omega",
39 ],
40 ),
41 (
42 "Drawdown",
43 [
44 "Max Drawdown",
45 "Max DD Duration",
46 "Avg Drawdown",
47 "Recovery Factor",
48 "Ulcer Index",
49 "Serenity Index",
50 ],
51 ),
52 (
53 "Trading",
54 [
55 "Gain/Pain Ratio",
56 "Gain/Pain (1M)",
57 "Payoff Ratio",
58 "Profit Factor",
59 "Common Sense Ratio",
60 "CPC Index",
61 "Tail Ratio",
62 "Outlier Win Ratio",
63 "Outlier Loss Ratio",
64 ],
65 ),
66 (
67 "Recent Returns",
68 [
69 "MTD",
70 "3M",
71 "6M",
72 "YTD",
73 "1Y",
74 "3Y (ann.)",
75 "5Y (ann.)",
76 "All-time (ann.)",
77 ],
78 ),
79 (
80 "Smart Ratios",
81 ["Smart Sharpe", "Smart Sortino", "Smart Sortino / √2"],
82 ),
83 (
84 "Risk",
85 [
86 "Volatility (ann.)",
87 "Calmar",
88 "Risk-Adjusted Return",
89 "Risk-Return Ratio",
90 "Ulcer Performance Index",
91 "Skew",
92 "Kurtosis",
93 ],
94 ),
95 (
96 "Averages",
97 [
98 "Avg. Return",
99 "Avg. Win",
100 "Avg. Loss",
101 "Win/Loss Ratio",
102 "Profit Ratio",
103 "Win Rate",
104 "Monthly Win Rate",
105 ],
106 ),
107 (
108 "Expected Returns",
109 ["Expected Daily", "Expected Monthly", "Expected Yearly"],
110 ),
111 (
112 "Tail Risk",
113 [
114 "Kelly Criterion",
115 "Risk of Ruin",
116 "Daily VaR",
117 "Expected Shortfall (cVaR)",
118 ],
119 ),
120 (
121 "Streaks",
122 ["Max Consecutive Wins", "Max Consecutive Losses"],
123 ),
124 (
125 "Best / Worst",
126 ["Best Day", "Worst Day"],
127 ),
128 (
129 "Benchmark",
130 ["Beta", "Alpha", "Correlation", "R²", "Treynor Ratio"],
131 ),
132]
134_PCT_METRICS: frozenset[str] = frozenset(
135 {
136 "Time in Market",
137 "Cumulative Return",
138 "CAGR",
139 "Prob. Sharpe Ratio",
140 "Max Drawdown",
141 "Avg Drawdown",
142 "MTD",
143 "3M",
144 "6M",
145 "YTD",
146 "1Y",
147 "3Y (ann.)",
148 "5Y (ann.)",
149 "All-time (ann.)",
150 "Volatility (ann.)",
151 "Risk-Adjusted Return",
152 "Avg. Return",
153 "Avg. Win",
154 "Avg. Loss",
155 "Win Rate",
156 "Monthly Win Rate",
157 "Expected Daily",
158 "Expected Monthly",
159 "Expected Yearly",
160 "Kelly Criterion",
161 "Risk of Ruin",
162 "Daily VaR",
163 "Expected Shortfall (cVaR)",
164 "Best Day",
165 "Worst Day",
166 "Alpha",
167 "Correlation",
168 }
169)
172def _metrics_table_html(df: pl.DataFrame) -> str:
173 """Render a metrics DataFrame as a styled HTML table with section headers.
175 Args:
176 df: DataFrame with a ``"Metric"`` column and one column per asset.
178 Returns:
179 An HTML ``<table>`` string.
181 """
182 assets = [c for c in df.columns if c != "Metric"]
183 rows_by_label: dict[str, dict[str, Any]] = {
184 str(row["Metric"]): {a: row.get(a) for a in assets} for row in df.iter_rows(named=True)
185 }
187 n_cols = len(assets) + 1
188 header_cells = "".join(f'<th class="asset-header">{escape(a)}</th>' for a in assets)
189 parts: list[str] = []
191 rendered: set[str] = set()
192 for section_label, section_metrics in _SECTION_SPANS:
193 section_rows = _section_rows_html(section_metrics, rows_by_label, assets, rendered)
194 if section_rows:
195 parts.append(
196 f'<tr class="table-section-header"><td colspan="{n_cols}"><strong>{section_label}</strong></td></tr>\n'
197 )
198 parts.extend(section_rows)
200 # Anything not matched by a section (e.g. string-valued rows like dates)
201 parts.extend(_unmatched_rows_html(rows_by_label, rendered, assets))
203 return _table_html(header_cells, "".join(parts))
206def _section_rows_html(
207 section_metrics: Any,
208 rows_by_label: dict[str, dict[str, Any]],
209 assets: list[str],
210 rendered: set[str],
211) -> list[str]:
212 """Render the ``<tr>`` rows for one metric section, in section order.
214 Args:
215 section_metrics: Ordered metric labels belonging to the section.
216 rows_by_label: Lookup of metric label → per-asset values.
217 assets: Asset column names, in output order.
218 rendered: Mutable set of labels already emitted; updated in place so
219 the caller can render unmatched rows afterwards.
221 Returns:
222 A list of HTML ``<tr>`` strings (empty when no metric matched).
224 """
225 section_rows: list[str] = []
226 for label in section_metrics:
227 if label not in rows_by_label:
228 continue
229 vals = rows_by_label[label]
230 rendered.add(label)
231 suffix = "%" if label in _PCT_METRICS else ""
232 cells = "".join(f'<td class="metric-value">{_fmt(vals.get(a), ".2f", suffix)}</td>' for a in assets)
233 section_rows.append(f'<tr><td class="metric-name">{label}</td>{cells}</tr>\n')
234 return section_rows
237def _unmatched_rows_html(
238 rows_by_label: dict[str, dict[str, Any]],
239 rendered: set[str],
240 assets: list[str],
241) -> list[str]:
242 """Render rows not claimed by any section (e.g. string-valued date rows).
244 Args:
245 rows_by_label: Lookup of metric label → per-asset values.
246 rendered: Labels already emitted by the section pass.
247 assets: Asset column names, in output order.
249 Returns:
250 A list of HTML ``<tr>`` strings for the leftover labels.
252 """
253 parts: list[str] = []
254 for label, vals in rows_by_label.items():
255 if label in rendered:
256 continue
257 raw = next(iter(vals.values()), None)
258 if isinstance(raw, str):
259 cells = "".join(f'<td class="metric-value">{vals.get(a, "")}</td>' for a in assets)
260 else:
261 cells = "".join(f'<td class="metric-value">{_fmt(vals.get(a), ".4f")}</td>' for a in assets)
262 parts.append(f'<tr><td class="metric-name">{label}</td>{cells}</tr>\n')
263 return parts
266def _drawdowns_section_html(data: Any, assets: list[str]) -> str:
267 """Render worst-5 drawdown periods per asset as HTML tables.
269 Args:
270 data: The DataLike object (accessed via ``getattr`` for stats).
271 assets: List of asset column names to render.
273 Returns:
274 HTML string containing one table per asset.
276 """
277 stats = getattr(data, "stats", None)
278 if stats is None:
279 return "<p>No drawdown data available.</p>"
281 parts: list[str] = []
282 try:
283 dd_dict: dict[str, pl.DataFrame] = stats.drawdown_details()
284 except Exception:
285 return "<p>Drawdown details unavailable.</p>"
287 for asset in assets:
288 df = dd_dict.get(asset)
289 if df is None or len(df) == 0:
290 parts.append(f"<h3>{escape(asset)}</h3><p>No drawdown periods found.</p>")
291 continue
293 worst5 = df.sort("max_drawdown").head(5)
294 rows = "".join(
295 f"<tr>"
296 f"<td>{row.get('start', '')}</td>"
297 f"<td>{row.get('valley', '')}</td>"
298 f"<td>{row.get('end', '') or '—'}</td>"
299 f"<td>{_fmt(row.get('max_drawdown'), '.2%')}</td>"
300 f"<td>{row.get('duration', '') or '—'}</td>"
301 f"</tr>"
302 for row in worst5.iter_rows(named=True)
303 )
304 parts.append(
305 f"<h3>{escape(asset)}</h3>"
306 '<table class="stats-table">'
307 "<thead><tr>"
308 "<th>Start</th><th>Valley</th><th>End</th><th>Max DD</th><th>Duration</th>"
309 "</tr></thead>"
310 f"<tbody>{rows}</tbody></table>"
311 )
313 return "\n".join(parts)
316def _try_plotly_div(fig: Any, include_cdn: bool = False) -> str:
317 """Convert a Plotly figure to an HTML div string.
319 Args:
320 fig: A Plotly Figure object (or anything with ``to_html``).
321 include_cdn: Include the Plotly JS CDN ``<script>`` tag. Defaults to False.
323 Returns:
324 An HTML string, or an empty string if conversion fails.
326 """
327 try:
328 return _plotly_div(fig, include_plotlyjs="cdn" if include_cdn else False)
329 except Exception:
330 return ""
333# ── Reports dataclass ─────────────────────────────────────────────────────────