#!/usr/bin/env python
# -*- coding: utf-8 -*-
""" Multi-strategy comparison report.
Ranks a set of :class:`~fynance.research.Experiment` runs into a leaderboard and
overlays their equity curves — the artifact for *"which of these strategies is
best, and by how much vs a baseline?"*. Written, like everything in the harness,
only to a caller-provided ``output_dir``.
"""
# Built-in
from __future__ import annotations
from pathlib import Path
# Third-party
import numpy as np
# Local
from fynance.research.experiment import Experiment
__all__ = ['compare_report', 'leaderboard']
[docs]
def leaderboard(
experiments: list[Experiment],
*,
sort_by: str = "sharpe",
descending: bool = True,
) -> list[dict[str, float | str]]:
""" Rank experiments into leaderboard rows.
Parameters
----------
experiments : list of Experiment
The runs to rank.
sort_by : str
Metric key to sort on (missing values sink to the bottom).
descending : bool
Higher is better when True.
Returns
-------
list of dict
One ``{"name": ..., <metric>: <value>, ...}`` row per experiment, ranked.
"""
rows: list[dict[str, float | str]] = [
{"name": e.name, **{k: float(v) for k, v in e.metrics.items()}}
for e in experiments
]
worst = float("-inf") if descending else float("inf")
rows.sort(key=lambda r: r.get(sort_by, worst), reverse=descending) # type: ignore[arg-type]
return rows
def _write_overlay(experiments: list[Experiment], png_path: Path) -> bool:
""" Overlay each experiment's equity curve (rebased to 100). """
curves = [(e.name, e.series["equity"]) for e in experiments
if e.series and e.series.get("equity")]
if not curves:
return False
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(10, 5))
for label, equity in curves:
eq = np.asarray(equity, dtype=float)
ax.plot(100.0 * eq / eq[0], label=label)
ax.set_title("Equity comparison (rebased to 100)")
ax.set_xlabel("step")
ax.set_ylabel("equity")
ax.legend(loc="best", fontsize=8)
fig.tight_layout()
fig.savefig(png_path, dpi=110, bbox_inches="tight")
plt.close(fig)
return True
def _markdown_table(rows: list[dict[str, float | str]]) -> str:
""" Render leaderboard rows as a markdown table. """
if not rows:
return "_no experiments_\n"
cols = ["name"] + [k for k in rows[0] if k != "name"]
head = "| " + " | ".join(cols) + " |"
sep = "| " + " | ".join("---" for _ in cols) + " |"
body = []
for r in rows:
cells = [str(r["name"])] + [
f"{r[c]:.4f}" if isinstance(r.get(c), float) else str(r.get(c, ""))
for c in cols[1:]
]
body.append("| " + " | ".join(cells) + " |")
return "\n".join([head, sep, *body]) + "\n"
[docs]
def compare_report(
experiments: list[Experiment],
output_dir: str | Path,
*,
name: str = "comparison",
sort_by: str = "sharpe",
) -> dict[str, Path]:
""" Write a leaderboard + equity-overlay comparison report.
Creates ``<output_dir>/<name>/`` with ``report.md`` (a leaderboard table
ranked by ``sort_by``) and ``equity_overlay.png`` (when curves are present).
The first experiment is treated as the baseline in the narrative.
Parameters
----------
experiments : list of Experiment
The runs to compare (≥ 1).
output_dir : str or pathlib.Path
Base directory for the artifacts.
name : str
Sub-directory name.
sort_by : str
Leaderboard sort metric.
Returns
-------
dict of str to pathlib.Path
Written artifacts (keys: ``markdown``, ``png`` when produced).
"""
if not experiments:
raise ValueError("compare_report needs at least one experiment")
target = Path(output_dir) / name
target.mkdir(parents=True, exist_ok=True)
written: dict[str, Path] = {}
png = target / "equity_overlay.png"
if _write_overlay(experiments, png):
written["png"] = png
rows = leaderboard(experiments, sort_by=sort_by)
md = [f"# Comparison: {name}\n",
f"Ranked by `{sort_by}` — {len(experiments)} experiment(s), "
f"baseline `{experiments[0].name}`.\n",
"## Leaderboard\n",
_markdown_table(rows)]
if "png" in written:
md += ["\n## Equity overlay\n", "\n"]
md_path = target / "report.md"
md_path.write_text("\n".join(md) + "\n", encoding="utf-8")
written["markdown"] = md_path
return written