feat: trade log table, strategy params panel, B+W color scheme

Dashboard:
- Trade log table: all trades with time, side, size, entry/exit price, PnL, duration
  in scrollable panel below charts
- Strategy params panel: displays all coefficients (z_entry, gamma, obi_entry,
  grid_levels, etc.) for the selected strategy
- Color scheme: professional black/white
  • positive: #03A9F4 (light blue)
  • negative: #FF5252 (red)
  • neutral: #777 (gray)
  • backgrounds: #0a0a0a / #111 / #181818
  • borders: #222 / #333

VBT runner:
- _extract_metrics now captures trades from pf.trades.records_readable
  (Avg Entry Price, Avg Exit Price, PnL, Return, Duration, Direction)
- _strategy_params() returns key coefficients per strategy type
- _empty_result includes empty trades/params

New vbt_server.py: minimal standalone dashboard (no live trading machinery,
no memory guard, no broadcast loop) — avoids crashing issues
This commit is contained in:
ramseshk
2026-08-07 12:53:52 +08:00
parent 121c67ae5f
commit 887a33f278
3 changed files with 399 additions and 212 deletions
+38
View File
@@ -442,6 +442,24 @@ class VBTBacktestRunner:
return coin_map.get(strategy, ["BTC"])
def _extract_metrics(self, pf, stats, strategy, interval, n_bars) -> dict:
# Extract trade records from VectorBT portfolio
trades = []
try:
trade_records = pf.trades.records_readable
for _, t in trade_records.iterrows():
trades.append({
"time": str(t.get("Exit Timestamp", t.get("Entry Timestamp", "")))[:19],
"side": "BUY" if str(t.get("Direction", "")) == "Long" else "SELL",
"size": round(float(t.get("Size", 0)), 6),
"entry_px": round(float(t.get("Avg Entry Price", 0)), 2),
"exit_px": round(float(t.get("Avg Exit Price", 0)), 2),
"pnl": round(float(t.get("PnL", 0)), 4),
"return_pct": round(float(t.get("Return", 0)) * 100, 3),
"duration": str(t.get("Duration", "")),
})
except Exception:
pass
return {
"strategy": strategy,
"interval": interval,
@@ -456,6 +474,8 @@ class VBTBacktestRunner:
"win_rate": round(float(stats.get("Win Rate [%]", 0)) / 100, 3),
"profit_factor": round(float(stats.get("Profit Factor", 0)), 3),
"expectancy": round(float(stats.get("Expectancy", 0)), 3),
"trades": trades,
"params": _strategy_params(strategy),
}
def _empty_result(self, strategy: str, interval: str) -> dict:
@@ -472,10 +492,28 @@ class VBTBacktestRunner:
"max_drawdown_pct": 0.0,
"win_rate": 0.0,
"total_trades": 0,
"trades": [],
"params": _strategy_params(strategy),
"generated_at": datetime.now(timezone.utc).isoformat(),
}
def _strategy_params(strategy: str) -> dict:
"""Return the key parameters/coefficients for a strategy."""
params = {
"pairs": {"z_entry": 1.5, "z_exit": 0.5, "lookback": 20, "type": "Stat Arb"},
"hurst_vpin": {"hurst_entry": 0.55, "hurst_exit": 0.45, "vpin_threshold": 0.25, "vpin_window": 50, "hurst_window": 64, "type": "Directional"},
"as_mm": {"gamma": 0.1, "sigma_dynamic": True, "inventory_skew": True, "type": "Market Making"},
"obi": {"obi_lookback": 20, "obi_entry": 0.35, "obi_exit": 0.10, "type": "Reversal"},
"grid_mm": {"grid_levels": 10, "grid_spacing_pct": 0.1, "rebalance_every": 20, "type": "Market Making"},
"composite_mm": {"obi_weight": 0.30, "as_weight": 0.40, "hurst_weight": 0.30, "entry_score": 0.50, "type": "Ensemble"},
"iceberg": {"vol_mult": 1.8, "min_consec": 3, "max_hold": 8, "type": "Momentum"},
"momentum": {"bollinger_window": 20, "bollinger_std": 2.0, "type": "Momentum"},
"mean_rev": {"vwap_window": 20, "deviation": 1.0, "type": "Reversal"},
}
return params.get(strategy, {"type": "Unknown"})
def _generate_signals_sweep(
strategy: str,
data: dict[str, pd.DataFrame],
+116 -171
View File
@@ -3,53 +3,65 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FTDT Quant Lab — VectorBT Dashboard</title>
<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:#090d14;color:#a8b2c0;min-height:100vh;overflow:hidden}
.topbar{background:#0d1321;border-bottom:1px solid #1a2332;padding:8px 20px;display:flex;justify-content:space-between;align-items:center;height:40px}
.topbar h1{font-size:13px;font-weight:600;color:#e0e6ed;letter-spacing:1px}
.topbar .dot{display:inline-block;width:6px;height:6px;background:#22c55e;border-radius:50%;margin-right:5px;animation:pulse 2s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.topbar .status{font-size:10px;color:#4ade80}
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}
.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:310px 1fr;height:calc(100vh - 40px)}
.sidebar{background:#0b1019;border-right:1px solid #1a2332;overflow-y:auto;padding:6px}
.controls{padding:6px;border-bottom:1px solid #1a2332;margin-bottom:4px}
.sidebar{background:#0d0d0d;border-right:1px solid #222;overflow-y:auto;padding:6px}
.controls{padding:6px;border-bottom:1px solid #222;margin-bottom:4px}
.controls .row{display:flex;gap:4px;margin-bottom:4px}
.controls .row:last-child{margin-bottom:0}
select{background:#111827;border:1px solid #1e293b;color:#c8d6e5;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:#3b82f6}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:3px;padding:5px 8px;border-radius:3px;font-size:10px;cursor:pointer;border:1px solid #1e293b;background:#111827;color:#a8b2c0;font-family:inherit;transition:all .12s;white-space:nowrap}
.btn:hover{background:#1a2744;border-color:#3b82f6}
.btn-primary{background:#1d4ed8;border-color:#2563eb;color:#e0e6ed}
.btn-primary:hover{background:#1e40af}
.btn:disabled{opacity:.5;cursor:not-allowed}
.result-item{background:#111827;border:1px solid #1a2332;border-left:3px solid transparent;border-radius:3px;padding:7px 8px;margin-bottom:3px;cursor:pointer;transition:all .12s}
.result-item:hover{border-color:#334155;background:#151e30}
.result-item.active{border-color:#3b82f6;border-left-color:#3b82f6;background:#121b2d}
.result-item .name{font-size:11px;font-weight:600;color:#c8d6e5;display:flex;justify-content:space-between;align-items:center}
.result-item .name .asset{font-size:9px;color:#6366f1;background:#1e1b4b50;border:1px solid #312e81;border-radius:3px;padding:1px 5px}
.result-item .meta{font-size:9px;color:#445265;margin-top:2px}
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:5px 8px;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: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:3px;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:10px;margin-top:3px;font-size:9px;font-family:monospace}
.pos{color:#4ade80}.neg{color:#f87171}.neu{color:#64748b}
.sidebar h3{font-size:9px;text-transform:uppercase;color:#445265;margin:8px 0 4px;letter-spacing:2px;padding:0 4px}
.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}
.content{padding:20px 28px;overflow-y:auto}
.content h2{font-size:15px;color:#e0e6ed;margin-bottom:2px}
.content .sub{font-size:10px;color:#445265;margin-bottom:16px}
.content h2{font-size:15px;color:#ddd;margin-bottom:2px}
.content .sub{font-size:10px;color:#555;margin-bottom:16px}
.metrics-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:12px}
.metric{background:#0d1321;border:1px solid #1a2332;border-radius:4px;padding:12px 14px}
.metric .label{font-size:8px;text-transform:uppercase;color:#445265;letter-spacing:1.5px;margin-bottom:3px}
.metric{background:#111;border:1px solid #222;border-radius:4px;padding:12px 14px}
.metric .label{font-size:8px;text-transform:uppercase;color:#555;letter-spacing:1.5px;margin-bottom:3px}
.metric .value{font-size:20px;font-weight:700;font-family:monospace}
.chart-row{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px}
.chart-box{background:#0d1321;border:1px solid #1a2332;border-radius:4px;padding:12px}
.chart-box h4{font-size:9px;color:#445265;text-transform:uppercase;letter-spacing:1.5px;margin-bottom:2px}
.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:#334155}
.empty h2{font-size:14px;margin-bottom:6px;color:#445265}
.loading{text-align:center;padding:14px;color:#334155;font-size:10px}
.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}
.params-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:6px;margin-bottom:12px}
.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}
</style>
</head>
<body>
@@ -81,93 +93,56 @@ select:focus{outline:none;border-color:#3b82f6}
<div class="row">
<select id="filter-interval" onchange="loadResults()">
<option value="">All Intervals</option>
<option value="1m">1 minute</option>
<option value="5m">5 minutes</option>
<option value="15m">15 minutes</option>
<option value="1h" selected>1 hour</option>
<option value="4h">4 hours</option>
<option value="1d">1 day</option>
<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="filter-sort" onchange="loadResults()">
<option value="date">Sort: Latest</option>
<option value="sharpe">Sort: Sharpe</option>
<option value="return">Sort: Return %</option>
<option value="dd">Sort: Min DD</option>
<option value="trades">Sort: Trades</option>
<option value="date">Latest</option>
<option value="sharpe">Sharpe</option>
<option value="return">Return %</option>
<option value="dd">Min DD</option>
<option value="trades">Trades</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">1000 bars</option>
<option value="2000">2000 bars</option>
<option value="5000">5000 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>
<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">100b</option><option value="200">200b</option><option value="500" selected>500b</option><option value="1000">1Kb</option><option value="2000">2Kb</option><option value="5000">5Kb</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>
<button class="btn btn-primary" onclick="runNewBacktest()">▶ Run Backtest</button>
</div>
<div style="font-size:9px;color:#334155;padding:2px 4px" id="result-count"></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>
<div class="content" id="content">
<div class="empty">
<h2>SELECT A BACKTEST</h2>
<p style="font-size:11px">Choose from sidebar or configure params and run a new test</p>
<p style="font-size:11px">Choose from sidebar or configure and run a new test</p>
</div>
</div>
</div>
<script>
const API='';
const HL_INTERVALS = ['1m','5m','15m','30m','1h','4h','8h','1d','1w'];
let currentResult = null;
let currentFilename = null;
let currentResult=null, currentFilename=null;
async function loadResults(){
const strat=document.getElementById('filter-strategy').value;
const interval=document.getElementById('filter-interval').value;
const sort=document.getElementById('filter-sort').value;
const asset=document.getElementById('filter-asset').value;
let url = `${API}/api/vbt/results?limit=200&strategy=${strat}&sort=${sort}`;
let url='${API}/api/vbt/results?limit=200&strategy='+strat+'&sort='+sort;
if(interval)url+='&interval='+interval;
try{
const data=await (await fetch(url)).json();
const el=document.getElementById('results-list');
const filtered=asset?data.filter(r=>(r.asset||'').includes(asset)):data;
document.getElementById('result-count').textContent=filtered.length+' results';
if (!filtered.length) { el.innerHTML = '<div style="padding:8px;color:#445265;font-size:10px">No results</div>'; return; }
if(!filtered.length){el.innerHTML='<div style="padding:8px;color:#555;font-size:10px">No results</div>';return;}
el.innerHTML=filtered.map((r,i)=>{
const c=r.sharpe>0.5?'pos':(r.sharpe<-0.5?'neg':'neu');
const rc=r.total_return_pct>=0?'pos':'neg';
return `<div class="result-item${i===0&&!currentResult?' active':''}" 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</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">DD${(r.max_drawdown_pct||0).toFixed(1)}%</span>
</div>
</div>`;
return '<div class="result-item'+(i===0&&!currentResult?' active':'')+'" 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</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></div></div>';
}).join('');
}catch(e){console.error(e);}
}
@@ -177,47 +152,57 @@ async function selectFile(filename) {
document.getElementById('item-'+filename)?.classList.add('active');
currentFilename=filename;
try{
const r = await (await fetch(`${API}/api/vbt/result/${filename}`)).json();
const r=await (await fetch('${API}/api/vbt/result/'+filename)).json();
if(r.error){document.getElementById('content').innerHTML='<div class="empty"><h2>FILE NOT FOUND</h2></div>';return;}
currentResult = r;
renderDetail(r);
} catch(e) {
document.getElementById('content').innerHTML = '<div class="empty"><h2>ERROR</h2><p>'+e+'</p></div>';
}
currentResult=r;renderDetail(r);
}catch(e){document.getElementById('content').innerHTML='<div class="empty"><h2>ERROR</h2><p>'+e+'</p></div>';}
}
function renderDetail(r){
const ret = r.total_return_pct ?? 0;
const dd = r.max_drawdown_pct ?? 0;
const sh = r.sharpe ?? 0;
const wr = (r.win_rate ?? 0) * 100;
const pf = r.profit_factor ?? 0;
const eq = r.end_equity ?? 10000;
const tr = r.total_trades ?? 0;
const nb = r.n_bars ?? 0;
const so = r.sortino ?? 0;
const asset = r.asset || '-';
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||{};
document.getElementById('content').innerHTML = `
<h2>${r.strategy} <span class="badge" style="font-size:10px;background:#1e1b4b50;color:#818cf8;border:1px solid #312e81">${asset}</span></h2>
<div class="sub">${r.interval||'1h'} · ${nb} bars · ${tr} trades · ${r.generated_at||''}</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">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">Trades</div><div class="value neu">${tr}</div></div>
<div class="metric"><div class="label">End Equity</div><div class="value neu">$${eq.toLocaleString()}</div></div>
<div class="metric"><div class="label">Sortino</div><div class="value ${so>=0?'pos':'neg'}">${so.toFixed(2)}</div></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>`;
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='';
const trades=r.trades||[];
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="'+(t.side==='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"><h4>Trade Log ('+trades.length+' trades)</h4><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">'+(r.interval||'1h')+' · '+nb+' bars · '+tr+' trades · '+(r.generated_at||'')+'</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">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">Trades</div><div class="value neu">'+tr+'</div></div>'+
'<div class="metric"><div class="label">End Equity</div><div class="value neu">$'+eq.toLocaleString()+'</div></div>'+
'<div class="metric"><div class="label">Sortino</div><div class="value '+(so>=0?'pos':'neg')+'">'+so.toFixed(2)+'</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:280px"></div></div>'+
'</div>'+
'<div class="chart-row">'+
'<div class="chart-box"><h4>Drawdown</h4><div id="chart-dd" style="height:240px"></div></div>'+
'<div class="chart-box"><h4>Period Returns</h4><div id="chart-ret" style="height:240px"></div></div>'+
'</div>'+
trades_html;
renderCharts(r);
}
@@ -225,45 +210,13 @@ 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: '#3b82f6', width: 1.2},
fill: 'tozeroy', fillcolor: 'rgba(59,130,246,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: '#111827', tickfont: {color: '#334155', size: 9}},
yaxis: {gridcolor: '#111827', tickfont: {color: '#334155', size: 9}, tickprefix: '$'},
showlegend: false
}, {responsive: true, displayModeBar: false});
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:280,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 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(239,68,68,0.12)',
line: {color: '#ef4444', 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: '#111827', tickfont: {color: '#334155', size: 9}},
yaxis: {gridcolor: '#111827', tickfont: {color: '#334155', size: 9}, ticksuffix: '%'},
showlegend: false
}, {responsive: true, displayModeBar: false});
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:240,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: '#6366f1', opacity: 0.6, line: {color: '#0d1321', 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: '#111827', tickfont: {color: '#334155', size: 9}, ticksuffix: '%'},
yaxis: {gridcolor: '#111827', tickfont: {color: '#334155', size: 9}},
showlegend: false, bargap: 0.02
}, {responsive: true, displayModeBar: false});
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:240,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});
}
}
@@ -273,26 +226,18 @@ async function runNewBacktest() {
const limit=document.getElementById('run-limit').value;
const coin=document.getElementById('run-coin').value;
const btn=document.querySelector('.btn-primary');
const orig = btn.textContent;
btn.textContent = '⏳ Running...';
btn.disabled = true;
const orig=btn.textContent;btn.textContent='⏳ Running...';btn.disabled=true;
try{
let url = `${API}/api/vbt/run?strategy=${strat}&interval=${interval}&limit=${limit}`;
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();
if(data.error){alert(data.error);btn.textContent=orig;btn.disabled=false;return;}
currentResult = data;
currentFilename = data.filename;
renderDetail(data);
loadResults();
} catch(e) {
alert('Failed: ' + e.message);
currentResult=data;currentFilename=data.filename;
renderDetail(data);loadResults();
}catch(e){alert('Failed: '+e.message);}
btn.textContent=orig;btn.disabled=false;
}
btn.textContent = orig;
btn.disabled = false;
}
loadResults();
</script>
</body>
+204
View File
@@ -0,0 +1,204 @@
"""
Minimal VBT dashboard server — no live trading, no memory guard, no broadcast.
Just serves the VBT dashboard HTML and backtest API endpoints.
"""
import json, os, sys
from pathlib import Path
from datetime import datetime
project_root = str(Path(__file__).resolve().parent.parent)
sys.path.insert(0, project_root)
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
RESULTS_DIR = Path(project_root) / "backtests" / "results"
HISTORICAL_DIR = RESULTS_DIR / "historical"
STATIC_DIR = Path(project_root) / "dashboard" / "static"
os.makedirs(RESULTS_DIR, exist_ok=True)
app = FastAPI(title="FTDT VBT Dashboard")
# ── Field normalization ──────────────────────────────────────────
def _normalize(data: dict) -> dict:
out = dict(data)
if "total_return_pct" not in out:
out["total_return_pct"] = out.get("pnl_pct", out.get("ann_return_pct", 0))
if out.get("total_return_pct") is None:
out["total_return_pct"] = 0
if "max_drawdown_pct" not in out:
dd = out.get("max_dd_pct", out.get("max_dd"))
if dd is not None and isinstance(dd, (int, float)) and abs(dd) < 1:
dd = dd * 100
out["max_drawdown_pct"] = dd or 0
if out.get("max_drawdown_pct") is None:
out["max_drawdown_pct"] = 0
if "n_bars" not in out:
out["n_bars"] = out.get("num_periods", 0)
if out.get("n_bars") is None:
out["n_bars"] = 0
if "profit_factor" not in out:
trades = out.get("trades", [])
if trades:
gross_win = sum(t.get("pnl", t.get("pnl_net", t.get("pnl_gross", 0))) or 0
for t in trades if (t.get("pnl", t.get("pnl_net", t.get("pnl_gross", 0))) or 0) > 0)
gross_loss = abs(sum(t.get("pnl", t.get("pnl_net", t.get("pnl_gross", 0))) or 0
for t in trades if (t.get("pnl", t.get("pnl_net", t.get("pnl_gross", 0))) or 0) < 0))
out["profit_factor"] = round(gross_win / gross_loss, 3) if gross_loss > 0 else 0
else:
out["profit_factor"] = 0
if "total_trades" not in out:
out["total_trades"] = len(out.get("trades", []))
if out.get("total_trades") is None:
out["total_trades"] = 0
if not out.get("win_rate") and "trades" in out:
trades = out.get("trades", [])
if trades:
wins = sum(1 for t in trades if (t.get("pnl", t.get("pnl_net", t.get("pnl_gross", 0))) or 0) > 0)
out["win_rate"] = round(wins / len(trades), 3)
return out
def _infer_asset(strategy_name: str, filename: str) -> str:
name = (strategy_name + " " + filename).lower()
for key, asset in {
"pairs": "BTC/ETH", "order book": "BTC", "obi": "BTC",
"iceberg": "BTC", "hurst": "BTC", "vpin": "BTC",
"avellaneda": "BTC", "as_mm": "BTC", "grid": "BTC",
"composite": "BTC", "funding": "BTC", "kalman": "BTC/ETH",
"cartea": "BTC", "gueant": "BTC", "hawkes": "BTC",
"deep lob": "BTC", "queue": "BTC",
}.items():
if key in name:
return asset
return "BTC" if "btc" in name or "eth" not in name else "ETH"
# ── REST API ────────────────────────────────────────────────────
@app.get("/api/vbt/results")
async def list_results(strategy: str = "", interval: str = "", sort: str = "date", limit: int = 200):
results = []
for d in [RESULTS_DIR, HISTORICAL_DIR]:
if not os.path.isdir(d):
continue
for fname in sorted(os.listdir(d), reverse=True):
if not fname.endswith(".json"):
continue
if strategy and strategy not in fname:
continue
try:
with open(os.path.join(d, fname)) as f:
n = _normalize(json.load(f))
if interval and n.get("interval", "1h") != interval:
continue
results.append({
"filename": fname,
"strategy": n.get("strategy", "unknown"),
"asset": _infer_asset(n.get("strategy", ""), fname),
"engine": n.get("engine", "vectorbt"),
"interval": n.get("interval", "1h"),
"sharpe": n.get("sharpe", 0),
"sortino": n.get("sortino", 0),
"total_return_pct": n["total_return_pct"],
"max_drawdown_pct": n["max_drawdown_pct"],
"win_rate": n.get("win_rate", 0),
"profit_factor": n["profit_factor"],
"total_trades": n["total_trades"],
"n_bars": n["n_bars"],
"generated_at": n.get("generated_at", ""),
"has_equity_curve": bool(n.get("equity_curve")),
})
except (json.JSONDecodeError, IOError):
pass
if len(results) >= limit:
break
sort_keys = {
"sharpe": ("sharpe", True), "return": ("total_return_pct", True),
"dd": ("max_drawdown_pct", False), "trades": ("total_trades", True),
}
if sort in sort_keys:
key, rev = sort_keys[sort]
results.sort(key=lambda r: r.get(key, -999 if rev else 999), reverse=rev)
else:
results.sort(key=lambda r: r.get("generated_at", ""), reverse=True)
return JSONResponse(results[:limit])
@app.get("/api/vbt/result/{filename}")
async def get_result(filename: str):
for d in [RESULTS_DIR, HISTORICAL_DIR]:
fpath = os.path.join(d, filename)
if os.path.exists(fpath):
with open(fpath) as f:
data = _normalize(json.load(f))
ec = data.get("equity_curve", [])
if ec and len(ec) > 500:
data["equity_curve"] = ec[::len(ec)//500]
return JSONResponse(data)
return JSONResponse({"error": "not found"}, status_code=404)
@app.get("/api/vbt/run")
async def run_backtest(strategy: str = "pairs", interval: str = "1h", limit: int = 500, coin: str = ""):
try:
from backtests.vbt_runner import VBTBacktestRunner
runner = VBTBacktestRunner()
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
result = runner.run_strategy(strategy=strategy, interval=interval, limit=limit)
if result:
if coin:
result["asset"] = coin.upper()
fname = f"{strategy}_{'' if not coin else coin+'_'}vbt_{ts}.json"
fpath = RESULTS_DIR / fname
with open(fpath, "w") as f:
json.dump(result, f, default=str)
result["filename"] = fname
return JSONResponse(result)
return JSONResponse({"error": "no results"}, status_code=500)
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@app.get("/api/vbt/strategies")
async def list_strategies():
return JSONResponse([
{"key": "pairs", "name": "Pairs Trading", "coins": ["BTC", "ETH"]},
{"key": "hurst_vpin", "name": "Hurst VPIN", "coins": ["BTC"]},
{"key": "as_mm", "name": "Avellaneda-Stoikov", "coins": ["BTC"]},
{"key": "obi", "name": "Order Book Imbalance", "coins": ["BTC"]},
{"key": "grid_mm", "name": "Grid Market Making", "coins": ["BTC"]},
{"key": "composite_mm", "name": "Composite MM", "coins": ["BTC"]},
{"key": "iceberg", "name": "Iceberg Detection", "coins": ["BTC"]},
])
# ── Static ──────────────────────────────────────────────────────
@app.get("/vbt")
async def vbt_page():
return FileResponse(STATIC_DIR / "vbt.html")
@app.get("/")
async def root():
return FileResponse(STATIC_DIR / "vbt.html")
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
# ── Main ────────────────────────────────────────────────────────
if __name__ == "__main__":
import uvicorn, argparse
p = argparse.ArgumentParser()
p.add_argument("--port", type=int, default=9175)
p.add_argument("--host", default="0.0.0.0")
args = p.parse_args()
print(f"VBT Dashboard → http://{args.host}:{args.port}/vbt")
uvicorn.run(app, host=args.host, port=args.port, log_level="error")