Coverage for src/jquantstats/_reports/_portfolio.py: 100%
97 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"""HTML report generation for portfolio analytics.
3This module defines the Report facade which produces a self-contained HTML
4document containing all relevant performance numbers and interactive Plotly
5visualisations for a Portfolio.
6"""
8from __future__ import annotations
10from collections.abc import Callable
11from pathlib import Path
12from typing import TYPE_CHECKING, Any
14import polars as pl
16if TYPE_CHECKING:
17 import plotly.graph_objects as go
19 from ._protocol import PortfolioLike
21from .._plots._backend import plot_backend
22from ._formatting import _env, _fmt, _is_finite, _plotly_div, _table_html
24# ── Stats table ───────────────────────────────────────────────────────────────
26_METRIC_FORMATS: dict[str, tuple[str, str]] = {
27 "avg_return": (".2%", ""),
28 "avg_win": (".2%", ""),
29 "avg_loss": (".2%", ""),
30 "best": (".2%", ""),
31 "worst": (".2%", ""),
32 "sharpe": (".2f", ""),
33 "calmar": (".2f", ""),
34 "recovery_factor": (".2f", ""),
35 "max_drawdown": (".2%", ""),
36 "avg_drawdown": (".2%", ""),
37 "max_drawdown_duration": (".0f", " days"),
38 "win_rate": (".1%", ""),
39 "monthly_win_rate": (".1%", ""),
40 "profit_factor": (".2f", ""),
41 "payoff_ratio": (".2f", ""),
42 "volatility": (".2%", ""),
43 "skew": (".2f", ""),
44 "kurtosis": (".2f", ""),
45 "value_at_risk": (".2%", ""),
46 "conditional_value_at_risk": (".2%", ""),
47}
49_METRIC_LABELS: dict[str, str] = {
50 "avg_return": "Avg Return",
51 "avg_win": "Avg Win",
52 "avg_loss": "Avg Loss",
53 "best": "Best Period",
54 "worst": "Worst Period",
55 "sharpe": "Sharpe Ratio",
56 "calmar": "Calmar Ratio",
57 "recovery_factor": "Recovery Factor",
58 "max_drawdown": "Max Drawdown",
59 "avg_drawdown": "Avg Drawdown",
60 "max_drawdown_duration": "Max DD Duration",
61 "win_rate": "Win Rate",
62 "monthly_win_rate": "Monthly Win Rate",
63 "profit_factor": "Profit Factor",
64 "payoff_ratio": "Payoff Ratio",
65 "volatility": "Volatility (ann.)",
66 "skew": "Skewness",
67 "kurtosis": "Kurtosis",
68 "value_at_risk": "VaR (95 %)",
69 "conditional_value_at_risk": "CVaR (95 %)",
70}
72# Metrics where the *highest* value across assets is highlighted.
73_HIGHER_IS_BETTER: frozenset[str] = frozenset(
74 {"sharpe", "calmar", "recovery_factor", "win_rate", "monthly_win_rate", "profit_factor", "payoff_ratio"}
75)
77_CATEGORIES: list[tuple[str, list[str]]] = [
78 ("Returns", ["avg_return", "avg_win", "avg_loss", "best", "worst"]),
79 ("Risk-Adjusted Performance", ["sharpe", "calmar", "recovery_factor"]),
80 ("Drawdown", ["max_drawdown", "avg_drawdown", "max_drawdown_duration"]),
81 ("Win / Loss", ["win_rate", "monthly_win_rate", "profit_factor", "payoff_ratio"]),
82 ("Distribution & Risk", ["volatility", "skew", "kurtosis", "value_at_risk", "conditional_value_at_risk"]),
83]
86def _stats_table_html(summary: pl.DataFrame) -> str:
87 """Render a stats summary DataFrame as a styled HTML table.
89 Args:
90 summary: Output of `Stats.summary` — one row per metric,
91 one column per asset plus a ``metric`` column.
93 Returns:
94 An HTML ``<table>`` string ready to embed in a page.
95 """
96 assets = [c for c in summary.columns if c != "metric"]
98 # Build a fast lookup: metric_name → {asset: value}
99 metric_data: dict[str, dict[str, Any]] = {}
100 for row in summary.iter_rows(named=True):
101 name = str(row["metric"])
102 metric_data[name] = {a: row.get(a) for a in assets}
104 header_cells = "".join(f'<th class="asset-header">{a}</th>' for a in assets)
105 rows_html_parts: list[str] = []
107 for category_label, metrics in _CATEGORIES:
108 rows_html_parts.append(
109 f'<tr class="table-section-header">'
110 f'<td colspan="{len(assets) + 1}"><strong>{category_label}</strong></td>'
111 f"</tr>\n"
112 )
113 for metric in metrics:
114 if metric not in metric_data:
115 continue
116 rows_html_parts.append(_stats_metric_row_html(metric, metric_data[metric], assets))
118 rows_html = "".join(rows_html_parts)
119 return _table_html(header_cells, rows_html)
122def _best_asset(metric: str, values: dict[str, Any]) -> str | None:
123 """Return the asset with the highest finite value, for higher-is-better metrics.
125 Args:
126 metric: The metric name being rendered.
127 values: Mapping of asset name → value for this metric.
129 Returns:
130 The best asset name to highlight, or ``None`` when the metric is not
131 higher-is-better or has no finite values.
133 """
134 if metric not in _HIGHER_IS_BETTER:
135 return None
136 finite_pairs = [(a, float(v)) for a, v in values.items() if _is_finite(v)]
137 if not finite_pairs:
138 return None
139 return max(finite_pairs, key=lambda x: x[1])[0]
142def _stats_metric_row_html(metric: str, values: dict[str, Any], assets: list[str]) -> str:
143 """Render a single metric row, highlighting the best asset where applicable.
145 Args:
146 metric: The metric name (drives label, format, and highlight rule).
147 values: Mapping of asset name → value for this metric.
148 assets: Asset column names, in output order.
150 Returns:
151 An HTML ``<tr>`` string for the metric.
153 """
154 fmt, suffix = _METRIC_FORMATS.get(metric, (".4f", ""))
155 label = _METRIC_LABELS.get(metric, metric.replace("_", " ").title())
156 best_asset = _best_asset(metric, values)
157 cells = "".join(
158 f'<td class="metric-value{" best-value" if a == best_asset else ""}">{_fmt(values.get(a), fmt, suffix)}</td>'
159 for a in assets
160 )
161 return f'<tr><td class="metric-name">{label}</td>{cells}</tr>\n'
164# ── Report dataclass ──────────────────────────────────────────────────────────
167def _figure_div(fig: go.Figure, include_plotlyjs: bool | str) -> str:
168 """Return an HTML div string for *fig*.
170 Args:
171 fig: Plotly figure to serialise.
172 include_plotlyjs: Passed directly to `plotly.io.to_html`.
173 Pass ``"cdn"`` for the first figure so the CDN script tag is
174 injected; pass ``False`` for all subsequent figures.
176 Returns:
177 HTML string (not a full page).
178 """
179 return _plotly_div(fig, include_plotlyjs=include_plotlyjs)
182class Report:
183 """Facade for generating HTML reports from a Portfolio.
185 Provides a `to_html` method that assembles a self-contained,
186 dark-themed HTML document with a performance-statistics table and
187 multiple interactive Plotly charts.
189 Usage::
191 report = portfolio.report
192 html_str = report.to_html()
193 report.to_html(path="output/report.html")
194 """
196 __slots__ = ("_portfolio",)
198 def __init__(self, portfolio: PortfolioLike) -> None:
199 self._portfolio = portfolio
201 def to_html(
202 self,
203 title: str = "JQuantStats Portfolio Report",
204 path: str | Path | None = None,
205 ) -> str | Path:
206 """Render a full HTML report as a string or save it to a file.
208 The document is self-contained: Plotly.js is loaded once from the
209 CDN and all charts are embedded as ``<div>`` elements. No external
210 CSS framework is required.
212 Args:
213 title: HTML ``<title>`` text and visible page heading.
214 path: When given, write the report to this path and return the
215 resolved `pathlib.Path`. A ``.html`` suffix is appended
216 automatically when *path* has no file extension. When
217 ``None`` (default) the HTML string is returned directly.
219 Returns:
220 The HTML string when *path* is ``None``, otherwise the resolved
221 `pathlib.Path` of the written file.
222 """
223 pf = self._portfolio
225 # ── Metadata ──────────────────────────────────────────────────────────
226 has_date = "date" in pf.prices.columns
227 if has_date:
228 dates = pf.prices["date"]
229 start_date = str(dates.min())
230 end_date = str(dates.max())
231 n_periods = pf.prices.height
232 period_info = f"{start_date} → {end_date} | {n_periods:,} periods"
233 else:
234 start_date = ""
235 end_date = ""
236 period_info = f"{pf.prices.height:,} periods"
238 assets_list = ", ".join(pf.assets)
240 # ── Figures ───────────────────────────────────────────────────────────
241 # The first chart includes Plotly.js from CDN; subsequent ones reuse it.
242 _first = True
244 def _div(fig: go.Figure) -> str:
245 """Serialise *fig* to an HTML div, embedding Plotly.js only on the first call."""
246 nonlocal _first
247 include = "cdn" if _first else False
248 _first = False
249 return _figure_div(fig, include)
251 def _try_div(build_fig: Callable[[], go.Figure]) -> str:
252 """Call *build_fig()* and return the chart div; on error return a notice."""
253 try:
254 fig = build_fig()
255 return _div(fig)
256 except Exception as exc:
257 return f'<p class="chart-unavailable">Chart unavailable: {exc}</p>'
259 # The report embeds Plotly JSON and links plotly.js, so its charts must be
260 # Plotly figures no matter what `set_plot_backend` has been told. Without
261 # this, a matplotlib default would leave every chart as a "Chart
262 # unavailable" notice: `_try_div` swallows the resulting failure.
263 with plot_backend("plotly"):
264 snapshot_div = _try_div(pf.plots.snapshot)
265 rolling_sharpe_div = _try_div(pf.plots.rolling_sharpe_plot)
266 rolling_vol_div = _try_div(pf.plots.rolling_volatility_plot)
267 annual_sharpe_div = _try_div(pf.plots.annual_sharpe_plot)
268 monthly_heatmap_div = _try_div(pf.plots.monthly_returns_heatmap)
269 corr_div = _try_div(pf.plots.correlation_heatmap)
270 lead_lag_div = _try_div(pf.plots.lead_lag_ir_plot)
271 trading_cost_div = _try_div(pf.plots.trading_cost_impact_plot)
273 # ── Stats table ───────────────────────────────────────────────────────
274 stats_table = _stats_table_html(pf.stats.summary())
276 # ── Turnover table ────────────────────────────────────────────────────
277 try:
278 turnover_df = pf.turnover_summary()
279 turnover_rows = "".join(
280 f'<tr><td class="metric-name">{row["metric"].replace("_", " ").title()}</td>'
281 f'<td class="metric-value">{row["value"]:.4f}</td></tr>'
282 for row in turnover_df.iter_rows(named=True)
283 )
284 turnover_html = (
285 '<table class="stats-table">'
286 "<thead><tr>"
287 '<th class="metric-header">Metric</th>'
288 '<th class="asset-header">Value</th>'
289 "</tr></thead>"
290 f"<tbody>{turnover_rows}</tbody>"
291 "</table>"
292 )
293 except Exception as exc:
294 turnover_html = f'<p class="chart-unavailable">Turnover data unavailable: {exc}</p>'
296 # ── Assemble HTML ─────────────────────────────────────────────────────
297 footer_date = end_date if has_date else ""
298 template = _env.get_template("portfolio_report.html")
299 html = template.render(
300 title=title,
301 period_info=period_info,
302 assets_list=assets_list,
303 aum=f"{pf.aum:,.0f}",
304 footer_date=footer_date,
305 snapshot_div=snapshot_div,
306 rolling_sharpe_div=rolling_sharpe_div,
307 rolling_vol_div=rolling_vol_div,
308 annual_sharpe_div=annual_sharpe_div,
309 monthly_heatmap_div=monthly_heatmap_div,
310 corr_div=corr_div,
311 lead_lag_div=lead_lag_div,
312 trading_cost_div=trading_cost_div,
313 stats_table=stats_table,
314 turnover_html=turnover_html,
315 container_max_width="1400px",
316 )
318 if path is None:
319 return html
321 p = Path(path)
322 if not p.suffix:
323 p = p.with_suffix(".html")
324 p.parent.mkdir(parents=True, exist_ok=True)
325 p.write_text(html, encoding="utf-8")
326 return p