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

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 

7Examples: 

8 >>> import dataclasses 

9 >>> from jquantstats._reports import Report 

10 >>> dataclasses.is_dataclass(Report) 

11 True 

12""" 

13 

14from __future__ import annotations 

15 

16import dataclasses 

17import math 

18from collections.abc import Callable 

19from pathlib import Path 

20from typing import TYPE_CHECKING, Any, TypeGuard 

21 

22import plotly.graph_objects as go 

23import plotly.io as pio 

24import polars as pl 

25from jinja2 import Environment, FileSystemLoader, select_autoescape 

26 

27if TYPE_CHECKING: 

28 from ._protocol import PortfolioLike 

29 

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) 

36 

37 

38# ── Formatting helpers ──────────────────────────────────────────────────────── 

39 

40 

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

46 

47 

48def _fmt(value: Any, fmt: str = ".4f", suffix: str = "") -> str: 

49 """Format *value* for display in an HTML table cell. 

50 

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

56 

57 

58# ── Stats table ─────────────────────────────────────────────────────────────── 

59 

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} 

82 

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} 

105 

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) 

110 

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] 

118 

119 

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

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

122 

123 Args: 

124 summary: Output of :py:meth:`Stats.summary` — one row per metric, 

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

126 

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

131 

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} 

137 

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

139 rows_html_parts: list[str] = [] 

140 

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] 

153 

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] 

160 

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

167 

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 ) 

177 

178 

179# ── Report dataclass ────────────────────────────────────────────────────────── 

180 

181 

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

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

184 

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. 

190 

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 ) 

199 

200 

201@dataclasses.dataclass(frozen=True) 

202class Report: 

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

204 

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. 

208 

209 Usage:: 

210 

211 report = portfolio.report 

212 html_str = report.to_html() 

213 report.save("output/report.html") 

214 """ 

215 

216 portfolio: PortfolioLike 

217 

218 def to_html(self, title: str = "JQuantStats Portfolio Report") -> str: 

219 """Render a full HTML report as a string. 

220 

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. 

224 

225 Args: 

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

227 

228 Returns: 

229 A complete HTML document as a :class:`str`. 

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

275 

276 # ── Stats table ─────────────────────────────────────────────────────── 

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

278 

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

298 

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 ) 

320 

321 def save(self, path: str | Path, title: str = "JQuantStats Portfolio Report") -> Path: 

322 """Save the HTML report to a file. 

323 

324 A ``.html`` suffix is appended automatically when *path* has no 

325 file extension. 

326 

327 Args: 

328 path: Destination file path. 

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

330 

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