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

66 statements  

« prev     ^ index     » next       coverage.py v7.17.1, created at 2026-11-03 15:52 +0000

1"""HTML fragments for the self-contained performance report. 

2 

3These helpers turn the metrics table, drawdown summary, and Plotly 

4charts assembled by ``Reports.full`` into HTML fragments, which 

5``templates/data_report.html`` assembles into a single dark-themed 

6document. 

7""" 

8 

9from __future__ import annotations 

10 

11from html import escape 

12from typing import Any 

13 

14import polars as pl 

15 

16from ._formatting import _fmt, _plotly_div, _table_html 

17 

18# ── Metrics-table section layout ─────────────────────────────────────────────── 

19 

20_SECTION_SPANS: list[tuple[str, list[str]]] = [ 

21 ( 

22 "Overview", 

23 [ 

24 "Start Period", 

25 "End Period", 

26 "Time in Market", 

27 "Cumulative Return", 

28 "CAGR", 

29 ], 

30 ), 

31 ( 

32 "Risk-Adjusted Ratios", 

33 [ 

34 "Sharpe", 

35 "Prob. Sharpe Ratio", 

36 "Sortino", 

37 "Sortino / √2", 

38 "Omega", 

39 ], 

40 ), 

41 ( 

42 "Drawdown", 

43 [ 

44 "Max Drawdown", 

45 "Max DD Duration", 

46 "Avg Drawdown", 

47 "Recovery Factor", 

48 "Ulcer Index", 

49 "Serenity Index", 

50 ], 

51 ), 

52 ( 

53 "Trading", 

54 [ 

55 "Gain/Pain Ratio", 

56 "Gain/Pain (1M)", 

57 "Payoff Ratio", 

58 "Profit Factor", 

59 "Common Sense Ratio", 

60 "CPC Index", 

61 "Tail Ratio", 

62 "Outlier Win Ratio", 

63 "Outlier Loss Ratio", 

64 ], 

65 ), 

66 ( 

67 "Recent Returns", 

68 [ 

69 "MTD", 

70 "3M", 

71 "6M", 

72 "YTD", 

73 "1Y", 

74 "3Y (ann.)", 

75 "5Y (ann.)", 

76 "All-time (ann.)", 

77 ], 

78 ), 

79 ( 

80 "Smart Ratios", 

81 ["Smart Sharpe", "Smart Sortino", "Smart Sortino / √2"], 

82 ), 

83 ( 

84 "Risk", 

85 [ 

86 "Volatility (ann.)", 

87 "Calmar", 

88 "Risk-Adjusted Return", 

89 "Risk-Return Ratio", 

90 "Ulcer Performance Index", 

91 "Skew", 

92 "Kurtosis", 

93 ], 

94 ), 

95 ( 

96 "Averages", 

97 [ 

98 "Avg. Return", 

99 "Avg. Win", 

100 "Avg. Loss", 

101 "Win/Loss Ratio", 

102 "Profit Ratio", 

103 "Win Rate", 

104 "Monthly Win Rate", 

105 ], 

106 ), 

107 ( 

108 "Expected Returns", 

109 ["Expected Daily", "Expected Monthly", "Expected Yearly"], 

110 ), 

111 ( 

112 "Tail Risk", 

113 [ 

114 "Kelly Criterion", 

115 "Risk of Ruin", 

116 "Daily VaR", 

117 "Expected Shortfall (cVaR)", 

118 ], 

119 ), 

120 ( 

121 "Streaks", 

122 ["Max Consecutive Wins", "Max Consecutive Losses"], 

123 ), 

124 ( 

125 "Best / Worst", 

126 ["Best Day", "Worst Day"], 

127 ), 

128 ( 

129 "Benchmark", 

130 ["Beta", "Alpha", "Correlation", "R²", "Treynor Ratio"], 

131 ), 

132] 

133 

134_PCT_METRICS: frozenset[str] = frozenset( 

135 { 

136 "Time in Market", 

137 "Cumulative Return", 

138 "CAGR", 

139 "Prob. Sharpe Ratio", 

140 "Max Drawdown", 

141 "Avg Drawdown", 

142 "MTD", 

143 "3M", 

144 "6M", 

145 "YTD", 

146 "1Y", 

147 "3Y (ann.)", 

148 "5Y (ann.)", 

149 "All-time (ann.)", 

150 "Volatility (ann.)", 

151 "Risk-Adjusted Return", 

152 "Avg. Return", 

153 "Avg. Win", 

154 "Avg. Loss", 

155 "Win Rate", 

156 "Monthly Win Rate", 

157 "Expected Daily", 

158 "Expected Monthly", 

159 "Expected Yearly", 

160 "Kelly Criterion", 

161 "Risk of Ruin", 

162 "Daily VaR", 

163 "Expected Shortfall (cVaR)", 

164 "Best Day", 

165 "Worst Day", 

166 "Alpha", 

167 "Correlation", 

168 } 

169) 

170 

171 

172def _metrics_table_html(df: pl.DataFrame) -> str: 

173 """Render a metrics DataFrame as a styled HTML table with section headers. 

174 

175 Args: 

176 df: DataFrame with a ``"Metric"`` column and one column per asset. 

177 

178 Returns: 

179 An HTML ``<table>`` string. 

180 

181 """ 

182 assets = [c for c in df.columns if c != "Metric"] 

183 rows_by_label: dict[str, dict[str, Any]] = { 

184 str(row["Metric"]): {a: row.get(a) for a in assets} for row in df.iter_rows(named=True) 

185 } 

186 

187 n_cols = len(assets) + 1 

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

189 parts: list[str] = [] 

190 

191 rendered: set[str] = set() 

192 for section_label, section_metrics in _SECTION_SPANS: 

193 section_rows = _section_rows_html(section_metrics, rows_by_label, assets, rendered) 

194 if section_rows: 

195 parts.append( 

196 f'<tr class="table-section-header"><td colspan="{n_cols}"><strong>{section_label}</strong></td></tr>\n' 

197 ) 

198 parts.extend(section_rows) 

199 

200 # Anything not matched by a section (e.g. string-valued rows like dates) 

201 parts.extend(_unmatched_rows_html(rows_by_label, rendered, assets)) 

202 

203 return _table_html(header_cells, "".join(parts)) 

204 

205 

206def _section_rows_html( 

207 section_metrics: Any, 

208 rows_by_label: dict[str, dict[str, Any]], 

209 assets: list[str], 

210 rendered: set[str], 

211) -> list[str]: 

212 """Render the ``<tr>`` rows for one metric section, in section order. 

213 

214 Args: 

215 section_metrics: Ordered metric labels belonging to the section. 

216 rows_by_label: Lookup of metric label → per-asset values. 

217 assets: Asset column names, in output order. 

218 rendered: Mutable set of labels already emitted; updated in place so 

219 the caller can render unmatched rows afterwards. 

220 

221 Returns: 

222 A list of HTML ``<tr>`` strings (empty when no metric matched). 

223 

224 """ 

225 section_rows: list[str] = [] 

226 for label in section_metrics: 

227 if label not in rows_by_label: 

228 continue 

229 vals = rows_by_label[label] 

230 rendered.add(label) 

231 suffix = "%" if label in _PCT_METRICS else "" 

232 cells = "".join(f'<td class="metric-value">{_fmt(vals.get(a), ".2f", suffix)}</td>' for a in assets) 

233 section_rows.append(f'<tr><td class="metric-name">{label}</td>{cells}</tr>\n') 

234 return section_rows 

235 

236 

237def _unmatched_rows_html( 

238 rows_by_label: dict[str, dict[str, Any]], 

239 rendered: set[str], 

240 assets: list[str], 

241) -> list[str]: 

242 """Render rows not claimed by any section (e.g. string-valued date rows). 

243 

244 Args: 

245 rows_by_label: Lookup of metric label → per-asset values. 

246 rendered: Labels already emitted by the section pass. 

247 assets: Asset column names, in output order. 

248 

249 Returns: 

250 A list of HTML ``<tr>`` strings for the leftover labels. 

251 

252 """ 

253 parts: list[str] = [] 

254 for label, vals in rows_by_label.items(): 

255 if label in rendered: 

256 continue 

257 raw = next(iter(vals.values()), None) 

258 if isinstance(raw, str): 

259 cells = "".join(f'<td class="metric-value">{vals.get(a, "")}</td>' for a in assets) 

260 else: 

261 cells = "".join(f'<td class="metric-value">{_fmt(vals.get(a), ".4f")}</td>' for a in assets) 

262 parts.append(f'<tr><td class="metric-name">{label}</td>{cells}</tr>\n') 

263 return parts 

264 

265 

266def _drawdowns_section_html(data: Any, assets: list[str]) -> str: 

267 """Render worst-5 drawdown periods per asset as HTML tables. 

268 

269 Args: 

270 data: The DataLike object (accessed via ``getattr`` for stats). 

271 assets: List of asset column names to render. 

272 

273 Returns: 

274 HTML string containing one table per asset. 

275 

276 """ 

277 stats = getattr(data, "stats", None) 

278 if stats is None: 

279 return "<p>No drawdown data available.</p>" 

280 

281 parts: list[str] = [] 

282 try: 

283 dd_dict: dict[str, pl.DataFrame] = stats.drawdown_details() 

284 except Exception: 

285 return "<p>Drawdown details unavailable.</p>" 

286 

287 for asset in assets: 

288 df = dd_dict.get(asset) 

289 if df is None or len(df) == 0: 

290 parts.append(f"<h3>{escape(asset)}</h3><p>No drawdown periods found.</p>") 

291 continue 

292 

293 worst5 = df.sort("max_drawdown").head(5) 

294 rows = "".join( 

295 f"<tr>" 

296 f"<td>{row.get('start', '')}</td>" 

297 f"<td>{row.get('valley', '')}</td>" 

298 f"<td>{row.get('end', '') or '—'}</td>" 

299 f"<td>{_fmt(row.get('max_drawdown'), '.2%')}</td>" 

300 f"<td>{row.get('duration', '') or '—'}</td>" 

301 f"</tr>" 

302 for row in worst5.iter_rows(named=True) 

303 ) 

304 parts.append( 

305 f"<h3>{escape(asset)}</h3>" 

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

307 "<thead><tr>" 

308 "<th>Start</th><th>Valley</th><th>End</th><th>Max DD</th><th>Duration</th>" 

309 "</tr></thead>" 

310 f"<tbody>{rows}</tbody></table>" 

311 ) 

312 

313 return "\n".join(parts) 

314 

315 

316def _try_plotly_div(fig: Any, include_cdn: bool = False) -> str: 

317 """Convert a Plotly figure to an HTML div string. 

318 

319 Args: 

320 fig: A Plotly Figure object (or anything with ``to_html``). 

321 include_cdn: Include the Plotly JS CDN ``<script>`` tag. Defaults to False. 

322 

323 Returns: 

324 An HTML string, or an empty string if conversion fails. 

325 

326 """ 

327 try: 

328 return _plotly_div(fig, include_plotlyjs="cdn" if include_cdn else False) 

329 except Exception: 

330 return "" 

331 

332 

333# ── Reports dataclass ─────────────────────────────────────────────────────────