Coverage for src / jquantstats / _reports / _data.py: 100%

268 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-07 14:28 +0000

1"""Financial report generation from returns data.""" 

2 

3from __future__ import annotations 

4 

5import dataclasses 

6import datetime 

7import math 

8from typing import TYPE_CHECKING, Any, cast 

9 

10import polars as pl 

11 

12if TYPE_CHECKING: 

13 from ._protocol import DataLike 

14 

15# ── Formatting helpers ──────────────────────────────────────────────────────── 

16 

17 

18def _is_finite(v: Any) -> bool: 

19 """Return True when *v* is a real, finite number.""" 

20 if not isinstance(v, (int, float)): 

21 return False 

22 return math.isfinite(float(v)) 

23 

24 

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

26 """Format *value* for display; return ``"N/A"`` for non-finite values.""" 

27 if not _is_finite(value): 

28 return "N/A" 

29 return f"{float(value):{fmt}}{suffix}" 

30 

31 

32def _safe(fn: Any, *args: Any, **kwargs: Any) -> dict[str, float]: 

33 """Call ``fn(*args, **kwargs)`` and return ``{}`` on any exception.""" 

34 try: 

35 return fn(*args, **kwargs) 

36 except Exception: 

37 return {} 

38 

39 

40def _pct(d: dict[str, float]) -> dict[str, float]: 

41 """Multiply every finite value in *d* by 100.""" 

42 return {k: v * 100.0 if _is_finite(v) else float("nan") for k, v in d.items()} 

43 

44 

45# ── Period-return helpers ───────────────────────────────────────────────────── 

46 

47 

48def _comp_since(all_df: pl.DataFrame, date_col: str, asset_cols: list[str], cutoff: Any) -> dict[str, float]: 

49 """Compounded return for each asset from *cutoff* to the last date.""" 

50 filtered = all_df.filter(pl.col(date_col) >= cutoff) 

51 result: dict[str, float] = {} 

52 for col in asset_cols: 

53 s = filtered[col].drop_nulls().cast(pl.Float64) 

54 result[col] = float((1.0 + s).product()) - 1.0 if len(s) > 0 else float("nan") 

55 return result 

56 

57 

58def _cagr_since( 

59 all_df: pl.DataFrame, 

60 date_col: str, 

61 asset_cols: list[str], 

62 cutoff: Any, 

63 periods_per_year: float, 

64) -> dict[str, float]: 

65 """Annualised CAGR for each asset from *cutoff* to the last date.""" 

66 filtered = all_df.filter(pl.col(date_col) >= cutoff) 

67 result: dict[str, float] = {} 

68 for col in asset_cols: 

69 s = filtered[col].drop_nulls().cast(pl.Float64) 

70 n = len(s) 

71 if n < 2: 

72 result[col] = float("nan") 

73 continue 

74 total = float((1.0 + s).product()) - 1.0 

75 years = n / periods_per_year 

76 result[col] = float(abs(1.0 + total) ** (1.0 / years) - 1.0) * (1 if total >= 0 else -1) 

77 return result 

78 

79 

80# ── Metrics-row helpers ─────────────────────────────────────────────────────── 

81 

82 

83def _cutoff_months(today: Any, n: int) -> Any: 

84 """Return the date *n* calendar months before *today*. 

85 

86 Args: 

87 today: Reference date (must support ``.year``, ``.month``, ``.day``). 

88 n: Number of calendar months to subtract. 

89 

90 Returns: 

91 A :class:`datetime.date` exactly *n* months before *today*. 

92 

93 """ 

94 import calendar 

95 from datetime import date as _date 

96 

97 y = today.year 

98 m = today.month 

99 for _ in range(n): 

100 m -= 1 

101 if m == 0: 

102 m = 12 

103 y -= 1 

104 d = min(today.day, calendar.monthrange(y, m)[1]) 

105 return _date(y, m, d) 

106 

107 

108def _add_overview_rows(rows: list[tuple[str, dict[str, Any]]], s: Any, ppy: float) -> None: 

109 """Append overview metric rows to *rows*. 

110 

111 Args: 

112 rows: Accumulator list of ``(label, values)`` tuples. 

113 s: Stats object providing the metric methods. 

114 ppy: Periods per year for annualisation. 

115 

116 """ 

117 rows.append(("Time in Market", _pct(_safe(s.exposure)))) 

118 rows.append(("Cumulative Return", _pct(_safe(s.comp)))) 

119 rows.append(("CAGR", _pct(_safe(s.cagr, periods=ppy)))) 

120 

121 

122def _add_risk_adjusted_rows(rows: list[tuple[str, dict[str, Any]]], s: Any, ppy: float) -> None: 

123 """Append risk-adjusted ratio rows to *rows*. 

124 

125 Args: 

126 rows: Accumulator list of ``(label, values)`` tuples. 

127 s: Stats object providing the metric methods. 

128 ppy: Periods per year for annualisation. 

129 

130 """ 

131 rows.append(("Sharpe", _safe(s.sharpe, periods=ppy))) 

132 rows.append(("Prob. Sharpe Ratio", _pct(_safe(s.probabilistic_sharpe_ratio)))) 

133 rows.append(("Sortino", _safe(s.sortino, periods=ppy))) 

134 rows.append(("Sortino / √2", _safe(s.adjusted_sortino, periods=ppy))) 

135 rows.append(("Omega", _safe(s.omega, periods=ppy))) 

136 

137 

138def _add_drawdown_rows(rows: list[tuple[str, dict[str, Any]]], s: Any) -> None: 

139 """Append drawdown metric rows to *rows*. 

140 

141 Args: 

142 rows: Accumulator list of ``(label, values)`` tuples. 

143 s: Stats object providing the metric methods. 

144 

145 """ 

146 rows.append(("Max Drawdown", _pct(_safe(s.max_drawdown)))) 

147 rows.append(("Max DD Duration", _safe(s.max_drawdown_duration))) 

148 rows.append(("Avg Drawdown", _pct(_safe(s.avg_drawdown)))) 

149 rows.append(("Recovery Factor", _safe(s.recovery_factor))) 

150 rows.append(("Ulcer Index", _safe(s.ulcer_index))) 

151 rows.append(("Serenity Index", _safe(s.serenity_index))) 

152 

153 

154def _add_trading_rows(rows: list[tuple[str, dict[str, Any]]], s: Any) -> None: 

155 """Append trading metric rows to *rows*. 

156 

157 Args: 

158 rows: Accumulator list of ``(label, values)`` tuples. 

159 s: Stats object providing the metric methods. 

160 

161 """ 

162 rows.append(("Gain/Pain Ratio", _safe(s.gain_to_pain_ratio))) 

163 rows.append(("Gain/Pain (1M)", _safe(s.gain_to_pain_ratio, aggregate="ME"))) 

164 rows.append(("Payoff Ratio", _safe(s.payoff_ratio))) 

165 rows.append(("Profit Factor", _safe(s.profit_factor))) 

166 rows.append(("Common Sense Ratio", _safe(s.common_sense_ratio))) 

167 rows.append(("CPC Index", _safe(s.cpc_index))) 

168 rows.append(("Tail Ratio", _safe(s.tail_ratio))) 

169 rows.append(("Outlier Win Ratio", _safe(s.outlier_win_ratio))) 

170 rows.append(("Outlier Loss Ratio", _safe(s.outlier_loss_ratio))) 

171 

172 

173def _add_recent_returns_rows( 

174 rows: list[tuple[str, dict[str, Any]]], 

175 all_df: pl.DataFrame, 

176 date_col: str, 

177 asset_cols: list[str], 

178 ppy: float, 

179 s: Any, 

180) -> None: 

181 """Append date-filtered recent return rows to *rows*. 

182 

183 Args: 

184 rows: Accumulator list of ``(label, values)`` tuples. 

185 all_df: Combined DataFrame containing date and return columns. 

186 date_col: Name of the date column in *all_df*. 

187 asset_cols: Names of asset return columns in *all_df*. 

188 ppy: Periods per year for annualisation. 

189 s: Stats object used for the all-time CAGR. 

190 

191 """ 

192 today = cast(datetime.date, all_df[date_col].max()) 

193 mtd_start = today.replace(day=1) 

194 ytd_start = today.replace(month=1, day=1) 

195 

196 rows.append(("MTD", _pct(_comp_since(all_df, date_col, asset_cols, mtd_start)))) 

197 rows.append(("3M", _pct(_comp_since(all_df, date_col, asset_cols, _cutoff_months(today, 3))))) 

198 rows.append(("6M", _pct(_comp_since(all_df, date_col, asset_cols, _cutoff_months(today, 6))))) 

199 rows.append(("YTD", _pct(_comp_since(all_df, date_col, asset_cols, ytd_start)))) 

200 rows.append(("1Y", _pct(_comp_since(all_df, date_col, asset_cols, _cutoff_months(today, 12))))) 

201 rows.append(("3Y (ann.)", _pct(_cagr_since(all_df, date_col, asset_cols, _cutoff_months(today, 36), ppy)))) 

202 rows.append(("5Y (ann.)", _pct(_cagr_since(all_df, date_col, asset_cols, _cutoff_months(today, 60), ppy)))) 

203 rows.append(("All-time (ann.)", _pct(_safe(s.cagr, periods=ppy)))) 

204 

205 

206def _add_full_mode_rows( 

207 rows: list[tuple[str, dict[str, Any]]], 

208 s: Any, 

209 ppy: float, 

210 data: Any, 

211 all_df: pl.DataFrame | None, 

212 date_col: str | None, 

213 asset_cols: list[str], 

214) -> None: 

215 """Append all full-mode extension rows to *rows*. 

216 

217 Covers smart ratios, extended risk, averages, expected returns, tail risk, 

218 streaks, best/worst periods, and benchmark metrics. 

219 

220 Args: 

221 rows: Accumulator list of ``(label, values)`` tuples. 

222 s: Stats object providing the metric methods. 

223 ppy: Periods per year for annualisation. 

224 data: The DataLike object (used for benchmark access). 

225 all_df: Combined DataFrame or ``None`` if unavailable. 

226 date_col: Name of the date column or ``None`` if unavailable. 

227 asset_cols: Asset column names. 

228 

229 """ 

230 # Smart ratios 

231 rows.append(("Smart Sharpe", _safe(s.smart_sharpe, periods=ppy))) 

232 ss = _safe(s.smart_sortino, periods=ppy) 

233 rows.append(("Smart Sortino", ss)) 

234 rows.append(("Smart Sortino / √2", {k: v / math.sqrt(2) for k, v in ss.items() if _is_finite(v)})) 

235 

236 # Risk 

237 rows.append(("Volatility (ann.)", _pct(_safe(s.volatility, periods=ppy)))) 

238 rows.append(("Calmar", _safe(s.calmar, periods=ppy))) 

239 rows.append(("Risk-Adjusted Return", _pct(_safe(s.rar, periods=ppy)))) 

240 rows.append(("Risk-Return Ratio", _safe(s.risk_return_ratio))) 

241 rows.append(("Ulcer Performance Index", _safe(s.ulcer_performance_index))) 

242 rows.append(("Skew", _safe(s.skew))) 

243 rows.append(("Kurtosis", _safe(s.kurtosis))) 

244 

245 # Averages 

246 rows.append(("Avg. Return", _pct(_safe(s.avg_return)))) 

247 rows.append(("Avg. Win", _pct(_safe(s.avg_win)))) 

248 rows.append(("Avg. Loss", _pct(_safe(s.avg_loss)))) 

249 rows.append(("Win/Loss Ratio", _safe(s.win_loss_ratio))) 

250 rows.append(("Profit Ratio", _safe(s.profit_ratio))) 

251 rows.append(("Win Rate", _pct(_safe(s.win_rate)))) 

252 rows.append(("Monthly Win Rate", _pct(_safe(s.monthly_win_rate)))) 

253 

254 # Expected returns 

255 rows.append(("Expected Daily", _pct(_safe(s.expected_return)))) 

256 rows.append(("Expected Monthly", _pct(_safe(s.expected_return, aggregate="monthly")))) 

257 rows.append(("Expected Yearly", _pct(_safe(s.expected_return, aggregate="yearly")))) 

258 

259 # Tail risk 

260 rows.append(("Kelly Criterion", _pct(_safe(s.kelly_criterion)))) 

261 rows.append(("Risk of Ruin", _pct(_safe(s.risk_of_ruin)))) 

262 rows.append(("Daily VaR", _pct(_safe(s.value_at_risk)))) 

263 rows.append(("Expected Shortfall (cVaR)", _pct(_safe(s.conditional_value_at_risk)))) 

264 

265 # Streaks & best / worst 

266 rows.append(("Max Consecutive Wins", _safe(s.consecutive_wins))) 

267 rows.append(("Max Consecutive Losses", _safe(s.consecutive_losses))) 

268 rows.append(("Best Day", _pct(_safe(s.best)))) 

269 rows.append(("Worst Day", _pct(_safe(s.worst)))) 

270 

271 # Benchmark greeks (only if benchmark is present) 

272 try: 

273 greeks = s.greeks() 

274 if greeks: 

275 beta = {k: v["beta"] for k, v in greeks.items()} 

276 alpha = {k: v["alpha"] * 100.0 for k, v in greeks.items()} 

277 rows.append(("Beta", beta)) 

278 rows.append(("Alpha", alpha)) 

279 except Exception: # noqa: S110 

280 pass # nosec B110 

281 

282 try: 

283 bench_obj = getattr(data, "benchmark", None) 

284 if bench_obj is not None and all_df is not None and date_col is not None: 

285 bench_col = bench_obj.columns[0] 

286 corr_dict: dict[str, float] = {} 

287 for ac in asset_cols: 

288 if ac == bench_col: 

289 continue 

290 sub = all_df.select([date_col, ac, bench_col]).drop_nulls() 

291 corr_val = float(sub.select(pl.corr(ac, bench_col))[0, 0]) 

292 corr_dict[ac] = corr_val * 100.0 

293 rows.append(("Correlation", corr_dict)) 

294 except Exception: # noqa: S110 

295 pass # nosec B110 

296 

297 rows.append(("R²", _safe(s.r2))) 

298 rows.append(("Treynor Ratio", _safe(s.treynor_ratio, periods=ppy))) 

299 

300 

301def _build_metrics_df(rows: list[tuple[str, dict[str, Any]]]) -> pl.DataFrame: 

302 """Build a metrics :class:`~polars.DataFrame` from accumulated row data. 

303 

304 Args: 

305 rows: List of ``(label, values)`` tuples where *values* maps asset 

306 names to numeric results. 

307 

308 Returns: 

309 A DataFrame with a leading ``"Metric"`` column and one column per 

310 asset, preserving the insertion order of both metrics and assets. 

311 

312 """ 

313 all_assets: list[str] = [] 

314 seen: set[str] = set() 

315 for _, vals in rows: 

316 for k in vals: 

317 if k not in seen: 

318 all_assets.append(k) 

319 seen.add(k) 

320 return pl.DataFrame([{"Metric": label, **{a: vals.get(a) for a in all_assets}} for label, vals in rows]) 

321 

322 

323# ── Metrics-table HTML renderer ─────────────────────────────────────────────── 

324 

325_SECTION_SPANS: list[tuple[str, list[str]]] = [ 

326 ( 

327 "Overview", 

328 [ 

329 "Start Period", 

330 "End Period", 

331 "Time in Market", 

332 "Cumulative Return", 

333 "CAGR", 

334 ], 

335 ), 

336 ( 

337 "Risk-Adjusted Ratios", 

338 [ 

339 "Sharpe", 

340 "Prob. Sharpe Ratio", 

341 "Sortino", 

342 "Sortino / √2", 

343 "Omega", 

344 ], 

345 ), 

346 ( 

347 "Drawdown", 

348 [ 

349 "Max Drawdown", 

350 "Max DD Duration", 

351 "Avg Drawdown", 

352 "Recovery Factor", 

353 "Ulcer Index", 

354 "Serenity Index", 

355 ], 

356 ), 

357 ( 

358 "Trading", 

359 [ 

360 "Gain/Pain Ratio", 

361 "Gain/Pain (1M)", 

362 "Payoff Ratio", 

363 "Profit Factor", 

364 "Common Sense Ratio", 

365 "CPC Index", 

366 "Tail Ratio", 

367 "Outlier Win Ratio", 

368 "Outlier Loss Ratio", 

369 ], 

370 ), 

371 ( 

372 "Recent Returns", 

373 [ 

374 "MTD", 

375 "3M", 

376 "6M", 

377 "YTD", 

378 "1Y", 

379 "3Y (ann.)", 

380 "5Y (ann.)", 

381 "All-time (ann.)", 

382 ], 

383 ), 

384 ( 

385 "Smart Ratios", 

386 ["Smart Sharpe", "Smart Sortino", "Smart Sortino / √2"], 

387 ), 

388 ( 

389 "Risk", 

390 [ 

391 "Volatility (ann.)", 

392 "Calmar", 

393 "Risk-Adjusted Return", 

394 "Risk-Return Ratio", 

395 "Ulcer Performance Index", 

396 "Skew", 

397 "Kurtosis", 

398 ], 

399 ), 

400 ( 

401 "Averages", 

402 [ 

403 "Avg. Return", 

404 "Avg. Win", 

405 "Avg. Loss", 

406 "Win/Loss Ratio", 

407 "Profit Ratio", 

408 "Win Rate", 

409 "Monthly Win Rate", 

410 ], 

411 ), 

412 ( 

413 "Expected Returns", 

414 ["Expected Daily", "Expected Monthly", "Expected Yearly"], 

415 ), 

416 ( 

417 "Tail Risk", 

418 [ 

419 "Kelly Criterion", 

420 "Risk of Ruin", 

421 "Daily VaR", 

422 "Expected Shortfall (cVaR)", 

423 ], 

424 ), 

425 ( 

426 "Streaks", 

427 ["Max Consecutive Wins", "Max Consecutive Losses"], 

428 ), 

429 ( 

430 "Best / Worst", 

431 ["Best Day", "Worst Day"], 

432 ), 

433 ( 

434 "Benchmark", 

435 ["Beta", "Alpha", "Correlation", "R²", "Treynor Ratio"], 

436 ), 

437] 

438 

439_PCT_METRICS: frozenset[str] = frozenset( 

440 { 

441 "Time in Market", 

442 "Cumulative Return", 

443 "CAGR", 

444 "Prob. Sharpe Ratio", 

445 "Max Drawdown", 

446 "Avg Drawdown", 

447 "MTD", 

448 "3M", 

449 "6M", 

450 "YTD", 

451 "1Y", 

452 "3Y (ann.)", 

453 "5Y (ann.)", 

454 "All-time (ann.)", 

455 "Volatility (ann.)", 

456 "Risk-Adjusted Return", 

457 "Avg. Return", 

458 "Avg. Win", 

459 "Avg. Loss", 

460 "Win Rate", 

461 "Monthly Win Rate", 

462 "Expected Daily", 

463 "Expected Monthly", 

464 "Expected Yearly", 

465 "Kelly Criterion", 

466 "Risk of Ruin", 

467 "Daily VaR", 

468 "Expected Shortfall (cVaR)", 

469 "Best Day", 

470 "Worst Day", 

471 "Alpha", 

472 "Correlation", 

473 } 

474) 

475 

476 

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

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

479 

480 Args: 

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

482 

483 Returns: 

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

485 

486 """ 

487 assets = [c for c in df.columns if c != "Metric"] 

488 rows_by_label: dict[str, dict[str, Any]] = { 

489 str(row["Metric"]): {a: row.get(a) for a in assets} for row in df.iter_rows(named=True) 

490 } 

491 

492 n_cols = len(assets) + 1 

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

494 parts: list[str] = [] 

495 

496 rendered: set[str] = set() 

497 for section_label, section_metrics in _SECTION_SPANS: 

498 section_rows: list[str] = [] 

499 for label in section_metrics: 

500 if label not in rows_by_label: 

501 continue 

502 vals = rows_by_label[label] 

503 rendered.add(label) 

504 suffix = "%" if label in _PCT_METRICS else "" 

505 cells = "".join(f'<td class="metric-value">{_fmt(vals.get(a), ".2f", suffix)}</td>' for a in assets) 

506 section_rows.append(f'<tr><td class="metric-name">{label}</td>{cells}</tr>\n') 

507 

508 if section_rows: 

509 parts.append( 

510 f'<tr class="table-section-header"><td colspan="{n_cols}"><strong>{section_label}</strong></td></tr>\n' 

511 ) 

512 parts.extend(section_rows) 

513 

514 # Anything not matched by a section (e.g. string-valued rows like dates) 

515 for label, vals in rows_by_label.items(): 

516 if label in rendered: 

517 continue 

518 raw = next(iter(vals.values()), None) 

519 if isinstance(raw, str): 

520 cells = "".join(f'<td class="metric-value">{vals.get(a, "")}</td>' for a in assets) 

521 else: 

522 cells = "".join(f'<td class="metric-value">{_fmt(vals.get(a), ".4f")}</td>' for a in assets) 

523 parts.append(f'<tr><td class="metric-name">{label}</td>{cells}</tr>\n') 

524 

525 return ( 

526 '<table class="stats-table">' 

527 "<thead><tr>" 

528 f'<th class="metric-header">Metric</th>{header_cells}' 

529 "</tr></thead>" 

530 f"<tbody>{''.join(parts)}</tbody>" 

531 "</table>" 

532 ) 

533 

534 

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

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

537 

538 Args: 

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

540 assets: List of asset column names to render. 

541 

542 Returns: 

543 HTML string containing one table per asset. 

544 

545 """ 

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

547 if stats is None: 

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

549 

550 parts: list[str] = [] 

551 try: 

552 dd_dict: dict[str, pl.DataFrame] = stats.drawdown_details() 

553 except Exception: 

554 return "<p>Drawdown details unavailable.</p>" 

555 

556 for asset in assets: 

557 df = dd_dict.get(asset) 

558 if df is None or len(df) == 0: 

559 parts.append(f"<h3>{asset}</h3><p>No drawdown periods found.</p>") 

560 continue 

561 

562 worst5 = df.sort("max_drawdown").head(5) 

563 rows = "".join( 

564 f"<tr>" 

565 f"<td>{row.get('start', '')}</td>" 

566 f"<td>{row.get('valley', '')}</td>" 

567 f"<td>{row.get('end', '') or '—'}</td>" 

568 f"<td>{_fmt(row.get('max_drawdown'), '.2%')}</td>" 

569 f"<td>{row.get('duration', '') or '—'}</td>" 

570 f"</tr>" 

571 for row in worst5.iter_rows(named=True) 

572 ) 

573 parts.append( 

574 f"<h3>{asset}</h3>" 

575 '<table class="stats-table">' 

576 "<thead><tr>" 

577 "<th>Start</th><th>Valley</th><th>End</th><th>Max DD</th><th>Duration</th>" 

578 "</tr></thead>" 

579 f"<tbody>{rows}</tbody></table>" 

580 ) 

581 

582 return "\n".join(parts) 

583 

584 

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

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

587 

588 Args: 

589 fig: A Plotly Figure object (or anything with ``to_html``). 

590 include_cdn: Include the Plotly JS CDN ``<script>`` tag. Defaults to False. 

591 

592 Returns: 

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

594 

595 """ 

596 try: 

597 import plotly.io as pio 

598 

599 return pio.to_html( 

600 fig, 

601 full_html=False, 

602 include_plotlyjs="cdn" if include_cdn else False, 

603 ) 

604 except Exception: 

605 return "" 

606 

607 

608_REPORT_CSS = """ 

609body{margin:0;font-family:system-ui,sans-serif;background:#0f1117;color:#e2e8f0} 

610h1{color:#90cdf4;margin:0 0 4px} 

611h2{color:#63b3ed;border-bottom:1px solid #2d3748;padding-bottom:6px} 

612h3{color:#a0aec0;margin:16px 0 6px} 

613header{padding:24px 32px;background:linear-gradient(135deg,#1a202c,#2d3748);border-bottom:1px solid #4a5568} 

614.period-info{color:#a0aec0;font-size:.85rem;margin-top:4px} 

615main{padding:24px 32px} 

616section{margin-bottom:40px} 

617.stats-table{border-collapse:collapse;width:100%;font-size:.85rem} 

618.stats-table th,.stats-table td{padding:6px 12px;text-align:right;border-bottom:1px solid #2d3748} 

619.stats-table th:first-child,.stats-table td:first-child{text-align:left} 

620.metric-header,.asset-header{background:#1a202c;color:#90cdf4;font-weight:600} 

621.metric-name{color:#cbd5e0} 

622.metric-value{font-family:monospace;color:#e2e8f0} 

623.table-section-header td{background:#1a202c;color:#68d391;font-size:.75rem;text-transform:uppercase; 

624letter-spacing:.08em;padding:8px 12px} 

625footer{padding:16px 32px;color:#718096;font-size:.75rem;border-top:1px solid #2d3748} 

626""" 

627 

628 

629def _build_full_html( 

630 title: str, 

631 period_info: str, 

632 assets_str: str, 

633 metrics_html: str, 

634 drawdowns_html: str, 

635 charts_html: str, 

636) -> str: 

637 """Assemble the full HTML report from its component parts. 

638 

639 Args: 

640 title: Page and ``<h1>`` title. 

641 period_info: Period metadata string for the header. 

642 assets_str: Comma-separated asset names for the header. 

643 metrics_html: Pre-rendered metrics ``<table>`` HTML. 

644 drawdowns_html: Pre-rendered worst-drawdowns HTML. 

645 charts_html: Pre-rendered Plotly chart divs. 

646 

647 Returns: 

648 A complete, self-contained HTML document string. 

649 

650 """ 

651 from datetime import date 

652 

653 footer_date = str(date.today()) 

654 return f"""<!DOCTYPE html> 

655<html lang="en"> 

656<head> 

657<meta charset="utf-8"> 

658<meta name="viewport" content="width=device-width,initial-scale=1"> 

659<title>{title}</title> 

660<style>{_REPORT_CSS}</style> 

661</head> 

662<body> 

663<header> 

664 <h1>{title}</h1> 

665 <div class="period-info">{period_info}</div> 

666 <div class="period-info">Assets: {assets_str}</div> 

667</header> 

668<main> 

669 <section id="metrics"> 

670 <h2>Performance Metrics</h2> 

671 {metrics_html} 

672 </section> 

673 <section id="drawdowns"> 

674 <h2>Worst 5 Drawdown Periods</h2> 

675 {drawdowns_html} 

676 </section> 

677 <section id="charts"> 

678 <h2>Charts</h2> 

679 {charts_html} 

680 </section> 

681</main> 

682<footer>Generated by jquantstats · {footer_date}</footer> 

683</body> 

684</html>""" 

685 

686 

687# ── Reports dataclass ───────────────────────────────────────────────────────── 

688 

689 

690@dataclasses.dataclass(frozen=True) 

691class Reports: 

692 """A class for generating financial reports from Data objects. 

693 

694 This class provides methods for calculating and formatting various financial metrics 

695 into report-ready formats such as DataFrames. 

696 

697 Attributes: 

698 data (DataLike): The financial data object to generate reports from. 

699 

700 """ 

701 

702 data: DataLike 

703 

704 def metrics( 

705 self, 

706 mode: str = "basic", 

707 periods_per_year: int | float = 252, 

708 rf: float = 0.0, 

709 ) -> pl.DataFrame: 

710 """Comprehensive performance metrics table matching ``qs.reports.metrics``. 

711 

712 Computes an ordered set of performance, risk, and trading metrics for 

713 every asset in the dataset and returns them as a tidy DataFrame. 

714 

715 Args: 

716 mode: ``"basic"`` (default) for core metrics, ``"full"`` for the 

717 extended set including smart ratios, expected returns, streaks, 

718 best/worst periods, win rates, and benchmark greeks. 

719 periods_per_year: Annualisation factor. Defaults to 252. 

720 rf: Annualised risk-free rate used in ratio calculations. 

721 Defaults to 0.0. 

722 

723 Returns: 

724 pl.DataFrame: One row per metric, one column per asset, plus a 

725 leading ``"Metric"`` column with the metric label. 

726 

727 """ 

728 s = self.data.stats 

729 ppy = float(periods_per_year) 

730 is_full = mode.lower() == "full" 

731 

732 rows: list[tuple[str, dict[str, Any]]] = [] 

733 

734 all_df: pl.DataFrame | None = getattr(self.data, "all", None) 

735 asset_cols: list[str] = [] 

736 date_col: str | None = None 

737 has_dates = False 

738 

739 if all_df is not None: 

740 date_col = all_df.columns[0] 

741 asset_cols = [c for c in all_df.columns if c != date_col] 

742 has_dates = all_df[date_col].dtype.is_temporal() 

743 

744 _add_overview_rows(rows, s, ppy) 

745 _add_risk_adjusted_rows(rows, s, ppy) 

746 _add_drawdown_rows(rows, s) 

747 _add_trading_rows(rows, s) 

748 

749 if has_dates and date_col is not None and all_df is not None: 

750 _add_recent_returns_rows(rows, all_df, date_col, asset_cols, ppy, s) 

751 

752 if is_full: 

753 _add_full_mode_rows(rows, s, ppy, self.data, all_df, date_col, asset_cols) 

754 

755 return _build_metrics_df(rows) 

756 

757 def full( 

758 self, 

759 title: str = "Performance Report", 

760 periods_per_year: int | float = 252, 

761 rf: float = 0.0, 

762 ) -> str: 

763 """Generate a self-contained HTML performance report. 

764 

765 Combines a comprehensive metrics table (full mode), worst-5 drawdown 

766 periods per asset, and interactive Plotly charts into a single 

767 dark-themed HTML document. 

768 

769 Args: 

770 title: Page ``<h1>`` title. Defaults to ``"Performance Report"``. 

771 periods_per_year: Annualisation factor passed to 

772 :py:meth:`metrics`. Defaults to 252. 

773 rf: Annualised risk-free rate. Defaults to 0.0. 

774 

775 Returns: 

776 str: A complete, self-contained HTML document. 

777 

778 """ 

779 # ── Metrics ─────────────────────────────────────────────────────────── 

780 metrics_df = self.metrics(mode="full", periods_per_year=periods_per_year, rf=rf) 

781 assets = [c for c in metrics_df.columns if c != "Metric"] 

782 metrics_html = _metrics_table_html(metrics_df) 

783 

784 # ── Period info for header ──────────────────────────────────────────── 

785 all_df: pl.DataFrame | None = getattr(self.data, "all", None) 

786 period_info = "" 

787 if all_df is not None: 

788 date_col = all_df.columns[0] 

789 if all_df[date_col].dtype.is_temporal(): 

790 start_dt = all_df[date_col].min() 

791 end_dt = all_df[date_col].max() 

792 n = len(all_df) 

793 period_info = f"{start_dt}{end_dt} | {n:,} observations" 

794 

795 # ── Drawdowns ───────────────────────────────────────────────────────── 

796 drawdowns_html = _drawdowns_section_html(self.data, assets) 

797 

798 # ── Charts ──────────────────────────────────────────────────────────── 

799 plots = getattr(self.data, "plots", None) 

800 chart_parts: list[str] = [] 

801 if plots is not None: 

802 _chart_methods = [ 

803 ("snapshot", {}), 

804 ("returns", {}), 

805 ("drawdown", {}), 

806 ("rolling_sharpe", {}), 

807 ("rolling_volatility", {}), 

808 ("monthly_heatmap", {}), 

809 ("yearly_returns", {}), 

810 ("histogram", {}), 

811 ] 

812 for i, (method, kwargs) in enumerate(_chart_methods): 

813 fn = getattr(plots, method, None) 

814 if fn is None: 

815 continue 

816 div = _try_plotly_div(fn(**kwargs), include_cdn=(i == 0)) 

817 if div: 

818 chart_parts.append(f'<div style="margin-bottom:24px">{div}</div>') 

819 

820 charts_html = "\n".join(chart_parts) if chart_parts else "<p>No charts available.</p>" 

821 

822 return _build_full_html( 

823 title=title, 

824 period_info=period_info, 

825 assets_str=", ".join(assets), 

826 metrics_html=metrics_html, 

827 drawdowns_html=drawdowns_html, 

828 charts_html=charts_html, 

829 )