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