From e2a640451974bbf99c73a80b5c7feb884f15730e Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Fri, 10 Jul 2026 12:50:59 +0200 Subject: [PATCH 1/5] perp-fees: M tier + size panels that follow the chain tab + methodology and faq --- benchmarks/perp-fees.yml | 48 ++++++++++++------- harnesses/perp-fees/cmd/script/hyperliquid.go | 20 +++++++- harnesses/perp-fees/cmd/script/lighter.go | 2 +- harnesses/perp-fees/cmd/script/polymarket.go | 2 +- harnesses/perp-fees/cmd/script/walk.go | 4 +- 5 files changed, 53 insertions(+), 23 deletions(-) diff --git a/benchmarks/perp-fees.yml b/benchmarks/perp-fees.yml index 39fe40af..d83088f1 100644 --- a/benchmarks/perp-fees.yml +++ b/benchmarks/perp-fees.yml @@ -4,8 +4,8 @@ slug: perp-fees number: "007" title: Cheapest perp DEX, live all-in fee on a $1000 ETH 10x long seo_title: "Cheapest perp DEX 2026" -seo_description: "{{best_name}} leads cheapest perp DEX at {{best_p50}} all-in (24h avg). $1000 ETH 10x long. Lighter, Hyperliquid, dYdX v4, GMX v2, gains.trade ranked live." -subtitle: All-in cost in basis points to open a $1000 ETH long 10x position. Taker fee plus half-spread plus impact, measured live from public APIs across Lighter, Hyperliquid, dYdX, GMX and gains.trade. +seo_description: "{{best_name}} leads cheapest perp DEX at {{best_p50}} all-in (24h avg). $1000 ETH 10x long, plus $100k and $1M size panels. Lighter, Hyperliquid, Polymarket, dYdX v4, GMX v2, gains.trade ranked live." +subtitle: All-in cost in basis points to open a $1000 ETH long 10x position, with companion panels at $100k and $1M where book depth takes over from fees. Taker fee plus half-spread plus impact, measured live from public APIs across Lighter, Hyperliquid, Polymarket, dYdX, GMX and gains.trade. category: Trading status: live metric: All-in cost @@ -27,8 +27,12 @@ seo_intro: | gains.trade are oracle priced, so their spread terms come from protocol parameters), and sum the two into a single basis-point figure refreshed every five minutes. Hyperliquid vs - Lighter vs dYdX vs GMX vs gains.trade is then a one-number - comparison, not a five-tab spreadsheet. + Lighter vs Polymarket vs dYdX vs GMX vs gains.trade is then a + one-number comparison, not a six-tab spreadsheet. Size changes the + answer, so the board is complemented by two panels that rerun the + exact same measurement at $100,000 and $1,000,000 of notional. Small + orders reward the lowest fee schedule; a $1M market order rewards the + deepest book, and the ranking visibly flips between the two. abstract: | We measure the live all-in cost of opening a $1000 ETH long 10x perp @@ -53,7 +57,8 @@ methodology: - "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." - "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 and $100,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." + - "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." - "Failures (5xx, timeouts, rate limits) leave the previous gauge in place and increment a per-venue `fetch_errors_total` counter. The page falls back to the last successful sample." per_chain_explainer: @@ -85,6 +90,10 @@ prometheus: expected_freshness_seconds: 1800 faq: + - q: "Which perp DEX is cheapest for a $1M market order?" + a: "The venue with the deepest book, not the one with the lowest fee schedule. At $1,000,000 of notional the price impact from walking the book dwarfs the taker fee, so zero fee venues can lose to a 4 bps venue whose book absorbs the size better. The $1M panel on this page reruns the exact same measurement at that size, and the ranking regularly differs from the $1000 headline board. A venue that shows no data there means its visible book could not fill $1M at measurement time, which is a depth signal in itself." + - q: "Why does the ranking change with order size?" + a: "Two cost components move in opposite directions. The taker fee is a flat percentage, identical at any size. Price impact grows with size because a bigger order eats deeper into the book. Small orders are fee dominated, so zero fee venues win. Large orders are depth dominated, so the deepest book wins. The board at $1000 and the panels at $100k and $1M show all three regimes live." - q: "What is the cheapest perpetual futures DEX right now?" a: "{{best_name}} currently leads the leaderboard at {{best_p50}} all-in (24 h average) for opening a $1000 ETH long 10x position. The leaderboard re-sorts every five minutes against fresh Prometheus samples, so the answer reflects the last 24 hours of live data from each venue's public API, not a frozen rack-rate table." - q: "Is Lighter really zero fees on perpetual futures?" @@ -204,21 +213,24 @@ providers: sample_size: count_over_time(perp_fees_all_in_bps{venue="gains"}[24h]) series: perp_fees_all_in_bps{venue="gains"} -# Notional-tier companion panels. Same pattern as perp-funding: the panel -# metric pins the asset label so the unfiltered "All" view reads one series -# per venue; on the BTC/SOL tabs the dimension injection skips chain= -# because it is already set, so the panels stay ETH-scoped by design. -# notional=1000 is not paneled: it duplicates the headline ledger. +# 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 +# follow the active tab; the unfiltered "All" view reads the cross-asset +# average, same semantics as the headline ledger. notional=1000 is not +# paneled (it duplicates the headline) and $10k reads within noise of +# $1k on every venue, so the panels show the two sizes where the story +# changes: $100k and $1M. metric_panels: - - id: all_in_10k - label: "All-in at $10k (ETH)" - metric: perp_fees_all_in_bps_tier{chain="ETH",notional="10000"} + - id: all_in_100k + label: All-in at $100k + metric: avg(perp_fees_all_in_bps_tier{notional="100000"}) label_key: venue unit: bps - description: "All-in opening cost measured at $10,000 notional on the ETH pair. Same formula as the headline (taker fee plus spread plus impact); only the orderbook walk depth changes." - - id: all_in_100k - label: "All-in at $100k (ETH)" - metric: perp_fees_all_in_bps_tier{chain="ETH",notional="100000"} + description: "All-in opening cost at $100,000 notional. Same formula as the headline (taker fee plus spread plus impact); price impact starts separating the books here and the zero fee lead narrows." + - id: all_in_1m + label: All-in at $1M + metric: avg(perp_fees_all_in_bps_tier{notional="1000000"}) label_key: venue unit: bps - description: "All-in opening cost at $100,000 notional on the ETH pair. Books that cannot fill the size are skipped by the harness rather than extrapolated, so a venue missing here is itself a depth signal." + description: "All-in opening cost at $1,000,000 notional. At this size book depth decides the ranking and fee schedules become secondary. A venue whose visible book cannot absorb $1M shows no data instead of an extrapolated figure, which is itself a depth signal." diff --git a/harnesses/perp-fees/cmd/script/hyperliquid.go b/harnesses/perp-fees/cmd/script/hyperliquid.go index 8f1cd5c3..8d87fbd7 100644 --- a/harnesses/perp-fees/cmd/script/hyperliquid.go +++ b/harnesses/perp-fees/cmd/script/hyperliquid.go @@ -128,7 +128,25 @@ func fetchHyperliquid(v VenueConfig) PerpSample { s.AllInBps = s.TakerFeeBps + s.SpreadBps // Notional tiers: rewalk the already-fetched book at $1k/$10k/$100k. - applyBookTiers(&s, levels, mid) + // The raw l2Book returns the top 20 price levels per side. On deep + // books (ETH ~$7M visible) that covers the $1M tier; on thinner ones + // (SOL ~$450k) it cannot. Refetch with nSigFigs=5, the finest price + // aggregation HL offers: same 20 levels but each bucket groups nearby + // prices, extending USD coverage with negligible price rounding. + tierLevels := levels + if _, err := walkBookForNotional(levels, tierNotionals[len(tierNotionals)-1]); err != nil { + var deep hlL2Book + if err := hlPost(client, map[string]any{"type": "l2Book", "coin": v.Asset, "nSigFigs": 5}, &deep); err == nil && len(deep.Levels) >= 2 && len(deep.Levels[1]) > 0 { + agg := make([]bookLevel, 0, len(deep.Levels[1])) + for _, a := range deep.Levels[1] { + px, _ := strconv.ParseFloat(a.Px, 64) + sz, _ := strconv.ParseFloat(a.Sz, 64) + agg = append(agg, bookLevel{Px: px, Sz: sz}) + } + tierLevels = agg + } + } + applyBookTiers(&s, tierLevels, mid) s.FetchLatencyMs = time.Since(start).Milliseconds() return s } diff --git a/harnesses/perp-fees/cmd/script/lighter.go b/harnesses/perp-fees/cmd/script/lighter.go index 61b3358c..3bf038fc 100644 --- a/harnesses/perp-fees/cmd/script/lighter.go +++ b/harnesses/perp-fees/cmd/script/lighter.go @@ -73,7 +73,7 @@ func fetchLighter(v VenueConfig) PerpSample { // 2) Orderbook for that market var book lighterOrders - if err := lighterGet(client, fmt.Sprintf("%s/orderBookOrders?market_id=%d&limit=50", lighterBase, marketID), &book); err != nil { + if err := lighterGet(client, fmt.Sprintf("%s/orderBookOrders?market_id=%d&limit=100", lighterBase, marketID), &book); err != nil { s.Err = fmt.Sprintf("orderbook: %v", err) s.FetchLatencyMs = time.Since(start).Milliseconds() return s diff --git a/harnesses/perp-fees/cmd/script/polymarket.go b/harnesses/perp-fees/cmd/script/polymarket.go index 0d5f9a54..4dfb0694 100644 --- a/harnesses/perp-fees/cmd/script/polymarket.go +++ b/harnesses/perp-fees/cmd/script/polymarket.go @@ -82,7 +82,7 @@ func fetchPolymarket(v VenueConfig) PerpSample { // 3) Orderbook var book polymarketBook - if err := polymarketGet(client, fmt.Sprintf("%s/book?instrument_id=%d&depth=100", polymarketBase, instrumentID), &book); err != nil { + if err := polymarketGet(client, fmt.Sprintf("%s/book?instrument_id=%d&depth=500", polymarketBase, instrumentID), &book); err != nil { s.Err = fmt.Sprintf("orderbook: %v", err) s.FetchLatencyMs = time.Since(start).Milliseconds() return s diff --git a/harnesses/perp-fees/cmd/script/walk.go b/harnesses/perp-fees/cmd/script/walk.go index 3cce7fa0..2ce71976 100644 --- a/harnesses/perp-fees/cmd/script/walk.go +++ b/harnesses/perp-fees/cmd/script/walk.go @@ -8,10 +8,10 @@ import ( // Notional tiers measured on every venue in addition to the headline // notional. The headline series perp_fees_all_in_bps stays defined at the // configured notional (default $1000); the tier gauge -// perp_fees_all_in_bps_tier{notional="1000"|"10000"|"100000"} is published +// perp_fees_all_in_bps_tier{notional="1000"|"10000"|"100000"|"1000000"} is // alongside so existing dashboards and spec queries keep their meaning. // The $1000 tier duplicates the headline series under the default config. -var tierNotionals = []float64{1000, 10000, 100000} +var tierNotionals = []float64{1000, 10000, 100000, 1000000} func notionalLabel(n float64) string { return strconv.FormatFloat(n, 'f', -1, 64) From a0fe27039a8068022c7ecdf950027e6c4c6eecdb Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Fri, 10 Jul 2026 12:58:30 +0200 Subject: [PATCH 2/5] perp-fees: add Polymarket to per-chain explainer venue lists --- benchmarks/perp-fees.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/perp-fees.yml b/benchmarks/perp-fees.yml index d83088f1..84431fbc 100644 --- a/benchmarks/perp-fees.yml +++ b/benchmarks/perp-fees.yml @@ -65,7 +65,7 @@ per_chain_explainer: - slug: ETH h2: "Cheapest perp DEX for ETH" body: | - The cheapest perp DEX for ETH on this benchmark sits at {{best_p50:chain:ETH}} all-in (p50, 24h) for a $1000 ETH long 10x. ETH-PERP is the deepest pair on every venue measured (Hyperliquid, Lighter, dYdX v4, GMX v2, gains.trade), which means the rack-rate taker fee dominates the all-in number rather than the spread. Lighter quotes 0 bps taker, Hyperliquid 4.5 bps, dYdX 5 bps tier-0, GMX 4 or 6 bps on the impact branch, gains.trade reads the openFeeP slot live from the on-chain Gains v8 fees contract. Spread plus impact at $1000 notional is added on top via an orderbook walk. + The cheapest perp DEX for ETH on this benchmark sits at {{best_p50:chain:ETH}} all-in (p50, 24h) for a $1000 ETH long 10x. ETH-PERP is the deepest pair on every venue measured (Hyperliquid, Lighter, Polymarket, dYdX v4, GMX v2, gains.trade), which means the rack-rate taker fee dominates the all-in number rather than the spread. Lighter quotes 0 bps taker, Hyperliquid 4.5 bps, dYdX 5 bps tier-0, GMX 4 or 6 bps on the impact branch, gains.trade reads the openFeeP slot live from the on-chain Gains v8 fees contract. Spread plus impact at $1000 notional is added on top via an orderbook walk. - slug: BTC h2: "Cheapest perp DEX for BTC" body: | From 028e4d2b94c1ae179b924b88be069af4d4b678af Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Fri, 10 Jul 2026 13:38:34 +0200 Subject: [PATCH 3/5] perp cohort: defillama v3 (free JSON overviews, volume paywalled), edgex via residential proxy (VPS IP 429-banned), hub drops drift+variational, vol30d derived from own history, funding sample_size fix, deribit out, young-venue stddev note --- benchmarks/perp-funding-stability.yml | 14 +- benchmarks/perp-funding.yml | 16 +- .../cmd/script/source_defillama.go | 275 ++++++------------ .../cmd/script/source_edgex.go | 19 +- src/lib/perp-stats.ts | 4 +- 5 files changed, 111 insertions(+), 217 deletions(-) diff --git a/benchmarks/perp-funding-stability.yml b/benchmarks/perp-funding-stability.yml index 9e8c7e01..5d5c5916 100644 --- a/benchmarks/perp-funding-stability.yml +++ b/benchmarks/perp-funding-stability.yml @@ -41,6 +41,7 @@ abstract: | upstream funding scrape (60 second poll, 30 second Prom scrape). methodology: + - "Young venues read artificially stable at first: a venue whose funding has not moved since listing (Polymarket perps launched 2026-07-08 with a constant default rate) shows a stddev near zero until real funding history accrues. Read the sample count next to the rank." - "Source. Same upstream as perp-funding: each venue's public funding endpoint polled every 60 seconds, normalized to bps per hour and per 24h hold, sign preserved." - "Metric. stddev_over_time(perp_funding_hold_24h_bps{asset=\"ETH\"}[24h]) per venue. The 7d window smooths intraday noise but reacts to regime shifts within a week." - "Sign handling. The underlying series is signed (positive means longs pay). Stability measures the swing, so the ranking uses the unsigned stddev. Average direction and level are the perp-funding bench." @@ -212,19 +213,6 @@ providers: sample_size: count_over_time(perp_venue_funding_24h_bps{venue="coinbase",asset="ETH"}[24h]) series: stddev_over_time(perp_venue_funding_24h_bps{venue="coinbase",asset="ETH"}[24h]) - - slug: deribit - name: Deribit - tag: Options-focused CEX, ETH/BTC perpetuals via Mobula aggregator - formula: "stddev_over_time of perp_venue_funding_24h_bps{venue=\"deribit\",asset=\"ETH\"} over a 24h window. Source: Mobula CEFI funding-rate aggregator." - queries: - p50: stddev_over_time(perp_venue_funding_24h_bps{venue="deribit",asset="ETH"}[24h]) - p90: stddev_over_time(perp_venue_funding_24h_bps{venue="deribit",asset="BTC"}[24h]) - p99: stddev_over_time(perp_venue_funding_24h_bps{venue="deribit",asset="SOL"}[24h]) - mean: stddev_over_time(perp_venue_funding_24h_bps{venue="deribit",asset="ETH"}[24h]) - success: (perp_venue_last_refresh_unix{venue="deribit"} > bool (time() - 300)) - sample_size: count_over_time(perp_venue_funding_24h_bps{venue="deribit",asset="ETH"}[24h]) - series: stddev_over_time(perp_venue_funding_24h_bps{venue="deribit",asset="ETH"}[24h]) - - slug: gate name: Gate tag: CEX, USDT perpetuals via Mobula aggregator, 8h funding diff --git a/benchmarks/perp-funding.yml b/benchmarks/perp-funding.yml index c1ceb31c..25ab2bf0 100644 --- a/benchmarks/perp-funding.yml +++ b/benchmarks/perp-funding.yml @@ -111,7 +111,7 @@ providers: p99: avg_over_time(perp_funding_hold_24h_bps{venue="hyperliquid",asset="SOL"}[24h]) mean: avg_over_time(perp_funding_annualized_pct{venue="hyperliquid",asset="ETH"}[24h]) success: (perp_funding_venue_ok_unix{venue="hyperliquid"} > bool (time() - 300)) - sample_size: count(perp_funding_rate_hourly_bps{venue="hyperliquid"}) + sample_size: count_over_time(perp_funding_hold_24h_bps{venue="hyperliquid",asset="ETH"}[24h]) series: avg_over_time(perp_funding_hold_24h_bps{venue="hyperliquid",asset="ETH"}[24h]) - slug: binance @@ -124,7 +124,7 @@ providers: p99: avg_over_time(perp_funding_hold_24h_bps{venue="binance",asset="SOL"}[24h]) mean: avg_over_time(perp_funding_annualized_pct{venue="binance",asset="ETH"}[24h]) success: (perp_funding_venue_ok_unix{venue="binance"} > bool (time() - 300)) - sample_size: count(perp_funding_rate_hourly_bps{venue="binance"}) + sample_size: count_over_time(perp_funding_hold_24h_bps{venue="binance",asset="ETH"}[24h]) series: avg_over_time(perp_funding_hold_24h_bps{venue="binance",asset="ETH"}[24h]) - slug: bybit @@ -137,7 +137,7 @@ providers: p99: avg_over_time(perp_funding_hold_24h_bps{venue="bybit",asset="SOL"}[24h]) mean: avg_over_time(perp_funding_annualized_pct{venue="bybit",asset="ETH"}[24h]) success: (perp_funding_venue_ok_unix{venue="bybit"} > bool (time() - 300)) - sample_size: count(perp_funding_rate_hourly_bps{venue="bybit"}) + sample_size: count_over_time(perp_funding_hold_24h_bps{venue="bybit",asset="ETH"}[24h]) series: avg_over_time(perp_funding_hold_24h_bps{venue="bybit",asset="ETH"}[24h]) - slug: okx @@ -150,7 +150,7 @@ providers: p99: avg_over_time(perp_funding_hold_24h_bps{venue="okx",asset="SOL"}[24h]) mean: avg_over_time(perp_funding_annualized_pct{venue="okx",asset="ETH"}[24h]) success: (perp_funding_venue_ok_unix{venue="okx"} > bool (time() - 300)) - sample_size: count(perp_funding_rate_hourly_bps{venue="okx"}) + sample_size: count_over_time(perp_funding_hold_24h_bps{venue="okx",asset="ETH"}[24h]) series: avg_over_time(perp_funding_hold_24h_bps{venue="okx",asset="ETH"}[24h]) - slug: dydx @@ -163,7 +163,7 @@ providers: p99: avg_over_time(perp_funding_hold_24h_bps{venue="dydx",asset="SOL"}[24h]) mean: avg_over_time(perp_funding_annualized_pct{venue="dydx",asset="ETH"}[24h]) success: (perp_funding_venue_ok_unix{venue="dydx"} > bool (time() - 300)) - sample_size: count(perp_funding_rate_hourly_bps{venue="dydx"}) + sample_size: count_over_time(perp_funding_hold_24h_bps{venue="dydx",asset="ETH"}[24h]) series: avg_over_time(perp_funding_hold_24h_bps{venue="dydx",asset="ETH"}[24h]) - slug: paradex @@ -176,7 +176,7 @@ providers: p99: avg_over_time(perp_funding_hold_24h_bps{venue="paradex",asset="SOL"}[24h]) mean: avg_over_time(perp_funding_annualized_pct{venue="paradex",asset="ETH"}[24h]) success: (perp_funding_venue_ok_unix{venue="paradex"} > bool (time() - 300)) - sample_size: count(perp_funding_rate_hourly_bps{venue="paradex"}) + sample_size: count_over_time(perp_funding_hold_24h_bps{venue="paradex",asset="ETH"}[24h]) series: avg_over_time(perp_funding_hold_24h_bps{venue="paradex",asset="ETH"}[24h]) - slug: polymarket @@ -189,7 +189,7 @@ providers: p99: avg_over_time(perp_funding_hold_24h_bps{venue="polymarket",asset="SOL"}[24h]) mean: avg_over_time(perp_funding_annualized_pct{venue="polymarket",asset="ETH"}[24h]) success: (perp_funding_venue_ok_unix{venue="polymarket"} > bool (time() - 300)) - sample_size: count(perp_funding_rate_hourly_bps{venue="polymarket"}) + sample_size: count_over_time(perp_funding_hold_24h_bps{venue="polymarket",asset="ETH"}[24h]) series: avg_over_time(perp_funding_hold_24h_bps{venue="polymarket",asset="ETH"}[24h]) - slug: aster @@ -202,7 +202,7 @@ providers: p99: avg_over_time(perp_funding_hold_24h_bps{venue="aster",asset="SOL"}[24h]) mean: avg_over_time(perp_funding_annualized_pct{venue="aster",asset="ETH"}[24h]) success: (perp_funding_venue_ok_unix{venue="aster"} > bool (time() - 300)) - sample_size: count(perp_funding_rate_hourly_bps{venue="aster"}) + sample_size: count_over_time(perp_funding_hold_24h_bps{venue="aster",asset="ETH"}[24h]) series: avg_over_time(perp_funding_hold_24h_bps{venue="aster",asset="ETH"}[24h]) metric_panels: diff --git a/harnesses/perp-cohort-stats/cmd/script/source_defillama.go b/harnesses/perp-cohort-stats/cmd/script/source_defillama.go index 5b426763..7136e1b6 100644 --- a/harnesses/perp-cohort-stats/cmd/script/source_defillama.go +++ b/harnesses/perp-cohort-stats/cmd/script/source_defillama.go @@ -1,148 +1,127 @@ package main import ( + "encoding/json" "fmt" "io" "net/http" - "regexp" - "strconv" "time" ) -// DefiLlamaScrapeSource pulls cohort numbers off the public protocol -// HTML pages at https://defillama.com/protocol/. The free JSON -// summary endpoints (/summary/derivatives/, /summary/dexs/) -// now return 402 (paid plan) so HTML scraping is the only public path -// for perp volume + OI without an API subscription. +// DefiLlamaScrapeSource pulls cohort numbers from DefiLlama's free JSON +// overview endpoints (2 requests per sweep, all venues at once): // -// The protocol HTML pages embed Next.js page-data as inline JSON. Each -// page contains structured blocks keyed by metric category: +// GET https://api.llama.fi/overview/fees?excludeTotalDataChart=true&excludeTotalDataChartBreakdown=true +// GET https://api.llama.fi/overview/open-interest?excludeTotalDataChart=true&excludeTotalDataChartBreakdown=true // -// "perpVolume":{ "total24h":..., "total30d":... } -// "openInterest":{ "total24h":..., "total30d":... } -// "fees":{ "total24h":..., "total30d":... } +// History of this source, because it keeps breaking in new ways: +// - v1 used /summary/derivatives/ JSON: went 402 (paid plan). +// - v2 scraped the protocol HTML pages: Cloudflare bot-challenged them +// to 403 in 2026-07, which nulled volume30d/fees30d for every venue +// on /perps and dragged bench success to 67%. +// - v3 (this) uses the overview endpoints that are still on the free +// tier. Perp VOLUME (24h/30d) is paid-only now, so this source no +// longer publishes mVolume24h/mVolume30d at all; the /perps hub +// derives volume30d from our own perp_venue_volume_24h_usd history +// instead, and per-venue native sources own vol24h. // -// We use a brace-matching extractor to pull the full nested block for -// each category, then scan for total24h / total30d. The pattern is -// stable across the 3 v1 venues; if DefiLlama changes its embed schema -// this source goes "unavailable" for that page and the router falls -// back to native sources (HL/Lighter) for the metrics they cover. +// The struct name is kept so the router wiring stays untouched. type DefiLlamaScrapeSource struct { client *http.Client } func NewDefillamaScrapeSource() *DefiLlamaScrapeSource { return &DefiLlamaScrapeSource{ - client: &http.Client{Timeout: 15 * time.Second}, + client: &http.Client{Timeout: 20 * time.Second}, } } func (s *DefiLlamaScrapeSource) Name() string { return srcDefillama } -// slugMap maps OCB venue slugs to DefiLlama protocol page slugs. -// Verified live against https://defillama.com/protocol/: -// - hyperliquid -> /protocol/hyperliquid (200 OK) -// - lighter -> /protocol/lighter (200 OK) -// - gmx-v2 -> /protocol/gmx-v2-perps (200 OK) -// - gains -> /protocol/gains-network (200 OK) -// - dydx -> /protocol/dydx-v4 (200 OK, perpVolume present) -// - paradex -> /protocol/paradex (200 OK, perpVolume present) -// - aster -> /protocol/aster (200 OK, perpVolume present) -// - edgex -> /protocol/edgex-perps (200 OK, perpVolume 24h=529M live) -// - grvt -> /protocol/grvt (200 OK, perpVolume present) -// -// Skipped: vertex (the /protocol/vertex-perps page has total24h=null -// today; native source is authoritative anyway). Re-add if DefiLlama -// resumes publishing daily numbers for the slug. -// -// Adding a venue: verify the page exists and returns the same embedded -// JSON blocks before adding the slug here. +// defillamaSlugMap maps OCB venue slugs to DefiLlama protocol slugs as +// they appear in the JSON overview endpoints (mostly `-perps` suffixed, +// unlike the old HTML page slugs). Verified live 2026-07-10 against +// /overview/open-interest. drift stays absent (parked venue, dead +// adapter); vertex is intentionally excluded: its llama adapter reports +// null totals and the native archive source is authoritative. var defillamaSlugMap = map[string]string{ - "hyperliquid": "hyperliquid", - "lighter": "lighter", + "hyperliquid": "hyperliquid-perps", + "lighter": "lighter-perps", "gmx-v2": "gmx-v2-perps", "gains": "gains-network", "dydx": "dydx-v4", - "paradex": "paradex", - "aster": "aster", + "paradex": "paradex-perps", + "aster": "aster-perps", "edgex": "edgex-perps", - "grvt": "grvt", - // Sprint 3: added after the 2026-06 probe sweep. Live perpVolume - // + openInterest verified on each page: - // - extended -> /protocol/extended (200 OK, vol24h live). - // - aevo -> /protocol/aevo (200 OK, vol24h live). - // - pacifica -> /protocol/pacifica (200 OK, vol24h live). - // - variational -> /protocol/variational (200 OK, vol24h live; - // api.variational.io - // is NXDOMAIN so this - // page is the only path). - // - ostium -> /protocol/ostium (200 OK, vol24h live). - // Drift is intentionally absent: its DefiLlama adapter reports - // $0 and the venue is parked from the Registry pending the - // Sprint 4 Solana RPC + Anchor IDL adapter. - "extended": "extended", - "aevo": "aevo", - "pacifica": "pacifica", + "grvt": "grvt-perps", + "extended": "extended-perps", + "aevo": "aevo-perps", + "pacifica": "pacifica-perps", "variational": "variational", "ostium": "ostium", } +type llamaProto struct { + Slug string `json:"slug"` + Total24h *float64 `json:"total24h"` + Total30d *float64 `json:"total30d"` +} + +type llamaOverview struct { + Protocols []llamaProto `json:"protocols"` +} + func (s *DefiLlamaScrapeSource) Fetch() (*SourceResult, error) { res := newSourceResult() + + fees, feesErr := s.overview("fees") + oi, oiErr := s.overview("open-interest") + if feesErr != nil && oiErr != nil { + perpCohortFetchErrors.WithLabelValues("all", srcDefillama, classifyError(feesErr.Error())).Inc() + fmt.Printf("[perp-cohort][all][%s] err: fees=%v oi=%v\n", srcDefillama, feesErr, oiErr) + return res, nil + } + for venueSlug, llamaSlug := range defillamaSlugMap { - if err := s.fetchVenue(venueSlug, llamaSlug, res); err != nil { - perpCohortFetchErrors.WithLabelValues(venueSlug, srcDefillama, classifyError(err.Error())).Inc() - fmt.Printf("[perp-cohort][%s][%s] err: %v\n", venueSlug, srcDefillama, err) - continue + var fees30, oiNow float64 + if fees != nil { + if p := findProto(fees, llamaSlug); p != nil && p.Total30d != nil { + fees30 = *p.Total30d + } + } + if oi != nil { + // open-interest overview: total24h holds the CURRENT OI in USD. + if p := findProto(oi, llamaSlug); p != nil && p.Total24h != nil { + oiNow = *p.Total24h + } + } + if fees30 > 0 { + res.Set(venueSlug, mFees30d, fees30) + } + if oiNow > 0 { + res.Set(venueSlug, mOI, oiNow) + } + if fees30 > 0 || oiNow > 0 { + fmt.Printf("[perp-cohort][%s][%s] ok: fees30d=%.0f oi=%.0f\n", venueSlug, srcDefillama, fees30, oiNow) } } return res, nil } -func (s *DefiLlamaScrapeSource) fetchVenue(venueSlug, llamaSlug string, res *SourceResult) error { - url := fmt.Sprintf("https://defillama.com/protocol/%s", llamaSlug) - html, err := s.get(url) - if err != nil { - return err - } - - // Each block: {category}{nested-objects}{total24h,...,total30d,...} - // Drift-style broken feeds publish $0 on the dashboard; treat any - // extracted-but-zero perpVolume as "unavailable" so the source does - // not falsely win the priority race. - perpVol24, perpVol30, perpOK := extractTotals(html, "perpVolume") - oi24, _, oiOK := extractTotals(html, "openInterest") - _, fees30, feesOK := extractTotals(html, "fees") - - logParts := fmt.Sprintf("perp_ok=%v oi_ok=%v fees_ok=%v", perpOK, oiOK, feesOK) - if perpOK && perpVol24 > 0 { - res.Set(venueSlug, mVolume24h, perpVol24) - } - if perpOK && perpVol30 > 0 { - res.Set(venueSlug, mVolume30d, perpVol30) - } - if oiOK && oi24 > 0 { - res.Set(venueSlug, mOI, oi24) - } - if feesOK && fees30 > 0 { - res.Set(venueSlug, mFees30d, fees30) - } - - // If literally everything came back zero we treat the page as - // unavailable so the router falls back to other sources cleanly. - if !perpOK && !oiOK && !feesOK { - return fmt.Errorf("unavailable: no blocks extracted from /%s", llamaSlug) +func findProto(o *llamaOverview, slug string) *llamaProto { + for i := range o.Protocols { + if o.Protocols[i].Slug == slug { + return &o.Protocols[i] + } } - - fmt.Printf("[perp-cohort][%s][%s] ok: %s vol24h=%.0f vol30d=%.0f oi=%.0f fees30d=%.0f\n", - venueSlug, srcDefillama, logParts, perpVol24, perpVol30, oi24, fees30) return nil } -func (s *DefiLlamaScrapeSource) get(url string) ([]byte, error) { +func (s *DefiLlamaScrapeSource) overview(kind string) (*llamaOverview, error) { + url := fmt.Sprintf("https://api.llama.fi/overview/%s?excludeTotalDataChart=true&excludeTotalDataChartBreakdown=true", kind) req, _ := http.NewRequest("GET", url, nil) req.Header.Set("User-Agent", "OpenChainBench-PerpCohort/1.0 contact@mobula.io") - req.Header.Set("Accept", "text/html,application/xhtml+xml") + req.Header.Set("Accept", "application/json") resp, err := s.client.Do(req) if err != nil { return nil, fmt.Errorf("request_error: %w", err) @@ -150,99 +129,11 @@ func (s *DefiLlamaScrapeSource) get(url string) ([]byte, error) { defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != 200 { - return nil, fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) - } - return body, nil -} - -// extractBlock returns the full JSON-object substring (including outer -// braces) for `"":{...}` using brace counting. Handles arbitrary -// nesting depth and skips quoted braces. Returns "" if not found. -func extractBlock(html []byte, key string) string { - needle := []byte(`"` + key + `":{`) - idx := indexOf(html, needle) - if idx < 0 { - return "" + return nil, fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 120)) } - start := idx + len(needle) - 1 // points at the '{' - depth := 0 - inStr := false - escape := false - for i := start; i < len(html); i++ { - c := html[i] - if escape { - escape = false - continue - } - if c == '\\' && inStr { - escape = true - continue - } - if c == '"' { - inStr = !inStr - continue - } - if inStr { - continue - } - switch c { - case '{': - depth++ - case '}': - depth-- - if depth == 0 { - return string(html[start : i+1]) - } - } - } - return "" -} - -var ( - reTotal24h = regexp.MustCompile(`"total24h":([0-9.]+)`) - reTotal30d = regexp.MustCompile(`"total30d":([0-9.]+)`) -) - -func extractTotals(html []byte, key string) (t24, t30 float64, ok bool) { - blk := extractBlock(html, key) - if blk == "" { - return 0, 0, false - } - m24 := reTotal24h.FindStringSubmatch(blk) - m30 := reTotal30d.FindStringSubmatch(blk) - if m24 != nil { - t24, _ = strconv.ParseFloat(m24[1], 64) - } - if m30 != nil { - t30, _ = strconv.ParseFloat(m30[1], 64) - } - // Consider a block "ok" if it parsed at all (even all-zero values - // from broken venues like Drift); the publisher above decides not - // to overwrite the gauge when the value is zero. - if m24 != nil || m30 != nil { - return t24, t30, true - } - return 0, 0, false -} - -// indexOf is a tiny byte-substring search; net/http already pulls in -// bytes, but a tight loop here avoids the import for one call. -func indexOf(hay, needle []byte) int { - if len(needle) == 0 { - return 0 - } - n := len(hay) - len(needle) - for i := 0; i <= n; i++ { - match := true - for j := 0; j < len(needle); j++ { - if hay[i+j] != needle[j] { - match = false - break - } - } - if match { - return i - } + var out llamaOverview + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("parse: %w", err) } - return -1 + return &out, nil } diff --git a/harnesses/perp-cohort-stats/cmd/script/source_edgex.go b/harnesses/perp-cohort-stats/cmd/script/source_edgex.go index 797216e7..1ff2385b 100644 --- a/harnesses/perp-cohort-stats/cmd/script/source_edgex.go +++ b/harnesses/perp-cohort-stats/cmd/script/source_edgex.go @@ -1,6 +1,8 @@ package main import ( + "net/url" + "os" "encoding/json" "fmt" "io" @@ -41,9 +43,24 @@ type edgexTickerRow struct { mark float64 } +// edgexHTTPClient routes through COHORT_PROXY_URL when set. edgeX +// rate-bans datacenter IPs (429 on every getTicker from the VPS since +// 2026-07 while residential IPs pass), and volume/OI numbers are not +// latency measurements, so a rotating residential proxy changes nothing +// methodologically. +func edgexHTTPClient() *http.Client { + c := &http.Client{Timeout: 15 * time.Second} + if raw := os.Getenv("COHORT_PROXY_URL"); raw != "" { + if u, err := url.Parse(raw); err == nil { + c.Transport = &http.Transport{Proxy: http.ProxyURL(u), DisableKeepAlives: true} + } + } + return c +} + func NewEdgexNativeSource() *EdgexNativeSource { return &EdgexNativeSource{ - client: &http.Client{Timeout: 15 * time.Second}, + client: edgexHTTPClient(), cache: map[string]edgexTickerRow{}, } } diff --git a/src/lib/perp-stats.ts b/src/lib/perp-stats.ts index 95fd45a0..08efd8e5 100644 --- a/src/lib/perp-stats.ts +++ b/src/lib/perp-stats.ts @@ -92,7 +92,6 @@ export const PERP_VENUES: VenueSeed[] = [ { slug: "gmx-v2", name: "GMX v2", chain: "Arbitrum", venueType: "onchain" }, { slug: "gains", name: "Gains Network", chain: "Arbitrum", venueType: "onchain" }, { slug: "dydx", name: "dYdX v4", chain: "Cosmos", venueType: "onchain" }, - { slug: "drift", name: "Drift", chain: "Solana", venueType: "onchain" }, { slug: "vertex", name: "Vertex", chain: "Arbitrum", venueType: "onchain" }, { slug: "paradex", name: "Paradex", chain: "Starknet", venueType: "onchain" }, { slug: "aster", name: "Aster", chain: "BNB Chain", venueType: "onchain" }, @@ -100,7 +99,6 @@ export const PERP_VENUES: VenueSeed[] = [ { slug: "extended", name: "Extended", chain: "Starknet", venueType: "onchain" }, { slug: "aevo", name: "Aevo", chain: "OP Stack", venueType: "onchain" }, { slug: "pacifica", name: "Pacifica", chain: "Solana", venueType: "onchain" }, - { slug: "variational", name: "Variational", chain: "Arbitrum", venueType: "onchain" }, { slug: "ostium", name: "Ostium", chain: "Arbitrum", venueType: "onchain" }, { slug: "grvt", name: "GRVT", chain: "zkSync", venueType: "onchain" }, { slug: "polymarket", name: "Polymarket", chain: "Polygon", venueType: "onchain" }, @@ -150,7 +148,7 @@ export async function fetchPerpCohortFresh(): Promise lastRefresh, ] = await Promise.all([ queryVector(prom, `perp_venue_volume_24h_usd`), - queryVector(prom, `perp_venue_volume_30d_usd`), + queryVector(prom, `perp_venue_volume_30d_usd or (avg_over_time(perp_venue_volume_24h_usd[30d]) * 30)`), queryVector(prom, `perp_venue_oi_usd`), queryVector(prom, `perp_venue_fees_30d_usd`), queryVector(prom, `perp_venue_active_markets`), From c32e930443b4bb7a36ac126bc3879e81884bafc0 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Fri, 10 Jul 2026 13:42:19 +0200 Subject: [PATCH 4/5] 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) +} From b45fcc2fb760b3156303b4ad989b37d75d11c1f4 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Fri, 10 Jul 2026 13:46:39 +0200 Subject: [PATCH 5/5] bench pages: awaiting-data renders bypass the ISR cache (degraded store reads showed 'no live data' for a full revalidate window) --- src/app/benchmarks/[slug]/page.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/app/benchmarks/[slug]/page.tsx b/src/app/benchmarks/[slug]/page.tsx index c82a7755..06d31a75 100644 --- a/src/app/benchmarks/[slug]/page.tsx +++ b/src/app/benchmarks/[slug]/page.tsx @@ -1,5 +1,6 @@ import { Suspense } from "react"; import type { Metadata } from "next"; +import { unstable_noStore as noStore } from "next/cache"; import { notFound } from "next/navigation"; import Link from "next/link"; import { ArrowLeft, ArrowUpRight, ChevronDown } from "lucide-react"; @@ -210,6 +211,14 @@ export default async function BenchmarkPage({ const isDraft = benchmark.status === "draft"; const isAwaiting = isDraft && benchmark.editorialStatus === "live"; + // An editorially-live bench rendering as draft means the store read + // came back empty this cycle (srh timeout, snapshot swap), not that + // the bench has no data: keep that render OUT of the ISR cache so the + // next visitor triggers a fresh read instead of everyone seeing + // "no live data yet" for a revalidate window (seen on perp-fees + // 2026-07-10). Genuinely-new benches render dynamically until their + // first samples land, which is the correct behavior anyway. + if (isAwaiting) noStore(); // Insufficient: editorially live, runtime might say "live" too, but the // shared predicate decided no provider has a usable p50. Drives the // pill above the H1 and the headline degradation downstream.