Files
Cerbero-Bite/src/cerbero_bite/gui/pages/3_📈_Equity.py
T
Adriano 63d1aa4262 feat(gui): traduzione italiana, logo Cerbero, saldi live e Forza ciclo
* Localizzazione italiana di tutte le pagine (Stato, Audit, Equity,
  Storico, Posizione) e della home; date relative ("5s fa", "12m fa").
* Logo Cerbero (cane a tre teste) in src/cerbero_bite/gui/assets/
  cerbero_logo.png — sostituisce l'emoji 🐺 (lupo, semanticamente
  errata) sia come favicon (`page_icon`) sia in sidebar e header.
* Caricamento automatico di `.env` dal CWD all'avvio della CLI (skip
  sotto pytest tramite PYTEST_CURRENT_TEST), evitando di doversi
  esportare manualmente le 4 URL MCP. Aggiunto python-dotenv come
  dipendenza, `.env.example` committato come template, `.env` resta
  ignorato da git.
* Pagina Stato: nuovo pannello "Saldi exchange" che fa fetch live
  via gateway MCP (Deribit USDC + USDT, Hyperliquid USDC + opzionale
  USDT spot) con cache TTL 60s e bottone refresh; tile riassuntivi
  totale USD / EUR / cambio.
* Pagina Stato: nuovo pannello "Forza ciclo" con tre bottoni
  (entry/monitor/health) che accodano azioni `run_cycle` nella tabella
  manual_actions; il consumer dell'engine — quando in esecuzione —
  dispatcha al `Orchestrator.run_*` corrispondente.
* manual_actions: nuovo `kind="run_cycle"` nello schema
  ManualAction; consumer accetta dict di cycle_runners che
  l'orchestrator popola in install_scheduler. 3 nuovi test (dispatch
  entry, ciclo sconosciuto, fallback senza runner).
* gui/live_data.py — modulo dedicato al fetch MCP dalla GUI
  (relax controllato della regola "no MCP from GUI" solo per i saldi,
  non per i dati di trading).

363/363 tests pass; ruff clean; mypy strict src clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:11:40 +02:00

179 lines
5.2 KiB
Python

"""Equity page — cumulative PnL, drawdown, distributions."""
from __future__ import annotations
import os
from collections import Counter
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pandas as pd
import plotly.graph_objects as go
import streamlit as st
from cerbero_bite.gui.data_layer import (
DEFAULT_DB_PATH,
compute_equity_curve,
compute_kpis,
compute_monthly_stats,
load_closed_positions,
)
def _resolve_db() -> Path:
return Path(os.environ.get("CERBERO_BITE_GUI_DB", DEFAULT_DB_PATH))
def _date_window(label: str) -> tuple[datetime | None, datetime | None]:
"""Selettore della finestra temporale per l'analitica."""
options = {
"Tutto lo storico": (None, None),
"Ultimi 30 giorni": (datetime.now(UTC) - timedelta(days=30), None),
"Ultimi 90 giorni": (datetime.now(UTC) - timedelta(days=90), None),
"Da inizio anno": (
datetime(datetime.now(UTC).year, 1, 1, tzinfo=UTC),
None,
),
}
pick = st.selectbox(label, list(options.keys()), index=0)
return options[pick]
def render() -> None:
st.title("📈 Equity")
st.caption(
"P&L realizzato cumulato, drawdown e distribuzione per trade. "
"Calcolato dalle posizioni chiuse in `data/state.sqlite`."
)
start, end = _date_window("Finestra")
db_path = _resolve_db()
positions = load_closed_positions(db_path=db_path, start=start, end=end)
if not positions:
st.info(
"Nessuna posizione chiusa nella finestra selezionata. "
"La curva equity si popolerà non appena il motore chiuderà "
"il primo trade."
)
return
# Striscia KPI
kpis = compute_kpis(positions)
cols = st.columns(5)
cols[0].metric("Trade chiusi", kpis.n_trades)
cols[1].metric("Win rate", f"{kpis.win_rate:.0%}")
cols[2].metric("P&L totale", f"${float(kpis.total_pnl_usd):+.2f}")
cols[3].metric("Edge / trade", f"${float(kpis.edge_per_trade_usd):+.2f}")
cols[4].metric(
"Max drawdown",
f"${float(kpis.max_drawdown_usd):.2f}",
delta=f"{kpis.max_drawdown_pct:.1%}",
delta_color="inverse",
)
st.divider()
# Equity curve + drawdown
curve = compute_equity_curve(positions)
df = pd.DataFrame(
{
"timestamp": [p.timestamp for p in curve],
"cumulative_pnl_usd": [float(p.cumulative_pnl_usd) for p in curve],
"drawdown_usd": [float(p.drawdown_usd) for p in curve],
"realized_pnl_usd": [float(p.realized_pnl_usd) for p in curve],
}
)
st.subheader("P&L cumulato (USD)")
fig = go.Figure()
fig.add_trace(
go.Scatter(
x=df["timestamp"],
y=df["cumulative_pnl_usd"],
mode="lines+markers",
name="P&L cumulato",
line={"color": "#2ecc71", "width": 2},
)
)
fig.add_hline(y=0, line_dash="dot", line_color="grey", opacity=0.5)
fig.update_layout(
height=320,
margin={"l": 10, "r": 10, "t": 30, "b": 10},
xaxis_title=None,
yaxis_title="USD",
)
st.plotly_chart(fig, use_container_width=True)
st.subheader("Drawdown (USD)")
dd_fig = go.Figure()
dd_fig.add_trace(
go.Scatter(
x=df["timestamp"],
y=-df["drawdown_usd"],
mode="lines",
fill="tozeroy",
name="drawdown",
line={"color": "#e74c3c", "width": 1.5},
)
)
dd_fig.update_layout(
height=220,
margin={"l": 10, "r": 10, "t": 30, "b": 10},
xaxis_title=None,
yaxis_title="USD",
)
st.plotly_chart(dd_fig, use_container_width=True)
# Distribuzione P&L
st.subheader("Distribuzione P&L per motivo di chiusura")
by_reason: dict[str, list[float]] = {}
for pos in positions:
if pos.pnl_usd is None:
continue
by_reason.setdefault(pos.close_reason or "(sconosciuto)", []).append(
float(pos.pnl_usd)
)
counts = Counter(
(pos.close_reason or "(sconosciuto)") for pos in positions
)
cols = st.columns(min(len(counts), 6) or 1)
for col, (reason, count) in zip(cols, counts.most_common(6), strict=False):
col.metric(reason, count)
hist_fig = go.Figure()
for reason, pnls in by_reason.items():
hist_fig.add_trace(
go.Histogram(x=pnls, name=reason, opacity=0.6, nbinsx=30)
)
hist_fig.update_layout(
barmode="overlay",
height=320,
margin={"l": 10, "r": 10, "t": 30, "b": 10},
xaxis_title="P&L (USD)",
yaxis_title="numero trade",
legend={"orientation": "h", "y": 1.1},
)
st.plotly_chart(hist_fig, use_container_width=True)
# Tabella mensile
st.subheader("Statistiche mensili")
months = compute_monthly_stats(positions)
rows = [
{
"mese": m.year_month,
"trade": m.n_trades,
"vittorie": m.n_wins,
"win rate": f"{m.win_rate:.0%}",
"P&L (USD)": f"{float(m.pnl_usd):+.2f}",
"media / trade": f"{float(m.avg_pnl_usd):+.2f}",
}
for m in months
]
st.dataframe(rows, use_container_width=True, hide_index=True)
render()