feat: Hyperliquid fee schedule — all tiers, staking, maker rebates

config/fee_tiers.py — complete rewrite:
  - 7 perps fee tiers (T0-T6) matching HL docs:
    T0: 0.045/0.015% → T6: 0.024/0.000%
  - 7 spot fee tiers (T0-T6):
    T0: 0.070/0.040% → T6: 0.025/0.000%
  - 7 staking tiers (none → diamond):
    multiplier 1.00 → 0.60 (40% discount)
  - 3 maker rebate tiers (>0.5%, >1.5%, >3% maker ratio)
    extra -0.001% to -0.003% on positive maker rates
  - compute_trade_fees() — per-trade fee breakdown
  - fee_tier_from_volume(), staking_tier_from_hype()
  - STRATEGY_FEE_MODELS: maker/taker classification per strategy

backtests/vbt_runner.py:
  - Accept vip_tier, staking_tier, maker_rebate_tier at init
  - Auto-detect fee model per strategy (maker vs taker)
  - compute_trade_fees() for per-trade fee calculation
  - Include fee_info in result JSON

dashboard/server.py:
  - /api/vbt/run accepts fee_tier/aking_tier/maker_rebate params
  - Trade normalization uses proper HL fee schedule per strategy
  - /api/vbt/result/{filename}/recalc — recalc trades with new tiers
  - /api/vbt/fee_tiers — get full fee schedule as JSON

dashboard/static/vbt.html:
  - Fee tier selector (T0-T6) + staking tier selector
  - Auto-recalculate on tier change when a result is selected
  - Fee rate shown in trade log header (e.g. 0.045%)
This commit is contained in:
ramseshk
2026-08-07 15:44:43 +08:00
parent 9d817ac2fa
commit 0162e83138
4 changed files with 311 additions and 65 deletions
+80 -10
View File
@@ -580,18 +580,23 @@ def _normalize_vbt_fields(data: dict) -> dict:
trades = out.get("trades", [])
if trades:
asset = _infer_asset(out.get("strategy", ""), "")
hl_fee = 0.0005 # default Hyperliquid taker rate
strategy_name = out.get("strategy", "")
from config.fee_tiers import get_strategy_fee_model, compute_trade_fees
fee_model = get_strategy_fee_model(strategy_name)
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}%"
ft = compute_trade_fees(
side=t.get("side", "BUY"),
size=float(t.get("size", 0)),
entry_px=float(t.get("entry_px", 0)),
exit_px=float(t.get("exit_px", 0)),
vip_tier=0, staking_tier="none",
fee_model=fee_model,
)
t["fee"] = ft["total_fee"]
t["fee_rate_pct"] = ft["effective_rate_pct"]
if not t.get("pnl_net"):
t["pnl_net"] = t.get("pnl", 0)
if not t.get("pnl_gross"):
@@ -746,11 +751,16 @@ async def run_vbt_backtest(
limit: int = 500,
coin: str = "",
testnet: bool = False,
fee_tier: int = 0,
staking_tier: str = "none",
maker_rebate: int = 0,
):
"""Run a new VectorBT backtest and return results."""
"""Run a new VectorBT backtest with Hyperliquid fee schedule."""
try:
from backtests.vbt_runner import VBTBacktestRunner
runner = VBTBacktestRunner()
runner = VBTBacktestRunner(
vip_tier=fee_tier, staking_tier=staking_tier, maker_rebate_tier=maker_rebate
)
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
coin_suffix = f"_{coin}" if coin else ""
result = runner.run_strategy(
@@ -806,6 +816,66 @@ async def list_vbt_strategies():
])
@app.get("/api/vbt/result/{filename}/recalc")
async def recalc_vbt_trades(
filename: str,
fee_tier: int = 0,
staking_tier: str = "none",
maker_rebate: int = 0,
):
"""Recalculate VBT trade fees with different fee tier/staking."""
_refresh_vbt_cache()
if filename in _vbt_full_cache:
data = dict(_vbt_full_cache[filename])
else:
for d in [BACKTEST_DIR, HISTORICAL_DIR]:
fpath = os.path.join(d, filename)
if os.path.exists(fpath):
with open(fpath) as f:
data = json.load(f)
break
else:
return JSONResponse({"error": "not found"}, status_code=404)
from config.fee_tiers import get_strategy_fee_model, compute_trade_fees
fee_model = get_strategy_fee_model(data.get("strategy", ""))
trades = data.get("trades", [])
for t in trades:
ft = compute_trade_fees(
side=t.get("side", "BUY"),
size=float(t.get("size", 0)),
entry_px=float(t.get("entry_px", 0)),
exit_px=float(t.get("exit_px", 0)),
vip_tier=fee_tier,
staking_tier=staking_tier,
fee_model=fee_model,
maker_rebate_tier=maker_rebate,
)
t["fee"] = ft["total_fee"]
t["fee_rate_pct"] = ft["effective_rate_pct"]
pnl_old = float(t.get("pnl_gross", t.get("pnl", 0)))
t["pnl_net"] = round(pnl_old - ft["total_fee"], 4)
data["fee_info"] = compute_trade_fees(
"BUY", 0.001, 100000.0, 100000.0,
vip_tier=fee_tier, staking_tier=staking_tier,
fee_model=fee_model, maker_rebate_tier=maker_rebate,
)
return JSONResponse(data)
@app.get("/api/vbt/fee_tiers")
async def list_fee_tiers():
"""Return current Hyperliquid fee schedule for frontend."""
from config.fee_tiers import PERPS_TIERS, STAKING_TIERS, MAKER_REBATES
return JSONResponse({
"perps": {str(k): v for k, v in PERPS_TIERS.items()},
"staking": {k: v for k, v in STAKING_TIERS.items()},
"maker_rebates": {str(k): v for k, v in MAKER_REBATES.items()},
})
@app.get("/api/vbt/result/{filename}/csv")
async def get_vbt_csv(filename: str):
"""Download VBT backtest trades as CSV."""
+38 -2
View File
@@ -118,6 +118,26 @@ select:focus{outline:none;border-color:#555}
<option value="trades">Trades</option>
</select>
</div>
<div class="row" style="margin-top:2px">
<select id="fee-tier" style="flex:0.6" onchange="recalcIfSelected()">
<option value="0" selected>T0 (Base 0.045%/0.015%)</option>
<option value="1">T1 (>$5M 0.040/0.012)</option>
<option value="2">T2 (>$25M 0.035/0.008)</option>
<option value="3">T3 (>$100M 0.030/0.004)</option>
<option value="4">T4 (>$500M 0.028/0.000)</option>
<option value="5">T5 (>$2B 0.026/0.000)</option>
<option value="6">T6 (>$7B 0.024/0.000)</option>
</select>
<select id="staking-tier" style="flex:0.4" onchange="recalcIfSelected()">
<option value="none">No Stake</option>
<option value="wood">Wood (-5%)</option>
<option value="bronze">Bronze (-10%)</option>
<option value="silver">Silver (-15%)</option>
<option value="gold">Gold (-20%)</option>
<option value="platinum">Platinum (-30%)</option>
<option value="diamond">Diamond (-40%)</option>
</select>
</div>
<div class="row">
<select id="run-strategy">
<option value="pairs">Pairs Trading</option>
@@ -259,7 +279,8 @@ function renderDetail(r){
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>';
const feeRate = trades[0]?.fee_rate_pct ? ' · '+trades[0].fee_rate_pct+'%' : '';
trades_html='<div class="chart-box full"><div style="display:flex;justify-content:space-between;align-items:center"><h4>Trade Log ('+trades.length+' trades'+feeRate+')</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=
@@ -307,11 +328,26 @@ function exportJSON(){if(!currentResult)return;const blob=new Blob([JSON.stringi
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 recalcIfSelected(){
if(!currentFilename)return;
const feeTier=document.getElementById('fee-tier').value;
const staking=document.getElementById('staking-tier').value;
const makerRebate=0;
try{
const url=API+'/api/vbt/result/'+encodeURIComponent(currentFilename)+'/recalc?fee_tier='+feeTier+'&staking_tier='+staking+'&maker_rebate='+makerRebate;
const r=await(await fetch(url)).json();
if(r.error){return;}
currentResult=r;renderDetail(r);
}catch(e){/* ignore */}
}
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 feeTier=document.getElementById('fee-tier').value;
const staking=document.getElementById('staking-tier').value;
const btn=document.getElementById('btn-run');
const status=document.getElementById('run-status');
const orig=btn.textContent;btn.textContent='⏳ Running...';btn.disabled=true;
@@ -322,7 +358,7 @@ async function runNewBacktest(){
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;
let url=API+'/api/vbt/run?strategy='+strat+'&interval='+interval+'&limit='+limit+'&fee_tier='+feeTier+'&staking_tier='+staking;
if(coin)url+='&coin='+coin;
const resp=await fetch(url);
const data=await resp.json();