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