Coverage for src/jquantstats/_reports/_portfolio.py: 100%

100 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-23 04:11 +0000

1"""HTML report generation for portfolio analytics. 

2 

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""" 

7 

8from __future__ import annotations 

9 

10from collections.abc import Callable 

11from pathlib import Path 

12from typing import TYPE_CHECKING, Any 

13 

14import polars as pl 

15from jinja2 import Environment, FileSystemLoader, select_autoescape 

16 

17if TYPE_CHECKING: 

18 import plotly.graph_objects as go 

19 

20 from ._protocol import PortfolioLike 

21 

22from .._plots._backend import plot_backend 

23from ._formatting import _fmt, _is_finite, _plotly_div, _table_html 

24 

25# templates/ lives one level above this subpackage (at src/jquantstats/templates/) 

26_TEMPLATES_DIR = Path(__file__).parent.parent / "templates" 

27_env = Environment( 

28 loader=FileSystemLoader(_TEMPLATES_DIR), 

29 autoescape=select_autoescape(["html"]), 

30) 

31 

32# ── Stats table ─────────────────────────────────────────────────────────────── 

33 

34_METRIC_FORMATS: dict[str, tuple[str, str]] = { 

35 "avg_return": (".2%", ""), 

36 "avg_win": (".2%", ""), 

37 "avg_loss": (".2%", ""), 

38 "best": (".2%", ""), 

39 "worst": (".2%", ""), 

40 "sharpe": (".2f", ""), 

41 "calmar": (".2f", ""), 

42 "recovery_factor": (".2f", ""), 

43 "max_drawdown": (".2%", ""), 

44 "avg_drawdown": (".2%", ""), 

45 "max_drawdown_duration": (".0f", " days"), 

46 "win_rate": (".1%", ""), 

47 "monthly_win_rate": (".1%", ""), 

48 "profit_factor": (".2f", ""), 

49 "payoff_ratio": (".2f", ""), 

50 "volatility": (".2%", ""), 

51 "skew": (".2f", ""), 

52 "kurtosis": (".2f", ""), 

53 "value_at_risk": (".2%", ""), 

54 "conditional_value_at_risk": (".2%", ""), 

55} 

56 

57_METRIC_LABELS: dict[str, str] = { 

58 "avg_return": "Avg Return", 

59 "avg_win": "Avg Win", 

60 "avg_loss": "Avg Loss", 

61 "best": "Best Period", 

62 "worst": "Worst Period", 

63 "sharpe": "Sharpe Ratio", 

64 "calmar": "Calmar Ratio", 

65 "recovery_factor": "Recovery Factor", 

66 "max_drawdown": "Max Drawdown", 

67 "avg_drawdown": "Avg Drawdown", 

68 "max_drawdown_duration": "Max DD Duration", 

69 "win_rate": "Win Rate", 

70 "monthly_win_rate": "Monthly Win Rate", 

71 "profit_factor": "Profit Factor", 

72 "payoff_ratio": "Payoff Ratio", 

73 "volatility": "Volatility (ann.)", 

74 "skew": "Skewness", 

75 "kurtosis": "Kurtosis", 

76 "value_at_risk": "VaR (95 %)", 

77 "conditional_value_at_risk": "CVaR (95 %)", 

78} 

79 

80# Metrics where the *highest* value across assets is highlighted. 

81_HIGHER_IS_BETTER: frozenset[str] = frozenset( 

82 {"sharpe", "calmar", "recovery_factor", "win_rate", "monthly_win_rate", "profit_factor", "payoff_ratio"} 

83) 

84 

85_CATEGORIES: list[tuple[str, list[str]]] = [ 

86 ("Returns", ["avg_return", "avg_win", "avg_loss", "best", "worst"]), 

87 ("Risk-Adjusted Performance", ["sharpe", "calmar", "recovery_factor"]), 

88 ("Drawdown", ["max_drawdown", "avg_drawdown", "max_drawdown_duration"]), 

89 ("Win / Loss", ["win_rate", "monthly_win_rate", "profit_factor", "payoff_ratio"]), 

90 ("Distribution & Risk", ["volatility", "skew", "kurtosis", "value_at_risk", "conditional_value_at_risk"]), 

91] 

92 

93 

94def _stats_table_html(summary: pl.DataFrame) -> str: 

95 """Render a stats summary DataFrame as a styled HTML table. 

96 

97 Args: 

98 summary: Output of `Stats.summary` — one row per metric, 

99 one column per asset plus a ``metric`` column. 

100 

101 Returns: 

102 An HTML ``<table>`` string ready to embed in a page. 

103 """ 

104 assets = [c for c in summary.columns if c != "metric"] 

105 

106 # Build a fast lookup: metric_name → {asset: value} 

107 metric_data: dict[str, dict[str, Any]] = {} 

108 for row in summary.iter_rows(named=True): 

109 name = str(row["metric"]) 

110 metric_data[name] = {a: row.get(a) for a in assets} 

111 

112 header_cells = "".join(f'<th class="asset-header">{a}</th>' for a in assets) 

113 rows_html_parts: list[str] = [] 

114 

115 for category_label, metrics in _CATEGORIES: 

116 rows_html_parts.append( 

117 f'<tr class="table-section-header">' 

118 f'<td colspan="{len(assets) + 1}"><strong>{category_label}</strong></td>' 

119 f"</tr>\n" 

120 ) 

121 for metric in metrics: 

122 if metric not in metric_data: 

123 continue 

124 rows_html_parts.append(_stats_metric_row_html(metric, metric_data[metric], assets)) 

125 

126 rows_html = "".join(rows_html_parts) 

127 return _table_html(header_cells, rows_html) 

128 

129 

130def _best_asset(metric: str, values: dict[str, Any]) -> str | None: 

131 """Return the asset with the highest finite value, for higher-is-better metrics. 

132 

133 Args: 

134 metric: The metric name being rendered. 

135 values: Mapping of asset name → value for this metric. 

136 

137 Returns: 

138 The best asset name to highlight, or ``None`` when the metric is not 

139 higher-is-better or has no finite values. 

140 

141 """ 

142 if metric not in _HIGHER_IS_BETTER: 

143 return None 

144 finite_pairs = [(a, float(v)) for a, v in values.items() if _is_finite(v)] 

145 if not finite_pairs: 

146 return None 

147 return max(finite_pairs, key=lambda x: x[1])[0] 

148 

149 

150def _stats_metric_row_html(metric: str, values: dict[str, Any], assets: list[str]) -> str: 

151 """Render a single metric row, highlighting the best asset where applicable. 

152 

153 Args: 

154 metric: The metric name (drives label, format, and highlight rule). 

155 values: Mapping of asset name → value for this metric. 

156 assets: Asset column names, in output order. 

157 

158 Returns: 

159 An HTML ``<tr>`` string for the metric. 

160 

161 """ 

162 fmt, suffix = _METRIC_FORMATS.get(metric, (".4f", "")) 

163 label = _METRIC_LABELS.get(metric, metric.replace("_", " ").title()) 

164 best_asset = _best_asset(metric, values) 

165 cells = "".join( 

166 f'<td class="metric-value{" best-value" if a == best_asset else ""}">{_fmt(values.get(a), fmt, suffix)}</td>' 

167 for a in assets 

168 ) 

169 return f'<tr><td class="metric-name">{label}</td>{cells}</tr>\n' 

170 

171 

172# ── Report dataclass ────────────────────────────────────────────────────────── 

173 

174 

175def _figure_div(fig: go.Figure, include_plotlyjs: bool | str) -> str: 

176 """Return an HTML div string for *fig*. 

177 

178 Args: 

179 fig: Plotly figure to serialise. 

180 include_plotlyjs: Passed directly to `plotly.io.to_html`. 

181 Pass ``"cdn"`` for the first figure so the CDN script tag is 

182 injected; pass ``False`` for all subsequent figures. 

183 

184 Returns: 

185 HTML string (not a full page). 

186 """ 

187 return _plotly_div(fig, include_plotlyjs=include_plotlyjs) 

188 

189 

190class Report: 

191 """Facade for generating HTML reports from a Portfolio. 

192 

193 Provides a `to_html` method that assembles a self-contained, 

194 dark-themed HTML document with a performance-statistics table and 

195 multiple interactive Plotly charts. 

196 

197 Usage:: 

198 

199 report = portfolio.report 

200 html_str = report.to_html() 

201 report.to_html(path="output/report.html") 

202 """ 

203 

204 __slots__ = ("_portfolio",) 

205 

206 def __init__(self, portfolio: PortfolioLike) -> None: 

207 self._portfolio = portfolio 

208 

209 def to_html( 

210 self, 

211 title: str = "JQuantStats Portfolio Report", 

212 path: str | Path | None = None, 

213 ) -> str | Path: 

214 """Render a full HTML report as a string or save it to a file. 

215 

216 The document is self-contained: Plotly.js is loaded once from the 

217 CDN and all charts are embedded as ``<div>`` elements. No external 

218 CSS framework is required. 

219 

220 Args: 

221 title: HTML ``<title>`` text and visible page heading. 

222 path: When given, write the report to this path and return the 

223 resolved `pathlib.Path`. A ``.html`` suffix is appended 

224 automatically when *path* has no file extension. When 

225 ``None`` (default) the HTML string is returned directly. 

226 

227 Returns: 

228 The HTML string when *path* is ``None``, otherwise the resolved 

229 `pathlib.Path` of the written file. 

230 """ 

231 pf = self._portfolio 

232 

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} &nbsp;|&nbsp; {n_periods:,} periods" 

241 else: 

242 start_date = "" 

243 end_date = "" 

244 period_info = f"{pf.prices.height:,} periods" 

245 

246 assets_list = ", ".join(pf.assets) 

247 

248 # ── Figures ─────────────────────────────────────────────────────────── 

249 # The first chart includes Plotly.js from CDN; subsequent ones reuse it. 

250 _first = True 

251 

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) 

258 

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>' 

266 

267 # The report embeds Plotly JSON and links plotly.js, so its charts must be 

268 # Plotly figures no matter what `set_plot_backend` has been told. Without 

269 # this, a matplotlib default would leave every chart as a "Chart 

270 # unavailable" notice: `_try_div` swallows the resulting failure. 

271 with plot_backend("plotly"): 

272 snapshot_div = _try_div(pf.plots.snapshot) 

273 rolling_sharpe_div = _try_div(pf.plots.rolling_sharpe_plot) 

274 rolling_vol_div = _try_div(pf.plots.rolling_volatility_plot) 

275 annual_sharpe_div = _try_div(pf.plots.annual_sharpe_plot) 

276 monthly_heatmap_div = _try_div(pf.plots.monthly_returns_heatmap) 

277 corr_div = _try_div(pf.plots.correlation_heatmap) 

278 lead_lag_div = _try_div(pf.plots.lead_lag_ir_plot) 

279 trading_cost_div = _try_div(pf.plots.trading_cost_impact_plot) 

280 

281 # ── Stats table ─────────────────────────────────────────────────────── 

282 stats_table = _stats_table_html(pf.stats.summary()) 

283 

284 # ── Turnover table ──────────────────────────────────────────────────── 

285 try: 

286 turnover_df = pf.turnover_summary() 

287 turnover_rows = "".join( 

288 f'<tr><td class="metric-name">{row["metric"].replace("_", " ").title()}</td>' 

289 f'<td class="metric-value">{row["value"]:.4f}</td></tr>' 

290 for row in turnover_df.iter_rows(named=True) 

291 ) 

292 turnover_html = ( 

293 '<table class="stats-table">' 

294 "<thead><tr>" 

295 '<th class="metric-header">Metric</th>' 

296 '<th class="asset-header">Value</th>' 

297 "</tr></thead>" 

298 f"<tbody>{turnover_rows}</tbody>" 

299 "</table>" 

300 ) 

301 except Exception as exc: 

302 turnover_html = f'<p class="chart-unavailable">Turnover data unavailable: {exc}</p>' 

303 

304 # ── Assemble HTML ───────────────────────────────────────────────────── 

305 footer_date = end_date if has_date else "" 

306 template = _env.get_template("portfolio_report.html") 

307 html = template.render( 

308 title=title, 

309 period_info=period_info, 

310 assets_list=assets_list, 

311 aum=f"{pf.aum:,.0f}", 

312 footer_date=footer_date, 

313 snapshot_div=snapshot_div, 

314 rolling_sharpe_div=rolling_sharpe_div, 

315 rolling_vol_div=rolling_vol_div, 

316 annual_sharpe_div=annual_sharpe_div, 

317 monthly_heatmap_div=monthly_heatmap_div, 

318 corr_div=corr_div, 

319 lead_lag_div=lead_lag_div, 

320 trading_cost_div=trading_cost_div, 

321 stats_table=stats_table, 

322 turnover_html=turnover_html, 

323 container_max_width="1400px", 

324 ) 

325 

326 if path is None: 

327 return html 

328 

329 p = Path(path) 

330 if not p.suffix: 

331 p = p.with_suffix(".html") 

332 p.parent.mkdir(parents=True, exist_ok=True) 

333 p.write_text(html, encoding="utf-8") 

334 return p