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

1"""HTML report generation for portfolio analytics. 

2 

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. 

6 

7Examples: 

8 >>> import dataclasses 

9 >>> from basanos.analytics._report import Report 

10 >>> dataclasses.is_dataclass(Report) 

11 True 

12""" 

13 

14from __future__ import annotations 

15 

16import dataclasses 

17import math 

18from pathlib import Path 

19from typing import TYPE_CHECKING, TypeGuard 

20 

21import plotly.graph_objects as go 

22import plotly.io as pio 

23import polars as pl 

24 

25if TYPE_CHECKING: 

26 from .portfolio import Portfolio 

27 

28 

29# ── Formatting helpers ──────────────────────────────────────────────────────── 

30 

31 

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)) 

37 

38 

39def _fmt(value: object, fmt: str = ".4f", suffix: str = "") -> str: 

40 """Format *value* for display in an HTML table cell. 

41 

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}" 

47 

48 

49# ── Stats table ─────────────────────────────────────────────────────────────── 

50 

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} 

73 

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} 

96 

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) 

101 

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] 

109 

110 

111def _stats_table_html(summary: pl.DataFrame) -> str: 

112 """Render a stats summary DataFrame as a styled HTML table. 

113 

114 Args: 

115 summary: Output of :py:meth:`Stats.summary` — one row per metric, 

116 one column per asset plus a ``metric`` column. 

117 

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"] 

122 

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} 

128 

129 header_cells = "".join(f'<th class="asset-header">{a}</th>' for a in assets) 

130 rows_html_parts: list[str] = [] 

131 

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] 

144 

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] 

151 

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') 

158 

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 ) 

168 

169 

170# ── CSS / HTML templates ────────────────────────────────────────────────────── 

171 

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} 

181 

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; } 

203 

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; } 

224 

225/* ── Main Content ─────────────────────────────────── */ 

226.container { 

227 max-width: 1400px; 

228 margin: 0 auto; 

229 padding: 2rem; 

230} 

231 

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} 

253 

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; } 

270 

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} 

311 

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} 

321 

322@media (max-width: 900px) { 

323 .chart-grid { grid-template-columns: 1fr; } 

324 .chart-card.full-width { grid-column: 1; } 

325} 

326""" 

327 

328 

329# ── Report dataclass ────────────────────────────────────────────────────────── 

330 

331 

332def _figure_div(fig: go.Figure, include_plotlyjs: bool | str) -> str: 

333 """Return an HTML div string for *fig*. 

334 

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. 

340 

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 ) 

349 

350 

351@dataclasses.dataclass(frozen=True) 

352class Report: 

353 """Facade for generating HTML reports from a Portfolio. 

354 

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. 

358 

359 Usage:: 

360 

361 report = portfolio.report 

362 html_str = report.to_html() 

363 report.save("output/report.html") 

364 """ 

365 

366 portfolio: Portfolio 

367 

368 def to_html(self, title: str = "Basanos Portfolio Report") -> str: 

369 """Render a full HTML report as a string. 

370 

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. 

374 

375 Args: 

376 title: HTML ``<title>`` text and visible page heading. 

377 

378 Returns: 

379 A complete HTML document as a :class:`str`. 

380 """ 

381 pf = self.portfolio 

382 

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} &nbsp;|&nbsp; {n_periods:,} periods" 

391 else: 

392 start_date = "" 

393 end_date = "" 

394 period_info = f"{pf.prices.height:,} periods" 

395 

396 assets_list = ", ".join(pf.assets) 

397 

398 # ── Figures ─────────────────────────────────────────────────────────── 

399 # The first chart includes Plotly.js from CDN; subsequent ones reuse it. 

400 _first = True 

401 

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) 

407 

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>' 

415 

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) 

424 

425 # ── Stats table ─────────────────────────────────────────────────────── 

426 stats_table = _stats_table_html(pf.stats.summary()) 

427 

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>' 

447 

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> 

459 

460<header class="report-header"> 

461 <h1>&#x1F4CA; {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> 

468 

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> 

480 

481<div class="container"> 

482 

483 <section class="section" id="performance"> 

484 <h2 class="section-title">Portfolio Performance</h2> 

485 <div class="chart-card">{snapshot_div}</div> 

486 </section> 

487 

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> 

495 

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> 

500 

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> 

505 

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> 

510 

511 <section class="section" id="correlation"> 

512 <h2 class="section-title">Correlation Analysis</h2> 

513 <div class="chart-card">{corr_div}</div> 

514 </section> 

515 

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> 

520 

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> 

525 

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> 

530 

531</div> 

532 

533<footer class="report-footer"> 

534 Generated by <strong>basanos</strong>&nbsp;|&nbsp;{footer_date} 

535</footer> 

536 

537</body> 

538</html>""" 

539 

540 def save(self, path: str | Path, title: str = "Basanos Portfolio Report") -> Path: 

541 """Save the HTML report to a file. 

542 

543 A ``.html`` suffix is appended automatically when *path* has no 

544 file extension. 

545 

546 Args: 

547 path: Destination file path. 

548 title: HTML ``<title>`` text and visible page heading. 

549 

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