Track backtest reports in git; add a report comparison tool

The reports are the evidence behind the production baseline, so they belong
next to the README that quotes them rather than living only on one machine.
Un-ignores reports/*.json (~2.6 MB compressed for all 11); the snapshot DBs
they run against stay ignored.

Renames the reports to a single dated scheme so they sort chronologically and
say what they measured. Each name is derived from the report's own contents
(the atr_trail_sweep / regime_overlay / blue_sky_projected / sizing_test
sections, and the qualified counts that identify the A/B arms), not from the
ad-hoc slugs they carried before. The run the README quotes is now
backtest-20260711-prod-baseline.json.

reports/compare_reports.py loads every report into one sortable table
(portfolio monitor, entry variants, exit policies, portfolio sim), filters by
report and lookback, and highlights the best row for a chosen metric — max
drawdown correctly ranking lowest-as-best. Stdlib tkinter, no dependencies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 10:04:13 +02:00
co-authored by Claude Opus 4.8
parent 88527f39b6
commit fa26ec3ec4
13 changed files with 1152743 additions and 1 deletions
+281
View File
@@ -0,0 +1,281 @@
"""Compare backtest report JSONs side by side.
Reads every backtest-*.json in this folder and shows one table of runs across
all of them, so a strategy can be compared across reports (or reports against
each other) without hand-diffing JSON. Highlights the best row for the chosen
metric. Stdlib only (tkinter) — run it with any Python 3:
python reports/compare_reports.py
"""
from __future__ import annotations
import glob
import json
import os
import tkinter as tk
from tkinter import ttk
HERE = os.path.dirname(os.path.abspath(__file__))
# Each report section exposes the same portfolio metrics; only the key naming
# the run (and whether it has lookbacks) differs.
SECTIONS = {
"Portfolio monitor (prod strategies)": {
"path": ("portfolio_monitor", "runs"),
"name_key": "strategy",
"lookbacks": True,
},
"Entry variants": {
"path": ("strategy_variants", "variants"),
"name_key": "variant",
"lookbacks": False,
},
"Exit policies": {
"path": ("exit_policy_variants", "variants"),
"name_key": "exit_policy",
"lookbacks": False,
},
"Portfolio sim (policies)": {
"path": ("portfolio_sim", "policies"),
"name_key": "policy",
"lookbacks": False,
},
}
# label, json key, format, higher_is_better (None = not rankable)
COLUMNS = [
("Report", "_report", "{}", None),
("Run", "_name", "{}", None),
("Lookback", "_lookback", "{}", None),
("CAGR %", "cagr_pct", "{:+.1f}", True),
("Max DD %", "max_drawdown_pct", "-{:.1f}", False),
("Sharpe", "sharpe", "{:.2f}", True),
("Total ret %", "total_return_pct", "{:+.1f}", True),
("SPY %", "spy_return_pct", "{:+.1f}", None),
("Trades", "trades", "{:.0f}", None),
("Win %", "win_rate", "{:.1f}", True),
("Hold d", "avg_hold_days", "{:.1f}", None),
]
RANKABLE = [c[0] for c in COLUMNS if c[3] is not None]
def load_reports() -> list[dict]:
reports = []
for path in sorted(glob.glob(os.path.join(HERE, "backtest-*.json"))):
try:
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
print(f"skipping {os.path.basename(path)}: {exc}")
continue
name = os.path.basename(path)[len("backtest-") : -len(".json")]
reports.append({
"name": name,
"data": data,
"generated": (data.get("generated_at") or "")[:16].replace("T", " "),
"qualified": data.get("qualified"),
})
return reports
def rows_for(report: dict, section: str) -> list[dict]:
"""Flatten one report's section into rows, tagging report/run/lookback."""
cfg = SECTIONS[section]
top, inner = cfg["path"]
block = report["data"].get(top) or {}
raw = block.get(inner) or []
rows = []
for item in raw:
if not isinstance(item, dict):
continue
row = dict(item)
row["_report"] = report["name"]
row["_name"] = item.get(cfg["name_key"]) or "?"
row["_lookback"] = item.get("lookback") or "-"
row["_production"] = bool(item.get("is_production"))
rows.append(row)
return rows
class App:
def __init__(self, root: tk.Tk, reports: list[dict]) -> None:
self.root = root
self.reports = reports
self.rows: list[dict] = []
root.title("Backtest report comparison")
root.geometry("1250x680")
controls = ttk.Frame(root, padding=8)
controls.pack(fill="x")
ttk.Label(controls, text="Section").pack(side="left")
self.section = ttk.Combobox(
controls, values=list(SECTIONS), state="readonly", width=32
)
self.section.current(0)
self.section.pack(side="left", padx=(4, 14))
self.section.bind("<<ComboboxSelected>>", lambda _e: self.on_section())
ttk.Label(controls, text="Lookback").pack(side="left")
self.lookback = ttk.Combobox(controls, state="readonly", width=10)
self.lookback.pack(side="left", padx=(4, 14))
self.lookback.bind("<<ComboboxSelected>>", lambda _e: self.refresh())
ttk.Label(controls, text="Best by").pack(side="left")
self.metric = ttk.Combobox(
controls, values=RANKABLE, state="readonly", width=12
)
self.metric.set("Sharpe")
self.metric.pack(side="left", padx=(4, 14))
self.metric.bind("<<ComboboxSelected>>", lambda _e: self.refresh())
body = ttk.Frame(root, padding=(8, 0))
body.pack(fill="both", expand=True)
left = ttk.Frame(body)
left.pack(side="left", fill="y", padx=(0, 8))
ttk.Label(left, text="Reports (select to filter)").pack(anchor="w")
self.report_list = tk.Listbox(
left, selectmode="extended", width=34, height=24, exportselection=False
)
for rep in self.reports:
label = f"{rep['name']} ({rep['generated'][5:]}"
label += f", q={rep['qualified']})" if rep["qualified"] else ")"
self.report_list.insert("end", label)
self.report_list.select_set(0, "end")
self.report_list.pack(fill="y", expand=True)
self.report_list.bind("<<ListboxSelect>>", lambda _e: self.refresh())
headers = [c[0] for c in COLUMNS]
self.tree = ttk.Treeview(body, columns=headers, show="headings")
for label, _key, _fmt, _hib in COLUMNS:
self.tree.heading(
label, text=label, command=lambda c=label: self.sort_by(c)
)
width = 190 if label == "Run" else (150 if label == "Report" else 82)
self.tree.column(label, width=width, anchor="w" if width > 100 else "e")
scroll = ttk.Scrollbar(body, orient="vertical", command=self.tree.yview)
self.tree.configure(yscrollcommand=scroll.set)
self.tree.pack(side="left", fill="both", expand=True)
scroll.pack(side="left", fill="y")
self.tree.tag_configure("best", background="#c8e6c9")
self.tree.tag_configure("prod", font=("TkDefaultFont", 9, "bold"))
self.status = ttk.Label(root, padding=8, anchor="w")
self.status.pack(fill="x")
self.sort_col: str | None = None
self.sort_desc = True
self.on_section()
def selected_reports(self) -> list[dict]:
picked = self.report_list.curselection()
return [self.reports[i] for i in picked] if picked else self.reports
def on_section(self) -> None:
cfg = SECTIONS[self.section.get()]
if cfg["lookbacks"]:
seen: list[str] = []
for rep in self.reports:
for row in rows_for(rep, self.section.get()):
if row["_lookback"] not in seen:
seen.append(row["_lookback"])
self.lookback.configure(values=["(all rows)"] + seen, state="readonly")
self.lookback.set("all" if "all" in seen else "(all rows)")
else:
self.lookback.configure(values=["(n/a)"], state="disabled")
self.lookback.set("(n/a)")
self.sort_col = None
self.refresh()
def refresh(self) -> None:
section = self.section.get()
rows: list[dict] = []
missing: list[str] = []
for rep in self.selected_reports():
found = rows_for(rep, section)
if not found:
missing.append(rep["name"])
rows.extend(found)
chosen = self.lookback.get()
if SECTIONS[section]["lookbacks"] and chosen not in ("(all rows)", "(n/a)"):
rows = [r for r in rows if r["_lookback"] == chosen]
metric_label = self.metric.get()
key, higher = next(
(c[1], c[3]) for c in COLUMNS if c[0] == metric_label
)
ranked = [r for r in rows if isinstance(r.get(key), (int, float))]
best = None
if ranked:
best = (max if higher else min)(ranked, key=lambda r: r[key])
sort_col = self.sort_col or metric_label
sort_key, sort_hib = next(
(c[1], c[3]) for c in COLUMNS if c[0] == sort_col
)
if self.sort_col is None:
# Default order: best value first for the chosen metric.
reverse = bool(higher)
else:
reverse = self.sort_desc
def sort_value(row: dict):
val = row.get(sort_key)
if isinstance(val, (int, float)):
return (1, val, "")
return (0, 0.0, str(val or ""))
rows.sort(key=sort_value, reverse=reverse)
self.tree.delete(*self.tree.get_children())
for row in rows:
values = []
for label, k, fmt, _hib in COLUMNS:
val = row.get(k)
if isinstance(val, (int, float)):
values.append(fmt.format(val))
else:
values.append("-" if val in (None, "") else str(val))
tags = []
if best is not None and row is best:
tags.append("best")
if row.get("_production"):
tags.append("prod")
self.tree.insert("", "end", values=values, tags=tags)
self.rows = rows
parts = [f"{len(rows)} runs from {len(self.selected_reports())} report(s)"]
if best is not None:
parts.append(
f"best {metric_label}: {best['_name']} "
f"({best['_report']}) = {best[key]:.2f}"
)
if missing:
parts.append(f"no '{section}' data in: {', '.join(missing)}")
parts.append("bold = production row")
self.status.configure(text=" | ".join(parts))
def sort_by(self, column: str) -> None:
if self.sort_col == column:
self.sort_desc = not self.sort_desc
else:
self.sort_col = column
self.sort_desc = True
self.refresh()
def main() -> None:
reports = load_reports()
if not reports:
raise SystemExit(f"No backtest-*.json found in {HERE}")
root = tk.Tk()
App(root, reports)
root.mainloop()
if __name__ == "__main__":
main()