Coverage for src / basanos / analytics / _report.py: 100%
99 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 05:23 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 05:23 +0000
1"""HTML report generation for portfolio analytics.
3This module defines the Report facade which produces a self-contained HTML
4document containing all relevant performance numbers and interactive Plotly
5visualisations for a Portfolio.
7Examples:
8 >>> import dataclasses
9 >>> from basanos.analytics._report import Report
10 >>> dataclasses.is_dataclass(Report)
11 True
12"""
14from __future__ import annotations
16import dataclasses
17import math
18from pathlib import Path
19from typing import TYPE_CHECKING, TypeGuard
21import plotly.graph_objects as go
22import plotly.io as pio
23import polars as pl
25if TYPE_CHECKING:
26 from .portfolio import Portfolio
29# ── Formatting helpers ────────────────────────────────────────────────────────
32def _is_finite(v: object) -> TypeGuard[int | float]:
33 """Return True when *v* is a real, finite number."""
34 if not isinstance(v, (int, float)):
35 return False
36 return math.isfinite(float(v))
39def _fmt(value: object, fmt: str = ".4f", suffix: str = "") -> str:
40 """Format *value* for display in an HTML table cell.
42 Returns ``"N/A"`` for ``None``, ``NaN``, or non-finite values.
43 """
44 if not _is_finite(value):
45 return "N/A"
46 return f"{float(value):{fmt}}{suffix}"
49# ── Stats table ───────────────────────────────────────────────────────────────
51_METRIC_FORMATS: dict[str, tuple[str, str]] = {
52 "avg_return": (".6f", ""),
53 "avg_win": (".6f", ""),
54 "avg_loss": (".6f", ""),
55 "best": (".6f", ""),
56 "worst": (".6f", ""),
57 "sharpe": (".2f", ""),
58 "calmar": (".2f", ""),
59 "recovery_factor": (".2f", ""),
60 "max_drawdown": (".2%", ""),
61 "avg_drawdown": (".2%", ""),
62 "max_drawdown_duration": (".0f", " days"),
63 "win_rate": (".1%", ""),
64 "monthly_win_rate": (".1%", ""),
65 "profit_factor": (".2f", ""),
66 "payoff_ratio": (".2f", ""),
67 "volatility": (".2%", ""),
68 "skew": (".2f", ""),
69 "kurtosis": (".2f", ""),
70 "value_at_risk": (".6f", ""),
71 "conditional_value_at_risk": (".6f", ""),
72}
74_METRIC_LABELS: dict[str, str] = {
75 "avg_return": "Avg Return",
76 "avg_win": "Avg Win",
77 "avg_loss": "Avg Loss",
78 "best": "Best Period",
79 "worst": "Worst Period",
80 "sharpe": "Sharpe Ratio",
81 "calmar": "Calmar Ratio",
82 "recovery_factor": "Recovery Factor",
83 "max_drawdown": "Max Drawdown",
84 "avg_drawdown": "Avg Drawdown",
85 "max_drawdown_duration": "Max DD Duration",
86 "win_rate": "Win Rate",
87 "monthly_win_rate": "Monthly Win Rate",
88 "profit_factor": "Profit Factor",
89 "payoff_ratio": "Payoff Ratio",
90 "volatility": "Volatility (ann.)",
91 "skew": "Skewness",
92 "kurtosis": "Kurtosis",
93 "value_at_risk": "VaR (95 %)",
94 "conditional_value_at_risk": "CVaR (95 %)",
95}
97# Metrics where the *highest* value across assets is highlighted.
98_HIGHER_IS_BETTER: frozenset[str] = frozenset(
99 {"sharpe", "calmar", "recovery_factor", "win_rate", "monthly_win_rate", "profit_factor", "payoff_ratio"}
100)
102_CATEGORIES: list[tuple[str, list[str]]] = [
103 ("Returns", ["avg_return", "avg_win", "avg_loss", "best", "worst"]),
104 ("Risk-Adjusted Performance", ["sharpe", "calmar", "recovery_factor"]),
105 ("Drawdown", ["max_drawdown", "avg_drawdown", "max_drawdown_duration"]),
106 ("Win / Loss", ["win_rate", "monthly_win_rate", "profit_factor", "payoff_ratio"]),
107 ("Distribution & Risk", ["volatility", "skew", "kurtosis", "value_at_risk", "conditional_value_at_risk"]),
108]
111def _stats_table_html(summary: pl.DataFrame) -> str:
112 """Render a stats summary DataFrame as a styled HTML table.
114 Args:
115 summary: Output of :py:meth:`Stats.summary` — one row per metric,
116 one column per asset plus a ``metric`` column.
118 Returns:
119 An HTML ``<table>`` string ready to embed in a page.
120 """
121 assets = [c for c in summary.columns if c != "metric"]
123 # Build a fast lookup: metric_name → {asset: value}
124 metric_data: dict[str, dict[str, object]] = {}
125 for row in summary.iter_rows(named=True):
126 name = str(row["metric"])
127 metric_data[name] = {a: row.get(a) for a in assets}
129 header_cells = "".join(f'<th class="asset-header">{a}</th>' for a in assets)
130 rows_html_parts: list[str] = []
132 for category_label, metrics in _CATEGORIES:
133 rows_html_parts.append(
134 f'<tr class="table-section-header">'
135 f'<td colspan="{len(assets) + 1}"><strong>{category_label}</strong></td>'
136 f"</tr>\n"
137 )
138 for metric in metrics:
139 if metric not in metric_data:
140 continue
141 fmt, suffix = _METRIC_FORMATS.get(metric, (".4f", ""))
142 label = _METRIC_LABELS.get(metric, metric.replace("_", " ").title())
143 values = metric_data[metric]
145 # Find the best asset to highlight (only for higher-is-better metrics)
146 best_asset: str | None = None
147 if metric in _HIGHER_IS_BETTER:
148 finite_pairs = [(a, float(v)) for a, v in values.items() if _is_finite(v)]
149 if finite_pairs:
150 best_asset = max(finite_pairs, key=lambda x: x[1])[0]
152 cells = "".join(
153 f'<td class="metric-value{" best-value" if a == best_asset else ""}">'
154 f"{_fmt(values.get(a), fmt, suffix)}</td>"
155 for a in assets
156 )
157 rows_html_parts.append(f'<tr><td class="metric-name">{label}</td>{cells}</tr>\n')
159 rows_html = "".join(rows_html_parts)
160 return (
161 '<table class="stats-table">'
162 "<thead><tr>"
163 f'<th class="metric-header">Metric</th>{header_cells}'
164 "</tr></thead>"
165 f"<tbody>{rows_html}</tbody>"
166 "</table>"
167 )
170# ── CSS / HTML templates ──────────────────────────────────────────────────────
172_CSS = """
173/* ── Reset & Base ─────────────────────────────────── */
174*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
175body {
176 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, sans-serif;
177 background: #0f1117;
178 color: #e2e8f0;
179 line-height: 1.6;
180}
182/* ── Header ───────────────────────────────────────── */
183.report-header {
184 background: linear-gradient(135deg, #1a1f35 0%, #0d1b2a 100%);
185 border-bottom: 2px solid #2d3748;
186 padding: 2.5rem 2rem 2rem;
187}
188.report-header h1 {
189 font-size: 2rem;
190 font-weight: 700;
191 color: #63b3ed;
192 letter-spacing: -0.5px;
193}
194.report-meta {
195 display: flex;
196 flex-wrap: wrap;
197 gap: 1.5rem;
198 margin-top: 0.75rem;
199 font-size: 0.875rem;
200 color: #a0aec0;
201}
202.report-meta span strong { color: #e2e8f0; }
204/* ── Table of Contents ────────────────────────────── */
205.toc {
206 background: #1a1f35;
207 border-bottom: 1px solid #2d3748;
208 padding: 0.75rem 2rem;
209 display: flex;
210 gap: 1.5rem;
211 flex-wrap: wrap;
212 font-size: 0.8rem;
213 position: sticky;
214 top: 0;
215 z-index: 100;
216}
217.toc a {
218 color: #63b3ed;
219 text-decoration: none;
220 opacity: 0.8;
221 transition: opacity 0.2s;
222}
223.toc a:hover { opacity: 1; text-decoration: underline; }
225/* ── Main Content ─────────────────────────────────── */
226.container {
227 max-width: 1400px;
228 margin: 0 auto;
229 padding: 2rem;
230}
232/* ── Sections ─────────────────────────────────────── */
233.section { margin-bottom: 3rem; }
234.section-title {
235 font-size: 1.25rem;
236 font-weight: 600;
237 color: #90cdf4;
238 margin-bottom: 1.25rem;
239 padding-bottom: 0.5rem;
240 border-bottom: 1px solid #2d3748;
241 display: flex;
242 align-items: center;
243 gap: 0.5rem;
244}
245.section-title::before {
246 content: "";
247 display: inline-block;
248 width: 4px;
249 height: 1.2em;
250 background: #4299e1;
251 border-radius: 2px;
252}
254/* ── Chart Grid ───────────────────────────────────── */
255.chart-grid {
256 display: grid;
257 grid-template-columns: 1fr 1fr;
258 gap: 1.5rem;
259}
260.chart-grid .chart-card.full-width { grid-column: 1 / -1; }
261.chart-card {
262 background: #1a202c;
263 border: 1px solid #2d3748;
264 border-radius: 12px;
265 padding: 1rem;
266 overflow: hidden;
267}
268.chart-card .js-plotly-plot,
269.chart-card .plotly-graph-div { width: 100% !important; }
271/* ── Stats Table ──────────────────────────────────── */
272.stats-table {
273 width: 100%;
274 border-collapse: collapse;
275 font-size: 0.875rem;
276}
277.stats-table th {
278 background: #2d3748;
279 color: #90cdf4;
280 padding: 0.6rem 1rem;
281 text-align: right;
282 font-weight: 600;
283 white-space: nowrap;
284}
285.stats-table th.metric-header { text-align: left; }
286.stats-table th.asset-header { text-align: right; }
287.stats-table td {
288 padding: 0.45rem 1rem;
289 border-bottom: 1px solid #2d3748;
290 text-align: right;
291}
292.stats-table td.metric-name {
293 text-align: left;
294 color: #cbd5e0;
295 padding-left: 1.5rem;
296}
297.stats-table tr.table-section-header td {
298 background: #1e2a3a;
299 color: #4299e1;
300 font-size: 0.75rem;
301 text-transform: uppercase;
302 letter-spacing: 0.1em;
303 padding: 0.4rem 1rem;
304 text-align: left;
305}
306.stats-table tbody tr:hover { background: #1e2a3a; }
307.stats-table td.best-value { color: #68d391; font-weight: 600; }
308.stats-table td.metric-value {
309 font-family: "SFMono-Regular", Consolas, monospace;
310}
312/* ── Footer ───────────────────────────────────────── */
313.report-footer {
314 text-align: center;
315 padding: 1.5rem;
316 color: #4a5568;
317 font-size: 0.75rem;
318 border-top: 1px solid #2d3748;
319 margin-top: 3rem;
320}
322@media (max-width: 900px) {
323 .chart-grid { grid-template-columns: 1fr; }
324 .chart-card.full-width { grid-column: 1; }
325}
326"""
329# ── Report dataclass ──────────────────────────────────────────────────────────
332def _figure_div(fig: go.Figure, include_plotlyjs: bool | str) -> str:
333 """Return an HTML div string for *fig*.
335 Args:
336 fig: Plotly figure to serialise.
337 include_plotlyjs: Passed directly to :func:`plotly.io.to_html`.
338 Pass ``"cdn"`` for the first figure so the CDN script tag is
339 injected; pass ``False`` for all subsequent figures.
341 Returns:
342 HTML string (not a full page).
343 """
344 return pio.to_html(
345 fig,
346 full_html=False,
347 include_plotlyjs=include_plotlyjs,
348 )
351@dataclasses.dataclass(frozen=True)
352class Report:
353 """Facade for generating HTML reports from a Portfolio.
355 Provides a :py:meth:`to_html` method that assembles a self-contained,
356 dark-themed HTML document with a performance-statistics table and
357 multiple interactive Plotly charts.
359 Usage::
361 report = portfolio.report
362 html_str = report.to_html()
363 report.save("output/report.html")
364 """
366 portfolio: Portfolio
368 def to_html(self, title: str = "Basanos Portfolio Report") -> str:
369 """Render a full HTML report as a string.
371 The document is self-contained: Plotly.js is loaded once from the
372 CDN and all charts are embedded as ``<div>`` elements. No external
373 CSS framework is required.
375 Args:
376 title: HTML ``<title>`` text and visible page heading.
378 Returns:
379 A complete HTML document as a :class:`str`.
380 """
381 pf = self.portfolio
383 # ── Metadata ──────────────────────────────────────────────────────────
384 has_date = "date" in pf.prices.columns
385 if has_date:
386 dates = pf.prices["date"]
387 start_date = str(dates.min())
388 end_date = str(dates.max())
389 n_periods = pf.prices.height
390 period_info = f"{start_date} → {end_date} | {n_periods:,} periods"
391 else:
392 start_date = ""
393 end_date = ""
394 period_info = f"{pf.prices.height:,} periods"
396 assets_list = ", ".join(pf.assets)
398 # ── Figures ───────────────────────────────────────────────────────────
399 # The first chart includes Plotly.js from CDN; subsequent ones reuse it.
400 _first = True
402 def _div(fig: go.Figure) -> str:
403 nonlocal _first
404 include = "cdn" if _first else False
405 _first = False
406 return _figure_div(fig, include)
408 def _try_div(build_fig: object) -> str:
409 """Call *build_fig()* and return the chart div; on error return a notice."""
410 try:
411 fig = build_fig() # type: ignore[operator]
412 return _div(fig)
413 except Exception as exc:
414 return f'<p class="chart-unavailable">Chart unavailable: {exc}</p>'
416 snapshot_div = _try_div(pf.plots.snapshot)
417 rolling_sharpe_div = _try_div(pf.plots.rolling_sharpe_plot)
418 rolling_vol_div = _try_div(pf.plots.rolling_volatility_plot)
419 annual_sharpe_div = _try_div(pf.plots.annual_sharpe_plot)
420 monthly_heatmap_div = _try_div(pf.plots.monthly_returns_heatmap)
421 corr_div = _try_div(pf.plots.correlation_heatmap)
422 lead_lag_div = _try_div(pf.plots.lead_lag_ir_plot)
423 trading_cost_div = _try_div(pf.plots.trading_cost_impact_plot)
425 # ── Stats table ───────────────────────────────────────────────────────
426 stats_table = _stats_table_html(pf.stats.summary())
428 # ── Turnover table ────────────────────────────────────────────────────
429 try:
430 turnover_df = pf.turnover_summary()
431 turnover_rows = "".join(
432 f'<tr><td class="metric-name">{row["metric"].replace("_", " ").title()}</td>'
433 f'<td class="metric-value">{row["value"]:.4f}</td></tr>'
434 for row in turnover_df.iter_rows(named=True)
435 )
436 turnover_html = (
437 '<table class="stats-table">'
438 "<thead><tr>"
439 '<th class="metric-header">Metric</th>'
440 '<th class="asset-header">Value</th>'
441 "</tr></thead>"
442 f"<tbody>{turnover_rows}</tbody>"
443 "</table>"
444 )
445 except Exception as exc:
446 turnover_html = f'<p class="chart-unavailable">Turnover data unavailable: {exc}</p>'
448 # ── Assemble HTML ─────────────────────────────────────────────────────
449 footer_date = end_date if has_date else ""
450 return f"""<!DOCTYPE html>
451<html lang="en">
452<head>
453 <meta charset="UTF-8" />
454 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
455 <title>{title}</title>
456 <style>{_CSS}</style>
457</head>
458<body>
460<header class="report-header">
461 <h1>📊 {title}</h1>
462 <div class="report-meta">
463 <span><strong>Period:</strong> {period_info}</span>
464 <span><strong>Assets:</strong> {assets_list}</span>
465 <span><strong>AUM:</strong> {pf.aum:,.0f}</span>
466 </div>
467</header>
469<nav class="toc">
470 <a href="#performance">Performance</a>
471 <a href="#risk">Risk</a>
472 <a href="#annual">Annual</a>
473 <a href="#monthly">Monthly Returns</a>
474 <a href="#stats-table">Statistics</a>
475 <a href="#correlation">Correlation</a>
476 <a href="#leadlag">Lead / Lag</a>
477 <a href="#costs">Trading Costs</a>
478 <a href="#turnover">Turnover</a>
479</nav>
481<div class="container">
483 <section class="section" id="performance">
484 <h2 class="section-title">Portfolio Performance</h2>
485 <div class="chart-card">{snapshot_div}</div>
486 </section>
488 <section class="section" id="risk">
489 <h2 class="section-title">Risk Analysis</h2>
490 <div class="chart-grid">
491 <div class="chart-card">{rolling_sharpe_div}</div>
492 <div class="chart-card">{rolling_vol_div}</div>
493 </div>
494 </section>
496 <section class="section" id="annual">
497 <h2 class="section-title">Annual Breakdown</h2>
498 <div class="chart-card">{annual_sharpe_div}</div>
499 </section>
501 <section class="section" id="monthly">
502 <h2 class="section-title">Monthly Returns</h2>
503 <div class="chart-card">{monthly_heatmap_div}</div>
504 </section>
506 <section class="section" id="stats-table">
507 <h2 class="section-title">Performance Statistics</h2>
508 <div class="chart-card" style="overflow-x: auto;">{stats_table}</div>
509 </section>
511 <section class="section" id="correlation">
512 <h2 class="section-title">Correlation Analysis</h2>
513 <div class="chart-card">{corr_div}</div>
514 </section>
516 <section class="section" id="leadlag">
517 <h2 class="section-title">Lead / Lag Information Ratio</h2>
518 <div class="chart-card">{lead_lag_div}</div>
519 </section>
521 <section class="section" id="costs">
522 <h2 class="section-title">Trading Cost Impact</h2>
523 <div class="chart-card">{trading_cost_div}</div>
524 </section>
526 <section class="section" id="turnover">
527 <h2 class="section-title">Turnover Summary</h2>
528 <div class="chart-card" style="overflow-x: auto;">{turnover_html}</div>
529 </section>
531</div>
533<footer class="report-footer">
534 Generated by <strong>basanos</strong> | {footer_date}
535</footer>
537</body>
538</html>"""
540 def save(self, path: str | Path, title: str = "Basanos Portfolio Report") -> Path:
541 """Save the HTML report to a file.
543 A ``.html`` suffix is appended automatically when *path* has no
544 file extension.
546 Args:
547 path: Destination file path.
548 title: HTML ``<title>`` text and visible page heading.
550 Returns:
551 The resolved :class:`pathlib.Path` of the written file.
552 """
553 p = Path(path)
554 if not p.suffix:
555 p = p.with_suffix(".html")
556 p.parent.mkdir(parents=True, exist_ok=True)
557 p.write_text(self.to_html(title=title), encoding="utf-8")
558 return p