feat(dashboard): scheda per strategia (modal: curva PnL reale vs sim + trade + link grafici) + versione sistema/strategia + equity restyling

- click su una strategia -> modal con descrizione, versione di creazione/modifica,
  curva PnL CUMULATO reale vs sim (mostra il leakage), trade, e link alla scheda
  dettagliata strategie_attive.html (servita, docs/report montato ro)
- versione di sistema (APP_VERSION) nell'header; per ogni strategia la versione/
  origine documentata (mappa VERSIONS da CLAUDE.md)
- equity restyling: gradiente verde/rosso secondo segno, tooltip con EUR e delta,
  valore+delta in testata, assi in EUR, piu' alto. /api/strategy con guard
  path-traversal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-13 21:20:33 +00:00
parent 22af19c7f9
commit b6b465878a
2 changed files with 161 additions and 15 deletions
+1
View File
@@ -33,6 +33,7 @@ services:
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./portfolios.yml:/app/portfolios.yml:ro - ./portfolios.yml:/app/portfolios.yml:ro
- ./docs/report:/app/docs/report:ro # scheda strategie_attive.html (modal "scheda dettagliata")
env_file: env_file:
- .env - .env
environment: environment:
+160 -15
View File
@@ -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: def _code_of(wid: str) -> str:
for code, _ in FAMILY: for code, _ in FAMILY:
if wid.startswith(code): if wid.startswith(code):
@@ -173,13 +196,14 @@ def build_state() -> dict:
# tickano sempre -> mai ritirati. # tickano sempre -> mai ritirati.
retired = (not paper_only) and _age_min(sp) > RETIRED_MIN retired = (not paper_only) and _age_min(sp) > RETIRED_MIN
strategies.append({ 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, "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, "realized_pnl": round(pnl, 2), "n_trades": n, "wins": wins,
"win_rate": round(wins / n * 100, 0) if n else 0.0, "win_rate": round(wins / n * 100, 0) if n else 0.0,
"in_position": bool(st.get("in_position")), "paper": paper_only, "in_position": bool(st.get("in_position")), "paper": paper_only,
"retired": retired, "tf": wid.split("__")[-1], "retired": retired, "tf": wid.split("__")[-1],
"desc": DESCRIPTIONS.get(_code_of(wid), ""), "desc": DESCRIPTIONS.get(_code_of(wid), ""),
"version": VERSIONS.get(_code_of(wid), ""),
}) })
if st.get("in_position") and not paper_only: if st.get("in_position") and not paper_only:
is_pair = "entry_a" in st is_pair = "entry_a" in st
@@ -223,6 +247,7 @@ def build_state() -> dict:
return { return {
"ts": now.isoformat(), "ts": now.isoformat(),
"version": _app_version(),
"portfolio": { "portfolio": {
"equity": round(equity, 2), "init": init_cap, "equity": round(equity, 2), "init": init_cap,
"pnl_total": round(equity - init_cap, 2), "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 ----------------------- # ----------------------- HTTP -----------------------
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
def log_message(self, *a): def log_message(self, *a):
@@ -254,12 +310,30 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.write(body.encode()) self.wfile.write(body.encode())
def do_GET(self): 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: try:
self._send(200, json.dumps(build_state()), "application/json") self._send(200, json.dumps(build_state()), "application/json")
except Exception as e: except Exception as e:
self._send(500, json.dumps({"error": str(e)}), "application/json") 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") self._send(200, HTML, "text/html; charset=utf-8")
else: else:
self._send(404, "not found", "text/plain") self._send(404, "not found", "text/plain")
@@ -289,17 +363,40 @@ HTML = r"""<!doctype html><html lang="it"><head><meta charset="utf-8">
.badge-ret{background:#3d1418;color:#f85149}.badge-paper{background:#21262d;color:var(--mut)} .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} .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)} .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}
</style></head><body><div class="wrap"> </style></head><body><div class="wrap">
<h1>PORT06 — Dashboard live <span id="ver" class="tag"></span></h1> <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="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="grid kpis" id="kpis"></div>
<div class="card sec"><h2>Equity</h2><canvas id="eq"></canvas></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>
<canvas id="eq"></canvas></div>
<div class="card sec"><h2>PnL per strategia (realizzato, netto fee)</h2><div id="strat"></div></div> <div class="card sec"><h2>PnL per strategia (realizzato, netto fee)</h2><div id="strat"></div></div>
<div class="card sec"><h2>Trade attivi — stato in tempo reale</h2><div id="active"></div></div> <div class="card sec"><h2>Trade attivi — stato in tempo reale</h2><div id="active"></div></div>
<div class="card sec"><h2>Trade chiusi (ultimi 50)</h2><div id="closed"></div></div> <div class="card sec"><h2>Trade chiusi (ultimi 50)</h2><div id="closed"></div></div>
<div class="card sec"><h2>Descrizioni strategie</h2><div id="descs"></div></div> <div class="card sec"><h2>Descrizioni strategie</h2><div id="descs"></div></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>
<canvas id="mchart"></canvas><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 src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script> <script>
const E=(s,...c)=>{const e=document.createElement(s);c.forEach(x=>e.append(x));return e}; const E=(s,...c)=>{const e=document.createElement(s);c.forEach(x=>e.append(x));return e};
@@ -312,14 +409,28 @@ function renderKpis(p){const k=document.getElementById('kpis');k.innerHTML='';
kpi('PnL totale',eur(p.pnl_total)+' ('+p.pnl_pct.toFixed(2)+'%)',cls(p.pnl_total)), kpi('PnL totale',eur(p.pnl_total)+' ('+p.pnl_pct.toFixed(2)+'%)',cls(p.pnl_total)),
kpi('Max Drawdown',p.dd.toFixed(2)+'%'), kpi('Max Drawdown',p.dd.toFixed(2)+'%'),
kpi('Peak',''+p.peak.toFixed(2)));} kpi('Peak',''+p.peak.toFixed(2)));}
function renderChart(curve){const ctx=document.getElementById('eq'); 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 labels=curve.map(c=>c.t.slice(5,16).replace('T',' ')),data=curve.map(c=>c.equity);
if(chart){chart.data.labels=labels;chart.data.datasets[0].data=data;chart.update('none');return;} const last=data[data.length-1],up=last>=init;
if(!window.Chart){ctx.replaceWith(E('div','grafico non disponibile (Chart.js offline)'));return;} const eqv=document.getElementById('eqval'),eqp=document.getElementById('eqpc');
chart=new Chart(ctx,{type:'line',data:{labels,datasets:[{data,borderColor:'#58a6ff', if(eqv){eqv.textContent=''+last.toFixed(2);eqp.textContent=(up?'▲ +':'')+(last-init).toFixed(2)+'';eqp.className='pc '+(up?'grn':'red');}
backgroundColor:'rgba(88,166,255,.1)',fill:true,pointRadius:0,borderWidth:2,tension:.2}]}, if(!window.Chart){if(ctx)ctx.replaceWith(E('div','grafico non disponibile (Chart.js offline)'));return;}
options:{plugins:{legend:{display:false}},scales:{x:{ticks:{color:'#8b949e',maxTicksLimit:8},grid:{display:false}}, const g=ctx.getContext('2d'),grad=g.createLinearGradient(0,0,0,300);
y:{ticks:{color:'#8b949e'},grid:{color:'#21262d'}}}}});} 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:{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}}}});}
function renderStrat(strats,fam){const wrap=document.getElementById('strat');wrap.innerHTML=''; function renderStrat(strats,fam){const wrap=document.getElementById('strat');wrap.innerHTML='';
const max=Math.max(100,...strats.map(s=>Math.abs(s.realized_pnl))); const max=Math.max(100,...strats.map(s=>Math.abs(s.realized_pnl)));
const t=E('table');t.innerHTML='<tr><th>Strategia</th><th>Fam</th><th>PnL realizz.</th><th>Trade</th><th>Win%</th><th>Capitale</th><th></th></tr>'; 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>';
@@ -329,11 +440,44 @@ function renderStrat(strats,fam){const wrap=document.getElementById('strat');wra
const badges=(s.retired?' <span class="tag badge-ret">RITIRATA→15m</span>':'') const badges=(s.retired?' <span class="tag badge-ret">RITIRATA→15m</span>':'')
+(s.paper?' <span class="tag badge-paper">paper</span>':'') +(s.paper?' <span class="tag badge-paper">paper</span>':'')
+(s.in_position?' <span class=tag>•aperta</span>':''); +(s.in_position?' <span class=tag>•aperta</span>':'');
tr.innerHTML=`<td><span class=id title="${(s.desc||'').replace(/"/g,'&quot;')}">${s.id}</span>${badges} tr.className+=' clk';tr.onclick=()=>openModal(s.wid);
<div class=desc>${s.desc||''}</div></td> 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=mut>${s.family}</td><td class="${cls(s.realized_pnl)}">${eur(s.realized_pnl)}</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>`; <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);} t.append(tr);});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:{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');};
function renderDescs(d){const wrap=document.getElementById('descs');if(!wrap)return;wrap.innerHTML=''; function renderDescs(d){const wrap=document.getElementById('descs');if(!wrap)return;wrap.innerHTML='';
const box=E('div');box.className='dl'; 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);}); Object.keys(d).forEach(k=>{const r=E('div');r.innerHTML=`<b>${k}</b> — ${d[k]}`;box.append(r);});
@@ -361,8 +505,9 @@ function renderClosed(c){const wrap=document.getElementById('closed');wrap.inner
async function tick(){try{const r=await fetch('/api/state');const s=await r.json(); 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;} if(s.error){document.getElementById('ts').textContent='errore: '+s.error;return;}
document.getElementById('ts').textContent=s.ts.slice(11,19); document.getElementById('ts').textContent=s.ts.slice(11,19);
document.getElementById('ver').textContent='v'+(s.version||'?');
document.getElementById('nact').textContent=s.n_active; document.getElementById('nact').textContent=s.n_active;
renderKpis(s.portfolio);renderChart(s.portfolio.curve); renderKpis(s.portfolio);renderChart(s.portfolio.curve,s.portfolio.init);
renderStrat(s.strategies,s.fam_pnl);renderActive(s.active);renderClosed(s.closed);renderDescs(s.descriptions); renderStrat(s.strategies,s.fam_pnl);renderActive(s.active);renderClosed(s.closed);renderDescs(s.descriptions);
}catch(e){document.getElementById('ts').textContent='offline';}} }catch(e){document.getElementById('ts').textContent='offline';}}
tick();setInterval(tick,5000); tick();setInterval(tick,5000);