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
+65
View File
@@ -875,6 +875,71 @@ async def run_vbt_sweep(strategy: str = "pairs"):
return JSONResponse({"error": str(e)}, status_code=500)
@app.get("/api/vbt/summary")
async def vbt_summary(sort_by: str = "sharpe"):
"""Aggregated stats: per-strategy best, per-interval count, heatmap data."""
_refresh_vbt_cache()
all_results = list(_vbt_meta_cache.values())
if not all_results:
return JSONResponse({"error": "no results"}, status_code=404)
# Per-strategy best
strategies = {}
for r in all_results:
s = r.get("strategy", "?")
if s not in strategies:
strategies[s] = []
strategies[s].append(r)
best_by_strat = {}
for s, results in strategies.items():
by_sharpe = sorted(results, key=lambda x: x.get("sharpe", -999), reverse=True)[:3]
best_by_strat[s] = {
"count": len(results),
"best_sharpe": max(r.get("sharpe", -999) for r in results),
"best_return": max(r.get("total_return_pct", -999) for r in results),
"best_calmar": max(r.get("calmar", -999) for r in results),
"avg_trades": round(sum(r.get("total_trades", 0) for r in results) / len(results), 1),
"positive_sharpe_pct": round(sum(1 for r in results if r.get("sharpe", 0) > 0) / len(results) * 100, 1),
"top_combo": {
"interval": by_sharpe[0].get("interval", "?"),
"limit": by_sharpe[0].get("n_bars", 0),
"sharpe": by_sharpe[0].get("sharpe", 0),
"return": by_sharpe[0].get("total_return_pct", 0),
},
}
# Heatmap: strategy × interval
intervals_set = sorted(set(r.get("interval", "?") for r in all_results))
heatmap = {}
for r in all_results:
s = r.get("strategy", "?")
iv = r.get("interval", "?")
if s not in heatmap:
heatmap[s] = {}
existing = heatmap[s].get(iv)
sh = r.get("sharpe", -999)
if existing is None or sh > existing.get("sharpe", -999):
heatmap[s][iv] = {
"interval": iv,
"sharpe": r.get("sharpe", 0),
"return_pct": r.get("total_return_pct", 0),
"win_rate": r.get("win_rate", 0),
"trades": r.get("total_trades", 0),
"n_bars": r.get("n_bars", 0),
}
return JSONResponse({
"total_results": len(all_results),
"strategy_count": len(strategies),
"interval_count": len(intervals_set),
"intervals": intervals_set,
"best_by_strategy": best_by_strat,
"heatmap_best_sharpe": heatmap,
})
@app.get("/api/vbt/strategies")
async def list_vbt_strategies():
"""List available strategies for VBT backtesting."""
+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>