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:
+29
-6
@@ -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", "")),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
Reference in New Issue
Block a user