"""Render :class:`BacktestResult` (or any other result) as a standalone HTML page.
Implementation is intentionally dependency-light: we build the page with plain
f-string templating so users don't need ``jinja2`` for the basic report. When
``[viz]`` is installed, embedded SVGs of the ROC and calibration curves are
included.
"""
from __future__ import annotations
from html import escape
from pathlib import Path
from typing import TYPE_CHECKING, Any
import numpy as np
if TYPE_CHECKING:
from ..backtest.backtest import BacktestResult
_CSS = """
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; max-width: 880px; margin: 2em auto; color: #1d1d1f; padding: 0 1em; }
h1 { font-size: 1.8rem; }
h2 { font-size: 1.3rem; margin-top: 2em; border-bottom: 1px solid #d2d2d7; padding-bottom: .25em; }
table { border-collapse: collapse; margin: 1em 0; }
th, td { padding: 0.35em 0.8em; text-align: left; border-bottom: 1px solid #f0f0f3; }
th { background: #f5f5f7; }
.meta { color: #6e6e73; font-size: 0.9rem; }
.metric-value { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
"""
def _svg_curve(
xs: np.ndarray,
ys: np.ndarray,
*,
title: str,
x_label: str,
y_label: str,
diagonal: bool = False,
) -> str:
"""Render a tiny SVG line chart without matplotlib."""
width, height, margin = 480, 320, 40
xs = np.asarray(xs, dtype=float)
ys = np.asarray(ys, dtype=float)
if xs.size == 0:
return ""
xmin, xmax = float(xs.min()), float(xs.max() if xs.max() > xs.min() else xs.min() + 1.0)
ymin, ymax = 0.0, max(float(ys.max()), 1.0)
sx = (width - 2 * margin) / max(xmax - xmin, 1e-9)
sy = (height - 2 * margin) / max(ymax - ymin, 1e-9)
def px(x: float) -> float:
return margin + (x - xmin) * sx
def py(y: float) -> float:
return height - margin - (y - ymin) * sy
points = " ".join(f"{px(x):.1f},{py(y):.1f}" for x, y in zip(xs, ys, strict=False))
extra = ""
if diagonal:
extra = (
f'<line x1="{px(xmin):.1f}" y1="{py(ymin):.1f}" '
f'x2="{px(xmax):.1f}" y2="{py(ymax):.1f}" '
'stroke="#86868b" stroke-dasharray="4 4" stroke-width="1" />'
)
return (
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}" '
f'preserveAspectRatio="xMidYMid meet" width="{width}" height="{height}">'
f'<rect width="100%" height="100%" fill="white"/>'
f'<text x="{width // 2}" y="20" text-anchor="middle" font-size="14" fill="#1d1d1f">{escape(title)}</text>'
f'<polyline fill="none" stroke="#0071e3" stroke-width="2" points="{points}"/>'
f"{extra}"
f'<text x="{width // 2}" y="{height - 8}" text-anchor="middle" font-size="11" fill="#6e6e73">{escape(x_label)}</text>'
f'<text x="14" y="{height // 2}" text-anchor="middle" font-size="11" fill="#6e6e73" '
f'transform="rotate(-90 14 {height // 2})">{escape(y_label)}</text>'
f"</svg>"
)
[docs]
def render_backtest_report(
result: BacktestResult,
*,
title: str = "merton backtest report",
metadata: dict[str, Any] | None = None,
out_path: str | Path | None = None,
) -> str:
"""Return an HTML string (and optionally write it to ``out_path``)."""
metadata = metadata or {}
meta_rows = "".join(
f"<tr><th>{escape(str(k))}</th><td>{escape(str(v))}</td></tr>" for k, v in metadata.items()
)
metric_rows = "".join(
f'<tr><th>{escape(k)}</th><td class="metric-value">{v:.6f}</td></tr>'
if isinstance(v, float)
else f"<tr><th>{escape(k)}</th><td>{escape(str(v))}</td></tr>"
for k, v in result.to_dict().items()
)
roc_svg = _svg_curve(
result.roc.fpr,
result.roc.tpr,
title="ROC curve",
x_label="false positive rate",
y_label="true positive rate",
diagonal=True,
)
cc = result.calibration
valid = np.isfinite(cc.fraction_positives)
calib_svg = _svg_curve(
cc.mean_predicted[valid],
cc.fraction_positives[valid],
title="Calibration curve",
x_label="mean predicted PD",
y_label="observed default rate",
diagonal=True,
)
html = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{escape(title)}</title>
<style>{_CSS}</style>
</head>
<body>
<h1>{escape(title)}</h1>
<p class="meta">Auto-generated by <code>merton.reports.render_backtest_report</code>.</p>
<h2>Metadata</h2>
<table>{meta_rows or "<tr><td><em>none</em></td></tr>"}</table>
<h2>Metrics</h2>
<table>{metric_rows}</table>
<h2>ROC curve</h2>
{roc_svg}
<h2>Calibration curve</h2>
{calib_svg}
</body>
</html>
"""
if out_path is not None:
Path(out_path).write_text(html)
return html
__all__ = ["render_backtest_report"]