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:
@@ -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()
|
||||
Reference in New Issue
Block a user