#!/usr/bin/env python
# -*- coding: utf-8 -*-
""" Portable report writer.
Renders an :class:`~fynance.research.Experiment` into remotely-viewable
artifacts under a caller-provided ``output_dir``: a markdown summary with an
embedded tearsheet PNG (viewable on GitHub from a phone) and a re-runnable
notebook. matplotlib and nbformat are imported lazily so ``import fynance`` stays
matplotlib-free and the package does not hard-require Jupyter.
"""
# Built-in
from __future__ import annotations
import warnings
from pathlib import Path
# Third-party
import numpy as np
# Local
from fynance.research.experiment import Experiment
__all__ = ['write_report']
def _metrics_table(metrics: dict[str, float]) -> str:
""" Render a metrics dict as a markdown table. """
if not metrics:
return "_no metrics_\n"
lines = ["| metric | value |", "| --- | --- |"]
lines += [f"| {k} | {v:.4f} |" for k, v in metrics.items()]
return "\n".join(lines) + "\n"
def _write_png(experiment: Experiment, png_path: Path, period: int) -> bool:
""" Render the tearsheet from the equity curve to ``png_path``.
Returns True if written, False if there is no equity curve to plot.
"""
if not experiment.series or not experiment.series.get("equity"):
return False
import matplotlib
matplotlib.use("Agg") # headless
import matplotlib.pyplot as plt
from fynance.plot import tearsheet
equity = np.asarray(experiment.series["equity"], dtype=float)
fig = tearsheet(equity, period=period)
fig.savefig(png_path, dpi=110, bbox_inches="tight")
plt.close(fig)
return True
def _build_notebook(experiment: Experiment, target: Path, period: int,
execute: bool) -> Path | None:
""" Write a re-runnable notebook reconstructing the report. Returns its path
or None when nbformat is unavailable. """
try:
import nbformat
from nbformat.v4 import new_code_cell, new_markdown_cell, new_notebook
except ImportError:
warnings.warn("nbformat not installed — skipping notebook generation.",
stacklevel=2)
return None
nb = new_notebook()
nb.cells = [
new_markdown_cell(f"# Experiment: {experiment.name}\n\n"
f"Reconstructed from `experiment.json` "
f"(fynance {experiment.fynance_version})."),
new_code_cell(
"import json\n"
"import numpy as np\n"
"from fynance.plot import tearsheet\n"
"\n"
"exp = json.load(open('experiment.json'))\n"
"equity = np.asarray(exp['series']['equity'], dtype=float)\n"
f"fig = tearsheet(equity, period={period})\n"
"fig"
),
]
nb_path = target / "report.ipynb"
if execute:
try:
from nbclient import NotebookClient
NotebookClient(nb, resources={"metadata": {"path": str(target)}}).execute()
except Exception as exc: # noqa: BLE001 — degrade gracefully (no kernel, etc.)
warnings.warn(f"notebook execution skipped: {exc!r}", stacklevel=2)
nbformat.write(nb, nb_path)
return nb_path
[docs]
def write_report(
experiment: Experiment,
output_dir: str | Path,
*,
notebook: bool = True,
execute: bool = False,
) -> dict[str, Path]:
""" Write a portable report for ``experiment`` under ``output_dir``.
Creates ``<output_dir>/<experiment.name>/`` with ``report.md`` and a
``tearsheet.png`` (when an equity curve is present), plus ``report.ipynb``
when ``notebook`` is True. Nothing is ever written outside ``output_dir``.
Parameters
----------
experiment : Experiment
The experiment to render.
output_dir : str or pathlib.Path
Base directory for the artifacts.
notebook : bool
Also emit a re-runnable ``report.ipynb`` (needs ``nbformat``).
execute : bool
Execute the notebook before writing it (needs ``nbclient`` + a kernel);
degrades to an unexecuted notebook with a warning if unavailable.
Returns
-------
dict of str to pathlib.Path
The written artifacts (keys: ``markdown``, ``png``, ``notebook`` —
present only when actually written).
"""
period = int(experiment.spec.get("period", 252)) if experiment.spec else 252
target = Path(output_dir) / experiment.name
target.mkdir(parents=True, exist_ok=True)
written: dict[str, Path] = {}
png_path = target / "tearsheet.png"
if _write_png(experiment, png_path, period):
written["png"] = png_path
md = [f"# Experiment: {experiment.name}\n",
f"- fynance: `{experiment.fynance_version}`",
f"- created: `{experiment.created_at}`",
f"- seed: `{experiment.seed}`",
f"- data: `{experiment.spec.get('data')}`" if experiment.spec else "",
f"- walk_forward: `{experiment.spec.get('walk_forward')}`"
if experiment.spec else "",
"\n## Metrics\n",
_metrics_table(experiment.metrics)]
if "png" in written:
md += ["\n## Tearsheet\n", "\n"]
md_path = target / "report.md"
md_path.write_text("\n".join(line for line in md if line != "") + "\n",
encoding="utf-8")
written["markdown"] = md_path
if notebook:
nb_path = _build_notebook(experiment, target, period, execute)
if nb_path is not None:
written["notebook"] = nb_path
return written