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:
@@ -18,3 +18,29 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
labels:
|
labels:
|
||||||
- com.centurylinklabs.watchtower.enable=false
|
- 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
|
||||||
|
|||||||
@@ -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://<host>: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)".
|
||||||
@@ -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