"""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("", 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._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( "", 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( "", lambda e: self.report_canvas.itemconfigure(self._list_window, width=e.width), ) # 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("")) 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) 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("<>", 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("", 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()