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) <noreply@anthropic.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user