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"]) return coin_map.get(strategy, ["BTC"])
def _extract_metrics(self, pf, stats, strategy, interval, n_bars) -> dict: 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 { return {
"strategy": strategy, "strategy": strategy,
"interval": interval, "interval": interval,
@@ -456,6 +474,8 @@ class VBTBacktestRunner:
"win_rate": round(float(stats.get("Win Rate [%]", 0)) / 100, 3), "win_rate": round(float(stats.get("Win Rate [%]", 0)) / 100, 3),
"profit_factor": round(float(stats.get("Profit Factor", 0)), 3), "profit_factor": round(float(stats.get("Profit Factor", 0)), 3),
"expectancy": round(float(stats.get("Expectancy", 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: def _empty_result(self, strategy: str, interval: str) -> dict:
@@ -472,10 +492,28 @@ class VBTBacktestRunner:
"max_drawdown_pct": 0.0, "max_drawdown_pct": 0.0,
"win_rate": 0.0, "win_rate": 0.0,
"total_trades": 0, "total_trades": 0,
"trades": [],
"params": _strategy_params(strategy),
"generated_at": datetime.now(timezone.utc).isoformat(), "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( def _generate_signals_sweep(
strategy: str, strategy: str,
data: dict[str, pd.DataFrame], data: dict[str, pd.DataFrame],
+157 -212
View File
@@ -3,53 +3,65 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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> <script src="https://cdn.plot.ly/plotly-3.1.0.min.js"></script>
<style> <style>
*{margin:0;padding:0;box-sizing:border-box} *{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} body{font-family:'SF Mono','Cascadia Code','Ubuntu Mono',monospace;background:#0a0a0a;color:#888;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} .pos{color:#03A9F4}.neg{color:#FF5252}.neu{color:#777}
.topbar h1{font-size:13px;font-weight:600;color:#e0e6ed;letter-spacing:1px} .topbar{background:#111;border-bottom:1px solid #222;padding:8px 20px;display:flex;justify-content:space-between;align-items:center;height:40px}
.topbar .dot{display:inline-block;width:6px;height:6px;background:#22c55e;border-radius:50%;margin-right:5px;animation:pulse 2s infinite} .topbar h1{font-size:13px;font-weight:600;color:#ddd;letter-spacing:1px}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}} .topbar .dot{display:inline-block;width:6px;height:6px;background:#fff;border-radius:50%;margin-right:5px;animation:pulse 2s infinite}
.topbar .status{font-size:10px;color:#4ade80} @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)} .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} .sidebar{background:#0d0d0d;border-right:1px solid #222;overflow-y:auto;padding:6px}
.controls{padding:6px;border-bottom:1px solid #1a2332;margin-bottom:4px} .controls{padding:6px;border-bottom:1px solid #222;margin-bottom:4px}
.controls .row{display:flex;gap:4px;margin-bottom:4px} .controls .row{display:flex;gap:4px;margin-bottom:4px}
.controls .row:last-child{margin-bottom:0} .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{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:#3b82f6} 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 #1e293b;background:#111827;color:#a8b2c0;font-family:inherit;transition:all .12s;white-space:nowrap} .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:#1a2744;border-color:#3b82f6} .btn:hover{background:#222;border-color:#555}
.btn-primary{background:#1d4ed8;border-color:#2563eb;color:#e0e6ed} .btn-primary{background:#03A9F4;border-color:#03A9F4;color:#fff}
.btn-primary:hover{background:#1e40af} .btn-primary:hover{background:#0288d1}
.btn:disabled{opacity:.5;cursor:not-allowed} .btn:disabled{opacity:.4;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{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:#334155;background:#151e30} .result-item:hover{border-color:#333;background:#161616}
.result-item.active{border-color:#3b82f6;border-left-color:#3b82f6;background:#121b2d} .result-item.active{border-color:#03A9F4;border-left-color:#03A9F4;background:#121212}
.result-item .name{font-size:11px;font-weight:600;color:#c8d6e5;display:flex;justify-content:space-between;align-items:center} .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:#6366f1;background:#1e1b4b50;border:1px solid #312e81;border-radius:3px;padding:1px 5px} .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:#445265;margin-top:2px} .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} .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:#555;margin:8px 0 4px;letter-spacing:2px;padding:0 4px}
.sidebar h3{font-size:9px;text-transform:uppercase;color:#445265;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} .badge{font-size:9px;border-radius:3px;padding:1px 4px;margin-left:3px;font-weight:400}
.content{padding:20px 28px;overflow-y:auto} .content{padding:20px 28px;overflow-y:auto}
.content h2{font-size:15px;color:#e0e6ed;margin-bottom:2px} .content h2{font-size:15px;color:#ddd;margin-bottom:2px}
.content .sub{font-size:10px;color:#445265;margin-bottom:16px} .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} .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{background:#111;border:1px solid #222;border-radius:4px;padding:12px 14px}
.metric .label{font-size:8px;text-transform:uppercase;color:#445265;letter-spacing:1.5px;margin-bottom:3px} .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} .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-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{background:#111;border:1px solid #222;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 h4{font-size:9px;color:#555;text-transform:uppercase;letter-spacing:1.5px;margin-bottom:2px}
.chart-box.full{grid-column:1/-1} .chart-box.full{grid-column:1/-1}
.empty{text-align:center;padding:60px 20px;color:#334155} .empty{text-align:center;padding:60px 20px;color:#333}
.empty h2{font-size:14px;margin-bottom:6px;color:#445265} .empty h2{font-size:14px;margin-bottom:6px;color:#555}
.loading{text-align:center;padding:14px;color:#334155;font-size:10px} .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> </style>
</head> </head>
<body> <body>
@@ -81,218 +93,151 @@ select:focus{outline:none;border-color:#3b82f6}
<div class="row"> <div class="row">
<select id="filter-interval" onchange="loadResults()"> <select id="filter-interval" onchange="loadResults()">
<option value="">All Intervals</option> <option value="">All Intervals</option>
<option value="1m">1 minute</option> <option value="1m">1m</option><option value="5m">5m</option><option value="15m">15m</option>
<option value="5m">5 minutes</option> <option value="1h" selected>1h</option><option value="4h">4h</option><option value="1d">1d</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>
</select> </select>
<select id="filter-sort" onchange="loadResults()"> <select id="filter-sort" onchange="loadResults()">
<option value="date">Sort: Latest</option> <option value="date">Latest</option>
<option value="sharpe">Sort: Sharpe</option> <option value="sharpe">Sharpe</option>
<option value="return">Sort: Return %</option> <option value="return">Return %</option>
<option value="dd">Sort: Min DD</option> <option value="dd">Min DD</option>
<option value="trades">Sort: Trades</option> <option value="trades">Trades</option>
</select> </select>
</div> </div>
<div class="row"> <div class="row">
<select id="run-interval"> <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>
<option value="1m">1m</option> <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>
<option value="5m">5m</option> <select id="run-coin"><option value="">auto</option><option value="BTC">BTC</option><option value="ETH">ETH</option><option value="SOL">SOL</option></select>
<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>
</div> </div>
<button class="btn btn-primary" onclick="runNewBacktest()">▶ Run Backtest</button> <button class="btn btn-primary" onclick="runNewBacktest()">▶ Run Backtest</button>
</div> </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 id="results-list"><div class="loading">Loading...</div></div>
</div> </div>
<div class="content" id="content"> <div class="content" id="content">
<div class="empty"> <div class="empty">
<h2>SELECT A BACKTEST</h2> <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> </div>
</div> </div>
<script> <script>
const API = ''; const API='';
const HL_INTERVALS = ['1m','5m','15m','30m','1h','4h','8h','1d','1w']; let currentResult=null, currentFilename=null;
let currentResult = null;
let currentFilename = null;
async function loadResults() { async function loadResults(){
const strat = document.getElementById('filter-strategy').value; const strat=document.getElementById('filter-strategy').value;
const interval = document.getElementById('filter-interval').value; const interval=document.getElementById('filter-interval').value;
const sort = document.getElementById('filter-sort').value; const sort=document.getElementById('filter-sort').value;
const asset = document.getElementById('filter-asset').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;
if (interval) url += '&interval=' + interval; try{
const data=await (await fetch(url)).json();
try { const el=document.getElementById('results-list');
const data = await (await fetch(url)).json(); const filtered=asset?data.filter(r=>(r.asset||'').includes(asset)):data;
const el = document.getElementById('results-list'); document.getElementById('result-count').textContent=filtered.length+' results';
const filtered = asset ? data.filter(r => (r.asset||'').includes(asset)) : data; if(!filtered.length){el.innerHTML='<div style="padding:8px;color:#555;font-size:10px">No results</div>';return;}
document.getElementById('result-count').textContent = filtered.length + ' results'; el.innerHTML=filtered.map((r,i)=>{
const c=r.sharpe>0.5?'pos':(r.sharpe<-0.5?'neg':'neu');
if (!filtered.length) { el.innerHTML = '<div style="padding:8px;color:#445265;font-size:10px">No results</div>'; return; } const rc=r.total_return_pct>=0?'pos':'neg';
el.innerHTML = filtered.map((r,i) => { 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>';
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>`;
}).join(''); }).join('');
} catch(e) { console.error(e); } }catch(e){console.error(e);}
} }
async function selectFile(filename) { async function selectFile(filename){
document.querySelectorAll('.result-item').forEach(el => el.classList.remove('active')); document.querySelectorAll('.result-item').forEach(el=>el.classList.remove('active'));
document.getElementById('item-'+filename)?.classList.add('active'); document.getElementById('item-'+filename)?.classList.add('active');
currentFilename = filename; currentFilename=filename;
try { 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; } if(r.error){document.getElementById('content').innerHTML='<div class="empty"><h2>FILE NOT FOUND</h2></div>';return;}
currentResult = r; currentResult=r;renderDetail(r);
renderDetail(r); }catch(e){document.getElementById('content').innerHTML='<div class="empty"><h2>ERROR</h2><p>'+e+'</p></div>';}
} catch(e) {
document.getElementById('content').innerHTML = '<div class="empty"><h2>ERROR</h2><p>'+e+'</p></div>';
}
} }
function renderDetail(r) { function renderDetail(r){
const ret = r.total_return_pct ?? 0; const ret=r.total_return_pct??0,dd=r.max_drawdown_pct??0,sh=r.sharpe??0;
const dd = r.max_drawdown_pct ?? 0; const wr=(r.win_rate??0)*100,pf=r.profit_factor??0,eq=r.end_equity??10000;
const sh = r.sharpe ?? 0; const tr=r.total_trades??0,nb=r.n_bars??0,so=r.sortino??0;
const wr = (r.win_rate ?? 0) * 100; const asset=r.asset||'-';const params=r.params||{};
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 || '-';
document.getElementById('content').innerHTML = ` let params_html='';
<h2>${r.strategy} <span class="badge" style="font-size:10px;background:#1e1b4b50;color:#818cf8;border:1px solid #312e81">${asset}</span></h2> for(const[k,v]of Object.entries(params)){
<div class="sub">${r.interval||'1h'} · ${nb} bars · ${tr} trades · ${r.generated_at||''}</div> let cls='neu';
<div class="metrics-grid"> if(typeof v==='number'){
<div class="metric"><div class="label">Total Return</div><div class="value ${ret>=0?'pos':'neg'}">${ret.toFixed(2)}%</div></div> if(k.includes('entry')||k.includes('threshold'))cls=v>0.5?'pos':'neu';
<div class="metric"><div class="label">Sharpe</div><div class="value ${sh>=0?'pos':'neg'}">${sh.toFixed(2)}</div></div> else cls=v>0?'pos':(v<0?'neg':'neu');
<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> 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>';
<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> let trades_html='';
<div class="metric"><div class="label">Sortino</div><div class="value ${so>=0?'pos':'neg'}">${so.toFixed(2)}</div></div> const trades=r.trades||[];
</div> if(trades.length){
<div class="chart-row"> 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('');
<div class="chart-box full"><h4>Equity Curve</h4><div id="chart-eq" style="height:300px"></div></div> 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>';
</div> }
<div class="chart-row">
<div class="chart-box"><h4>Drawdown</h4><div id="chart-dd" style="height:250px"></div></div> document.getElementById('content').innerHTML=
<div class="chart-box"><h4>Period Returns</h4><div id="chart-ret" style="height:250px"></div></div> '<h2>'+r.strategy+' <span class="badge" style="font-size:10px;background:#0d1f2b;color:#03A9F4;border:1px solid #1a3a4a">'+asset+'</span></h2>'+
</div>`; '<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); renderCharts(r);
} }
function renderCharts(r) { function renderCharts(r){
const ec = r.equity_curve || []; const ec=r.equity_curve||[];
if (!ec.length) return; if(!ec.length)return;
const times = ec.map(p => p.t), values = ec.map(p => p.v); 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: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});
Plotly.newPlot('chart-eq', [{ const peak=values.reduce((a,v,i)=>(a.push(i?Math.max(a[i-1],v):v),a),[]);
x: times, y: values, type: 'scatter', mode: 'lines', const dd=values.map((v,i)=>i?-(peak[i]-v)/peak[i]*100:0);
line: {color: '#3b82f6', width: 1.2}, 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});
fill: 'tozeroy', fillcolor: 'rgba(59,130,246,0.06)' if(values.length>1){
}], { const rets=values.slice(1).map((v,i)=>(v-values[i])/values[i]*100);
margin: {t:4,r:12,b:28,l:65}, height: 300, 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});
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});
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});
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});
} }
} }
async function runNewBacktest() { async function runNewBacktest(){
const strat = document.getElementById('filter-strategy').value || 'pairs'; const strat=document.getElementById('filter-strategy').value||'pairs';
const interval = document.getElementById('run-interval').value; const interval=document.getElementById('run-interval').value;
const limit = document.getElementById('run-limit').value; const limit=document.getElementById('run-limit').value;
const coin = document.getElementById('run-coin').value; const coin=document.getElementById('run-coin').value;
const btn = document.querySelector('.btn-primary'); const btn=document.querySelector('.btn-primary');
const orig = btn.textContent; const orig=btn.textContent;btn.textContent='⏳ Running...';btn.disabled=true;
btn.textContent = '⏳ Running...'; try{
btn.disabled = true; let url='${API}/api/vbt/run?strategy='+strat+'&interval='+interval+'&limit='+limit;
try { if(coin)url+='&coin='+coin;
let url = `${API}/api/vbt/run?strategy=${strat}&interval=${interval}&limit=${limit}`; const resp=await fetch(url);
if (coin) url += '&coin=' + coin; const data=await resp.json();
const resp = await fetch(url); if(data.error){alert(data.error);btn.textContent=orig;btn.disabled=false;return;}
const data = await resp.json(); currentResult=data;currentFilename=data.filename;
if (data.error) { alert(data.error); btn.textContent = orig; btn.disabled = false; return; } renderDetail(data);loadResults();
currentResult = data; }catch(e){alert('Failed: '+e.message);}
currentFilename = data.filename; btn.textContent=orig;btn.disabled=false;
renderDetail(data);
loadResults();
} catch(e) {
alert('Failed: ' + e.message);
}
btn.textContent = orig;
btn.disabled = false;
} }
loadResults(); loadResults();
</script> </script>
</body> </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")