From c32e930443b4bb7a36ac126bc3879e81884bafc0 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Fri, 10 Jul 2026 13:42:19 +0200 Subject: [PATCH] perp-fees: add Paradex (api-tier fee, depth-100 book) and Extended (full book, documented 2.5 bps disclosed) --- benchmarks/perp-fees.yml | 28 +++++ harnesses/perp-fees/cmd/script/config.go | 4 + harnesses/perp-fees/cmd/script/extended.go | 108 ++++++++++++++++++ harnesses/perp-fees/cmd/script/main.go | 4 + harnesses/perp-fees/cmd/script/paradex.go | 121 +++++++++++++++++++++ 5 files changed, 265 insertions(+) create mode 100644 harnesses/perp-fees/cmd/script/extended.go create mode 100644 harnesses/perp-fees/cmd/script/paradex.go diff --git a/benchmarks/perp-fees.yml b/benchmarks/perp-fees.yml index 84431fbc..18e00cbd 100644 --- a/benchmarks/perp-fees.yml +++ b/benchmarks/perp-fees.yml @@ -56,6 +56,8 @@ methodology: - "GMX v2 (Arbitrum): Subsquid GraphQL on the synthetics-arbitrum subgraph for `positionFeeFactorForNegativeImpact` (worst-case open) on the ETH/USD, BTC/USD and SOL/USD markets + gmxinfra REST for funding. Oracle priced, no orderbook: the spread term is zero." - "Lighter: `/orderBookDetails` (taker fee per market) + `/orderBookOrders` (orderbook walk)." - "gains.trade (Gains v8 on Base): fees read directly on-chain. `eth_call pairs(N)` gives `spreadP` and `feeIndex`, then `eth_call fees(feeIndex)` gives the open fee (`totalPositionSizeFeeP`). The taker crosses one side of the book, so we charge half of `spreadP`. SOL has `spreadP` 0 on-chain (Gains prices that pair's spread dynamically), so its figure is the open fee alone. Values in 1e10 precision per Gains v8 convention. Fee tier cached 1h, pair config 6h." + - "Paradex: `/markets` fee config for the api-tier taker rate + `/orderbook?depth=100` walk. The book endpoint caps at 100 levels (about $1M visible on majors), so the $1M tier can be skipped when the visible book thins." + - "Extended: full public book from `/info/markets//orderbook` + `/stats` funding (1h native). Its taker fee is not exposed by any public endpoint; the documented base rate of 2.5 bps is used and this is the one venue where the fee is not read live." - "All-in formula: `all_in_bps = taker_fee_bps + spread_bps`. Both components emitted as separate metrics for transparency." - "Notional tiers: the same measurement runs at $1,000, $10,000, $100,000 and $1,000,000 by rewalking the already fetched book, published to `perp_fees_all_in_bps_tier{venue, chain, notional}`. The headline `perp_fees_all_in_bps` stays defined at $1,000. A tier the book cannot fill is skipped and counted in `perp_fees_tier_skipped_total`, never extrapolated. Oracle priced venues (GMX v2, gains.trade) charge a flat percentage of size, so their figure repeats across tiers." - "Book depth for the $1M tier: Lighter is walked on its top 100 levels, dYdX on the full indexer book, Polymarket on up to 500 levels. Hyperliquid returns its 20 best raw levels; when those cannot absorb the tier the harness refetches with nSigFigs=5, the finest price aggregation the API offers, which extends coverage with negligible price rounding." @@ -213,6 +215,32 @@ providers: sample_size: count_over_time(perp_fees_all_in_bps{venue="gains"}[24h]) series: perp_fees_all_in_bps{venue="gains"} + - slug: paradex + name: Paradex + tag: Starknet appchain perps, 2 bps api taker tier + formula: "Average over 24h of (api-tier taker rate from the Paradex /markets fee config + half-spread plus impact from the /orderbook asks, depth 100, walked for $1000 of buy notional in the selected asset), in bps." + queries: + p50: avg_over_time(perp_fees_all_in_bps{venue="paradex"}[24h]) + p90: quantile_over_time(0.90, perp_fees_all_in_bps{venue="paradex"}[24h]) + p99: quantile_over_time(0.99, perp_fees_all_in_bps{venue="paradex"}[24h]) + mean: avg_over_time(perp_fees_all_in_bps{venue="paradex"}[24h]) + success: avg_over_time(perp_fees_health{venue="paradex"}[24h]) + sample_size: count_over_time(perp_fees_all_in_bps{venue="paradex"}[24h]) + series: perp_fees_all_in_bps{venue="paradex"} + + - slug: extended + name: Extended + tag: Starknet perps, full public book, documented 2.5 bps taker + formula: "Average over 24h of (documented base taker rate of 2.5 bps, not exposed by Extended's public API, plus half-spread and impact from the full public orderbook walked for $1000 of buy notional in the selected asset), in bps." + queries: + p50: avg_over_time(perp_fees_all_in_bps{venue="extended"}[24h]) + p90: quantile_over_time(0.90, perp_fees_all_in_bps{venue="extended"}[24h]) + p99: quantile_over_time(0.99, perp_fees_all_in_bps{venue="extended"}[24h]) + mean: avg_over_time(perp_fees_all_in_bps{venue="extended"}[24h]) + success: avg_over_time(perp_fees_health{venue="extended"}[24h]) + sample_size: count_over_time(perp_fees_all_in_bps{venue="extended"}[24h]) + series: perp_fees_all_in_bps{venue="extended"} + # Notional-tier companion panels. The panel metric leaves the chain label # unpinned and wraps the selector in avg(), so the ETH / BTC / SOL tab # filter is injected exactly like on the headline queries and the panels diff --git a/harnesses/perp-fees/cmd/script/config.go b/harnesses/perp-fees/cmd/script/config.go index 276edd6e..aa2bc52f 100644 --- a/harnesses/perp-fees/cmd/script/config.go +++ b/harnesses/perp-fees/cmd/script/config.go @@ -45,6 +45,10 @@ func loadConfig() *Config { // Polymarket perps (2026-07-08 launch): public info API, base fee // tier taker 4 bps. ETH/BTC/SOL all listed at launch. {slug: "polymarket", display: "Polymarket", assets: []string{"ETH", "BTC", "SOL"}}, + {slug: "paradex", display: "Paradex", assets: []string{"ETH", "BTC", "SOL"}}, + // Extended's taker fee is documented (2.5 bps), not API-exposed; + // disclosed in the spec formula. + {slug: "extended", display: "Extended", assets: []string{"ETH", "BTC", "SOL"}}, } venues := make([]VenueConfig, 0, len(defs)*3) diff --git a/harnesses/perp-fees/cmd/script/extended.go b/harnesses/perp-fees/cmd/script/extended.go new file mode 100644 index 00000000..6768e244 --- /dev/null +++ b/harnesses/perp-fees/cmd/script/extended.go @@ -0,0 +1,108 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +// Extended (Starknet). Public no-auth REST returning the FULL book +// (~$7.6M visible on ETH asks), so every tier walks real depth. The +// taker fee is NOT exposed by any public endpoint: the documented base +// rate (0.025% = 2.5 bps, docs.extended.exchange) is used and disclosed +// in the spec formula, the one exception to the fees-from-API rule. + +const extendedBase = "https://api.starknet.extended.exchange/api/v1" + +// Documented base taker rate. Revisit if Extended ships a public fees +// endpoint. +const extendedTakerBps = 2.5 + +type extendedBook struct { + Status string `json:"status"` + Data struct { + Bid []struct { + Qty string `json:"qty"` + Price string `json:"price"` + } `json:"bid"` + Ask []struct { + Qty string `json:"qty"` + Price string `json:"price"` + } `json:"ask"` + } `json:"data"` +} + +type extendedStats struct { + Data struct { + FundingRate string `json:"fundingRate"` // per 1h + } `json:"data"` +} + +func fetchExtended(v VenueConfig) PerpSample { + s := PerpSample{Venue: v.Slug, Asset: v.Asset, At: time.Now().UTC().Format(time.RFC3339)} + start := time.Now() + client := &http.Client{Timeout: 8 * time.Second} + market := v.Asset + "-USD" + + s.TakerFeeBps = extendedTakerBps + + var book extendedBook + if err := extendedGet(client, fmt.Sprintf("%s/info/markets/%s/orderbook", extendedBase, market), &book); err != nil { + s.Err = fmt.Sprintf("orderbook: %v", err) + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + if len(book.Data.Bid) == 0 || len(book.Data.Ask) == 0 { + s.Err = "empty_orderbook" + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + bestBid, _ := strconv.ParseFloat(book.Data.Bid[0].Price, 64) + bestAsk, _ := strconv.ParseFloat(book.Data.Ask[0].Price, 64) + mid := (bestBid + bestAsk) / 2 + s.MidPrice = mid + + levels := make([]bookLevel, 0, len(book.Data.Ask)) + for _, a := range book.Data.Ask { + px, _ := strconv.ParseFloat(a.Price, 64) + sz, _ := strconv.ParseFloat(a.Qty, 64) + levels = append(levels, bookLevel{Px: px, Sz: sz}) + } + effective, err := walkBookForNotional(levels, v.NotionalUSD) + if err != nil { + s.Err = fmt.Sprintf("walk: %v", err) + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + s.SpreadBps = (effective - mid) / mid * 10000 + s.AllInBps = s.TakerFeeBps + s.SpreadBps + applyBookTiers(&s, levels, mid) + + var stats extendedStats + if err := extendedGet(client, fmt.Sprintf("%s/info/markets/%s/stats", extendedBase, market), &stats); err == nil { + r, _ := strconv.ParseFloat(stats.Data.FundingRate, 64) + s.FundingRatePerHrBps = r * 10000 + } + + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s +} + +func extendedGet(client *http.Client, url string, out any) error { + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", "OpenChainBench-PerpFees/1.0 contact@mobula.io") + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) + } + return json.Unmarshal(body, out) +} diff --git a/harnesses/perp-fees/cmd/script/main.go b/harnesses/perp-fees/cmd/script/main.go index b174f607..6026812d 100644 --- a/harnesses/perp-fees/cmd/script/main.go +++ b/harnesses/perp-fees/cmd/script/main.go @@ -101,6 +101,10 @@ func fetchOne(v VenueConfig, cfg *Config) PerpSample { return fetchGains(v, cfg.MobulaAPIKey) case "polymarket": return fetchPolymarket(v) + case "paradex": + return fetchParadex(v) + case "extended": + return fetchExtended(v) default: return PerpSample{Venue: v.Slug, Asset: v.Asset, Err: "unsupported_venue"} } diff --git a/harnesses/perp-fees/cmd/script/paradex.go b/harnesses/perp-fees/cmd/script/paradex.go new file mode 100644 index 00000000..5a03984d --- /dev/null +++ b/harnesses/perp-fees/cmd/script/paradex.go @@ -0,0 +1,121 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +// Paradex (Starknet appchain). Public no-auth REST. The book endpoint +// caps at depth=100 (~$1M visible on majors), so the $1M tier can be +// legitimately skipped when the visible book thins out. + +const paradexBase = "https://api.prod.paradex.trade/v1" + +type paradexMarkets struct { + Results []struct { + Symbol string `json:"symbol"` + FeeConfig struct { + APIFee struct { + TakerFee struct { + Fee string `json:"fee"` // decimal, e.g. "0.0002" + } `json:"taker_fee"` + } `json:"api_fee"` + } `json:"fee_config"` + } `json:"results"` +} + +type paradexBook struct { + Asks [][2]string `json:"asks"` // [price, size] strings + Bids [][2]string `json:"bids"` +} + +type paradexFunding struct { + Results []struct { + FundingRate string `json:"funding_rate"` // per 8h period + } `json:"results"` +} + +func fetchParadex(v VenueConfig) PerpSample { + s := PerpSample{Venue: v.Slug, Asset: v.Asset, At: time.Now().UTC().Format(time.RFC3339)} + start := time.Now() + client := &http.Client{Timeout: 8 * time.Second} + market := v.Asset + "-USD-PERP" + + // 1) Taker fee from the market's fee config (api tier, not the UI one). + var mkts paradexMarkets + if err := paradexGet(client, fmt.Sprintf("%s/markets?market=%s", paradexBase, market), &mkts); err != nil { + s.Err = fmt.Sprintf("markets: %v", err) + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + if len(mkts.Results) == 0 { + s.Err = "asset_not_found" + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + rate, _ := strconv.ParseFloat(mkts.Results[0].FeeConfig.APIFee.TakerFee.Fee, 64) + s.TakerFeeBps = rate * 10000 + + // 2) Orderbook (max depth 100). + var book paradexBook + if err := paradexGet(client, fmt.Sprintf("%s/orderbook/%s?depth=100", paradexBase, market), &book); err != nil { + s.Err = fmt.Sprintf("orderbook: %v", err) + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + if len(book.Bids) == 0 || len(book.Asks) == 0 { + s.Err = "empty_orderbook" + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + bestBid, _ := strconv.ParseFloat(book.Bids[0][0], 64) + bestAsk, _ := strconv.ParseFloat(book.Asks[0][0], 64) + mid := (bestBid + bestAsk) / 2 + s.MidPrice = mid + + levels := make([]bookLevel, 0, len(book.Asks)) + for _, a := range book.Asks { + px, _ := strconv.ParseFloat(a[0], 64) + sz, _ := strconv.ParseFloat(a[1], 64) + levels = append(levels, bookLevel{Px: px, Sz: sz}) + } + effective, err := walkBookForNotional(levels, v.NotionalUSD) + if err != nil { + s.Err = fmt.Sprintf("walk: %v", err) + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + s.SpreadBps = (effective - mid) / mid * 10000 + s.AllInBps = s.TakerFeeBps + s.SpreadBps + applyBookTiers(&s, levels, mid) + + // 3) Funding: per 8h period, normalize to per hour. + var fund paradexFunding + if err := paradexGet(client, fmt.Sprintf("%s/funding/data?market=%s&page_size=1", paradexBase, market), &fund); err == nil && len(fund.Results) > 0 { + r, _ := strconv.ParseFloat(fund.Results[0].FundingRate, 64) + s.FundingRatePerHrBps = r / 8 * 10000 + } + + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s +} + +func paradexGet(client *http.Client, url string, out any) error { + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", "OpenChainBench-PerpFees/1.0 contact@mobula.io") + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) + } + return json.Unmarshal(body, out) +}