Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions benchmarks/perp-fees.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/<m>/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."
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions harnesses/perp-fees/cmd/script/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
108 changes: 108 additions & 0 deletions harnesses/perp-fees/cmd/script/extended.go
Original file line number Diff line number Diff line change
@@ -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)
}
4 changes: 4 additions & 0 deletions harnesses/perp-fees/cmd/script/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
}
Expand Down
121 changes: 121 additions & 0 deletions harnesses/perp-fees/cmd/script/paradex.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading