Coverage for src / jquantstats / _reports / _portfolio.py: 100%
103 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-07 14:36 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-07 14:36 +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.
7Examples:
8 >>> import dataclasses
9 >>> from jquantstats._reports import Report
10 >>> dataclasses.is_dataclass(Report)
11 True
12"""
14from __future__ import annotations
16import dataclasses
17import math
18from collections.abc import Callable
19from pathlib import Path
20from typing import TYPE_CHECKING, Any, TypeGuard
22import plotly.graph_objects as go
23import plotly.io as pio
24import polars as pl
25from jinja2 import Environment, FileSystemLoader, select_autoescape
27if TYPE_CHECKING:
28 from ._protocol import PortfolioLike
30# templates/ lives one level above this subpackage (at src/jquantstats/templates/)
31_TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
32_env = Environment(
33 loader=FileSystemLoader(_TEMPLATES_DIR),
34 autoescape=select_autoescape(["html"]),
35)
38# ── Formatting helpers ────────────────────────────────────────────────────────
41def _is_finite(v: Any) -> TypeGuard[int | float]:
42 """Return True when *v* is a real, finite number."""
43 if not isinstance(v, (int, float)):
44 return False
45 return math.isfinite(float(v))
48def _fmt(value: Any, fmt: str = ".4f", suffix: str = "") -> str:
49 """Format *value* for display in an HTML table cell.
51 Returns ``"N/A"`` for ``None``, ``NaN``, or non-finite values.
52 """
53 if not _is_finite(value):
54 return "N/A"
55 return f"{float(value):{fmt}}{suffix}"
58# ── Stats table ───────────────────────────────────────────────────────────────
60_METRIC_FORMATS: dict[str, tuple[str, str]] = {
61 "avg_return": (".2%", ""),
62 "avg_win": (".2%", ""),
63 "avg_loss": (".2%", ""),
64 "best": (".2%", ""),
65 "worst": (".2%", ""),
66 "sharpe": (".2f", ""),
67 "calmar": (".2f", ""),
68 "recovery_factor": (".2f", ""),
69 "max_drawdown": (".2%", ""),
70 "avg_drawdown": (".2%", ""),
71 "max_drawdown_duration": (".0f", " days"),
72 "win_rate": (".1%", ""),
73 "monthly_win_rate": (".1%", ""),
74 "profit_factor": (".2f", ""),
75 "payoff_ratio": (".2f", ""),
76 "volatility": (".2%", ""),
77 "skew": (".2f", ""),
78 "kurtosis": (".2f", ""),
79 "value_at_risk": (".2%", ""),
80 "conditional_value_at_risk": (".2%", ""),
81}
83_METRIC_LABELS: dict[str, str] = {
84 "avg_return": "Avg Return",
85 "avg_win": "Avg Win",
86 "avg_loss": "Avg Loss",
87 "best": "Best Period",
88 "worst": "Worst Period",
89 "sharpe": "Sharpe Ratio",
90 "calmar": "Calmar Ratio",
91 "recovery_factor": "Recovery Factor",
92 "max_drawdown": "Max Drawdown",
93 "avg_drawdown": "Avg Drawdown",
94 "max_drawdown_duration": "Max DD Duration",
95 "win_rate": "Win Rate",
96 "monthly_win_rate": "Monthly Win Rate",
97 "profit_factor": "Profit Factor",
98 "payoff_ratio": "Payoff Ratio",
99 "volatility": "Volatility (ann.)",
100 "skew": "Skewness",
101 "kurtosis": "Kurtosis",
102 "value_at_risk": "VaR (95 %)",
103 "conditional_value_at_risk": "CVaR (95 %)",
104}
106# Metrics where the *highest* value across assets is highlighted.
107_HIGHER_IS_BETTER: frozenset[str] = frozenset(
108 {"sharpe", "calmar", "recovery_factor", "win_rate", "monthly_win_rate", "profit_factor", "payoff_ratio"}
109)
111_CATEGORIES: list[tuple[str, list[str]]] = [
112 ("Returns", ["avg_return", "avg_win", "avg_loss", "best", "worst"]),
113 ("Risk-Adjusted Performance", ["sharpe", "calmar", "recovery_factor"]),
114 ("Drawdown", ["max_drawdown", "avg_drawdown", "max_drawdown_duration"]),
115 ("Win / Loss", ["win_rate", "monthly_win_rate", "profit_factor", "payoff_ratio"]),
116 ("Distribution & Risk", ["volatility", "skew", "kurtosis", "value_at_risk", "conditional_value_at_risk"]),
117]
120def _stats_table_html(summary: pl.DataFrame) -> str:
121 """Render a stats summary DataFrame as a styled HTML table.
123 Args:
124 summary: Output of :py:meth:`Stats.summary` — one row per metric,
125 one column per asset plus a ``metric`` column.
127 Returns:
128 An HTML ``<table>`` string ready to embed in a page.
129 """
130 assets = [c for c in summary.columns if c != "metric"]
132 # Build a fast lookup: metric_name → {asset: value}
133 metric_data: dict[str, dict[str, Any]] = {}
134 for row in summary.iter_rows(named=True):
135 name = str(row["metric"])
136 metric_data[name] = {a: row.get(a) for a in assets}
138 header_cells = "".join(f'<th class="asset-header">{a}</th>' for a in assets)
139 rows_html_parts: list[str] = []
141 for category_label, metrics in _CATEGORIES:
142 rows_html_parts.append(
143 f'<tr class="table-section-header">'
144 f'<td colspan="{len(assets) + 1}"><strong>{category_label}</strong></td>'
145 f"</tr>\n"
146 )
147 for metric in metrics:
148 if metric not in metric_data:
149 continue
150 fmt, suffix = _METRIC_FORMATS.get(metric, (".4f", ""))
151 label = _METRIC_LABELS.get(metric, metric.replace("_", " ").title())
152 values = metric_data[metric]
154 # Find the best asset to highlight (only for higher-is-better metrics)
155 best_asset: str | None = None
156 if metric in _HIGHER_IS_BETTER:
157 finite_pairs = [(a, float(v)) for a, v in values.items() if _is_finite(v)]
158 if finite_pairs:
159 best_asset = max(finite_pairs, key=lambda x: x[1])[0]
161 cells = "".join(
162 f'<td class="metric-value{" best-value" if a == best_asset else ""}">'
163 f"{_fmt(values.get(a), fmt, suffix)}</td>"
164 for a in assets
165 )
166 rows_html_parts.append(f'<tr><td class="metric-name">{label}</td>{cells}</tr>\n')
168 rows_html = "".join(rows_html_parts)
169 return (
170 '<table class="stats-table">'
171 "<thead><tr>"
172 f'<th class="metric-header">Metric</th>{header_cells}'
173 "</tr></thead>"
174 f"<tbody>{rows_html}</tbody>"
175 "</table>"
176 )
179# ── Report dataclass ──────────────────────────────────────────────────────────
182def _figure_div(fig: go.Figure, include_plotlyjs: bool | str) -> str:
183 """Return an HTML div string for *fig*.
185 Args:
186 fig: Plotly figure to serialise.
187 include_plotlyjs: Passed directly to :func:`plotly.io.to_html`.
188 Pass ``"cdn"`` for the first figure so the CDN script tag is
189 injected; pass ``False`` for all subsequent figures.
191 Returns:
192 HTML string (not a full page).
193 """
194 return pio.to_html(
195 fig,
196 full_html=False,
197 include_plotlyjs=include_plotlyjs,
198 )
201@dataclasses.dataclass(frozen=True)
202class Report:
203 """Facade for generating HTML reports from a Portfolio.
205 Provides a :py:meth:`to_html` method that assembles a self-contained,
206 dark-themed HTML document with a performance-statistics table and
207 multiple interactive Plotly charts.
209 Usage::
211 report = portfolio.report
212 html_str = report.to_html()
213 report.save("output/report.html")
214 """
216 portfolio: PortfolioLike
218 def to_html(self, title: str = "JQuantStats Portfolio Report") -> str:
219 """Render a full HTML report as a string.
221 The document is self-contained: Plotly.js is loaded once from the
222 CDN and all charts are embedded as ``<div>`` elements. No external
223 CSS framework is required.
225 Args:
226 title: HTML ``<title>`` text and visible page heading.
228 Returns:
229 A complete HTML document as a :class:`str`.
230 """
231 pf = self.portfolio
233 # ── Metadata ──────────────────────────────────────────────────────────
234 has_date = "date" in pf.prices.columns
235 if has_date:
236 dates = pf.prices["date"]
237 start_date = str(dates.min())
238 end_date = str(dates.max())
239 n_periods = pf.prices.height
240 period_info = f"{start_date} → {end_date} | {n_periods:,} periods"
241 else:
242 start_date = ""
243 end_date = ""
244 period_info = f"{pf.prices.height:,} periods"
246 assets_list = ", ".join(pf.assets)
248 # ── Figures ───────────────────────────────────────────────────────────
249 # The first chart includes Plotly.js from CDN; subsequent ones reuse it.
250 _first = True
252 def _div(fig: go.Figure) -> str:
253 """Serialise *fig* to an HTML div, embedding Plotly.js only on the first call."""
254 nonlocal _first
255 include = "cdn" if _first else False
256 _first = False
257 return _figure_div(fig, include)
259 def _try_div(build_fig: Callable[[], go.Figure]) -> str:
260 """Call *build_fig()* and return the chart div; on error return a notice."""
261 try:
262 fig = build_fig()
263 return _div(fig)
264 except Exception as exc:
265 return f'<p class="chart-unavailable">Chart unavailable: {exc}</p>'
267 snapshot_div = _try_div(pf.plots.snapshot)
268 rolling_sharpe_div = _try_div(pf.plots.rolling_sharpe_plot)
269 rolling_vol_div = _try_div(pf.plots.rolling_volatility_plot)
270 annual_sharpe_div = _try_div(pf.plots.annual_sharpe_plot)
271 monthly_heatmap_div = _try_div(pf.plots.monthly_returns_heatmap)
272 corr_div = _try_div(pf.plots.correlation_heatmap)
273 lead_lag_div = _try_div(pf.plots.lead_lag_ir_plot)
274 trading_cost_div = _try_div(pf.plots.trading_cost_impact_plot)
276 # ── Stats table ───────────────────────────────────────────────────────
277 stats_table = _stats_table_html(pf.stats.summary())
279 # ── Turnover table ────────────────────────────────────────────────────
280 try:
281 turnover_df = pf.turnover_summary()
282 turnover_rows = "".join(
283 f'<tr><td class="metric-name">{row["metric"].replace("_", " ").title()}</td>'
284 f'<td class="metric-value">{row["value"]:.4f}</td></tr>'
285 for row in turnover_df.iter_rows(named=True)
286 )
287 turnover_html = (
288 '<table class="stats-table">'
289 "<thead><tr>"
290 '<th class="metric-header">Metric</th>'
291 '<th class="asset-header">Value</th>'
292 "</tr></thead>"
293 f"<tbody>{turnover_rows}</tbody>"
294 "</table>"
295 )
296 except Exception as exc:
297 turnover_html = f'<p class="chart-unavailable">Turnover data unavailable: {exc}</p>'
299 # ── Assemble HTML ─────────────────────────────────────────────────────
300 footer_date = end_date if has_date else ""
301 template = _env.get_template("portfolio_report.html")
302 return template.render(
303 title=title,
304 period_info=period_info,
305 assets_list=assets_list,
306 aum=f"{pf.aum:,.0f}",
307 footer_date=footer_date,
308 snapshot_div=snapshot_div,
309 rolling_sharpe_div=rolling_sharpe_div,
310 rolling_vol_div=rolling_vol_div,
311 annual_sharpe_div=annual_sharpe_div,
312 monthly_heatmap_div=monthly_heatmap_div,
313 corr_div=corr_div,
314 lead_lag_div=lead_lag_div,
315 trading_cost_div=trading_cost_div,
316 stats_table=stats_table,
317 turnover_html=turnover_html,
318 container_max_width="1400px",
319 )
321 def save(self, path: str | Path, title: str = "JQuantStats Portfolio Report") -> Path:
322 """Save the HTML report to a file.
324 A ``.html`` suffix is appended automatically when *path* has no
325 file extension.
327 Args:
328 path: Destination file path.
329 title: HTML ``<title>`` text and visible page heading.
331 Returns:
332 The resolved :class:`pathlib.Path` of the written file.
333 """
334 p = Path(path)
335 if not p.suffix:
336 p = p.with_suffix(".html")
337 p.parent.mkdir(parents=True, exist_ok=True)
338 p.write_text(self.to_html(title=title), encoding="utf-8")
339 return p