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
This commit is contained in:
ramseshk
2026-08-07 15:35:33 +08:00
parent 9ee13a45bb
commit 9d817ac2fa
3 changed files with 60 additions and 8 deletions
+29 -6
View File
@@ -442,18 +442,41 @@ 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 # Determine which coin this strategy trades
main_coin = self._get_coins(strategy)[0]
asset = main_coin if main_coin else "BTC"
trades = [] trades = []
try: try:
trade_records = pf.trades.records_readable trade_records = pf.trades.records_readable
for _, t in trade_records.iterrows(): for _, t in trade_records.iterrows():
side = "BUY" if str(t.get("Direction", "")) == "Long" else "SELL"
entry_px = round(float(t.get("Avg Entry Price", 0)), 2)
exit_px = round(float(t.get("Avg Exit Price", 0)), 2)
size = round(float(t.get("Size", 0)), 6)
notional = size * entry_px
# VectorBT's PnL already accounts for fees when fees= is set on Portfolio
pnl_vbt = round(float(t.get("PnL", 0)), 4)
fee_rate = self._fee_rate # applied per side by VBT
entry_fee = round(notional * fee_rate, 6)
exit_fee = round(size * exit_px * fee_rate, 6)
total_fee = round(entry_fee + exit_fee, 6)
# Gross PnL = net + fees
gross_pnl = round(pnl_vbt + total_fee, 4)
trades.append({ trades.append({
"time": str(t.get("Exit Timestamp", t.get("Entry Timestamp", "")))[:19], "time": str(t.get("Exit Timestamp", t.get("Entry Timestamp", "")))[:19],
"side": "BUY" if str(t.get("Direction", "")) == "Long" else "SELL", "side": side,
"size": round(float(t.get("Size", 0)), 6), "asset": asset,
"entry_px": round(float(t.get("Avg Entry Price", 0)), 2), "size": size,
"exit_px": round(float(t.get("Avg Exit Price", 0)), 2), "entry_px": entry_px,
"pnl": round(float(t.get("PnL", 0)), 4), "exit_px": exit_px,
"pnl_gross": gross_pnl,
"pnl_net": pnl_vbt,
"fee": total_fee,
"fee_rate": f"{fee_rate*100:.3f}%",
"return_pct": round(float(t.get("Return", 0)) * 100, 3), "return_pct": round(float(t.get("Return", 0)) * 100, 3),
"duration": str(t.get("Duration", "")), "duration": str(t.get("Duration", "")),
}) })
+23
View File
@@ -576,6 +576,29 @@ def _normalize_vbt_fields(data: dict) -> dict:
wins = sum(1 for t in trades if (t.get("pnl_net", t.get("pnl_gross", t.get("pnl", 0))) or 0) > 0) wins = sum(1 for t in trades if (t.get("pnl_net", t.get("pnl_gross", t.get("pnl", 0))) or 0) > 0)
out["win_rate"] = round(wins / len(trades), 3) out["win_rate"] = round(wins / len(trades), 3)
# Normalize trade records: add asset, fee, pnl_net/pnl_gross for old files
trades = out.get("trades", [])
if trades:
asset = _infer_asset(out.get("strategy", ""), "")
hl_fee = 0.0005 # default Hyperliquid taker rate
for t in trades:
if not t.get("asset"):
t["asset"] = asset
if not t.get("fee"):
entry_px = float(t.get("entry_px", 0))
exit_px = float(t.get("exit_px", 0))
size = float(t.get("size", 0))
notional_entry = size * entry_px
notional_exit = size * exit_px
t["fee"] = round((notional_entry + notional_exit) * hl_fee, 6)
t["fee_rate"] = f"{hl_fee*100:.3f}%"
if not t.get("pnl_net"):
t["pnl_net"] = t.get("pnl", 0)
if not t.get("pnl_gross"):
pnl_net = float(t.get("pnl_net", t.get("pnl", 0)))
fee = float(t.get("fee", 0))
t["pnl_gross"] = round(pnl_net + fee, 4)
return out return out
+8 -2
View File
@@ -252,8 +252,14 @@ function renderDetail(r){
let trades_html=''; let trades_html='';
if(trades.length){ if(trades.length){
const rows=trades.map(t=>'<tr><td style="color:#777;font-size:10px">'+String(t.time||'').substring(0,19)+'</td><td class="'+(String(t.side||'').includes('BUY')?'pos':'neg')+'">'+String(t.side||'')+'</td><td>'+Number(t.size||0).toFixed(6)+'</td><td>$'+Number(t.entry_px||0).toFixed(1)+'</td><td>$'+Number(t.exit_px||0).toFixed(1)+'</td><td class="'+(Number(t.pnl||0)>=0?'pnl-pos':'pnl-neg')+'">$'+Number(t.pnl||0).toFixed(4)+'</td><td>'+String(t.duration||'')+'</td></tr>').join(''); const rows=trades.map(t=>{
trades_html='<div class="chart-box full"><div style="display:flex;justify-content:space-between;align-items:center"><h4>Trade Log ('+trades.length+' trades)</h4></div><div class="trade-scroll"><table class="trades-table"><thead><tr><th>Time</th><th>Side</th><th>Size</th><th>Entry</th><th>Exit</th><th>PnL</th><th>Duration</th></tr></thead><tbody>'+rows+'</tbody></table></div></div>'; 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= document.getElementById('content').innerHTML=