From 26e977d338f1f86b53db2758f0f6921b49b7198e Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Sat, 20 Jun 2026 08:57:55 +0000 Subject: [PATCH] feat(monitor): dashboard PAPER del portafoglio attivo (TP01+XS01) + paper forward loop src/live/dashboard.py: web UI stdlib (:8787) che mostra metriche (FULL/HOLD Sharpe, DD, CAGR), per-sleeve, posizioni correnti, equity (backtest + paper forward), ultimo dato. Solo MONITOR, esecuzione REALE disabilitata. scripts/live/paper_portfolio.py: forward-only del portafoglio (StrategyPortfolio su active_sleeves), stato persistente in data/paper_portfolio (gitignored). Dockerfile + docker-compose.yml minimali (solo servizio dashboard; runner/esecuzione restano in Old/). Container pythagoras-dashboard ricostruito col codice nuovo (il vecchio mostrava dati pre-reset). Mount data/ read-only. .dockerignore esclude Old/data/.venv/.git/.env. Co-Authored-By: Claude Opus 4.8 (1M context) --- .dockerignore | 11 +++ Dockerfile | 11 +++ docker-compose.yml | 12 +++ scripts/live/paper_portfolio.py | 86 ++++++++++++++++++++ src/live/__init__.py | 1 + src/live/dashboard.py | 136 ++++++++++++++++++++++++++++++++ 6 files changed, 257 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 scripts/live/paper_portfolio.py create mode 100644 src/live/__init__.py create mode 100644 src/live/dashboard.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c8ce885 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +Old/ +data/ +.venv/ +.git/ +logs/ +__pycache__/ +**/__pycache__/ +*.pyc +.env +.env.mainnet +docs/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..42f3fe2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.11-slim +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv +WORKDIR /app +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev +COPY src/ src/ +COPY scripts/ scripts/ +COPY VERSION ./ +VOLUME /app/data +# Monitor PAPER del portafoglio attivo (TP01+XS01). Esecuzione REALE disabilitata. +CMD ["uv", "run", "python", "-m", "src.live.dashboard", "--port", "8787"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0d1c9d4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +# Solo MONITOR (dashboard paper) del portafoglio attivo. Niente runner/esecuzione reale +# (archiviati in Old/). v2.0.0+. +services: + 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:ro diff --git a/scripts/live/paper_portfolio.py b/scripts/live/paper_portfolio.py new file mode 100644 index 0000000..5a9be6e --- /dev/null +++ b/scripts/live/paper_portfolio.py @@ -0,0 +1,86 @@ +"""PAPER PORTFOLIO — forward-only del portafoglio attivo (TP01 + XS01), simulato. + +Traccia l'equity del portafoglio (StrategyPortfolio su active_sleeves) FORWARD-ONLY da una data di +partenza, sui dati certificati (BTC/ETH Deribit + alt Hyperliquid). Nessuna esecuzione reale: +applica i rendimenti GIORNALIERI combinati man mano che arrivano barre nuove. Stato persistente. +Il dashboard (src/live/dashboard.py) legge questo stato + ricalcola il backtest a colpo d'occhio. + + uv run python scripts/live/paper_portfolio.py # avanza (init al 1o run) + uv run python scripts/live/paper_portfolio.py --status # solo stato + uv run python scripts/live/paper_portfolio.py --reset # azzera (riparte da ora) +""" +from __future__ import annotations +import sys, json +from pathlib import Path +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) +import numpy as np, pandas as pd +from src.portfolio.portfolio import StrategyPortfolio +from src.portfolio.sleeves import active_sleeves + +STATE_DIR = PROJECT_ROOT / "data" / "paper_portfolio" +STATE = STATE_DIR / "state.json" +EQ = STATE_DIR / "equity.csv" +INITIAL = 2000.0 + + +def portfolio_daily(): + pf = StrategyPortfolio(active_sleeves(), capital=INITIAL) + return pf, pf.combined_daily() + + +def load(): + return json.loads(STATE.read_text()) if STATE.exists() else None + + +def save(st): + STATE_DIR.mkdir(parents=True, exist_ok=True) + STATE.write_text(json.dumps(st, indent=2)) + + +def advance(): + pf, r = portfolio_daily() + st = load() + if st is None: # init: forward-only, parte dall'ultima barra + last = str(r.index[-1]) + st = dict(start=last, last=last, equity=INITIAL, initial=INITIAL, + peak=INITIAL, max_dd=0.0, n_days=0) + save(st) + STATE_DIR.mkdir(parents=True, exist_ok=True) + EQ.write_text("date,equity\n" + f"{last},{INITIAL}\n") + return st + last = pd.Timestamp(st["last"]) + new = r[r.index > last] + if len(new): + eq = st["equity"]; peak = st["peak"]; dd = st["max_dd"] + lines = [] + for d, ret in new.items(): + eq *= (1.0 + float(ret)); peak = max(peak, eq); dd = max(dd, (peak - eq) / peak if peak > 0 else 0) + lines.append(f"{d},{eq:.4f}") + st.update(equity=eq, last=str(new.index[-1]), peak=peak, max_dd=dd, n_days=st["n_days"] + len(new)) + save(st) + with open(EQ, "a") as f: + f.write("\n".join(lines) + "\n") + return st + + +def main(): + a = sys.argv[1:] + if "--reset" in a: + for f in (STATE, EQ): + f.unlink(missing_ok=True) + print("paper portfolio azzerato.") + st = load() if "--status" in a else advance() + if st is None: + st = advance() + pf, _ = portfolio_daily() + days = (pd.Timestamp(st["last"]) - pd.Timestamp(st["start"])).days + ret = st["equity"] / st["initial"] - 1 + print(f"PAPER PORTFOLIO (TP01+XS01) — forward-only") + print(f" start {st['start'][:10]} -> last {st['last'][:10]} ({days}g, {st['n_days']} barre)") + print(f" equity {st['equity']:.2f} (start {st['initial']:.0f}) ret {ret*100:+.2f}% maxDD {st['max_dd']*100:.1f}%") + print(f" posizioni correnti: {pf.current_positions()}") + + +if __name__ == "__main__": + main() diff --git a/src/live/__init__.py b/src/live/__init__.py new file mode 100644 index 0000000..724010d --- /dev/null +++ b/src/live/__init__.py @@ -0,0 +1 @@ +"""Monitoraggio paper (dashboard). Lo stack live REALE resta in Old/.""" diff --git a/src/live/dashboard.py b/src/live/dashboard.py new file mode 100644 index 0000000..2304219 --- /dev/null +++ b/src/live/dashboard.py @@ -0,0 +1,136 @@ +"""DASHBOARD web del portafoglio attivo (TP01 + XS01) — monitoraggio PAPER, stdlib only. + +Mostra: metriche (FULL/HOLD Sharpe, DD, CAGR), per-sleeve, posizioni correnti, equity (backtest + +paper forward da scripts/live/paper_portfolio.py), ultima data dato. Nessuna auth -> solo rete +interna. Esecuzione REALE disabilitata: e' un monitor, non un trader. + + uv run python -m src.live.dashboard --port 8787 +""" +from __future__ import annotations +import sys, json, time +from pathlib import Path +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) +import numpy as np, pandas as pd +from src.portfolio.portfolio import StrategyPortfolio, metrics, HOLDOUT +from src.portfolio.sleeves import active_sleeves +from src.version import APP_VERSION + +PAPER = PROJECT_ROOT / "data" / "paper_portfolio" / "state.json" +_CACHE = {"t": 0.0, "data": None} +_TTL = 120.0 + + +def build(): + if _CACHE["data"] is not None and time.time() - _CACHE["t"] < _TTL: + return _CACHE["data"] + pf = StrategyPortfolio(active_sleeves(), capital=2000.0) + bt = pf.backtest() + eq = bt["equity"]; idx = bt["index"] + # sparkline: subsample ~400 punti + step = max(1, len(eq) // 400) + spark = [(str(idx[i].date()), float(eq[i])) for i in range(0, len(eq), step)] + paper = json.loads(PAPER.read_text()) if PAPER.exists() else None + data = dict( + version=APP_VERSION, + last_data=str(idx[-1].date()), + full=bt["full"], holdout=bt["holdout"], weights=bt["weights"], + per_sleeve=bt["per_sleeve"], yearly=bt["yearly"], + positions=pf.current_positions(), spark=spark, paper=paper, + bh=None, + ) + _CACHE.update(t=time.time(), data=data) + return data + + +def svg_spark(spark, w=900, h=220): + ys = [v for _, v in spark] + lo, hi = min(ys), max(ys) + rng = hi - lo or 1 + pts = [] + for i, (_, v) in enumerate(spark): + x = i / (len(spark) - 1) * w + y = h - (v - lo) / rng * (h - 10) - 5 + pts.append(f"{x:.1f},{y:.1f}") + return (f'' + f'') + + +def html(): + d = build() + f, ho = d["full"], d["holdout"] + rows = "" + for name, s in d["per_sleeve"].items(): + rows += (f"{name}{s['weight']*100:.0f}%" + f"{s['full']['sharpe']:.2f}{s['full']['maxdd']*100:.0f}%" + f"{s['holdout']['sharpe']:.2f}") + yrs = "".join(f"{y}: {v['ret']*100:+.0f}%" for y, v in sorted(d["yearly"].items())) + pos = "" + for sl, p in d["positions"].items(): + pos += f"{sl}{'flat (in cash)' if p == {'BTC': 0.0, 'ETH': 0.0} else (p if p is not None else 'stat-mode (book 19 gambe)')}" + pp = d["paper"] + if pp: + days = (pd.Timestamp(pp["last"]) - pd.Timestamp(pp["start"])).days + ret = pp["equity"] / pp["initial"] - 1 + paper_html = (f"{pp['equity']:.2f} (start {pp['initial']:.0f}, {pp['start'][:10]} → " + f"{pp['last'][:10]}, {days}g)   ret {ret*100:+.2f}%   maxDD {pp['max_dd']*100:.1f}%") + else: + paper_html = "non inizializzato (gira paper_portfolio.py)" + return f""" +PythagorasGoal — Portafoglio + +

PythagorasGoal — Portafoglio attivo (TP01 + XS01)

+
monitor PAPER · v{d['version']} · ultimo dato {d['last_data']} · esecuzione REALE disabilitata
+
+
FULL Sharpe
{f['sharpe']:.2f}
+
HOLD-OUT Sharpe (2025-26)
{ho['sharpe']:.2f}
+
maxDD
{f['maxdd']*100:.1f}%
+
CAGR
{f['cagr']*100:.0f}%
+
ret totale
{f['ret']*100:+.0f}%
+
+
EQUITY backtest (2019→oggi, €2k)
{svg_spark(d['spark'])}
+
Paper forward-only: {paper_html}
+

Sleeve

+{rows}
sleevepesoFULL ShDDHOLD Sh
+

Posizioni correnti (ultima barra chiusa)

+{pos}
+
{yrs}
+

⚠️ Paper/monitor. XS01 e' STAT-MODE (book a 19 gambe market-neutral, non eseguibile a €2k). Storia XS ~2.5 anni.

+""" + + +class H(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_GET(self): + if self.path not in ("/", "/index.html"): + self.send_response(404); self.end_headers(); return + try: + body = html().encode() + except Exception as e: + body = f"
errore: {type(e).__name__}: {e}
".encode() + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers(); self.wfile.write(body) + + +def main(): + port = 8787 + if "--port" in sys.argv: + port = int(sys.argv[sys.argv.index("--port") + 1]) + print(f"dashboard su :{port} (Ctrl-C per uscire)") + ThreadingHTTPServer(("0.0.0.0", port), H).serve_forever() + + +if __name__ == "__main__": + main()