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:
+6
-3
@@ -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
|
||||
|
||||
+41
-6
@@ -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
|
||||
# * 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>'
|
||||
)
|
||||
@@ -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 '/'}"
|
||||
)
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user