c8b956ab56
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>
690 lines
40 KiB
Python
690 lines
40 KiB
Python
"""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()
|