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
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-07 14:28 +0000
1"""Financial report generation from returns data."""
3from __future__ import annotations
5import dataclasses
6import datetime
7import math
8from typing import TYPE_CHECKING, Any, cast
10import polars as pl
12if TYPE_CHECKING:
13 from ._protocol import DataLike
15# ── Formatting helpers ────────────────────────────────────────────────────────
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))
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}"
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 {}
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()}
45# ── Period-return helpers ─────────────────────────────────────────────────────
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
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
80# ── Metrics-row helpers ───────────────────────────────────────────────────────
83def _cutoff_months(today: Any, n: int) -> Any:
84 """Return the date *n* calendar months before *today*.
86 Args:
87 today: Reference date (must support ``.year``, ``.month``, ``.day``).
88 n: Number of calendar months to subtract.
90 Returns:
91 A :class:`datetime.date` exactly *n* months before *today*.
93 """
94 import calendar
95 from datetime import date as _date
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)
108def _add_overview_rows(rows: list[tuple[str, dict[str, Any]]], s: Any, ppy: float) -> None:
109 """Append overview metric rows to *rows*.
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.
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))))
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*.
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.
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)))
138def _add_drawdown_rows(rows: list[tuple[str, dict[str, Any]]], s: Any) -> None:
139 """Append drawdown metric rows to *rows*.
141 Args:
142 rows: Accumulator list of ``(label, values)`` tuples.
143 s: Stats object providing the metric methods.
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)))
154def _add_trading_rows(rows: list[tuple[str, dict[str, Any]]], s: Any) -> None:
155 """Append trading metric rows to *rows*.
157 Args:
158 rows: Accumulator list of ``(label, values)`` tuples.
159 s: Stats object providing the metric methods.
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)))
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*.
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.
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)
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))))
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*.
217 Covers smart ratios, extended risk, averages, expected returns, tail risk,
218 streaks, best/worst periods, and benchmark metrics.
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.
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)}))
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)))
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))))
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"))))
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))))
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))))
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
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
297 rows.append(("R²", _safe(s.r2)))
298 rows.append(("Treynor Ratio", _safe(s.treynor_ratio, periods=ppy)))
301def _build_metrics_df(rows: list[tuple[str, dict[str, Any]]]) -> pl.DataFrame:
302 """Build a metrics :class:`~polars.DataFrame` from accumulated row data.
304 Args:
305 rows: List of ``(label, values)`` tuples where *values* maps asset
306 names to numeric results.
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.
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])
323# ── Metrics-table HTML renderer ───────────────────────────────────────────────
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]
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)
477def _metrics_table_html(df: pl.DataFrame) -> str:
478 """Render a metrics DataFrame as a styled HTML table with section headers.
480 Args:
481 df: DataFrame with a ``"Metric"`` column and one column per asset.
483 Returns:
484 An HTML ``<table>`` string.
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 }
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] = []
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')
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)
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')
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 )
535def _drawdowns_section_html(data: Any, assets: list[str]) -> str:
536 """Render worst-5 drawdown periods per asset as HTML tables.
538 Args:
539 data: The DataLike object (accessed via ``getattr`` for stats).
540 assets: List of asset column names to render.
542 Returns:
543 HTML string containing one table per asset.
545 """
546 stats = getattr(data, "stats", None)
547 if stats is None:
548 return "<p>No drawdown data available.</p>"
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>"
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
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 )
582 return "\n".join(parts)
585def _try_plotly_div(fig: Any, include_cdn: bool = False) -> str:
586 """Convert a Plotly figure to an HTML div string.
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.
592 Returns:
593 An HTML string, or an empty string if conversion fails.
595 """
596 try:
597 import plotly.io as pio
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 ""
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"""
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.
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.
647 Returns:
648 A complete, self-contained HTML document string.
650 """
651 from datetime import date
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>"""
687# ── Reports dataclass ─────────────────────────────────────────────────────────
690@dataclasses.dataclass(frozen=True)
691class Reports:
692 """A class for generating financial reports from Data objects.
694 This class provides methods for calculating and formatting various financial metrics
695 into report-ready formats such as DataFrames.
697 Attributes:
698 data (DataLike): The financial data object to generate reports from.
700 """
702 data: DataLike
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``.
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.
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.
723 Returns:
724 pl.DataFrame: One row per metric, one column per asset, plus a
725 leading ``"Metric"`` column with the metric label.
727 """
728 s = self.data.stats
729 ppy = float(periods_per_year)
730 is_full = mode.lower() == "full"
732 rows: list[tuple[str, dict[str, Any]]] = []
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
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()
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)
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)
752 if is_full:
753 _add_full_mode_rows(rows, s, ppy, self.data, all_df, date_col, asset_cols)
755 return _build_metrics_df(rows)
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.
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.
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.
775 Returns:
776 str: A complete, self-contained HTML document.
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)
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"
795 # ── Drawdowns ─────────────────────────────────────────────────────────
796 drawdowns_html = _drawdowns_section_html(self.data, assets)
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>')
820 charts_html = "\n".join(chart_parts) if chart_parts else "<p>No charts available.</p>"
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 )