Coverage for src/jquantstats/_reports/_data.py: 100%
78 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-23 04:11 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-23 04:11 +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 .._plots._backend import plot_backend
14from ._html import (
15 _build_full_html,
16 _drawdowns_section_html,
17 _metrics_table_html,
18 _try_plotly_div,
19)
20from ._metrics import (
21 _add_drawdown_rows,
22 _add_full_mode_rows,
23 _add_overview_rows,
24 _add_recent_returns_rows,
25 _add_risk_adjusted_rows,
26 _add_trading_rows,
27 _build_metrics_df,
28)
31class Reports:
32 """A class for generating financial reports from Data objects.
34 This class provides methods for calculating and formatting various financial metrics
35 into report-ready formats such as DataFrames.
36 """
38 __slots__ = ("_data",)
40 def __init__(self, data: DataLike) -> None:
41 self._data = data
43 def metrics(
44 self,
45 mode: str = "basic",
46 periods_per_year: int | float = 252,
47 rf: float = 0.0,
48 ) -> pl.DataFrame:
49 """Comprehensive performance metrics table matching ``qs.reports.metrics``.
51 Computes an ordered set of performance, risk, and trading metrics for
52 every asset in the dataset and returns them as a tidy DataFrame.
54 Args:
55 mode: ``"basic"`` (default) for core metrics, ``"full"`` for the
56 extended set including smart ratios, expected returns, streaks,
57 best/worst periods, win rates, and benchmark greeks.
58 periods_per_year: Annualisation factor. Defaults to 252.
59 rf: Annualised risk-free rate used in ratio calculations.
60 Defaults to 0.0.
62 Returns:
63 pl.DataFrame: One row per metric, one column per asset, plus a
64 leading ``"Metric"`` column with the metric label.
66 """
67 s = self._data.stats
68 ppy = float(periods_per_year)
69 is_full = mode.lower() == "full"
71 rows: list[tuple[str, dict[str, Any]]] = []
73 all_df: pl.DataFrame | None = getattr(self._data, "all", None)
74 asset_cols: list[str] = []
75 date_col: str | None = None
76 has_dates = False
78 if all_df is not None: # pragma: no branch — Data always exposes .all; getattr default is defensive
79 date_col = all_df.columns[0]
80 asset_cols = [c for c in all_df.columns if c != date_col]
81 has_dates = all_df[date_col].dtype.is_temporal()
83 _add_overview_rows(rows, s, ppy)
84 _add_risk_adjusted_rows(rows, s, ppy)
85 _add_drawdown_rows(rows, s)
86 _add_trading_rows(rows, s)
88 if has_dates and date_col is not None and all_df is not None: # pragma: no branch
89 _add_recent_returns_rows(rows, all_df, date_col, asset_cols, ppy, s)
91 if is_full:
92 _add_full_mode_rows(rows, s, ppy, self._data, all_df, date_col, asset_cols)
94 return _build_metrics_df(rows)
96 def full(
97 self,
98 title: str = "Performance Report",
99 periods_per_year: int | float = 252,
100 rf: float = 0.0,
101 ) -> str:
102 """Generate a self-contained HTML performance report.
104 Combines a comprehensive metrics table (full mode), worst-5 drawdown
105 periods per asset, and interactive Plotly charts into a single
106 dark-themed HTML document.
108 Args:
109 title: Page ``<h1>`` title. Defaults to ``"Performance Report"``.
110 periods_per_year: Annualisation factor passed to
111 `metrics`. Defaults to 252.
112 rf: Annualised risk-free rate. Defaults to 0.0.
114 Returns:
115 str: A complete, self-contained HTML document.
117 """
118 # ── Metrics ───────────────────────────────────────────────────────────
119 metrics_df = self.metrics(mode="full", periods_per_year=periods_per_year, rf=rf)
120 assets = [c for c in metrics_df.columns if c != "Metric"]
121 metrics_html = _metrics_table_html(metrics_df)
123 # ── Period info for header ────────────────────────────────────────────
124 all_df: pl.DataFrame | None = getattr(self._data, "all", None)
125 period_info, temporal_index = _report_period_info(all_df)
127 # ── Drawdowns ─────────────────────────────────────────────────────────
128 drawdowns_html = _drawdowns_section_html(self._data, assets)
130 # ── Charts ────────────────────────────────────────────────────────────
131 plots = getattr(self._data, "plots", None)
132 charts_html = _report_charts_html(plots, temporal_index)
134 return _build_full_html(
135 title=title,
136 period_info=period_info,
137 assets_str=", ".join(assets),
138 metrics_html=metrics_html,
139 drawdowns_html=drawdowns_html,
140 charts_html=charts_html,
141 )
144def _report_period_info(all_df: pl.DataFrame | None) -> tuple[str, bool]:
145 """Derive the header period string and whether the index is temporal.
147 Args:
148 all_df: The combined ``Data.all`` frame, or ``None`` if unavailable.
150 Returns:
151 A ``(period_info, temporal_index)`` tuple. ``period_info`` is empty
152 for a non-temporal (integer) index.
154 """
155 period_info = ""
156 temporal_index = False
157 if all_df is not None: # pragma: no branch — Data always exposes .all; getattr default is defensive
158 date_col = all_df.columns[0]
159 temporal_index = all_df[date_col].dtype.is_temporal()
160 if temporal_index:
161 start_dt = all_df[date_col].min()
162 end_dt = all_df[date_col].max()
163 n = len(all_df)
164 period_info = f"{start_dt!s} → {end_dt!s} | {n:,} observations"
165 return period_info, temporal_index
168def _report_charts_html(plots: Any, temporal_index: bool) -> str:
169 """Render every available report chart as embedded Plotly ``<div>`` HTML.
171 Calendar-based charts (which resample by ``dt.year``/``dt.month``) are
172 skipped with a warning when the index is not temporal.
174 Args:
175 plots: The ``Data.plots`` facade, or ``None`` if unavailable.
176 temporal_index: Whether the underlying index is date/datetime typed.
178 Returns:
179 Concatenated chart HTML, or a placeholder when none could be rendered.
181 """
182 chart_parts: list[str] = []
183 if plots is not None: # pragma: no branch — Data always exposes .plots; getattr default is defensive
184 _chart_methods: list[tuple[str, dict[str, Any]]] = [
185 ("snapshot", {}),
186 ("returns", {}),
187 ("drawdown", {}),
188 ("rolling_sharpe", {}),
189 ("rolling_volatility", {}),
190 ("monthly_heatmap", {}),
191 ("yearly_returns", {}),
192 ("histogram", {}),
193 ]
194 # These charts aggregate by calendar period (resample, dt.year/
195 # dt.month) and cannot be computed for an integer index.
196 _calendar_charts = {"snapshot", "monthly_heatmap", "yearly_returns"}
197 if not temporal_index:
198 skipped = ", ".join(m for m, _ in _chart_methods if m in _calendar_charts)
199 warnings.warn(
200 f"Index is not temporal; skipping calendar-based charts: {skipped}.",
201 stacklevel=2,
202 )
203 # The report embeds Plotly JSON and links plotly.js, so its charts must be
204 # Plotly figures no matter what `set_plot_backend` has been told. Without
205 # this, a matplotlib default would leave every chart silently blank:
206 # `_try_plotly_div` swallows the resulting failure and returns "".
207 with plot_backend("plotly"):
208 chart_parts = _collect_chart_divs(plots, _chart_methods, _calendar_charts, temporal_index)
210 return "\n".join(chart_parts) if chart_parts else "<p>No charts available.</p>"
213def _collect_chart_divs(
214 plots: Any,
215 chart_methods: list[tuple[str, dict[str, Any]]],
216 calendar_charts: set[str],
217 temporal_index: bool,
218) -> list[str]:
219 """Render each requested chart to an embedded ``<div>``, skipping the impossible.
221 Args:
222 plots: The ``Data.plots`` facade.
223 chart_methods: Ordered ``(method_name, kwargs)`` pairs to attempt.
224 calendar_charts: Method names that require a temporal index.
225 temporal_index: Whether the underlying index is date/datetime typed.
227 Returns:
228 The successfully rendered chart ``<div>`` strings, in request order.
230 """
231 chart_parts: list[str] = []
232 for method, kwargs in chart_methods:
233 if not temporal_index and method in calendar_charts:
234 continue
235 fn = getattr(plots, method, None)
236 if fn is None:
237 continue
238 div = _try_plotly_div(fn(**kwargs), include_cdn=not chart_parts)
239 if div: # pragma: no branch — _try_plotly_div only returns falsy on render failure
240 chart_parts.append(f'<div style="margin-bottom:24px">{div}</div>')
241 return chart_parts