Coverage for src/jquantstats/_reports/_portfolio.py: 100%
95 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-23 06:13 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-23 06:13 +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 plotly.graph_objects as go
15import polars as pl
16from jinja2 import Environment, FileSystemLoader, select_autoescape
18if TYPE_CHECKING:
19 from ._protocol import PortfolioLike
21from ._formatting import _fmt, _is_finite, _plotly_div, _table_html
23# templates/ lives one level above this subpackage (at src/jquantstats/templates/)
24_TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
25_env = Environment(
26 loader=FileSystemLoader(_TEMPLATES_DIR),
27 autoescape=select_autoescape(["html"]),
28)
30# ── Stats table ───────────────────────────────────────────────────────────────
32_METRIC_FORMATS: dict[str, tuple[str, str]] = {
33 "avg_return": (".2%", ""),
34 "avg_win": (".2%", ""),
35 "avg_loss": (".2%", ""),
36 "best": (".2%", ""),
37 "worst": (".2%", ""),
38 "sharpe": (".2f", ""),
39 "calmar": (".2f", ""),
40 "recovery_factor": (".2f", ""),
41 "max_drawdown": (".2%", ""),
42 "avg_drawdown": (".2%", ""),
43 "max_drawdown_duration": (".0f", " days"),
44 "win_rate": (".1%", ""),
45 "monthly_win_rate": (".1%", ""),
46 "profit_factor": (".2f", ""),
47 "payoff_ratio": (".2f", ""),
48 "volatility": (".2%", ""),
49 "skew": (".2f", ""),
50 "kurtosis": (".2f", ""),
51 "value_at_risk": (".2%", ""),
52 "conditional_value_at_risk": (".2%", ""),
53}
55_METRIC_LABELS: dict[str, str] = {
56 "avg_return": "Avg Return",
57 "avg_win": "Avg Win",
58 "avg_loss": "Avg Loss",
59 "best": "Best Period",
60 "worst": "Worst Period",
61 "sharpe": "Sharpe Ratio",
62 "calmar": "Calmar Ratio",
63 "recovery_factor": "Recovery Factor",
64 "max_drawdown": "Max Drawdown",
65 "avg_drawdown": "Avg Drawdown",
66 "max_drawdown_duration": "Max DD Duration",
67 "win_rate": "Win Rate",
68 "monthly_win_rate": "Monthly Win Rate",
69 "profit_factor": "Profit Factor",
70 "payoff_ratio": "Payoff Ratio",
71 "volatility": "Volatility (ann.)",
72 "skew": "Skewness",
73 "kurtosis": "Kurtosis",
74 "value_at_risk": "VaR (95 %)",
75 "conditional_value_at_risk": "CVaR (95 %)",
76}
78# Metrics where the *highest* value across assets is highlighted.
79_HIGHER_IS_BETTER: frozenset[str] = frozenset(
80 {"sharpe", "calmar", "recovery_factor", "win_rate", "monthly_win_rate", "profit_factor", "payoff_ratio"}
81)
83_CATEGORIES: list[tuple[str, list[str]]] = [
84 ("Returns", ["avg_return", "avg_win", "avg_loss", "best", "worst"]),
85 ("Risk-Adjusted Performance", ["sharpe", "calmar", "recovery_factor"]),
86 ("Drawdown", ["max_drawdown", "avg_drawdown", "max_drawdown_duration"]),
87 ("Win / Loss", ["win_rate", "monthly_win_rate", "profit_factor", "payoff_ratio"]),
88 ("Distribution & Risk", ["volatility", "skew", "kurtosis", "value_at_risk", "conditional_value_at_risk"]),
89]
92def _stats_table_html(summary: pl.DataFrame) -> str:
93 """Render a stats summary DataFrame as a styled HTML table.
95 Args:
96 summary: Output of `Stats.summary` — one row per metric,
97 one column per asset plus a ``metric`` column.
99 Returns:
100 An HTML ``<table>`` string ready to embed in a page.
101 """
102 assets = [c for c in summary.columns if c != "metric"]
104 # Build a fast lookup: metric_name → {asset: value}
105 metric_data: dict[str, dict[str, Any]] = {}
106 for row in summary.iter_rows(named=True):
107 name = str(row["metric"])
108 metric_data[name] = {a: row.get(a) for a in assets}
110 header_cells = "".join(f'<th class="asset-header">{a}</th>' for a in assets)
111 rows_html_parts: list[str] = []
113 for category_label, metrics in _CATEGORIES:
114 rows_html_parts.append(
115 f'<tr class="table-section-header">'
116 f'<td colspan="{len(assets) + 1}"><strong>{category_label}</strong></td>'
117 f"</tr>\n"
118 )
119 for metric in metrics:
120 if metric not in metric_data:
121 continue
122 fmt, suffix = _METRIC_FORMATS.get(metric, (".4f", ""))
123 label = _METRIC_LABELS.get(metric, metric.replace("_", " ").title())
124 values = metric_data[metric]
126 # Find the best asset to highlight (only for higher-is-better metrics)
127 best_asset: str | None = None
128 if metric in _HIGHER_IS_BETTER:
129 finite_pairs = [(a, float(v)) for a, v in values.items() if _is_finite(v)]
130 if finite_pairs:
131 best_asset = max(finite_pairs, key=lambda x: x[1])[0]
133 cells = "".join(
134 f'<td class="metric-value{" best-value" if a == best_asset else ""}">'
135 f"{_fmt(values.get(a), fmt, suffix)}</td>"
136 for a in assets
137 )
138 rows_html_parts.append(f'<tr><td class="metric-name">{label}</td>{cells}</tr>\n')
140 rows_html = "".join(rows_html_parts)
141 return _table_html(header_cells, rows_html)
144# ── Report dataclass ──────────────────────────────────────────────────────────
147def _figure_div(fig: go.Figure, include_plotlyjs: bool | str) -> str:
148 """Return an HTML div string for *fig*.
150 Args:
151 fig: Plotly figure to serialise.
152 include_plotlyjs: Passed directly to `plotly.io.to_html`.
153 Pass ``"cdn"`` for the first figure so the CDN script tag is
154 injected; pass ``False`` for all subsequent figures.
156 Returns:
157 HTML string (not a full page).
158 """
159 return _plotly_div(fig, include_plotlyjs=include_plotlyjs)
162class Report:
163 """Facade for generating HTML reports from a Portfolio.
165 Provides a `to_html` method that assembles a self-contained,
166 dark-themed HTML document with a performance-statistics table and
167 multiple interactive Plotly charts.
169 Usage::
171 report = portfolio.report
172 html_str = report.to_html()
173 report.to_html(path="output/report.html")
174 """
176 __slots__ = ("_portfolio",)
178 def __init__(self, portfolio: PortfolioLike) -> None:
179 self._portfolio = portfolio
181 def to_html(
182 self,
183 title: str = "JQuantStats Portfolio Report",
184 path: str | Path | None = None,
185 ) -> str | Path:
186 """Render a full HTML report as a string or save it to a file.
188 The document is self-contained: Plotly.js is loaded once from the
189 CDN and all charts are embedded as ``<div>`` elements. No external
190 CSS framework is required.
192 Args:
193 title: HTML ``<title>`` text and visible page heading.
194 path: When given, write the report to this path and return the
195 resolved `pathlib.Path`. A ``.html`` suffix is appended
196 automatically when *path* has no file extension. When
197 ``None`` (default) the HTML string is returned directly.
199 Returns:
200 The HTML string when *path* is ``None``, otherwise the resolved
201 `pathlib.Path` of the written file.
202 """
203 pf = self._portfolio
205 # ── Metadata ──────────────────────────────────────────────────────────
206 has_date = "date" in pf.prices.columns
207 if has_date:
208 dates = pf.prices["date"]
209 start_date = str(dates.min())
210 end_date = str(dates.max())
211 n_periods = pf.prices.height
212 period_info = f"{start_date} → {end_date} | {n_periods:,} periods"
213 else:
214 start_date = ""
215 end_date = ""
216 period_info = f"{pf.prices.height:,} periods"
218 assets_list = ", ".join(pf.assets)
220 # ── Figures ───────────────────────────────────────────────────────────
221 # The first chart includes Plotly.js from CDN; subsequent ones reuse it.
222 _first = True
224 def _div(fig: go.Figure) -> str:
225 """Serialise *fig* to an HTML div, embedding Plotly.js only on the first call."""
226 nonlocal _first
227 include = "cdn" if _first else False
228 _first = False
229 return _figure_div(fig, include)
231 def _try_div(build_fig: Callable[[], go.Figure]) -> str:
232 """Call *build_fig()* and return the chart div; on error return a notice."""
233 try:
234 fig = build_fig()
235 return _div(fig)
236 except Exception as exc:
237 return f'<p class="chart-unavailable">Chart unavailable: {exc}</p>'
239 snapshot_div = _try_div(pf.plots.snapshot)
240 rolling_sharpe_div = _try_div(pf.plots.rolling_sharpe_plot)
241 rolling_vol_div = _try_div(pf.plots.rolling_volatility_plot)
242 annual_sharpe_div = _try_div(pf.plots.annual_sharpe_plot)
243 monthly_heatmap_div = _try_div(pf.plots.monthly_returns_heatmap)
244 corr_div = _try_div(pf.plots.correlation_heatmap)
245 lead_lag_div = _try_div(pf.plots.lead_lag_ir_plot)
246 trading_cost_div = _try_div(pf.plots.trading_cost_impact_plot)
248 # ── Stats table ───────────────────────────────────────────────────────
249 stats_table = _stats_table_html(pf.stats.summary())
251 # ── Turnover table ────────────────────────────────────────────────────
252 try:
253 turnover_df = pf.turnover_summary()
254 turnover_rows = "".join(
255 f'<tr><td class="metric-name">{row["metric"].replace("_", " ").title()}</td>'
256 f'<td class="metric-value">{row["value"]:.4f}</td></tr>'
257 for row in turnover_df.iter_rows(named=True)
258 )
259 turnover_html = (
260 '<table class="stats-table">'
261 "<thead><tr>"
262 '<th class="metric-header">Metric</th>'
263 '<th class="asset-header">Value</th>'
264 "</tr></thead>"
265 f"<tbody>{turnover_rows}</tbody>"
266 "</table>"
267 )
268 except Exception as exc:
269 turnover_html = f'<p class="chart-unavailable">Turnover data unavailable: {exc}</p>'
271 # ── Assemble HTML ─────────────────────────────────────────────────────
272 footer_date = end_date if has_date else ""
273 template = _env.get_template("portfolio_report.html")
274 html = template.render(
275 title=title,
276 period_info=period_info,
277 assets_list=assets_list,
278 aum=f"{pf.aum:,.0f}",
279 footer_date=footer_date,
280 snapshot_div=snapshot_div,
281 rolling_sharpe_div=rolling_sharpe_div,
282 rolling_vol_div=rolling_vol_div,
283 annual_sharpe_div=annual_sharpe_div,
284 monthly_heatmap_div=monthly_heatmap_div,
285 corr_div=corr_div,
286 lead_lag_div=lead_lag_div,
287 trading_cost_div=trading_cost_div,
288 stats_table=stats_table,
289 turnover_html=turnover_html,
290 container_max_width="1400px",
291 )
293 if path is None:
294 return html
296 p = Path(path)
297 if not p.suffix:
298 p = p.with_suffix(".html")
299 p.parent.mkdir(parents=True, exist_ok=True)
300 p.write_text(html, encoding="utf-8")
301 return p