From a43157cd44fdefd77cd8a959310be2e4cde651c8 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Fri, 15 May 2026 19:23:12 +0000 Subject: [PATCH] refactor(gui): split dashboard in core (GA) + strategy_crypto (paper) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NEW src/multi_swarm_core/multi_swarm_core/dashboard/ con theme.py + data.py + nicegui_app.py - theme.py condiviso (CSS + colors + _apply_theme + _json_to_html + _build_header parametrico) - core GUI: pagine /, /convergence, /genomes — legge SOLO runs.db - strategy GUI slim: solo /, legge SOLO strategy_crypto.db — importa theme dal core - Aggiunto nicegui+plotly al core pyproject (uv.lock rigenerato) - docker-compose: nuovo servizio multi-swarm-core-gui su /multi_swarm_core_gui (Traefik PathPrefix + replacepathregex, NO stripprefix per evitare doppio root_path) - .env.example: DASHBOARD_ROOT_PATH ora per-servizio Pattern: ogni modulo possiede la sua GUI, ogni GUI legge solo il proprio DB. N strategie future = duplica lo scheletro strategy_crypto/frontend/. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 9 +- docker-compose.yml | 55 +- .../multi_swarm_core/dashboard/__init__.py | 0 .../multi_swarm_core/dashboard/data.py | 68 ++ .../multi_swarm_core/dashboard/nicegui_app.py | 560 ++++++++++++ .../multi_swarm_core/dashboard/theme.py | 383 ++++++++ src/multi_swarm_core/pyproject.toml | 2 + .../strategy_crypto/frontend/data.py | 52 +- .../strategy_crypto/frontend/nicegui_app.py | 863 +----------------- uv.lock | 4 + 10 files changed, 1092 insertions(+), 904 deletions(-) create mode 100644 src/multi_swarm_core/multi_swarm_core/dashboard/__init__.py create mode 100644 src/multi_swarm_core/multi_swarm_core/dashboard/data.py create mode 100644 src/multi_swarm_core/multi_swarm_core/dashboard/nicegui_app.py create mode 100644 src/multi_swarm_core/multi_swarm_core/dashboard/theme.py diff --git a/.env.example b/.env.example index bf1dc0e..3cf1a35 100644 --- a/.env.example +++ b/.env.example @@ -29,12 +29,15 @@ GA_DB_PATH=./state/runs.db STRATEGY_CRYPTO_DB_PATH=./state/strategy_crypto.db # Docker / Traefik (usati SOLO da docker-compose.yml) -# Dominio base: traefik espone la dashboard su swarm.${DOMAIN_NAME}/strategy_crypto_gui +# Dominio base: traefik espone le dashboard su swarm.${DOMAIN_NAME}/... DOMAIN_NAME=tielogic.xyz # Porta interna della NiceGUI dashboard (Traefik fa il TLS davanti) SWARM_DASHBOARD_PORT=8080 -# Subpath URL del dashboard NiceGUI (usato come root_path in produzione) -DASHBOARD_ROOT_PATH=/strategy_crypto_gui +# Subpath URL del dashboard NiceGUI — ora PER-SERVIZIO nel docker-compose.yml: +# strategy-crypto-gui -> DASHBOARD_ROOT_PATH=/strategy_crypto_gui +# multi-swarm-core-gui -> DASHBOARD_ROOT_PATH=/multi_swarm_core_gui +# In sviluppo locale lascia vuoto (nessun subpath). +DASHBOARD_ROOT_PATH= # Paper-trading runner — override del command nel compose (opzionali) PAPER_RUN_NAME=phase3-papertrade-prod diff --git a/docker-compose.yml b/docker-compose.yml index ee4dd42..580611e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,14 @@ # docker-compose.yml — Multi-Swarm Coevolutive # -# Due servizi della strategia crypto, condividono la stessa immagine +# Tre servizi della strategia crypto, condividono la stessa immagine # `multi-swarm-coevolutive:dev` buildata dal Dockerfile root (uv workspace): # -# * strategy-crypto-paper — paper-trading runner long-running -# (scripts/run_paper_trading.py) -# * strategy-crypto-gui — NiceGUI dashboard esposta da Traefik su -# https://swarm.${DOMAIN_NAME:-tielogic.xyz}/strategy_crypto_gui +# * strategy-crypto-paper — paper-trading runner long-running +# (scripts/run_paper_trading.py) +# * strategy-crypto-gui — NiceGUI dashboard esposta da Traefik su +# https://swarm.${DOMAIN_NAME:-tielogic.xyz}/strategy_crypto_gui +# * multi-swarm-core-gui — NiceGUI dashboard GA esposta su +# https://swarm.${DOMAIN_NAME:-tielogic.xyz}/multi_swarm_core_gui # # Entrambi joinano la rete external `traefik` cosi' il client Cerbero # risolve direttamente l'host `cerbero-mcp` (porta 9000) senza passare @@ -36,12 +38,11 @@ x-swarm-env: &swarm-env # DB separati per dominio: GA_DB_PATH: /app/state/runs.db STRATEGY_CRYPTO_DB_PATH: /app/state/strategy_crypto.db - # Subpath sotto cui la dashboard NiceGUI e' esposta da Traefik. + # DASHBOARD_ROOT_PATH e' ora per-servizio (vedi environment blocks sotto). # IMPORTANT: NON usare StripPrefix middleware con questo. NiceGUI/Starlette # gestisce internamente il root_path su request path che ARRIVANO con prefix. # StripPrefix causa doppio prefix negli asset URL (NiceGUI prefixa + uvicorn # rilegge X-Forwarded-Prefix e prefixa di nuovo). - DASHBOARD_ROOT_PATH: /strategy_crypto_gui services: strategy-crypto-paper: @@ -85,11 +86,10 @@ services: env_file: .env environment: <<: *swarm-env + DASHBOARD_ROOT_PATH: /strategy_crypto_gui volumes: - # Dashboard legge entrambi i DB: state/ in read-only (WAL: vedi nota) + # Dashboard legge solo strategy_crypto.db: state/ in read-only (WAL: vedi nota) - ./state:/app/state:ro - - ./data:/app/data:ro - - ./series:/app/series:ro entrypoint: - python - -m @@ -121,3 +121,38 @@ services: - "traefik.http.middlewares.strategy-crypto-replace.replacepathregex.replacement=$$1" - "traefik.http.routers.strategy-crypto-gui.middlewares=strategy-crypto-replace" - com.centurylinklabs.watchtower.enable=true + + multi-swarm-core-gui: + image: multi-swarm-coevolutive:dev + build: + context: . + dockerfile: Dockerfile + container_name: multi-swarm-core-gui + restart: unless-stopped + networks: [traefik] + env_file: .env + environment: + <<: *swarm-env + DASHBOARD_ROOT_PATH: /multi_swarm_core_gui + volumes: + - ./state:/app/state:ro + entrypoint: [python, -m, multi_swarm_core.dashboard.nicegui_app] + command: [] + healthcheck: + test: ["CMD", "python", "-c", "import os, urllib.request; urllib.request.urlopen(f'http://localhost:{os.environ.get(\"SWARM_DASHBOARD_PORT\",\"8080\")}/', timeout=3).close()"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + labels: + - traefik.enable=true + - traefik.docker.network=traefik + - "traefik.http.routers.multi-swarm-core-gui.rule=Host(`swarm.${DOMAIN_NAME:-tielogic.xyz}`) && PathPrefix(`/multi_swarm_core_gui`)" + - traefik.http.routers.multi-swarm-core-gui.tls=true + - traefik.http.routers.multi-swarm-core-gui.entrypoints=websecure + - traefik.http.routers.multi-swarm-core-gui.tls.certresolver=mytlschallenge + - "traefik.http.services.multi-swarm-core-gui.loadbalancer.server.port=${SWARM_DASHBOARD_PORT:-8080}" + - "traefik.http.middlewares.multi-swarm-core-replace.replacepathregex.regex=^/multi_swarm_core_gui(/.*|$$)" + - "traefik.http.middlewares.multi-swarm-core-replace.replacepathregex.replacement=$$1" + - "traefik.http.routers.multi-swarm-core-gui.middlewares=multi-swarm-core-replace" + - com.centurylinklabs.watchtower.enable=true diff --git a/src/multi_swarm_core/multi_swarm_core/dashboard/__init__.py b/src/multi_swarm_core/multi_swarm_core/dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/multi_swarm_core/multi_swarm_core/dashboard/data.py b/src/multi_swarm_core/multi_swarm_core/dashboard/data.py new file mode 100644 index 0000000..5829118 --- /dev/null +++ b/src/multi_swarm_core/multi_swarm_core/dashboard/data.py @@ -0,0 +1,68 @@ +"""GA data access functions for the core dashboard. + +Reads exclusively from runs.db (GA tables). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pandas as pd # type: ignore[import-untyped] + +from multi_swarm_core.persistence.repository import Repository + +__all__ = [ + "get_repo", + "list_runs_df", + "get_run_overview", + "generations_df", + "evaluations_df", + "genomes_df", +] + + +def get_repo(db_path: str | Path) -> Repository: + return Repository(db_path=db_path) + + +def list_runs_df(repo: Repository) -> pd.DataFrame: + return pd.DataFrame(repo.list_runs()) + + +def get_run_overview(repo: Repository, run_id: str) -> dict[str, Any]: + run = repo.get_run(run_id) + return { + "name": run["name"], + "started_at": run["started_at"], + "completed_at": run["completed_at"], + "status": run["status"], + "total_cost_usd": run["total_cost_usd"], + "config": json.loads(run["config_json"]), + } + + +def generations_df(repo: Repository, run_id: str) -> pd.DataFrame: + return pd.DataFrame(repo.list_generations(run_id)) + + +def evaluations_df(repo: Repository, run_id: str) -> pd.DataFrame: + return pd.DataFrame(repo.list_evaluations(run_id)) + + +def genomes_df( + repo: Repository, run_id: str, generation_idx: int | None = None +) -> pd.DataFrame: + rows = repo.list_genomes(run_id, generation_idx) + flat: list[dict[str, Any]] = [] + for r in rows: + payload = json.loads(r["payload_json"]) + flat.append( + { + "id": r["id"], + "generation_idx": r["generation_idx"], + **payload, + } + ) + return pd.DataFrame(flat) diff --git a/src/multi_swarm_core/multi_swarm_core/dashboard/nicegui_app.py b/src/multi_swarm_core/multi_swarm_core/dashboard/nicegui_app.py new file mode 100644 index 0000000..4ccfc38 --- /dev/null +++ b/src/multi_swarm_core/multi_swarm_core/dashboard/nicegui_app.py @@ -0,0 +1,560 @@ +"""Multi-Swarm Core Dashboard — GA pages: /, /convergence, /genomes. + +Avvio: ``uv run python -m multi_swarm_core.dashboard.nicegui_app`` +Legge SOLO runs.db (tabelle GA). +""" + +from __future__ import annotations + +import html +import os +from pathlib import Path +from typing import Any + +import plotly.graph_objects as go # type: ignore[import-untyped] +from nicegui import app, ui + +from multi_swarm_core.dashboard.data import ( + evaluations_df, + generations_df, + genomes_df, + get_repo, + get_run_overview, + list_runs_df, +) +from multi_swarm_core.dashboard.theme import ( + COLOR_ACCENT, + COLOR_PRIMARY, + COLOR_SECONDARY, + COLOR_SURFACE, + COLOR_TEXT, + COLOR_TEXT_MUTED, + _STATUS_BADGE, + _apply_theme, + _build_header, + _json_to_html, +) + +GA_DB_PATH = os.environ.get("GA_DB_PATH", "./state/runs.db") +DASHBOARD_ROOT_PATH = os.environ.get("DASHBOARD_ROOT_PATH", "") +REFRESH_INTERVAL_S = 3.0 + + +def _runs_options() -> dict[str, str]: + repo = get_repo(GA_DB_PATH) + runs = list_runs_df(repo) + if runs.empty: + return {} + return { + row["id"]: f"{row['name']} — {row['status']} ({row['started_at'][:16]})" + for _, row in runs.iterrows() + } + + +def _snapshot(run_id: str) -> dict[str, Any]: + repo = get_repo(GA_DB_PATH) + ov = get_run_overview(repo, run_id) + evals = evaluations_df(repo, run_id) + gens = generations_df(repo, run_id) + + cfg = ov["config"] + pop_size = int(cfg.get("population_size", 0)) + n_gens = int(cfg.get("n_generations", 0)) + evals_total = max(pop_size * n_gens, 1) + evals_done = len(evals) + gens_done = int(gens["completed_at"].notna().sum()) if not gens.empty else 0 + live_cost = float(repo.total_cost(run_id)) if ov["status"] == "running" else float( + ov["total_cost_usd"] + ) + + top_fit = float(evals["fitness"].max()) if evals_done else float("nan") + median_fit = float(evals["fitness"].median()) if evals_done else float("nan") + parse_success = ( + 100.0 * float(evals["parse_error"].isna().sum()) / evals_done if evals_done else 0.0 + ) + + return { + "status": ov["status"], + "name": cfg.get("run_name", "—"), + "started_at": ov["started_at"], + "completed_at": ov["completed_at"] or "—", + "cost_usd": live_cost, + "pop_size": pop_size, + "n_gens": n_gens, + "evals_done": evals_done, + "evals_total": evals_total, + "gens_done": gens_done, + "top_fit": top_fit, + "median_fit": median_fit, + "parse_success": parse_success, + "config": cfg, + "gens_df": gens, + } + + +def _convergence_figure(gens_df: Any) -> go.Figure: + fig = go.Figure() + if gens_df.empty: + fig.add_annotation( + text="Nessuna generazione registrata", x=0.5, y=0.5, showarrow=False, + font={"color": COLOR_TEXT_MUTED, "size": 14}, + ) + else: + fig.add_trace( + go.Scatter( + x=gens_df["generation_idx"], y=gens_df["fitness_max"], + name="max", mode="lines+markers", + line={"color": COLOR_PRIMARY, "width": 3, "shape": "spline", "smoothing": 0.6}, + marker={"size": 9, "color": COLOR_PRIMARY, + "line": {"color": "#fff", "width": 1}}, + fill="tozeroy", + fillcolor="rgba(255, 45, 135, 0.12)", + ) + ) + fig.add_trace( + go.Scatter( + x=gens_df["generation_idx"], y=gens_df["fitness_p90"], + name="p90", mode="lines+markers", + line={"color": COLOR_ACCENT, "width": 2, "dash": "dot", "shape": "spline"}, + marker={"size": 7, "color": COLOR_ACCENT}, + ) + ) + fig.add_trace( + go.Scatter( + x=gens_df["generation_idx"], y=gens_df["fitness_median"], + name="median", mode="lines+markers", + line={"color": COLOR_SECONDARY, "width": 2, "shape": "spline"}, + marker={"size": 7, "color": COLOR_SECONDARY}, + ) + ) + fig.update_layout( + template="plotly_dark", + paper_bgcolor=COLOR_SURFACE, + plot_bgcolor=COLOR_SURFACE, + font={"color": COLOR_TEXT}, + xaxis={"title": "generation", "gridcolor": "rgba(148, 163, 184, 0.08)", "dtick": 1}, + yaxis={"title": "fitness", "gridcolor": "rgba(148, 163, 184, 0.08)"}, + title={"text": "Fitness convergence", "font": {"color": COLOR_TEXT, "size": 18}}, + legend={"bgcolor": "rgba(19, 19, 26, 0.95)", "bordercolor": COLOR_PRIMARY, "borderwidth": 1}, + margin={"l": 50, "r": 30, "t": 50, "b": 50}, + ) + return fig + + +def _entropy_figure(gens_df: Any) -> go.Figure: + fig = go.Figure() + if not gens_df.empty: + fig.add_trace( + go.Scatter( + x=gens_df["generation_idx"], y=gens_df["entropy"], + mode="lines+markers", + line={"color": COLOR_SECONDARY, "width": 3, "shape": "spline", "smoothing": 0.6}, + marker={"size": 9, "color": COLOR_SECONDARY, + "line": {"color": "#fff", "width": 1}}, + fill="tozeroy", + fillcolor="rgba(0, 217, 255, 0.12)", + name="entropy", + ) + ) + fig.add_hline( + y=0.5, line_dash="dash", line_color=COLOR_ACCENT, + annotation_text="gate threshold (0.5)", + annotation_font_color=COLOR_ACCENT, + ) + fig.update_layout( + template="plotly_dark", + paper_bgcolor=COLOR_SURFACE, + plot_bgcolor=COLOR_SURFACE, + font={"color": COLOR_TEXT}, + xaxis={"title": "generation", "gridcolor": "rgba(148, 163, 184, 0.08)", "dtick": 1}, + yaxis={"title": "entropy", "gridcolor": "rgba(148, 163, 184, 0.08)"}, + title={"text": "Diversity (fitness entropy)", "font": {"color": COLOR_TEXT, "size": 18}}, + margin={"l": 50, "r": 30, "t": 50, "b": 50}, + ) + return fig + + +@ui.page("/") +def index() -> None: + _apply_theme() + _build_header( + active="/", + brand_subtitle="Coevolutivo / GA", + nav_items=[("/", "Overview"), ("/convergence", "Convergence"), ("/genomes", "Genomes")], + db_label=f"⛁ {Path(GA_DB_PATH).resolve().name}", + ) + + options = _runs_options() + if not options: + ui.label("Nessuna run nel database.").classes("text-h5") + return + + state: dict[str, Any] = {"run_id": next(iter(options))} + + with ui.row().classes("w-full items-center gap-4 q-mb-md"): + select = ui.select(options=options, value=state["run_id"], label="Run").classes( + "flex-grow" + ) + status_badge = ui.badge("…", color="primary").classes("text-body1 q-pa-sm") + ui.button("🔄 Refresh", on_click=lambda: refresh()).props("outline color=primary") + + with ui.card().classes("w-full"): + ui.label("Progresso run").classes("text-subtitle1") + gen_label = ui.label("Generations: 0/0") + gen_bar = ui.linear_progress(0.0, show_value=False).props("size=20px color=primary") + eval_label = ui.label("Evaluations: 0/0 (0.0%)") + eval_bar = ui.linear_progress(0.0, show_value=False).props("size=20px color=accent") + + with ui.row().classes("w-full gap-4"): + with ui.card().classes("flex-grow metric-card accent-cyan"): + ui.label("Top fitness").classes("text-caption") + top_lbl = ui.label("—").classes("text-h4") + with ui.card().classes("flex-grow metric-card accent-purple"): + ui.label("Median fitness").classes("text-caption") + median_lbl = ui.label("—").classes("text-h4") + with ui.card().classes("flex-grow metric-card accent-amber"): + ui.label("Parse success").classes("text-caption") + parse_lbl = ui.label("—").classes("text-h4") + with ui.card().classes("flex-grow metric-card accent-green"): + ui.label("Cost (USD)").classes("text-caption") + cost_lbl = ui.label("—").classes("text-h4") + + with ui.row().classes("w-full gap-4 q-mt-md"): + started_lbl = ui.label("Started: —") + completed_lbl = ui.label("Completed: —") + ui.separator() + ui.label("Config").classes("text-subtitle1") + cfg_code = ui.html('
').classes("w-full")
+
+    def refresh() -> None:
+        run_id = select.value
+        if not run_id:
+            return
+        try:
+            s = _snapshot(run_id)
+        except Exception as e:  # noqa: BLE001
+            ui.notify(f"Errore: {e}", type="negative")
+            return
+
+        text, color = _STATUS_BADGE.get(s["status"], (s["status"], "primary"))
+        status_badge.text = text
+        status_badge.props(f"color={color}")
+
+        gen_frac = min(s["gens_done"] / max(s["n_gens"], 1), 1.0)
+        eval_frac = min(s["evals_done"] / s["evals_total"], 1.0)
+        gen_bar.value = gen_frac
+        eval_bar.value = eval_frac
+        gen_label.text = f"Generations: {s['gens_done']}/{s['n_gens']}"
+        eval_label.text = (
+            f"Evaluations: {s['evals_done']}/{s['evals_total']} ({100 * eval_frac:.1f}%)"
+        )
+
+        top_lbl.text = f"{s['top_fit']:.4f}" if s["evals_done"] else "—"
+        median_lbl.text = f"{s['median_fit']:.4f}" if s["evals_done"] else "—"
+        parse_lbl.text = f"{s['parse_success']:.1f}%" if s["evals_done"] else "—"
+        cost_lbl.text = f"${s['cost_usd']:.4f}"
+
+        started_lbl.text = f"Started: {s['started_at']}"
+        completed_lbl.text = f"Completed: {s['completed_at']}"
+        cfg_code.content = f'
{_json_to_html(s["config"])}
' + + def on_select_change() -> None: + state["run_id"] = select.value + refresh() + + select.on_value_change(on_select_change) + ui.timer(REFRESH_INTERVAL_S, refresh) + refresh() + + +@ui.page("/convergence") +def convergence() -> None: + _apply_theme() + _build_header( + active="/convergence", + brand_subtitle="Coevolutivo / GA", + nav_items=[("/", "Overview"), ("/convergence", "Convergence"), ("/genomes", "Genomes")], + db_label=f"⛁ {Path(GA_DB_PATH).resolve().name}", + ) + + options = _runs_options() + if not options: + ui.label("Nessuna run nel database.").classes("text-h5") + return + + state: dict[str, Any] = {"run_id": next(iter(options))} + + with ui.row().classes("w-full items-center gap-4 q-mb-md"): + select = ui.select(options=options, value=state["run_id"], label="Run").classes( + "flex-grow" + ) + gen_count_lbl = ui.label("Gens: 0/0").classes("text-body1").style( + f"color: {COLOR_PRIMARY}; font-weight: 600;" + ) + ui.button("🔄 Refresh", on_click=lambda: refresh()).props("outline color=primary") + + fitness_plot = ui.plotly(_convergence_figure(generations_df(get_repo(GA_DB_PATH), state["run_id"]))).classes("w-full") + entropy_plot = ui.plotly(_entropy_figure(generations_df(get_repo(GA_DB_PATH), state["run_id"]))).classes("w-full q-mt-md") + + ui.separator() + ui.label("Tabella generazioni").classes("text-subtitle1 q-mt-md") + gens_table = ui.table( + columns=[ + {"name": "generation_idx", "label": "gen", "field": "generation_idx", "sortable": True}, + {"name": "n_genomes", "label": "n", "field": "n_genomes"}, + {"name": "fitness_max", "label": "max", "field": "fitness_max"}, + {"name": "fitness_p90", "label": "p90", "field": "fitness_p90"}, + {"name": "fitness_median", "label": "median", "field": "fitness_median"}, + {"name": "entropy", "label": "entropy", "field": "entropy"}, + {"name": "completed_at", "label": "completed", "field": "completed_at"}, + ], + rows=[], + row_key="generation_idx", + ).classes("w-full") + + def refresh() -> None: + run_id = select.value + if not run_id: + return + try: + gens = generations_df(get_repo(GA_DB_PATH), run_id) + ov = get_run_overview(get_repo(GA_DB_PATH), run_id) + except Exception as e: # noqa: BLE001 + ui.notify(f"Errore: {e}", type="negative") + return + + n_gens = int(ov["config"].get("n_generations", 0)) + gens_done = int(gens["completed_at"].notna().sum()) if not gens.empty else 0 + gen_count_lbl.text = f"Gens: {gens_done}/{n_gens}" + + fitness_plot.update_figure(_convergence_figure(gens)) + entropy_plot.update_figure(_entropy_figure(gens)) + + if gens.empty: + gens_table.rows = [] + else: + display_cols = [ + "generation_idx", "n_genomes", + "fitness_max", "fitness_p90", "fitness_median", + "entropy", "completed_at", + ] + gens_table.rows = [ + { + col: (round(v, 6) if isinstance(v, float) else v) + for col, v in row.items() + if col in display_cols + } + for _, row in gens.iterrows() + ] + gens_table.update() + + def on_select_change() -> None: + state["run_id"] = select.value + refresh() + + select.on_value_change(on_select_change) + ui.timer(REFRESH_INTERVAL_S, refresh) + refresh() + + +@ui.page("/genomes") +def genomes() -> None: + _apply_theme() + _build_header( + active="/genomes", + brand_subtitle="Coevolutivo / GA", + nav_items=[("/", "Overview"), ("/convergence", "Convergence"), ("/genomes", "Genomes")], + db_label=f"⛁ {Path(GA_DB_PATH).resolve().name}", + ) + + options = _runs_options() + if not options: + ui.label("Nessuna run nel database.").classes("text-h5") + return + + state: dict[str, Any] = { + "run_id": next(iter(options)), + "selected_gid": None, + "merged": None, + } + + with ui.row().classes("w-full items-center gap-4 q-mb-md"): + select = ui.select(options=options, value=state["run_id"], label="Run").classes( + "flex-grow" + ) + top_k_select = ui.select( + options={10: "Top 10", 25: "Top 25", 50: "Top 50"}, + value=10, + label="Top K", + ) + ui.button("🔄 Refresh", on_click=lambda: refresh()).props("outline color=primary") + + ui.label("Top genomi per fitness").classes("text-subtitle1 q-mt-sm") + top_table = ui.table( + columns=[ + {"name": "genome_id", "label": "id", "field": "genome_id", "align": "left"}, + {"name": "fitness", "label": "fitness", "field": "fitness", "sortable": True}, + {"name": "dsr", "label": "DSR", "field": "dsr"}, + {"name": "sharpe", "label": "Sharpe", "field": "sharpe"}, + {"name": "max_dd", "label": "max DD", "field": "max_dd"}, + {"name": "n_trades", "label": "trades", "field": "n_trades"}, + {"name": "cognitive_style", "label": "style", "field": "cognitive_style"}, + {"name": "temperature", "label": "T", "field": "temperature"}, + {"name": "lookback_window", "label": "lookback", "field": "lookback_window"}, + ], + rows=[], + row_key="genome_id", + selection="single", + ).classes("w-full") + + ui.separator().classes("q-my-md") + + with ui.card().classes("w-full"): + ui.label("Ispezione genoma").classes("text-subtitle1") + detail_hint = ui.label("Seleziona un genoma dalla tabella sopra.").classes( + "text-caption" + ).style(f"color: {COLOR_TEXT_MUTED};") + + with ui.row().classes("w-full gap-4 q-mt-sm"): + with ui.card().classes("flex-grow metric-card accent-cyan"): + ui.label("fitness").classes("text-caption") + fit_lbl = ui.label("—").classes("text-h4") + with ui.card().classes("flex-grow metric-card accent-purple"): + ui.label("DSR").classes("text-caption") + dsr_lbl = ui.label("—").classes("text-h4") + with ui.card().classes("flex-grow metric-card accent-amber"): + ui.label("Sharpe").classes("text-caption") + sharpe_lbl = ui.label("—").classes("text-h4") + with ui.card().classes("flex-grow metric-card"): + ui.label("max DD").classes("text-caption") + dd_lbl = ui.label("—").classes("text-h4") + with ui.card().classes("flex-grow metric-card accent-green"): + ui.label("trades").classes("text-caption") + trades_lbl = ui.label("—").classes("text-h4") + with ui.card().classes("flex-grow metric-card"): + ui.label("style").classes("text-caption") + style_lbl = ui.label("—").classes("text-h4") + + ui.label("System prompt").classes("text-subtitle1 q-mt-md") + prompt_code = ui.html('
').classes("w-full") + + ui.label("Raw LLM output").classes("text-subtitle1 q-mt-md") + raw_code = ui.html('
').classes("w-full") + + parse_error_lbl = ui.label("").classes("q-mt-sm").style( + "color: #FF6B6B; font-weight: 600;" + ) + + def _render_detail(row: dict[str, Any]) -> None: + detail_hint.text = f"Genoma: {row.get('genome_id', '—')}" + fit_lbl.text = f"{float(row.get('fitness', 0)):.4f}" + dsr_lbl.text = f"{float(row.get('dsr', 0)):.4f}" + sharpe_lbl.text = f"{float(row.get('sharpe', 0)):.3f}" + dd_lbl.text = f"{float(row.get('max_dd', 0)):.3f}" + trades_lbl.text = str(int(row.get("n_trades", 0))) + style_lbl.text = str(row.get("cognitive_style", "—")) + prompt_code.content = ( + f'
{html.escape(str(row.get("system_prompt", "—")))}
' + ) + raw_code.content = ( + f'
{html.escape(str(row.get("raw_text", "—") or "—"))}
' + ) + pe = row.get("parse_error") + parse_error_lbl.text = f"❌ Parse error: {pe}" if pe else "" + + def refresh() -> None: + run_id = select.value + if not run_id: + return + try: + repo = get_repo(GA_DB_PATH) + evals = evaluations_df(repo, run_id) + gens = genomes_df(repo, run_id) + except Exception as e: # noqa: BLE001 + ui.notify(f"Errore: {e}", type="negative") + return + + if evals.empty: + top_table.rows = [] + top_table.update() + return + + merged = evals.merge( + gens, left_on="genome_id", right_on="id", how="left", suffixes=("", "_g") + ) + state["merged"] = merged + + k = int(top_k_select.value) + top = merged.sort_values("fitness", ascending=False).head(k) + + rows = [] + for _, r in top.iterrows(): + rows.append( + { + "genome_id": str(r.get("genome_id", "—"))[:12] + "…", + "fitness": round(float(r.get("fitness", 0)), 4), + "dsr": round(float(r.get("dsr", 0)), 4), + "sharpe": round(float(r.get("sharpe", 0)), 3), + "max_dd": round(float(r.get("max_dd", 0)), 3), + "n_trades": int(r.get("n_trades", 0)), + "cognitive_style": str(r.get("cognitive_style", "—")), + "temperature": round(float(r.get("temperature", 0)), 2), + "lookback_window": int(r.get("lookback_window", 0)), + "_full_id": str(r.get("genome_id", "")), + } + ) + top_table.rows = rows + top_table.update() + + sel = state.get("selected_gid") + if sel: + match = merged[merged["genome_id"] == sel] + if not match.empty: + _render_detail(match.iloc[0].to_dict()) + + def on_row_selected(e: Any) -> None: + rows = (e.args or {}).get("rows") or [] + if not rows: + return + full_id = rows[0].get("_full_id") + if not full_id: + return + state["selected_gid"] = full_id + merged = state.get("merged") + if merged is None: + return + match = merged[merged["genome_id"] == full_id] + if not match.empty: + _render_detail(match.iloc[0].to_dict()) + + def on_select_change() -> None: + state["run_id"] = select.value + state["selected_gid"] = None + refresh() + + select.on_value_change(on_select_change) + top_k_select.on_value_change(lambda _: refresh()) + top_table.on("selection", on_row_selected) + ui.timer(REFRESH_INTERVAL_S, refresh) + refresh() + + +def main() -> None: + app.on_startup( + lambda: print( + f"GA DB: {Path(GA_DB_PATH).resolve()} | root_path: {DASHBOARD_ROOT_PATH or '/'}" + ) + ) + ui.run( + host="0.0.0.0", + port=int(os.environ.get("SWARM_DASHBOARD_PORT", "8080")), + title="Multi-Swarm Core Dashboard", + reload=False, + show=False, + dark=True, + root_path=DASHBOARD_ROOT_PATH, + ) + + +if __name__ in {"__main__", "__mp_main__"}: + main() diff --git a/src/multi_swarm_core/multi_swarm_core/dashboard/theme.py b/src/multi_swarm_core/multi_swarm_core/dashboard/theme.py new file mode 100644 index 0000000..20c0cec --- /dev/null +++ b/src/multi_swarm_core/multi_swarm_core/dashboard/theme.py @@ -0,0 +1,383 @@ +"""Shared theme module for NiceGUI dashboards. + +Exports palette constants, CSS, and helper functions used by both +multi_swarm_core.dashboard and strategy_crypto.frontend dashboards. +""" + +from __future__ import annotations + +import html +from typing import Any + +from nicegui import ui + +__all__ = [ + "COLOR_BG", + "COLOR_SURFACE", + "COLOR_SURFACE_2", + "COLOR_BORDER", + "COLOR_BORDER_HOVER", + "COLOR_PRIMARY", + "COLOR_SECONDARY", + "COLOR_ACCENT", + "COLOR_SUCCESS", + "COLOR_DANGER", + "COLOR_TEXT", + "COLOR_TEXT_MUTED", + "_STATUS_BADGE", + "_CUSTOM_CSS", + "_json_to_html", + "_apply_theme", + "_build_header", +] + +# --- Neon Trading Dashboard palette --- +COLOR_BG = "#0A0A0F" +COLOR_SURFACE = "#13131A" +COLOR_SURFACE_2 = "#1C1C26" +COLOR_BORDER = "rgba(255, 45, 135, 0.12)" +COLOR_BORDER_HOVER = "rgba(255, 45, 135, 0.45)" +COLOR_PRIMARY = "#FF2D87" +COLOR_SECONDARY = "#00D9FF" +COLOR_ACCENT = "#FFB800" +COLOR_SUCCESS = "#00E676" +COLOR_DANGER = "#FF3D60" +COLOR_TEXT = "#FFFFFF" +COLOR_TEXT_MUTED = "#7A7A8C" + +_STATUS_BADGE: dict[str, tuple[str, str]] = { + "running": ("● running", "positive"), + "completed": ("✓ completed", "positive"), + "failed": ("✕ failed", "negative"), +} + +_CUSTOM_CSS = f""" + +""" + + +def _json_to_html(obj: Any, indent: int = 0) -> str: + """Render JSON con span colorati espliciti. Garantisce leggibilità ovunque.""" + pad = " " * indent + inner_pad = " " * (indent + 1) + if isinstance(obj, dict): + if not obj: + return '{}' + items = [] + for k, v in obj.items(): + key = f'"{html.escape(str(k))}"' + val = _json_to_html(v, indent + 1) + items.append(f"{inner_pad}{key}: {val}") + return ('{\n' + + ',\n'.join(items) + + f'\n{pad}}}') + if isinstance(obj, list): + if not obj: + return '[]' + items = [_json_to_html(x, indent + 1) for x in obj] + return ('[\n' + + ',\n'.join(inner_pad + i for i in items) + + f'\n{pad}]') + if isinstance(obj, bool): + return f'{str(obj).lower()}' + if obj is None: + return 'null' + if isinstance(obj, (int, float)): + return f'{obj}' + return f'"{html.escape(str(obj))}"' + + +def _apply_theme() -> None: + ui.add_head_html(_CUSTOM_CSS) + ui.dark_mode().enable() + ui.colors( + primary=COLOR_PRIMARY, + secondary=COLOR_SECONDARY, + accent=COLOR_ACCENT, + dark=COLOR_BG, + dark_page=COLOR_BG, + positive=COLOR_SUCCESS, + negative=COLOR_DANGER, + info=COLOR_PRIMARY, + warning=COLOR_ACCENT, + ) + + +def _build_header( + active: str, + brand_subtitle: str, + nav_items: list[tuple[str, str]], + db_label: str, +) -> None: + """Render the top navigation header. + + Args: + active: URL path of the currently active page (e.g. "/"). + brand_subtitle: Text shown after the brand dot, e.g. "Coevolutivo / GA". + nav_items: List of (path, label) tuples for nav links. + db_label: Short DB identifier shown in the top-right corner. + """ + with ui.header().classes("items-center justify-between q-px-lg q-py-md"): + with ui.row().classes("items-center gap-8"): + with ui.row().classes("items-center gap-2").classes("brand"): + ui.html('') + ui.html( + f'Multi-Swarm / {brand_subtitle}' + ) + with ui.row().classes("items-center gap-1"): + for path, label in nav_items: + cls = "nav-link active" if active == path else "nav-link" + ui.link(label, path).classes(cls) + with ui.row().classes("items-center gap-3"): + ui.html( + f'{db_label}' + ) diff --git a/src/multi_swarm_core/pyproject.toml b/src/multi_swarm_core/pyproject.toml index 866c942..18e850d 100644 --- a/src/multi_swarm_core/pyproject.toml +++ b/src/multi_swarm_core/pyproject.toml @@ -18,6 +18,8 @@ dependencies = [ "pyyaml>=6.0", "pyarrow>=18.0", "yfinance>=1.3.0", + "nicegui>=3.11.1", + "plotly>=5.24", ] [build-system] diff --git a/src/strategy_crypto/strategy_crypto/frontend/data.py b/src/strategy_crypto/strategy_crypto/frontend/data.py index 738a008..0ecb6a7 100644 --- a/src/strategy_crypto/strategy_crypto/frontend/data.py +++ b/src/strategy_crypto/strategy_crypto/frontend/data.py @@ -1,3 +1,8 @@ +"""Paper-trading data access functions for the strategy_crypto dashboard. + +Reads exclusively from strategy_crypto.db (paper_trading_* tables). +""" + from __future__ import annotations import json @@ -7,53 +12,6 @@ from typing import Any import pandas as pd # type: ignore[import-untyped] -from multi_swarm_core.persistence.repository import Repository - - -def get_repo(db_path: str | Path) -> Repository: - return Repository(db_path=db_path) - - -def list_runs_df(repo: Repository) -> pd.DataFrame: - return pd.DataFrame(repo.list_runs()) - - -def get_run_overview(repo: Repository, run_id: str) -> dict[str, Any]: - run = repo.get_run(run_id) - return { - "name": run["name"], - "started_at": run["started_at"], - "completed_at": run["completed_at"], - "status": run["status"], - "total_cost_usd": run["total_cost_usd"], - "config": json.loads(run["config_json"]), - } - - -def generations_df(repo: Repository, run_id: str) -> pd.DataFrame: - return pd.DataFrame(repo.list_generations(run_id)) - - -def evaluations_df(repo: Repository, run_id: str) -> pd.DataFrame: - return pd.DataFrame(repo.list_evaluations(run_id)) - - -def genomes_df( - repo: Repository, run_id: str, generation_idx: int | None = None -) -> pd.DataFrame: - rows = repo.list_genomes(run_id, generation_idx) - flat: list[dict[str, Any]] = [] - for r in rows: - payload = json.loads(r["payload_json"]) - flat.append( - { - "id": r["id"], - "generation_idx": r["generation_idx"], - **payload, - } - ) - return pd.DataFrame(flat) - def _paper_conn(db_path: str | Path) -> sqlite3.Connection: conn = sqlite3.connect(str(db_path)) diff --git a/src/strategy_crypto/strategy_crypto/frontend/nicegui_app.py b/src/strategy_crypto/strategy_crypto/frontend/nicegui_app.py index eca9201..0f314f2 100644 --- a/src/strategy_crypto/strategy_crypto/frontend/nicegui_app.py +++ b/src/strategy_crypto/strategy_crypto/frontend/nicegui_app.py @@ -1,9 +1,7 @@ -"""NiceGUI dashboard — port progressivo da Streamlit. +"""Strategy Crypto Dashboard — paper-trading page: /. Avvio: ``uv run python -m strategy_crypto.frontend.nicegui_app`` -Default port 8080. Streamlit resta su 8501 durante la migrazione. - -Riusa ``dashboard.data`` (Repository helpers) senza modifiche al backend. +Default port 8080. Legge SOLO strategy_crypto.db (paper_trading_* tables). Palette "Neon Trading Dashboard" (ispirata screenshot 2026-05-11): - BG: #0A0A0F (near-black con tinge blu) @@ -18,8 +16,6 @@ Palette "Neon Trading Dashboard" (ispirata screenshot 2026-05-11): from __future__ import annotations -import html -import json import os from pathlib import Path from typing import Any @@ -29,12 +25,6 @@ import plotly.graph_objects as go # type: ignore[import-untyped] from nicegui import app, ui from strategy_crypto.frontend.data import ( - evaluations_df, - generations_df, - genomes_df, - get_repo, - get_run_overview, - list_runs_df, paper_equity_df, paper_positions_df, paper_run_summary, @@ -42,840 +32,21 @@ from strategy_crypto.frontend.data import ( paper_ticks_df, paper_trades_df, ) +from multi_swarm_core.dashboard.theme import ( + COLOR_PRIMARY, + COLOR_SURFACE, + COLOR_SURFACE_2, + COLOR_TEXT, + COLOR_TEXT_MUTED, + _STATUS_BADGE, + _apply_theme, + _build_header, +) -# Dual-DB: GA core e paper strategy_crypto vivono in DB separati. -GA_DB_PATH = os.environ.get("GA_DB_PATH", "./state/runs.db") PAPER_DB_PATH = os.environ.get("STRATEGY_CRYPTO_DB_PATH", "./state/strategy_crypto.db") -# Subpath per Traefik: "" in dev, "/strategy_crypto_gui" in prod. DASHBOARD_ROOT_PATH = os.environ.get("DASHBOARD_ROOT_PATH", "") REFRESH_INTERVAL_S = 3.0 -# --- Neon Trading Dashboard palette --- -COLOR_BG = "#0A0A0F" -COLOR_SURFACE = "#13131A" -COLOR_SURFACE_2 = "#1C1C26" -COLOR_BORDER = "rgba(255, 45, 135, 0.12)" -COLOR_BORDER_HOVER = "rgba(255, 45, 135, 0.45)" -COLOR_PRIMARY = "#FF2D87" -COLOR_SECONDARY = "#00D9FF" -COLOR_ACCENT = "#FFB800" -COLOR_SUCCESS = "#00E676" -COLOR_DANGER = "#FF3D60" -COLOR_TEXT = "#FFFFFF" -COLOR_TEXT_MUTED = "#7A7A8C" - -_STATUS_BADGE: dict[str, tuple[str, str]] = { - "running": ("● running", "positive"), - "completed": ("✓ completed", "positive"), - "failed": ("✕ failed", "negative"), -} - -_CUSTOM_CSS = f""" - -""" - - -def _json_to_html(obj: Any, indent: int = 0) -> str: - """Render JSON con span colorati espliciti. Garantisce leggibilità ovunque.""" - pad = " " * indent - inner_pad = " " * (indent + 1) - if isinstance(obj, dict): - if not obj: - return '{}' - items = [] - for k, v in obj.items(): - key = f'"{html.escape(str(k))}"' - val = _json_to_html(v, indent + 1) - items.append(f"{inner_pad}{key}: {val}") - return ('{\n' - + ',\n'.join(items) - + f'\n{pad}}}') - if isinstance(obj, list): - if not obj: - return '[]' - items = [_json_to_html(x, indent + 1) for x in obj] - return ('[\n' - + ',\n'.join(inner_pad + i for i in items) - + f'\n{pad}]') - if isinstance(obj, bool): - return f'{str(obj).lower()}' - if obj is None: - return 'null' - if isinstance(obj, (int, float)): - return f'{obj}' - return f'"{html.escape(str(obj))}"' - - -def _apply_theme() -> None: - ui.add_head_html(_CUSTOM_CSS) - ui.dark_mode().enable() - ui.colors( - primary=COLOR_PRIMARY, - secondary=COLOR_SECONDARY, - accent=COLOR_ACCENT, - dark=COLOR_BG, - dark_page=COLOR_BG, - positive=COLOR_SUCCESS, - negative=COLOR_DANGER, - info=COLOR_PRIMARY, - warning=COLOR_ACCENT, - ) - - -def _build_header(active: str) -> None: - with ui.header().classes("items-center justify-between q-px-lg q-py-md"): - with ui.row().classes("items-center gap-8"): - with ui.row().classes("items-center gap-2").classes("brand"): - ui.html('') - ui.html('Multi-Swarm / Coevolutivo') - with ui.row().classes("items-center gap-1"): - for path, label in ( - ("/", "Overview"), - ("/convergence", "Convergence"), - ("/genomes", "Genomes"), - ("/paper", "Paper"), - ): - cls = "nav-link active" if active == path else "nav-link" - ui.link(label, path).classes(cls) - with ui.row().classes("items-center gap-3"): - ui.html(f'' - f'⛁ {Path(GA_DB_PATH).resolve().name} + {Path(PAPER_DB_PATH).resolve().name}') - - -def _runs_options() -> dict[str, str]: - repo = get_repo(GA_DB_PATH) - runs = list_runs_df(repo) - if runs.empty: - return {} - return { - row["id"]: f"{row['name']} — {row['status']} ({row['started_at'][:16]})" - for _, row in runs.iterrows() - } - - -def _snapshot(run_id: str) -> dict[str, Any]: - repo = get_repo(GA_DB_PATH) - ov = get_run_overview(repo, run_id) - evals = evaluations_df(repo, run_id) - gens = generations_df(repo, run_id) - - cfg = ov["config"] - pop_size = int(cfg.get("population_size", 0)) - n_gens = int(cfg.get("n_generations", 0)) - evals_total = max(pop_size * n_gens, 1) - evals_done = len(evals) - gens_done = int(gens["completed_at"].notna().sum()) if not gens.empty else 0 - # runs.total_cost_usd è 0 finché complete_run non viene chiamato. - # Per le run in corso leggiamo la somma live da cost_records. - live_cost = float(repo.total_cost(run_id)) if ov["status"] == "running" else float( - ov["total_cost_usd"] - ) - - top_fit = float(evals["fitness"].max()) if evals_done else float("nan") - median_fit = float(evals["fitness"].median()) if evals_done else float("nan") - parse_success = ( - 100.0 * float(evals["parse_error"].isna().sum()) / evals_done if evals_done else 0.0 - ) - - return { - "status": ov["status"], - "name": cfg.get("run_name", "—"), - "started_at": ov["started_at"], - "completed_at": ov["completed_at"] or "—", - "cost_usd": live_cost, - "pop_size": pop_size, - "n_gens": n_gens, - "evals_done": evals_done, - "evals_total": evals_total, - "gens_done": gens_done, - "top_fit": top_fit, - "median_fit": median_fit, - "parse_success": parse_success, - "config": cfg, - "gens_df": gens, - } - - -def _convergence_figure(gens_df: Any) -> go.Figure: - fig = go.Figure() - if gens_df.empty: - fig.add_annotation( - text="Nessuna generazione registrata", x=0.5, y=0.5, showarrow=False, - font={"color": COLOR_TEXT_MUTED, "size": 14}, - ) - else: - fig.add_trace( - go.Scatter( - x=gens_df["generation_idx"], y=gens_df["fitness_max"], - name="max", mode="lines+markers", - line={"color": COLOR_PRIMARY, "width": 3, "shape": "spline", "smoothing": 0.6}, - marker={"size": 9, "color": COLOR_PRIMARY, - "line": {"color": "#fff", "width": 1}}, - fill="tozeroy", - fillcolor="rgba(255, 45, 135, 0.12)", - ) - ) - fig.add_trace( - go.Scatter( - x=gens_df["generation_idx"], y=gens_df["fitness_p90"], - name="p90", mode="lines+markers", - line={"color": COLOR_ACCENT, "width": 2, "dash": "dot", "shape": "spline"}, - marker={"size": 7, "color": COLOR_ACCENT}, - ) - ) - fig.add_trace( - go.Scatter( - x=gens_df["generation_idx"], y=gens_df["fitness_median"], - name="median", mode="lines+markers", - line={"color": COLOR_SECONDARY, "width": 2, "shape": "spline"}, - marker={"size": 7, "color": COLOR_SECONDARY}, - ) - ) - fig.update_layout( - template="plotly_dark", - paper_bgcolor=COLOR_SURFACE, - plot_bgcolor=COLOR_SURFACE, - font={"color": COLOR_TEXT}, - xaxis={"title": "generation", "gridcolor": "rgba(148, 163, 184, 0.08)", "dtick": 1}, - yaxis={"title": "fitness", "gridcolor": "rgba(148, 163, 184, 0.08)"}, - title={"text": "Fitness convergence", "font": {"color": COLOR_TEXT, "size": 18}}, - legend={"bgcolor": "rgba(19, 19, 26, 0.95)", "bordercolor": COLOR_PRIMARY, "borderwidth": 1}, - margin={"l": 50, "r": 30, "t": 50, "b": 50}, - ) - return fig - - -def _entropy_figure(gens_df: Any) -> go.Figure: - fig = go.Figure() - if not gens_df.empty: - fig.add_trace( - go.Scatter( - x=gens_df["generation_idx"], y=gens_df["entropy"], - mode="lines+markers", - line={"color": COLOR_SECONDARY, "width": 3, "shape": "spline", "smoothing": 0.6}, - marker={"size": 9, "color": COLOR_SECONDARY, - "line": {"color": "#fff", "width": 1}}, - fill="tozeroy", - fillcolor="rgba(0, 217, 255, 0.12)", - name="entropy", - ) - ) - fig.add_hline( - y=0.5, line_dash="dash", line_color=COLOR_ACCENT, - annotation_text="gate threshold (0.5)", - annotation_font_color=COLOR_ACCENT, - ) - fig.update_layout( - template="plotly_dark", - paper_bgcolor=COLOR_SURFACE, - plot_bgcolor=COLOR_SURFACE, - font={"color": COLOR_TEXT}, - xaxis={"title": "generation", "gridcolor": "rgba(148, 163, 184, 0.08)", "dtick": 1}, - yaxis={"title": "entropy", "gridcolor": "rgba(148, 163, 184, 0.08)"}, - title={"text": "Diversity (fitness entropy)", "font": {"color": COLOR_TEXT, "size": 18}}, - margin={"l": 50, "r": 30, "t": 50, "b": 50}, - ) - return fig - - -@ui.page("/") -def index() -> None: - _apply_theme() - _build_header(active="/") - - options = _runs_options() - if not options: - ui.label("Nessuna run nel database.").classes("text-h5") - return - - state: dict[str, Any] = {"run_id": next(iter(options))} - - with ui.row().classes("w-full items-center gap-4 q-mb-md"): - select = ui.select(options=options, value=state["run_id"], label="Run").classes( - "flex-grow" - ) - status_badge = ui.badge("…", color="primary").classes("text-body1 q-pa-sm") - ui.button("🔄 Refresh", on_click=lambda: refresh()).props("outline color=primary") - - with ui.card().classes("w-full"): - ui.label("Progresso run").classes("text-subtitle1") - gen_label = ui.label("Generations: 0/0") - gen_bar = ui.linear_progress(0.0, show_value=False).props("size=20px color=primary") - eval_label = ui.label("Evaluations: 0/0 (0.0%)") - eval_bar = ui.linear_progress(0.0, show_value=False).props("size=20px color=accent") - - with ui.row().classes("w-full gap-4"): - with ui.card().classes("flex-grow metric-card accent-cyan"): - ui.label("Top fitness").classes("text-caption") - top_lbl = ui.label("—").classes("text-h4") - with ui.card().classes("flex-grow metric-card accent-purple"): - ui.label("Median fitness").classes("text-caption") - median_lbl = ui.label("—").classes("text-h4") - with ui.card().classes("flex-grow metric-card accent-amber"): - ui.label("Parse success").classes("text-caption") - parse_lbl = ui.label("—").classes("text-h4") - with ui.card().classes("flex-grow metric-card accent-green"): - ui.label("Cost (USD)").classes("text-caption") - cost_lbl = ui.label("—").classes("text-h4") - - with ui.row().classes("w-full gap-4 q-mt-md"): - started_lbl = ui.label("Started: —") - completed_lbl = ui.label("Completed: —") - ui.separator() - ui.label("Config").classes("text-subtitle1") - cfg_code = ui.html('
').classes("w-full")
-
-    def refresh() -> None:
-        run_id = select.value
-        if not run_id:
-            return
-        try:
-            s = _snapshot(run_id)
-        except Exception as e:  # noqa: BLE001
-            ui.notify(f"Errore: {e}", type="negative")
-            return
-
-        text, color = _STATUS_BADGE.get(s["status"], (s["status"], "primary"))
-        status_badge.text = text
-        status_badge.props(f"color={color}")
-
-        gen_frac = min(s["gens_done"] / max(s["n_gens"], 1), 1.0)
-        eval_frac = min(s["evals_done"] / s["evals_total"], 1.0)
-        gen_bar.value = gen_frac
-        eval_bar.value = eval_frac
-        gen_label.text = f"Generations: {s['gens_done']}/{s['n_gens']}"
-        eval_label.text = (
-            f"Evaluations: {s['evals_done']}/{s['evals_total']} ({100 * eval_frac:.1f}%)"
-        )
-
-        top_lbl.text = f"{s['top_fit']:.4f}" if s["evals_done"] else "—"
-        median_lbl.text = f"{s['median_fit']:.4f}" if s["evals_done"] else "—"
-        parse_lbl.text = f"{s['parse_success']:.1f}%" if s["evals_done"] else "—"
-        cost_lbl.text = f"${s['cost_usd']:.4f}"
-
-        started_lbl.text = f"Started: {s['started_at']}"
-        completed_lbl.text = f"Completed: {s['completed_at']}"
-        cfg_code.content = f'
{_json_to_html(s["config"])}
' - - def on_select_change() -> None: - state["run_id"] = select.value - refresh() - - select.on_value_change(on_select_change) - ui.timer(REFRESH_INTERVAL_S, refresh) - refresh() - - -@ui.page("/convergence") -def convergence() -> None: - _apply_theme() - _build_header(active="/convergence") - - options = _runs_options() - if not options: - ui.label("Nessuna run nel database.").classes("text-h5") - return - - state: dict[str, Any] = {"run_id": next(iter(options))} - - with ui.row().classes("w-full items-center gap-4 q-mb-md"): - select = ui.select(options=options, value=state["run_id"], label="Run").classes( - "flex-grow" - ) - gen_count_lbl = ui.label("Gens: 0/0").classes("text-body1").style( - f"color: {COLOR_PRIMARY}; font-weight: 600;" - ) - ui.button("🔄 Refresh", on_click=lambda: refresh()).props("outline color=primary") - - fitness_plot = ui.plotly(_convergence_figure(generations_df(get_repo(GA_DB_PATH), state["run_id"]))).classes("w-full") - entropy_plot = ui.plotly(_entropy_figure(generations_df(get_repo(GA_DB_PATH), state["run_id"]))).classes("w-full q-mt-md") - - ui.separator() - ui.label("Tabella generazioni").classes("text-subtitle1 q-mt-md") - gens_table = ui.table( - columns=[ - {"name": "generation_idx", "label": "gen", "field": "generation_idx", "sortable": True}, - {"name": "n_genomes", "label": "n", "field": "n_genomes"}, - {"name": "fitness_max", "label": "max", "field": "fitness_max"}, - {"name": "fitness_p90", "label": "p90", "field": "fitness_p90"}, - {"name": "fitness_median", "label": "median", "field": "fitness_median"}, - {"name": "entropy", "label": "entropy", "field": "entropy"}, - {"name": "completed_at", "label": "completed", "field": "completed_at"}, - ], - rows=[], - row_key="generation_idx", - ).classes("w-full") - - def refresh() -> None: - run_id = select.value - if not run_id: - return - try: - gens = generations_df(get_repo(GA_DB_PATH), run_id) - ov = get_run_overview(get_repo(GA_DB_PATH), run_id) - except Exception as e: # noqa: BLE001 - ui.notify(f"Errore: {e}", type="negative") - return - - n_gens = int(ov["config"].get("n_generations", 0)) - gens_done = int(gens["completed_at"].notna().sum()) if not gens.empty else 0 - gen_count_lbl.text = f"Gens: {gens_done}/{n_gens}" - - fitness_plot.update_figure(_convergence_figure(gens)) - entropy_plot.update_figure(_entropy_figure(gens)) - - if gens.empty: - gens_table.rows = [] - else: - display_cols = [ - "generation_idx", "n_genomes", - "fitness_max", "fitness_p90", "fitness_median", - "entropy", "completed_at", - ] - gens_table.rows = [ - { - col: (round(v, 6) if isinstance(v, float) else v) - for col, v in row.items() - if col in display_cols - } - for _, row in gens.iterrows() - ] - gens_table.update() - - def on_select_change() -> None: - state["run_id"] = select.value - refresh() - - select.on_value_change(on_select_change) - ui.timer(REFRESH_INTERVAL_S, refresh) - refresh() - - -@ui.page("/genomes") -def genomes() -> None: - _apply_theme() - _build_header(active="/genomes") - - options = _runs_options() - if not options: - ui.label("Nessuna run nel database.").classes("text-h5") - return - - state: dict[str, Any] = { - "run_id": next(iter(options)), - "selected_gid": None, - "merged": None, - } - - with ui.row().classes("w-full items-center gap-4 q-mb-md"): - select = ui.select(options=options, value=state["run_id"], label="Run").classes( - "flex-grow" - ) - top_k_select = ui.select( - options={10: "Top 10", 25: "Top 25", 50: "Top 50"}, - value=10, - label="Top K", - ) - ui.button("🔄 Refresh", on_click=lambda: refresh()).props("outline color=primary") - - ui.label("Top genomi per fitness").classes("text-subtitle1 q-mt-sm") - top_table = ui.table( - columns=[ - {"name": "genome_id", "label": "id", "field": "genome_id", "align": "left"}, - {"name": "fitness", "label": "fitness", "field": "fitness", "sortable": True}, - {"name": "dsr", "label": "DSR", "field": "dsr"}, - {"name": "sharpe", "label": "Sharpe", "field": "sharpe"}, - {"name": "max_dd", "label": "max DD", "field": "max_dd"}, - {"name": "n_trades", "label": "trades", "field": "n_trades"}, - {"name": "cognitive_style", "label": "style", "field": "cognitive_style"}, - {"name": "temperature", "label": "T", "field": "temperature"}, - {"name": "lookback_window", "label": "lookback", "field": "lookback_window"}, - ], - rows=[], - row_key="genome_id", - selection="single", - ).classes("w-full") - - ui.separator().classes("q-my-md") - - with ui.card().classes("w-full"): - ui.label("Ispezione genoma").classes("text-subtitle1") - detail_hint = ui.label("Seleziona un genoma dalla tabella sopra.").classes( - "text-caption" - ).style(f"color: {COLOR_TEXT_MUTED};") - - with ui.row().classes("w-full gap-4 q-mt-sm"): - with ui.card().classes("flex-grow metric-card accent-cyan"): - ui.label("fitness").classes("text-caption") - fit_lbl = ui.label("—").classes("text-h4") - with ui.card().classes("flex-grow metric-card accent-purple"): - ui.label("DSR").classes("text-caption") - dsr_lbl = ui.label("—").classes("text-h4") - with ui.card().classes("flex-grow metric-card accent-amber"): - ui.label("Sharpe").classes("text-caption") - sharpe_lbl = ui.label("—").classes("text-h4") - with ui.card().classes("flex-grow metric-card"): - ui.label("max DD").classes("text-caption") - dd_lbl = ui.label("—").classes("text-h4") - with ui.card().classes("flex-grow metric-card accent-green"): - ui.label("trades").classes("text-caption") - trades_lbl = ui.label("—").classes("text-h4") - with ui.card().classes("flex-grow metric-card"): - ui.label("style").classes("text-caption") - style_lbl = ui.label("—").classes("text-h4") - - ui.label("System prompt").classes("text-subtitle1 q-mt-md") - prompt_code = ui.html('
').classes("w-full") - - ui.label("Raw LLM output").classes("text-subtitle1 q-mt-md") - raw_code = ui.html('
').classes("w-full") - - parse_error_lbl = ui.label("").classes("q-mt-sm").style( - "color: #FF6B6B; font-weight: 600;" - ) - - def _render_detail(row: dict[str, Any]) -> None: - detail_hint.text = f"Genoma: {row.get('genome_id', '—')}" - fit_lbl.text = f"{float(row.get('fitness', 0)):.4f}" - dsr_lbl.text = f"{float(row.get('dsr', 0)):.4f}" - sharpe_lbl.text = f"{float(row.get('sharpe', 0)):.3f}" - dd_lbl.text = f"{float(row.get('max_dd', 0)):.3f}" - trades_lbl.text = str(int(row.get("n_trades", 0))) - style_lbl.text = str(row.get("cognitive_style", "—")) - prompt_code.content = ( - f'
{html.escape(str(row.get("system_prompt", "—")))}
' - ) - raw_code.content = ( - f'
{html.escape(str(row.get("raw_text", "—") or "—"))}
' - ) - pe = row.get("parse_error") - parse_error_lbl.text = f"❌ Parse error: {pe}" if pe else "" - - def refresh() -> None: - run_id = select.value - if not run_id: - return - try: - repo = get_repo(GA_DB_PATH) - evals = evaluations_df(repo, run_id) - gens = genomes_df(repo, run_id) - except Exception as e: # noqa: BLE001 - ui.notify(f"Errore: {e}", type="negative") - return - - if evals.empty: - top_table.rows = [] - top_table.update() - return - - merged = evals.merge( - gens, left_on="genome_id", right_on="id", how="left", suffixes=("", "_g") - ) - state["merged"] = merged - - k = int(top_k_select.value) - top = merged.sort_values("fitness", ascending=False).head(k) - - rows = [] - for _, r in top.iterrows(): - rows.append( - { - "genome_id": str(r.get("genome_id", "—"))[:12] + "…", - "fitness": round(float(r.get("fitness", 0)), 4), - "dsr": round(float(r.get("dsr", 0)), 4), - "sharpe": round(float(r.get("sharpe", 0)), 3), - "max_dd": round(float(r.get("max_dd", 0)), 3), - "n_trades": int(r.get("n_trades", 0)), - "cognitive_style": str(r.get("cognitive_style", "—")), - "temperature": round(float(r.get("temperature", 0)), 2), - "lookback_window": int(r.get("lookback_window", 0)), - "_full_id": str(r.get("genome_id", "")), - } - ) - top_table.rows = rows - top_table.update() - - sel = state.get("selected_gid") - if sel: - match = merged[merged["genome_id"] == sel] - if not match.empty: - _render_detail(match.iloc[0].to_dict()) - - def on_row_selected(e: Any) -> None: - rows = (e.args or {}).get("rows") or [] - if not rows: - return - full_id = rows[0].get("_full_id") - if not full_id: - return - state["selected_gid"] = full_id - merged = state.get("merged") - if merged is None: - return - match = merged[merged["genome_id"] == full_id] - if not match.empty: - _render_detail(match.iloc[0].to_dict()) - - def on_select_change() -> None: - state["run_id"] = select.value - state["selected_gid"] = None - refresh() - - select.on_value_change(on_select_change) - top_k_select.on_value_change(lambda _: refresh()) - top_table.on("selection", on_row_selected) - ui.timer(REFRESH_INTERVAL_S, refresh) - refresh() - def _paper_runs_options(only_running: bool = False) -> dict[str, str]: runs = paper_runs_df(PAPER_DB_PATH) @@ -925,10 +96,15 @@ def _paper_equity_figure(eq_df: Any, initial_capital: float) -> go.Figure: return fig -@ui.page("/paper") +@ui.page("/") def paper() -> None: _apply_theme() - _build_header(active="/paper") + _build_header( + active="/", + brand_subtitle="Strategy Crypto", + nav_items=[("/", "Paper")], + db_label=f"⛁ {Path(PAPER_DB_PATH).resolve().name}", + ) options = _paper_runs_options() if not options: @@ -1087,7 +263,6 @@ def paper() -> None: def main() -> None: app.on_startup( lambda: print( - f"GA DB: {Path(GA_DB_PATH).resolve()} | " f"Paper DB: {Path(PAPER_DB_PATH).resolve()} | " f"root_path: {DASHBOARD_ROOT_PATH or '/'}" ) diff --git a/uv.lock b/uv.lock index c64a9b6..d2e48fa 100644 --- a/uv.lock +++ b/uv.lock @@ -936,9 +936,11 @@ version = "0.1.0" source = { editable = "src/multi_swarm_core" } dependencies = [ { name = "httpx" }, + { name = "nicegui" }, { name = "numpy" }, { name = "openai" }, { name = "pandas" }, + { name = "plotly" }, { name = "pyarrow" }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -953,9 +955,11 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.28" }, + { name = "nicegui", specifier = ">=3.11.1" }, { name = "numpy", specifier = ">=2.1" }, { name = "openai", specifier = ">=1.55" }, { name = "pandas", specifier = ">=2.2" }, + { name = "plotly", specifier = ">=5.24" }, { name = "pyarrow", specifier = ">=18.0" }, { name = "pydantic", specifier = ">=2.9" }, { name = "pydantic-settings", specifier = ">=2.6" },