Coverage for src/jquantstats/_reports/_data.py: 100%
270 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-23 06:13 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-23 06:13 +0000
1"""Financial report generation from returns data."""
3from __future__ import annotations
5import datetime
6import math
7import warnings
8from typing import TYPE_CHECKING, Any, cast
10import polars as pl
12if TYPE_CHECKING:
13 from jquantstats._protocol import DataLike
15from ._formatting import _fmt, _is_finite, _plotly_div, _table_html
17# ── Private helpers ───────────────────────────────────────────────────────────
20def _safe(fn: Any, *args: Any, **kwargs: Any) -> dict[str, float]:
21 """Call ``fn(*args, **kwargs)`` and return ``{}`` on any exception."""
22 try:
23 result: dict[str, float] = fn(*args, **kwargs)
24 except Exception:
25 return {}
26 return result
29def _pct(d: dict[str, float]) -> dict[str, float]:
30 """Multiply every finite value in *d* by 100."""
31 return {k: v * 100.0 if _is_finite(v) else float("nan") for k, v in d.items()}
34# ── Period-return helpers ─────────────────────────────────────────────────────
37def _comp_since(all_df: pl.DataFrame, date_col: str, asset_cols: list[str], cutoff: Any) -> dict[str, float]:
38 """Compounded return for each asset from *cutoff* to the last date."""
39 filtered = all_df.filter(pl.col(date_col) >= cutoff)
40 result: dict[str, float] = {}
41 for col in asset_cols:
42 s = filtered[col].drop_nulls().cast(pl.Float64)
43 result[col] = float((1.0 + s).product()) - 1.0 if len(s) > 0 else float("nan")
44 return result
47def _cagr_since(
48 all_df: pl.DataFrame,
49 date_col: str,
50 asset_cols: list[str],
51 cutoff: Any,
52 periods_per_year: float,
53) -> dict[str, float]:
54 """Annualised CAGR for each asset from *cutoff* to the last date."""
55 filtered = all_df.filter(pl.col(date_col) >= cutoff)
56 result: dict[str, float] = {}
57 for col in asset_cols:
58 s = filtered[col].drop_nulls().cast(pl.Float64)
59 n = len(s)
60 if n < 2:
61 result[col] = float("nan")
62 continue
63 total = float((1.0 + s).product()) - 1.0
64 years = n / periods_per_year
65 result[col] = float(abs(1.0 + total) ** (1.0 / years) - 1.0)
66 return result
69# ── Metrics-row helpers ───────────────────────────────────────────────────────
72def _cutoff_months(today: Any, n: int) -> Any:
73 """Return the date *n* calendar months before *today*.
75 Args:
76 today: Reference date (must support ``.year``, ``.month``, ``.day``).
77 n: Number of calendar months to subtract.
79 Returns:
80 A `datetime.date` exactly *n* months before *today*.
82 """
83 import calendar
84 from datetime import date as _date
86 y = today.year
87 m = today.month
88 for _ in range(n):
89 m -= 1
90 if m == 0:
91 m = 12
92 y -= 1
93 d = min(today.day, calendar.monthrange(y, m)[1])
94 return _date(y, m, d)
97def _add_overview_rows(rows: list[tuple[str, dict[str, Any]]], s: Any, ppy: float) -> None:
98 """Append overview metric rows to *rows*.
100 Args:
101 rows: Accumulator list of ``(label, values)`` tuples.
102 s: Stats object providing the metric methods.
103 ppy: Periods per year for annualisation.
105 """
106 rows.append(("Time in Market", _pct(_safe(s.exposure))))
107 rows.append(("Cumulative Return", _pct(_safe(s.comp))))
108 rows.append(("CAGR", _pct(_safe(s.cagr, periods=ppy))))
111def _add_risk_adjusted_rows(rows: list[tuple[str, dict[str, Any]]], s: Any, ppy: float) -> None:
112 """Append risk-adjusted ratio rows to *rows*.
114 Args:
115 rows: Accumulator list of ``(label, values)`` tuples.
116 s: Stats object providing the metric methods.
117 ppy: Periods per year for annualisation.
119 """
120 rows.append(("Sharpe", _safe(s.sharpe, periods=ppy)))
121 rows.append(("Prob. Sharpe Ratio", _pct(_safe(s.probabilistic_sharpe_ratio))))
122 rows.append(("Sortino", _safe(s.sortino, periods=ppy)))
123 rows.append(("Sortino / √2", _safe(s.adjusted_sortino, periods=ppy)))
124 rows.append(("Omega", _safe(s.omega, periods=ppy)))
127def _add_drawdown_rows(rows: list[tuple[str, dict[str, Any]]], s: Any) -> None:
128 """Append drawdown metric rows to *rows*.
130 Args:
131 rows: Accumulator list of ``(label, values)`` tuples.
132 s: Stats object providing the metric methods.
134 """
135 rows.append(("Max Drawdown", _pct(_safe(s.max_drawdown))))
136 rows.append(("Max DD Duration", _safe(s.max_drawdown_duration)))
137 rows.append(("Avg Drawdown", _pct(_safe(s.avg_drawdown))))
138 rows.append(("Recovery Factor", _safe(s.recovery_factor)))
139 rows.append(("Ulcer Index", _safe(s.ulcer_index)))
140 rows.append(("Serenity Index", _safe(s.serenity_index)))
143def _add_trading_rows(rows: list[tuple[str, dict[str, Any]]], s: Any) -> None:
144 """Append trading metric rows to *rows*.
146 Args:
147 rows: Accumulator list of ``(label, values)`` tuples.
148 s: Stats object providing the metric methods.
150 """
151 rows.append(("Gain/Pain Ratio", _safe(s.gain_to_pain_ratio)))
152 rows.append(("Gain/Pain (1M)", _safe(s.gain_to_pain_ratio, aggregate="ME")))
153 rows.append(("Payoff Ratio", _safe(s.payoff_ratio)))
154 rows.append(("Profit Factor", _safe(s.profit_factor)))
155 rows.append(("Common Sense Ratio", _safe(s.common_sense_ratio)))
156 rows.append(("CPC Index", _safe(s.cpc_index)))
157 rows.append(("Tail Ratio", _safe(s.tail_ratio)))
158 rows.append(("Outlier Win Ratio", _safe(s.outlier_win_ratio)))
159 rows.append(("Outlier Loss Ratio", _safe(s.outlier_loss_ratio)))
162def _add_recent_returns_rows(
163 rows: list[tuple[str, dict[str, Any]]],
164 all_df: pl.DataFrame,
165 date_col: str,
166 asset_cols: list[str],
167 ppy: float,
168 s: Any,
169) -> None:
170 """Append date-filtered recent return rows to *rows*.
172 Args:
173 rows: Accumulator list of ``(label, values)`` tuples.
174 all_df: Combined DataFrame containing date and return columns.
175 date_col: Name of the date column in *all_df*.
176 asset_cols: Names of asset return columns in *all_df*.
177 ppy: Periods per year for annualisation.
178 s: Stats object used for the all-time CAGR.
180 """
181 today = cast(datetime.date, all_df[date_col].max())
182 mtd_start = today.replace(day=1)
183 ytd_start = today.replace(month=1, day=1)
185 rows.append(("MTD", _pct(_comp_since(all_df, date_col, asset_cols, mtd_start))))
186 rows.append(("3M", _pct(_comp_since(all_df, date_col, asset_cols, _cutoff_months(today, 3)))))
187 rows.append(("6M", _pct(_comp_since(all_df, date_col, asset_cols, _cutoff_months(today, 6)))))
188 rows.append(("YTD", _pct(_comp_since(all_df, date_col, asset_cols, ytd_start))))
189 rows.append(("1Y", _pct(_comp_since(all_df, date_col, asset_cols, _cutoff_months(today, 12)))))
190 rows.append(("3Y (ann.)", _pct(_cagr_since(all_df, date_col, asset_cols, _cutoff_months(today, 36), ppy))))
191 rows.append(("5Y (ann.)", _pct(_cagr_since(all_df, date_col, asset_cols, _cutoff_months(today, 60), ppy))))
192 rows.append(("All-time (ann.)", _pct(_safe(s.cagr, periods=ppy))))
195def _add_full_mode_rows(
196 rows: list[tuple[str, dict[str, Any]]],
197 s: Any,
198 ppy: float,
199 data: Any,
200 all_df: pl.DataFrame | None,
201 date_col: str | None,
202 asset_cols: list[str],
203) -> None:
204 """Append all full-mode extension rows to *rows*.
206 Covers smart ratios, extended risk, averages, expected returns, tail risk,
207 streaks, best/worst periods, and benchmark metrics.
209 Args:
210 rows: Accumulator list of ``(label, values)`` tuples.
211 s: Stats object providing the metric methods.
212 ppy: Periods per year for annualisation.
213 data: The DataLike object (used for benchmark access).
214 all_df: Combined DataFrame or ``None`` if unavailable.
215 date_col: Name of the date column or ``None`` if unavailable.
216 asset_cols: Asset column names.
218 """
219 # Smart ratios
220 rows.append(("Smart Sharpe", _safe(s.smart_sharpe, periods=ppy)))
221 ss = _safe(s.smart_sortino, periods=ppy)
222 rows.append(("Smart Sortino", ss))
223 rows.append(("Smart Sortino / √2", {k: v / math.sqrt(2) for k, v in ss.items() if _is_finite(v)}))
225 # Risk
226 rows.append(("Volatility (ann.)", _pct(_safe(s.volatility, periods=ppy))))
227 rows.append(("Calmar", _safe(s.calmar, periods=ppy)))
228 rows.append(("Risk-Adjusted Return", _pct(_safe(s.rar, periods=ppy))))
229 rows.append(("Risk-Return Ratio", _safe(s.risk_return_ratio)))
230 rows.append(("Ulcer Performance Index", _safe(s.ulcer_performance_index)))
231 rows.append(("Skew", _safe(s.skew)))
232 rows.append(("Kurtosis", _safe(s.kurtosis)))
234 # Averages
235 rows.append(("Avg. Return", _pct(_safe(s.avg_return))))
236 rows.append(("Avg. Win", _pct(_safe(s.avg_win))))
237 rows.append(("Avg. Loss", _pct(_safe(s.avg_loss))))
238 rows.append(("Win/Loss Ratio", _safe(s.payoff_ratio)))
239 rows.append(("Profit Ratio", _safe(s.profit_ratio)))
240 rows.append(("Win Rate", _pct(_safe(s.win_rate))))
241 rows.append(("Monthly Win Rate", _pct(_safe(s.monthly_win_rate))))
243 # Expected returns
244 rows.append(("Expected Daily", _pct(_safe(s.expected_return))))
245 rows.append(("Expected Monthly", _pct(_safe(s.expected_return, aggregate="monthly"))))
246 rows.append(("Expected Yearly", _pct(_safe(s.expected_return, aggregate="yearly"))))
248 # Tail risk
249 rows.append(("Kelly Criterion", _pct(_safe(s.kelly_criterion))))
250 rows.append(("Risk of Ruin", _pct(_safe(s.risk_of_ruin))))
251 rows.append(("Daily VaR", _pct(_safe(s.value_at_risk))))
252 rows.append(("Expected Shortfall (cVaR)", _pct(_safe(s.conditional_value_at_risk))))
254 # Streaks & best / worst
255 rows.append(("Max Consecutive Wins", _safe(s.consecutive_wins)))
256 rows.append(("Max Consecutive Losses", _safe(s.consecutive_losses)))
257 rows.append(("Best Day", _pct(_safe(s.best))))
258 rows.append(("Worst Day", _pct(_safe(s.worst))))
260 # Benchmark greeks (only if benchmark is present)
261 try:
262 greeks = s.greeks()
263 if greeks: # pragma: no branch — greeks() raises without a benchmark, so falsy is unreachable
264 beta = {k: v["beta"] for k, v in greeks.items()}
265 alpha = {k: v["alpha"] * 100.0 for k, v in greeks.items()}
266 rows.append(("Beta", beta))
267 rows.append(("Alpha", alpha))
268 except Exception: # noqa: S110
269 pass # nosec B110
271 try:
272 bench_obj = getattr(data, "benchmark", None)
273 if bench_obj is not None and all_df is not None and date_col is not None: # pragma: no branch
274 bench_col = bench_obj.columns[0]
275 corr_dict: dict[str, float] = {}
276 for ac in asset_cols:
277 if ac == bench_col:
278 continue
279 sub = all_df.select([date_col, ac, bench_col]).drop_nulls()
280 corr_val = float(sub.select(pl.corr(ac, bench_col))[0, 0])
281 corr_dict[ac] = corr_val * 100.0
282 rows.append(("Correlation", corr_dict))
283 except Exception: # noqa: S110
284 pass # nosec B110
286 rows.append(("R²", _safe(s.r_squared)))
287 rows.append(("Treynor Ratio", _safe(s.treynor_ratio, periods=ppy)))
290def _build_metrics_df(rows: list[tuple[str, dict[str, Any]]]) -> pl.DataFrame:
291 """Build a metrics `pl.DataFrame` from accumulated row data.
293 Args:
294 rows: List of ``(label, values)`` tuples where *values* maps asset
295 names to numeric results.
297 Returns:
298 A DataFrame with a leading ``"Metric"`` column and one column per
299 asset, preserving the insertion order of both metrics and assets.
301 """
302 all_assets: list[str] = []
303 seen: set[str] = set()
304 for _, vals in rows:
305 for k in vals:
306 if k not in seen:
307 all_assets.append(k)
308 seen.add(k)
309 return pl.DataFrame([{"Metric": label, **{a: vals.get(a) for a in all_assets}} for label, vals in rows])
312# ── Metrics-table HTML renderer ───────────────────────────────────────────────
314_SECTION_SPANS: list[tuple[str, list[str]]] = [
315 (
316 "Overview",
317 [
318 "Start Period",
319 "End Period",
320 "Time in Market",
321 "Cumulative Return",
322 "CAGR",
323 ],
324 ),
325 (
326 "Risk-Adjusted Ratios",
327 [
328 "Sharpe",
329 "Prob. Sharpe Ratio",
330 "Sortino",
331 "Sortino / √2",
332 "Omega",
333 ],
334 ),
335 (
336 "Drawdown",
337 [
338 "Max Drawdown",
339 "Max DD Duration",
340 "Avg Drawdown",
341 "Recovery Factor",
342 "Ulcer Index",
343 "Serenity Index",
344 ],
345 ),
346 (
347 "Trading",
348 [
349 "Gain/Pain Ratio",
350 "Gain/Pain (1M)",
351 "Payoff Ratio",
352 "Profit Factor",
353 "Common Sense Ratio",
354 "CPC Index",
355 "Tail Ratio",
356 "Outlier Win Ratio",
357 "Outlier Loss Ratio",
358 ],
359 ),
360 (
361 "Recent Returns",
362 [
363 "MTD",
364 "3M",
365 "6M",
366 "YTD",
367 "1Y",
368 "3Y (ann.)",
369 "5Y (ann.)",
370 "All-time (ann.)",
371 ],
372 ),
373 (
374 "Smart Ratios",
375 ["Smart Sharpe", "Smart Sortino", "Smart Sortino / √2"],
376 ),
377 (
378 "Risk",
379 [
380 "Volatility (ann.)",
381 "Calmar",
382 "Risk-Adjusted Return",
383 "Risk-Return Ratio",
384 "Ulcer Performance Index",
385 "Skew",
386 "Kurtosis",
387 ],
388 ),
389 (
390 "Averages",
391 [
392 "Avg. Return",
393 "Avg. Win",
394 "Avg. Loss",
395 "Win/Loss Ratio",
396 "Profit Ratio",
397 "Win Rate",
398 "Monthly Win Rate",
399 ],
400 ),
401 (
402 "Expected Returns",
403 ["Expected Daily", "Expected Monthly", "Expected Yearly"],
404 ),
405 (
406 "Tail Risk",
407 [
408 "Kelly Criterion",
409 "Risk of Ruin",
410 "Daily VaR",
411 "Expected Shortfall (cVaR)",
412 ],
413 ),
414 (
415 "Streaks",
416 ["Max Consecutive Wins", "Max Consecutive Losses"],
417 ),
418 (
419 "Best / Worst",
420 ["Best Day", "Worst Day"],
421 ),
422 (
423 "Benchmark",
424 ["Beta", "Alpha", "Correlation", "R²", "Treynor Ratio"],
425 ),
426]
428_PCT_METRICS: frozenset[str] = frozenset(
429 {
430 "Time in Market",
431 "Cumulative Return",
432 "CAGR",
433 "Prob. Sharpe Ratio",
434 "Max Drawdown",
435 "Avg Drawdown",
436 "MTD",
437 "3M",
438 "6M",
439 "YTD",
440 "1Y",
441 "3Y (ann.)",
442 "5Y (ann.)",
443 "All-time (ann.)",
444 "Volatility (ann.)",
445 "Risk-Adjusted Return",
446 "Avg. Return",
447 "Avg. Win",
448 "Avg. Loss",
449 "Win Rate",
450 "Monthly Win Rate",
451 "Expected Daily",
452 "Expected Monthly",
453 "Expected Yearly",
454 "Kelly Criterion",
455 "Risk of Ruin",
456 "Daily VaR",
457 "Expected Shortfall (cVaR)",
458 "Best Day",
459 "Worst Day",
460 "Alpha",
461 "Correlation",
462 }
463)
466def _metrics_table_html(df: pl.DataFrame) -> str:
467 """Render a metrics DataFrame as a styled HTML table with section headers.
469 Args:
470 df: DataFrame with a ``"Metric"`` column and one column per asset.
472 Returns:
473 An HTML ``<table>`` string.
475 """
476 assets = [c for c in df.columns if c != "Metric"]
477 rows_by_label: dict[str, dict[str, Any]] = {
478 str(row["Metric"]): {a: row.get(a) for a in assets} for row in df.iter_rows(named=True)
479 }
481 n_cols = len(assets) + 1
482 header_cells = "".join(f'<th class="asset-header">{a}</th>' for a in assets)
483 parts: list[str] = []
485 rendered: set[str] = set()
486 for section_label, section_metrics in _SECTION_SPANS:
487 section_rows: list[str] = []
488 for label in section_metrics:
489 if label not in rows_by_label:
490 continue
491 vals = rows_by_label[label]
492 rendered.add(label)
493 suffix = "%" if label in _PCT_METRICS else ""
494 cells = "".join(f'<td class="metric-value">{_fmt(vals.get(a), ".2f", suffix)}</td>' for a in assets)
495 section_rows.append(f'<tr><td class="metric-name">{label}</td>{cells}</tr>\n')
497 if section_rows:
498 parts.append(
499 f'<tr class="table-section-header"><td colspan="{n_cols}"><strong>{section_label}</strong></td></tr>\n'
500 )
501 parts.extend(section_rows)
503 # Anything not matched by a section (e.g. string-valued rows like dates)
504 for label, vals in rows_by_label.items():
505 if label in rendered:
506 continue
507 raw = next(iter(vals.values()), None)
508 if isinstance(raw, str):
509 cells = "".join(f'<td class="metric-value">{vals.get(a, "")}</td>' for a in assets)
510 else:
511 cells = "".join(f'<td class="metric-value">{_fmt(vals.get(a), ".4f")}</td>' for a in assets)
512 parts.append(f'<tr><td class="metric-name">{label}</td>{cells}</tr>\n')
514 return _table_html(header_cells, "".join(parts))
517def _drawdowns_section_html(data: Any, assets: list[str]) -> str:
518 """Render worst-5 drawdown periods per asset as HTML tables.
520 Args:
521 data: The DataLike object (accessed via ``getattr`` for stats).
522 assets: List of asset column names to render.
524 Returns:
525 HTML string containing one table per asset.
527 """
528 stats = getattr(data, "stats", None)
529 if stats is None:
530 return "<p>No drawdown data available.</p>"
532 parts: list[str] = []
533 try:
534 dd_dict: dict[str, pl.DataFrame] = stats.drawdown_details()
535 except Exception:
536 return "<p>Drawdown details unavailable.</p>"
538 for asset in assets:
539 df = dd_dict.get(asset)
540 if df is None or len(df) == 0:
541 parts.append(f"<h3>{asset}</h3><p>No drawdown periods found.</p>")
542 continue
544 worst5 = df.sort("max_drawdown").head(5)
545 rows = "".join(
546 f"<tr>"
547 f"<td>{row.get('start', '')}</td>"
548 f"<td>{row.get('valley', '')}</td>"
549 f"<td>{row.get('end', '') or '—'}</td>"
550 f"<td>{_fmt(row.get('max_drawdown'), '.2%')}</td>"
551 f"<td>{row.get('duration', '') or '—'}</td>"
552 f"</tr>"
553 for row in worst5.iter_rows(named=True)
554 )
555 parts.append(
556 f"<h3>{asset}</h3>"
557 '<table class="stats-table">'
558 "<thead><tr>"
559 "<th>Start</th><th>Valley</th><th>End</th><th>Max DD</th><th>Duration</th>"
560 "</tr></thead>"
561 f"<tbody>{rows}</tbody></table>"
562 )
564 return "\n".join(parts)
567def _try_plotly_div(fig: Any, include_cdn: bool = False) -> str:
568 """Convert a Plotly figure to an HTML div string.
570 Args:
571 fig: A Plotly Figure object (or anything with ``to_html``).
572 include_cdn: Include the Plotly JS CDN ``<script>`` tag. Defaults to False.
574 Returns:
575 An HTML string, or an empty string if conversion fails.
577 """
578 try:
579 return _plotly_div(fig, include_plotlyjs="cdn" if include_cdn else False)
580 except Exception:
581 return ""
584_REPORT_CSS = """
585body{margin:0;font-family:system-ui,sans-serif;background:#0f1117;color:#e2e8f0}
586h1{color:#90cdf4;margin:0 0 4px}
587h2{color:#63b3ed;border-bottom:1px solid #2d3748;padding-bottom:6px}
588h3{color:#a0aec0;margin:16px 0 6px}
589header{padding:24px 32px;background:linear-gradient(135deg,#1a202c,#2d3748);border-bottom:1px solid #4a5568}
590.period-info{color:#a0aec0;font-size:.85rem;margin-top:4px}
591main{padding:24px 32px}
592section{margin-bottom:40px}
593.stats-table{border-collapse:collapse;width:100%;font-size:.85rem}
594.stats-table th,.stats-table td{padding:6px 12px;text-align:right;border-bottom:1px solid #2d3748}
595.stats-table th:first-child,.stats-table td:first-child{text-align:left}
596.metric-header,.asset-header{background:#1a202c;color:#90cdf4;font-weight:600}
597.metric-name{color:#cbd5e0}
598.metric-value{font-family:monospace;color:#e2e8f0}
599.table-section-header td{background:#1a202c;color:#68d391;font-size:.75rem;text-transform:uppercase;
600letter-spacing:.08em;padding:8px 12px}
601footer{padding:16px 32px;color:#718096;font-size:.75rem;border-top:1px solid #2d3748}
602"""
605def _build_full_html(
606 title: str,
607 period_info: str,
608 assets_str: str,
609 metrics_html: str,
610 drawdowns_html: str,
611 charts_html: str,
612) -> str:
613 """Assemble the full HTML report from its component parts.
615 Args:
616 title: Page and ``<h1>`` title.
617 period_info: Period metadata string for the header.
618 assets_str: Comma-separated asset names for the header.
619 metrics_html: Pre-rendered metrics ``<table>`` HTML.
620 drawdowns_html: Pre-rendered worst-drawdowns HTML.
621 charts_html: Pre-rendered Plotly chart divs.
623 Returns:
624 A complete, self-contained HTML document string.
626 """
627 from datetime import date
629 footer_date = str(date.today())
630 return f"""<!DOCTYPE html>
631<html lang="en">
632<head>
633<meta charset="utf-8">
634<meta name="viewport" content="width=device-width,initial-scale=1">
635<title>{title}</title>
636<style>{_REPORT_CSS}</style>
637</head>
638<body>
639<header>
640 <h1>{title}</h1>
641 <div class="period-info">{period_info}</div>
642 <div class="period-info">Assets: {assets_str}</div>
643</header>
644<main>
645 <section id="metrics">
646 <h2>Performance Metrics</h2>
647 {metrics_html}
648 </section>
649 <section id="drawdowns">
650 <h2>Worst 5 Drawdown Periods</h2>
651 {drawdowns_html}
652 </section>
653 <section id="charts">
654 <h2>Charts</h2>
655 {charts_html}
656 </section>
657</main>
658<footer>Generated by jquantstats · {footer_date}</footer>
659</body>
660</html>"""
663# ── Reports dataclass ─────────────────────────────────────────────────────────
666class Reports:
667 """A class for generating financial reports from Data objects.
669 This class provides methods for calculating and formatting various financial metrics
670 into report-ready formats such as DataFrames.
671 """
673 __slots__ = ("_data",)
675 def __init__(self, data: DataLike) -> None:
676 self._data = data
678 def metrics(
679 self,
680 mode: str = "basic",
681 periods_per_year: int | float = 252,
682 rf: float = 0.0,
683 ) -> pl.DataFrame:
684 """Comprehensive performance metrics table matching ``qs.reports.metrics``.
686 Computes an ordered set of performance, risk, and trading metrics for
687 every asset in the dataset and returns them as a tidy DataFrame.
689 Args:
690 mode: ``"basic"`` (default) for core metrics, ``"full"`` for the
691 extended set including smart ratios, expected returns, streaks,
692 best/worst periods, win rates, and benchmark greeks.
693 periods_per_year: Annualisation factor. Defaults to 252.
694 rf: Annualised risk-free rate used in ratio calculations.
695 Defaults to 0.0.
697 Returns:
698 pl.DataFrame: One row per metric, one column per asset, plus a
699 leading ``"Metric"`` column with the metric label.
701 """
702 s = self._data.stats
703 ppy = float(periods_per_year)
704 is_full = mode.lower() == "full"
706 rows: list[tuple[str, dict[str, Any]]] = []
708 all_df: pl.DataFrame | None = getattr(self._data, "all", None)
709 asset_cols: list[str] = []
710 date_col: str | None = None
711 has_dates = False
713 if all_df is not None: # pragma: no branch — Data always exposes .all; getattr default is defensive
714 date_col = all_df.columns[0]
715 asset_cols = [c for c in all_df.columns if c != date_col]
716 has_dates = all_df[date_col].dtype.is_temporal()
718 _add_overview_rows(rows, s, ppy)
719 _add_risk_adjusted_rows(rows, s, ppy)
720 _add_drawdown_rows(rows, s)
721 _add_trading_rows(rows, s)
723 if has_dates and date_col is not None and all_df is not None: # pragma: no branch
724 _add_recent_returns_rows(rows, all_df, date_col, asset_cols, ppy, s)
726 if is_full:
727 _add_full_mode_rows(rows, s, ppy, self._data, all_df, date_col, asset_cols)
729 return _build_metrics_df(rows)
731 def full(
732 self,
733 title: str = "Performance Report",
734 periods_per_year: int | float = 252,
735 rf: float = 0.0,
736 ) -> str:
737 """Generate a self-contained HTML performance report.
739 Combines a comprehensive metrics table (full mode), worst-5 drawdown
740 periods per asset, and interactive Plotly charts into a single
741 dark-themed HTML document.
743 Args:
744 title: Page ``<h1>`` title. Defaults to ``"Performance Report"``.
745 periods_per_year: Annualisation factor passed to
746 `metrics`. Defaults to 252.
747 rf: Annualised risk-free rate. Defaults to 0.0.
749 Returns:
750 str: A complete, self-contained HTML document.
752 """
753 # ── Metrics ───────────────────────────────────────────────────────────
754 metrics_df = self.metrics(mode="full", periods_per_year=periods_per_year, rf=rf)
755 assets = [c for c in metrics_df.columns if c != "Metric"]
756 metrics_html = _metrics_table_html(metrics_df)
758 # ── Period info for header ────────────────────────────────────────────
759 all_df: pl.DataFrame | None = getattr(self._data, "all", None)
760 period_info = ""
761 temporal_index = False
762 if all_df is not None: # pragma: no branch — Data always exposes .all; getattr default is defensive
763 date_col = all_df.columns[0]
764 temporal_index = all_df[date_col].dtype.is_temporal()
765 if temporal_index:
766 start_dt = all_df[date_col].min()
767 end_dt = all_df[date_col].max()
768 n = len(all_df)
769 period_info = f"{start_dt!s} → {end_dt!s} | {n:,} observations"
771 # ── Drawdowns ─────────────────────────────────────────────────────────
772 drawdowns_html = _drawdowns_section_html(self._data, assets)
774 # ── Charts ────────────────────────────────────────────────────────────
775 plots = getattr(self._data, "plots", None)
776 chart_parts: list[str] = []
777 if plots is not None: # pragma: no branch — Data always exposes .plots; getattr default is defensive
778 _chart_methods: list[tuple[str, dict[str, Any]]] = [
779 ("snapshot", {}),
780 ("returns", {}),
781 ("drawdown", {}),
782 ("rolling_sharpe", {}),
783 ("rolling_volatility", {}),
784 ("monthly_heatmap", {}),
785 ("yearly_returns", {}),
786 ("histogram", {}),
787 ]
788 # These charts aggregate by calendar period (resample, dt.year/
789 # dt.month) and cannot be computed for an integer index.
790 _calendar_charts = {"snapshot", "monthly_heatmap", "yearly_returns"}
791 if not temporal_index:
792 skipped = ", ".join(m for m, _ in _chart_methods if m in _calendar_charts)
793 warnings.warn(
794 f"Index is not temporal; skipping calendar-based charts: {skipped}.",
795 stacklevel=2,
796 )
797 for method, kwargs in _chart_methods:
798 if not temporal_index and method in _calendar_charts:
799 continue
800 fn = getattr(plots, method, None)
801 if fn is None:
802 continue
803 div = _try_plotly_div(fn(**kwargs), include_cdn=not chart_parts)
804 if div: # pragma: no branch — _try_plotly_div only returns falsy on render failure
805 chart_parts.append(f'<div style="margin-bottom:24px">{div}</div>')
807 charts_html = "\n".join(chart_parts) if chart_parts else "<p>No charts available.</p>"
809 return _build_full_html(
810 title=title,
811 period_info=period_info,
812 assets_str=", ".join(assets),
813 metrics_html=metrics_html,
814 drawdowns_html=drawdowns_html,
815 charts_html=charts_html,
816 )