feat(dashboard): frontend web PORT06 — stato live, PnL totale/per-strategia, grafici, trade

Server stdlib http.server (zero dipendenze nuove) che legge data/: KPI equity/PnL/DD,
grafico equity (Chart.js CDN + fallback), PnL per-strategia (barre, realizzato reale),
trade attivi in TEMPO REALE (mark Cerbero best-effort, PnL non realizzato, barre, eta
stantio) e chiusi (ultimi 50). Servizio docker-compose 'dashboard' porta 8787, stessa
immagine, monta data/, restart unless-stopped + healthcheck. Nessuna auth -> rete interna.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-13 19:46:47 +00:00
parent dc22256e0e
commit 31f08ddf32
3 changed files with 394 additions and 0 deletions
+328
View File
@@ -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://<host>: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"""<!doctype html><html lang="it"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PORT06 — Dashboard</title>
<style>
:root{--bg:#0d1117;--card:#161b22;--bd:#30363d;--tx:#c9d1d9;--mut:#8b949e;--grn:#3fb950;--red:#f85149;--acc:#58a6ff}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--tx);font:14px/1.4 -apple-system,Segoe UI,Roboto,sans-serif}
.wrap{max-width:1200px;margin:0 auto;padding:16px}
h1{font-size:18px;margin:0 0 2px}.sub{color:var(--mut);font-size:12px;margin-bottom:14px}
.grid{display:grid;gap:12px}.kpis{grid-template-columns:repeat(auto-fit,minmax(150px,1fr))}
.card{background:var(--card);border:1px solid var(--bd);border-radius:10px;padding:14px}
.kpi .v{font-size:24px;font-weight:600}.kpi .l{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.04em}
.grn{color:var(--grn)}.red{color:var(--red)}.mut{color:var(--mut)}
table{width:100%;border-collapse:collapse;font-size:13px}th,td{text-align:right;padding:6px 8px;border-bottom:1px solid var(--bd)}
th:first-child,td:first-child{text-align:left}th{color:var(--mut);font-weight:500;font-size:11px;text-transform:uppercase}
.bar{height:18px;border-radius:4px;display:inline-block;vertical-align:middle}
.tag{font-size:10px;padding:1px 6px;border-radius:10px;background:#21262d;color:var(--mut);margin-left:6px}
.sec{margin-top:18px}.sec h2{font-size:14px;margin:0 0 8px;color:var(--acc)}
.dot{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:5px}
.pill{font-size:11px;padding:1px 7px;border-radius:10px}.long{background:#0f3d2e;color:var(--grn)}.short{background:#3d1418;color:var(--red)}
.stale{color:#d29922}.muted-row td{color:var(--mut)}
canvas{width:100%!important;height:220px!important}
</style></head><body><div class="wrap">
<h1>PORT06 — Dashboard live <span id="ver" class="tag"></span></h1>
<div class="sub">aggiornato <span id="ts">—</span> · refresh 5s · <span id="nact">0</span> posizioni aperte</div>
<div class="grid kpis" id="kpis"></div>
<div class="card sec"><h2>Equity</h2><canvas id="eq"></canvas></div>
<div class="card sec"><h2>PnL per strategia (realizzato, netto fee)</h2><div id="strat"></div></div>
<div class="card sec"><h2>Trade attivi — stato in tempo reale</h2><div id="active"></div></div>
<div class="card sec"><h2>Trade chiusi (ultimi 50)</h2><div id="closed"></div></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script>
const E=(s,...c)=>{const e=document.createElement(s);c.forEach(x=>e.append(x));return e};
const eur=n=>(n>=0?'+':'')+n.toFixed(2)+'', cls=n=>n>=0?'grn':'red';
let chart=null;
function kpi(l,v,c){const d=E('div');d.className='card kpi';const a=E('div');a.className='l';a.textContent=l;
const b=E('div');b.className='v '+(c||'');b.textContent=v;d.append(a,b);return d}
function renderKpis(p){const k=document.getElementById('kpis');k.innerHTML='';
k.append(kpi('Equity',''+p.equity.toFixed(2)),
kpi('PnL totale',eur(p.pnl_total)+' ('+p.pnl_pct.toFixed(2)+'%)',cls(p.pnl_total)),
kpi('Max Drawdown',p.dd.toFixed(2)+'%'),
kpi('Peak',''+p.peak.toFixed(2)));}
function renderChart(curve){const ctx=document.getElementById('eq');
const labels=curve.map(c=>c.t.slice(5,16).replace('T',' ')),data=curve.map(c=>c.equity);
if(chart){chart.data.labels=labels;chart.data.datasets[0].data=data;chart.update('none');return;}
if(!window.Chart){ctx.replaceWith(E('div','grafico non disponibile (Chart.js offline)'));return;}
chart=new Chart(ctx,{type:'line',data:{labels,datasets:[{data,borderColor:'#58a6ff',
backgroundColor:'rgba(88,166,255,.1)',fill:true,pointRadius:0,borderWidth:2,tension:.2}]},
options:{plugins:{legend:{display:false}},scales:{x:{ticks:{color:'#8b949e',maxTicksLimit:8},grid:{display:false}},
y:{ticks:{color:'#8b949e'},grid:{color:'#21262d'}}}}});}
function renderStrat(strats,fam){const wrap=document.getElementById('strat');wrap.innerHTML='';
const max=Math.max(100,...strats.map(s=>Math.abs(s.realized_pnl)));
const t=E('table');t.innerHTML='<tr><th>Strategia</th><th>Fam</th><th>PnL realizz.</th><th>Trade</th><th>Win%</th><th>Capitale</th><th></th></tr>';
strats.forEach(s=>{const tr=E('tr');if(s.paper)tr.className='muted-row';
const w=Math.abs(s.realized_pnl)/max*120;
const bar=`<span class="bar" style="width:${w}px;background:${s.realized_pnl>=0?'#3fb950':'#f85149'}"></span>`;
tr.innerHTML=`<td>${s.id}${s.paper?'<span class=tag>paper</span>':''}${s.in_position?' <span class=tag>•aperta</span>':''}</td>
<td class=mut>${s.family}</td><td class="${cls(s.realized_pnl)}">${eur(s.realized_pnl)}</td>
<td>${s.n_trades}</td><td class=mut>${s.win_rate.toFixed(0)}</td><td class=mut>€${s.capital.toFixed(0)}</td><td>${bar}</td>`;
t.append(tr);});wrap.append(t);}
function renderActive(a){const wrap=document.getElementById('active');wrap.innerHTML='';
if(!a.length){wrap.innerHTML='<div class=mut>nessuna posizione aperta</div>';return;}
const t=E('table');t.innerHTML='<tr><th>Strategia</th><th>Lato</th><th>Entry</th><th>Mark</th><th>PnL non realizz.</th><th>Barre</th><th>→TP%</th><th>Età</th></tr>';
a.forEach(p=>{const tr=E('tr');
const side=`<span class="pill ${p.dir=='LONG'?'long':'short'}">${p.dir}</span>`;
const ue=p.unreal_eur!=null?`<span class="${cls(p.unreal_eur)}">${eur(p.unreal_eur)} (${p.unreal_pct}%)</span>`:(p.pair?'<span class=mut>pairs (z)</span>':'<span class=mut>—</span>');
tr.innerHTML=`<td>${p.id}${p.real?'':' <span class=tag>sim</span>'}</td><td>${side}</td>
<td>${p.entry}</td><td>${p.mark||''}</td><td>${ue}</td>
<td>${p.bars}/${p.max_bars||''}</td><td class=mut>${p.to_tp_pct!=null?p.to_tp_pct:''}</td>
<td class="${p.stale?'stale':'mut'}">${p.age_min}m${p.stale?'':''}</td>`;
t.append(tr);});wrap.append(t);}
function renderClosed(c){const wrap=document.getElementById('closed');wrap.innerHTML='';
if(!c.length){wrap.innerHTML='<div class=mut>nessun trade chiuso</div>';return;}
const t=E('table');t.innerHTML='<tr><th>Ora (UTC)</th><th>Strategia</th><th>Motivo</th><th>PnL</th><th>sim</th><th>esito</th></tr>';
c.forEach(x=>{const tr=E('tr');
tr.innerHTML=`<td class=mut>${x.ts.slice(5,16).replace('T',' ')}</td><td>${x.worker}</td>
<td class=mut>${x.reason}</td><td class="${cls(x.pnl)}">${eur(x.pnl)}</td>
<td class=mut>${x.sim_pnl!=null?x.sim_pnl.toFixed(2):''}</td>
<td><span class="dot" style="background:${x.win?'#3fb950':'#f85149'}"></span>${x.win?'win':'loss'}</td>`;
t.append(tr);});wrap.append(t);}
async function tick(){try{const r=await fetch('/api/state');const s=await r.json();
if(s.error){document.getElementById('ts').textContent='errore: '+s.error;return;}
document.getElementById('ts').textContent=s.ts.slice(11,19);
document.getElementById('nact').textContent=s.n_active;
renderKpis(s.portfolio);renderChart(s.portfolio.curve);
renderStrat(s.strategies,s.fam_pnl);renderActive(s.active);renderClosed(s.closed);
}catch(e){document.getElementById('ts').textContent='offline';}}
tick();setInterval(tick,5000);
</script></body></html>"""
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()