From df19a595911f35fc95a7ace4b302a94d8955b7a1 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:27:15 +0200 Subject: [PATCH] fix: N/A not 0% for venues without liq source (GMX, Aevo); fix doc mismatches --- benchmarks/perp-liq-rate.yml | 14 ++-- harnesses/perp-liq-rate/cmd/script/common.go | 4 ++ harnesses/perp-liq-rate/cmd/script/metrics.go | 15 ++++ harnesses/perp-liq-rate/cmd/script/runner.go | 71 +++++++++++-------- .../perp-liq-rate/cmd/script/source_aevo.go | 4 ++ .../perp-liq-rate/cmd/script/source_dydx.go | 3 + .../perp-liq-rate/cmd/script/source_gains.go | 3 + .../perp-liq-rate/cmd/script/source_gmx.go | 8 ++- .../cmd/script/source_hyperliquid.go | 3 + .../cmd/script/source_lighter.go | 3 + .../cmd/script/source_paradex.go | 3 + 11 files changed, 91 insertions(+), 40 deletions(-) diff --git a/benchmarks/perp-liq-rate.yml b/benchmarks/perp-liq-rate.yml index 8628f471..790d1e08 100644 --- a/benchmarks/perp-liq-rate.yml +++ b/benchmarks/perp-liq-rate.yml @@ -60,12 +60,12 @@ methodology: - "Cadence: every 5 minutes, in parallel across all venue/asset pairs." - "Sliding window: liquidation events are stored in a 24-hour ring buffer keyed by a dedup string (trade hash or composite timestamp+size key). Events older than 24 hours are pruned on each tick. The window sum is the numerator of the liq rate." - "Hyperliquid: OI from POST /info {type:metaAndAssetCtxs} (openInterest in base units × midPx). Liquidations from 0xArchive GET /v1/hyperliquid/liquidations/{coin} with cursor pagination, covering all liquidation types. Fallback without OXARCHIVE_API_KEY: userFillsByTime on HLP vault address (backstop-only subset)." - - "dYdX v4: OI and liquidations from native indexer REST. Liquidation events use the dYdX subaccount liquidation endpoint." - - "GMX v2 (Arbitrum): OI from arbitrum-api.gmxinfra.io/markets/info (openInterestLong + openInterestShort per market, 30-decimal USD, summed across all listed markets for the asset). Liquidation source: defunct TheGraph subgraph, returns empty; liq_rate = 0." + - "dYdX v4: OI from GET /v4/perpetualMarkets (openInterest base units * oraclePrice). Liquidations from GET /v4/trades/perpetualMarket/{ticker}?limit=100 paginated backwards, keeping type == LIQUIDATED rows; notional = size * price." + - "GMX v2 (Arbitrum): OI from arbitrum-api.gmxinfra.io/markets/info (openInterestLong + openInterestShort per market, 30-decimal USD, summed across all listed markets for the asset). Liquidation source: unavailable (TheGraph subgraph defunct; Subsquid positionChanges does not expose orderType/isLiquidation). liq_rate and liq_volume are not published for GMX; perp_liq_source_available{venue='gmx'} = 0." - "Lighter: OI from GET /api/v1/orderBookDetails?filter=perp (open_interest in base units × mark_price). Liquidations from Coinalyze /v1/liquidation-history (hourly buckets, symbols 0.T=ETH 1.T=BTC, base asset units × current mark_price). Requires COINALYZE_API_KEY." - "gains.trade (Base): OI from GET backend-base.gains.trade/trading-variables, sum of oiLongCollateral + oiShortCollateral for the USDC collateral at the asset's pair index (BTC=0, ETH=1), divided by 1e6. Liquidations from eth_getLogs on the Gains diamond (0x6cd5ac19...) filtering TradeClosed events with cancelReason=1; notional = collateralAmount/1e6 × leverage/1e3." - - "Aevo: OI from native REST API. Liquidation source: no public API found, returns empty." - - "Paradex: OI and liquidations from native Starknet REST API." + - "Aevo: OI from GET /statistics?asset=BASE&instrument_type=PERPETUAL (open_interest.total contracts * mark_price). Liquidation source: none — Aevo has no public liquidation feed as of 2025-08. liq_rate and liq_volume are not published; perp_liq_source_available{venue='aevo'} = 0." + - "Paradex: OI from GET /v1/markets/summary?market=X (open_interest base units * mark_price). Liquidations from GET /v1/trades?market=X&start_at=&end_at= paginated by cursor, keeping trade_type == LIQUIDATION rows." - "Apple-to-apple: OI = current notional USD (collateral × leverage) at all venues. Liquidation events = notional USD of forcibly closed positions. Both sides use the same definition across all venues with available data." - "GMX caveat: liq_rate is effectively 0% due to missing liquidation data; OI is real and per-asset. Do not compare GMX liq_rate to other venues until a working source is added." @@ -74,7 +74,7 @@ findings: - "{{name:hyperliquid}} shows {{p50:hyperliquid}}% liq rate on a combined ETH+BTC open interest of over $4B. Hyperliquid dominates perpetual DEX volume and its liquidation coverage is the most complete of any venue here, sourced from 0xArchive which captures all liquidation types." - "{{name:lighter}} shows {{p50:lighter}}% liq rate. Liquidation data via Coinalyze is available in hourly buckets; the USD conversion uses the current mark price rather than the exact price at liquidation time, so intra-hour values are approximate." - "{{name:gains}} shows {{p50:gains}}% liq rate on a small Base deployment ($55K ETH OI, $161K BTC OI). Zero liquidations in quiet 24-hour windows is consistent with this OI level." - - "GMX liq_rate shows 0% because the GMX V2 TheGraph subgraph is defunct and no alternative public liquidation source has been identified. OI is correct ($29M ETH, $21M BTC from the gmxinfra markets/info endpoint)." + - "GMX and Aevo show no liquidation rate because no public liquidation data source exists for either venue. GMX OI is correct ($29M ETH, $21M BTC). Aevo OI is correct ($3M ETH). The perp_liq_source_available gauge is 0 for both, so the frontend displays N/A rather than 0%." source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/perp-liq-rate @@ -86,8 +86,8 @@ prometheus: faq: - q: "What is the liquidation rate and why does it matter?" a: "The liquidation rate is the fraction of a venue's open interest that gets forcibly closed in 24 hours. A high rate means many traders are hitting their margin limits and being liquidated by the venue's insurance or backstop system. For a trader it signals how aggressively the venue's risk engine operates and whether the margin buffer they need to survive a volatile session is larger or smaller than on a competing venue." - - q: "Why is GMX liq rate 0%?" - a: "GMX v2 open interest is fetched correctly from the gmxinfra /markets/info endpoint ($29M ETH, $21M BTC). The liquidation numerator is 0 because the TheGraph subgraph used to read GMX V2 liquidation events is defunct and no alternative public API was found. The 0% figure is a data gap, not a claim that GMX has zero liquidations. OI can still be compared across venues." + - q: "Why does GMX show no liquidation rate?" + a: "GMX v2 open interest is fetched correctly from the gmxinfra /markets/info endpoint ($29M ETH, $21M BTC). There is no accessible liquidation source: the TheGraph subgraph is defunct, and the Subsquid positionChanges API does not expose an orderType or isLiquidation flag to distinguish liquidations from voluntary closes. The bench publishes perp_liq_source_available=0 for GMX, so the frontend shows N/A rather than a misleading 0%. OI can still be compared across venues." - q: "How is Lighter liquidation data obtained?" a: "Lighter's /api/v1/trades endpoint requires authentication, so direct liquidation data is not available. The bench uses Coinalyze /v1/liquidation-history for Lighter's exchange ID (symbols 0.T=ETH, 1.T=BTC), which provides hourly buckets of long and short liquidation volume in base asset units. These are converted to USD using the current mark price from orderBookDetails at the time of each 5-minute tick. The USD value of old buckets may be slightly off if the price moved significantly during the hour, but the error is bounded." - q: "How is Hyperliquid liquidation data obtained?" diff --git a/harnesses/perp-liq-rate/cmd/script/common.go b/harnesses/perp-liq-rate/cmd/script/common.go index a78451e5..a062aaeb 100644 --- a/harnesses/perp-liq-rate/cmd/script/common.go +++ b/harnesses/perp-liq-rate/cmd/script/common.go @@ -34,6 +34,10 @@ type LiqEvent struct { type Source interface { FetchLiquidationsSince(asset string, sinceMs int64) ([]LiqEvent, error) FetchOI(asset string) (float64, error) + // HasLiquidationSource reports whether this venue has an actual liquidation + // data source. When false, FetchLiquidationsSince always returns empty and + // the runner must not publish liq_rate or liq_volume (N/A, not 0%). + HasLiquidationSource() bool } // ErrVenueUnavailable marks a venue as temporarily unavailable for this tick diff --git a/harnesses/perp-liq-rate/cmd/script/metrics.go b/harnesses/perp-liq-rate/cmd/script/metrics.go index e4bd2c0d..85493f86 100644 --- a/harnesses/perp-liq-rate/cmd/script/metrics.go +++ b/harnesses/perp-liq-rate/cmd/script/metrics.go @@ -46,6 +46,11 @@ var ( Name: "perp_liq_fetch_errors_total", Help: "Fetch/decode errors per venue, asset and error type.", }, []string{"venue", "chain", "error_type"}) + + liqSourceAvailable = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "perp_liq_source_available", + Help: "1 if a liquidation data source exists for the venue, 0 if liq_rate is structurally unavailable (not a data gap — use to display N/A instead of 0%).", + }, []string{"venue"}) ) // registerMetrics builds a dedicated registry containing only this @@ -60,6 +65,7 @@ func registerMetrics() *prometheus.Registry { liqHealth, liqLastRefresh, liqFetchErrors, + liqSourceAvailable, ) return reg } @@ -86,6 +92,15 @@ func recordFetchError(venue, asset, errType string) { liqFetchErrors.WithLabelValues(venue, asset, errType).Inc() } +// setSourceAvailable publishes whether a liquidation source exists for the venue. +func setSourceAvailable(venue string, available bool) { + v := 0.0 + if available { + v = 1.0 + } + liqSourceAvailable.WithLabelValues(venue).Set(v) +} + // setVenueHealth publishes venue health (1 healthy / 0 degraded). func setVenueHealth(venue string, healthy bool) { v := 0.0 diff --git a/harnesses/perp-liq-rate/cmd/script/runner.go b/harnesses/perp-liq-rate/cmd/script/runner.go index ecec03fd..225530e5 100644 --- a/harnesses/perp-liq-rate/cmd/script/runner.go +++ b/harnesses/perp-liq-rate/cmd/script/runner.go @@ -29,29 +29,35 @@ func runTick(va VenueAsset, w *SlidingWindow, seen *SeenSet, sinceMs int64) bool w.MarkTick(now) ok := true + hasLiqSource := va.Source.HasLiquidationSource() + setSourceAvailable(va.Venue, hasLiqSource) - events, liqErr := va.Source.FetchLiquidationsSince(va.Asset, sinceMs) - if liqErr != nil { - handleFetchError(va, "liquidations", liqErr) - ok = false - } else { - added := 0 - for _, e := range events { - if e.Key == "" || e.TimestampMs <= 0 || e.NotionalUSD <= 0 { - continue - } - if e.TimestampMs < cutoffMs { - continue // older than the window; irrelevant + var liqErr error + if hasLiqSource { + var events []LiqEvent + events, liqErr = va.Source.FetchLiquidationsSince(va.Asset, sinceMs) + if liqErr != nil { + handleFetchError(va, "liquidations", liqErr) + ok = false + } else { + added := 0 + for _, e := range events { + if e.Key == "" || e.TimestampMs <= 0 || e.NotionalUSD <= 0 { + continue + } + if e.TimestampMs < cutoffMs { + continue // older than the window; irrelevant + } + if seen.Add(e.Key, e.TimestampMs) { + w.Add(e.TimestampMs, e.NotionalUSD) + added++ + } } - if seen.Add(e.Key, e.TimestampMs) { - w.Add(e.TimestampMs, e.NotionalUSD) - added++ + if added > 0 { + log.Printf("[%s/%s] +%d liquidation event(s), window now %d event(s)", + va.Venue, va.Asset, added, w.Len()) } } - if added > 0 { - log.Printf("[%s/%s] +%d liquidation event(s), window now %d event(s)", - va.Venue, va.Asset, added, w.Len()) - } } w.Prune(nowMs) @@ -63,20 +69,23 @@ func runTick(va VenueAsset, w *SlidingWindow, seen *SeenSet, sinceMs int64) bool ok = false } - // Publish. Volume is valid whenever the liquidation fetch succeeded; the - // rate additionally needs a positive OI. On failure the previous gauge - // values are kept as-is (spec: "on error keep previous gauge"). - if liqErr == nil { + // Publish OI unconditionally (all venues have OI). + // Publish liq_volume and liq_rate only for venues with a liquidation source — + // absent series display as N/A in the frontend, not as 0%. + if oiErr == nil { + if oi > 0 { + liqOpenInterest.WithLabelValues(va.Venue, va.Asset).Set(oi) + } else { + recordFetchError(va.Venue, va.Asset, "oi_zero") + log.Printf("[%s/%s] OI endpoint returned non-positive value %.4f; keeping previous OI gauge", va.Venue, va.Asset, oi) + ok = false + } + } + if hasLiqSource && liqErr == nil { volume := w.Sum() setLiqVolume(va.Venue, va.Asset, volume) - if oiErr == nil { - if oi > 0 { - setOIAndRate(va.Venue, va.Asset, volume, oi) - } else { - recordFetchError(va.Venue, va.Asset, "oi_zero") - log.Printf("[%s/%s] OI endpoint returned non-positive value %.4f; keeping previous OI/rate gauges", va.Venue, va.Asset, oi) - ok = false - } + if oiErr == nil && oi > 0 { + liqRate.WithLabelValues(va.Venue, va.Asset).Set(volume / oi * 100) } } diff --git a/harnesses/perp-liq-rate/cmd/script/source_aevo.go b/harnesses/perp-liq-rate/cmd/script/source_aevo.go index 1d1218c2..dfc20759 100644 --- a/harnesses/perp-liq-rate/cmd/script/source_aevo.go +++ b/harnesses/perp-liq-rate/cmd/script/source_aevo.go @@ -27,6 +27,10 @@ type Aevo struct { // NewAevo returns the Aevo source. func NewAevo() *Aevo { return &Aevo{baseURL: aevoBaseURL} } +// HasLiquidationSource reports false — Aevo has no public liquidation feed. +// liq_rate is not published (N/A, not 0%). +func (a *Aevo) HasLiquidationSource() bool { return false } + // FetchLiquidationsSince returns empty — Aevo has no public liquidation feed. func (a *Aevo) FetchLiquidationsSince(asset string, sinceMs int64) ([]LiqEvent, error) { if _, ok := aevoInstruments[asset]; !ok { diff --git a/harnesses/perp-liq-rate/cmd/script/source_dydx.go b/harnesses/perp-liq-rate/cmd/script/source_dydx.go index f47362a5..7c89cbab 100644 --- a/harnesses/perp-liq-rate/cmd/script/source_dydx.go +++ b/harnesses/perp-liq-rate/cmd/script/source_dydx.go @@ -45,6 +45,9 @@ type dydxTradesResp struct { Trades []dydxTrade `json:"trades"` } +// HasLiquidationSource reports true — public trade tape exposes LIQUIDATED type. +func (d *Dydx) HasLiquidationSource() bool { return true } + // FetchLiquidationsSince pages the trade feed backwards until sinceMs. func (d *Dydx) FetchLiquidationsSince(asset string, sinceMs int64) ([]LiqEvent, error) { ticker, ok := dydxTickers[asset] diff --git a/harnesses/perp-liq-rate/cmd/script/source_gains.go b/harnesses/perp-liq-rate/cmd/script/source_gains.go index fae27a98..b44af2f6 100644 --- a/harnesses/perp-liq-rate/cmd/script/source_gains.go +++ b/harnesses/perp-liq-rate/cmd/script/source_gains.go @@ -158,6 +158,9 @@ type ethLog struct { } // FetchLiquidationsSince scans TradeClosed logs from lastBlock+1 (first tick: +// HasLiquidationSource reports true — TradeClosed on-chain logs give full coverage. +func (g *Gains) HasLiquidationSource() bool { return true } + // latest-43200) to latest and returns those decoded as liquidations of the // requested asset. sinceMs is unused: block cursoring replaces it here. func (g *Gains) FetchLiquidationsSince(asset string, _ int64) ([]LiqEvent, error) { diff --git a/harnesses/perp-liq-rate/cmd/script/source_gmx.go b/harnesses/perp-liq-rate/cmd/script/source_gmx.go index 66b5f78f..e1f32560 100644 --- a/harnesses/perp-liq-rate/cmd/script/source_gmx.go +++ b/harnesses/perp-liq-rate/cmd/script/source_gmx.go @@ -78,8 +78,12 @@ func gmxAssetMatches(marketName, asset string) bool { strings.HasPrefix(upper, a+"-") } -// FetchLiquidationsSince returns empty — no public GMX V2 liquidation source -// is available (TheGraph subgraph defunct, no REST alternative found). +// HasLiquidationSource reports false — GMX has no accessible liquidation source. +// TheGraph subgraph is defunct; Subsquid positionChanges does not expose an +// orderType/isLiquidation flag. liq_rate is not published (N/A, not 0%). +func (g *GMX) HasLiquidationSource() bool { return false } + +// FetchLiquidationsSince returns empty — GMX has no accessible liquidation source. func (g *GMX) FetchLiquidationsSince(asset string, _ int64) ([]LiqEvent, error) { if !gmxTrackedAssets[asset] { return nil, fmt.Errorf("gmx: unsupported asset %q", asset) diff --git a/harnesses/perp-liq-rate/cmd/script/source_hyperliquid.go b/harnesses/perp-liq-rate/cmd/script/source_hyperliquid.go index 2aebdf22..a0e206ca 100644 --- a/harnesses/perp-liq-rate/cmd/script/source_hyperliquid.go +++ b/harnesses/perp-liq-rate/cmd/script/source_hyperliquid.go @@ -148,6 +148,9 @@ func (h *Hyperliquid) fetchOxaLiquidations(coin string, sinceMs int64) ([]LiqEve return events, nil } +// HasLiquidationSource reports true — 0xArchive or vault fallback is always available. +func (h *Hyperliquid) HasLiquidationSource() bool { return true } + // FetchLiquidationsSince returns liquidation events newer than sinceMs. // Uses 0xArchive when OXARCHIVE_API_KEY is set; otherwise falls back to the // HLP liquidator vault (backstop liquidations only). diff --git a/harnesses/perp-liq-rate/cmd/script/source_lighter.go b/harnesses/perp-liq-rate/cmd/script/source_lighter.go index 306d3b7a..d4db5ef5 100644 --- a/harnesses/perp-liq-rate/cmd/script/source_lighter.go +++ b/harnesses/perp-liq-rate/cmd/script/source_lighter.go @@ -114,6 +114,9 @@ func (l *Lighter) findMarket(asset string) (lighterMarketDetail, error) { } // FetchLiquidationsSince returns hourly liquidation buckets from Coinalyze, +// HasLiquidationSource reports true — Coinalyze provides hourly liq buckets. +func (l *Lighter) HasLiquidationSource() bool { return true } + // converted to USD using the current mark_price from orderBookDetails. // Returns empty if COINALYZE_API_KEY is not set. func (l *Lighter) FetchLiquidationsSince(asset string, sinceMs int64) ([]LiqEvent, error) { diff --git a/harnesses/perp-liq-rate/cmd/script/source_paradex.go b/harnesses/perp-liq-rate/cmd/script/source_paradex.go index f6bce148..397d4f05 100644 --- a/harnesses/perp-liq-rate/cmd/script/source_paradex.go +++ b/harnesses/perp-liq-rate/cmd/script/source_paradex.go @@ -47,6 +47,9 @@ type paradexTradesResp struct { Next *string `json:"next"` } +// HasLiquidationSource reports true — public trade tape exposes LIQUIDATION trade_type. +func (p *Paradex) HasLiquidationSource() bool { return true } + // FetchLiquidationsSince pages the public trade tape and keeps LIQUIDATION rows. func (p *Paradex) FetchLiquidationsSince(asset string, sinceMs int64) ([]LiqEvent, error) { market, ok := paradexMarkets[asset]