Coverage for src/jquantstats/_reports/_data.py: 100%
76 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-06 04:52 +0000
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-06 04:52 +0000
1"""Financial report generation from returns data."""
3from __future__ import annotations
5import warnings
6from typing import TYPE_CHECKING, Any
8import polars as pl
10if TYPE_CHECKING:
11 from jquantstats._protocol import DataLike
13from ._html import (
14 _build_full_html,
15 _drawdowns_section_html,
16 _metrics_table_html,
17 _try_plotly_div,
18)
19from ._metrics import (
20 _add_drawdown_rows,
21 _add_full_mode_rows,
22 _add_overview_rows,
23 _add_recent_returns_rows,
24 _add_risk_adjusted_rows,
25 _add_trading_rows,
26 _build_metrics_df,
27)
30class Reports:
31 """A class for generating financial reports from Data objects.
33 This class provides methods for calculating and formatting various financial metrics
34 into report-ready formats such as DataFrames.
35 """
37 __slots__ = ("_data",)
39 def __init__(self, data: DataLike) -> None:
40 self._data = data
42 def metrics(
43 self,
44 mode: str = "basic",
45 periods_per_year: int | float = 252,
46 rf: float = 0.0,
47 ) -> pl.DataFrame:
48 """Comprehensive performance metrics table matching ``qs.reports.metrics``.
50 Computes an ordered set of performance, risk, and trading metrics for
51 every asset in the dataset and returns them as a tidy DataFrame.
53 Args:
54 mode: ``"basic"`` (default) for core metrics, ``"full"`` for the
55 extended set including smart ratios, expected returns, streaks,
56 best/worst periods, win rates, and benchmark greeks.
57 periods_per_year: Annualisation factor. Defaults to 252.
58 rf: Annualised risk-free rate used in ratio calculations.
59 Defaults to 0.0.
61 Returns:
62 pl.DataFrame: One row per metric, one column per asset, plus a
63 leading ``"Metric"`` column with the metric label.
65 """
66 s = self._data.stats
67 ppy = float(periods_per_year)
68 is_full = mode.lower() == "full"
70 rows: list[tuple[str, dict[str, Any]]] = []
72 all_df: pl.DataFrame | None = getattr(self._data, "all", None)
73 asset_cols: list[str] = []
74 date_col: str | None = None
75 has_dates = False
77 if all_df is not None: # pragma: no branch — Data always exposes .all; getattr default is defensive
78 date_col = all_df.columns[0]
79 asset_cols = [c for c in all_df.columns if c != date_col]
80 has_dates = all_df[date_col].dtype.is_temporal()
82 _add_overview_rows(rows, s, ppy)
83 _add_risk_adjusted_rows(rows, s, ppy)
84 _add_drawdown_rows(rows, s)
85 _add_trading_rows(rows, s)
87 if has_dates and date_col is not None and all_df is not None: # pragma: no branch
88 _add_recent_returns_rows(rows, all_df, date_col, asset_cols, ppy, s)
90 if is_full:
91 _add_full_mode_rows(rows, s, ppy, self._data, all_df, date_col, asset_cols)
93 return _build_metrics_df(rows)
95 def full(
96 self,
97 title: str = "Performance Report",
98 periods_per_year: int | float = 252,
99 rf: float = 0.0,
100 ) -> str:
101 """Generate a self-contained HTML performance report.
103 Combines a comprehensive metrics table (full mode), worst-5 drawdown
104 periods per asset, and interactive Plotly charts into a single
105 dark-themed HTML document.
107 Args:
108 title: Page ``<h1>`` title. Defaults to ``"Performance Report"``.
109 periods_per_year: Annualisation factor passed to
110 `metrics`. Defaults to 252.
111 rf: Annualised risk-free rate. Defaults to 0.0.
113 Returns:
114 str: A complete, self-contained HTML document.
116 """
117 # ── Metrics ───────────────────────────────────────────────────────────
118 metrics_df = self.metrics(mode="full", periods_per_year=periods_per_year, rf=rf)
119 assets = [c for c in metrics_df.columns if c != "Metric"]
120 metrics_html = _metrics_table_html(metrics_df)
122 # ── Period info for header ────────────────────────────────────────────
123 all_df: pl.DataFrame | None = getattr(self._data, "all", None)
124 period_info, temporal_index = _report_period_info(all_df)
126 # ── Drawdowns ─────────────────────────────────────────────────────────
127 drawdowns_html = _drawdowns_section_html(self._data, assets)
129 # ── Charts ────────────────────────────────────────────────────────────
130 plots = getattr(self._data, "plots", None)
131 charts_html = _report_charts_html(plots, temporal_index)
133 return _build_full_html(
134 title=title,
135 period_info=period_info,
136 assets_str=", ".join(assets),
137 metrics_html=metrics_html,
138 drawdowns_html=drawdowns_html,
139 charts_html=charts_html,
140 )
143def _report_period_info(all_df: pl.DataFrame | None) -> tuple[str, bool]:
144 """Derive the header period string and whether the index is temporal.
146 Args:
147 all_df: The combined ``Data.all`` frame, or ``None`` if unavailable.
149 Returns:
150 A ``(period_info, temporal_index)`` tuple. ``period_info`` is empty
151 for a non-temporal (integer) index.
153 """
154 period_info = ""
155 temporal_index = False
156 if all_df is not None: # pragma: no branch — Data always exposes .all; getattr default is defensive
157 date_col = all_df.columns[0]
158 temporal_index = all_df[date_col].dtype.is_temporal()
159 if temporal_index:
160 start_dt = all_df[date_col].min()
161 end_dt = all_df[date_col].max()
162 n = len(all_df)
163 period_info = f"{start_dt!s} → {end_dt!s} | {n:,} observations"
164 return period_info, temporal_index
167def _report_charts_html(plots: Any, temporal_index: bool) -> str:
168 """Render every available report chart as embedded Plotly ``<div>`` HTML.
170 Calendar-based charts (which resample by ``dt.year``/``dt.month``) are
171 skipped with a warning when the index is not temporal.
173 Args:
174 plots: The ``Data.plots`` facade, or ``None`` if unavailable.
175 temporal_index: Whether the underlying index is date/datetime typed.
177 Returns:
178 Concatenated chart HTML, or a placeholder when none could be rendered.
180 """
181 chart_parts: list[str] = []
182 if plots is not None: # pragma: no branch — Data always exposes .plots; getattr default is defensive
183 _chart_methods: list[tuple[str, dict[str, Any]]] = [
184 ("snapshot", {}),
185 ("returns", {}),
186 ("drawdown", {}),
187 ("rolling_sharpe", {}),
188 ("rolling_volatility", {}),
189 ("monthly_heatmap", {}),
190 ("yearly_returns", {}),
191 ("histogram", {}),
192 ]
193 # These charts aggregate by calendar period (resample, dt.year/
194 # dt.month) and cannot be computed for an integer index.
195 _calendar_charts = {"snapshot", "monthly_heatmap", "yearly_returns"}
196 if not temporal_index:
197 skipped = ", ".join(m for m, _ in _chart_methods if m in _calendar_charts)
198 warnings.warn(
199 f"Index is not temporal; skipping calendar-based charts: {skipped}.",
200 stacklevel=2,
201 )
202 chart_parts = _collect_chart_divs(plots, _chart_methods, _calendar_charts, temporal_index)
204 return "\n".join(chart_parts) if chart_parts else "<p>No charts available.</p>"
207def _collect_chart_divs(
208 plots: Any,
209 chart_methods: list[tuple[str, dict[str, Any]]],
210 calendar_charts: set[str],
211 temporal_index: bool,
212) -> list[str]:
213 """Render each requested chart to an embedded ``<div>``, skipping the impossible.
215 Args:
216 plots: The ``Data.plots`` facade.
217 chart_methods: Ordered ``(method_name, kwargs)`` pairs to attempt.
218 calendar_charts: Method names that require a temporal index.
219 temporal_index: Whether the underlying index is date/datetime typed.
221 Returns:
222 The successfully rendered chart ``<div>`` strings, in request order.
224 """
225 chart_parts: list[str] = []
226 for method, kwargs in chart_methods:
227 if not temporal_index and method in calendar_charts:
228 continue
229 fn = getattr(plots, method, None)
230 if fn is None:
231 continue
232 div = _try_plotly_div(fn(**kwargs), include_cdn=not chart_parts)
233 if div: # pragma: no branch — _try_plotly_div only returns falsy on render failure
234 chart_parts.append(f'<div style="margin-bottom:24px">{div}</div>')
235 return chart_parts