"""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")] RETIRED_MIN = 30 # status.json non aggiornato da >30min = worker non piu' nel # config attivo (es. le fade 1h sostituite dal 15m allo swap) # Descrizioni concise (fonte: docs/report/strategie_attive.html / make_strategy_doc.py) DESCRIPTIONS = { "MR01": "Bollinger Fade — quando il close esce dalla banda ±2.5σ (SMA50) entra CONTRO il " "movimento (short sopra/long sotto); TP alla media, SL 2·ATR, max 24 barre. Mean-reversion.", "MR02": "Donchian Fade — fada la rottura degli estremi del canale a 20 barre (max/min recenti); " "TP al centro del canale. Stessa tesi di MR01 con trigger diverso.", "MR07": "Return Reversal — fada il rendimento di barra estremo (z-score ±3.5); exit in multipli " "di ATR. La fade più selettiva (esposizione ~8% del tempo).", "DIP01": "Dip Buy — compra il dip quando lo z del prezzo incrocia sotto −2.5; TP alla SMA50, " "EXIT-16 sul SL, max 24 barre. Unico sleeve BTC con round-trip reali su testnet.", "PR01": "Pairs Reversion — market-neutral: quando |z| del log-ratio fra due asset ≥2 compra la " "gamba debole/shorta la forte, chiude a |z|≤0.75 o 72 barre. Scorrelato dal mercato (~0.05). " "Anche ETH/BTC a 15m (flat-skip). Fee su 2 gambe, senza stop → size ridotta.", "SH01": "Shape-ML — una LogisticRegression legge 17 feature di forma in walk-forward e predice il " "segno del rendimento a 12 barre. Win-rate ~50%: l'edge è nell'asimmetria. Diversificatore, no stop.", "TR01": "Basket Trend (4h) — long quando EMA20>EMA100 su paniere equal-weight di 5 asset, flat " "altrimenti. Trend-following difensivo che cattura i trend lunghi che le fade non prendono.", "ROT02": "Dual Momentum (1d) — ogni giorno tiene le top-3 per momentum 60g (se positive), gross 0.45, " "tutto cash se BTC str: try: from src.version import APP_VERSION return str(APP_VERSION) except Exception: return "?" def _code_of(wid: str) -> str: for code, _ in FAMILY: if wid.startswith(code): return code return "?" _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 _ledger_pnl(equity: float) -> tuple[float, float]: """(pnl_total, capitale iniziale) dal ledger — MAI hardcoded. La equity.jsonl scrive `pnl_total = equity − initial_capital` ad ogni tick → e' la fonte di verita' (il micro-test mainnet parte da 500, il testnet partiva da 2000; lo status non persiste l'iniziale). Da pnl_total derivo l'iniziale (equity − pnl_total) cosi' la dashboard combacia col ledger per costruzione. Fallback: primo punto equity, poi l'equity stessa.""" f = LEDGER / "equity.jsonl" if f.exists(): lines = [l for l in f.read_text().splitlines() if l.strip()] if lines: try: last = json.loads(lines[-1]) if last.get("pnl_total") is not None: pt = float(last["pnl_total"]) return pt, equity - pt init = float(json.loads(lines[0]).get("equity", equity)) return equity - init, init except Exception: pass return 0.0, equity 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) pnl_total, init_cap = _ledger_pnl(equity) # dal ledger, NON hardcoded (500 mainnet / 2000 testnet) strategies, active, closed = [], [], [] paper_strategies, paper_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 = (d.parent.name == "portfolio_paper_stats") # multi-asset (no real exec) pnl, n, wins, closes = _worker_trades(wid) for c in closes: # tag famiglia per le curve aggregate c["fam"] = _family_of(wid) (paper_closed if paper_only else closed).extend(closes) cap = st.get("capital", 0.0) rc = st.get("real_capital") # ritirata = status.json non aggiornato da >30min (non piu' tickato dal runner = # fuori dal config attivo, es. le fade 1h sostituite dal 15m). I paper/stats # tickano sempre -> mai ritirati. retired = (not paper_only) and _age_min(sp) > RETIRED_MIN (paper_strategies if paper_only else strategies).append({ "id": _short(wid), "wid": wid, "family": _family_of(wid), "code": _code_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, "retired": retired, "tf": wid.split("__")[-1], "desc": DESCRIPTIONS.get(_code_of(wid), ""), "version": VERSIONS.get(_code_of(wid), ""), }) if st.get("in_position") and not paper_only: is_pair = "entry_a" in st # SEMPRE prezzi REALI (entry reale di esecuzione vs mark reale USDC): il feed # di decisione SIM (testnet inverse) è dislocato e non va mostrato come prezzo. executed = st.get("real_in_position") et = st.get("entry_time", "") held = None if et: try: t0 = datetime.fromisoformat(et.replace("Z", "+00:00")) held = round((now - t0).total_seconds() / 60.0, 1) except Exception: held = None row = { "id": _short(wid), "family": _family_of(wid), "dir": "LONG" if st.get("direction", 0) > 0 else "SHORT", "bars": st.get("bars_held", 0), "max_bars": st.get("max_bars", 0), "tp": st.get("tp", 0.0), "age_min": round(_age_min(sp), 1), "stale": _age_min(sp) > 15, "pair": is_pair, "real": bool(executed), "entry_time": et, "held_min": held, } if is_pair: a_, b_ = wid.split("__")[1].split("_") # es. ETH_SOL open_assets.update({a_, b_}) row.update({ "asset": None, "leg_a": a_, "leg_b": b_, "entry_a": st.get("real_entry_a") if executed else st.get("entry_a"), "entry_b": st.get("real_entry_b") if executed else st.get("entry_b"), "notional_a": st.get("real_notional_a") or 0.0, "notional_b": st.get("real_notional_b") or 0.0, "dirnum": 1 if st.get("direction", 0) > 0 else -1, "entry": round(st.get("real_entry_a") or st.get("entry_a") or 0.0, 4), }) else: asset = wid.split("__")[1] open_assets.add(asset) entry = (st.get("real_entry_price") if executed else None) or st.get("entry_price") or 0.0 row.update({"asset": asset, "entry": round(entry, 4), "notional": st.get("real_entry_notional") or round(cap * 0.5 * 3, 1)}) active.append(row) # valore di mercato REALE + PnL non realizzato reale (mark USDC live, best-effort) marks = _marks(open_assets) for a in active: if not a["pair"] and a.get("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) if a["tp"]: a["to_tp_pct"] = round(abs(a["tp"] - mark) / mark * 100, 2) elif a["pair"]: ma, mb = marks.get(a["leg_a"]), marks.get(a["leg_b"]) if ma and mb and a["entry_a"] and a["entry_b"]: s = a["dirnum"] # +1 long_ratio: long A / short B gain_a = a["notional_a"] * s * (ma - a["entry_a"]) / a["entry_a"] gain_b = a["notional_b"] * (-s) * (mb - a["entry_b"]) / a["entry_b"] a["mark"] = round(ma, 4) a["mark_b"] = round(mb, 4) a["unreal_eur"] = round(gain_a + gain_b, 2) # curve EQUITY per FAMIGLIA: PnL cumulato di ogni famiglia su asse-tempo comune # (ad ogni CLOSE di una qualsiasi famiglia, il cumulato di TUTTE avanza step-wise). real_asc = sorted(closed, key=lambda c: c["ts"]) fams = sorted({c["fam"] for c in real_asc}) fcum = {f: 0.0 for f in fams} flabels: list[str] = [] fseries: dict[str, list[float]] = {f: [] for f in fams} for c in real_asc: fcum[c["fam"]] += c["pnl"] or 0.0 flabels.append(c["ts"]) for f in fams: fseries[f].append(round(fcum[f], 3)) fam_curves = {"labels": flabels, "series": fseries} closed.sort(key=lambda c: c["ts"], reverse=True) # aggregati per famiglia (SOLO reali: il paper ha la sua area) fam_pnl: dict[str, float] = {} for s in strategies: fam_pnl[s["family"]] = fam_pnl.get(s["family"], 0.0) + s["realized_pnl"] # --- area PAPER distinta: equity propria = capitale-base + PnL cumulato del book --- paper_init = round(sum(s["capital"] - s["realized_pnl"] for s in paper_strategies), 2) paper_closed.sort(key=lambda c: c["ts"]) pcurve, acc = [], paper_init for c in paper_closed: acc += c["pnl"] or 0.0 pcurve.append({"t": c["ts"], "equity": round(acc, 2)}) paper_pnl = round(sum(s["realized_pnl"] for s in paper_strategies), 2) paper_cap = round(sum(s["capital"] for s in paper_strategies), 2) return { "ts": now.isoformat(), "version": _app_version(), "portfolio": { "equity": round(equity, 2), "init": round(init_cap, 2), "pnl_total": round(pnl_total, 2), "pnl_pct": round((pnl_total / init_cap * 100) if init_cap else 0.0, 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())}, "fam_curves": fam_curves, "descriptions": DESCRIPTIONS, # ritirate in fondo, poi per famiglia/id "strategies": sorted(strategies, key=lambda s: (s["retired"], s["family"], s["id"])), "active": sorted(active, key=lambda a: a.get("unreal_eur", 0)), "closed": closed[:50], "n_active": len(active), # area PAPER (multi-asset TR01/ROT02/TSM01/XS01: solo statistica, fuori dal pool reale) "paper": { "strategies": sorted(paper_strategies, key=lambda s: s["id"]), "curve": pcurve, "init": paper_init, "equity": paper_cap, "pnl": paper_pnl, "n": len(paper_strategies), }, } def strategy_detail(wid: str) -> dict: """Dettaglio di una strategia: descrizione + curva PnL cumulato (reale e sim) dai suoi CLOSE + lista trade. Alimenta il modal 'apri scheda strategia'.""" code = _code_of(wid) d = PAPER / wid if not d.exists(): d = STATS / wid sp = d / "status.json" st = json.loads(sp.read_text()) if sp.exists() else {} _, _, _, closes = _worker_trades(wid) closes.sort(key=lambda c: c["ts"]) curve, cr, cs = [], 0.0, 0.0 for c in closes: cr += (c["real_pnl"] if c["real_pnl"] is not None else c["pnl"]) or 0.0 cs += (c["sim_pnl"] if c["sim_pnl"] is not None else c["pnl"]) or 0.0 curve.append({"t": c["ts"], "real": round(cr, 3), "sim": round(cs, 3)}) pos = None if st.get("in_position"): pos = {"dir": "LONG" if st.get("direction", 0) > 0 else "SHORT", "entry": st.get("entry_price") or st.get("entry_a"), "bars": st.get("bars_held"), "max_bars": st.get("max_bars"), "tp": st.get("tp"), "sl": st.get("sl")} return {"id": _short(wid), "code": code, "family": _family_of(wid), "desc": DESCRIPTIONS.get(code, ""), "version": VERSIONS.get(code, ""), "tf": wid.split("__")[-1], "retired": (d.parent.name != "portfolio_paper_stats") and _age_min(sp) > RETIRED_MIN, "paper": d.parent.name == "portfolio_paper_stats", "position": pos, "curve": curve, "trades": list(reversed(closes))[:60]} # ----------------------- 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): from urllib.parse import urlparse, parse_qs p = urlparse(self.path) if p.path == "/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 p.path == "/api/strategy": wid = (parse_qs(p.query).get("wid") or [""])[0] # difesa path-traversal: solo nome cartella semplice if not wid or "/" in wid or ".." in wid: self._send(400, json.dumps({"error": "wid invalido"}), "application/json") return try: self._send(200, json.dumps(strategy_detail(wid)), "application/json") except Exception as e: self._send(500, json.dumps({"error": str(e)}), "application/json") elif p.path == "/report/strategie_attive.html": f = PROJECT_ROOT / "docs" / "report" / "strategie_attive.html" if f.exists(): self._send(200, f.read_text(), "text/html; charset=utf-8") else: self._send(404, "scheda non disponibile (docs/report non montato)", "text/plain") elif p.path == "/" or p.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

Trade attivi — stato in tempo reale

Equity per famiglia (PnL cumulato realizzato)

Strategie REALI per famiglia (realizzato, netto fee)

Trade chiusi (ultimi 50)

📄 Area PAPER — multi-asset (solo statistica, fuori dal pool reale)

TR01 / ROT02 / TSM01 / XS01 girano con capitale nozionale fisso per valutarne l'edge in vista di un'esecuzione reale futura (bloccata dal capitale a €2k). Equity e PnL separati dal portafoglio reale.

Descrizioni strategie

""" 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()