Source code for merton.reports.html

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