diff --git a/benchmarks/perp-capital-efficiency.yml b/benchmarks/perp-capital-efficiency.yml new file mode 100644 index 00000000..0aa33e21 --- /dev/null +++ b/benchmarks/perp-capital-efficiency.yml @@ -0,0 +1,146 @@ +# OpenChainBench. Bench 144 + +slug: perp-capital-efficiency +number: "144" +title: "Perp DEX capital efficiency: active markets per $M TVL, live" +seo_title: "Perp DEX capital efficiency 2026" +seo_description: "gains.trade lists 40+ active perp markets per $1M TVL versus Hyperliquid at under 0.1. Capital efficiency measures how many tradable instruments a venue can sustain per dollar of locked collateral. Synthetic oracle venues outperform CLOB venues by two orders of magnitude. Live from DefiLlama TVL and native venue APIs." +subtitle: "Active perp markets divided by trading TVL in USD millions. Higher means more instruments supported per dollar of locked collateral. Synthetic oracle venues like gains.trade carry many markets on a shared pool; CLOB venues like Hyperliquid require dedicated market-maker inventory per pair." +category: Trading +status: live +metric: Markets per $M TVL +unit: count +higher_is_better: true + +disclaimer: | + TVL from DefiLlama /protocol/{slug} currentChainTvls, excluding staking and pool2 categories. Market count from each venue's native API (same source as the perp-active-markets bench). The ratio reflects architectural choices, not absolute liquidity: a high ratio means the venue can list many markets cheaply, not that each market has deep liquidity. Compare with perp-open-interest for the liquidity angle. + +seo_intro: | + Every perp DEX comparison ranks by volume, fees or funding rates. Few ask the + capital question: how many markets can a venue sustain per dollar it locks up? + The answer reveals the structural gap between synthetic oracle venues and CLOB + order book venues. A synthetic venue like gains.trade needs one shared collateral + pool to back unlimited pairs. A CLOB like Hyperliquid needs market-maker inventory + for every individual pair. The collateral requirements scale completely differently. + + gains.trade supports over 40 active markets per $1M of trading TVL. Hyperliquid + supports less than 0.1 markets per $1M TVL. The 400x gap is not a performance + difference; it is an architectural one. The bench does not judge which model is + better: deep single-pair liquidity has real value. It measures a real dimension + that existing leaderboards ignore entirely. + +abstract: | + The bench computes active_markets / (trading_tvl_usd / 1_000_000) for each + tracked perp venue. Both inputs are updated every 5 minutes. Active market count + comes from each venue's native API via the perp-cohort-stats harness (same + source as the perp-active-markets bench). Trading TVL comes from DefiLlama + /protocol/{slug} currentChainTvls with staking and pool2 keys excluded, also + polled every 5 minutes. The ratio is recomputed on every Prometheus scrape. + TVL fluctuates with collateral deposits and crypto prices, so the 24h average + smooths intraday noise. + +methodology: + - "Cadence: every 5 minutes. Both active_markets and TVL are re-read on the same sweep cycle." + - "Active markets: native venue API per the perp-active-markets bench methodology. gains.trade: Mobula perp pairs catalog (mainnet only). Hyperliquid: info metaAndAssetCtxs. dYdX: indexer perpetualMarkets. Others: native endpoints." + - "TVL: DefiLlama /protocol/{slug} currentChainTvls. Staking and pool2 keys excluded. Multi-chain TVL summed across all deployment chains." + - "gains.trade TVL: GToken vault across Arbitrum, Base, Polygon and ApeChain. This is the shared liquidity pool that backs all synthetic pairs simultaneously." + - "Hyperliquid TVL: total user deposits (USDC). Funds serve as both margin and implicit market-maker collateral; every listed pair draws from this pool for maker fills." + - "Ratio formula: avg_over_time(markets[24h]) / avg_over_time(tvl_usd[24h]) * 1_000_000." + - "Interpretation: a ratio of 40 means the venue can sustain 40 active markets per $1M locked. Higher is more capital-efficient in terms of market breadth." + +findings: + - "{{best_name}} leads capital efficiency with {{best_p50}} active markets per $1M TVL." + - "gains.trade lists {{p50:gains}} markets per $1M TVL, the highest among measured venues. Its synthetic oracle model backs 449+ pairs from a single shared GToken vault." + - "Hyperliquid lists {{p50:hyperliquid}} markets per $1M TVL. Its CLOB model requires market-maker inventory per pair; the $6B deposit base reflects that inventory requirement." + - "dYdX v4 lists {{p50:dydx}} markets per $1M TVL. Cosmos appchain orderbook." + - "GMX v2 lists {{p50:gmx-v2}} markets per $1M TVL. Its GM pools require per-market collateral, so adding a new market means deploying a new pool." + - "Ostium lists {{p50:ostium}} markets per $1M TVL. Synthetic perps on Arbitrum covering FX, crypto and commodities." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/perp-cohort-stats + +prometheus: + window: 24h + expected_freshness_seconds: 1800 + +faq: + - q: "What does capital efficiency mean here?" + a: "It measures how many distinct perpetual markets a venue can sustain per $1M of trading collateral locked. Higher is more efficient: you get more instruments per dollar of capital. It does not measure depth per market." + - q: "Why does gains.trade win so clearly?" + a: "gains.trade uses synthetic oracles backed by a single shared liquidity pool (the GToken vault). Adding a new pair costs near zero additional capital. CLOB venues like Hyperliquid or GMX need per-pair market-maker inventory or per-pool collateral, so capital scales with pair count." + - q: "Is capital efficiency always desirable?" + a: "Not necessarily. Deep per-pair liquidity has value for large traders. A venue with 449 pairs on $11M TVL may have thin liquidity per pair. Pair this bench with perp-open-interest for the depth angle." + - q: "Why is TVL from DefiLlama and not native APIs?" + a: "DefiLlama aggregates TVL across all deployment chains and normalizes the methodology across venues. Native TVL endpoints (where they exist) differ in scope: some include user margin, others only LP collateral." + - q: "Does TVL change with crypto prices?" + a: "Yes. Venues whose TVL includes crypto-denominated assets (LP tokens, staked tokens) see their USD TVL move with prices. The 24h average smooths intraday moves, but the ratio can shift in volatile markets." + +providers: + - slug: gains + name: gains.trade + tag: Synthetic perps on Arbitrum, Base, Polygon and ApeChain, shared GToken vault + confidence_tier: onchain + formula: "avg_over_time(perp_venue_active_markets{venue=\"gains\"}[24h]) / avg_over_time(perp_venue_tvl_usd{venue=\"gains\"}[24h]) * 1000000" + queries: + p50: avg_over_time(perp_venue_active_markets{venue="gains"}[24h]) / avg_over_time(perp_venue_tvl_usd{venue="gains"}[24h]) * 1000000 + p90: quantile_over_time(0.90, perp_venue_active_markets{venue="gains"}[24h]) / avg_over_time(perp_venue_tvl_usd{venue="gains"}[24h]) * 1000000 + p99: quantile_over_time(0.99, perp_venue_active_markets{venue="gains"}[24h]) / avg_over_time(perp_venue_tvl_usd{venue="gains"}[24h]) * 1000000 + mean: avg_over_time(perp_venue_active_markets{venue="gains"}[24h]) / avg_over_time(perp_venue_tvl_usd{venue="gains"}[24h]) * 1000000 + success: avg_over_time(perp_venue_health{venue="gains"}[24h]) + sample_size: count_over_time(perp_venue_active_markets{venue="gains"}[24h]) + series: perp_venue_active_markets{venue="gains"} / perp_venue_tvl_usd{venue="gains"} * 1000000 + + - slug: hyperliquid + name: Hyperliquid + tag: Custom L1 HyperBFT CLOB, total user deposits as TVL + confidence_tier: onchain + formula: "avg_over_time(perp_venue_active_markets{venue=\"hyperliquid\"}[24h]) / avg_over_time(perp_venue_tvl_usd{venue=\"hyperliquid\"}[24h]) * 1000000" + queries: + p50: avg_over_time(perp_venue_active_markets{venue="hyperliquid"}[24h]) / avg_over_time(perp_venue_tvl_usd{venue="hyperliquid"}[24h]) * 1000000 + p90: quantile_over_time(0.90, perp_venue_active_markets{venue="hyperliquid"}[24h]) / avg_over_time(perp_venue_tvl_usd{venue="hyperliquid"}[24h]) * 1000000 + p99: quantile_over_time(0.99, perp_venue_active_markets{venue="hyperliquid"}[24h]) / avg_over_time(perp_venue_tvl_usd{venue="hyperliquid"}[24h]) * 1000000 + mean: avg_over_time(perp_venue_active_markets{venue="hyperliquid"}[24h]) / avg_over_time(perp_venue_tvl_usd{venue="hyperliquid"}[24h]) * 1000000 + success: avg_over_time(perp_venue_health{venue="hyperliquid"}[24h]) + sample_size: count_over_time(perp_venue_active_markets{venue="hyperliquid"}[24h]) + series: perp_venue_active_markets{venue="hyperliquid"} / perp_venue_tvl_usd{venue="hyperliquid"} * 1000000 + + - slug: dydx + name: dYdX v4 + tag: Cosmos appchain orderbook perps + confidence_tier: onchain + formula: "avg_over_time(perp_venue_active_markets{venue=\"dydx\"}[24h]) / avg_over_time(perp_venue_tvl_usd{venue=\"dydx\"}[24h]) * 1000000" + queries: + p50: avg_over_time(perp_venue_active_markets{venue="dydx"}[24h]) / avg_over_time(perp_venue_tvl_usd{venue="dydx"}[24h]) * 1000000 + p90: quantile_over_time(0.90, perp_venue_active_markets{venue="dydx"}[24h]) / avg_over_time(perp_venue_tvl_usd{venue="dydx"}[24h]) * 1000000 + p99: quantile_over_time(0.99, perp_venue_active_markets{venue="dydx"}[24h]) / avg_over_time(perp_venue_tvl_usd{venue="dydx"}[24h]) * 1000000 + mean: avg_over_time(perp_venue_active_markets{venue="dydx"}[24h]) / avg_over_time(perp_venue_tvl_usd{venue="dydx"}[24h]) * 1000000 + success: avg_over_time(perp_venue_health{venue="dydx"}[24h]) + sample_size: count_over_time(perp_venue_active_markets{venue="dydx"}[24h]) + series: perp_venue_active_markets{venue="dydx"} / perp_venue_tvl_usd{venue="dydx"} * 1000000 + + - slug: gmx-v2 + name: GMX v2 + tag: EVM Arbitrum, per-market GM pool collateral + confidence_tier: onchain + formula: "avg_over_time(perp_venue_active_markets{venue=\"gmx-v2\"}[24h]) / avg_over_time(perp_venue_tvl_usd{venue=\"gmx-v2\"}[24h]) * 1000000" + queries: + p50: avg_over_time(perp_venue_active_markets{venue="gmx-v2"}[24h]) / avg_over_time(perp_venue_tvl_usd{venue="gmx-v2"}[24h]) * 1000000 + p90: quantile_over_time(0.90, perp_venue_active_markets{venue="gmx-v2"}[24h]) / avg_over_time(perp_venue_tvl_usd{venue="gmx-v2"}[24h]) * 1000000 + p99: quantile_over_time(0.99, perp_venue_active_markets{venue="gmx-v2"}[24h]) / avg_over_time(perp_venue_tvl_usd{venue="gmx-v2"}[24h]) * 1000000 + mean: avg_over_time(perp_venue_active_markets{venue="gmx-v2"}[24h]) / avg_over_time(perp_venue_tvl_usd{venue="gmx-v2"}[24h]) * 1000000 + success: avg_over_time(perp_venue_health{venue="gmx-v2"}[24h]) + sample_size: count_over_time(perp_venue_active_markets{venue="gmx-v2"}[24h]) + series: perp_venue_active_markets{venue="gmx-v2"} / perp_venue_tvl_usd{venue="gmx-v2"} * 1000000 + + - slug: ostium + name: Ostium + tag: Synthetic perps on Arbitrum, FX and commodities alongside crypto + confidence_tier: onchain + formula: "avg_over_time(perp_venue_active_markets{venue=\"ostium\"}[24h]) / avg_over_time(perp_venue_tvl_usd{venue=\"ostium\"}[24h]) * 1000000" + queries: + p50: avg_over_time(perp_venue_active_markets{venue="ostium"}[24h]) / avg_over_time(perp_venue_tvl_usd{venue="ostium"}[24h]) * 1000000 + p90: quantile_over_time(0.90, perp_venue_active_markets{venue="ostium"}[24h]) / avg_over_time(perp_venue_tvl_usd{venue="ostium"}[24h]) * 1000000 + p99: quantile_over_time(0.99, perp_venue_active_markets{venue="ostium"}[24h]) / avg_over_time(perp_venue_tvl_usd{venue="ostium"}[24h]) * 1000000 + mean: avg_over_time(perp_venue_active_markets{venue="ostium"}[24h]) / avg_over_time(perp_venue_tvl_usd{venue="ostium"}[24h]) * 1000000 + success: avg_over_time(perp_venue_health{venue="ostium"}[24h]) + sample_size: count_over_time(perp_venue_active_markets{venue="ostium"}[24h]) + series: perp_venue_active_markets{venue="ostium"} / perp_venue_tvl_usd{venue="ostium"} * 1000000 diff --git a/harnesses/perp-cohort-stats/cmd/script/adapter.go b/harnesses/perp-cohort-stats/cmd/script/adapter.go index 001a1bdd..960177e4 100644 --- a/harnesses/perp-cohort-stats/cmd/script/adapter.go +++ b/harnesses/perp-cohort-stats/cmd/script/adapter.go @@ -82,6 +82,7 @@ const ( mFees30d = "fees_30d" mActiveMarkets = "active_markets" mTopVol24h = "top_market_volume_24h" + mTVL = "tvl" ) // Source names. Keep these stable: they appear as Prom label values @@ -290,6 +291,17 @@ func priorityMap(venue, metric string) []string { case "polymarket": return []string{srcPolymarketNative} } + case mTVL: + // TVL comes from DefiLlama /protocol/{slug} for venues where the + // slug is known and the TVL is meaningful trading collateral. + // Venues not listed here (e.g. polymarket, variational) have no + // reliable TVL signal and are omitted from the capital-efficiency bench. + switch venue { + case "hyperliquid", "gains", "gmx-v2", "dydx", "ostium", + "lighter", "paradex", "edgex", "aster", "vertex", + "grvt", "extended", "aevo", "pacifica": + return []string{srcDefillama} + } } return nil } @@ -442,7 +454,7 @@ func (r *Router) Sweep() { // Resolve each (venue, metric) using priorityMap. Run cross-check // against the next source in the priority list. - cohortMetrics := []string{mVolume24h, mVolume30d, mOI, mFees30d, mActiveMarkets, mTopVol24h} + cohortMetrics := []string{mVolume24h, mVolume30d, mOI, mFees30d, mActiveMarkets, mTopVol24h, mTVL} for _, v := range Registry { var healthHits, healthTotal int for _, metric := range cohortMetrics { @@ -614,6 +626,8 @@ func publishCohort(venue, metric string, val float64) { perpVenueActiveMarkets.WithLabelValues(venue).Set(val) case mTopVol24h: perpVenueTopMarketVolume24hUsd.WithLabelValues(venue).Set(val) + case mTVL: + perpVenueTvlUsd.WithLabelValues(venue).Set(val) } } diff --git a/harnesses/perp-cohort-stats/cmd/script/metrics.go b/harnesses/perp-cohort-stats/cmd/script/metrics.go index fdf6223d..1e21219f 100644 --- a/harnesses/perp-cohort-stats/cmd/script/metrics.go +++ b/harnesses/perp-cohort-stats/cmd/script/metrics.go @@ -73,6 +73,13 @@ var ( }, []string{"venue"}, ) + perpVenueTvlUsd = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "perp_venue_tvl_usd", + Help: "Total value locked in USD per perp venue, excluding staking and pool2 categories. Source: DefiLlama /protocol/{slug} currentChainTvls.", + }, + []string{"venue"}, + ) perpVenueHealth = prometheus.NewGaugeVec( prometheus.GaugeOpts{ Name: "perp_venue_health", @@ -137,6 +144,7 @@ func init() { perpVenueVolume24hUsd, perpVenueVolume30dUsd, perpVenueOIUsd, perpVenueFees30dUsd, perpVenueActiveMarkets, perpVenueTopMarketVolume24hUsd, perpVenueMarketsByClass, perpVenueNoncoreMarketsTotal, + perpVenueTvlUsd, perpVenueHealth, perpVenueFunding24hBps, perpVenueFundingIntervalHours, perpVenueLastRefreshUnix, perpCohortFetchErrors, perpCohortSourceUsed, perpCohortDataDivergence, diff --git a/harnesses/perp-cohort-stats/cmd/script/source_defillama.go b/harnesses/perp-cohort-stats/cmd/script/source_defillama.go index 7136e1b6..8b78724f 100644 --- a/harnesses/perp-cohort-stats/cmd/script/source_defillama.go +++ b/harnesses/perp-cohort-stats/cmd/script/source_defillama.go @@ -5,6 +5,8 @@ import ( "fmt" "io" "net/http" + "strings" + "sync" "time" ) @@ -61,6 +63,28 @@ var defillamaSlugMap = map[string]string{ "ostium": "ostium", } +// tvlSlugMap maps OCB venue slugs to DefiLlama /protocol/{slug} identifiers +// used for the trading TVL endpoint. These slugs differ from defillamaSlugMap +// (which uses -perps suffixed overview slugs). Verified 2026-08. +// Venues omitted here (variational, polymarket) have no reliable TVL page. +// tvlSlugMap verified 2026-08 against DefiLlama /protocol/{slug}. +// vertex has no TVL page; variational and polymarket omitted (no protocol page). +var tvlSlugMap = map[string]string{ + "hyperliquid": "hyperliquid", + "gains": "gains-network", + "gmx-v2": "gmx", + "dydx": "dydx", + "ostium": "ostium", + "lighter": "lighter", + "paradex": "paradex", + "edgex": "edgex", + "aster": "aster", + "grvt": "grvt", + "extended": "extended", + "aevo": "aevo", + "pacifica": "pacifica-perps", +} + type llamaProto struct { Slug string `json:"slug"` Total24h *float64 `json:"total24h"` @@ -105,9 +129,74 @@ func (s *DefiLlamaScrapeSource) Fetch() (*SourceResult, error) { fmt.Printf("[perp-cohort][%s][%s] ok: fees30d=%.0f oi=%.0f\n", venueSlug, srcDefillama, fees30, oiNow) } } + + // Fetch per-venue TVL in parallel using /protocol/{slug}. + type tvlResult struct { + venue string + tvl float64 + err error + } + tvlCh := make(chan tvlResult, len(tvlSlugMap)) + var wg sync.WaitGroup + for venueSlug, llamaSlug := range tvlSlugMap { + wg.Add(1) + go func(v, l string) { + defer wg.Done() + tvl, err := s.protocolTVL(l) + tvlCh <- tvlResult{venue: v, tvl: tvl, err: err} + }(venueSlug, llamaSlug) + } + wg.Wait() + close(tvlCh) + for r := range tvlCh { + if r.err != nil { + perpCohortFetchErrors.WithLabelValues(r.venue, srcDefillama, classifyError(r.err.Error())).Inc() + fmt.Printf("[perp-cohort][%s][%s] tvl err: %v\n", r.venue, srcDefillama, r.err) + continue + } + if r.tvl > 0 { + res.SetIfPositive(r.venue, mTVL, r.tvl) + fmt.Printf("[perp-cohort][%s][%s] ok: tvl=%.0f\n", r.venue, srcDefillama, r.tvl) + } + } + return res, nil } +// protocolTVL fetches the /protocol/{slug} endpoint and returns the trading +// TVL in USD, excluding staking and pool2 categories which inflate the +// headline number with non-trading capital. +func (s *DefiLlamaScrapeSource) protocolTVL(slug string) (float64, error) { + url := "https://api.llama.fi/protocol/" + slug + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", "OpenChainBench-PerpCohort/1.0 contact@mobula.io") + req.Header.Set("Accept", "application/json") + resp, err := s.client.Do(req) + if err != nil { + return 0, fmt.Errorf("request_error: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return 0, fmt.Errorf("status_%d", resp.StatusCode) + } + var proto struct { + CurrentChainTvls map[string]float64 `json:"currentChainTvls"` + } + if err := json.Unmarshal(body, &proto); err != nil { + return 0, fmt.Errorf("parse: %w", err) + } + var total float64 + for k, v := range proto.CurrentChainTvls { + k = strings.ToLower(k) + if strings.Contains(k, "staking") || strings.Contains(k, "pool2") { + continue + } + total += v + } + return total, nil +} + func findProto(o *llamaOverview, slug string) *llamaProto { for i := range o.Protocols { if o.Protocols[i].Slug == slug { diff --git a/src/lib/removed-benches.ts b/src/lib/removed-benches.ts index af035797..7c16fb9d 100644 --- a/src/lib/removed-benches.ts +++ b/src/lib/removed-benches.ts @@ -131,6 +131,11 @@ export const REMOVED_BENCH_SLUGS = new Set([ // changed without notice. Specs deleted. "pm-fee-comparison", "pm-geographic-access", + // polymarket-resolution-delay was drafted in a worktree but never merged + // to main. Answer pages originally referenced it; those were updated to + // pm-resolution-delay (2026-08-04). A 308 redirect covers inbound links. + // Adding here prevents stale Redis data from re-appearing in the sitemap. + "polymarket-resolution-delay", ]); /** @@ -147,5 +152,10 @@ export const REMOVED_PRODUCT_SLUGS = new Set([ // dropped 2026-07-24. Product page was live briefly, so Google // likely indexed the URL. 410 speeds de-indexing. "bitquery", + // goldrush (Covalent) was removed from indexing-freshness bench in + // 2026-08-04 data-api report (PR #1758). Stale Redis data can keep + // the provider in getProviders() output while the page 404s; 410 guard + // + sitemap exclusion stop the smoke-gate rollback until Redis flushes. + "goldrush", ]); diff --git a/src/lib/sitemap-builder.ts b/src/lib/sitemap-builder.ts index a8613ed0..b85fb33c 100644 --- a/src/lib/sitemap-builder.ts +++ b/src/lib/sitemap-builder.ts @@ -6,6 +6,7 @@ import { getAllReports, getAllReportCategories } from "@/lib/reports/loader"; import { COMPARE_PAIRS } from "@/data/compare-pairs"; import { adHocPairs } from "@/lib/compare/adhoc-pairs"; import { REMOVED_BENCH_SLUGS } from "@/middleware"; +import { REMOVED_PRODUCT_SLUGS } from "@/lib/removed-benches"; import { isHlBuilderSlug, isHlBuilderWithHistory, @@ -313,6 +314,10 @@ async function buildFullSitemap(): Promise { if (await isHlBuilderSlug(slug)) return null; // Perp venue slugs (except polymarket) 308 to /perp/. if (PERP_PRODUCT_PILL_SLUGS.has(slug) && slug !== "polymarket") return null; + // Providers removed from all benches: stale Redis data can keep them + // in getProviders() while the page 410s. Explicit exclusion here + // prevents the smoke-gate rollback until the cache flushes. + if (REMOVED_PRODUCT_SLUGS.has(slug)) return null; const p = await getProvider(slug); return p ? slug : null; }),