diff --git a/docker-compose.yml b/docker-compose.yml index 78bd1d6..4099a59 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,3 +18,29 @@ services: retries: 3 labels: - com.centurylinklabs.watchtower.enable=false + + # Dashboard web read-only (stato live, PnL totale/per-strategia, grafico equity, + # trade attivi+chiusi). Stessa immagine del runner, monta gli stessi data/ in sola + # lettura logica (legge equity.jsonl + status/trades dei worker). Porta 8787. + # NB: nessuna auth -> non esporre su internet pubblico, solo rete interna/VPN. + dashboard: + build: . + container_name: pythagoras-dashboard + restart: unless-stopped + command: ["uv", "run", "python", "-m", "src.live.dashboard", "--port", "8787"] + ports: + - "8787:8787" + volumes: + - ./data:/app/data + - ./portfolios.yml:/app/portfolios.yml:ro + env_file: + - .env + environment: + - PYTHONUNBUFFERED=1 + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8787/api/state', timeout=5)"] + interval: 120s + timeout: 10s + retries: 3 + labels: + - com.centurylinklabs.watchtower.enable=false diff --git a/docs/diary/2026-06-13-dashboard.md b/docs/diary/2026-06-13-dashboard.md new file mode 100644 index 0000000..69822de --- /dev/null +++ b/docs/diary/2026-06-13-dashboard.md @@ -0,0 +1,40 @@ +# 2026-06-13 — Dashboard web PORT06 (stato live + PnL + grafici + trade) + +Richiesta utente: frontend per visualizzare lo stato con PnL totale e per-strategia, +grafici, e liste trade (attivi in tempo reale + chiusi). + +## Cosa + +`src/live/dashboard.py` — server **stdlib `http.server`** (zero nuove dipendenze), +legge i file `data/` e serve: +- `GET /api/state` → JSON con tutto lo stato calcolato +- `GET /` → single-page HTML (vanilla JS, polling ogni 5s) + +Contenuto della pagina: +- **KPI**: equity, PnL totale (€ e %), max DD, peak +- **Grafico equity** (Chart.js da CDN, fallback testuale se offline) dalla + `equity.jsonl` del ledger (downsample a 400 punti) +- **PnL per strategia** (barre verdi/rosse): realizzato netto fee = Σ `pnl` reali + dai CLOSE (REAL-TRUTH), n trade, win-rate, capitale; tag `paper` per i + multi-asset non eseguiti, `•aperta` se in posizione +- **Trade attivi in tempo reale**: lato, entry, **mark corrente** (Cerbero + best-effort, cache 20s), **PnL non realizzato** (€ e %, da `real_entry_notional`), + barre/max_bars, distanza al TP, età dello status (⚠ se >15min = stantio) +- **Trade chiusi** (ultimi 50): ora, strategia, motivo, PnL reale, sim, esito + +## Deploy + +Servizio docker-compose `dashboard` (stessa immagine del runner, monta gli stessi +`data/`, porta **8787**), `restart: unless-stopped` + healthcheck sull'API. +Accesso: `http://:8787`. **Nessuna auth** → solo rete interna/VPN, non +esporre pubblicamente. Avvio: `docker compose up -d --build dashboard` (il runner +non viene toccato). + + uv run python -m src.live.dashboard --port 8787 # anche standalone su host + +## Note + +- Il PnL per-strategia usa il PnL REALE (real_truth), coerente col report orario. +- I 6 fade 1h ritirati dallo swap restano in lista (hanno storico CLOSE): flat, + mostrano il loro PnL realizzato storico accanto ai gemelli 15m attivi. +- Unrealized € sui pairs non mostrato (posizione a 2 gambe, z-based) → "pairs (z)". diff --git a/src/live/dashboard.py b/src/live/dashboard.py new file mode 100644 index 0000000..d1a60fe --- /dev/null +++ b/src/live/dashboard.py @@ -0,0 +1,328 @@ +"""Dashboard web PORT06 — stato live, PnL totale e per-strategia, grafici, trade. + +Server self-contained (stdlib http.server, zero nuove dipendenze): legge i file +sotto data/ (equity.jsonl del ledger + status.json/trades.jsonl dei worker) e serve: + GET / -> single-page HTML (polling ogni 5s, grafico equity, barre PnL, + tabelle trade attivi in tempo reale + chiusi) + GET /api/state -> JSON con tutto lo stato calcolato + +PnL per-strategia = somma dei `pnl` reali dai CLOSE (REAL-TRUTH); i trade attivi +mostrano il PnL NON realizzato live (mark corrente da Cerbero, best-effort cache 20s). + + uv run python -m src.live.dashboard # porta 8787 + uv run python -m src.live.dashboard --port 9000 + # poi apri http://:8787 +""" +from __future__ import annotations + +import json +import sys +import time +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +PAPER = PROJECT_ROOT / "data" / "portfolio_paper" +STATS = PROJECT_ROOT / "data" / "portfolio_paper_stats" +LEDGER = PROJECT_ROOT / "data" / "portfolios" / "PORT06" + +FAMILY = [("MR01", "FADE"), ("MR02", "FADE"), ("MR07", "FADE"), ("DIP01", "HONEST"), + ("PR01", "PAIRS"), ("SH01", "SHAPE"), ("TR01", "HONEST"), + ("ROT02", "HONEST"), ("TSM01", "TSM"), ("XS01", "XSEC")] +_MARK_CACHE: dict = {"ts": 0.0, "marks": {}} + + +def _family_of(wid: str) -> str: + for code, fam in FAMILY: + if wid.startswith(code): + return fam + return "?" + + +def _short(wid: str) -> str: + return (wid.replace("_bollinger_fade", "").replace("_donchian_fade", "") + .replace("_return_reversal", "").replace("_pairs_reversion", "") + .replace("_shape_ml", "").replace("_dip_buy", "").replace("_basket", "") + .replace("_rot", "").replace("__", " ")) + + +def _age_min(path: Path) -> float: + return (time.time() - path.stat().st_mtime) / 60.0 + + +def _marks(assets: set[str]) -> dict[str, float]: + """Mark correnti (USDC perp) best-effort, cache 20s. Vuoto se Cerbero non risponde.""" + if not assets: + return {} + if time.time() - _MARK_CACHE["ts"] < 20: + return _MARK_CACHE["marks"] + try: + from src.live.cerbero_client import CerberoClient + insts = [f"{a}_USDC-PERPETUAL" for a in assets] + data = CerberoClient().get_ticker_batch(insts) + marks = {t["instrument_name"].split("_")[0].replace("-PERPETUAL", ""): t.get("mark_price") + for t in data.get("tickers", [])} + _MARK_CACHE.update(ts=time.time(), marks=marks) + return marks + except Exception: + return _MARK_CACHE["marks"] + + +def _equity_curve(max_pts: int = 400) -> list[dict]: + f = LEDGER / "equity.jsonl" + if not f.exists(): + return [] + rows = [json.loads(l) for l in f.read_text().splitlines() if l.strip()] + if len(rows) > max_pts: # downsample uniforme + step = len(rows) / max_pts + rows = [rows[int(i * step)] for i in range(max_pts)] + return [{"t": r["ts"], "equity": r["equity"], "dd": r.get("dd", 0.0)} for r in rows] + + +def _worker_trades(wid: str) -> tuple[float, int, int, list[dict]]: + """(pnl realizzato, n_chiusi, n_win, lista CLOSE) dal trades.jsonl.""" + f = PAPER / wid / "trades.jsonl" + if not f.exists(): + f = STATS / wid / "trades.jsonl" + pnl = 0.0 + wins = n = 0 + closes = [] + if f.exists(): + for line in f.read_text().splitlines(): + if not line.strip(): + continue + try: + e = json.loads(line) + except Exception: + continue + if e.get("event") != "CLOSE": + continue + p = e.get("pnl", 0.0) or 0.0 + pnl += p + n += 1 + win = bool(e.get("win", p > 0)) + wins += win + closes.append({"ts": e.get("ts", ""), "worker": _short(wid), + "reason": e.get("reason", "?"), "pnl": round(p, 3), + "sim_pnl": e.get("sim_pnl"), "real_pnl": e.get("real_pnl"), + "win": win, "src": e.get("pnl_source", "")}) + return pnl, n, wins, closes + + +def build_state() -> dict: + now = datetime.now(timezone.utc) + # --- ledger / portfolio --- + led = json.loads((LEDGER / "status.json").read_text()) if (LEDGER / "status.json").exists() else {} + curve = _equity_curve() + equity = led.get("equity", curve[-1]["equity"] if curve else 0.0) + init_cap = 2000.0 + strategies, active, closed = [], [], [] + open_assets: set[str] = set() + + dirs = sorted(list(PAPER.glob("*")) + list(STATS.glob("*"))) + for d in dirs: + sp = d / "status.json" + if not sp.exists(): + continue + wid = d.name + st = json.loads(sp.read_text()) + paper_only = ("weights" in st) # multi-asset stats (no real exec) + pnl, n, wins, closes = _worker_trades(wid) + closed.extend(closes) + cap = st.get("capital", 0.0) + rc = st.get("real_capital") + strategies.append({ + "id": _short(wid), "family": _family_of(wid), + "capital": round(cap, 2), "real_capital": round(rc, 2) if rc is not None else None, + "realized_pnl": round(pnl, 2), "n_trades": n, "wins": wins, + "win_rate": round(wins / n * 100, 0) if n else 0.0, + "in_position": bool(st.get("in_position")), "paper": paper_only, + }) + if st.get("in_position") and not paper_only: + is_pair = "entry_a" in st + asset = wid.split("__")[1] + entry = st.get("entry_price") or st.get("entry_a") or 0.0 + if not is_pair: + open_assets.add(asset) + active.append({ + "id": _short(wid), "family": _family_of(wid), + "dir": "LONG" if st.get("direction", 0) > 0 else "SHORT", + "entry": entry, "bars": st.get("bars_held", 0), + "max_bars": st.get("max_bars", 0), + "tp": st.get("tp", 0.0), "sl": st.get("sl", 0.0), + "age_min": round(_age_min(sp), 1), + "stale": _age_min(sp) > 15, + "asset": None if is_pair else asset, + "pair": is_pair, + "notional": st.get("real_entry_notional") or round(cap * 0.5 * 3, 1), + "real": bool(st.get("real_in_position")), + }) + + # PnL non realizzato live per i trade attivi single-leg (mark best-effort) + marks = _marks(open_assets) + for a in active: + if a["asset"] and marks.get(a["asset"]) and a["entry"]: + mark = marks[a["asset"]] + sign = 1 if a["dir"] == "LONG" else -1 + pct = sign * (mark - a["entry"]) / a["entry"] + a["mark"] = round(mark, 4) + a["unreal_pct"] = round(pct * 100, 2) + a["unreal_eur"] = round(a["notional"] * pct, 2) + # distanza al TP (%) + if a["tp"]: + a["to_tp_pct"] = round(abs(a["tp"] - mark) / mark * 100, 2) + + closed.sort(key=lambda c: c["ts"], reverse=True) + # aggregati per famiglia + fam_pnl: dict[str, float] = {} + for s in strategies: + fam_pnl[s["family"]] = fam_pnl.get(s["family"], 0.0) + s["realized_pnl"] + + return { + "ts": now.isoformat(), + "portfolio": { + "equity": round(equity, 2), "init": init_cap, + "pnl_total": round(equity - init_cap, 2), + "pnl_pct": round((equity / init_cap - 1) * 100, 2), + "dd": led.get("max_dd", 0.0), "peak": led.get("peak", equity), + "last_rebalance": led.get("last_rebalance", ""), + "curve": curve, + }, + "fam_pnl": {k: round(v, 2) for k, v in sorted(fam_pnl.items())}, + "strategies": sorted(strategies, key=lambda s: (s["family"], s["id"])), + "active": sorted(active, key=lambda a: a.get("unreal_eur", 0)), + "closed": closed[:50], + "n_active": len(active), + } + + +# ----------------------- HTTP ----------------------- +class Handler(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def _send(self, code, body, ctype): + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body.encode()) + + def do_GET(self): + if self.path.startswith("/api/state"): + try: + self._send(200, json.dumps(build_state()), "application/json") + except Exception as e: + self._send(500, json.dumps({"error": str(e)}), "application/json") + elif self.path == "/" or self.path.startswith("/index"): + self._send(200, HTML, "text/html; charset=utf-8") + else: + self._send(404, "not found", "text/plain") + + +HTML = r""" + +PORT06 — Dashboard +
+

PORT06 — Dashboard live

+
aggiornato · refresh 5s · 0 posizioni aperte
+
+

Equity

+

PnL per strategia (realizzato, netto fee)

+

Trade attivi — stato in tempo reale

+

Trade chiusi (ultimi 50)

+
+ +""" + + +def main(): + port = 8787 + if "--port" in sys.argv: + port = int(sys.argv[sys.argv.index("--port") + 1]) + srv = ThreadingHTTPServer(("0.0.0.0", port), Handler) + print(f"[dashboard] PORT06 live su http://0.0.0.0:{port} (Ctrl-C per fermare)") + try: + srv.serve_forever() + except KeyboardInterrupt: + print("\n[dashboard] stop") + + +if __name__ == "__main__": + main()