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:
@@ -875,6 +875,71 @@ async def run_vbt_sweep(strategy: str = "pairs"):
|
|||||||
return JSONResponse({"error": str(e)}, status_code=500)
|
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")
|
@app.get("/api/vbt/strategies")
|
||||||
async def list_vbt_strategies():
|
async def list_vbt_strategies():
|
||||||
"""List available strategies for VBT backtesting."""
|
"""List available strategies for VBT backtesting."""
|
||||||
|
|||||||
@@ -73,6 +73,20 @@ select:focus{outline:none;border-color:#555}
|
|||||||
.export-row{display:flex;gap:6px;margin-bottom:6px}
|
.export-row{display:flex;gap:6px;margin-bottom:6px}
|
||||||
.progress-bar{height:4px;background:#03A9F4;border-radius:2px;transition:width .3s;margin-top:4px}
|
.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}
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -179,9 +193,9 @@ select:focus{outline:none;border-color:#555}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="content" id="content">
|
<div class="content" id="content">
|
||||||
<div class="empty">
|
<div id="vbt-overview">
|
||||||
<h2>SELECT A BACKTEST</h2>
|
<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>
|
||||||
<p style="font-size:11px">Choose from the sidebar or configure and run a new test</p>
|
<div id="overview-content"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -242,6 +256,8 @@ async function selectFile(filename){
|
|||||||
currentFilename=filename;
|
currentFilename=filename;
|
||||||
currentResult=null;
|
currentResult=null;
|
||||||
window.location.hash=filename;
|
window.location.hash=filename;
|
||||||
|
const overview=document.getElementById('vbt-overview');
|
||||||
|
if(overview)overview.style.display='none';
|
||||||
const content=document.getElementById('content');
|
const content=document.getElementById('content');
|
||||||
content.innerHTML='<div class="loading" style="padding:40px">Loading '+filename+'...</div>';
|
content.innerHTML='<div class="loading" style="padding:40px">Loading '+filename+'...</div>';
|
||||||
try{
|
try{
|
||||||
@@ -284,6 +300,7 @@ function renderDetail(r){
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('content').innerHTML=
|
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>'+
|
'<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="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">'+
|
'<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-asset').addEventListener('change',applyFilter);
|
||||||
document.getElementById('filter-sort').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(){
|
function restoreFromHash(){
|
||||||
const hash=window.location.hash.replace('#','');
|
const hash=window.location.hash.replace('#','');
|
||||||
if(hash&&hash.endsWith('.json')){selectFile(hash);}
|
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);
|
window.addEventListener('hashchange',restoreFromHash);
|
||||||
|
loadSummary();
|
||||||
loadResults();
|
loadResults();
|
||||||
setTimeout(restoreFromHash,100);
|
setTimeout(restoreFromHash,100);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user