From 14f130aa5a84d016ddd04eb67eb9d608ffea1597 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Thu, 14 May 2026 19:52:02 +0000 Subject: [PATCH] feat(dashboard): pagina /paper per monitoring forward-test Nuova pagina NiceGUI "Paper" che legge le tabelle paper_trading_*: - 4 metric card: Equity, P/L cumulato %, Trades chiusi, Open/Tick count - Equity curve plotly con hline initial capital - Tre tabelle: open positions, ultimi 30 tick (ts/bar/symbol/signal/action), trades chiusi (entry/exit/pnl/fees) - Run selector dropdown + status badge + auto-refresh REFRESH_INTERVAL_S dashboard/data.py: aggiunti 6 helper read-only su SQLite (paper_runs_df, paper_equity_df, paper_positions_df, paper_trades_df, paper_ticks_df, paper_run_summary). Connessione separata da Repository per usare direttamente lo schema paper_trading_* senza passare per la classe di write PaperRepository. dashboard/nicegui_app.py: aggiunto import pandas (necessario per to_datetime nell'equity curve), nav link "Paper" nell'header, @ui.page("/paper") con helper _paper_runs_options + _paper_equity_figure. Chiude il primo TODO della roadmap sez 10.1 ("Pagina dashboard paper-trading"). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/multi_swarm/dashboard/data.py | 119 +++++++++++++ src/multi_swarm/dashboard/nicegui_app.py | 215 +++++++++++++++++++++++ 2 files changed, 334 insertions(+) diff --git a/src/multi_swarm/dashboard/data.py b/src/multi_swarm/dashboard/data.py index 3abfe15..33503ff 100644 --- a/src/multi_swarm/dashboard/data.py +++ b/src/multi_swarm/dashboard/data.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import sqlite3 from pathlib import Path from typing import Any @@ -52,3 +53,121 @@ def genomes_df( } ) return pd.DataFrame(flat) + + +def _paper_conn(db_path: str | Path) -> sqlite3.Connection: + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + return conn + + +def paper_runs_df(db_path: str | Path) -> pd.DataFrame: + with _paper_conn(db_path) as conn: + rows = conn.execute( + "SELECT id, name, started_at, stopped_at, status, initial_capital, config_json " + "FROM paper_trading_runs ORDER BY started_at DESC" + ).fetchall() + return pd.DataFrame([dict(r) for r in rows]) + + +def paper_equity_df(db_path: str | Path, run_id: str) -> pd.DataFrame: + with _paper_conn(db_path) as conn: + rows = conn.execute( + "SELECT ts, equity, cash, positions_value FROM paper_trading_equity " + "WHERE paper_run_id=? ORDER BY ts ASC", + (run_id,), + ).fetchall() + return pd.DataFrame([dict(r) for r in rows]) + + +def paper_positions_df(db_path: str | Path, run_id: str) -> pd.DataFrame: + with _paper_conn(db_path) as conn: + rows = conn.execute( + "SELECT symbol, side, qty, entry_price, entry_ts " + "FROM paper_trading_positions WHERE paper_run_id=? ORDER BY symbol", + (run_id,), + ).fetchall() + return pd.DataFrame([dict(r) for r in rows]) + + +def paper_trades_df(db_path: str | Path, run_id: str, limit: int = 100) -> pd.DataFrame: + with _paper_conn(db_path) as conn: + rows = conn.execute( + "SELECT symbol, side, qty, entry_price, exit_price, entry_ts, exit_ts, pnl, fees " + "FROM paper_trading_trades WHERE paper_run_id=? ORDER BY exit_ts DESC LIMIT ?", + (run_id, limit), + ).fetchall() + return pd.DataFrame([dict(r) for r in rows]) + + +def paper_ticks_df(db_path: str | Path, run_id: str, limit: int = 50) -> pd.DataFrame: + with _paper_conn(db_path) as conn: + rows = conn.execute( + "SELECT ts, bar_ts, symbol, close_price, signal, action_taken " + "FROM paper_trading_ticks WHERE paper_run_id=? ORDER BY ts DESC LIMIT ?", + (run_id, limit), + ).fetchall() + return pd.DataFrame([dict(r) for r in rows]) + + +def paper_run_summary(db_path: str | Path, run_id: str) -> dict[str, Any]: + """Aggrega metriche sintetiche per la pagina paper trading.""" + with _paper_conn(db_path) as conn: + run = conn.execute( + "SELECT id, name, started_at, stopped_at, status, initial_capital, config_json " + "FROM paper_trading_runs WHERE id=?", + (run_id,), + ).fetchone() + if run is None: + return {} + run = dict(run) + + eq_row = conn.execute( + "SELECT equity, cash, positions_value, ts FROM paper_trading_equity " + "WHERE paper_run_id=? ORDER BY ts DESC LIMIT 1", + (run_id,), + ).fetchone() + + trades_agg = conn.execute( + "SELECT COUNT(*) AS n, COALESCE(SUM(pnl),0) AS sum_pnl, " + "COALESCE(SUM(fees),0) AS sum_fees FROM paper_trading_trades " + "WHERE paper_run_id=?", + (run_id,), + ).fetchone() + + tick_agg = conn.execute( + "SELECT COUNT(*) AS n, MAX(ts) AS last_ts FROM paper_trading_ticks " + "WHERE paper_run_id=?", + (run_id,), + ).fetchone() + + positions_n = conn.execute( + "SELECT COUNT(*) AS n FROM paper_trading_positions WHERE paper_run_id=?", + (run_id,), + ).fetchone()["n"] + + initial = float(run["initial_capital"]) + current_equity = float(eq_row["equity"]) if eq_row is not None else initial + pnl_pct = (current_equity - initial) / initial * 100.0 if initial else 0.0 + + return { + "id": run["id"], + "name": run["name"], + "status": run["status"], + "started_at": run["started_at"], + "stopped_at": run["stopped_at"], + "initial_capital": initial, + "config": json.loads(run["config_json"]), + "current_equity": current_equity, + "current_cash": float(eq_row["cash"]) if eq_row is not None else initial, + "current_positions_value": float(eq_row["positions_value"]) if eq_row is not None else 0.0, + "last_equity_ts": eq_row["ts"] if eq_row is not None else None, + "pnl_abs": current_equity - initial, + "pnl_pct": pnl_pct, + "n_trades": int(trades_agg["n"]), + "trades_pnl": float(trades_agg["sum_pnl"]), + "trades_fees": float(trades_agg["sum_fees"]), + "n_ticks": int(tick_agg["n"]), + "last_tick_ts": tick_agg["last_ts"], + "n_open_positions": int(positions_n), + } diff --git a/src/multi_swarm/dashboard/nicegui_app.py b/src/multi_swarm/dashboard/nicegui_app.py index afe3fa9..15cf9ea 100644 --- a/src/multi_swarm/dashboard/nicegui_app.py +++ b/src/multi_swarm/dashboard/nicegui_app.py @@ -24,6 +24,7 @@ import os from pathlib import Path from typing import Any +import pandas as pd # type: ignore[import-untyped] import plotly.graph_objects as go # type: ignore[import-untyped] from nicegui import app, ui @@ -34,6 +35,12 @@ from multi_swarm.dashboard.data import ( get_repo, get_run_overview, list_runs_df, + paper_equity_df, + paper_positions_df, + paper_run_summary, + paper_runs_df, + paper_ticks_df, + paper_trades_df, ) DB_PATH = os.environ.get("DB_PATH", "./runs.db") @@ -370,6 +377,7 @@ def _build_header(active: str) -> None: ("/", "Overview"), ("/convergence", "Convergence"), ("/genomes", "Genomes"), + ("/paper", "Paper"), ): cls = "nav-link active" if active == path else "nav-link" ui.link(label, path).classes(cls) @@ -864,6 +872,213 @@ def genomes() -> None: refresh() +def _paper_runs_options(only_running: bool = False) -> dict[str, str]: + runs = paper_runs_df(DB_PATH) + if runs.empty: + return {} + if only_running: + runs = runs[runs["status"] == "running"] + if runs.empty: + return {} + return { + row["id"]: f"{row['name']} — {row['status']} ({row['started_at'][:16]})" + for _, row in runs.iterrows() + } + + +def _paper_equity_figure(eq_df: Any, initial_capital: float) -> go.Figure: + fig = go.Figure() + if eq_df is not None and not eq_df.empty: + ts = pd.to_datetime(eq_df["ts"]) + fig.add_trace( + go.Scatter( + x=ts, + y=eq_df["equity"], + mode="lines", + line={"color": COLOR_PRIMARY, "width": 2}, + name="equity", + ) + ) + fig.add_hline( + y=initial_capital, + line={"color": COLOR_TEXT_MUTED, "width": 1, "dash": "dash"}, + annotation_text=f"initial ${initial_capital:.0f}", + annotation_position="bottom right", + annotation_font_color=COLOR_TEXT_MUTED, + ) + fig.update_layout( + title=None, + paper_bgcolor=COLOR_SURFACE, + plot_bgcolor=COLOR_SURFACE, + font={"color": COLOR_TEXT, "family": "Inter"}, + xaxis={"gridcolor": COLOR_SURFACE_2, "title": None}, + yaxis={"gridcolor": COLOR_SURFACE_2, "title": "Equity ($)"}, + margin={"l": 60, "r": 20, "t": 10, "b": 40}, + height=320, + showlegend=False, + ) + return fig + + +@ui.page("/paper") +def paper() -> None: + _apply_theme() + _build_header(active="/paper") + + options = _paper_runs_options() + if not options: + ui.label("Nessuna paper-trading 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="Paper 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.row().classes("w-full gap-4"): + with ui.card().classes("flex-grow metric-card accent-cyan"): + ui.label("Equity").classes("text-caption") + equity_lbl = ui.label("—").classes("text-h4") + with ui.card().classes("flex-grow metric-card accent-purple"): + ui.label("P/L cumulato").classes("text-caption") + pnl_lbl = ui.label("—").classes("text-h4") + with ui.card().classes("flex-grow metric-card accent-amber"): + ui.label("Trades chiusi").classes("text-caption") + trades_lbl = ui.label("—").classes("text-h4") + with ui.card().classes("flex-grow metric-card accent-green"): + ui.label("Open / Tick").classes("text-caption") + ticks_lbl = ui.label("—").classes("text-h4") + + with ui.row().classes("w-full gap-4 q-mt-md"): + started_lbl = ui.label("Started: —") + last_tick_lbl = ui.label("Last tick: —") + cash_lbl = ui.label("Cash: —") + + ui.separator() + ui.label("Equity curve").classes("text-subtitle1 q-mt-md") + equity_plot = ui.plotly(_paper_equity_figure(None, 0.0)).classes("w-full") + + ui.separator() + ui.label("Open positions").classes("text-subtitle1 q-mt-md") + positions_table = ui.table( + columns=[ + {"name": "symbol", "label": "symbol", "field": "symbol"}, + {"name": "side", "label": "side", "field": "side"}, + {"name": "qty", "label": "qty", "field": "qty"}, + {"name": "entry_price", "label": "entry", "field": "entry_price"}, + {"name": "entry_ts", "label": "entry ts", "field": "entry_ts"}, + ], + rows=[], + row_key="symbol", + ).classes("w-full") + + ui.separator() + ui.label("Ultimi 30 tick").classes("text-subtitle1 q-mt-md") + ticks_table = ui.table( + columns=[ + {"name": "ts", "label": "ts", "field": "ts"}, + {"name": "symbol", "label": "symbol", "field": "symbol"}, + {"name": "bar_ts", "label": "bar", "field": "bar_ts"}, + {"name": "close_price", "label": "close", "field": "close_price"}, + {"name": "signal", "label": "signal", "field": "signal"}, + {"name": "action_taken", "label": "action", "field": "action_taken"}, + ], + rows=[], + row_key="ts", + ).classes("w-full") + + ui.separator() + ui.label("Trades chiusi (ultimi 50)").classes("text-subtitle1 q-mt-md") + trades_table = ui.table( + columns=[ + {"name": "exit_ts", "label": "exit ts", "field": "exit_ts"}, + {"name": "symbol", "label": "symbol", "field": "symbol"}, + {"name": "side", "label": "side", "field": "side"}, + {"name": "qty", "label": "qty", "field": "qty"}, + {"name": "entry_price", "label": "entry", "field": "entry_price"}, + {"name": "exit_price", "label": "exit", "field": "exit_price"}, + {"name": "pnl", "label": "pnl", "field": "pnl"}, + {"name": "fees", "label": "fees", "field": "fees"}, + ], + rows=[], + row_key="exit_ts", + ).classes("w-full") + + def refresh() -> None: + run_id = select.value + if not run_id: + return + try: + summary = paper_run_summary(DB_PATH, run_id) + eq = paper_equity_df(DB_PATH, run_id) + positions = paper_positions_df(DB_PATH, run_id) + ticks = paper_ticks_df(DB_PATH, run_id, limit=30) + trades = paper_trades_df(DB_PATH, run_id, limit=50) + except Exception as e: # noqa: BLE001 + ui.notify(f"Errore: {e}", type="negative") + return + + text, color = _STATUS_BADGE.get(summary["status"], (summary["status"], "primary")) + status_badge.text = text + status_badge.props(f"color={color}") + + equity_lbl.text = f"${summary['current_equity']:.2f}" + pnl_lbl.text = f"{summary['pnl_pct']:+.2f}%" + trades_lbl.text = str(summary["n_trades"]) + ticks_lbl.text = f"{summary['n_open_positions']} / {summary['n_ticks']}" + + started_lbl.text = f"Started: {summary['started_at']}" + last_tick_lbl.text = f"Last tick: {summary['last_tick_ts'] or '—'}" + cash_lbl.text = ( + f"Cash: ${summary['current_cash']:.2f} | " + f"Pos value: ${summary['current_positions_value']:.2f}" + ) + + equity_plot.update_figure(_paper_equity_figure(eq, summary["initial_capital"])) + + positions_table.rows = ( + [ + {col: (round(v, 6) if isinstance(v, float) else v) for col, v in row.items()} + for _, row in positions.iterrows() + ] + if not positions.empty + else [] + ) + positions_table.update() + + ticks_table.rows = ( + [ + {col: (round(v, 6) if isinstance(v, float) else v) for col, v in row.items()} + for _, row in ticks.iterrows() + ] + if not ticks.empty + else [] + ) + ticks_table.update() + + trades_table.rows = ( + [ + {col: (round(v, 6) if isinstance(v, float) else v) for col, v in row.items()} + for _, row in trades.iterrows() + ] + if not trades.empty + else [] + ) + trades_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() + + def main() -> None: app.on_startup(lambda: print(f"DB: {Path(DB_PATH).resolve()}")) ui.run(