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" },