From 0162e831380a4c30ff4215b12794076ee025137b Mon Sep 17 00:00:00 2001 From: ramseshk <45832522+ramseshk@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:44:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Hyperliquid=20fee=20schedule=20?= =?UTF-8?q?=E2=80=94=20all=20tiers,=20staking,=20maker=20rebates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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%) --- backtests/vbt_runner.py | 58 ++++++++---- config/fee_tiers.py | 188 ++++++++++++++++++++++++++++++-------- dashboard/server.py | 90 ++++++++++++++++-- dashboard/static/vbt.html | 40 +++++++- 4 files changed, 311 insertions(+), 65 deletions(-) diff --git a/backtests/vbt_runner.py b/backtests/vbt_runner.py index 74fa0e3..1a9f160 100644 --- a/backtests/vbt_runner.py +++ b/backtests/vbt_runner.py @@ -300,9 +300,15 @@ def _hurst_rs_series(returns_series: pd.Series) -> float: class VBTBacktestRunner: """VectorBT-powered backtesting on Hyperliquid candle data.""" - def __init__(self, fee_rate: float = 0.0005): + def __init__(self, fee_rate: float | None = None, + vip_tier: int = 0, staking_tier: str = "none", maker_rebate_tier: int = 0): + from config.fee_tiers import get_perp_fees, get_strategy_fee_model self._provider = HyperliquidDataProvider() - self._fee_rate = fee_rate + self._vip_tier = vip_tier + self._staking_tier = staking_tier + self._maker_rebate_tier = maker_rebate_tier + self._fee_rate = fee_rate if fee_rate is not None else get_perp_fees(vip_tier, staking_tier, "taker", maker_rebate_tier) + self._maker_rate = get_perp_fees(vip_tier, staking_tier, "maker", maker_rebate_tier) def run_strategy( self, @@ -344,11 +350,14 @@ class VBTBacktestRunner: return self._empty_result(strategy, interval) try: + from config.fee_tiers import get_strategy_fee_model + fee_model = get_strategy_fee_model(strategy) + effective_fee = self._maker_rate if fee_model == "maker" else self._fee_rate pf = vbt.Portfolio.from_signals( close=close, entries=entries, exits=exits, - fees=self._fee_rate, + fees=effective_fee, slippage=0.001, freq=INTERVAL_MAP.get(interval, "1h"), init_cash=10000.0, @@ -442,9 +451,19 @@ class VBTBacktestRunner: return coin_map.get(strategy, ["BTC"]) def _extract_metrics(self, pf, stats, strategy, interval, n_bars) -> dict: - # Determine which coin this strategy trades + from config.fee_tiers import compute_trade_fees, get_strategy_fee_model + main_coin = self._get_coins(strategy)[0] asset = main_coin if main_coin else "BTC" + fee_model = get_strategy_fee_model(strategy) + vip = self._vip_tier + staking = self._staking_tier + rebate = self._maker_rebate_tier + + # Summary fee info from compute_trade_fees at nominal size + fee_info = compute_trade_fees("BUY", 0.001, 100000.0, 100000.0, + vip_tier=vip, staking_tier=staking, + fee_model=fee_model, maker_rebate_tier=rebate) trades = [] try: @@ -454,17 +473,21 @@ class VBTBacktestRunner: 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) + # Compute actual per-trade fees using HL schedule + ft = compute_trade_fees( + side=side, + size=size, + entry_px=entry_px, + exit_px=exit_px, + vip_tier=vip, + staking_tier=staking, + fee_model=fee_model, + maker_rebate_tier=rebate, + ) - # Gross PnL = net + fees - gross_pnl = round(pnl_vbt + total_fee, 4) + pnl_gross_raw = float(t.get("PnL", 0)) + pnl_net = round(pnl_gross_raw - ft["total_fee"], 4) trades.append({ "time": str(t.get("Exit Timestamp", t.get("Entry Timestamp", "")))[:19], @@ -473,10 +496,10 @@ class VBTBacktestRunner: "size": size, "entry_px": entry_px, "exit_px": exit_px, - "pnl_gross": gross_pnl, - "pnl_net": pnl_vbt, - "fee": total_fee, - "fee_rate": f"{fee_rate*100:.3f}%", + "pnl_gross": round(pnl_gross_raw, 4), + "pnl_net": pnl_net, + "fee": ft["total_fee"], + "fee_rate_pct": fee_info["effective_rate_pct"], "return_pct": round(float(t.get("Return", 0)) * 100, 3), "duration": str(t.get("Duration", "")), }) @@ -499,6 +522,7 @@ class VBTBacktestRunner: "expectancy": round(float(stats.get("Expectancy", 0)), 3), "trades": trades, "params": _strategy_params(strategy), + "fee_info": fee_info, } def _empty_result(self, strategy: str, interval: str) -> dict: diff --git a/config/fee_tiers.py b/config/fee_tiers.py index c348f30..350bafc 100644 --- a/config/fee_tiers.py +++ b/config/fee_tiers.py @@ -1,64 +1,104 @@ """ -Hyperliquid fee tiers — perps and spot, base rates + staking discounts. +Hyperliquid fee schedule — perps, spot, staking discounts, maker rebates. Source: https://hyperliquid.gitbook.io/hyperliquid-docs/trading/fees -Fee = base_rate × staking_multiplier -Staking tiers are based on staked HYPE tokens. +Perps fee tiers: 14d rolling volume determines base taker/maker rate. +Staking tiers: staked HYPE discount applied on top of base rates. +Maker rebates: extra rebate for high maker-volume-ratio accounts. """ -from dataclasses import dataclass, field -from typing import Optional +from __future__ import annotations + +# ═══════════════════════════════════════════════════════════════ +# Perps fee tiers — base taker & maker rates (decimal) +# ═══════════════════════════════════════════════════════════════ -# ── Perps fee tiers (base rates) ── PERPS_TIERS = { - 0: {"name": "Tier 0", "volume": 0, "taker": 0.00045, "maker": 0.00015}, - 1: {"name": "Tier 1", "volume": 5_000_000, "taker": 0.00040, "maker": 0.00012}, - 2: {"name": "Tier 2", "volume": 25_000_000, "taker": 0.00035, "maker": 0.00008}, - 3: {"name": "Tier 3", "volume": 100_000_000,"taker": 0.00030, "maker": 0.00004}, - 4: {"name": "Tier 4", "volume": 500_000_000, "taker": 0.00028, "maker": 0.00000}, - 5: {"name": "Tier 5", "volume": 2_000_000_000, "taker": 0.00026, "maker": 0.00000}, - 6: {"name": "Tier 6", "volume": 7_000_000_000, "taker": 0.00024, "maker": 0.00000}, + 0: {"name": "Tier 0 (<$5M)", "min_volume": 0, "taker": 0.00045, "maker": 0.00015}, + 1: {"name": "Tier 1 (>$5M)", "min_volume": 5_000_000, "taker": 0.00040, "maker": 0.00012}, + 2: {"name": "Tier 2 (>$25M)", "min_volume": 25_000_000, "taker": 0.00035, "maker": 0.00008}, + 3: {"name": "Tier 3 (>$100M)", "min_volume": 100_000_000, "taker": 0.00030, "maker": 0.00004}, + 4: {"name": "Tier 4 (>$500M)", "min_volume": 500_000_000, "taker": 0.00028, "maker": 0.00000}, + 5: {"name": "Tier 5 (>$2B)", "min_volume": 2_000_000_000,"taker": 0.00026, "maker": 0.00000}, + 6: {"name": "Tier 6 (>$7B)", "min_volume": 7_000_000_000,"taker": 0.00024, "maker": 0.00000}, } -# ── Spot fee tiers (base rates) ── +# ═══════════════════════════════════════════════════════════════ +# Spot fee tiers — base taker & maker rates (decimal) +# ═══════════════════════════════════════════════════════════════ + SPOT_TIERS = { - 0: {"name": "Tier 0", "volume": 0, "taker": 0.00070, "maker": 0.00040}, - 1: {"name": "Tier 1", "volume": 100_000, "taker": 0.00060, "maker": 0.00030}, - 2: {"name": "Tier 2", "volume": 1_000_000, "taker": 0.00050, "maker": 0.00020}, - 3: {"name": "Tier 3", "volume": 10_000_000, "taker": 0.00040, "maker": 0.00010}, - 4: {"name": "Tier 4", "volume": 50_000_000, "taker": 0.00030, "maker": 0.00005}, - 5: {"name": "Tier 5", "volume": 200_000_000, "taker": 0.00020, "maker": 0.00000}, - 6: {"name": "Tier 6", "volume": 1_000_000_000,"taker":0.00010,"maker": -0.00005}, + 0: {"name": "Tier 0 (<$5M)", "min_volume": 0, "taker": 0.00070, "maker": 0.00040}, + 1: {"name": "Tier 1 (>$5M)", "min_volume": 5_000_000, "taker": 0.00060, "maker": 0.00030}, + 2: {"name": "Tier 2 (>$25M)", "min_volume": 25_000_000, "taker": 0.00050, "maker": 0.00020}, + 3: {"name": "Tier 3 (>$100M)", "min_volume": 100_000_000, "taker": 0.00040, "maker": 0.00010}, + 4: {"name": "Tier 4 (>$500M)", "min_volume": 500_000_000, "taker": 0.00035, "maker": 0.00000}, + 5: {"name": "Tier 5 (>$2B)", "min_volume": 2_000_000_000,"taker": 0.00030, "maker": 0.00000}, + 6: {"name": "Tier 6 (>$7B)", "min_volume": 7_000_000_000,"taker": 0.00025, "maker": 0.00000}, } -# ── Staking discount multipliers ── +# ═══════════════════════════════════════════════════════════════ +# Staking tiers — discount multiplier on all fees +# ═══════════════════════════════════════════════════════════════ + STAKING_TIERS = { - "none": {"name": "No Stake", "multiplier": 1.00}, - "wood": {"name": "Wood", "multiplier": 0.95}, - "bronze": {"name": "Bronze", "multiplier": 0.90}, - "silver": {"name": "Silver", "multiplier": 0.85}, - "gold": {"name": "Gold", "multiplier": 0.80}, - "platinum": {"name": "Platinum", "multiplier": 0.70}, - "diamond": {"name": "Diamond", "multiplier": 0.60}, + "none": {"name": "No Stake", "min_hype": 0, "multiplier": 1.00}, + "wood": {"name": "Wood (>10)", "min_hype": 10, "multiplier": 0.95}, + "bronze": {"name": "Bronze (>100)", "min_hype": 100, "multiplier": 0.90}, + "silver": {"name": "Silver (>1K)", "min_hype": 1_000, "multiplier": 0.85}, + "gold": {"name": "Gold (>10K)", "min_hype": 10_000, "multiplier": 0.80}, + "platinum": {"name": "Platinum (>100K)", "min_hype": 100_000, "multiplier": 0.70}, + "diamond": {"name": "Diamond (>500K)","min_hype": 500_000, "multiplier": 0.60}, } +# ═══════════════════════════════════════════════════════════════ +# Maker rebates — extra rebate for high maker-volume accounts +# ONLY applies when maker fee > 0 (doesn't stack with zero maker) +# ═══════════════════════════════════════════════════════════════ + +MAKER_REBATES = { + 0: {"name": "No rebate", "min_maker_ratio": 0.0, "rebate": 0}, + 1: {"name": "Rebate 1 (>0.5%)", "min_maker_ratio": 0.005, "rebate": -0.00001}, + 2: {"name": "Rebate 2 (>1.5%)", "min_maker_ratio": 0.015, "rebate": -0.00002}, + 3: {"name": "Rebate 3 (>3%)", "min_maker_ratio": 0.03, "rebate": -0.00003}, +} + +# ═══════════════════════════════════════════════════════════════ +# Compute effective rates +# ═══════════════════════════════════════════════════════════════ def effective_rate(base_rate: float, staking_tier: str = "none") -> float: - """Calculate effective fee rate after staking discount.""" + """Apply staking discount to base rate.""" mult = STAKING_TIERS.get(staking_tier, STAKING_TIERS["none"])["multiplier"] return base_rate * mult -def get_perp_fees(vip_tier: int, staking_tier: str = "none", fee_model: str = "taker") -> float: - """Get effective perp fee for a given VIP tier and staking tier.""" +def get_perp_fees(vip_tier: int, staking_tier: str = "none", fee_model: str = "taker", + maker_rebate_tier: int = 0) -> float: + """Get effective perp fee rate. + + Args: + vip_tier: 0-6 volume tier + staking_tier: none/wood/bronze/silver/gold/platinum/diamond + fee_model: 'taker' or 'maker' + maker_rebate_tier: 0-3 maker volume rebate tier + + Returns fee rate as decimal (e.g. 0.00045 = 0.045%) + """ tier = PERPS_TIERS.get(vip_tier, PERPS_TIERS[0]) base = tier[fee_model] if fee_model in ("taker", "maker") else tier["taker"] - return effective_rate(base, staking_tier) + rate = effective_rate(base, staking_tier) + + if fee_model == "maker" and base > 0 and maker_rebate_tier > 0: + rebate = MAKER_REBATES.get(maker_rebate_tier, {}).get("rebate", 0) + rate = rate + rebate + + return rate def get_spot_fees(vip_tier: int, staking_tier: str = "none", fee_model: str = "taker") -> float: - """Get effective spot fee for a given VIP tier and staking tier.""" + """Get effective spot fee rate.""" tier = SPOT_TIERS.get(vip_tier, SPOT_TIERS[0]) base = tier[fee_model] if fee_model in ("taker", "maker") else tier["taker"] return effective_rate(base, staking_tier) @@ -69,12 +109,69 @@ def fee_tier_from_volume(volume_14d: float, market: str = "perps") -> int: tiers = PERPS_TIERS if market == "perps" else SPOT_TIERS current = 0 for t in sorted(tiers.keys()): - if volume_14d >= tiers[t]["volume"]: + if volume_14d >= tiers[t]["min_volume"]: current = t return current -# ── Strategy-specific defaults (matching existing classification) ── +def staking_tier_from_hype(hype_staked: float) -> str: + """Determine staking tier from staked HYPE amount.""" + current = "none" + for name, info in STAKING_TIERS.items(): + if hype_staked >= info["min_hype"]: + current = name + return current + + +# ═══════════════════════════════════════════════════════════════ +# Trade PnL computation +# ═══════════════════════════════════════════════════════════════ + +def compute_trade_fees( + side: str, + size: float, + entry_px: float, + exit_px: float, + vip_tier: int = 0, + staking_tier: str = "none", + fee_model: str = "taker", + maker_rebate_tier: int = 0, +) -> dict: + """Compute entry, exit, and total fees for a round-trip trade. + + Returns dict with entry_fee, exit_fee, total_fee, effective_rate_taker, effective_rate_maker. + """ + notional_entry = size * entry_px + notional_exit = size * exit_px + + taker_rate = get_perp_fees(vip_tier, staking_tier, "taker", maker_rebate_tier) + maker_rate = get_perp_fees(vip_tier, staking_tier, "maker", maker_rebate_tier) + + rate = taker_rate if fee_model == "taker" else maker_rate + + entry_fee = notional_entry * rate + exit_fee = notional_exit * rate + total_fee = entry_fee + exit_fee + + return { + "entry_fee": round(entry_fee, 6), + "exit_fee": round(exit_fee, 6), + "total_fee": round(total_fee, 6), + "taker_rate_pct": round(taker_rate * 100, 4), + "maker_rate_pct": round(maker_rate * 100, 4), + "effective_rate_pct": round(rate * 100, 4) if rate else 0, + "vip_tier": vip_tier, + "staking_tier": staking_tier, + "fee_model": fee_model, + "tier_name": PERPS_TIERS[vip_tier]["name"], + "staking_name": STAKING_TIERS.get(staking_tier, STAKING_TIERS["none"])["name"], + } + + +# ═══════════════════════════════════════════════════════════════ +# Strategy-specific defaults +# ═══════════════════════════════════════════════════════════════ + STRATEGY_FEE_MODELS = { "Order Book Imbalance": "taker", "Iceberg Detection": "taker", @@ -88,4 +185,23 @@ STRATEGY_FEE_MODELS = { "Cartea-Jaimungal": "maker", "Queue Imbalance": "taker", "Guéant Market Making": "maker", + + # VBT strategy names + "pairs": "taker", + "hurst_vpin": "taker", + "as_mm": "maker", + "obi": "taker", + "grid_mm": "maker", + "composite_mm": "maker", + "iceberg": "taker", + "momentum": "taker", + "mean_rev": "taker", } + + +def get_strategy_fee_model(strategy_name: str) -> str: + """Get the default fee model for a strategy name.""" + for key, model in STRATEGY_FEE_MODELS.items(): + if key.lower() in strategy_name.lower(): + return model + return "taker" diff --git a/dashboard/server.py b/dashboard/server.py index 6d52fd5..50570fe 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -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.""" diff --git a/dashboard/static/vbt.html b/dashboard/static/vbt.html index f2af7cc..e25faa2 100644 --- a/dashboard/static/vbt.html +++ b/dashboard/static/vbt.html @@ -118,6 +118,26 @@ select:focus{outline:none;border-color:#555} +
+ + +