feat: VBT dashboard overview — strategy cards + heatmap + /api/vbt/summary

dashboard/server.py — new /api/vbt/summary endpoint:
  - Aggregated per-strategy best stats (Sharpe, Return, Calmar, trades,
    positive-Sharpe %, top combo details)
  - Heatmap: strategy × interval matrix with best Sharpe per cell

dashboard/static/vbt.html — overview panel replaces empty state:
  - Strategy cards grid: best Sharpe, best return, top combo,
    positive-Sharpe %, run count. Click to filter sidebar.
  - Heatmap: color-coded strategy × interval table (green=good,
    red=bad). Hover for tooltip, click to load result.
  - Back-to-overview button when viewing a strategy detail
  - Overview re-shown on page load, auto-loads from /api/vbt/summary

Dashboard opens to overview → select strategy → drill into details
This commit is contained in:
ramseshk
2026-08-10 12:03:49 +08:00
parent 140b0cc360
commit b7c7fbb0af
2 changed files with 154 additions and 3 deletions
+89 -3
View File
@@ -73,6 +73,20 @@ select:focus{outline:none;border-color:#555}
.export-row{display:flex;gap:6px;margin-bottom:6px}
.progress-bar{height:4px;background:#03A9F4;border-radius:2px;transition:width .3s;margin-top:4px}
.run-status{font-size:10px;color:#f59e0b;padding:4px 0}
.heatmap-table{border-collapse:collapse;font-size:10px;font-family:monospace;width:100%}
.heatmap-table th{text-align:center;padding:3px 6px;color:#555;font-weight:500;font-size:8px;text-transform:uppercase;letter-spacing:1px}
.heatmap-table th:first-child{text-align:left;min-width:130px}
.heatmap-table td{text-align:center;padding:3px 6px;border:1px solid #1a1a1a;font-size:10px}
.heatmap-table td:first-child{text-align:left;color:#aaa;font-size:10px}
.heatmap-cell{cursor:pointer;transition:all .12s;border-radius:2px}
.heatmap-cell:hover{outline:1px solid #555;z-index:1;position:relative}
.strat-summary{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:6px;margin-bottom:10px}
.strat-card{background:#111;border:1px solid #222;border-radius:3px;padding:7px 9px;cursor:pointer;transition:all .12s}
.strat-card:hover{border-color:#444;background:#161616}
.strat-card .strat-name{font-size:10px;font-weight:600;color:#ccc;margin-bottom:4px}
.strat-card .strat-row{display:flex;justify-content:space-between;font-size:9px;padding:1px 0;color:#666}
.strat-card .strat-row .val{font-family:monospace;color:#888}
.strat-card .strat-row .val.pos{color:#03A9F4}.strat-card .strat-row .val.neg{color:#FF5252}
</style>
</head>
<body>
@@ -179,9 +193,9 @@ select:focus{outline:none;border-color:#555}
</div>
</div>
<div class="content" id="content">
<div class="empty">
<h2>SELECT A BACKTEST</h2>
<p style="font-size:11px">Choose from the sidebar or configure and run a new test</p>
<div id="vbt-overview">
<div style="padding:10px 0"><span style="font-size:12px;color:#ddd;font-weight:600">OVERVIEW</span><span class="loading" id="overview-loading" style="display:inline;margin-left:10px;font-size:10px">Loading...</span></div>
<div id="overview-content"></div>
</div>
</div>
</div>
@@ -242,6 +256,8 @@ async function selectFile(filename){
currentFilename=filename;
currentResult=null;
window.location.hash=filename;
const overview=document.getElementById('vbt-overview');
if(overview)overview.style.display='none';
const content=document.getElementById('content');
content.innerHTML='<div class="loading" style="padding:40px">Loading '+filename+'...</div>';
try{
@@ -284,6 +300,7 @@ function renderDetail(r){
}
document.getElementById('content').innerHTML=
'<div style="margin-bottom:8px"><button class="btn" onclick="showOverview()">← Overview</button></div>'+
'<h2>'+r.strategy+' <span class="badge" style="font-size:10px;background:#0d1f2b;color:#03A9F4;border:1px solid #1a3a4a">'+asset+'</span></h2>'+
'<div class="sub"><span>'+(r.interval||'1h')+'</span><span>'+nb+' bars</span><span>'+tr+' trades</span><span>'+(r.generated_at||'').substring(0,19)+'</span>'+
'<div class="export-row">'+
@@ -378,11 +395,80 @@ document.getElementById('filter-interval').addEventListener('change',applyFilter
document.getElementById('filter-asset').addEventListener('change',applyFilter);
document.getElementById('filter-sort').addEventListener('change',applyFilter);
async function loadSummary(){
document.getElementById('overview-loading').style.display='inline';
try{
const resp=await fetch(API+'/api/vbt/summary');
const d=await resp.json();
if(d.error){document.getElementById('overview-content').innerHTML='<div style="color:#555;padding:10px">'+d.error+'</div>';return;}
renderSummary(d);
}catch(e){document.getElementById('overview-content').innerHTML='<div style="color:#FF5252;padding:10px">Failed to load summary</div>';}
document.getElementById('overview-loading').style.display='none';
}
function renderSummary(d){
const strategies=d.best_by_strategy||{};
const heatmap=d.heatmap_best_sharpe||{};
const intervals=d.intervals||[];
const stratNames=Object.keys(strategies).sort();
// Strategy cards
let cards='<div class="strat-summary">';
for(const s of stratNames){
const info=strategies[s];
const sh=info.best_sharpe||0;
const shCls=sh>0.5?'pos':(sh<-0.5?'neg':'neu');
const retCls=info.best_return>=0?'pos':'neg';
cards+='<div class="strat-card" onclick="document.getElementById(\'filter-strategy\').value=\''+s+'\';applyFilter()"><div class="strat-name">'+s+'</div>'+
'<div class="strat-row"><span>Best Sharpe</span><span class="val '+shCls+'">'+sh.toFixed(2)+'</span></div>'+
'<div class="strat-row"><span>Best Return</span><span class="val '+retCls+'">'+(info.best_return||0).toFixed(1)+'%</span></div>'+
'<div class="strat-row"><span>Best Combo</span><span class="val">'+info.top_combo.interval+' '+info.top_combo.limit+'b</span></div>'+
'<div class="strat-row"><span>+Sharpe %</span><span class="val">'+(info.positive_sharpe_pct||0).toFixed(0)+'%</span></div>'+
'<div class="strat-row"><span>Runs</span><span class="val">'+info.count+'</span></div>'+
'</div>';
}
cards+='</div>';
// Heatmap
let hmap='<div class="chart-box full"><h4>Strategy × Interval Heatmap (Best Sharpe)</h4><div style="overflow-x:auto"><table class="heatmap-table"><thead><tr><th></th>';
for(const iv of intervals)hmap+='<th>'+iv+'</th>';
hmap+='</tr></thead><tbody>';
for(const s of stratNames){
hmap+='<tr><td>'+s+'</td>';
for(const iv of intervals){
const cell=(heatmap[s]||{})[iv]||{};
const sh=cell.sharpe||0;
let bg='#111',clr='#555';
if(sh>2){bg='#0a2a0a';clr='#00e676'}
else if(sh>0.5){bg='#0a1a0a';clr='#4caf50'}
else if(sh>0){bg='#1a1a0a';clr='#aaa'}
else if(sh>-0.5){bg='#1a0a0a';clr='#aa5555'}
else {bg='#2a0a0a';clr='#ff1744'}
hmap+='<td class="heatmap-cell" style="background:'+bg+';color:'+clr+'" title="'+s+' '+iv+': S='+sh.toFixed(2)+' ret='+(cell.return_pct||0).toFixed(1)+'% '+cell.trades+'t" onclick="document.getElementById(\'filter-strategy\').value=\''+s+'\';document.getElementById(\'filter-interval\').value=\''+iv+'\';selectFile(\''+(cell.filename||(s+'_BTC_'+iv+'_500_'+(new Date().toISOString().slice(0,10).replace(/-/g,''))+'.json'))+'\')">'+sh.toFixed(1)+'</td>';
}
hmap+='</tr>';
}
hmap+='</tbody></table></div></div>';
document.getElementById('overview-content').innerHTML=
'<div style="font-size:10px;color:#555;margin-bottom:6px">'+d.total_results+' results across '+d.strategy_count+' strategies</div>'+
cards+hmap;
}
function restoreFromHash(){
const hash=window.location.hash.replace('#','');
if(hash&&hash.endsWith('.json')){selectFile(hash);}
}
function showOverview(){
currentFilename=null;currentResult=null;
window.location.hash='';
document.getElementById('vbt-overview').style.display='block';
document.getElementById('content').innerHTML='<div id="vbt-overview"><div style="padding:10px 0"><span style="font-size:12px;color:#ddd;font-weight:600">OVERVIEW</span></div><div id="overview-content"></div></div>';
document.querySelectorAll('.result-item').forEach(el=>el.classList.remove('active'));
loadSummary();
}
window.addEventListener('hashchange',restoreFromHash);
loadSummary();
loadResults();
setTimeout(restoreFromHash,100);
</script>