Files
dennisthiessen 57ac1d2cdd Replace compare_reports with a full backtest report explorer.
Browse, drill, and compare all report sections (not just four tables), overlay equity curves, and sort reports by generated_at so the newest run is always on top.
2026-07-12 19:27:02 +02:00

1708 lines
65 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Backtest report explorer — browse, drill, and compare report JSONs.
Stdlib only (tkinter). Launch from repo root or this folder:
python reports/compare_reports.py
"""
from __future__ import annotations
import glob
import json
import os
import tkinter as tk
from tkinter import font as tkfont
from tkinter import messagebox, ttk
from typing import Any, Callable
HERE = os.path.dirname(os.path.abspath(__file__))
# ---------------------------------------------------------------------------
# Theme
# ---------------------------------------------------------------------------
THEME = {
"bg": "#0f1419",
"panel": "#1a2332",
"panel2": "#243044",
"border": "#2d3a4f",
"text": "#e7ecf3",
"muted": "#8b9bb4",
"accent": "#3b82f6",
"accent2": "#60a5fa",
"good": "#22c55e",
"good_bg": "#14532d",
"bad": "#ef4444",
"warn": "#f59e0b",
"prod": "#fbbf24",
"row_alt": "#15202b",
"select": "#1e3a5f",
"chart_bg": "#0b1220",
"grid": "#1e293b",
}
CHART_COLORS = [
"#3b82f6",
"#22c55e",
"#f59e0b",
"#a78bfa",
"#f472b6",
"#2dd4bf",
"#fb7185",
"#94a3b8",
"#eab308",
"#38bdf8",
]
# ---------------------------------------------------------------------------
# Column / section schema
# ---------------------------------------------------------------------------
# label, key, format, higher_is_better (None = not rankable)
PORTFOLIO_COLS = [
("CAGR %", "cagr_pct", "{:+.1f}", True),
# Stored as positive magnitude in reports; display as a drawdown.
("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),
("Avg PnL", "avg_trade_pnl", "{:+.1f}", True),
("Hold d", "avg_hold_days", "{:.1f}", None),
("Best R", "best_trade_r", "{:+.2f}", None),
("Worst R", "worst_trade_r", "{:+.2f}", None),
]
TRADE_STATS_COLS = [
("Total", "total", "{:.0f}", None),
("Wins", "wins", "{:.0f}", None),
("Losses", "losses", "{:.0f}", None),
("Expired", "expired", "{:.0f}", None),
("Hit %", "hit_rate", "{:.1f}", True),
("Win %", "win_rate", "{:.1f}", True),
("Avg R", "avg_r", "{:+.3f}", True),
("Net avg R", "net_avg_r", "{:+.3f}", True),
("Net total R", "net_total_r", "{:+.1f}", True),
("PF", "profit_factor", "{:.2f}", True),
("Net R/day", "net_r_per_day", "{:+.4f}", True),
("Median net R", "median_net_r", "{:+.3f}", None),
("Hold d", "avg_hold_days", "{:.1f}", None),
("Net ex-top5", "net_avg_r_ex_top5", "{:+.3f}", True),
("Best R", "best_r", "{:+.2f}", None),
("Worst R", "worst_r", "{:+.2f}", None),
]
SIGNAL_COLS = [
("Signal", "signal", "{}", None),
("Weeks", "weeks", "{:.0f}", None),
("Cross-sec", "avg_cross_section", "{:.1f}", None),
("Mean IC", "mean_ic", "{:+.4f}", True),
("IC t-stat", "ic_t_stat", "{:+.2f}", True),
("IC>0 %", "ic_positive_pct", "{:.1f}", True),
("Q-spread", "mean_quintile_spread", "{:+.4f}", True),
("Reliable", "reliable", "{}", None),
]
# Section extractors: each returns (columns, rows, note)
# columns use same 4-tuple schema; rows are plain dicts with optional _meta keys.
def _flatten_metrics(item: dict, name_key: str | None = None) -> dict:
row = dict(item)
if name_key and name_key in item:
row["_name"] = item[name_key]
# Flatten exit_reasons into a short string
er = item.get("exit_reasons")
if isinstance(er, dict) and er:
row["exit_reasons_str"] = ", ".join(f"{k}:{v}" for k, v in sorted(er.items()))
return row
def _as_list(block: Any, *keys: str) -> list:
if block is None:
return []
if isinstance(block, list):
return block
if isinstance(block, dict):
for k in keys:
v = block.get(k)
if isinstance(v, list):
return v
return []
def extract_portfolio_monitor(data: dict) -> tuple[list, list[dict], str]:
block = data.get("portfolio_monitor") or {}
rows = []
for item in _as_list(block, "runs"):
if not isinstance(item, dict):
continue
row = _flatten_metrics(item)
row["_name"] = item.get("label") or item.get("strategy") or "?"
row["_id"] = item.get("strategy") or row["_name"]
row["_lookback"] = item.get("lookback") or "-"
row["_production"] = bool(item.get("is_production"))
rows.append(row)
cols = [
("Run", "_name", "{}", None),
("Lookback", "_lookback", "{}", None),
("Exit", "exit_policy", "{}", None),
*PORTFOLIO_COLS,
("Exits", "exit_reasons_str", "{}", None),
]
return cols, rows, block.get("note") or ""
def extract_strategy_variants(data: dict) -> tuple[list, list[dict], str]:
block = data.get("strategy_variants") or {}
rows = []
for item in _as_list(block, "variants"):
if not isinstance(item, dict):
continue
row = _flatten_metrics(item)
row["_name"] = item.get("label") or item.get("variant") or "?"
rows.append(row)
cols = [
("Variant", "_name", "{}", None),
("Ranking", "ranking", "{}", None),
("Cutoff", "cutoff", "{:.0f}", None),
("Max pos", "max_positions", "{:.0f}", None),
*PORTFOLIO_COLS,
]
return cols, rows, block.get("note") or ""
def extract_exit_policies(data: dict) -> tuple[list, list[dict], str]:
block = data.get("exit_policy_variants") or {}
rows = []
for item in _as_list(block, "variants"):
if not isinstance(item, dict):
continue
row = _flatten_metrics(item)
row["_name"] = item.get("label") or item.get("exit_policy") or "?"
rows.append(row)
cols = [
("Exit policy", "_name", "{}", None),
*PORTFOLIO_COLS,
("Exits", "exit_reasons_str", "{}", None),
]
return cols, rows, block.get("note") or ""
def extract_portfolio_sim(data: dict) -> tuple[list, list[dict], str]:
block = data.get("portfolio_sim") or {}
rows = []
for item in _as_list(block, "policies"):
if not isinstance(item, dict):
continue
row = _flatten_metrics(item)
row["_name"] = item.get("label") or item.get("policy") or "?"
rows.append(row)
cols = [
("Policy", "_name", "{}", None),
*PORTFOLIO_COLS,
("Exits", "exit_reasons_str", "{}", None),
]
note = block.get("note") or ""
params = block.get("params")
if isinstance(params, dict) and params:
note = (note + " | params: " + json.dumps(params)).strip(" |")
return cols, rows, note
def extract_sweep(data: dict) -> tuple[list, list[dict], str]:
rows = []
for item in data.get("sweep") or []:
if not isinstance(item, dict):
continue
row = dict(item)
row["_name"] = f"p{item.get('min_momentum_percentile', '?')}"
rows.append(row)
cols = [
("Cutoff", "min_momentum_percentile", "{:.0f}", None),
*TRADE_STATS_COLS,
]
return cols, rows, "Residual-momentum percentile sweep (trade-level expectancy)."
def extract_gate_ablation(data: dict) -> tuple[list, list[dict], str]:
rows = []
for item in data.get("gate_ablation") or []:
if not isinstance(item, dict):
continue
row = dict(item)
row["_name"] = item.get("variant") or "?"
rows.append(row)
cols = [
("Variant", "_name", "{}", None),
*TRADE_STATS_COLS,
("Hold net R", "hold_net_avg_r", "{:+.3f}", True),
("Hold total R", "hold_total_r", "{:+.1f}", True),
]
return cols, rows, data.get("gate_ablation_note") or ""
def extract_time_exit(data: dict) -> tuple[list, list[dict], str]:
rows = []
for item in data.get("time_exit_sweep") or []:
if not isinstance(item, dict):
continue
row = dict(item)
row["_name"] = f"{item.get('hold_days', '?')}d"
rows.append(row)
cols = [
("Hold days", "hold_days", "{:.0f}", None),
*TRADE_STATS_COLS,
]
return cols, rows, "Fixed-hold exit sweep on qualified setups."
def extract_signal_eval(data: dict) -> tuple[list, list[dict], str]:
rows = []
for item in data.get("signal_eval") or []:
if not isinstance(item, dict):
continue
row = dict(item)
row["_name"] = item.get("signal") or "?"
rows.append(row)
return SIGNAL_COLS, rows, data.get("signal_eval_note") or ""
def extract_overall(data: dict) -> tuple[list, list[dict], str]:
rows = []
for key, label in (
("overall_qualified", "Qualified"),
("overall_all", "All setups"),
):
block = data.get(key)
if isinstance(block, dict):
row = dict(block)
row["_name"] = label
rows.append(row)
by_dir = data.get("by_direction") or {}
if isinstance(by_dir, dict):
for direction, block in by_dir.items():
if isinstance(block, dict):
row = dict(block)
row["_name"] = f"Direction: {direction}"
rows.append(row)
cols = [("Bucket", "_name", "{}", None), *TRADE_STATS_COLS]
return cols, rows, "Trade-level expectancy: qualified gate vs all candidates vs by direction."
def extract_holdout(data: dict) -> tuple[list, list[dict], str]:
block = data.get("holdout")
if not isinstance(block, dict):
return [], [], ""
rows = []
for item in block.get("rows") or []:
if not isinstance(item, dict):
continue
row = _flatten_metrics(item)
row["_name"] = item.get("window") or "?"
rows.append(row)
cols = [
("Window", "_name", "{}", None),
("Exit", "exit_policy", "{}", None),
("Start", "start_date", "{}", None),
("End", "end_date", "{}", None),
*PORTFOLIO_COLS,
]
note = block.get("note") or ""
split = block.get("split_date")
strategy = block.get("strategy")
if split or strategy:
note = f"split={split} strategy={strategy} | {note}".strip(" |")
return cols, rows, note
def extract_min_rr_sweep(data: dict) -> tuple[list, list[dict], str]:
block = data.get("min_rr_sweep")
if not isinstance(block, dict):
return [], [], ""
rows = []
for item in block.get("rows") or []:
if not isinstance(item, dict):
continue
row = _flatten_metrics(item)
row["_name"] = f"RR≥{item.get('min_rr', '?')}"
row["_production"] = bool(item.get("is_live"))
rows.append(row)
cols = [
("Min R:R", "min_rr", "{:.1f}", None),
("Live", "is_live", "{}", None),
("Qualified", "qualified_setups", "{:.0f}", None),
*PORTFOLIO_COLS,
]
note = block.get("note") or ""
meta = []
if block.get("live_min_rr") is not None:
meta.append(f"live_min_rr={block['live_min_rr']}")
if block.get("window"):
meta.append(f"window={block['window']}")
if block.get("entries_from"):
meta.append(f"entries_from={block['entries_from']}")
if meta:
note = " ".join(meta) + (" | " + note if note else "")
return cols, rows, note
def extract_atr_trail_sweep(data: dict) -> tuple[list, list[dict], str]:
rows = []
for item in data.get("atr_trail_sweep") or []:
if not isinstance(item, dict):
continue
row = _flatten_metrics(item)
row["_name"] = f"ATR×{item.get('atr_mult', '?')}"
rows.append(row)
cols = [("ATR mult", "atr_mult", "{:.1f}", None), *PORTFOLIO_COLS]
return cols, rows, "ATR trailing-stop multiplier sweep."
def extract_regime_overlay(data: dict) -> tuple[list, list[dict], str]:
rows = []
for item in data.get("regime_overlay") or []:
if not isinstance(item, dict):
continue
row = _flatten_metrics(item)
row["_name"] = str(item.get("regime_filter") or "?")
rows.append(row)
cols = [("Regime filter", "_name", "{}", None), *PORTFOLIO_COLS]
return cols, rows, "Regime overlay comparison."
def extract_cutoff_book(data: dict) -> tuple[list, list[dict], str]:
rows = []
for item in data.get("cutoff_book_sweep") or []:
if not isinstance(item, dict):
continue
row = _flatten_metrics(item)
row["_name"] = f"cut{item.get('cutoff', '?')}/max{item.get('max_positions', '?')}"
rows.append(row)
cols = [
("Cutoff", "cutoff", "{:.0f}", None),
("Max pos", "max_positions", "{:.0f}", None),
*PORTFOLIO_COLS,
]
return cols, rows, "Momentum cutoff × book-size grid."
def extract_sizing(data: dict) -> tuple[list, list[dict], str]:
rows = []
for item in data.get("sizing_test") or []:
if not isinstance(item, dict):
continue
row = _flatten_metrics(item)
row["_name"] = str(item.get("sizing_mode") or "?")
rows.append(row)
cols = [
("Sizing", "_name", "{}", None),
("Risk/trade", "risk_per_trade", "{:.2f}", None),
("Avg exposure %", "avg_exposure_pct", "{:.1f}", None),
*PORTFOLIO_COLS,
]
return cols, rows, "Position sizing modes."
def extract_blue_sky(data: dict) -> tuple[list, list[dict], str]:
block = data.get("blue_sky_projected")
if not isinstance(block, dict):
return [], [], ""
rows = []
for key, label in (
("qualified_stats", "Qualified stats"),
("generated_stats", "Generated stats"),
("portfolio_atr_trail3", "Portfolio ATR×3"),
):
item = block.get(key)
if isinstance(item, dict):
row = _flatten_metrics(item)
row["_name"] = label
rows.append(row)
# summary counts as a pseudo-row
if block.get("generated") is not None or block.get("qualified") is not None:
rows.insert(
0,
{
"_name": "Counts",
"generated": block.get("generated"),
"qualified": block.get("qualified"),
},
)
cols = [
("Bucket", "_name", "{}", None),
("Generated", "generated", "{:.0f}", None),
("Qualified", "qualified", "{:.0f}", None),
*PORTFOLIO_COLS,
*TRADE_STATS_COLS,
]
# de-dupe column keys keeping first
seen: set[str] = set()
uniq = []
for c in cols:
if c[1] not in seen:
seen.add(c[1])
uniq.append(c)
return uniq, rows, "Blue-sky target projection experiment."
SECTIONS: dict[str, Callable[[dict], tuple[list, list[dict], str]]] = {
"Overview (expectancy)": extract_overall,
"Portfolio monitor": extract_portfolio_monitor,
"Strategy variants": extract_strategy_variants,
"Exit policies": extract_exit_policies,
"Portfolio sim": extract_portfolio_sim,
"Momentum sweep": extract_sweep,
"Gate ablation": extract_gate_ablation,
"Time-exit sweep": extract_time_exit,
"Signal edge (IC)": extract_signal_eval,
"Holdout (train/test)": extract_holdout,
"Min R:R sweep": extract_min_rr_sweep,
"ATR trail sweep": extract_atr_trail_sweep,
"Regime overlay": extract_regime_overlay,
"Cutoff × book size": extract_cutoff_book,
"Position sizing": extract_sizing,
"Blue-sky targets": extract_blue_sky,
}
DEFAULT_SECTION = "Portfolio monitor"
# ---------------------------------------------------------------------------
# Data loading
# ---------------------------------------------------------------------------
def load_reports(folder: str | None = None) -> list[dict]:
folder = folder or HERE
reports: list[dict] = []
for path in glob.glob(os.path.join(folder, "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
base = os.path.basename(path)
slug = base[len("backtest-") : -len(".json")] if base.startswith("backtest-") else base
prod = _production_headline(data)
generated_at = data.get("generated_at") or ""
reports.append(
{
"path": path,
"file": base,
"slug": slug,
"data": data,
# Full ISO timestamp for correct newest-first ordering (date+time).
"generated_at": generated_at,
"generated": generated_at[:16].replace("T", " ") if generated_at else "",
"mtime": os.path.getmtime(path),
"qualified": data.get("qualified"),
"candidates": data.get("candidates"),
"tickers": data.get("tickers"),
"headline": (data.get("recommendation") or {}).get("headline") or "",
"prod": prod,
}
)
# Newest first: prefer report generated_at, fall back to file mtime.
reports.sort(
key=lambda r: (r.get("generated_at") or "", r.get("mtime") or 0),
reverse=True,
)
return reports
def _production_headline(data: dict) -> dict:
"""Pull production 'all' lookback metrics if present."""
block = data.get("portfolio_monitor") or {}
for item in _as_list(block, "runs"):
if (
isinstance(item, dict)
and item.get("is_production")
and item.get("lookback") in ("all", None)
):
return {
"cagr": item.get("cagr_pct"),
"sharpe": item.get("sharpe"),
"dd": item.get("max_drawdown_pct"),
"ret": item.get("total_return_pct"),
"spy": item.get("spy_return_pct"),
"trades": item.get("trades"),
"strategy": item.get("strategy") or item.get("label"),
}
# fallback: first production any lookback
for item in _as_list(block, "runs"):
if isinstance(item, dict) and item.get("is_production"):
return {
"cagr": item.get("cagr_pct"),
"sharpe": item.get("sharpe"),
"dd": item.get("max_drawdown_pct"),
"ret": item.get("total_return_pct"),
"spy": item.get("spy_return_pct"),
"trades": item.get("trades"),
"strategy": item.get("strategy") or item.get("label"),
}
return {}
def fmt_val(val: Any, fmt: str) -> str:
if val is None or val == "":
return "—"
if isinstance(val, bool):
return "yes" if val else "no"
if isinstance(val, (int, float)):
try:
return fmt.format(val)
except (ValueError, TypeError):
return str(val)
return str(val)
def numeric_sort_key(val: Any, reverse: bool) -> tuple:
if isinstance(val, bool):
return (1, float(val))
if isinstance(val, (int, float)):
return (1, float(val))
s = str(val or "")
return (0, s.lower()) if not reverse else (0, s.lower())
# ---------------------------------------------------------------------------
# UI helpers
# ---------------------------------------------------------------------------
class SortableTree(ttk.Treeview):
"""Treeview with click-to-sort and optional best-row highlight."""
def __init__(self, master, **kwargs):
super().__init__(master, **kwargs)
self._rows: list[dict] = []
self._cols_meta: list[tuple] = []
self._sort_col: str | None = None
self._sort_desc = True
self._best_key: str | None = None
self._best_higher: bool | None = None
self.bind("<Double-1>", self._on_double)
def set_columns(self, columns: list[tuple]) -> None:
self._cols_meta = columns
labels = [c[0] for c in columns]
self["columns"] = labels
self["show"] = "headings"
for label, key, _fmt, hib in columns:
self.heading(label, text=label, command=lambda c=label: self.sort_by(c))
# width heuristics
if key in ("_name", "label", "description", "variant", "signal"):
w, anchor = 220, "w"
elif key in ("_report", "exit_reasons_str"):
w, anchor = 160, "w"
elif key in ("_lookback", "lookback"):
w, anchor = 70, "center"
else:
w, anchor = 88, "e"
self.column(label, width=w, minwidth=50, anchor=anchor, stretch=True)
def populate(
self,
rows: list[dict],
best_key: str | None = None,
best_higher: bool | None = None,
default_sort: str | None = None,
) -> None:
self._rows = list(rows)
self._best_key = best_key
self._best_higher = best_higher
if default_sort and self._sort_col is None:
self._sort_col = default_sort
self._sort_desc = True
self._render()
def sort_by(self, label: str) -> None:
if self._sort_col == label:
self._sort_desc = not self._sort_desc
else:
self._sort_col = label
self._sort_desc = True
self._render()
def _col_by_label(self, label: str) -> tuple | None:
for c in self._cols_meta:
if c[0] == label:
return c
return None
def _render(self) -> None:
self.delete(*self.get_children())
rows = list(self._rows)
sort_label = self._sort_col
reverse = self._sort_desc
if sort_label:
meta = self._col_by_label(sort_label)
if meta:
key = meta[1]
rows.sort(
key=lambda r: numeric_sort_key(r.get(key), reverse),
reverse=reverse,
)
best_id = None
if self._best_key and self._best_higher is not None:
ranked = [
r
for r in rows
if isinstance(r.get(self._best_key), (int, float))
]
if ranked:
best = (max if self._best_higher else min)(
ranked, key=lambda r: r[self._best_key]
)
best_id = id(best)
for i, row in enumerate(rows):
values = []
for label, key, fmt, _hib in self._cols_meta:
values.append(fmt_val(row.get(key), fmt))
tags = []
if i % 2:
tags.append("alt")
if row.get("_production"):
tags.append("prod")
if best_id is not None and id(row) == best_id:
tags.append("best")
self.insert("", "end", values=values, tags=tags, iid=str(i))
self._display_rows = rows
def selected_row(self) -> dict | None:
sel = self.selection()
if not sel:
return None
try:
idx = int(sel[0])
return self._display_rows[idx]
except (ValueError, IndexError, AttributeError):
return None
def _on_double(self, _event=None) -> None:
# bubbles via virtual event
self.event_generate("<<RowActivate>>")
def apply_theme(root: tk.Tk) -> None:
style = ttk.Style(root)
try:
style.theme_use("clam")
except tk.TclError:
pass
bg = THEME["bg"]
panel = THEME["panel"]
text = THEME["text"]
muted = THEME["muted"]
accent = THEME["accent"]
border = THEME["border"]
select = THEME["select"]
root.configure(bg=bg)
style.configure(".", background=bg, foreground=text, fieldbackground=panel, bordercolor=border)
style.configure("TFrame", background=bg)
style.configure("Panel.TFrame", background=panel)
style.configure("TLabel", background=bg, foreground=text)
style.configure("Muted.TLabel", background=bg, foreground=muted)
style.configure("Panel.TLabel", background=panel, foreground=text)
style.configure("Title.TLabel", background=bg, foreground=text, font=("Segoe UI", 14, "bold"))
style.configure("Header.TLabel", background=panel, foreground=text, font=("Segoe UI", 11, "bold"))
style.configure("CardValue.TLabel", background=panel, foreground=THEME["accent2"], font=("Segoe UI", 16, "bold"))
style.configure("CardLabel.TLabel", background=panel, foreground=muted, font=("Segoe UI", 8))
style.configure("Good.TLabel", background=bg, foreground=THEME["good"])
style.configure("Bad.TLabel", background=bg, foreground=THEME["bad"])
style.configure("TButton", background=panel, foreground=text, borderwidth=1, focusthickness=0, padding=(10, 5))
style.map("TButton", background=[("active", THEME["panel2"])], foreground=[("active", text)])
style.configure("Accent.TButton", background=accent, foreground="#fff")
style.map("Accent.TButton", background=[("active", THEME["accent2"])])
style.configure("TNotebook", background=bg, borderwidth=0)
style.configure("TNotebook.Tab", background=panel, foreground=muted, padding=(14, 6), borderwidth=0)
style.map(
"TNotebook.Tab",
background=[("selected", THEME["panel2"])],
foreground=[("selected", text)],
)
style.configure(
"Treeview",
background=panel,
foreground=text,
fieldbackground=panel,
borderwidth=0,
rowheight=26,
font=("Segoe UI", 9),
)
style.configure(
"Treeview.Heading",
background=THEME["panel2"],
foreground=muted,
relief="flat",
font=("Segoe UI", 9, "bold"),
)
style.map("Treeview", background=[("selected", select)], foreground=[("selected", text)])
style.configure("TCombobox", fieldbackground=panel, background=panel, foreground=text, arrowcolor=text)
style.map("TCombobox", fieldbackground=[("readonly", panel)], foreground=[("readonly", text)])
style.configure("TEntry", fieldbackground=panel, foreground=text, insertcolor=text)
style.configure("TCheckbutton", background=bg, foreground=text)
style.configure("TRadiobutton", background=bg, foreground=text)
style.configure("TSeparator", background=border)
style.configure("Vertical.TScrollbar", background=panel, troughcolor=bg, bordercolor=border, arrowcolor=muted)
style.configure("Horizontal.TScrollbar", background=panel, troughcolor=bg, bordercolor=border, arrowcolor=muted)
style.configure("TPanedwindow", background=bg)
style.configure("Status.TLabel", background=THEME["panel2"], foreground=muted, padding=(10, 6))
# ---------------------------------------------------------------------------
# Main application
# ---------------------------------------------------------------------------
class App:
def __init__(self, root: tk.Tk, reports: list[dict]) -> None:
self.root = root
self.reports = reports
self._report_by_slug = {r["slug"]: r for r in reports}
self._selected_slugs: set[str] = {r["slug"] for r in reports}
self._focus_slug: str | None = reports[0]["slug"] if reports else None
self._detail_row: dict | None = None
root.title("Backtest Report Explorer")
root.geometry("1480x900")
root.minsize(1100, 700)
apply_theme(root)
self._build()
self._populate_report_list()
self.refresh_all()
# ---- layout -----------------------------------------------------------
def _build(self) -> None:
# Top bar
top = ttk.Frame(self.root, padding=(12, 10))
top.pack(fill="x")
ttk.Label(top, text="Backtest Report Explorer", style="Title.TLabel").pack(side="left")
ttk.Label(
top,
text=f" · {len(self.reports)} reports in reports/",
style="Muted.TLabel",
).pack(side="left")
ttk.Button(top, text="Reload", command=self.reload).pack(side="right", padx=(6, 0))
ttk.Button(top, text="Select none", command=lambda: self._select_all(False)).pack(side="right", padx=2)
ttk.Button(top, text="Select all", command=lambda: self._select_all(True)).pack(side="right", padx=2)
# Body: sidebar + main
body = ttk.Panedwindow(self.root, orient="horizontal")
body.pack(fill="both", expand=True, padx=10, pady=(0, 6))
sidebar = ttk.Frame(body, style="Panel.TFrame", padding=8)
main = ttk.Frame(body)
body.add(sidebar, weight=0)
body.add(main, weight=1)
# Sidebar
ttk.Label(sidebar, text="REPORTS", style="Header.TLabel").pack(anchor="w")
search_fr = ttk.Frame(sidebar, style="Panel.TFrame")
search_fr.pack(fill="x", pady=(6, 6))
self.search_var = tk.StringVar()
self.search_var.trace_add("write", lambda *_: self._populate_report_list())
ent = ttk.Entry(search_fr, textvariable=self.search_var)
ent.pack(fill="x")
ent.insert(0, "")
# placeholder-ish hint
ttk.Label(sidebar, text="Filter by name… Click = focus, ☑ = compare", style="Muted.TLabel").pack(anchor="w")
list_fr = ttk.Frame(sidebar, style="Panel.TFrame")
list_fr.pack(fill="both", expand=True, pady=(6, 0))
self.report_canvas = tk.Canvas(list_fr, bg=THEME["panel"], highlightthickness=0, width=280)
sb = ttk.Scrollbar(list_fr, orient="vertical", command=self.report_canvas.yview)
self.report_inner = ttk.Frame(self.report_canvas, style="Panel.TFrame")
self.report_inner.bind(
"<Configure>",
lambda e: self.report_canvas.configure(scrollregion=self.report_canvas.bbox("all")),
)
self._list_window = self.report_canvas.create_window((0, 0), window=self.report_inner, anchor="nw")
self.report_canvas.configure(yscrollcommand=sb.set)
self.report_canvas.pack(side="left", fill="both", expand=True)
sb.pack(side="right", fill="y")
self.report_canvas.bind(
"<Configure>",
lambda e: self.report_canvas.itemconfigure(self._list_window, width=e.width),
)
# mousewheel when over list
self.report_canvas.bind("<Enter>", lambda _e: self.report_canvas.bind_all("<MouseWheel>", self._on_wheel))
self.report_canvas.bind("<Leave>", lambda _e: self.report_canvas.unbind_all("<MouseWheel>"))
self._check_vars: dict[str, tk.BooleanVar] = {}
# Main notebook
self.nb = ttk.Notebook(main)
self.nb.pack(fill="both", expand=True)
self.tab_browse = ttk.Frame(self.nb, padding=8)
self.tab_compare = ttk.Frame(self.nb, padding=8)
self.tab_equity = ttk.Frame(self.nb, padding=8)
self.nb.add(self.tab_browse, text=" Browse ")
self.nb.add(self.tab_compare, text=" Compare ")
self.nb.add(self.tab_equity, text=" Equity curves ")
self._build_browse()
self._build_compare()
self._build_equity()
# Status
self.status = ttk.Label(self.root, text="", style="Status.TLabel", anchor="w")
self.status.pack(fill="x", side="bottom")
def _on_wheel(self, event) -> None:
self.report_canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
# ---- report list ------------------------------------------------------
def _populate_report_list(self) -> None:
for w in self.report_inner.winfo_children():
w.destroy()
q = (self.search_var.get() or "").strip().lower()
# self.reports is already newest-first (by generated_at).
for rep in self.reports:
if q and q not in rep["slug"].lower() and q not in (rep["headline"] or "").lower():
continue
self._add_report_card(rep)
def _add_report_card(self, rep: dict) -> None:
slug = rep["slug"]
if slug not in self._check_vars:
self._check_vars[slug] = tk.BooleanVar(value=slug in self._selected_slugs)
var = self._check_vars[slug]
card = tk.Frame(
self.report_inner,
bg=THEME["panel2"] if slug == self._focus_slug else THEME["panel"],
highlightbackground=THEME["accent"] if slug == self._focus_slug else THEME["border"],
highlightthickness=1,
padx=8,
pady=6,
)
card.pack(fill="x", pady=3, padx=2)
top = tk.Frame(card, bg=card["bg"])
top.pack(fill="x")
cb = tk.Checkbutton(
top,
variable=var,
bg=card["bg"],
activebackground=card["bg"],
fg=THEME["text"],
selectcolor=THEME["panel"],
activeforeground=THEME["text"],
highlightthickness=0,
command=lambda s=slug: self._on_check(s),
)
cb.pack(side="left")
title = tk.Label(
top,
text=slug,
bg=card["bg"],
fg=THEME["text"],
font=("Segoe UI", 9, "bold"),
anchor="w",
cursor="hand2",
)
title.pack(side="left", fill="x", expand=True)
title.bind("<Button-1>", lambda _e, s=slug: self._focus(s))
card.bind("<Button-1>", lambda _e, s=slug: self._focus(s))
meta_parts = []
if rep["generated"]:
meta_parts.append(rep["generated"][5:] if len(rep["generated"]) > 5 else rep["generated"])
if rep["qualified"] is not None:
meta_parts.append(f"q={rep['qualified']}")
prod = rep.get("prod") or {}
if prod.get("sharpe") is not None:
meta_parts.append(f"Sh {prod['sharpe']:.2f}")
if prod.get("cagr") is not None:
meta_parts.append(f"CAGR {prod['cagr']:+.0f}%")
meta = tk.Label(
card,
text=" · ".join(meta_parts),
bg=card["bg"],
fg=THEME["muted"],
font=("Segoe UI", 8),
anchor="w",
cursor="hand2",
)
meta.pack(fill="x")
meta.bind("<Button-1>", lambda _e, s=slug: self._focus(s))
if rep.get("headline"):
hl = tk.Label(
card,
text=rep["headline"][:110] + ("…" if len(rep["headline"]) > 110 else ""),
bg=card["bg"],
fg=THEME["muted"],
font=("Segoe UI", 8),
anchor="w",
justify="left",
wraplength=250,
cursor="hand2",
)
hl.pack(fill="x", pady=(2, 0))
hl.bind("<Button-1>", lambda _e, s=slug: self._focus(s))
def _on_check(self, slug: str) -> None:
if self._check_vars[slug].get():
self._selected_slugs.add(slug)
else:
self._selected_slugs.discard(slug)
self.refresh_all()
def _focus(self, slug: str) -> None:
self._focus_slug = slug
# auto-include focused report in selection
if slug not in self._selected_slugs:
self._selected_slugs.add(slug)
if slug in self._check_vars:
self._check_vars[slug].set(True)
self._populate_report_list()
self.refresh_all()
def _select_all(self, on: bool) -> None:
if on:
self._selected_slugs = {r["slug"] for r in self.reports}
else:
self._selected_slugs = set()
if self._focus_slug:
self._selected_slugs.add(self._focus_slug)
for slug, var in self._check_vars.items():
var.set(slug in self._selected_slugs)
self.refresh_all()
def selected_reports(self) -> list[dict]:
return [r for r in self.reports if r["slug"] in self._selected_slugs]
def focus_report(self) -> dict | None:
if self._focus_slug and self._focus_slug in self._report_by_slug:
return self._report_by_slug[self._focus_slug]
sel = self.selected_reports()
# selected_reports preserves newest-first order; prefer first selected.
return sel[0] if sel else (self.reports[0] if self.reports else None)
# ---- Browse tab -------------------------------------------------------
def _build_browse(self) -> None:
# Header cards + meta
self.browse_header = ttk.Frame(self.tab_browse)
self.browse_header.pack(fill="x")
self.browse_title = ttk.Label(self.browse_header, text="", style="Title.TLabel")
self.browse_title.pack(anchor="w")
self.browse_sub = ttk.Label(self.browse_header, text="", style="Muted.TLabel", wraplength=1100)
self.browse_sub.pack(anchor="w", pady=(2, 6))
self.cards_fr = ttk.Frame(self.browse_header)
self.cards_fr.pack(fill="x", pady=(0, 8))
# Section selector + table + detail
ctrl = ttk.Frame(self.tab_browse)
ctrl.pack(fill="x", pady=(0, 6))
ttk.Label(ctrl, text="Section").pack(side="left")
self.browse_section = ttk.Combobox(
ctrl, values=list(SECTIONS), state="readonly", width=28
)
self.browse_section.set(DEFAULT_SECTION)
self.browse_section.pack(side="left", padx=(6, 12))
self.browse_section.bind("<<ComboboxSelected>>", lambda _e: self.refresh_browse())
ttk.Label(ctrl, text="Lookback").pack(side="left")
self.browse_lookback = ttk.Combobox(ctrl, state="readonly", width=12)
self.browse_lookback.pack(side="left", padx=(6, 12))
self.browse_lookback.bind("<<ComboboxSelected>>", lambda _e: self.refresh_browse())
ttk.Label(ctrl, text="Highlight best by").pack(side="left")
self.browse_metric = ttk.Combobox(ctrl, state="readonly", width=14)
self.browse_metric.pack(side="left", padx=(6, 0))
self.browse_metric.bind("<<ComboboxSelected>>", lambda _e: self.refresh_browse())
self.browse_note = ttk.Label(self.tab_browse, text="", style="Muted.TLabel", wraplength=1200)
self.browse_note.pack(anchor="w", pady=(0, 4))
# split table / detail
paned = ttk.Panedwindow(self.tab_browse, orient="vertical")
paned.pack(fill="both", expand=True)
table_fr = ttk.Frame(paned)
detail_fr = ttk.Frame(paned, style="Panel.TFrame", padding=8)
paned.add(table_fr, weight=3)
paned.add(detail_fr, weight=1)
self.browse_tree = SortableTree(table_fr)
ysb = ttk.Scrollbar(table_fr, orient="vertical", command=self.browse_tree.yview)
xsb = ttk.Scrollbar(table_fr, orient="horizontal", command=self.browse_tree.xview)
self.browse_tree.configure(yscrollcommand=ysb.set, xscrollcommand=xsb.set)
self.browse_tree.grid(row=0, column=0, sticky="nsew")
ysb.grid(row=0, column=1, sticky="ns")
xsb.grid(row=1, column=0, sticky="ew")
table_fr.rowconfigure(0, weight=1)
table_fr.columnconfigure(0, weight=1)
self.browse_tree.tag_configure("best", background=THEME["good_bg"])
self.browse_tree.tag_configure("prod", foreground=THEME["prod"])
self.browse_tree.tag_configure("alt", background=THEME["row_alt"])
self.browse_tree.bind("<<TreeviewSelect>>", lambda _e: self._show_detail(self.browse_tree))
self.browse_tree.bind("<<RowActivate>>", lambda _e: self._show_detail(self.browse_tree))
ttk.Label(detail_fr, text="ROW DETAIL (select a row)", style="Header.TLabel").pack(anchor="w")
self.detail_text = tk.Text(
detail_fr,
height=8,
wrap="word",
bg=THEME["panel"],
fg=THEME["text"],
insertbackground=THEME["text"],
relief="flat",
font=("Consolas", 9),
padx=6,
pady=6,
)
self.detail_text.pack(fill="both", expand=True, pady=(6, 0))
self.detail_text.configure(state="disabled")
# Recommendation block under cards is rebuilt in refresh
def _metric_options(self, columns: list) -> list[str]:
return [c[0] for c in columns if c[3] is not None]
def refresh_browse(self) -> None:
rep = self.focus_report()
if not rep:
self.browse_title.configure(text="No report selected")
return
data = rep["data"]
self.browse_title.configure(text=rep["slug"])
act = data.get("activation") or {}
params = data.get("params") or {}
act_bits = [
f"mom≥{act.get('min_momentum_percentile')}",
f"RR≥{act.get('min_rr')}",
f"conf≥{act.get('min_confidence')}",
]
if act.get("exclude_neutral"):
act_bits.append("excl neutral")
if act.get("exclude_conflicts"):
act_bits.append("excl conflicts")
sub = (
f"{rep['generated']} · {rep.get('tickers') or '?'} tickers · "
f"{rep.get('candidates') or '?'} candidates · {rep.get('qualified') or '?'} qualified · "
f"activation: {', '.join(str(b) for b in act_bits)} · "
f"horizon {params.get('horizon_days')}d / cost {params.get('cost_per_side_pct')}%"
)
self.browse_sub.configure(text=sub)
# cards
for w in self.cards_fr.winfo_children():
w.destroy()
prod = rep.get("prod") or {}
oq = data.get("overall_qualified") or {}
cards = [
("CAGR", f"{prod['cagr']:+.1f}%" if prod.get("cagr") is not None else "—"),
("Sharpe", f"{prod['sharpe']:.2f}" if prod.get("sharpe") is not None else "—"),
("Max DD", f"{prod['dd']:.1f}%" if prod.get("dd") is not None else "—"),
("vs SPY", f"{(prod.get('ret') or 0) - (prod.get('spy') or 0):+.0f}pp" if prod.get("ret") is not None else "—"),
("Trades", f"{prod['trades']:.0f}" if prod.get("trades") is not None else "—"),
("Net avg R", f"{oq['net_avg_r']:+.3f}" if oq.get("net_avg_r") is not None else "—"),
("Hit rate", f"{oq['hit_rate']:.1f}%" if oq.get("hit_rate") is not None else "—"),
("PF", f"{oq['profit_factor']:.2f}" if oq.get("profit_factor") is not None else "—"),
]
for label, value in cards:
self._metric_card(self.cards_fr, label, value)
# recommendation strip
rec = data.get("recommendation") or {}
if rec.get("headline"):
# store as sub already has meta; append note area in browse_note partially
pass
section = self.browse_section.get() or DEFAULT_SECTION
extractor = SECTIONS.get(section)
if not extractor:
return
columns, rows, note = extractor(data)
# lookbacks
lookbacks = sorted({r.get("_lookback") for r in rows if r.get("_lookback") and r.get("_lookback") != "-"})
if lookbacks:
vals = ["(all)"] + lookbacks
self.browse_lookback.configure(values=vals, state="readonly")
if self.browse_lookback.get() not in vals:
# prefer "all" lookback if present
self.browse_lookback.set("all" if "all" in lookbacks else "(all)")
chosen = self.browse_lookback.get()
if chosen not in ("(all)", "", "(n/a)"):
rows = [r for r in rows if r.get("_lookback") == chosen]
else:
self.browse_lookback.configure(values=["(n/a)"], state="disabled")
self.browse_lookback.set("(n/a)")
# metrics
metrics = self._metric_options(columns)
self.browse_metric.configure(values=metrics or ["—"])
if metrics:
if self.browse_metric.get() not in metrics:
# prefer Sharpe / Net avg R
for pref in ("Sharpe", "Net avg R", "CAGR %", "Mean IC"):
if pref in metrics:
self.browse_metric.set(pref)
break
else:
self.browse_metric.set(metrics[0])
else:
self.browse_metric.set("—")
best_key = best_higher = None
mlabel = self.browse_metric.get()
for c in columns:
if c[0] == mlabel and c[3] is not None:
best_key, best_higher = c[1], c[3]
break
# notes + recommendation
rec_items = []
if rec.get("headline"):
rec_items.append(f"★ {rec['headline']}")
note_bits = [n for n in (note, data.get("note")) if n]
self.browse_note.configure(
text=" | ".join(rec_items + note_bits) if (rec_items or note_bits) else ""
)
self.browse_tree.set_columns(columns)
self.browse_tree.populate(rows, best_key=best_key, best_higher=best_higher, default_sort=mlabel if metrics else None)
# recommendation items into detail if empty
if not self.browse_tree.selected_row():
self._set_detail(self._recommendation_text(data))
def _metric_card(self, parent: ttk.Frame, label: str, value: str) -> None:
fr = tk.Frame(parent, bg=THEME["panel"], highlightbackground=THEME["border"], highlightthickness=1, padx=12, pady=8)
fr.pack(side="left", padx=(0, 8))
tk.Label(fr, text=label.upper(), bg=THEME["panel"], fg=THEME["muted"], font=("Segoe UI", 8)).pack(anchor="w")
tk.Label(fr, text=value, bg=THEME["panel"], fg=THEME["accent2"], font=("Segoe UI", 15, "bold")).pack(anchor="w")
def _recommendation_text(self, data: dict) -> str:
lines = []
rec = data.get("recommendation") or {}
if rec.get("headline"):
lines.append(f"RECOMMENDATION\n{rec['headline']}\n")
for item in rec.get("items") or []:
if isinstance(item, dict):
topic = item.get("topic") or ""
text = item.get("text") or ""
lines.append(f" [{topic}] {text}")
research = data.get("research_recommendation") or {}
if research.get("items"):
lines.append("\nRESEARCH")
for item in research["items"]:
if isinstance(item, dict):
cand = "CANDIDATE" if item.get("candidate") else "no"
lines.append(f" ({cand}) [{item.get('topic','')}] {item.get('text','')}")
if research.get("note"):
lines.append(f" note: {research['note']}")
if rec.get("note"):
lines.append(f"\nnote: {rec['note']}")
if data.get("note"):
lines.append(f"\nreport note: {data['note']}")
return "\n".join(lines) if lines else "Select a row for detail, or see recommendation above."
def _show_detail(self, tree: SortableTree) -> None:
row = tree.selected_row()
if not row:
rep = self.focus_report()
if rep:
self._set_detail(self._recommendation_text(rep["data"]))
return
self._detail_row = row
lines = []
name = row.get("_name") or row.get("label") or row.get("strategy") or ""
if name:
lines.append(f"{name}")
if row.get("_report"):
lines.append(f"report: {row['_report']}")
lines.append("")
# key metrics first
priority = [
"strategy", "label", "description", "variant", "exit_policy", "lookback",
"cagr_pct", "max_drawdown_pct", "sharpe", "total_return_pct", "spy_return_pct",
"trades", "win_rate", "net_avg_r", "profit_factor", "mean_ic", "reliable",
"start_date", "end_date", "exit_reasons", "yearly_returns",
]
shown = set()
for k in priority:
if k in row and row[k] is not None and not str(k).startswith("_"):
lines.append(self._fmt_detail_line(k, row[k]))
shown.add(k)
# rest
for k, v in sorted(row.items()):
if k in shown or str(k).startswith("_") or v is None:
continue
if k in ("equity_curve", "benchmark_curve"):
lines.append(f"{k}: [{len(v)} points]" if isinstance(v, list) else f"{k}: {v}")
continue
lines.append(self._fmt_detail_line(k, v))
self._set_detail("\n".join(lines))
def _fmt_detail_line(self, key: str, val: Any) -> str:
if key == "yearly_returns" and isinstance(val, list):
parts = []
for y in val:
if isinstance(y, dict):
parts.append(f"{y.get('year')}: {y.get('return_pct'):+.1f}%" if y.get("return_pct") is not None else str(y))
return f"yearly_returns: {', '.join(parts)}"
if key == "exit_reasons" and isinstance(val, dict):
return "exit_reasons: " + ", ".join(f"{k}={v}" for k, v in sorted(val.items()))
if key == "filters" and isinstance(val, list):
return f"filters: {json.dumps(val)}"
if isinstance(val, float):
return f"{key}: {val:.4g}"
if isinstance(val, (dict, list)):
return f"{key}: {json.dumps(val, default=str)[:500]}"
return f"{key}: {val}"
def _set_detail(self, text: str) -> None:
self.detail_text.configure(state="normal")
self.detail_text.delete("1.0", "end")
self.detail_text.insert("1.0", text)
self.detail_text.configure(state="disabled")
# ---- Compare tab ------------------------------------------------------
def _build_compare(self) -> None:
ctrl = ttk.Frame(self.tab_compare)
ctrl.pack(fill="x", pady=(0, 6))
ttk.Label(ctrl, text="Section").pack(side="left")
self.cmp_section = ttk.Combobox(ctrl, values=list(SECTIONS), state="readonly", width=28)
self.cmp_section.set(DEFAULT_SECTION)
self.cmp_section.pack(side="left", padx=(6, 12))
self.cmp_section.bind("<<ComboboxSelected>>", lambda _e: self.refresh_compare())
ttk.Label(ctrl, text="Lookback").pack(side="left")
self.cmp_lookback = ttk.Combobox(ctrl, state="readonly", width=12)
self.cmp_lookback.pack(side="left", padx=(6, 12))
self.cmp_lookback.bind("<<ComboboxSelected>>", lambda _e: self.refresh_compare())
ttk.Label(ctrl, text="Best by").pack(side="left")
self.cmp_metric = ttk.Combobox(ctrl, state="readonly", width=14)
self.cmp_metric.pack(side="left", padx=(6, 12))
self.cmp_metric.bind("<<ComboboxSelected>>", lambda _e: self.refresh_compare())
self.cmp_prod_only = tk.BooleanVar(value=False)
ttk.Checkbutton(
ctrl, text="Production rows only", variable=self.cmp_prod_only, command=self.refresh_compare
).pack(side="left", padx=(8, 0))
self.cmp_note = ttk.Label(self.tab_compare, text="", style="Muted.TLabel", wraplength=1200)
self.cmp_note.pack(anchor="w", pady=(0, 4))
table_fr = ttk.Frame(self.tab_compare)
table_fr.pack(fill="both", expand=True)
self.cmp_tree = SortableTree(table_fr)
ysb = ttk.Scrollbar(table_fr, orient="vertical", command=self.cmp_tree.yview)
xsb = ttk.Scrollbar(table_fr, orient="horizontal", command=self.cmp_tree.xview)
self.cmp_tree.configure(yscrollcommand=ysb.set, xscrollcommand=xsb.set)
self.cmp_tree.grid(row=0, column=0, sticky="nsew")
ysb.grid(row=0, column=1, sticky="ns")
xsb.grid(row=1, column=0, sticky="ew")
table_fr.rowconfigure(0, weight=1)
table_fr.columnconfigure(0, weight=1)
self.cmp_tree.tag_configure("best", background=THEME["good_bg"])
self.cmp_tree.tag_configure("prod", foreground=THEME["prod"])
self.cmp_tree.tag_configure("alt", background=THEME["row_alt"])
def refresh_compare(self) -> None:
section = self.cmp_section.get() or DEFAULT_SECTION
extractor = SECTIONS.get(section)
if not extractor:
return
all_rows: list[dict] = []
notes: list[str] = []
columns: list = []
missing: list[str] = []
for rep in self.selected_reports():
cols, rows, note = extractor(rep["data"])
if not rows:
missing.append(rep["slug"])
continue
if not columns:
columns = [("Report", "_report", "{}", None), *cols]
for r in rows:
r = dict(r)
r["_report"] = rep["slug"]
all_rows.append(r)
if note and note not in notes:
notes.append(note)
# lookbacks
lookbacks = sorted({r.get("_lookback") for r in all_rows if r.get("_lookback") and r.get("_lookback") != "-"})
if lookbacks:
vals = ["(all)"] + lookbacks
self.cmp_lookback.configure(values=vals, state="readonly")
if self.cmp_lookback.get() not in vals:
self.cmp_lookback.set("all" if "all" in lookbacks else "(all)")
chosen = self.cmp_lookback.get()
if chosen not in ("(all)", "", "(n/a)"):
all_rows = [r for r in all_rows if r.get("_lookback") == chosen]
else:
self.cmp_lookback.configure(values=["(n/a)"], state="disabled")
self.cmp_lookback.set("(n/a)")
if self.cmp_prod_only.get():
prod_rows = [r for r in all_rows if r.get("_production")]
if prod_rows:
all_rows = prod_rows
metrics = self._metric_options(columns)
self.cmp_metric.configure(values=metrics or ["—"])
if metrics:
if self.cmp_metric.get() not in metrics:
for pref in ("Sharpe", "Net avg R", "CAGR %", "Mean IC"):
if pref in metrics:
self.cmp_metric.set(pref)
break
else:
self.cmp_metric.set(metrics[0])
else:
self.cmp_metric.set("—")
best_key = best_higher = None
mlabel = self.cmp_metric.get()
for c in columns:
if c[0] == mlabel and c[3] is not None:
best_key, best_higher = c[1], c[3]
break
note_text = " | ".join(notes[:2]) if notes else ""
if missing:
note_text = (note_text + f" · no data in: {', '.join(missing)}").strip(" ·")
self.cmp_note.configure(text=note_text)
if not columns:
columns = [("Report", "_report", "{}", None), ("Run", "_name", "{}", None)]
self.cmp_tree.set_columns(columns)
self.cmp_tree.populate(
all_rows,
best_key=best_key,
best_higher=best_higher,
default_sort=mlabel if metrics else None,
)
n_rep = len(self.selected_reports())
self.status.configure(
text=f"Compare: {len(all_rows)} rows from {n_rep} report(s) · section={section} · "
f"green = best {mlabel} · gold text = production/live row"
)
# ---- Equity tab -------------------------------------------------------
def _build_equity(self) -> None:
ctrl = ttk.Frame(self.tab_equity)
ctrl.pack(fill="x", pady=(0, 6))
ttk.Label(ctrl, text="Lookback").pack(side="left")
self.eq_lookback = ttk.Combobox(ctrl, state="readonly", width=10)
self.eq_lookback.pack(side="left", padx=(6, 12))
self.eq_lookback.bind("<<ComboboxSelected>>", lambda _e: self.refresh_equity())
self.eq_prod_only = tk.BooleanVar(value=True)
ttk.Checkbutton(
ctrl, text="Production only", variable=self.eq_prod_only, command=self.refresh_equity
).pack(side="left", padx=(0, 12))
self.eq_show_spy = tk.BooleanVar(value=True)
ttk.Checkbutton(
ctrl, text="Show SPY benchmark", variable=self.eq_show_spy, command=self.refresh_equity
).pack(side="left")
ttk.Button(ctrl, text="Redraw", command=self.refresh_equity).pack(side="right")
body = ttk.Panedwindow(self.tab_equity, orient="horizontal")
body.pack(fill="both", expand=True)
left = ttk.Frame(body)
right = ttk.Frame(body)
body.add(left, weight=0)
body.add(right, weight=1)
ttk.Label(left, text="Curves (check to plot)", style="Header.TLabel").pack(anchor="w")
self.eq_list = tk.Listbox(
left,
selectmode="extended",
bg=THEME["panel"],
fg=THEME["text"],
selectbackground=THEME["select"],
highlightthickness=0,
borderwidth=0,
font=("Segoe UI", 9),
width=42,
exportselection=False,
)
self.eq_list.pack(fill="both", expand=True, pady=(6, 0))
self.eq_list.bind("<<ListboxSelect>>", lambda _e: self._draw_equity())
self.eq_canvas = tk.Canvas(right, bg=THEME["chart_bg"], highlightthickness=0)
self.eq_canvas.pack(fill="both", expand=True)
self.eq_canvas.bind("<Configure>", lambda _e: self._draw_equity())
self.eq_legend = ttk.Label(right, text="", style="Muted.TLabel", wraplength=900)
self.eq_legend.pack(fill="x", pady=(4, 0))
self._eq_series: list[dict] = []
def refresh_equity(self) -> None:
# collect series from selected reports' portfolio_monitor
lookbacks_seen: set[str] = set()
series: list[dict] = []
for rep in self.selected_reports():
block = rep["data"].get("portfolio_monitor") or {}
for item in _as_list(block, "runs"):
if not isinstance(item, dict):
continue
curve = item.get("equity_curve")
if not curve:
continue
lb = item.get("lookback") or "-"
lookbacks_seen.add(lb)
if self.eq_prod_only.get() and not item.get("is_production"):
continue
series.append(
{
"report": rep["slug"],
"name": item.get("label") or item.get("strategy") or "?",
"lookback": lb,
"is_production": bool(item.get("is_production")),
"equity_curve": curve,
"benchmark_curve": item.get("benchmark_curve"),
"cagr": item.get("cagr_pct"),
"sharpe": item.get("sharpe"),
}
)
lbs = sorted(lookbacks_seen, key=lambda x: {"6m": 0, "1y": 1, "3y": 2, "5y": 3, "all": 4}.get(x, 9))
if lbs:
self.eq_lookback.configure(values=lbs, state="readonly")
if self.eq_lookback.get() not in lbs:
self.eq_lookback.set("all" if "all" in lbs else lbs[-1])
series = [s for s in series if s["lookback"] == self.eq_lookback.get()]
else:
self.eq_lookback.configure(values=["(n/a)"], state="disabled")
self.eq_lookback.set("(n/a)")
self._eq_series = series
self.eq_list.delete(0, "end")
for s in series:
prod = "★ " if s["is_production"] else " "
metrics = []
if s.get("sharpe") is not None:
metrics.append(f"Sh {s['sharpe']:.2f}")
if s.get("cagr") is not None:
metrics.append(f"CAGR {s['cagr']:+.0f}%")
label = f"{prod}{s['report']} · {s['name']}"
if metrics:
label += f" ({', '.join(metrics)})"
self.eq_list.insert("end", label)
# select all by default (cap at 8 for readability)
n = min(len(series), 8)
if n:
self.eq_list.selection_set(0, n - 1)
self._draw_equity()
def _draw_equity(self) -> None:
c = self.eq_canvas
c.delete("all")
w = c.winfo_width()
h = c.winfo_height()
if w < 40 or h < 40:
return
pad_l, pad_r, pad_t, pad_b = 56, 16, 16, 36
plot_w = w - pad_l - pad_r
plot_h = h - pad_t - pad_b
if plot_w < 10 or plot_h < 10:
return
idxs = list(self.eq_list.curselection())
if not idxs:
c.create_text(
w / 2, h / 2, text="Select one or more curves in the list", fill=THEME["muted"], font=("Segoe UI", 11)
)
return
# normalize each series to return_pct if present, else equity rebased to 0
plotted: list[tuple[str, list[tuple[str, float]], str]] = []
show_spy = self.eq_show_spy.get()
spy_added = False
for i, idx in enumerate(idxs):
if idx >= len(self._eq_series):
continue
s = self._eq_series[idx]
color = CHART_COLORS[i % len(CHART_COLORS)]
pts = self._curve_to_pct(s["equity_curve"])
if pts:
label = f"{s['report']} · {s['name']}"
plotted.append((label, pts, color))
if show_spy and not spy_added and s.get("benchmark_curve"):
bpts = self._curve_to_pct(s["benchmark_curve"])
if bpts:
plotted.append(("SPY", bpts, "#64748b"))
spy_added = True
if not plotted:
c.create_text(w / 2, h / 2, text="No equity_curve data in selection", fill=THEME["muted"])
return
all_y = [y for _, pts, _ in plotted for _, y in pts]
y_min = min(all_y)
y_max = max(all_y)
if y_min == y_max:
y_min -= 1
y_max += 1
# pad 5%
span = y_max - y_min
y_min -= span * 0.05
y_max += span * 0.05
# use longest series dates as x if possible — align by index after resampling to common length
# simpler: each series uses its own index 0..n mapped to plot width
# better: align on date strings
all_dates = sorted({d for _, pts, _ in plotted for d, _ in pts})
if not all_dates:
return
date_index = {d: i for i, d in enumerate(all_dates)}
n_dates = len(all_dates)
def x_of(date: str) -> float:
return pad_l + (date_index[date] / max(n_dates - 1, 1)) * plot_w
def y_of(val: float) -> float:
return pad_t + (1 - (val - y_min) / (y_max - y_min)) * plot_h
# grid
c.create_rectangle(pad_l, pad_t, pad_l + plot_w, pad_t + plot_h, outline=THEME["grid"], fill=THEME["chart_bg"])
for frac in (0, 0.25, 0.5, 0.75, 1.0):
yv = y_min + (y_max - y_min) * frac
y = y_of(yv)
c.create_line(pad_l, y, pad_l + plot_w, y, fill=THEME["grid"])
c.create_text(pad_l - 8, y, text=f"{yv:+.0f}%", fill=THEME["muted"], anchor="e", font=("Segoe UI", 8))
# zero line
if y_min < 0 < y_max:
y0 = y_of(0)
c.create_line(pad_l, y0, pad_l + plot_w, y0, fill=THEME["border"], dash=(3, 3))
# x labels
for frac in (0, 0.33, 0.66, 1.0):
di = int(frac * (n_dates - 1))
d = all_dates[di]
c.create_text(
x_of(d), pad_t + plot_h + 12, text=d[:7], fill=THEME["muted"], anchor="n", font=("Segoe UI", 8)
)
leg = []
for label, pts, color in plotted:
coords = []
for d, v in pts:
if d in date_index:
coords.extend([x_of(d), y_of(v)])
if len(coords) >= 4:
c.create_line(*coords, fill=color, width=2, smooth=False)
final = pts[-1][1] if pts else 0
short = label if len(label) < 48 else label[:45] + "…"
leg.append(f"● {short} {final:+.1f}%")
# in-chart legend
for i, (label, pts, color) in enumerate(plotted[:12]):
final = pts[-1][1] if pts else 0
short = label if len(label) < 48 else label[:45] + "…"
c.create_text(
pad_l + 8,
pad_t + 10 + i * 14,
text=f"● {short} {final:+.1f}%",
fill=color,
anchor="nw",
font=("Segoe UI", 8),
)
self.eq_legend.configure(text=" ".join(leg[:8]))
def _curve_to_pct(self, curve: list) -> list[tuple[str, float]]:
pts: list[tuple[str, float]] = []
if not curve:
return pts
# prefer return_pct field; else rebase equity
first_eq = None
for p in curve:
if not isinstance(p, dict):
continue
d = p.get("date") or ""
if p.get("return_pct") is not None:
pts.append((d, float(p["return_pct"])))
elif p.get("equity") is not None:
eq = float(p["equity"])
if first_eq is None:
first_eq = eq if eq else 1.0
pts.append((d, (eq / first_eq - 1.0) * 100.0))
return pts
# ---- global refresh / reload ------------------------------------------
def refresh_all(self) -> None:
self.refresh_browse()
self.refresh_compare()
self.refresh_equity()
rep = self.focus_report()
n = len(self.selected_reports())
focus = rep["slug"] if rep else "—"
self.status.configure(
text=f"Focus: {focus} · {n} report(s) selected for compare/equity · "
f"Browse = single-report drill-down · Compare = multi-report table · gold = production"
)
def reload(self) -> None:
self.reports = load_reports()
self._report_by_slug = {r["slug"]: r for r in self.reports}
# preserve selection where possible
known = {r["slug"] for r in self.reports}
self._selected_slugs = {s for s in self._selected_slugs if s in known} or known
if self._focus_slug not in known:
self._focus_slug = self.reports[0]["slug"] if self.reports else None
self._check_vars = {}
self._populate_report_list()
self.refresh_all()
messagebox.showinfo("Reloaded", f"Loaded {len(self.reports)} report(s).")
def main() -> None:
reports = load_reports()
if not reports:
raise SystemExit(f"No backtest-*.json found in {HERE}")
root = tk.Tk()
# Prefer Segoe UI where available
try:
tkfont.nametofont("TkDefaultFont").configure(family="Segoe UI", size=9)
except tk.TclError:
pass
App(root, reports)
root.mainloop()
if __name__ == "__main__":
main()