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

270 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-23 06:13 +0000

1"""Financial report generation from returns data.""" 

2 

3from __future__ import annotations 

4 

5import datetime 

6import math 

7import warnings 

8from typing import TYPE_CHECKING, Any, cast 

9 

10import polars as pl 

11 

12if TYPE_CHECKING: 

13 from jquantstats._protocol import DataLike 

14 

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

16 

17# ── Private helpers ─────────────────────────────────────────────────────────── 

18 

19 

20def _safe(fn: Any, *args: Any, **kwargs: Any) -> dict[str, float]: 

21 """Call ``fn(*args, **kwargs)`` and return ``{}`` on any exception.""" 

22 try: 

23 result: dict[str, float] = fn(*args, **kwargs) 

24 except Exception: 

25 return {} 

26 return result 

27 

28 

29def _pct(d: dict[str, float]) -> dict[str, float]: 

30 """Multiply every finite value in *d* by 100.""" 

31 return {k: v * 100.0 if _is_finite(v) else float("nan") for k, v in d.items()} 

32 

33 

34# ── Period-return helpers ───────────────────────────────────────────────────── 

35 

36 

37def _comp_since(all_df: pl.DataFrame, date_col: str, asset_cols: list[str], cutoff: Any) -> dict[str, float]: 

38 """Compounded return for each asset from *cutoff* to the last date.""" 

39 filtered = all_df.filter(pl.col(date_col) >= cutoff) 

40 result: dict[str, float] = {} 

41 for col in asset_cols: 

42 s = filtered[col].drop_nulls().cast(pl.Float64) 

43 result[col] = float((1.0 + s).product()) - 1.0 if len(s) > 0 else float("nan") 

44 return result 

45 

46 

47def _cagr_since( 

48 all_df: pl.DataFrame, 

49 date_col: str, 

50 asset_cols: list[str], 

51 cutoff: Any, 

52 periods_per_year: float, 

53) -> dict[str, float]: 

54 """Annualised CAGR for each asset from *cutoff* to the last date.""" 

55 filtered = all_df.filter(pl.col(date_col) >= cutoff) 

56 result: dict[str, float] = {} 

57 for col in asset_cols: 

58 s = filtered[col].drop_nulls().cast(pl.Float64) 

59 n = len(s) 

60 if n < 2: 

61 result[col] = float("nan") 

62 continue 

63 total = float((1.0 + s).product()) - 1.0 

64 years = n / periods_per_year 

65 result[col] = float(abs(1.0 + total) ** (1.0 / years) - 1.0) 

66 return result 

67 

68 

69# ── Metrics-row helpers ─────────────────────────────────────────────────────── 

70 

71 

72def _cutoff_months(today: Any, n: int) -> Any: 

73 """Return the date *n* calendar months before *today*. 

74 

75 Args: 

76 today: Reference date (must support ``.year``, ``.month``, ``.day``). 

77 n: Number of calendar months to subtract. 

78 

79 Returns: 

80 A `datetime.date` exactly *n* months before *today*. 

81 

82 """ 

83 import calendar 

84 from datetime import date as _date 

85 

86 y = today.year 

87 m = today.month 

88 for _ in range(n): 

89 m -= 1 

90 if m == 0: 

91 m = 12 

92 y -= 1 

93 d = min(today.day, calendar.monthrange(y, m)[1]) 

94 return _date(y, m, d) 

95 

96 

97def _add_overview_rows(rows: list[tuple[str, dict[str, Any]]], s: Any, ppy: float) -> None: 

98 """Append overview metric rows to *rows*. 

99 

100 Args: 

101 rows: Accumulator list of ``(label, values)`` tuples. 

102 s: Stats object providing the metric methods. 

103 ppy: Periods per year for annualisation. 

104 

105 """ 

106 rows.append(("Time in Market", _pct(_safe(s.exposure)))) 

107 rows.append(("Cumulative Return", _pct(_safe(s.comp)))) 

108 rows.append(("CAGR", _pct(_safe(s.cagr, periods=ppy)))) 

109 

110 

111def _add_risk_adjusted_rows(rows: list[tuple[str, dict[str, Any]]], s: Any, ppy: float) -> None: 

112 """Append risk-adjusted ratio rows to *rows*. 

113 

114 Args: 

115 rows: Accumulator list of ``(label, values)`` tuples. 

116 s: Stats object providing the metric methods. 

117 ppy: Periods per year for annualisation. 

118 

119 """ 

120 rows.append(("Sharpe", _safe(s.sharpe, periods=ppy))) 

121 rows.append(("Prob. Sharpe Ratio", _pct(_safe(s.probabilistic_sharpe_ratio)))) 

122 rows.append(("Sortino", _safe(s.sortino, periods=ppy))) 

123 rows.append(("Sortino / √2", _safe(s.adjusted_sortino, periods=ppy))) 

124 rows.append(("Omega", _safe(s.omega, periods=ppy))) 

125 

126 

127def _add_drawdown_rows(rows: list[tuple[str, dict[str, Any]]], s: Any) -> None: 

128 """Append drawdown metric rows to *rows*. 

129 

130 Args: 

131 rows: Accumulator list of ``(label, values)`` tuples. 

132 s: Stats object providing the metric methods. 

133 

134 """ 

135 rows.append(("Max Drawdown", _pct(_safe(s.max_drawdown)))) 

136 rows.append(("Max DD Duration", _safe(s.max_drawdown_duration))) 

137 rows.append(("Avg Drawdown", _pct(_safe(s.avg_drawdown)))) 

138 rows.append(("Recovery Factor", _safe(s.recovery_factor))) 

139 rows.append(("Ulcer Index", _safe(s.ulcer_index))) 

140 rows.append(("Serenity Index", _safe(s.serenity_index))) 

141 

142 

143def _add_trading_rows(rows: list[tuple[str, dict[str, Any]]], s: Any) -> None: 

144 """Append trading metric rows to *rows*. 

145 

146 Args: 

147 rows: Accumulator list of ``(label, values)`` tuples. 

148 s: Stats object providing the metric methods. 

149 

150 """ 

151 rows.append(("Gain/Pain Ratio", _safe(s.gain_to_pain_ratio))) 

152 rows.append(("Gain/Pain (1M)", _safe(s.gain_to_pain_ratio, aggregate="ME"))) 

153 rows.append(("Payoff Ratio", _safe(s.payoff_ratio))) 

154 rows.append(("Profit Factor", _safe(s.profit_factor))) 

155 rows.append(("Common Sense Ratio", _safe(s.common_sense_ratio))) 

156 rows.append(("CPC Index", _safe(s.cpc_index))) 

157 rows.append(("Tail Ratio", _safe(s.tail_ratio))) 

158 rows.append(("Outlier Win Ratio", _safe(s.outlier_win_ratio))) 

159 rows.append(("Outlier Loss Ratio", _safe(s.outlier_loss_ratio))) 

160 

161 

162def _add_recent_returns_rows( 

163 rows: list[tuple[str, dict[str, Any]]], 

164 all_df: pl.DataFrame, 

165 date_col: str, 

166 asset_cols: list[str], 

167 ppy: float, 

168 s: Any, 

169) -> None: 

170 """Append date-filtered recent return rows to *rows*. 

171 

172 Args: 

173 rows: Accumulator list of ``(label, values)`` tuples. 

174 all_df: Combined DataFrame containing date and return columns. 

175 date_col: Name of the date column in *all_df*. 

176 asset_cols: Names of asset return columns in *all_df*. 

177 ppy: Periods per year for annualisation. 

178 s: Stats object used for the all-time CAGR. 

179 

180 """ 

181 today = cast(datetime.date, all_df[date_col].max()) 

182 mtd_start = today.replace(day=1) 

183 ytd_start = today.replace(month=1, day=1) 

184 

185 rows.append(("MTD", _pct(_comp_since(all_df, date_col, asset_cols, mtd_start)))) 

186 rows.append(("3M", _pct(_comp_since(all_df, date_col, asset_cols, _cutoff_months(today, 3))))) 

187 rows.append(("6M", _pct(_comp_since(all_df, date_col, asset_cols, _cutoff_months(today, 6))))) 

188 rows.append(("YTD", _pct(_comp_since(all_df, date_col, asset_cols, ytd_start)))) 

189 rows.append(("1Y", _pct(_comp_since(all_df, date_col, asset_cols, _cutoff_months(today, 12))))) 

190 rows.append(("3Y (ann.)", _pct(_cagr_since(all_df, date_col, asset_cols, _cutoff_months(today, 36), ppy)))) 

191 rows.append(("5Y (ann.)", _pct(_cagr_since(all_df, date_col, asset_cols, _cutoff_months(today, 60), ppy)))) 

192 rows.append(("All-time (ann.)", _pct(_safe(s.cagr, periods=ppy)))) 

193 

194 

195def _add_full_mode_rows( 

196 rows: list[tuple[str, dict[str, Any]]], 

197 s: Any, 

198 ppy: float, 

199 data: Any, 

200 all_df: pl.DataFrame | None, 

201 date_col: str | None, 

202 asset_cols: list[str], 

203) -> None: 

204 """Append all full-mode extension rows to *rows*. 

205 

206 Covers smart ratios, extended risk, averages, expected returns, tail risk, 

207 streaks, best/worst periods, and benchmark metrics. 

208 

209 Args: 

210 rows: Accumulator list of ``(label, values)`` tuples. 

211 s: Stats object providing the metric methods. 

212 ppy: Periods per year for annualisation. 

213 data: The DataLike object (used for benchmark access). 

214 all_df: Combined DataFrame or ``None`` if unavailable. 

215 date_col: Name of the date column or ``None`` if unavailable. 

216 asset_cols: Asset column names. 

217 

218 """ 

219 # Smart ratios 

220 rows.append(("Smart Sharpe", _safe(s.smart_sharpe, periods=ppy))) 

221 ss = _safe(s.smart_sortino, periods=ppy) 

222 rows.append(("Smart Sortino", ss)) 

223 rows.append(("Smart Sortino / √2", {k: v / math.sqrt(2) for k, v in ss.items() if _is_finite(v)})) 

224 

225 # Risk 

226 rows.append(("Volatility (ann.)", _pct(_safe(s.volatility, periods=ppy)))) 

227 rows.append(("Calmar", _safe(s.calmar, periods=ppy))) 

228 rows.append(("Risk-Adjusted Return", _pct(_safe(s.rar, periods=ppy)))) 

229 rows.append(("Risk-Return Ratio", _safe(s.risk_return_ratio))) 

230 rows.append(("Ulcer Performance Index", _safe(s.ulcer_performance_index))) 

231 rows.append(("Skew", _safe(s.skew))) 

232 rows.append(("Kurtosis", _safe(s.kurtosis))) 

233 

234 # Averages 

235 rows.append(("Avg. Return", _pct(_safe(s.avg_return)))) 

236 rows.append(("Avg. Win", _pct(_safe(s.avg_win)))) 

237 rows.append(("Avg. Loss", _pct(_safe(s.avg_loss)))) 

238 rows.append(("Win/Loss Ratio", _safe(s.payoff_ratio))) 

239 rows.append(("Profit Ratio", _safe(s.profit_ratio))) 

240 rows.append(("Win Rate", _pct(_safe(s.win_rate)))) 

241 rows.append(("Monthly Win Rate", _pct(_safe(s.monthly_win_rate)))) 

242 

243 # Expected returns 

244 rows.append(("Expected Daily", _pct(_safe(s.expected_return)))) 

245 rows.append(("Expected Monthly", _pct(_safe(s.expected_return, aggregate="monthly")))) 

246 rows.append(("Expected Yearly", _pct(_safe(s.expected_return, aggregate="yearly")))) 

247 

248 # Tail risk 

249 rows.append(("Kelly Criterion", _pct(_safe(s.kelly_criterion)))) 

250 rows.append(("Risk of Ruin", _pct(_safe(s.risk_of_ruin)))) 

251 rows.append(("Daily VaR", _pct(_safe(s.value_at_risk)))) 

252 rows.append(("Expected Shortfall (cVaR)", _pct(_safe(s.conditional_value_at_risk)))) 

253 

254 # Streaks & best / worst 

255 rows.append(("Max Consecutive Wins", _safe(s.consecutive_wins))) 

256 rows.append(("Max Consecutive Losses", _safe(s.consecutive_losses))) 

257 rows.append(("Best Day", _pct(_safe(s.best)))) 

258 rows.append(("Worst Day", _pct(_safe(s.worst)))) 

259 

260 # Benchmark greeks (only if benchmark is present) 

261 try: 

262 greeks = s.greeks() 

263 if greeks: # pragma: no branch — greeks() raises without a benchmark, so falsy is unreachable 

264 beta = {k: v["beta"] for k, v in greeks.items()} 

265 alpha = {k: v["alpha"] * 100.0 for k, v in greeks.items()} 

266 rows.append(("Beta", beta)) 

267 rows.append(("Alpha", alpha)) 

268 except Exception: # noqa: S110 

269 pass # nosec B110 

270 

271 try: 

272 bench_obj = getattr(data, "benchmark", None) 

273 if bench_obj is not None and all_df is not None and date_col is not None: # pragma: no branch 

274 bench_col = bench_obj.columns[0] 

275 corr_dict: dict[str, float] = {} 

276 for ac in asset_cols: 

277 if ac == bench_col: 

278 continue 

279 sub = all_df.select([date_col, ac, bench_col]).drop_nulls() 

280 corr_val = float(sub.select(pl.corr(ac, bench_col))[0, 0]) 

281 corr_dict[ac] = corr_val * 100.0 

282 rows.append(("Correlation", corr_dict)) 

283 except Exception: # noqa: S110 

284 pass # nosec B110 

285 

286 rows.append(("R²", _safe(s.r_squared))) 

287 rows.append(("Treynor Ratio", _safe(s.treynor_ratio, periods=ppy))) 

288 

289 

290def _build_metrics_df(rows: list[tuple[str, dict[str, Any]]]) -> pl.DataFrame: 

291 """Build a metrics `pl.DataFrame` from accumulated row data. 

292 

293 Args: 

294 rows: List of ``(label, values)`` tuples where *values* maps asset 

295 names to numeric results. 

296 

297 Returns: 

298 A DataFrame with a leading ``"Metric"`` column and one column per 

299 asset, preserving the insertion order of both metrics and assets. 

300 

301 """ 

302 all_assets: list[str] = [] 

303 seen: set[str] = set() 

304 for _, vals in rows: 

305 for k in vals: 

306 if k not in seen: 

307 all_assets.append(k) 

308 seen.add(k) 

309 return pl.DataFrame([{"Metric": label, **{a: vals.get(a) for a in all_assets}} for label, vals in rows]) 

310 

311 

312# ── Metrics-table HTML renderer ─────────────────────────────────────────────── 

313 

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

315 ( 

316 "Overview", 

317 [ 

318 "Start Period", 

319 "End Period", 

320 "Time in Market", 

321 "Cumulative Return", 

322 "CAGR", 

323 ], 

324 ), 

325 ( 

326 "Risk-Adjusted Ratios", 

327 [ 

328 "Sharpe", 

329 "Prob. Sharpe Ratio", 

330 "Sortino", 

331 "Sortino / √2", 

332 "Omega", 

333 ], 

334 ), 

335 ( 

336 "Drawdown", 

337 [ 

338 "Max Drawdown", 

339 "Max DD Duration", 

340 "Avg Drawdown", 

341 "Recovery Factor", 

342 "Ulcer Index", 

343 "Serenity Index", 

344 ], 

345 ), 

346 ( 

347 "Trading", 

348 [ 

349 "Gain/Pain Ratio", 

350 "Gain/Pain (1M)", 

351 "Payoff Ratio", 

352 "Profit Factor", 

353 "Common Sense Ratio", 

354 "CPC Index", 

355 "Tail Ratio", 

356 "Outlier Win Ratio", 

357 "Outlier Loss Ratio", 

358 ], 

359 ), 

360 ( 

361 "Recent Returns", 

362 [ 

363 "MTD", 

364 "3M", 

365 "6M", 

366 "YTD", 

367 "1Y", 

368 "3Y (ann.)", 

369 "5Y (ann.)", 

370 "All-time (ann.)", 

371 ], 

372 ), 

373 ( 

374 "Smart Ratios", 

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

376 ), 

377 ( 

378 "Risk", 

379 [ 

380 "Volatility (ann.)", 

381 "Calmar", 

382 "Risk-Adjusted Return", 

383 "Risk-Return Ratio", 

384 "Ulcer Performance Index", 

385 "Skew", 

386 "Kurtosis", 

387 ], 

388 ), 

389 ( 

390 "Averages", 

391 [ 

392 "Avg. Return", 

393 "Avg. Win", 

394 "Avg. Loss", 

395 "Win/Loss Ratio", 

396 "Profit Ratio", 

397 "Win Rate", 

398 "Monthly Win Rate", 

399 ], 

400 ), 

401 ( 

402 "Expected Returns", 

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

404 ), 

405 ( 

406 "Tail Risk", 

407 [ 

408 "Kelly Criterion", 

409 "Risk of Ruin", 

410 "Daily VaR", 

411 "Expected Shortfall (cVaR)", 

412 ], 

413 ), 

414 ( 

415 "Streaks", 

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

417 ), 

418 ( 

419 "Best / Worst", 

420 ["Best Day", "Worst Day"], 

421 ), 

422 ( 

423 "Benchmark", 

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

425 ), 

426] 

427 

428_PCT_METRICS: frozenset[str] = frozenset( 

429 { 

430 "Time in Market", 

431 "Cumulative Return", 

432 "CAGR", 

433 "Prob. Sharpe Ratio", 

434 "Max Drawdown", 

435 "Avg Drawdown", 

436 "MTD", 

437 "3M", 

438 "6M", 

439 "YTD", 

440 "1Y", 

441 "3Y (ann.)", 

442 "5Y (ann.)", 

443 "All-time (ann.)", 

444 "Volatility (ann.)", 

445 "Risk-Adjusted Return", 

446 "Avg. Return", 

447 "Avg. Win", 

448 "Avg. Loss", 

449 "Win Rate", 

450 "Monthly Win Rate", 

451 "Expected Daily", 

452 "Expected Monthly", 

453 "Expected Yearly", 

454 "Kelly Criterion", 

455 "Risk of Ruin", 

456 "Daily VaR", 

457 "Expected Shortfall (cVaR)", 

458 "Best Day", 

459 "Worst Day", 

460 "Alpha", 

461 "Correlation", 

462 } 

463) 

464 

465 

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

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

468 

469 Args: 

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

471 

472 Returns: 

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

474 

475 """ 

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

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

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

479 } 

480 

481 n_cols = len(assets) + 1 

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

483 parts: list[str] = [] 

484 

485 rendered: set[str] = set() 

486 for section_label, section_metrics in _SECTION_SPANS: 

487 section_rows: list[str] = [] 

488 for label in section_metrics: 

489 if label not in rows_by_label: 

490 continue 

491 vals = rows_by_label[label] 

492 rendered.add(label) 

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

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

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

496 

497 if section_rows: 

498 parts.append( 

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

500 ) 

501 parts.extend(section_rows) 

502 

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

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

505 if label in rendered: 

506 continue 

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

508 if isinstance(raw, str): 

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

510 else: 

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

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

513 

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

515 

516 

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

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

519 

520 Args: 

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

522 assets: List of asset column names to render. 

523 

524 Returns: 

525 HTML string containing one table per asset. 

526 

527 """ 

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

529 if stats is None: 

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

531 

532 parts: list[str] = [] 

533 try: 

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

535 except Exception: 

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

537 

538 for asset in assets: 

539 df = dd_dict.get(asset) 

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

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

542 continue 

543 

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

545 rows = "".join( 

546 f"<tr>" 

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

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

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

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

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

552 f"</tr>" 

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

554 ) 

555 parts.append( 

556 f"<h3>{asset}</h3>" 

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

558 "<thead><tr>" 

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

560 "</tr></thead>" 

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

562 ) 

563 

564 return "\n".join(parts) 

565 

566 

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

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

569 

570 Args: 

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

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

573 

574 Returns: 

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

576 

577 """ 

578 try: 

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

580 except Exception: 

581 return "" 

582 

583 

584_REPORT_CSS = """ 

585body{margin:0;font-family:system-ui,sans-serif;background:#0f1117;color:#e2e8f0} 

586h1{color:#90cdf4;margin:0 0 4px} 

587h2{color:#63b3ed;border-bottom:1px solid #2d3748;padding-bottom:6px} 

588h3{color:#a0aec0;margin:16px 0 6px} 

589header{padding:24px 32px;background:linear-gradient(135deg,#1a202c,#2d3748);border-bottom:1px solid #4a5568} 

590.period-info{color:#a0aec0;font-size:.85rem;margin-top:4px} 

591main{padding:24px 32px} 

592section{margin-bottom:40px} 

593.stats-table{border-collapse:collapse;width:100%;font-size:.85rem} 

594.stats-table th,.stats-table td{padding:6px 12px;text-align:right;border-bottom:1px solid #2d3748} 

595.stats-table th:first-child,.stats-table td:first-child{text-align:left} 

596.metric-header,.asset-header{background:#1a202c;color:#90cdf4;font-weight:600} 

597.metric-name{color:#cbd5e0} 

598.metric-value{font-family:monospace;color:#e2e8f0} 

599.table-section-header td{background:#1a202c;color:#68d391;font-size:.75rem;text-transform:uppercase; 

600letter-spacing:.08em;padding:8px 12px} 

601footer{padding:16px 32px;color:#718096;font-size:.75rem;border-top:1px solid #2d3748} 

602""" 

603 

604 

605def _build_full_html( 

606 title: str, 

607 period_info: str, 

608 assets_str: str, 

609 metrics_html: str, 

610 drawdowns_html: str, 

611 charts_html: str, 

612) -> str: 

613 """Assemble the full HTML report from its component parts. 

614 

615 Args: 

616 title: Page and ``<h1>`` title. 

617 period_info: Period metadata string for the header. 

618 assets_str: Comma-separated asset names for the header. 

619 metrics_html: Pre-rendered metrics ``<table>`` HTML. 

620 drawdowns_html: Pre-rendered worst-drawdowns HTML. 

621 charts_html: Pre-rendered Plotly chart divs. 

622 

623 Returns: 

624 A complete, self-contained HTML document string. 

625 

626 """ 

627 from datetime import date 

628 

629 footer_date = str(date.today()) 

630 return f"""<!DOCTYPE html> 

631<html lang="en"> 

632<head> 

633<meta charset="utf-8"> 

634<meta name="viewport" content="width=device-width,initial-scale=1"> 

635<title>{title}</title> 

636<style>{_REPORT_CSS}</style> 

637</head> 

638<body> 

639<header> 

640 <h1>{title}</h1> 

641 <div class="period-info">{period_info}</div> 

642 <div class="period-info">Assets: {assets_str}</div> 

643</header> 

644<main> 

645 <section id="metrics"> 

646 <h2>Performance Metrics</h2> 

647 {metrics_html} 

648 </section> 

649 <section id="drawdowns"> 

650 <h2>Worst 5 Drawdown Periods</h2> 

651 {drawdowns_html} 

652 </section> 

653 <section id="charts"> 

654 <h2>Charts</h2> 

655 {charts_html} 

656 </section> 

657</main> 

658<footer>Generated by jquantstats · {footer_date}</footer> 

659</body> 

660</html>""" 

661 

662 

663# ── Reports dataclass ───────────────────────────────────────────────────────── 

664 

665 

666class Reports: 

667 """A class for generating financial reports from Data objects. 

668 

669 This class provides methods for calculating and formatting various financial metrics 

670 into report-ready formats such as DataFrames. 

671 """ 

672 

673 __slots__ = ("_data",) 

674 

675 def __init__(self, data: DataLike) -> None: 

676 self._data = data 

677 

678 def metrics( 

679 self, 

680 mode: str = "basic", 

681 periods_per_year: int | float = 252, 

682 rf: float = 0.0, 

683 ) -> pl.DataFrame: 

684 """Comprehensive performance metrics table matching ``qs.reports.metrics``. 

685 

686 Computes an ordered set of performance, risk, and trading metrics for 

687 every asset in the dataset and returns them as a tidy DataFrame. 

688 

689 Args: 

690 mode: ``"basic"`` (default) for core metrics, ``"full"`` for the 

691 extended set including smart ratios, expected returns, streaks, 

692 best/worst periods, win rates, and benchmark greeks. 

693 periods_per_year: Annualisation factor. Defaults to 252. 

694 rf: Annualised risk-free rate used in ratio calculations. 

695 Defaults to 0.0. 

696 

697 Returns: 

698 pl.DataFrame: One row per metric, one column per asset, plus a 

699 leading ``"Metric"`` column with the metric label. 

700 

701 """ 

702 s = self._data.stats 

703 ppy = float(periods_per_year) 

704 is_full = mode.lower() == "full" 

705 

706 rows: list[tuple[str, dict[str, Any]]] = [] 

707 

708 all_df: pl.DataFrame | None = getattr(self._data, "all", None) 

709 asset_cols: list[str] = [] 

710 date_col: str | None = None 

711 has_dates = False 

712 

713 if all_df is not None: # pragma: no branch — Data always exposes .all; getattr default is defensive 

714 date_col = all_df.columns[0] 

715 asset_cols = [c for c in all_df.columns if c != date_col] 

716 has_dates = all_df[date_col].dtype.is_temporal() 

717 

718 _add_overview_rows(rows, s, ppy) 

719 _add_risk_adjusted_rows(rows, s, ppy) 

720 _add_drawdown_rows(rows, s) 

721 _add_trading_rows(rows, s) 

722 

723 if has_dates and date_col is not None and all_df is not None: # pragma: no branch 

724 _add_recent_returns_rows(rows, all_df, date_col, asset_cols, ppy, s) 

725 

726 if is_full: 

727 _add_full_mode_rows(rows, s, ppy, self._data, all_df, date_col, asset_cols) 

728 

729 return _build_metrics_df(rows) 

730 

731 def full( 

732 self, 

733 title: str = "Performance Report", 

734 periods_per_year: int | float = 252, 

735 rf: float = 0.0, 

736 ) -> str: 

737 """Generate a self-contained HTML performance report. 

738 

739 Combines a comprehensive metrics table (full mode), worst-5 drawdown 

740 periods per asset, and interactive Plotly charts into a single 

741 dark-themed HTML document. 

742 

743 Args: 

744 title: Page ``<h1>`` title. Defaults to ``"Performance Report"``. 

745 periods_per_year: Annualisation factor passed to 

746 `metrics`. Defaults to 252. 

747 rf: Annualised risk-free rate. Defaults to 0.0. 

748 

749 Returns: 

750 str: A complete, self-contained HTML document. 

751 

752 """ 

753 # ── Metrics ─────────────────────────────────────────────────────────── 

754 metrics_df = self.metrics(mode="full", periods_per_year=periods_per_year, rf=rf) 

755 assets = [c for c in metrics_df.columns if c != "Metric"] 

756 metrics_html = _metrics_table_html(metrics_df) 

757 

758 # ── Period info for header ──────────────────────────────────────────── 

759 all_df: pl.DataFrame | None = getattr(self._data, "all", None) 

760 period_info = "" 

761 temporal_index = False 

762 if all_df is not None: # pragma: no branch — Data always exposes .all; getattr default is defensive 

763 date_col = all_df.columns[0] 

764 temporal_index = all_df[date_col].dtype.is_temporal() 

765 if temporal_index: 

766 start_dt = all_df[date_col].min() 

767 end_dt = all_df[date_col].max() 

768 n = len(all_df) 

769 period_info = f"{start_dt!s}{end_dt!s} | {n:,} observations" 

770 

771 # ── Drawdowns ───────────────────────────────────────────────────────── 

772 drawdowns_html = _drawdowns_section_html(self._data, assets) 

773 

774 # ── Charts ──────────────────────────────────────────────────────────── 

775 plots = getattr(self._data, "plots", None) 

776 chart_parts: list[str] = [] 

777 if plots is not None: # pragma: no branch — Data always exposes .plots; getattr default is defensive 

778 _chart_methods: list[tuple[str, dict[str, Any]]] = [ 

779 ("snapshot", {}), 

780 ("returns", {}), 

781 ("drawdown", {}), 

782 ("rolling_sharpe", {}), 

783 ("rolling_volatility", {}), 

784 ("monthly_heatmap", {}), 

785 ("yearly_returns", {}), 

786 ("histogram", {}), 

787 ] 

788 # These charts aggregate by calendar period (resample, dt.year/ 

789 # dt.month) and cannot be computed for an integer index. 

790 _calendar_charts = {"snapshot", "monthly_heatmap", "yearly_returns"} 

791 if not temporal_index: 

792 skipped = ", ".join(m for m, _ in _chart_methods if m in _calendar_charts) 

793 warnings.warn( 

794 f"Index is not temporal; skipping calendar-based charts: {skipped}.", 

795 stacklevel=2, 

796 ) 

797 for method, kwargs in _chart_methods: 

798 if not temporal_index and method in _calendar_charts: 

799 continue 

800 fn = getattr(plots, method, None) 

801 if fn is None: 

802 continue 

803 div = _try_plotly_div(fn(**kwargs), include_cdn=not chart_parts) 

804 if div: # pragma: no branch — _try_plotly_div only returns falsy on render failure 

805 chart_parts.append(f'<div style="margin-bottom:24px">{div}</div>') 

806 

807 charts_html = "\n".join(chart_parts) if chart_parts else "<p>No charts available.</p>" 

808 

809 return _build_full_html( 

810 title=title, 

811 period_info=period_info, 

812 assets_str=", ".join(assets), 

813 metrics_html=metrics_html, 

814 drawdowns_html=drawdowns_html, 

815 charts_html=charts_html, 

816 )