From 57ac1d2cdd1d6f51529cacf4cdd81c78d113bd7c Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Sun, 12 Jul 2026 19:27:02 +0200 Subject: [PATCH] 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. --- reports/compare_reports.py | 1834 ++++++++++++++++++++++++++++++++---- 1 file changed, 1630 insertions(+), 204 deletions(-) diff --git a/reports/compare_reports.py b/reports/compare_reports.py index 39f1dba..455edaa 100644 --- a/reports/compare_reports.py +++ b/reports/compare_reports.py @@ -1,9 +1,6 @@ -"""Compare backtest report JSONs side by side. +"""Backtest report explorer — browse, drill, and compare report JSONs. -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: +Stdlib only (tkinter). Launch from repo root or this folder: python reports/compare_reports.py """ @@ -14,258 +11,1682 @@ import glob import json import os import tkinter as tk -from tkinter import ttk +from tkinter import font as tkfont +from tkinter import messagebox, ttk +from typing import Any, Callable 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, - }, +# --------------------------------------------------------------------------- +# 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", } -# label, json key, format, higher_is_better (None = not rankable) -COLUMNS = [ - ("Report", "_report", "{}", None), - ("Run", "_name", "{}", None), - ("Lookback", "_lookback", "{}", None), +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), ] -RANKABLE = [c[0] for c in COLUMNS if c[3] is not 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 load_reports() -> list[dict]: - reports = [] - for path in sorted(glob.glob(os.path.join(HERE, "backtest-*.json"))): +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 - 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"), - }) + 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 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 +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("", 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("<>") + + +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.rows: list[dict] = [] - root.title("Backtest report comparison") - root.geometry("1250x680") + 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 - controls = ttk.Frame(root, padding=8) - controls.pack(fill="x") + root.title("Backtest Report Explorer") + root.geometry("1480x900") + root.minsize(1100, 700) + apply_theme(root) - ttk.Label(controls, text="Section").pack(side="left") - self.section = ttk.Combobox( - controls, values=list(SECTIONS), state="readonly", width=32 + 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( + "", + lambda e: self.report_canvas.configure(scrollregion=self.report_canvas.bbox("all")), ) - self.section.current(0) - self.section.pack(side="left", padx=(4, 14)) - self.section.bind("<>", 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("<>", 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._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( + "", + lambda e: self.report_canvas.itemconfigure(self._list_window, width=e.width), ) - self.metric.set("Sharpe") - self.metric.pack(side="left", padx=(4, 14)) - self.metric.bind("<>", lambda _e: self.refresh()) + # mousewheel when over list + self.report_canvas.bind("", lambda _e: self.report_canvas.bind_all("", self._on_wheel)) + self.report_canvas.bind("", lambda _e: self.report_canvas.unbind_all("")) - body = ttk.Frame(root, padding=(8, 0)) + 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("", lambda _e, s=slug: self._focus(s)) + card.bind("", 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("", 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("", 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("<>", 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("<>", 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("<>", 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("<>", lambda _e: self._show_detail(self.browse_tree)) + self.browse_tree.bind("<>", 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("<>", 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("<>", 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("<>", 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("<>", 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) - 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 + 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, ) - 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("<>", lambda _e: self.refresh()) + self.eq_list.pack(fill="both", expand=True, pady=(6, 0)) + self.eq_list.bind("<>", lambda _e: self._draw_equity()) - 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.eq_canvas = tk.Canvas(right, bg=THEME["chart_bg"], highlightthickness=0) + self.eq_canvas.pack(fill="both", expand=True) + self.eq_canvas.bind("", lambda _e: self._draw_equity()) - self.tree.tag_configure("best", background="#c8e6c9") - self.tree.tag_configure("prod", font=("TkDefaultFont", 9, "bold")) + self.eq_legend = ttk.Label(right, text="", style="Muted.TLabel", wraplength=900) + self.eq_legend.pack(fill="x", pady=(4, 0)) - self.status = ttk.Label(root, padding=8, anchor="w") - self.status.pack(fill="x") + self._eq_series: list[dict] = [] - 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] = [] + 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(): - found = rows_for(rep, section) - if not found: - missing.append(rep["name"]) - rows.extend(found) + 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"), + } + ) - 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) + 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: - reverse = self.sort_desc + self.eq_lookback.configure(values=["(n/a)"], state="disabled") + self.eq_lookback.set("(n/a)") - 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 "")) + 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) - rows.sort(key=sort_value, reverse=reverse) + # 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() - 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) + 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 - 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}" + 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) ) - if missing: - parts.append(f"no '{section}' data in: {', '.join(missing)}") - parts.append("bold = production row") - self.status.configure(text=" | ".join(parts)) + return - 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() + # 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: @@ -273,6 +1694,11 @@ def main() -> None: 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()