Coverage for src/jquantstats/_reports/_html.py: 100%
70 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"""HTML rendering for the self-contained performance report.
3These helpers turn the metrics table, drawdown summary, and Plotly
4charts assembled by ``Reports.full`` into a single dark-themed HTML
5document.
6"""
8from __future__ import annotations
10from typing import Any
12import polars as pl
14from ._formatting import _fmt, _plotly_div, _table_html
16# ── Metrics-table section layout ───────────────────────────────────────────────
18_SECTION_SPANS: list[tuple[str, list[str]]] = [
19 (
20 "Overview",
21 [
22 "Start Period",
23 "End Period",
24 "Time in Market",
25 "Cumulative Return",
26 "CAGR",
27 ],
28 ),
29 (
30 "Risk-Adjusted Ratios",
31 [
32 "Sharpe",
33 "Prob. Sharpe Ratio",
34 "Sortino",
35 "Sortino / √2",
36 "Omega",
37 ],
38 ),
39 (
40 "Drawdown",
41 [
42 "Max Drawdown",
43 "Max DD Duration",
44 "Avg Drawdown",
45 "Recovery Factor",
46 "Ulcer Index",
47 "Serenity Index",
48 ],
49 ),
50 (
51 "Trading",
52 [
53 "Gain/Pain Ratio",
54 "Gain/Pain (1M)",
55 "Payoff Ratio",
56 "Profit Factor",
57 "Common Sense Ratio",
58 "CPC Index",
59 "Tail Ratio",
60 "Outlier Win Ratio",
61 "Outlier Loss Ratio",
62 ],
63 ),
64 (
65 "Recent Returns",
66 [
67 "MTD",
68 "3M",
69 "6M",
70 "YTD",
71 "1Y",
72 "3Y (ann.)",
73 "5Y (ann.)",
74 "All-time (ann.)",
75 ],
76 ),
77 (
78 "Smart Ratios",
79 ["Smart Sharpe", "Smart Sortino", "Smart Sortino / √2"],
80 ),
81 (
82 "Risk",
83 [
84 "Volatility (ann.)",
85 "Calmar",
86 "Risk-Adjusted Return",
87 "Risk-Return Ratio",
88 "Ulcer Performance Index",
89 "Skew",
90 "Kurtosis",
91 ],
92 ),
93 (
94 "Averages",
95 [
96 "Avg. Return",
97 "Avg. Win",
98 "Avg. Loss",
99 "Win/Loss Ratio",
100 "Profit Ratio",
101 "Win Rate",
102 "Monthly Win Rate",
103 ],
104 ),
105 (
106 "Expected Returns",
107 ["Expected Daily", "Expected Monthly", "Expected Yearly"],
108 ),
109 (
110 "Tail Risk",
111 [
112 "Kelly Criterion",
113 "Risk of Ruin",
114 "Daily VaR",
115 "Expected Shortfall (cVaR)",
116 ],
117 ),
118 (
119 "Streaks",
120 ["Max Consecutive Wins", "Max Consecutive Losses"],
121 ),
122 (
123 "Best / Worst",
124 ["Best Day", "Worst Day"],
125 ),
126 (
127 "Benchmark",
128 ["Beta", "Alpha", "Correlation", "R²", "Treynor Ratio"],
129 ),
130]
132_PCT_METRICS: frozenset[str] = frozenset(
133 {
134 "Time in Market",
135 "Cumulative Return",
136 "CAGR",
137 "Prob. Sharpe Ratio",
138 "Max Drawdown",
139 "Avg Drawdown",
140 "MTD",
141 "3M",
142 "6M",
143 "YTD",
144 "1Y",
145 "3Y (ann.)",
146 "5Y (ann.)",
147 "All-time (ann.)",
148 "Volatility (ann.)",
149 "Risk-Adjusted Return",
150 "Avg. Return",
151 "Avg. Win",
152 "Avg. Loss",
153 "Win Rate",
154 "Monthly Win Rate",
155 "Expected Daily",
156 "Expected Monthly",
157 "Expected Yearly",
158 "Kelly Criterion",
159 "Risk of Ruin",
160 "Daily VaR",
161 "Expected Shortfall (cVaR)",
162 "Best Day",
163 "Worst Day",
164 "Alpha",
165 "Correlation",
166 }
167)
170def _metrics_table_html(df: pl.DataFrame) -> str:
171 """Render a metrics DataFrame as a styled HTML table with section headers.
173 Args:
174 df: DataFrame with a ``"Metric"`` column and one column per asset.
176 Returns:
177 An HTML ``<table>`` string.
179 """
180 assets = [c for c in df.columns if c != "Metric"]
181 rows_by_label: dict[str, dict[str, Any]] = {
182 str(row["Metric"]): {a: row.get(a) for a in assets} for row in df.iter_rows(named=True)
183 }
185 n_cols = len(assets) + 1
186 header_cells = "".join(f'<th class="asset-header">{a}</th>' for a in assets)
187 parts: list[str] = []
189 rendered: set[str] = set()
190 for section_label, section_metrics in _SECTION_SPANS:
191 section_rows = _section_rows_html(section_metrics, rows_by_label, assets, rendered)
192 if section_rows:
193 parts.append(
194 f'<tr class="table-section-header"><td colspan="{n_cols}"><strong>{section_label}</strong></td></tr>\n'
195 )
196 parts.extend(section_rows)
198 # Anything not matched by a section (e.g. string-valued rows like dates)
199 parts.extend(_unmatched_rows_html(rows_by_label, rendered, assets))
201 return _table_html(header_cells, "".join(parts))
204def _section_rows_html(
205 section_metrics: Any,
206 rows_by_label: dict[str, dict[str, Any]],
207 assets: list[str],
208 rendered: set[str],
209) -> list[str]:
210 """Render the ``<tr>`` rows for one metric section, in section order.
212 Args:
213 section_metrics: Ordered metric labels belonging to the section.
214 rows_by_label: Lookup of metric label → per-asset values.
215 assets: Asset column names, in output order.
216 rendered: Mutable set of labels already emitted; updated in place so
217 the caller can render unmatched rows afterwards.
219 Returns:
220 A list of HTML ``<tr>`` strings (empty when no metric matched).
222 """
223 section_rows: list[str] = []
224 for label in section_metrics:
225 if label not in rows_by_label:
226 continue
227 vals = rows_by_label[label]
228 rendered.add(label)
229 suffix = "%" if label in _PCT_METRICS else ""
230 cells = "".join(f'<td class="metric-value">{_fmt(vals.get(a), ".2f", suffix)}</td>' for a in assets)
231 section_rows.append(f'<tr><td class="metric-name">{label}</td>{cells}</tr>\n')
232 return section_rows
235def _unmatched_rows_html(
236 rows_by_label: dict[str, dict[str, Any]],
237 rendered: set[str],
238 assets: list[str],
239) -> list[str]:
240 """Render rows not claimed by any section (e.g. string-valued date rows).
242 Args:
243 rows_by_label: Lookup of metric label → per-asset values.
244 rendered: Labels already emitted by the section pass.
245 assets: Asset column names, in output order.
247 Returns:
248 A list of HTML ``<tr>`` strings for the leftover labels.
250 """
251 parts: list[str] = []
252 for label, vals in rows_by_label.items():
253 if label in rendered:
254 continue
255 raw = next(iter(vals.values()), None)
256 if isinstance(raw, str):
257 cells = "".join(f'<td class="metric-value">{vals.get(a, "")}</td>' for a in assets)
258 else:
259 cells = "".join(f'<td class="metric-value">{_fmt(vals.get(a), ".4f")}</td>' for a in assets)
260 parts.append(f'<tr><td class="metric-name">{label}</td>{cells}</tr>\n')
261 return parts
264def _drawdowns_section_html(data: Any, assets: list[str]) -> str:
265 """Render worst-5 drawdown periods per asset as HTML tables.
267 Args:
268 data: The DataLike object (accessed via ``getattr`` for stats).
269 assets: List of asset column names to render.
271 Returns:
272 HTML string containing one table per asset.
274 """
275 stats = getattr(data, "stats", None)
276 if stats is None:
277 return "<p>No drawdown data available.</p>"
279 parts: list[str] = []
280 try:
281 dd_dict: dict[str, pl.DataFrame] = stats.drawdown_details()
282 except Exception:
283 return "<p>Drawdown details unavailable.</p>"
285 for asset in assets:
286 df = dd_dict.get(asset)
287 if df is None or len(df) == 0:
288 parts.append(f"<h3>{asset}</h3><p>No drawdown periods found.</p>")
289 continue
291 worst5 = df.sort("max_drawdown").head(5)
292 rows = "".join(
293 f"<tr>"
294 f"<td>{row.get('start', '')}</td>"
295 f"<td>{row.get('valley', '')}</td>"
296 f"<td>{row.get('end', '') or '—'}</td>"
297 f"<td>{_fmt(row.get('max_drawdown'), '.2%')}</td>"
298 f"<td>{row.get('duration', '') or '—'}</td>"
299 f"</tr>"
300 for row in worst5.iter_rows(named=True)
301 )
302 parts.append(
303 f"<h3>{asset}</h3>"
304 '<table class="stats-table">'
305 "<thead><tr>"
306 "<th>Start</th><th>Valley</th><th>End</th><th>Max DD</th><th>Duration</th>"
307 "</tr></thead>"
308 f"<tbody>{rows}</tbody></table>"
309 )
311 return "\n".join(parts)
314def _try_plotly_div(fig: Any, include_cdn: bool = False) -> str:
315 """Convert a Plotly figure to an HTML div string.
317 Args:
318 fig: A Plotly Figure object (or anything with ``to_html``).
319 include_cdn: Include the Plotly JS CDN ``<script>`` tag. Defaults to False.
321 Returns:
322 An HTML string, or an empty string if conversion fails.
324 """
325 try:
326 return _plotly_div(fig, include_plotlyjs="cdn" if include_cdn else False)
327 except Exception:
328 return ""
331_REPORT_CSS = """
332body{margin:0;font-family:system-ui,sans-serif;background:#0f1117;color:#e2e8f0}
333h1{color:#90cdf4;margin:0 0 4px}
334h2{color:#63b3ed;border-bottom:1px solid #2d3748;padding-bottom:6px}
335h3{color:#a0aec0;margin:16px 0 6px}
336header{padding:24px 32px;background:linear-gradient(135deg,#1a202c,#2d3748);border-bottom:1px solid #4a5568}
337.period-info{color:#a0aec0;font-size:.85rem;margin-top:4px}
338main{padding:24px 32px}
339section{margin-bottom:40px}
340.stats-table{border-collapse:collapse;width:100%;font-size:.85rem}
341.stats-table th,.stats-table td{padding:6px 12px;text-align:right;border-bottom:1px solid #2d3748}
342.stats-table th:first-child,.stats-table td:first-child{text-align:left}
343.metric-header,.asset-header{background:#1a202c;color:#90cdf4;font-weight:600}
344.metric-name{color:#cbd5e0}
345.metric-value{font-family:monospace;color:#e2e8f0}
346.table-section-header td{background:#1a202c;color:#68d391;font-size:.75rem;text-transform:uppercase;
347letter-spacing:.08em;padding:8px 12px}
348footer{padding:16px 32px;color:#718096;font-size:.75rem;border-top:1px solid #2d3748}
349"""
352def _build_full_html(
353 title: str,
354 period_info: str,
355 assets_str: str,
356 metrics_html: str,
357 drawdowns_html: str,
358 charts_html: str,
359) -> str:
360 """Assemble the full HTML report from its component parts.
362 Args:
363 title: Page and ``<h1>`` title.
364 period_info: Period metadata string for the header.
365 assets_str: Comma-separated asset names for the header.
366 metrics_html: Pre-rendered metrics ``<table>`` HTML.
367 drawdowns_html: Pre-rendered worst-drawdowns HTML.
368 charts_html: Pre-rendered Plotly chart divs.
370 Returns:
371 A complete, self-contained HTML document string.
373 """
374 from datetime import date
376 footer_date = str(date.today())
377 return f"""<!DOCTYPE html>
378<html lang="en">
379<head>
380<meta charset="utf-8">
381<meta name="viewport" content="width=device-width,initial-scale=1">
382<title>{title}</title>
383<style>{_REPORT_CSS}</style>
384</head>
385<body>
386<header>
387 <h1>{title}</h1>
388 <div class="period-info">{period_info}</div>
389 <div class="period-info">Assets: {assets_str}</div>
390</header>
391<main>
392 <section id="metrics">
393 <h2>Performance Metrics</h2>
394 {metrics_html}
395 </section>
396 <section id="drawdowns">
397 <h2>Worst 5 Drawdown Periods</h2>
398 {drawdowns_html}
399 </section>
400 <section id="charts">
401 <h2>Charts</h2>
402 {charts_html}
403 </section>
404</main>
405<footer>Generated by jquantstats · {footer_date}</footer>
406</body>
407</html>"""
410# ── Reports dataclass ─────────────────────────────────────────────────────────