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
146 changes: 146 additions & 0 deletions benchmarks/perp-capital-efficiency.yml
Original file line number Diff line number Diff line change
@@ -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
16 changes: 15 additions & 1 deletion harnesses/perp-cohort-stats/cmd/script/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}

Expand Down
8 changes: 8 additions & 0 deletions harnesses/perp-cohort-stats/cmd/script/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -137,6 +144,7 @@ func init() {
perpVenueVolume24hUsd, perpVenueVolume30dUsd, perpVenueOIUsd,
perpVenueFees30dUsd, perpVenueActiveMarkets, perpVenueTopMarketVolume24hUsd,
perpVenueMarketsByClass, perpVenueNoncoreMarketsTotal,
perpVenueTvlUsd,
perpVenueHealth, perpVenueFunding24hBps, perpVenueFundingIntervalHours,
perpVenueLastRefreshUnix,
perpCohortFetchErrors, perpCohortSourceUsed, perpCohortDataDivergence,
Expand Down
89 changes: 89 additions & 0 deletions harnesses/perp-cohort-stats/cmd/script/source_defillama.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)

Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions src/lib/removed-benches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]);

/**
Expand All @@ -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",
]);

5 changes: 5 additions & 0 deletions src/lib/sitemap-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -313,6 +314,10 @@ async function buildFullSitemap(): Promise<MetadataRoute.Sitemap> {
if (await isHlBuilderSlug(slug)) return null;
// Perp venue slugs (except polymarket) 308 to /perp/<slug>.
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;
}),
Expand Down
Loading