refactor(gui): split dashboard in core (GA) + strategy_crypto (paper)

- 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) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-05-15 19:23:12 +00:00
parent 436613bfde
commit a43157cd44
10 changed files with 1092 additions and 904 deletions
+6 -3
View File
@@ -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
+45 -10
View File
@@ -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
@@ -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)
@@ -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('<pre class="config-block"></pre>').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'<pre class="config-block">{_json_to_html(s["config"])}</pre>'
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('<pre class="raw-block">—</pre>').classes("w-full")
ui.label("Raw LLM output").classes("text-subtitle1 q-mt-md")
raw_code = ui.html('<pre class="raw-block">—</pre>').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'<pre class="raw-block">{html.escape(str(row.get("system_prompt", "")))}</pre>'
)
raw_code.content = (
f'<pre class="raw-block">{html.escape(str(row.get("raw_text", "") or ""))}</pre>'
)
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()
@@ -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"""
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
html, body, .q-page, .q-card, .q-btn, .q-field, .q-table, .text-h4, .text-h6, .text-subtitle1, .text-caption, .text-body1, .nav-link, .brand, label, p, span, div {{
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
letter-spacing: -0.01em;
}}
.material-icons, .material-icons-outlined, .material-symbols-outlined, .q-icon, i.q-icon, i[class*="material"] {{
font-family: 'Material Icons' !important;
font-feature-settings: 'liga';
letter-spacing: normal !important;
}}
code, pre, .q-code, .nicegui-code {{ font-family: 'JetBrains Mono', 'Fira Code', monospace !important; font-size: 13.5px !important; }}
body, .q-page-container, .q-page {{
background: {COLOR_BG} !important;
color: {COLOR_TEXT};
background-image:
radial-gradient(ellipse 800px 400px at 20% 0%, rgba(255, 45, 135, 0.08) 0%, transparent 60%),
radial-gradient(ellipse 600px 400px at 80% 100%, rgba(0, 217, 255, 0.06) 0%, transparent 60%);
background-attachment: fixed;
}}
.q-card {{
background: {COLOR_SURFACE} !important;
color: {COLOR_TEXT} !important;
border: 1px solid {COLOR_BORDER};
border-radius: 14px !important;
box-shadow:
0 1px 2px rgba(0,0,0,0.5),
0 8px 24px rgba(0,0,0,0.25),
inset 0 1px 0 rgba(255,255,255,0.04);
transition: all 0.2s ease;
position: relative;
overflow: hidden;
}}
.q-card::before {{
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 45, 135, 0.4), transparent);
opacity: 0.5;
}}
.q-card:hover {{
border-color: rgba(255, 45, 135, 0.5);
box-shadow:
0 1px 2px rgba(0,0,0,0.5),
0 8px 32px rgba(255, 45, 135, 0.15),
inset 0 1px 0 rgba(255,255,255,0.05);
}}
.metric-card {{
padding: 20px 16px;
text-align: left;
display: flex;
flex-direction: column;
gap: 6px;
min-width: 140px;
}}
.metric-card .text-caption {{
color: {COLOR_TEXT_MUTED} !important;
font-size: 11px !important;
font-weight: 500 !important;
text-transform: uppercase;
letter-spacing: 0.06em;
}}
.metric-card .text-h4 {{
color: {COLOR_TEXT} !important;
font-weight: 600 !important;
font-size: 26px !important;
line-height: 1.2 !important;
font-feature-settings: 'tnum';
}}
.metric-card.accent-cyan .text-h4 {{ color: {COLOR_PRIMARY} !important; }}
.metric-card.accent-purple .text-h4 {{ color: {COLOR_SECONDARY} !important; }}
.metric-card.accent-amber .text-h4 {{ color: {COLOR_ACCENT} !important; }}
.metric-card.accent-green .text-h4 {{ color: {COLOR_SUCCESS} !important; }}
.q-header {{
background: rgba(10, 10, 15, 0.75) !important;
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border-bottom: 1px solid {COLOR_BORDER} !important;
box-shadow: 0 1px 0 rgba(255, 45, 135, 0.15) !important;
}}
.nav-link {{
color: {COLOR_TEXT_MUTED} !important;
padding: 8px 14px;
border-radius: 8px;
text-decoration: none;
font-size: 13.5px;
font-weight: 500;
transition: all 0.15s ease;
position: relative;
}}
.nav-link:hover {{
color: {COLOR_TEXT} !important;
background: {COLOR_SURFACE_2};
}}
.nav-link.active {{
color: {COLOR_PRIMARY} !important;
background: rgba(255, 45, 135, 0.08);
}}
.nav-link.active::after {{
content: '';
position: absolute;
bottom: -16px;
left: 14px;
right: 14px;
height: 2px;
background: {COLOR_PRIMARY};
border-radius: 2px 2px 0 0;
}}
.brand {{
color: {COLOR_TEXT};
font-weight: 700;
font-size: 15px;
display: flex;
align-items: center;
gap: 8px;
}}
.brand-dot {{
width: 10px;
height: 10px;
border-radius: 50%;
background: {COLOR_PRIMARY};
box-shadow: 0 0 16px {COLOR_PRIMARY}, 0 0 4px {COLOR_PRIMARY};
animation: pulse-pink 2s ease-in-out infinite;
}}
@keyframes pulse-pink {{
0%, 100% {{ box-shadow: 0 0 16px {COLOR_PRIMARY}, 0 0 4px {COLOR_PRIMARY}; }}
50% {{ box-shadow: 0 0 24px {COLOR_PRIMARY}, 0 0 8px {COLOR_PRIMARY}; }}
}}
.q-linear-progress {{ height: 8px !important; border-radius: 6px !important; }}
.q-linear-progress__track {{ background: {COLOR_SURFACE_2} !important; }}
.q-linear-progress__model {{ border-radius: 6px !important; }}
.q-separator {{ background: {COLOR_BORDER} !important; }}
.q-field--outlined .q-field__control {{
background: {COLOR_SURFACE} !important;
border-radius: 8px !important;
}}
.q-field--outlined .q-field__control:before {{ border-color: {COLOR_BORDER} !important; }}
.q-field--outlined.q-field--focused .q-field__control:after {{ border-color: {COLOR_PRIMARY} !important; }}
.q-field__label {{ color: {COLOR_TEXT_MUTED} !important; }}
.q-field__native, .q-field__input {{ color: {COLOR_TEXT} !important; }}
.q-btn {{ border-radius: 8px !important; font-weight: 500 !important; text-transform: none !important; letter-spacing: 0 !important; }}
.q-table {{ background: transparent !important; color: {COLOR_TEXT} !important; }}
.q-table thead tr {{ background: {COLOR_SURFACE_2} !important; }}
.q-table th {{
color: {COLOR_TEXT_MUTED} !important;
font-size: 11px !important;
font-weight: 600 !important;
text-transform: uppercase;
letter-spacing: 0.06em;
}}
.q-table tbody tr {{ transition: background 0.15s; }}
.q-table tbody tr:hover {{ background: rgba(255, 45, 135, 0.05) !important; }}
.q-table tbody tr.selected {{ background: rgba(255, 45, 135, 0.12) !important; }}
.q-table td {{ border-bottom: 1px solid {COLOR_BORDER} !important; font-feature-settings: 'tnum'; }}
.text-h6 {{ font-weight: 600 !important; letter-spacing: -0.015em !important; }}
.text-subtitle1 {{
color: {COLOR_TEXT_MUTED} !important;
font-size: 11px !important;
font-weight: 600 !important;
text-transform: uppercase !important;
letter-spacing: 0.08em !important;
margin-bottom: 8px !important;
}}
code, pre, .nicegui-code {{
background: #1A1A24 !important;
color: {COLOR_TEXT} !important;
border: 1px solid {COLOR_BORDER};
border-radius: 10px !important;
padding: 16px !important;
font-size: 13.5px !important;
line-height: 1.6 !important;
}}
.hljs {{ background: transparent !important; color: {COLOR_TEXT} !important; }}
.hljs-attr, .hljs-attribute {{ color: {COLOR_SECONDARY} !important; font-weight: 500; }}
.hljs-string {{ color: {COLOR_SUCCESS} !important; }}
.hljs-number, .hljs-literal {{ color: {COLOR_PRIMARY} !important; font-weight: 500; }}
.hljs-keyword, .hljs-built_in {{ color: {COLOR_ACCENT} !important; }}
.hljs-punctuation, .hljs-meta {{ color: {COLOR_TEXT_MUTED} !important; }}
.hljs-comment {{ color: {COLOR_TEXT_MUTED} !important; font-style: italic; }}
.hljs-name, .hljs-title {{ color: {COLOR_PRIMARY} !important; }}
/* Prism.js tokens (NiceGUI usa Prism per ui.code) */
.token.property, .token.attr-name, .token.tag {{ color: {COLOR_SECONDARY} !important; font-weight: 500; }}
.token.string, .token.url {{ color: {COLOR_SUCCESS} !important; }}
.token.number, .token.boolean, .token.null, .token.symbol {{ color: {COLOR_PRIMARY} !important; font-weight: 500; }}
.token.keyword, .token.constant, .token.builtin, .token.atrule {{ color: {COLOR_ACCENT} !important; }}
.token.punctuation, .token.operator {{ color: {COLOR_TEXT_MUTED} !important; }}
.token.comment {{ color: {COLOR_TEXT_MUTED} !important; font-style: italic; }}
.token.function, .token.class-name {{ color: {COLOR_PRIMARY} !important; }}
pre[class*="language-"], code[class*="language-"] {{
color: {COLOR_TEXT} !important;
text-shadow: none !important;
}}
.q-badge {{ border-radius: 6px !important; font-weight: 500 !important; padding: 4px 10px !important; font-size: 12px !important; }}
.config-block {{
background: #1A1A24;
color: {COLOR_TEXT};
border: 1px solid {COLOR_BORDER};
border-radius: 10px;
padding: 18px 20px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 13.5px;
line-height: 1.7;
overflow-x: auto;
white-space: pre;
margin: 0;
}}
.config-block .cb-key {{ color: {COLOR_SECONDARY}; font-weight: 500; }}
.config-block .cb-string {{ color: {COLOR_SUCCESS}; }}
.config-block .cb-number {{ color: {COLOR_PRIMARY}; font-weight: 500; }}
.config-block .cb-bool {{ color: {COLOR_ACCENT}; }}
.config-block .cb-null {{ color: {COLOR_ACCENT}; font-style: italic; }}
.config-block .cb-punct {{ color: {COLOR_TEXT_MUTED}; }}
.raw-block {{
background: #1A1A24;
color: {COLOR_TEXT};
border: 1px solid {COLOR_BORDER};
border-radius: 10px;
padding: 18px 20px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 13px;
line-height: 1.6;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
max-height: 400px;
overflow-y: auto;
margin: 0;
}}
</style>
"""
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 '<span class="cb-punct">{}</span>'
items = []
for k, v in obj.items():
key = f'<span class="cb-key">"{html.escape(str(k))}"</span>'
val = _json_to_html(v, indent + 1)
items.append(f"{inner_pad}{key}<span class=\"cb-punct\">:</span> {val}")
return ('<span class="cb-punct">{</span>\n'
+ '<span class="cb-punct">,</span>\n'.join(items)
+ f'\n{pad}<span class="cb-punct">}}</span>')
if isinstance(obj, list):
if not obj:
return '<span class="cb-punct">[]</span>'
items = [_json_to_html(x, indent + 1) for x in obj]
return ('<span class="cb-punct">[</span>\n'
+ '<span class="cb-punct">,</span>\n'.join(inner_pad + i for i in items)
+ f'\n{pad}<span class="cb-punct">]</span>')
if isinstance(obj, bool):
return f'<span class="cb-bool">{str(obj).lower()}</span>'
if obj is None:
return '<span class="cb-null">null</span>'
if isinstance(obj, (int, float)):
return f'<span class="cb-number">{obj}</span>'
return f'<span class="cb-string">"{html.escape(str(obj))}"</span>'
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('<span class="brand-dot"></span>')
ui.html(
f'<span class="brand">Multi-Swarm <span style="color:{COLOR_TEXT_MUTED}'
f';font-weight:400;">/ {brand_subtitle}</span></span>'
)
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'<span style="color:{COLOR_TEXT_MUTED};font-size:12px;'
f'font-family:JetBrains Mono,monospace;">{db_label}</span>'
)
+2
View File
@@ -18,6 +18,8 @@ dependencies = [
"pyyaml>=6.0",
"pyarrow>=18.0",
"yfinance>=1.3.0",
"nicegui>=3.11.1",
"plotly>=5.24",
]
[build-system]
@@ -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))
@@ -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"""
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
html, body, .q-page, .q-card, .q-btn, .q-field, .q-table, .text-h4, .text-h6, .text-subtitle1, .text-caption, .text-body1, .nav-link, .brand, label, p, span, div {{
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
letter-spacing: -0.01em;
}}
.material-icons, .material-icons-outlined, .material-symbols-outlined, .q-icon, i.q-icon, i[class*="material"] {{
font-family: 'Material Icons' !important;
font-feature-settings: 'liga';
letter-spacing: normal !important;
}}
code, pre, .q-code, .nicegui-code {{ font-family: 'JetBrains Mono', 'Fira Code', monospace !important; font-size: 13.5px !important; }}
body, .q-page-container, .q-page {{
background: {COLOR_BG} !important;
color: {COLOR_TEXT};
background-image:
radial-gradient(ellipse 800px 400px at 20% 0%, rgba(255, 45, 135, 0.08) 0%, transparent 60%),
radial-gradient(ellipse 600px 400px at 80% 100%, rgba(0, 217, 255, 0.06) 0%, transparent 60%);
background-attachment: fixed;
}}
.q-card {{
background: {COLOR_SURFACE} !important;
color: {COLOR_TEXT} !important;
border: 1px solid {COLOR_BORDER};
border-radius: 14px !important;
box-shadow:
0 1px 2px rgba(0,0,0,0.5),
0 8px 24px rgba(0,0,0,0.25),
inset 0 1px 0 rgba(255,255,255,0.04);
transition: all 0.2s ease;
position: relative;
overflow: hidden;
}}
.q-card::before {{
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 45, 135, 0.4), transparent);
opacity: 0.5;
}}
.q-card:hover {{
border-color: rgba(255, 45, 135, 0.5);
box-shadow:
0 1px 2px rgba(0,0,0,0.5),
0 8px 32px rgba(255, 45, 135, 0.15),
inset 0 1px 0 rgba(255,255,255,0.05);
}}
.metric-card {{
padding: 20px 16px;
text-align: left;
display: flex;
flex-direction: column;
gap: 6px;
min-width: 140px;
}}
.metric-card .text-caption {{
color: {COLOR_TEXT_MUTED} !important;
font-size: 11px !important;
font-weight: 500 !important;
text-transform: uppercase;
letter-spacing: 0.06em;
}}
.metric-card .text-h4 {{
color: {COLOR_TEXT} !important;
font-weight: 600 !important;
font-size: 26px !important;
line-height: 1.2 !important;
font-feature-settings: 'tnum';
}}
.metric-card.accent-cyan .text-h4 {{ color: {COLOR_PRIMARY} !important; }}
.metric-card.accent-purple .text-h4 {{ color: {COLOR_SECONDARY} !important; }}
.metric-card.accent-amber .text-h4 {{ color: {COLOR_ACCENT} !important; }}
.metric-card.accent-green .text-h4 {{ color: {COLOR_SUCCESS} !important; }}
.q-header {{
background: rgba(10, 10, 15, 0.75) !important;
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border-bottom: 1px solid {COLOR_BORDER} !important;
box-shadow: 0 1px 0 rgba(255, 45, 135, 0.15) !important;
}}
.nav-link {{
color: {COLOR_TEXT_MUTED} !important;
padding: 8px 14px;
border-radius: 8px;
text-decoration: none;
font-size: 13.5px;
font-weight: 500;
transition: all 0.15s ease;
position: relative;
}}
.nav-link:hover {{
color: {COLOR_TEXT} !important;
background: {COLOR_SURFACE_2};
}}
.nav-link.active {{
color: {COLOR_PRIMARY} !important;
background: rgba(255, 45, 135, 0.08);
}}
.nav-link.active::after {{
content: '';
position: absolute;
bottom: -16px;
left: 14px;
right: 14px;
height: 2px;
background: {COLOR_PRIMARY};
border-radius: 2px 2px 0 0;
}}
.brand {{
color: {COLOR_TEXT};
font-weight: 700;
font-size: 15px;
display: flex;
align-items: center;
gap: 8px;
}}
.brand-dot {{
width: 10px;
height: 10px;
border-radius: 50%;
background: {COLOR_PRIMARY};
box-shadow: 0 0 16px {COLOR_PRIMARY}, 0 0 4px {COLOR_PRIMARY};
animation: pulse-pink 2s ease-in-out infinite;
}}
@keyframes pulse-pink {{
0%, 100% {{ box-shadow: 0 0 16px {COLOR_PRIMARY}, 0 0 4px {COLOR_PRIMARY}; }}
50% {{ box-shadow: 0 0 24px {COLOR_PRIMARY}, 0 0 8px {COLOR_PRIMARY}; }}
}}
.q-linear-progress {{ height: 8px !important; border-radius: 6px !important; }}
.q-linear-progress__track {{ background: {COLOR_SURFACE_2} !important; }}
.q-linear-progress__model {{ border-radius: 6px !important; }}
.q-separator {{ background: {COLOR_BORDER} !important; }}
.q-field--outlined .q-field__control {{
background: {COLOR_SURFACE} !important;
border-radius: 8px !important;
}}
.q-field--outlined .q-field__control:before {{ border-color: {COLOR_BORDER} !important; }}
.q-field--outlined.q-field--focused .q-field__control:after {{ border-color: {COLOR_PRIMARY} !important; }}
.q-field__label {{ color: {COLOR_TEXT_MUTED} !important; }}
.q-field__native, .q-field__input {{ color: {COLOR_TEXT} !important; }}
.q-btn {{ border-radius: 8px !important; font-weight: 500 !important; text-transform: none !important; letter-spacing: 0 !important; }}
.q-table {{ background: transparent !important; color: {COLOR_TEXT} !important; }}
.q-table thead tr {{ background: {COLOR_SURFACE_2} !important; }}
.q-table th {{
color: {COLOR_TEXT_MUTED} !important;
font-size: 11px !important;
font-weight: 600 !important;
text-transform: uppercase;
letter-spacing: 0.06em;
}}
.q-table tbody tr {{ transition: background 0.15s; }}
.q-table tbody tr:hover {{ background: rgba(255, 45, 135, 0.05) !important; }}
.q-table tbody tr.selected {{ background: rgba(255, 45, 135, 0.12) !important; }}
.q-table td {{ border-bottom: 1px solid {COLOR_BORDER} !important; font-feature-settings: 'tnum'; }}
.text-h6 {{ font-weight: 600 !important; letter-spacing: -0.015em !important; }}
.text-subtitle1 {{
color: {COLOR_TEXT_MUTED} !important;
font-size: 11px !important;
font-weight: 600 !important;
text-transform: uppercase !important;
letter-spacing: 0.08em !important;
margin-bottom: 8px !important;
}}
code, pre, .nicegui-code {{
background: #1A1A24 !important;
color: {COLOR_TEXT} !important;
border: 1px solid {COLOR_BORDER};
border-radius: 10px !important;
padding: 16px !important;
font-size: 13.5px !important;
line-height: 1.6 !important;
}}
.hljs {{ background: transparent !important; color: {COLOR_TEXT} !important; }}
.hljs-attr, .hljs-attribute {{ color: {COLOR_SECONDARY} !important; font-weight: 500; }}
.hljs-string {{ color: {COLOR_SUCCESS} !important; }}
.hljs-number, .hljs-literal {{ color: {COLOR_PRIMARY} !important; font-weight: 500; }}
.hljs-keyword, .hljs-built_in {{ color: {COLOR_ACCENT} !important; }}
.hljs-punctuation, .hljs-meta {{ color: {COLOR_TEXT_MUTED} !important; }}
.hljs-comment {{ color: {COLOR_TEXT_MUTED} !important; font-style: italic; }}
.hljs-name, .hljs-title {{ color: {COLOR_PRIMARY} !important; }}
/* Prism.js tokens (NiceGUI usa Prism per ui.code) */
.token.property, .token.attr-name, .token.tag {{ color: {COLOR_SECONDARY} !important; font-weight: 500; }}
.token.string, .token.url {{ color: {COLOR_SUCCESS} !important; }}
.token.number, .token.boolean, .token.null, .token.symbol {{ color: {COLOR_PRIMARY} !important; font-weight: 500; }}
.token.keyword, .token.constant, .token.builtin, .token.atrule {{ color: {COLOR_ACCENT} !important; }}
.token.punctuation, .token.operator {{ color: {COLOR_TEXT_MUTED} !important; }}
.token.comment {{ color: {COLOR_TEXT_MUTED} !important; font-style: italic; }}
.token.function, .token.class-name {{ color: {COLOR_PRIMARY} !important; }}
pre[class*="language-"], code[class*="language-"] {{
color: {COLOR_TEXT} !important;
text-shadow: none !important;
}}
.q-badge {{ border-radius: 6px !important; font-weight: 500 !important; padding: 4px 10px !important; font-size: 12px !important; }}
.config-block {{
background: #1A1A24;
color: {COLOR_TEXT};
border: 1px solid {COLOR_BORDER};
border-radius: 10px;
padding: 18px 20px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 13.5px;
line-height: 1.7;
overflow-x: auto;
white-space: pre;
margin: 0;
}}
.config-block .cb-key {{ color: {COLOR_SECONDARY}; font-weight: 500; }}
.config-block .cb-string {{ color: {COLOR_SUCCESS}; }}
.config-block .cb-number {{ color: {COLOR_PRIMARY}; font-weight: 500; }}
.config-block .cb-bool {{ color: {COLOR_ACCENT}; }}
.config-block .cb-null {{ color: {COLOR_ACCENT}; font-style: italic; }}
.config-block .cb-punct {{ color: {COLOR_TEXT_MUTED}; }}
.raw-block {{
background: #1A1A24;
color: {COLOR_TEXT};
border: 1px solid {COLOR_BORDER};
border-radius: 10px;
padding: 18px 20px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 13px;
line-height: 1.6;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
max-height: 400px;
overflow-y: auto;
margin: 0;
}}
</style>
"""
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 '<span class="cb-punct">{}</span>'
items = []
for k, v in obj.items():
key = f'<span class="cb-key">"{html.escape(str(k))}"</span>'
val = _json_to_html(v, indent + 1)
items.append(f"{inner_pad}{key}<span class=\"cb-punct\">:</span> {val}")
return ('<span class="cb-punct">{</span>\n'
+ '<span class="cb-punct">,</span>\n'.join(items)
+ f'\n{pad}<span class="cb-punct">}}</span>')
if isinstance(obj, list):
if not obj:
return '<span class="cb-punct">[]</span>'
items = [_json_to_html(x, indent + 1) for x in obj]
return ('<span class="cb-punct">[</span>\n'
+ '<span class="cb-punct">,</span>\n'.join(inner_pad + i for i in items)
+ f'\n{pad}<span class="cb-punct">]</span>')
if isinstance(obj, bool):
return f'<span class="cb-bool">{str(obj).lower()}</span>'
if obj is None:
return '<span class="cb-null">null</span>'
if isinstance(obj, (int, float)):
return f'<span class="cb-number">{obj}</span>'
return f'<span class="cb-string">"{html.escape(str(obj))}"</span>'
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('<span class="brand-dot"></span>')
ui.html('<span class="brand">Multi-Swarm <span style="color:'
+ COLOR_TEXT_MUTED + ';font-weight:400;">/ Coevolutivo</span></span>')
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'<span style="color:{COLOR_TEXT_MUTED};font-size:12px;'
f'font-family:JetBrains Mono,monospace;">'
f'{Path(GA_DB_PATH).resolve().name} + {Path(PAPER_DB_PATH).resolve().name}</span>')
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('<pre class="config-block"></pre>').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'<pre class="config-block">{_json_to_html(s["config"])}</pre>'
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('<pre class="raw-block">—</pre>').classes("w-full")
ui.label("Raw LLM output").classes("text-subtitle1 q-mt-md")
raw_code = ui.html('<pre class="raw-block">—</pre>').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'<pre class="raw-block">{html.escape(str(row.get("system_prompt", "")))}</pre>'
)
raw_code.content = (
f'<pre class="raw-block">{html.escape(str(row.get("raw_text", "") or ""))}</pre>'
)
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 '/'}"
)
Generated
+4
View File
@@ -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" },