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