diff --git a/docker-compose.yml b/docker-compose.yml index 4099a59..595fad2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,7 @@ services: volumes: - ./data:/app/data - ./portfolios.yml:/app/portfolios.yml:ro + - ./docs/report:/app/docs/report:ro # scheda strategie_attive.html (modal "scheda dettagliata") env_file: - .env environment: diff --git a/src/live/dashboard.py b/src/live/dashboard.py index c6a69bc..127a53d 100644 --- a/src/live/dashboard.py +++ b/src/live/dashboard.py @@ -59,6 +59,29 @@ DESCRIPTIONS = { } +# 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): @@ -173,13 +196,14 @@ def build_state() -> dict: # tickano sempre -> mai ritirati. retired = (not paper_only) and _age_min(sp) > RETIRED_MIN strategies.append({ - "id": _short(wid), "family": _family_of(wid), "code": _code_of(wid), + "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 @@ -223,6 +247,7 @@ def build_state() -> dict: return { "ts": now.isoformat(), + "version": _app_version(), "portfolio": { "equity": round(equity, 2), "init": init_cap, "pnl_total": round(equity - init_cap, 2), @@ -241,6 +266,37 @@ def build_state() -> dict: } +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): @@ -254,12 +310,30 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(body.encode()) def do_GET(self): - if self.path.startswith("/api/state"): + 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 self.path == "/" or self.path.startswith("/index"): + 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") @@ -289,17 +363,40 @@ HTML = r"""
.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)} - canvas{width:100%!important;height:220px!important} + .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} + canvas{width:100%!important;height:220px!important}#mchart{height:240px!important}#eq{height:300px!important} + #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}