Files
ftdt-quant-lab/dashboard/static/vbt.html
T
ramseshk 9d817ac2fa feat: VBT trade log — show asset, entry/exit prices, Hyperliquid fees
Backend (vbt_runner.py):
  - Add asset (BTC/ETH) to each trade record
  - Compute per-trade fee using HL taker rate (0.05%)
    entry_fee = size * entry_px * fee_rate
    exit_fee = size * exit_px * fee_rate
  - Add pnl_gross (before fees) and pnl_net (after fees)
  - Add fee_rate field for transparency

Server (server.py):
  - Normalize old backtest trades: add missing asset, fee,
    pnl_net, pnl_gross fields
  - Holyliquid default fee rate: 0.05% taker

Frontend (vbt.html):
  - Trade log table now shows:
    Time | Side + Asset | Size | Entry | Exit | Fee | PnL (net) | Duration
  - Asset shown as inline badge in Side column
  - Fee column with explicit USD amount
  - PnL now explicitly labeled 'net' (after fees)
  - Fallback to old 'pnl' field for legacy backtest files
2026-08-07 15:35:33 +08:00

354 lines
24 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)){
renderResultsList(data.results,data.total||0,data.has_more??false);
}else if(Array.isArray(data)){
renderResultsList(data,data.length,false);
}else{
throw new Error('Unexpected response');
}
}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/Math.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=>{
const pnl_net=Number(t.pnl_net??t.pnl??0);
const fee=Number(t.fee??0);
const pnlCls=pnl_net>=0?'pnl-pos':'pnl-neg';
const assetBadge = t.asset ? '<span style="font-size:9px;color:#03A9F4;background:#0d1f2b;border:1px solid #1a3a4a;border-radius:2px;padding:0 4px;margin-left:4px">'+t.asset+'</span>' : '';
return '<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||'')+assetBadge+'</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="neg" style="font-size:10px">$'+fee.toFixed(6)+'</td><td class="'+pnlCls+'">$'+pnl_net.toFixed(4)+'</td><td style="font-size:10px">'+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>Fee</th><th>PnL (net)</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>