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:
+160
-15
@@ -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"""<!doctype html><html lang="it"><head><meta charset="utf-8">
|
||||
.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}
|
||||
</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"><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>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>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>
|
||||
<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>
|
||||
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('Max Drawdown',p.dd.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);
|
||||
if(chart){chart.data.labels=labels;chart.data.datasets[0].data=data;chart.update('none');return;}
|
||||
if(!window.Chart){ctx.replaceWith(E('div','grafico non disponibile (Chart.js offline)'));return;}
|
||||
chart=new Chart(ctx,{type:'line',data:{labels,datasets:[{data,borderColor:'#58a6ff',
|
||||
backgroundColor:'rgba(88,166,255,.1)',fill:true,pointRadius:0,borderWidth:2,tension:.2}]},
|
||||
options:{plugins:{legend:{display:false}},scales:{x:{ticks:{color:'#8b949e',maxTicksLimit:8},grid:{display:false}},
|
||||
y:{ticks:{color:'#8b949e'},grid:{color:'#21262d'}}}}});}
|
||||
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:{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='';
|
||||
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>';
|
||||
@@ -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>':'')
|
||||
+(s.paper?' <span class="tag badge-paper">paper</span>':'')
|
||||
+(s.in_position?' <span class=tag>•aperta</span>':'');
|
||||
tr.innerHTML=`<td><span class=id title="${(s.desc||'').replace(/"/g,'"')}">${s.id}</span>${badges}
|
||||
<div class=desc>${s.desc||''}</div></td>
|
||||
tr.className+=' clk';tr.onclick=()=>openModal(s.wid);
|
||||
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>${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);}
|
||||
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='';
|
||||
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);});
|
||||
@@ -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();
|
||||
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);
|
||||
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);
|
||||
}catch(e){document.getElementById('ts').textContent='offline';}}
|
||||
tick();setInterval(tick,5000);
|
||||
|
||||
Reference in New Issue
Block a user