Files
PythagorasGoal/src/live/dashboard.py
T
Adriano Dal Pastro c8b956ab56 fix(dashboard): PnL totale dal ledger, non init hardcoded 2000
La KPI "PnL totale" sottraeva un capitale iniziale fisso di 2000 (retaggio
testnet) -> sul micro-test mainnet da 500 mostrava -1499 (-74.95%) invece di
+0.95 (+0.19%). Nuovo helper _ledger_pnl(): legge pnl_total dall'ultima riga
di equity.jsonl (il ledger lo scrive come equity-initial_capital) e ne deriva
l'iniziale -> la dashboard combacia col ledger per costruzione, qualunque sia
il capitale (500 mainnet / 2000 testnet), niente piu' valori fissi da
aggiornare a mano. Fallback: primo punto equity, poi l'equity stessa.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:48:33 +00:00

690 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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")]
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<SMA100. Rotazione multi-crypto.",
"TSM01": "TSMOM (1d) — long sugli asset con consenso pieno di momentum su 3/6/12 mesi, gross 0.30, "
"risk-off se BTC<SMA100. Diversificatore, non motore di ritorno.",
"XS01": "Cross-Sectional Reversion — ogni 12h classifica 8 crypto per momentum e va long i perdenti "
"relativi / short i vincenti (market-neutral), con dispersion-gate. 3 sub-book sfasati.",
}
# Versione di creazione/ultima modifica significativa per strategia (fonte: CLAUDE.md + diari)
VERSIONS = {
"MR01": "Fade storica · ultima modifica v1.1.30 — swap 1h→15m (2026-06-12)",
"MR02": "Fade storica · v1.1.30 swap 15m (06-12) · MR02/ETH stop-largo (2026-06-09)",
"MR07": "Fade storica · ultima modifica v1.1.30 — swap 1h→15m (2026-06-12)",
"DIP01": "Exec reale v1.0.3 (2026-06-04) · EXIT-16 esteso (2026-06-07)",
"PR01": "Exec reale 2 gambe v1.1.12 (2026-06-08) · blend ETH/BTC 15m v1.1.16 (2026-06-09)",
"SH01": "Live 2026-06-01 · train full-history (06-07) · exec reale v1.1.13 (2026-06-08)",
"TR01": "Honest, paper · fix mean(rets) (2026-06-11)",
"ROT02": "Honest, paper · top_k=3 DD 40→26%",
"TSM01": "Paper · diversificatore risk-off",
"XS01": "Attivo 2026-06-09 · dispersion-gate v1.1.20 (06-10) · phase-tranching (06-11)",
}
def _app_version() -> 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"""<!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)}
.retired-row td{color:#6e7681;opacity:.7}.retired-row .id{text-decoration:line-through}
.badge-ret{background:#3d1418;color:#f85149}.badge-paper{background:#21262d;color:var(--mut)}
.desc{font-size:11px;color:var(--mut);margin-top:2px;max-width:520px}
.dl b{color:var(--acc)}.dl div{padding:5px 0;border-bottom:1px solid var(--bd)}
.ver{font-size:10px;color:#6e7681;margin-top:2px}
.clk{cursor:pointer}.clk:hover .id{color:var(--acc)}
.ov{position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;z-index:10;align-items:flex-start;justify-content:center;overflow:auto}
.ov.on{display:flex}.modal{background:var(--card);border:1px solid var(--bd);border-radius:12px;max-width:820px;width:94%;margin:40px 0;padding:20px}
.modal h3{margin:0 0 4px}.x{float:right;cursor:pointer;color:var(--mut);font-size:20px;line-height:1}
.btn{display:inline-block;background:#1f6feb;color:#fff;padding:7px 12px;border-radius:7px;text-decoration:none;font-size:13px;margin-top:10px}
/* contenitore ad altezza fissa + canvas responsive: l'hit-test del tooltip combacia
col mouse (forzare l'altezza del canvas via CSS sfalsa la risoluzione interna) */
.chartbox{position:relative;width:100%}.chartbox canvas{width:100%!important;height:100%!important}
.chartbox.eq{height:300px}.chartbox.peq{height:200px}.chartbox.m{height:240px}
.paperzone{margin-top:22px;border:1px dashed #6e552233;border-radius:12px;padding:14px;background:linear-gradient(180deg,rgba(210,153,34,.05),transparent)}
#eqcard{background:linear-gradient(180deg,#11161d,#0d1117)}
.eqhead{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px}
.eqhead .big{font-size:22px;font-weight:600}.eqhead .pc{font-size:13px}
</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" id="eqcard"><div class="eqhead"><h2 style="margin:0">Equity</h2>
<div><span class="big" id="eqval">—</span> <span class="pc" id="eqpc"></span></div></div>
<div class="chartbox eq"><canvas id="eq"></canvas></div></div>
<div class="card sec"><h2>Trade attivi — stato in tempo reale</h2><div id="active"></div></div>
<div class="card sec"><h2>Equity per famiglia <span class="mut" style="font-size:12px">(PnL cumulato realizzato)</span></h2>
<div class="chartbox" style="height:240px"><canvas id="famchart"></canvas></div></div>
<div class="card sec"><h2>Strategie REALI per famiglia <span class="mut" style="font-size:12px">(realizzato, netto fee)</span></h2><div id="strat"></div></div>
<div class="card sec"><h2>Trade chiusi (ultimi 50)</h2><div id="closed"></div></div>
<div class="paperzone">
<div class="eqhead"><h2 style="margin:0;color:#d29922">📄 Area PAPER — multi-asset (solo statistica, fuori dal pool reale)</h2>
<div><span class="big" id="peqval">—</span> <span class="pc" id="peqpc"></span></div></div>
<div class="sub">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.</div>
<div class="card" style="margin:10px 0"><div class="chartbox peq"><canvas id="peq"></canvas></div></div>
<div class="card"><div id="pstrat"></div></div>
</div>
<div class="card sec"><h2>Descrizioni strategie</h2><div id="descs"></div></div>
</div>
<div class="ov" id="ov"><div class="modal">
<span class="x" id="mx">✕</span>
<h3 id="mtitle">—</h3><div class="sub" id="msub"></div>
<div class="desc" id="mdesc" style="max-width:none"></div>
<div class="ver" id="mver"></div>
<div id="mpos" class="sub"></div>
<h2 style="font-size:13px;color:var(--acc);margin:14px 0 6px">PnL cumulato (reale vs sim)</h2>
<div class="chartbox m"><canvas id="mchart"></canvas></div><div id="mempty" class="mut"></div>
<a class="btn" id="mfull" href="/report/strategie_attive.html" target="_blank">📊 Scheda dettagliata con grafici della strategia</a>
<h2 style="font-size:13px;color:var(--acc);margin:16px 0 6px">Trade di questa strategia</h2>
<div id="mtrades"></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,init){const ctx=document.getElementById('eq');
const labels=curve.map(c=>c.t.slice(5,16).replace('T',' ')),data=curve.map(c=>c.equity);
const last=data[data.length-1],up=last>=init;
const eqv=document.getElementById('eqval'),eqp=document.getElementById('eqpc');
if(eqv){eqv.textContent='€'+last.toFixed(2);eqp.textContent=(up?'▲ +':'▼ ')+(last-init).toFixed(2)+'€';eqp.className='pc '+(up?'grn':'red');}
if(!window.Chart){if(ctx)ctx.replaceWith(E('div','grafico non disponibile (Chart.js offline)'));return;}
const g=ctx.getContext('2d'),grad=g.createLinearGradient(0,0,0,300);
const col=up?'63,185,80':'248,81,73';
grad.addColorStop(0,`rgba(${col},.35)`);grad.addColorStop(1,`rgba(${col},0)`);
if(chart){chart.data.labels=labels;chart.data.datasets[0].data=data;
chart.data.datasets[0].borderColor=`rgb(${col})`;chart.data.datasets[0].backgroundColor=grad;
chart.options.plugins.annLine=init;chart.update('none');return;}
chart=new Chart(ctx,{type:'line',data:{labels,datasets:[{data,borderColor:`rgb(${col})`,
backgroundColor:grad,fill:true,pointRadius:0,pointHoverRadius:5,pointHoverBackgroundColor:'#fff',
borderWidth:2.5,tension:.25}]},
options:{responsive:true,maintainAspectRatio:false,interaction:{mode:'index',intersect:false},plugins:{legend:{display:false},
tooltip:{backgroundColor:'#161b22',borderColor:'#30363d',borderWidth:1,titleColor:'#8b949e',
bodyColor:'#c9d1d9',padding:10,displayColors:false,
callbacks:{label:c=>'€'+c.parsed.y.toFixed(2)+' ('+(c.parsed.y-init>=0?'+':'')+(c.parsed.y-init).toFixed(2)+'€)'}}},
scales:{x:{ticks:{color:'#6e7681',maxTicksLimit:8,font:{size:10}},grid:{display:false}},
y:{ticks:{color:'#6e7681',callback:v=>'€'+v},grid:{color:'rgba(48,54,61,.5)'},
suggestedMin:Math.min(init,...data)-2,suggestedMax:Math.max(init,...data)+2}}}});}
const FAMCOL={FADE:'#3fb950',PAIRS:'#58a6ff',SHAPE:'#bc8cff',HONEST:'#d29922',TSM:'#39c5cf',XSEC:'#f778ba'};
let famchart=null;
function renderFamChart(fc,fpnl){const ctx=document.getElementById('famchart');if(!ctx||!window.Chart)return;
const labels=(fc.labels||[]).map(t=>t.slice(5,16).replace('T',' '));
const ds=Object.keys(fc.series||{}).map(f=>({label:f+' ('+eur(fpnl[f]||0)+')',data:fc.series[f],
borderColor:FAMCOL[f]||'#8b949e',backgroundColor:'transparent',pointRadius:0,pointHoverRadius:4,borderWidth:2,tension:.15}));
if(famchart){famchart.data.labels=labels;famchart.data.datasets=ds;famchart.update('none');return;}
famchart=new Chart(ctx,{type:'line',data:{labels,datasets:ds},
options:{responsive:true,maintainAspectRatio:false,interaction:{mode:'index',intersect:false},
plugins:{legend:{labels:{color:'#c9d1d9',boxWidth:12,font:{size:11}}},
tooltip:{callbacks:{label:c=>c.dataset.label.split(' ')[0]+': '+eur(c.parsed.y)}}},
scales:{x:{ticks:{color:'#6e7681',maxTicksLimit:8,font:{size:10}},grid:{display:false}},
y:{ticks:{color:'#6e7681',callback:v=>eur(v)},grid:{color:'rgba(48,54,61,.5)'}}}}});}
function stratRow(s,max){const tr=E('tr');tr.className=(s.retired?'retired-row':(s.paper?'muted-row':''))+' clk';
tr.onclick=()=>openModal(s.wid);
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>`;
const badges=(s.retired?' <span class="tag badge-ret">RITIRATA→15m</span>':'')
+(s.in_position?' <span class=tag>•aperta</span>':'');
tr.innerHTML=`<td><span class=id title="apri scheda">${s.id}</span>${badges}
<div class=desc>${s.desc||''}</div><div class=ver>🏷 ${s.version||''}</div></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>`;
return tr;}
function renderStrat(strats,fpnl){const wrap=document.getElementById('strat');wrap.innerHTML='';
const max=Math.max(100,...strats.map(s=>Math.abs(s.realized_pnl)));
// raggruppa per famiglia, attive prima delle ritirate
const byfam={};strats.forEach(s=>{(byfam[s.family]=byfam[s.family]||[]).push(s);});
Object.keys(byfam).sort().forEach(fam=>{
const head=E('div');head.style.cssText='display:flex;justify-content:space-between;align-items:center;margin:14px 0 4px';
const fp=fpnl[fam]||0;
head.innerHTML=`<span style="font-weight:600;color:${FAMCOL[fam]||'#c9d1d9'}">▌${fam}</span>`
+`<span class="${cls(fp)}">${eur(fp)}</span>`;
wrap.append(head);
const t=E('table');t.innerHTML='<tr><th>Strategia</th><th>PnL realizz.</th><th>Trade</th><th>Win%</th><th>Capitale</th><th></th></tr>';
byfam[fam].sort((a,b)=>(a.retired-b.retired)||a.id.localeCompare(b.id))
.forEach(s=>t.append(stratRow(s,max)));
wrap.append(t);});}
let mchart=null;
async function openModal(wid){const ov=document.getElementById('ov');ov.classList.add('on');
document.getElementById('mtitle').textContent='caricamento…';
try{const r=await fetch('/api/strategy?wid='+encodeURIComponent(wid));const d=await r.json();
document.getElementById('mtitle').textContent=d.id+' ['+d.code+']';
document.getElementById('msub').textContent=d.family+' · '+d.tf+(d.retired?' · RITIRATA':'')+(d.paper?' · paper':'');
document.getElementById('mdesc').textContent=d.desc||'';
document.getElementById('mver').innerHTML='🏷 <b>versione:</b> '+(d.version||'—');
document.getElementById('mpos').innerHTML=d.position?`posizione aperta: <b>${d.position.dir}</b> @${d.position.entry} · barre ${d.position.bars}/${d.position.max_bars||'∞'}`:'';
// curva PnL cumulato
const c=d.curve||[];const lab=c.map(x=>x.t.slice(5,16).replace('T',' '));
const em=document.getElementById('mempty');
if(!c.length){em.textContent='nessun trade ancora — la curva apparirà col primo trade chiuso.';}
else em.textContent='';
if(mchart){mchart.destroy();mchart=null;}
if(c.length&&window.Chart){mchart=new Chart(document.getElementById('mchart'),{type:'line',
data:{labels:lab,datasets:[
{label:'reale',data:c.map(x=>x.real),borderColor:'#3fb950',backgroundColor:'rgba(63,185,80,.08)',fill:true,pointRadius:0,borderWidth:2,tension:.15},
{label:'sim',data:c.map(x=>x.sim),borderColor:'#8b949e',borderDash:[5,4],pointRadius:0,borderWidth:1.5,tension:.15}]},
options:{responsive:true,maintainAspectRatio:false,interaction:{mode:'index',intersect:false},plugins:{legend:{labels:{color:'#c9d1d9'}}},scales:{x:{ticks:{color:'#8b949e',maxTicksLimit:7},grid:{display:false}},y:{ticks:{color:'#8b949e'},grid:{color:'#21262d'}}}}});}
// trade
const mt=document.getElementById('mtrades');
if(!d.trades.length){mt.innerHTML='<div class=mut>nessun trade</div>';}
else{const t=E('table');t.innerHTML='<tr><th>Ora</th><th>Motivo</th><th>PnL</th><th>sim</th><th>esito</th></tr>';
d.trades.forEach(x=>{const tr=E('tr');tr.innerHTML=`<td class=mut>${x.ts.slice(5,16).replace('T',' ')}</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);});
mt.innerHTML='';mt.append(t);}
}catch(e){document.getElementById('mtitle').textContent='errore: '+e;}}
document.getElementById('mx').onclick=()=>document.getElementById('ov').classList.remove('on');
document.getElementById('ov').onclick=e=>{if(e.target.id=='ov')document.getElementById('ov').classList.remove('on');};
let peqchart=null;
function renderPaper(p){
const v=document.getElementById('peqval'),pc=document.getElementById('peqpc');
const up=p.pnl>=0;v.textContent='€'+p.equity.toFixed(2);
pc.textContent=(up?'▲ +':'▼ ')+p.pnl.toFixed(2)+'€ realizz.';pc.className='pc '+(up?'grn':'red');
// tabella paper (riuso lo stile della tabella strategie)
const wrap=document.getElementById('pstrat');wrap.innerHTML='';
const max=Math.max(50,...p.strategies.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>';
p.strategies.forEach(s=>{const tr=E('tr');tr.className='clk';tr.onclick=()=>openModal(s.wid);
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><span class=id>${s.id}</span> <span class="tag badge-paper">paper</span>${s.in_position?' <span class=tag>•aperta</span>':''}
<div class=desc>${s.desc||''}</div><div class=ver>🏷 ${s.version||''}</div></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);
// curva equity paper
const c=p.curve||[];const ctx=document.getElementById('peq');
if(!window.Chart||!ctx)return;
const lab=c.map(x=>x.t.slice(5,16).replace('T',' ')),data=c.map(x=>x.equity);
if(peqchart){peqchart.data.labels=lab;peqchart.data.datasets[0].data=data;peqchart.update('none');return;}
peqchart=new Chart(ctx,{type:'line',data:{labels:lab.length?lab:['inizio'],datasets:[{
data:data.length?data:[p.init],borderColor:'#d29922',backgroundColor:'rgba(210,153,34,.12)',
fill:true,pointRadius:c.length<30?3:0,borderWidth:2,tension:.2}]},
options:{responsive:true,maintainAspectRatio:false,interaction:{mode:'index',intersect:false},plugins:{legend:{display:false},tooltip:{callbacks:{label:x=>'€'+x.parsed.y.toFixed(2)}}},
scales:{x:{ticks:{color:'#6e7681',maxTicksLimit:7,font:{size:10}},grid:{display:false}},
y:{ticks:{color:'#6e7681',callback:v=>'€'+v},grid:{color:'rgba(48,54,61,.5)'}}}}});}
function renderDescs(d){const wrap=document.getElementById('descs');if(!wrap)return;wrap.innerHTML='';
const box=E('div');box.className='dl';
Object.keys(d).forEach(k=>{const r=E('div');r.innerHTML=`<b>${k}</b> — ${d[k]}`;box.append(r);});
wrap.append(box);}
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>Ingresso (UTC)</th><th>In posizione</th><th>Entry reale</th><th>Mercato reale</th><th>PnL non realizz.</th><th>Barre</th><th>→TP%</th></tr>';
a.forEach(p=>{const tr=E('tr');
const side=`<span class="pill ${p.dir=='LONG'?'long':'short'}">${p.dir}${p.pair?' ratio':''}</span>`;
const ue=p.unreal_eur!=null?`<span class="${cls(p.unreal_eur)}">${eur(p.unreal_eur)}${p.unreal_pct!=null?' ('+p.unreal_pct+'%)':''}</span>`:'<span class=mut>—</span>';
const mkt=p.pair?(p.mark!=null?`${p.mark} / ${p.mark_b}`:'—'):(p.mark||'—');
const ing=p.entry_time?p.entry_time.slice(5,16).replace('T',' '):'—';
const held=fmtDur(p.held_min);
tr.innerHTML=`<td>${p.id}${p.real?'':' <span class=tag>sim</span>'}${p.pair?' <span class=tag>'+p.leg_a+'/'+p.leg_b+'</span>':''}</td><td>${side}</td>
<td class=mut>${ing}</td><td class="${p.stale?'stale':''}">${held}${p.stale?' ⚠':''}</td>
<td>${p.entry}</td><td>${mkt}</td><td>${ue}</td>
<td>${p.bars}/${p.max_bars||'∞'}</td><td class=mut>${p.to_tp_pct!=null?p.to_tp_pct:'—'}</td>`;
t.append(tr);});wrap.append(t);}
function fmtDur(m){if(m==null)return '—';m=Math.round(m);if(m<60)return m+'m';
const h=Math.floor(m/60),mm=m%60;if(h<24)return h+'h '+mm+'m';
const d=Math.floor(h/24);return d+'g '+(h%24)+'h';}
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('ver').textContent='v'+(s.version||'?');
document.getElementById('nact').textContent=s.n_active;
renderKpis(s.portfolio);renderChart(s.portfolio.curve,s.portfolio.init);
renderFamChart(s.fam_curves,s.fam_pnl);renderStrat(s.strategies,s.fam_pnl);renderActive(s.active);renderClosed(s.closed);
if(s.paper)renderPaper(s.paper);renderDescs(s.descriptions);
}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()