From 028e4d2b94c1ae179b924b88be069af4d4b678af Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Fri, 10 Jul 2026 13:38:34 +0200 Subject: [PATCH] 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`),