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

1"""HTML rendering for the self-contained performance report. 

2 

3These helpers turn the metrics table, drawdown summary, and Plotly 

4charts assembled by ``Reports.full`` into a single dark-themed HTML 

5document. 

6""" 

7 

8from __future__ import annotations 

9 

10from typing import Any 

11 

12import polars as pl 

13 

14from ._formatting import _fmt, _plotly_div, _table_html 

15 

16# ── Metrics-table section layout ─────────────────────────────────────────────── 

17 

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] 

131 

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) 

168 

169 

170def _metrics_table_html(df: pl.DataFrame) -> str: 

171 """Render a metrics DataFrame as a styled HTML table with section headers. 

172 

173 Args: 

174 df: DataFrame with a ``"Metric"`` column and one column per asset. 

175 

176 Returns: 

177 An HTML ``<table>`` string. 

178 

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 } 

184 

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] = [] 

188 

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) 

197 

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

200 

201 return _table_html(header_cells, "".join(parts)) 

202 

203 

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. 

211 

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. 

218 

219 Returns: 

220 A list of HTML ``<tr>`` strings (empty when no metric matched). 

221 

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 

233 

234 

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

241 

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. 

246 

247 Returns: 

248 A list of HTML ``<tr>`` strings for the leftover labels. 

249 

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 

262 

263 

264def _drawdowns_section_html(data: Any, assets: list[str]) -> str: 

265 """Render worst-5 drawdown periods per asset as HTML tables. 

266 

267 Args: 

268 data: The DataLike object (accessed via ``getattr`` for stats). 

269 assets: List of asset column names to render. 

270 

271 Returns: 

272 HTML string containing one table per asset. 

273 

274 """ 

275 stats = getattr(data, "stats", None) 

276 if stats is None: 

277 return "<p>No drawdown data available.</p>" 

278 

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

284 

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 

290 

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 ) 

310 

311 return "\n".join(parts) 

312 

313 

314def _try_plotly_div(fig: Any, include_cdn: bool = False) -> str: 

315 """Convert a Plotly figure to an HTML div string. 

316 

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. 

320 

321 Returns: 

322 An HTML string, or an empty string if conversion fails. 

323 

324 """ 

325 try: 

326 return _plotly_div(fig, include_plotlyjs="cdn" if include_cdn else False) 

327 except Exception: 

328 return "" 

329 

330 

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

350 

351 

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. 

361 

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. 

369 

370 Returns: 

371 A complete, self-contained HTML document string. 

372 

373 """ 

374 from datetime import date 

375 

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

408 

409 

410# ── Reports dataclass ─────────────────────────────────────────────────────────