Files
ftdt-quant-lab/dashboard/static/vbt.html
T
ramseshk 6889e06a86 feat: VBT dashboard overhaul — pagination, caching, LTTB downsampling, deep links, export, more metrics
- Merge vbt_server.py into server.py (eliminate duplicated VBT API)
- Add server-side pagination (offset/limit) with metadata (total, has_more)
- Add server-side ?asset= filtering to results endpoint
- Add JSON file caching with 5s TTL to avoid re-parsing on every request
- Add LTTB (Largest-Triangle-Three-Buckets) downsampling for equity curves
- Add pre-computed drawdown curve to result detail response
- Add /api/vbt/result/{filename}/csv endpoint for trade export
- Add Calmar ratio and expectancy to results metadata
- Rebuild vbt.html frontend with:
  - URL hash deep-linking (#filename) for bookmarkable views
  - JSON and CSV export buttons in detail panel
  - More metrics: Calmar, Sortino, Expectancy, End Equity (10 total)
  - Running backtest progress indicator with elapsed seconds
  - Pagination controls (prev/next) with page info
  - Filter/sort changes auto-apply (no manual refresh needed)
  - Better error states with retry buttons
  - Run strategy selector independent of filter
2026-08-07 14:14:40 +08:00

347 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FTDT Quant Lab — VBT Dashboard</title>
<script src="https://cdn.plot.ly/plotly-3.1.0.min.js"></script>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'SF Mono','Cascadia Code','Ubuntu Mono',monospace;background:#0a0a0a;color:#888;min-height:100vh;overflow:hidden}
.pos{color:#03A9F4}.neg{color:#FF5252}.neu{color:#777}.warn{color:#f59e0b}
.topbar{background:#111;border-bottom:1px solid #222;padding:8px 20px;display:flex;justify-content:space-between;align-items:center;height:40px}
.topbar h1{font-size:13px;font-weight:600;color:#ddd;letter-spacing:1px}
.topbar .dot{display:inline-block;width:6px;height:6px;background:#fff;border-radius:50%;margin-right:5px;animation:pulse 2s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
.topbar .status{font-size:10px;color:#999}
.main{display:grid;grid-template-columns:320px 1fr;height:calc(100vh - 40px)}
.sidebar{background:#0d0d0d;border-right:1px solid #222;overflow-y:auto;padding:6px;display:flex;flex-direction:column}
.controls{padding:6px;border-bottom:1px solid #222;margin-bottom:4px;flex-shrink:0}
.controls .row{display:flex;gap:3px;margin-bottom:3px}
select{background:#181818;border:1px solid #333;color:#aaa;border-radius:3px;padding:5px 6px;font-size:10px;font-family:inherit;cursor:pointer;flex:1;min-width:0}
select:focus{outline:none;border-color:#555}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:3px;padding:4px 7px;border-radius:3px;font-size:10px;cursor:pointer;border:1px solid #333;background:#181818;color:#aaa;font-family:inherit;transition:all .12s;white-space:nowrap}
.btn:hover{background:#222;border-color:#555}
.btn-primary{background:#03A9F4;border-color:#03A9F4;color:#fff}
.btn-primary:hover{background:#0288d1}
.btn-success{background:#4CAF50;border-color:#4CAF50;color:#fff}
.btn-success:hover{background:#388E3C}
.btn:disabled{opacity:.4;cursor:not-allowed}
.result-item{background:#111;border:1px solid #222;border-left:3px solid transparent;border-radius:3px;padding:7px 8px;margin-bottom:2px;cursor:pointer;transition:all .12s}
.result-item:hover{border-color:#333;background:#161616}
.result-item.active{border-color:#03A9F4;border-left-color:#03A9F4;background:#121212}
.result-item .name{font-size:11px;font-weight:600;color:#ccc;display:flex;justify-content:space-between;align-items:center}
.result-item .name .asset{font-size:9px;color:#03A9F4;background:#0d1f2b;border:1px solid #1a3a4a;border-radius:3px;padding:1px 5px}
.result-item .meta{font-size:9px;color:#555;margin-top:2px}
.result-item .stats{display:flex;gap:8px;margin-top:3px;font-size:9px;font-family:monospace;flex-wrap:wrap}
.sidebar h3{font-size:9px;text-transform:uppercase;color:#555;margin:8px 0 4px;letter-spacing:2px;padding:0 4px}
.badge{font-size:9px;border-radius:3px;padding:1px 4px;margin-left:3px;font-weight:400}
#results-list{flex:1;overflow-y:auto;min-height:0}
.pagination-row{display:flex;align-items:center;justify-content:space-between;padding:4px 4px 2px;border-top:1px solid #1a1a1a;flex-shrink:0}
.pagination-row .count{font-size:9px;color:#555}
.pagination-row .nav-btn{font-size:9px;padding:2px 6px;cursor:pointer;border:1px solid #333;background:#181818;color:#aaa;border-radius:3px;font-family:inherit}
.pagination-row .nav-btn:hover{background:#222;border-color:#555}
.pagination-row .nav-btn:disabled{opacity:.3;cursor:default}
.content{padding:20px 28px;overflow-y:auto}
.content h2{font-size:15px;color:#ddd;margin-bottom:2px}
.content .sub{font-size:10px;color:#555;margin-bottom:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.metrics-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:6px;margin-bottom:10px}
.metric{background:#111;border:1px solid #222;border-radius:4px;padding:10px 12px}
.metric .label{font-size:8px;text-transform:uppercase;color:#555;letter-spacing:1.5px;margin-bottom:3px}
.metric .value{font-size:18px;font-weight:700;font-family:monospace}
.chart-row{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px}
.chart-box{background:#111;border:1px solid #222;border-radius:4px;padding:12px}
.chart-box h4{font-size:9px;color:#555;text-transform:uppercase;letter-spacing:1.5px;margin-bottom:2px}
.chart-box.full{grid-column:1/-1}
.empty{text-align:center;padding:60px 20px;color:#333}
.empty h2{font-size:14px;margin-bottom:6px;color:#555}
.loading{text-align:center;padding:14px;color:#333;font-size:10px}
.error-box{background:#1a0a0a;border:1px solid #551111;border-radius:4px;padding:14px;text-align:center}
.error-box h3{color:#FF5252;font-size:12px;margin-bottom:6px}
.error-box button{margin-top:8px}
.params-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:6px;margin-bottom:10px}
.param{background:#111;border:1px solid #222;border-radius:3px;padding:8px 10px}
.param .k{font-size:9px;color:#555;text-transform:uppercase;margin-bottom:2px}
.param .v{font-size:13px;color:#03A9F4;font-family:monospace;font-weight:600}
.trades-table{width:100%;border-collapse:collapse;font-size:11px;font-family:monospace}
.trades-table th{text-align:left;padding:7px 10px;border-bottom:1px solid #222;color:#555;font-weight:500;font-size:9px;text-transform:uppercase;letter-spacing:1px;position:sticky;top:0;background:#111;z-index:1}
.trades-table td{padding:5px 10px;border-bottom:1px solid #1a1a1a;color:#aaa}
.trades-table tr:hover{background:#151515}
.trades-table .pnl-pos{color:#03A9F4}.trades-table .pnl-neg{color:#FF5252}
.trade-scroll{max-height:400px;overflow-y:auto;border:1px solid #222;border-radius:4px}
.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}
</style>
</head>
<body>
<div class="topbar">
<h1><span class="dot"></span>FTDT QUANT LAB — VBT</h1>
<div class="status">Hyperliquid · VectorBT</div>
</div>
<div class="main">
<div class="sidebar">
<div class="controls">
<div class="row">
<select id="filter-strategy">
<option value="">All Strategies</option>
<option value="pairs">Pairs Trading</option>
<option value="hurst_vpin">Hurst VPIN</option>
<option value="as_mm">Avellaneda-Stoikov</option>
<option value="obi">Order Book Imbalance</option>
<option value="grid_mm">Grid Market Making</option>
<option value="composite_mm">Composite MM</option>
<option value="iceberg">Iceberg Detection</option>
<option value="momentum">Momentum Breakout</option>
<option value="mean_rev">Mean Reversion</option>
</select>
<select id="filter-asset">
<option value="">All Assets</option>
<option value="BTC">BTC</option>
<option value="ETH">ETH</option>
<option value="BTC/ETH">BTC/ETH</option>
</select>
</div>
<div class="row">
<select id="filter-interval">
<option value="">All Intervals</option>
<option value="1m">1m</option><option value="5m">5m</option><option value="15m">15m</option>
<option value="1h">1h</option><option value="4h">4h</option><option value="1d">1d</option>
</select>
<select id="filter-sort">
<option value="date">Latest</option>
<option value="sharpe">Sharpe</option>
<option value="return">Return %</option>
<option value="calmar">Calmar</option>
<option value="dd">Min DD</option>
<option value="trades">Trades</option>
</select>
</div>
<div class="row">
<select id="run-strategy">
<option value="pairs">Pairs Trading</option>
<option value="hurst_vpin">Hurst VPIN</option>
<option value="as_mm">Avellaneda-Stoikov</option>
<option value="obi">Order Book Imbalance</option>
<option value="grid_mm">Grid Market Making</option>
<option value="composite_mm">Composite MM</option>
<option value="iceberg">Iceberg Detection</option>
<option value="momentum">Momentum Breakout</option>
<option value="mean_rev">Mean Reversion</option>
</select>
</div>
<div class="row">
<select id="run-interval">
<option value="1m">1m</option><option value="5m">5m</option><option value="15m">15m</option>
<option value="1h" selected>1h</option><option value="4h">4h</option><option value="1d">1d</option>
</select>
<select id="run-limit">
<option value="100">100 bars</option><option value="200">200 bars</option><option value="500" selected>500 bars</option>
<option value="1000">1K bars</option><option value="2000">2K bars</option><option value="5000">5K bars</option>
</select>
<select id="run-coin">
<option value="">auto</option><option value="BTC">BTC</option><option value="ETH">ETH</option><option value="SOL">SOL</option>
</select>
</div>
<div style="display:flex;gap:4px">
<button class="btn btn-primary" onclick="runNewBacktest()" id="btn-run">▶ Run Backtest</button>
<button class="btn" onclick="loadResults()" id="btn-refresh">↻</button>
</div>
<div id="run-status"></div>
</div>
<div style="font-size:9px;color:#444;padding:2px 4px" id="result-count"></div>
<div id="results-list"><div class="loading">Loading...</div></div>
<div class="pagination-row" id="pagination" style="display:none">
<button class="nav-btn" id="btn-prev" onclick="loadPage(-1)" disabled>← Prev</button>
<span class="count" id="page-info"></span>
<button class="nav-btn" id="btn-next" onclick="loadPage(1)" disabled>Next →</button>
</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>
</div>
</div>
<script>
const API='';
const PAGE_SIZE=50;
let currentResult=null, currentFilename=null, currentOffset=0, currentTotal=0, runTimer=null;
function buildUrl(){let p=new URLSearchParams();const s=document.getElementById('filter-strategy').value,i=document.getElementById('filter-interval').value,a=document.getElementById('filter-asset').value,so=document.getElementById('filter-sort').value;p.set('limit',PAGE_SIZE);p.set('offset',currentOffset);if(s)p.set('strategy',s);if(i)p.set('interval',i);if(a)p.set('asset',a);p.set('sort',so);return`${API}/api/vbt/results?`+p;}
async function loadResults(){
document.getElementById('results-list').innerHTML='<div class="loading">Loading...</div>';
try{
const resp=await fetch(buildUrl());const data=await resp.json();
if(!data||!Array.isArray(data.results)){
const results=data.results||[];const total=data.total||results.length;const hasMore=data.has_more??false;
renderResultsList(results,total,hasMore);
}else{
renderResultsList(data,data.length,false);
}
}catch(e){document.getElementById('results-list').innerHTML='<div class="error-box" style="padding:8px;font-size:10px">Failed to load results</div><div style="font-size:9px;color:#555;text-align:center;margin-top:4px">'+e+'</div>';}
}
function renderResultsList(results,total,hasMore){
currentTotal=total;
const el=document.getElementById('results-list');
document.getElementById('result-count').textContent=total+' results';
if(!results.length){el.innerHTML='<div style="padding:8px;color:#555;font-size:10px">No results</div>';document.getElementById('pagination').style.display='none';return;}
el.innerHTML=results.map((r,i)=>{
const c=(r.sharpe||0)>0.5?'pos':((r.sharpe||0)<-0.5?'neg':'neu');
const rc=(r.total_return_pct||0)>=0?'pos':'neg';
const activeCls=r.filename===currentFilename?' active':'';
return '<div class="result-item'+activeCls+'" onclick="selectFile(\''+r.filename+'\')" id="item-'+r.filename+'"><div class="name">'+r.strategy+'<span class="asset">'+(r.asset||'?')+'</span></div><div class="meta">'+(r.interval||'1h')+' · '+(r.total_trades||0)+'t · '+(r.n_bars||0)+'b · '+(r.generated_at||'').substring(0,10)+'</div><div class="stats"><span class="'+c+'">S'+(r.sharpe||0).toFixed(2)+'</span><span class="'+rc+'">'+(r.total_return_pct||0).toFixed(1)+'%</span><span class="neu">PF'+(r.profit_factor||0).toFixed(2)+'</span><span class="neu">WR'+(Math.round((r.win_rate||0)*100))+'%</span></div></div>';
}).join('');
const pagination=document.getElementById('pagination');
if(total>PAGE_SIZE){
pagination.style.display='flex';
document.getElementById('btn-prev').disabled=currentOffset===0;
document.getElementById('btn-next').disabled=!hasMore;
const end=Math.min(currentOffset+PAGE_SIZE,total);
document.getElementById('page-info').textContent=(currentOffset+1)+'–'+end+' of '+total;
}else{pagination.style.display='none';}
document.getElementById('pagination').style.display=total>PAGE_SIZE?'flex':'none';
}
function loadPage(dir){
currentOffset=Math.max(0,currentOffset+dir*PAGE_SIZE);
loadResults();
}
async function selectFile(filename){
document.querySelectorAll('.result-item').forEach(el=>el.classList.remove('active'));
const item=document.getElementById('item-'+filename);
if(item)item.classList.add('active');
currentFilename=filename;
currentResult=null;
window.location.hash=filename;
const content=document.getElementById('content');
content.innerHTML='<div class="loading" style="padding:40px">Loading '+filename+'...</div>';
try{
const r=await(await fetch(API+'/api/vbt/result/'+encodeURIComponent(filename))).json();
if(r.error||!r.strategy){content.innerHTML='<div class="error-box"><h3>Error</h3><p style="font-size:11px;color:#999">'+r.error+'</p><button class="btn" onclick="selectFile(\''+filename+'\')">Retry</button></div>';return;}
currentResult=r;renderDetail(r);
}catch(e){content.innerHTML='<div class="error-box"><h3>Error</h3><p style="font-size:11px;color:#999">'+e+'</p><button class="btn" onclick="selectFile(\''+filename+'\')">Retry</button></div>';}
}
function renderDetail(r){
const ret=r.total_return_pct??0,dd=r.max_drawdown_pct??0,sh=r.sharpe??0;
const wr=(r.win_rate??0)*100,pf=r.profit_factor??0,eq=r.end_equity??10000;
const tr=r.total_trades??0,nb=r.n_bars??0,so=r.sortino??0;
const asset=r.asset||'-';const params=r.params||{};
const calmar=r.calmar||((dd)?(ret/abs(dd)):0);
const expec=r.expectancy??(tr?(r.pnl??0)/tr:0);
let avgDur='';
const trades=r.trades||[];
if(trades.length){const durations=trades.map(t=>String(t.duration||'')).filter(d=>d);if(durations.length)avgDur=durations[0];}
let params_html='';
for(const[k,v]of Object.entries(params)){
let cls='neu';
if(typeof v==='number'){if(k.includes('entry')||k.includes('threshold'))cls=v>0.5?'pos':'neu';else cls=v>0?'pos':(v<0?'neg':'neu');}
params_html+='<div class="param"><div class="k">'+k.replace(/_/g,' ')+'</div><div class="v '+cls+'">'+(typeof v==='number'?v.toFixed(3):v)+'</div></div>';
}
let trades_html='';
if(trades.length){
const rows=trades.map(t=>'<tr><td style="color:#777;font-size:10px">'+String(t.time||'').substring(0,19)+'</td><td class="'+(String(t.side||'').includes('BUY')?'pos':'neg')+'">'+String(t.side||'')+'</td><td>'+Number(t.size||0).toFixed(6)+'</td><td>$'+Number(t.entry_px||0).toFixed(1)+'</td><td>$'+Number(t.exit_px||0).toFixed(1)+'</td><td class="'+(Number(t.pnl||0)>=0?'pnl-pos':'pnl-neg')+'">$'+Number(t.pnl||0).toFixed(4)+'</td><td>'+String(t.duration||'')+'</td></tr>').join('');
trades_html='<div class="chart-box full"><div style="display:flex;justify-content:space-between;align-items:center"><h4>Trade Log ('+trades.length+' trades)</h4></div><div class="trade-scroll"><table class="trades-table"><thead><tr><th>Time</th><th>Side</th><th>Size</th><th>Entry</th><th>Exit</th><th>PnL</th><th>Duration</th></tr></thead><tbody>'+rows+'</tbody></table></div></div>';
}
document.getElementById('content').innerHTML=
'<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">'+
'<button class="btn" onclick="exportJSON()" title="Download full result as JSON">JSON</button>'+
'<button class="btn" onclick="exportCSV()" title="Download trades as CSV">CSV</button>'+
'</div></div>'+
'<div class="metrics-grid">'+
'<div class="metric"><div class="label">Total Return</div><div class="value '+(ret>=0?'pos':'neg')+'">'+ret.toFixed(2)+'%</div></div>'+
'<div class="metric"><div class="label">Sharpe</div><div class="value '+(sh>=0?'pos':'neg')+'">'+sh.toFixed(2)+'</div></div>'+
'<div class="metric"><div class="label">Sortino</div><div class="value '+(so>=0?'pos':'neg')+'">'+so.toFixed(2)+'</div></div>'+
'<div class="metric"><div class="label">Calmar</div><div class="value '+(calmar>=0?'pos':'neg')+'">'+calmar.toFixed(2)+'</div></div>'+
'<div class="metric"><div class="label">Max DD</div><div class="value neg">'+dd.toFixed(2)+'%</div></div>'+
'<div class="metric"><div class="label">Win Rate</div><div class="value '+(wr>=50?'pos':'neg')+'">'+wr.toFixed(0)+'%</div></div>'+
'<div class="metric"><div class="label">Profit Factor</div><div class="value '+(pf>=1?'pos':'neg')+'">'+pf.toFixed(2)+'</div></div>'+
'<div class="metric"><div class="label">Expectancy</div><div class="value '+(expec>=0?'pos':'neg')+'">$'+expec.toFixed(4)+'</div></div>'+
'<div class="metric"><div class="label">Trades</div><div class="value neu">'+tr+'</div></div>'+
'<div class="metric"><div class="label">End Equity</div><div class="value neu">$'+parseInt(eq).toLocaleString()+'</div></div>'+
'</div>'+
'<div class="chart-box full"><h4>Strategy Parameters</h4><div class="params-grid">'+params_html+'</div></div>'+
'<div class="chart-row">'+
'<div class="chart-box full"><h4>Equity Curve</h4><div id="chart-eq" style="height:300px"></div></div>'+
'</div>'+
'<div class="chart-row">'+
'<div class="chart-box"><h4>Drawdown</h4><div id="chart-dd" style="height:250px"></div></div>'+
'<div class="chart-box"><h4>Period Returns</h4><div id="chart-ret" style="height:250px"></div></div>'+
'</div>'+
trades_html;
renderCharts(r);
}
function renderCharts(r){
const ec=r.equity_curve||[];
if(!ec.length)return;
const times=ec.map(p=>p.t),values=ec.map(p=>p.v);
Plotly.newPlot('chart-eq',[{x:times,y:values,type:'scatter',mode:'lines',line:{color:'#03A9F4',width:1.2},fill:'tozeroy',fillcolor:'rgba(3,169,244,0.06)'}],{margin:{t:4,r:12,b:28,l:65},height:300,paper_bgcolor:'rgba(0,0,0,0)',plot_bgcolor:'rgba(0,0,0,0)',xaxis:{gridcolor:'#1a1a1a',tickfont:{color:'#444',size:9}},yaxis:{gridcolor:'#1a1a1a',tickfont:{color:'#444',size:9},tickprefix:'$'},showlegend:false},{responsive:true,displayModeBar:false});
const ddData=r.drawdown_curve||[];
if(ddData.length){Plotly.newPlot('chart-dd',[{x:times,y:ddData,type:'scatter',mode:'none',fill:'tozeroy',fillcolor:'rgba(255,82,82,0.10)',line:{color:'#FF5252',width:0.8}}],{margin:{t:4,r:12,b:28,l:55},height:250,paper_bgcolor:'rgba(0,0,0,0)',plot_bgcolor:'rgba(0,0,0,0)',xaxis:{gridcolor:'#1a1a1a',tickfont:{color:'#444',size:9}},yaxis:{gridcolor:'#1a1a1a',tickfont:{color:'#444',size:9},ticksuffix:'%'},showlegend:false},{responsive:true,displayModeBar:false});}else{const peak=values.reduce((a,v,i)=>(a.push(i?Math.max(a[i-1],v):v),a),[]);const dd=values.map((v,i)=>i?-(peak[i]-v)/peak[i]*100:0);Plotly.newPlot('chart-dd',[{x:times,y:dd,type:'scatter',mode:'none',fill:'tozeroy',fillcolor:'rgba(255,82,82,0.10)',line:{color:'#FF5252',width:0.8}}],{margin:{t:4,r:12,b:28,l:55},height:250,paper_bgcolor:'rgba(0,0,0,0)',plot_bgcolor:'rgba(0,0,0,0)',xaxis:{gridcolor:'#1a1a1a',tickfont:{color:'#444',size:9}},yaxis:{gridcolor:'#1a1a1a',tickfont:{color:'#444',size:9},ticksuffix:'%'},showlegend:false},{responsive:true,displayModeBar:false});}
if(values.length>1){const rets=values.slice(1).map((v,i)=>(v-values[i])/values[i]*100);Plotly.newPlot('chart-ret',[{x:rets,type:'histogram',nbinsx:40,marker:{color:'#03A9F4',opacity:0.6,line:{color:'#111',width:1}}}],{margin:{t:4,r:12,b:28,l:45},height:250,paper_bgcolor:'rgba(0,0,0,0)',plot_bgcolor:'rgba(0,0,0,0)',xaxis:{gridcolor:'#1a1a1a',tickfont:{color:'#444',size:9},ticksuffix:'%'},yaxis:{gridcolor:'#1a1a1a',tickfont:{color:'#444',size:9}},showlegend:false,bargap:0.02},{responsive:true,displayModeBar:false});}
}
function exportJSON(){if(!currentResult)return;const blob=new Blob([JSON.stringify(currentResult,null,2)],{type:'application/json'});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=(currentFilename||'backtest')+'.json';a.click();}
function exportCSV(){if(!currentFilename)return;const a=document.createElement('a');a.href=API+'/api/vbt/result/'+encodeURIComponent(currentFilename)+'/csv';a.download=currentFilename.replace('.json','')+'_trades.csv';a.click();}
async function runNewBacktest(){
const strat=document.getElementById('run-strategy').value;
const interval=document.getElementById('run-interval').value;
const limit=document.getElementById('run-limit').value;
const coin=document.getElementById('run-coin').value;
const btn=document.getElementById('btn-run');
const status=document.getElementById('run-status');
const orig=btn.textContent;btn.textContent='⏳ Running...';btn.disabled=true;
status.innerHTML='<div class="run-status">Running '+strat+' '+interval+'...</div><div class="progress-bar" style="width:0%" id="progress-bar"></div>';
const startTime=Date.now();
runTimer=setInterval(()=>{
const elapsed=Math.round((Date.now()-startTime)/1000);
status.innerHTML='<div class="run-status">Running '+strat+' '+interval+'... '+elapsed+'s</div><div class="progress-bar" style="width:'+Math.min(elapsed*10,95)+'%"></div>';
},1000);
try{
let url=API+'/api/vbt/run?strategy='+strat+'&interval='+interval+'&limit='+limit;
if(coin)url+='&coin='+coin;
const resp=await fetch(url);
const data=await resp.json();
clearInterval(runTimer);runTimer=null;
if(data.error){status.innerHTML='<div style="font-size:10px;color:#FF5252;padding:4px 0">Error: '+data.error+'</div>';btn.textContent=orig;btn.disabled=false;return;}
currentResult=data;currentFilename=data.filename;
renderDetail(data);
currentOffset=0;loadResults();
status.innerHTML='<div style="font-size:10px;color:#4CAF50;padding:4px 0">Done in '+Math.round((Date.now()-startTime)/1000)+'s</div>';
}catch(e){clearInterval(runTimer);runTimer=null;status.innerHTML='<div style="font-size:10px;color:#FF5252;padding:4px 0">Failed: '+e.message+'</div>';}
btn.textContent=orig;btn.disabled=false;
}
function applyFilter(){currentOffset=0;loadResults();}
document.getElementById('filter-strategy').addEventListener('change',applyFilter);
document.getElementById('filter-interval').addEventListener('change',applyFilter);
document.getElementById('filter-asset').addEventListener('change',applyFilter);
document.getElementById('filter-sort').addEventListener('change',applyFilter);
function restoreFromHash(){
const hash=window.location.hash.replace('#','');
if(hash&&hash.endsWith('.json')){selectFile(hash);}
}
window.addEventListener('hashchange',restoreFromHash);
loadResults();
setTimeout(restoreFromHash,100);
</script>
</body>
</html>