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
14 changes: 7 additions & 7 deletions benchmarks/perp-liq-rate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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=<ms>&end_at=<ms> 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."

Expand All @@ -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

Expand All @@ -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?"
Expand Down
4 changes: 4 additions & 0 deletions harnesses/perp-liq-rate/cmd/script/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions harnesses/perp-liq-rate/cmd/script/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -60,6 +65,7 @@ func registerMetrics() *prometheus.Registry {
liqHealth,
liqLastRefresh,
liqFetchErrors,
liqSourceAvailable,
)
return reg
}
Expand All @@ -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
Expand Down
71 changes: 40 additions & 31 deletions harnesses/perp-liq-rate/cmd/script/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
}

Expand Down
4 changes: 4 additions & 0 deletions harnesses/perp-liq-rate/cmd/script/source_aevo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions harnesses/perp-liq-rate/cmd/script/source_dydx.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
3 changes: 3 additions & 0 deletions harnesses/perp-liq-rate/cmd/script/source_gains.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 6 additions & 2 deletions harnesses/perp-liq-rate/cmd/script/source_gmx.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions harnesses/perp-liq-rate/cmd/script/source_hyperliquid.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
3 changes: 3 additions & 0 deletions harnesses/perp-liq-rate/cmd/script/source_lighter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions harnesses/perp-liq-rate/cmd/script/source_paradex.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading