diff --git a/benchmarks/perp-pe-ratio.yml b/benchmarks/perp-pe-ratio.yml index 770ca15d9..90fd9ffdf 100644 --- a/benchmarks/perp-pe-ratio.yml +++ b/benchmarks/perp-pe-ratio.yml @@ -2,9 +2,9 @@ slug: perp-pe-ratio number: "234" -title: "Perp DEX token P/E ratio 2026: Hyperliquid, GMX, dYdX, GNS live" +title: "Perp DEX token P/E ratio 2026: Hyperliquid, Lighter, GMX, dYdX, GNS live" seo_title: "Perp DEX token P/E ratio 2026: Hyperliquid vs GMX vs dYdX vs GNS" -seo_description: "{{best_name}} has the lowest P/E ratio at {{best_p50}}x among perp DEX tokens. FDV divided by annualized protocol revenue for Hyperliquid, GMX, Gains, dYdX, Drift, Ostium. Live from DeFiLlama and CoinGecko, updated hourly." +seo_description: "{{best_name}} has the lowest P/E ratio at {{best_p50}}x among perp DEX tokens. FDV divided by annualized protocol revenue for Hyperliquid, Lighter, Jupiter, GMX, Gains, dYdX, ApeX, SynFutures, Derive, Drift. Live from DeFiLlama and CoinGecko, updated hourly." subtitle: FDV divided by annualized protocol revenue (30-day trailing average) for the major perp DEX tokens. Lower is cheaper relative to earnings. Updated hourly from DeFiLlama and CoinGecko. category: Trading @@ -36,10 +36,11 @@ abstract: | methodology: - "Revenue source: DeFiLlama dailyRevenue (protocol revenue accruing to treasury/holders, not total user fees)." - - "30-day trailing average: last 30 data points from totalDataChart, filtered for non-zero values." - - "Annual revenue: 30d avg x 365." - - "FDV source: CoinGecko /coins/markets fully_diluted_valuation (falls back to market_cap if FDV null)." + - "Annual revenue: sum of the last 30 daily points of totalDataChart x 365/30. Zero days inside the window count, so an adapter gap lowers the run rate instead of hiding it." + - "Revenue scope: the DeFiLlama parent the token accrues from (Jupiter, Hyperliquid, GMX, Lighter, ApeX, Derive span several products), so FDV and revenue share one scope." + - "FDV source: CoinGecko /coins/markets fully_diluted_valuation (falls back to market_cap if FDV null). dYdX is priced on the dYdX Chain token (CoinGecko dydx-chain)." - "P/E = FDV / annual_revenue. Null if FDV or revenue unavailable." + - "Protocols whose adapter publishes no revenue series (Aster, Avantis, Orderly) or reports zero (Drift perps) have no P/E; see bench 265 for their price to fees." - "Poll cadence: harness fetches DeFiLlama + CoinGecko every 60 minutes. Prometheus scrapes :2112/metrics every 60s." findings: @@ -129,3 +130,68 @@ providers: success: perp_protocol_health{protocol="drift"} sample_size: perp_protocol_pe_ratio{protocol="drift"} series: perp_protocol_pe_ratio{protocol="drift"} + + - slug: lighter + name: LIT + tag: "Lighter, parent scope" + formula: "P/E = CoinGecko FDV / (DeFiLlama 30d revenue x 365/30)" + queries: + p50: perp_protocol_pe_ratio{protocol="lighter"} + p90: perp_protocol_pe_ratio{protocol="lighter"} + p99: perp_protocol_pe_ratio{protocol="lighter"} + mean: perp_protocol_pe_ratio{protocol="lighter"} + success: perp_protocol_health{protocol="lighter"} + sample_size: perp_protocol_pe_ratio{protocol="lighter"} + series: perp_protocol_pe_ratio{protocol="lighter"} + + - slug: jupiter + name: JUP + tag: "Jupiter, parent scope: all products" + formula: "P/E = CoinGecko FDV / (DeFiLlama 30d revenue x 365/30)" + queries: + p50: perp_protocol_pe_ratio{protocol="jupiter"} + p90: perp_protocol_pe_ratio{protocol="jupiter"} + p99: perp_protocol_pe_ratio{protocol="jupiter"} + mean: perp_protocol_pe_ratio{protocol="jupiter"} + success: perp_protocol_health{protocol="jupiter"} + sample_size: perp_protocol_pe_ratio{protocol="jupiter"} + series: perp_protocol_pe_ratio{protocol="jupiter"} + + - slug: apex + name: APEX + tag: "ApeX, parent scope" + formula: "P/E = CoinGecko FDV / (DeFiLlama 30d revenue x 365/30)" + queries: + p50: perp_protocol_pe_ratio{protocol="apex"} + p90: perp_protocol_pe_ratio{protocol="apex"} + p99: perp_protocol_pe_ratio{protocol="apex"} + mean: perp_protocol_pe_ratio{protocol="apex"} + success: perp_protocol_health{protocol="apex"} + sample_size: perp_protocol_pe_ratio{protocol="apex"} + series: perp_protocol_pe_ratio{protocol="apex"} + + - slug: synfutures + name: F + tag: "SynFutures" + formula: "P/E = CoinGecko FDV / (DeFiLlama 30d revenue x 365/30)" + queries: + p50: perp_protocol_pe_ratio{protocol="synfutures"} + p90: perp_protocol_pe_ratio{protocol="synfutures"} + p99: perp_protocol_pe_ratio{protocol="synfutures"} + mean: perp_protocol_pe_ratio{protocol="synfutures"} + success: perp_protocol_health{protocol="synfutures"} + sample_size: perp_protocol_pe_ratio{protocol="synfutures"} + series: perp_protocol_pe_ratio{protocol="synfutures"} + + - slug: derive + name: DRV + tag: "Derive, parent scope: perps and options" + formula: "P/E = CoinGecko FDV / (DeFiLlama 30d revenue x 365/30)" + queries: + p50: perp_protocol_pe_ratio{protocol="derive"} + p90: perp_protocol_pe_ratio{protocol="derive"} + p99: perp_protocol_pe_ratio{protocol="derive"} + mean: perp_protocol_pe_ratio{protocol="derive"} + success: perp_protocol_health{protocol="derive"} + sample_size: perp_protocol_pe_ratio{protocol="derive"} + series: perp_protocol_pe_ratio{protocol="derive"} diff --git a/benchmarks/perp-pf-ratio.yml b/benchmarks/perp-pf-ratio.yml new file mode 100644 index 000000000..534fc08af --- /dev/null +++ b/benchmarks/perp-pf-ratio.yml @@ -0,0 +1,430 @@ +# OpenChainBench. Bench 265 + +slug: perp-pf-ratio +number: "265" +title: "Perp DEX price to fees ratio 2026: Hyperliquid, Aster, Lighter, GMX, GNS live" +seo_title: "Perp DEX P/F ratio 2026: Hyperliquid vs Lighter vs Aster vs GMX vs GNS" +seo_description: "{{best_name}} is the cheapest perp DEX token at {{best_p50}} annualized fees. P/F, FDV/F, P/S, float and open interest for {{count}} perp DEXes, live from DeFiLlama and CoinGecko." +subtitle: Market cap divided by annualized trading fees (trailing 30 days) for every perp DEX with a listed token, alongside FDV, protocol revenue, float and open interest. Lower means the market pays less per dollar of fees. Same convention as the DeFiLlama P/F ranking, updated hourly. + +category: Trading +status: live +metric: P/F ratio +unit: x +higher_is_better: false + +disclaimer: | + P/F = circulating market cap (CoinGecko) / annualized fees (DeFiLlama dailyFees, trailing 30d x 365/30), on the DeFiLlama parent the token accrues from. Fees include the share paid to liquidity providers; P/S uses protocol revenue only. Low-float tokens show a much higher FDV/F than P/F. Pre-TGE venues are listed unranked with fees and OI, no ratio. A low multiple is not a recommendation. + +seo_intro: | + The price to fees ratio for perp DEX tokens measures how many years of current trading fees + the market is paying for. It is the on-chain cousin of a price to sales multiple: market cap on + top, annualized fees at the bottom. A low P/F means the token is cheap relative to the fees + its venue generates; a high P/F prices in growth, or a token that does not capture the fees. + Hyperliquid, Aster and Lighter generate the most absolute fees but carry multi-billion + valuations with a quarter to a third of supply in circulation, so their FDV/F runs far above + their P/F. GMX and Gains are fully circulating with real fee flow and trade at low single-digit + multiples. dYdX and Aster sit at the expensive end on today's fee run rate; Paradex's multiple + is inflated by an incomplete DeFiLlama fee adapter. This benchmark tracks the whole table live + from DeFiLlama (fees, revenue, open interest) and CoinGecko (market cap, FDV, supply), the same + inputs as defillama.com/pf, cut identically for every protocol and refreshed every hour. + +abstract: | + The perp-token-metrics harness polls DeFiLlama /summary/fees/[slug] for dailyFees and + dailyRevenue every hour and cuts the trailing 30, 60 and 365 day windows itself from + totalDataChart, so every protocol is annualized the same way (30d sum x 365/30) regardless + of which convenience fields its adapter fills. It fetches market cap, fully diluted valuation + and circulating vs total supply from CoinGecko /coins/markets in one batch, and open interest + from the DeFiLlama open-interest overview. P/F = mcap / annualized fees, FDV/F = FDV / annualized + fees, P/S = mcap / annualized revenue. Ratios whose denominator is zero or whose token has no + listing are not published, so a missing value never reads as a zero valuation. Gauges are + perp_protocol_pf_ratio, perp_protocol_pf_fdv_ratio, perp_protocol_ps_ratio, + perp_protocol_fees_30d_usd, perp_protocol_rev_30d_usd, perp_protocol_mcap_usd, + perp_protocol_fdv_usd, perp_protocol_float_pct and perp_protocol_oi_usd, one series per protocol. + +methodology: + - "Fees source: DeFiLlama dailyFees for the token's parent entity (all products the token accrues from), so market cap and fees share one scope. Perps-only fees are published separately as perp_protocol_perp_fees_30d_usd." + - "Annualized fees: sum of the last 30 daily points x 365/30. Zero days inside the window count; the current partial day is included, as in DeFiLlama's own 30d figure." + - "Market cap and FDV: CoinGecko /coins/markets circulating market cap and fully_diluted_valuation (market cap when FDV is null). Float = circulating / total supply." + - "P/F = market cap / annualized fees (DeFiLlama P/F convention). FDV/F = FDV / annualized fees. P/S = market cap / annualized protocol revenue (DeFiLlama dailyRevenue). Bench 234 keeps P/E = FDV / annualized revenue." + - "Revenue share = 30d revenue / 30d fees. Adapters that publish no revenue series, or a flat zero, leave P/S and revenue share undefined rather than zero." + - "Open interest: DeFiLlama open-interest overview, perps product only. Fees to OI = annualized fees / open interest." + - "Drift's DeFiLlama perps adapter (drift-trade) currently reports zero fees; the row stays in the cohort with every ratio undefined until the source recovers." + - "Pre-TGE venues (Ostium, Pacifica, Extended, Nado, Decibel) are declared `unranked` in the spec: they sit under the ranked field with their fees and open interest, are excluded from ranks, leader claims and the best / median / worst strip, and gain a ratio the day a token lists." + - "edgeX (EDGE) and Paradex (DIME) have listed tokens and are ranked. DeFiLlama's fee adapter reports very low fees for Paradex, which inflates its multiple; treat that row with caution until the adapter is fixed upstream." + - "Poll cadence: DeFiLlama and CoinGecko every 60 minutes. Prometheus scrapes :2112/metrics every 60s." + +findings: + - "{{best_name}} is the cheapest perp DEX token at {{best_p50}} annualized fees." + - "Hyperliquid, Aster and Lighter earn the most fees in absolute terms but trade at a large multiple, and their FDV/F is three to four times their P/F because most supply is not circulating." + - "GMX and Gains are fully circulating, so their P/F and FDV/F coincide; both sit in low single digits." + - "{{worst_name}} is the most expensive token in the cohort at {{worst_p50}} on today's fee run rate." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/perp-token-metrics + +prometheus: + window: 24h + freshness_metric: perp_protocol_health + +panel_main_description: "Circulating market cap divided by annualized fees (trailing 30 days x 365/30), the DeFiLlama P/F convention. Lower means the market pays less per dollar of fees the venue generates." + +metric_panels: + - id: pf_fdv + label: FDV / fees + label_key: protocol + metric: perp_protocol_pf_fdv_ratio + unit: x + description: "Fully diluted valuation divided by annualized fees. The conservative multiple: it prices unvested supply as if it were circulating." + - id: ps + label: P/S + label_key: protocol + metric: perp_protocol_ps_ratio + unit: x + description: "Market cap divided by annualized protocol revenue (the share of fees that reaches the protocol, treasury or token holders)." + - id: fees_30d + label: Fees 30d + label_key: protocol + metric: perp_protocol_fees_30d_usd + unit: usd + higher_is_better: true + description: "Trailing 30 day fees paid by traders, token scope (parent protocol). Includes venues without a token." + - id: rev_30d + label: Revenue 30d + label_key: protocol + metric: perp_protocol_rev_30d_usd + unit: usd + higher_is_better: true + description: "Trailing 30 day protocol revenue per DeFiLlama dailyRevenue. Absent when the adapter publishes fees only." + - id: rev_share + label: Revenue share + label_key: protocol + metric: perp_protocol_rev_share_pct + unit: pct + higher_is_better: true + tab: false + description: "Share of 30 day fees retained as protocol revenue." + - id: mcap + label: Market cap + label_key: protocol + metric: perp_protocol_mcap_usd + unit: usd + tab: false + description: "Circulating market cap from CoinGecko." + - id: fdv + label: FDV + label_key: protocol + metric: perp_protocol_fdv_usd + unit: usd + tab: false + description: "Fully diluted valuation from CoinGecko." + - id: float_pct + label: Float + label_key: protocol + metric: perp_protocol_float_pct + unit: pct + higher_is_better: true + description: "Circulating supply as a percent of total supply. The gap between P/F and FDV/F is this number." + - id: oi + label: Open interest + label_key: protocol + metric: perp_protocol_oi_usd + unit: usd + higher_is_better: true + description: "Open interest on the perps product from the DeFiLlama open-interest overview." + - id: fees_to_oi + label: Fees / OI + label_key: protocol + metric: perp_protocol_fees_to_oi_ratio + unit: x + higher_is_better: true + description: "Annualized fees divided by open interest: how much fee the venue extracts per dollar of positions held. High values flag either high take rates or churned, possibly incentivized, volume." + +ledger_columns: + - { label: "P/F", slot: p50 } + - { label: "FDV / fees", panel: pf_fdv } + - { label: "P/S", panel: ps } + - { label: "Fees 30d", panel: fees_30d } + - { label: "Market cap", panel: mcap } + - { label: "Float", panel: float_pct } + +faq: + - q: "What is the price to fees ratio of a perp DEX?" + a: "Market cap divided by the fees the venue generates in a year, using the trailing 30 days annualized. It answers how many years of current fee flow the market is paying for. It is the same P/F that defillama.com/pf ranks, computed here per protocol with identical windows and published as a Prometheus series you can query." + - q: "Why show both P/F and FDV/F?" + a: "P/F uses circulating market cap, so a token with 25 percent of supply in circulation looks four times cheaper than the same token valued on fully diluted supply. FDV/F prices unvested allocations as if they were already trading. Neither is wrong; the float column tells you how far apart they are for each token." + - q: "How do fees differ from revenue here?" + a: "Fees are everything traders paid, including the share that goes to liquidity providers, vaults or referrers. Revenue is the part that reaches the protocol, its treasury or token holders. Hyperliquid routes almost all fees to HYPE buybacks so its P/F and P/S are close; GMX passes most fees to GLP and GM depositors, so its P/S is well above its P/F." + - q: "Why does Jupiter's fee number include more than perps?" + a: "JUP is one token for the aggregator, the perps exchange, lending and launchpad. Comparing its market cap against perps fees alone would overstate the multiple. The bench measures every token against the fees of the DeFiLlama parent it accrues from and publishes the perps-only figure separately." + - q: "Why are Ostium, Pacifica, Extended, Nado and Decibel listed without a ratio?" + a: "They have not launched a token yet, so there is no market cap to divide. They are listed unranked, below the field, with their fees and open interest: that is the fee base a future token would be valued on, and the row gains a ratio the day it lists." + - q: "Is a low P/F a buy signal?" + a: "No. It can mean the market expects fees to fall, that fees were inflated by incentivized or wash volume, or that the token has no claim on the fees. Read it with the float, the revenue share and the month over month fee trend, then with your own research." + - q: "How is this different from bench 234, the perp DEX P/E ratio?" + a: "Bench 234 divides FDV by protocol revenue: the strictest multiple, on the money that actually reaches holders. This bench divides market cap by total fees, the broader and more common convention. The same harness computes both from the same inputs every hour." + +providers: + - slug: hyperliquid + name: HYPE + tag: "Hyperliquid, parent scope: perps, spot, HLP" + formula: "P/F = CoinGecko circulating market cap / (DeFiLlama 30d fees x 365/30), parent scope" + queries: + p50: perp_protocol_pf_ratio{protocol="hyperliquid"} + p90: perp_protocol_pf_ratio{protocol="hyperliquid"} + p99: perp_protocol_pf_ratio{protocol="hyperliquid"} + mean: perp_protocol_pf_ratio{protocol="hyperliquid"} + success: perp_protocol_health{protocol="hyperliquid"} + sample_size: perp_protocol_fees_30d_usd{protocol="hyperliquid"} + series: perp_protocol_pf_ratio{protocol="hyperliquid"} + + - slug: aster + name: ASTER + tag: "Aster, BNB Chain perps" + formula: "P/F = CoinGecko circulating market cap / (DeFiLlama 30d fees x 365/30), parent scope" + queries: + p50: perp_protocol_pf_ratio{protocol="aster"} + p90: perp_protocol_pf_ratio{protocol="aster"} + p99: perp_protocol_pf_ratio{protocol="aster"} + mean: perp_protocol_pf_ratio{protocol="aster"} + success: perp_protocol_health{protocol="aster"} + sample_size: perp_protocol_fees_30d_usd{protocol="aster"} + series: perp_protocol_pf_ratio{protocol="aster"} + + - slug: lighter + name: LIT + tag: "Lighter, parent scope: Lighter and Robinhood perps" + formula: "P/F = CoinGecko circulating market cap / (DeFiLlama 30d fees x 365/30), parent scope" + queries: + p50: perp_protocol_pf_ratio{protocol="lighter"} + p90: perp_protocol_pf_ratio{protocol="lighter"} + p99: perp_protocol_pf_ratio{protocol="lighter"} + mean: perp_protocol_pf_ratio{protocol="lighter"} + success: perp_protocol_health{protocol="lighter"} + sample_size: perp_protocol_fees_30d_usd{protocol="lighter"} + series: perp_protocol_pf_ratio{protocol="lighter"} + + - slug: jupiter + name: JUP + tag: "Jupiter, parent scope: all Jupiter products" + formula: "P/F = CoinGecko circulating market cap / (DeFiLlama 30d fees x 365/30), parent scope" + queries: + p50: perp_protocol_pf_ratio{protocol="jupiter"} + p90: perp_protocol_pf_ratio{protocol="jupiter"} + p99: perp_protocol_pf_ratio{protocol="jupiter"} + mean: perp_protocol_pf_ratio{protocol="jupiter"} + success: perp_protocol_health{protocol="jupiter"} + sample_size: perp_protocol_fees_30d_usd{protocol="jupiter"} + series: perp_protocol_pf_ratio{protocol="jupiter"} + + - slug: gmx + name: GMX + tag: "GMX, parent scope: GMX v1 and v2" + formula: "P/F = CoinGecko circulating market cap / (DeFiLlama 30d fees x 365/30), parent scope" + queries: + p50: perp_protocol_pf_ratio{protocol="gmx"} + p90: perp_protocol_pf_ratio{protocol="gmx"} + p99: perp_protocol_pf_ratio{protocol="gmx"} + mean: perp_protocol_pf_ratio{protocol="gmx"} + success: perp_protocol_health{protocol="gmx"} + sample_size: perp_protocol_fees_30d_usd{protocol="gmx"} + series: perp_protocol_pf_ratio{protocol="gmx"} + + - slug: gains + name: GNS + tag: "Gains, gTrade, multi-chain" + formula: "P/F = CoinGecko circulating market cap / (DeFiLlama 30d fees x 365/30), parent scope" + queries: + p50: perp_protocol_pf_ratio{protocol="gains"} + p90: perp_protocol_pf_ratio{protocol="gains"} + p99: perp_protocol_pf_ratio{protocol="gains"} + mean: perp_protocol_pf_ratio{protocol="gains"} + success: perp_protocol_health{protocol="gains"} + sample_size: perp_protocol_fees_30d_usd{protocol="gains"} + series: perp_protocol_pf_ratio{protocol="gains"} + + - slug: dydx + name: DYDX + tag: "dYdX, dYdX chain" + formula: "P/F = CoinGecko circulating market cap / (DeFiLlama 30d fees x 365/30), parent scope" + queries: + p50: perp_protocol_pf_ratio{protocol="dydx"} + p90: perp_protocol_pf_ratio{protocol="dydx"} + p99: perp_protocol_pf_ratio{protocol="dydx"} + mean: perp_protocol_pf_ratio{protocol="dydx"} + success: perp_protocol_health{protocol="dydx"} + sample_size: perp_protocol_fees_30d_usd{protocol="dydx"} + series: perp_protocol_pf_ratio{protocol="dydx"} + + - slug: drift + name: DRIFT + tag: "Drift, Solana; DeFiLlama perps adapter reports zero" + formula: "P/F = CoinGecko circulating market cap / (DeFiLlama 30d fees x 365/30), parent scope" + queries: + p50: perp_protocol_pf_ratio{protocol="drift"} + p90: perp_protocol_pf_ratio{protocol="drift"} + p99: perp_protocol_pf_ratio{protocol="drift"} + mean: perp_protocol_pf_ratio{protocol="drift"} + success: perp_protocol_health{protocol="drift"} + sample_size: perp_protocol_fees_30d_usd{protocol="drift"} + series: perp_protocol_pf_ratio{protocol="drift"} + + - slug: avantis + name: AVNT + tag: "Avantis, Base perps" + formula: "P/F = CoinGecko circulating market cap / (DeFiLlama 30d fees x 365/30), parent scope" + queries: + p50: perp_protocol_pf_ratio{protocol="avantis"} + p90: perp_protocol_pf_ratio{protocol="avantis"} + p99: perp_protocol_pf_ratio{protocol="avantis"} + mean: perp_protocol_pf_ratio{protocol="avantis"} + success: perp_protocol_health{protocol="avantis"} + sample_size: perp_protocol_fees_30d_usd{protocol="avantis"} + series: perp_protocol_pf_ratio{protocol="avantis"} + + - slug: apex + name: APEX + tag: "ApeX, parent scope: ApeX Omni and Pro" + formula: "P/F = CoinGecko circulating market cap / (DeFiLlama 30d fees x 365/30), parent scope" + queries: + p50: perp_protocol_pf_ratio{protocol="apex"} + p90: perp_protocol_pf_ratio{protocol="apex"} + p99: perp_protocol_pf_ratio{protocol="apex"} + mean: perp_protocol_pf_ratio{protocol="apex"} + success: perp_protocol_health{protocol="apex"} + sample_size: perp_protocol_fees_30d_usd{protocol="apex"} + series: perp_protocol_pf_ratio{protocol="apex"} + + - slug: orderly + name: ORDER + tag: "Orderly, omnichain perps" + formula: "P/F = CoinGecko circulating market cap / (DeFiLlama 30d fees x 365/30), parent scope" + queries: + p50: perp_protocol_pf_ratio{protocol="orderly"} + p90: perp_protocol_pf_ratio{protocol="orderly"} + p99: perp_protocol_pf_ratio{protocol="orderly"} + mean: perp_protocol_pf_ratio{protocol="orderly"} + success: perp_protocol_health{protocol="orderly"} + sample_size: perp_protocol_fees_30d_usd{protocol="orderly"} + series: perp_protocol_pf_ratio{protocol="orderly"} + + - slug: synfutures + name: F (SynFutures) + tag: "SynFutures, parent scope: v3" + formula: "P/F = CoinGecko circulating market cap / (DeFiLlama 30d fees x 365/30), parent scope" + queries: + p50: perp_protocol_pf_ratio{protocol="synfutures"} + p90: perp_protocol_pf_ratio{protocol="synfutures"} + p99: perp_protocol_pf_ratio{protocol="synfutures"} + mean: perp_protocol_pf_ratio{protocol="synfutures"} + success: perp_protocol_health{protocol="synfutures"} + sample_size: perp_protocol_fees_30d_usd{protocol="synfutures"} + series: perp_protocol_pf_ratio{protocol="synfutures"} + + - slug: derive + name: DRV + tag: "Derive, parent scope: perps and options" + formula: "P/F = CoinGecko circulating market cap / (DeFiLlama 30d fees x 365/30), parent scope" + queries: + p50: perp_protocol_pf_ratio{protocol="derive"} + p90: perp_protocol_pf_ratio{protocol="derive"} + p99: perp_protocol_pf_ratio{protocol="derive"} + mean: perp_protocol_pf_ratio{protocol="derive"} + success: perp_protocol_health{protocol="derive"} + sample_size: perp_protocol_fees_30d_usd{protocol="derive"} + series: perp_protocol_pf_ratio{protocol="derive"} + + - slug: ostium + name: Ostium + tag: "Arbitrum" + unranked: "Pre-TGE" + formula: "No token yet: fees and open interest published, no ratio until a listing exists" + queries: + p50: perp_protocol_pf_ratio{protocol="ostium"} + p90: perp_protocol_pf_ratio{protocol="ostium"} + p99: perp_protocol_pf_ratio{protocol="ostium"} + mean: perp_protocol_pf_ratio{protocol="ostium"} + success: perp_protocol_health{protocol="ostium"} + sample_size: perp_protocol_fees_30d_usd{protocol="ostium"} + series: perp_protocol_pf_ratio{protocol="ostium"} + + - slug: pacifica + name: Pacifica + tag: "Solana" + unranked: "Pre-TGE" + formula: "No token yet: fees and open interest published, no ratio until a listing exists" + queries: + p50: perp_protocol_pf_ratio{protocol="pacifica"} + p90: perp_protocol_pf_ratio{protocol="pacifica"} + p99: perp_protocol_pf_ratio{protocol="pacifica"} + mean: perp_protocol_pf_ratio{protocol="pacifica"} + success: perp_protocol_health{protocol="pacifica"} + sample_size: perp_protocol_fees_30d_usd{protocol="pacifica"} + series: perp_protocol_pf_ratio{protocol="pacifica"} + + - slug: extended + name: Extended + tag: "Starknet" + unranked: "Pre-TGE" + formula: "No token yet: fees and open interest published, no ratio until a listing exists" + queries: + p50: perp_protocol_pf_ratio{protocol="extended"} + p90: perp_protocol_pf_ratio{protocol="extended"} + p99: perp_protocol_pf_ratio{protocol="extended"} + mean: perp_protocol_pf_ratio{protocol="extended"} + success: perp_protocol_health{protocol="extended"} + sample_size: perp_protocol_fees_30d_usd{protocol="extended"} + series: perp_protocol_pf_ratio{protocol="extended"} + + - slug: edgex + name: EDGE + tag: "edgeX, parent scope: perps" + formula: "P/F = CoinGecko circulating market cap / (DeFiLlama 30d fees x 365/30), parent scope" + queries: + p50: perp_protocol_pf_ratio{protocol="edgex"} + p90: perp_protocol_pf_ratio{protocol="edgex"} + p99: perp_protocol_pf_ratio{protocol="edgex"} + mean: perp_protocol_pf_ratio{protocol="edgex"} + success: perp_protocol_health{protocol="edgex"} + sample_size: perp_protocol_fees_30d_usd{protocol="edgex"} + series: perp_protocol_pf_ratio{protocol="edgex"} + + - slug: paradex + name: DIME + tag: "Paradex; DeFiLlama's fee adapter reports very low fees for this venue" + formula: "P/F = CoinGecko circulating market cap / (DeFiLlama 30d fees x 365/30), parent scope" + queries: + p50: perp_protocol_pf_ratio{protocol="paradex"} + p90: perp_protocol_pf_ratio{protocol="paradex"} + p99: perp_protocol_pf_ratio{protocol="paradex"} + mean: perp_protocol_pf_ratio{protocol="paradex"} + success: perp_protocol_health{protocol="paradex"} + sample_size: perp_protocol_fees_30d_usd{protocol="paradex"} + series: perp_protocol_pf_ratio{protocol="paradex"} + + - slug: nado + name: Nado + tag: "Ink, Kraken L2" + unranked: "Pre-TGE" + formula: "No token yet: fees and open interest published, no ratio until a listing exists" + queries: + p50: perp_protocol_pf_ratio{protocol="nado"} + p90: perp_protocol_pf_ratio{protocol="nado"} + p99: perp_protocol_pf_ratio{protocol="nado"} + mean: perp_protocol_pf_ratio{protocol="nado"} + success: perp_protocol_health{protocol="nado"} + sample_size: perp_protocol_fees_30d_usd{protocol="nado"} + series: perp_protocol_pf_ratio{protocol="nado"} + + - slug: decibel + name: Decibel + tag: "Aptos" + unranked: "Pre-TGE" + formula: "No token yet: fees and open interest published, no ratio until a listing exists" + queries: + p50: perp_protocol_pf_ratio{protocol="decibel"} + p90: perp_protocol_pf_ratio{protocol="decibel"} + p99: perp_protocol_pf_ratio{protocol="decibel"} + mean: perp_protocol_pf_ratio{protocol="decibel"} + success: perp_protocol_health{protocol="decibel"} + sample_size: perp_protocol_fees_30d_usd{protocol="decibel"} + series: perp_protocol_pf_ratio{protocol="decibel"} diff --git a/harnesses/apps/cmd/api/main.go b/harnesses/apps/cmd/api/main.go index 95aecfec1..f13540518 100644 --- a/harnesses/apps/cmd/api/main.go +++ b/harnesses/apps/cmd/api/main.go @@ -59,7 +59,7 @@ var deploymentMeta = map[string]struct{ Name, Slug, Category string }{ "dydx-v4:dydx-chain": {Name: "dYdX", Slug: "dydx", Category: "perps"}, "gmx-v2:arbitrum": {Name: "GMX v2 (Arbitrum)", Slug: "gmx", Category: "perps"}, "gmx-v2:avalanche": {Name: "GMX v2 (Avalanche)", Slug: "gmx", Category: "perps"}, - "gains-trade:arbitrum": {Name: "Gains.trade", Slug: "gains-trade", Category: "perps"}, + "gains-trade:arbitrum": {Name: "Gains", Slug: "gains-trade", Category: "perps"}, "drift:solana": {Name: "Drift", Slug: "drift", Category: "perps"}, "jupiter-perps:solana": {Name: "Jupiter Perps", Slug: "jupiter-perps", Category: "perps"}, "vertex:arbitrum": {Name: "Vertex", Slug: "vertex", Category: "perps"}, diff --git a/harnesses/permissions-scan/cmd/script/metrics.go b/harnesses/permissions-scan/cmd/script/metrics.go index e2e484f5a..0103f1935 100644 --- a/harnesses/permissions-scan/cmd/script/metrics.go +++ b/harnesses/permissions-scan/cmd/script/metrics.go @@ -50,13 +50,13 @@ func init() { epochNumberGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{ Name: "perp_exit_epoch_number", - Help: "Current gToken epoch number from gains.trade gDAI vault (live RPC).", + Help: "Current gToken epoch number from the Gains gDAI vault (live RPC).", }, []string{"venue"}) prometheus.MustRegister(epochNumberGauge) epochAgeHrsGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{ Name: "perp_exit_epoch_age_hours", - Help: "Hours since the current gains.trade epoch started (live RPC).", + Help: "Hours since the current Gains epoch started (live RPC).", }, []string{"venue"}) prometheus.MustRegister(epochAgeHrsGauge) } diff --git a/harnesses/perp-cohort-stats/cmd/script/registry.go b/harnesses/perp-cohort-stats/cmd/script/registry.go index 5cec711ac..3aef704be 100644 --- a/harnesses/perp-cohort-stats/cmd/script/registry.go +++ b/harnesses/perp-cohort-stats/cmd/script/registry.go @@ -22,7 +22,7 @@ var Registry = []Venue{ {Slug: "hyperliquid", Name: "Hyperliquid", Type: "perp", Chain: "hyperliquid"}, {Slug: "lighter", Name: "Lighter", Type: "perp", Chain: "zksync"}, {Slug: "gmx-v2", Name: "GMX V2", Type: "perp", Chain: "arbitrum"}, - {Slug: "gains", Name: "Gains Network", Type: "perp", Chain: "arbitrum"}, + {Slug: "gains", Name: "Gains", Type: "perp", Chain: "arbitrum"}, {Slug: "dydx", Name: "dYdX v4", Type: "perp", Chain: "dydx"}, {Slug: "paradex", Name: "Paradex", Type: "perp", Chain: "paradex"}, {Slug: "edgex", Name: "edgeX", Type: "perp", Chain: "edgex"}, diff --git a/harnesses/perp-fees/README.md b/harnesses/perp-fees/README.md index f10992fe0..f1054fc35 100644 --- a/harnesses/perp-fees/README.md +++ b/harnesses/perp-fees/README.md @@ -12,7 +12,7 @@ Exposes Prometheus metrics on `:2112/metrics`. | dYdX v4 | indexer orderbook + perpetualMarkets funding + Cosmos REST fee tier | | GMX v2 (Arbitrum) | Subsquid GraphQL `positionFeeFactorForNegativeImpact` + gmxinfra REST funding | | Lighter | `/orderBookDetails` taker fee + `/orderBookOrders` orderbook walk | -| gains.trade (v8 on Base) | `eth_call` on the on-chain fee oracle | +| Gains (v8 on Base) | `eth_call` on the on-chain fee oracle | Cadence: every 5 minutes, in parallel across all venues. Failed scrapes leave the previous gauge in place and increment `perp_fees_fetch_errors_total`. diff --git a/harnesses/perp-fees/cmd/script/config.go b/harnesses/perp-fees/cmd/script/config.go index 89f01d78e..3381091b1 100644 --- a/harnesses/perp-fees/cmd/script/config.go +++ b/harnesses/perp-fees/cmd/script/config.go @@ -41,7 +41,7 @@ func loadConfig() *Config { // for each asset is discovered by scanning the diamond's // `pairs(N)` slot for the asset/USD name match (findGainsPair in // gains.go). - {slug: "gains", display: "Gains Network", assets: []string{"ETH", "BTC", "SOL"}}, + {slug: "gains", display: "Gains", assets: []string{"ETH", "BTC", "SOL"}}, // Polymarket perps (2026-07-08 launch): public info API, base fee // tier taker 4 bps. ETH/BTC/SOL all listed at launch. {slug: "polymarket", display: "Polymarket", assets: []string{"ETH", "BTC", "SOL"}}, diff --git a/harnesses/perp-fees/cmd/script/gains.go b/harnesses/perp-fees/cmd/script/gains.go index 141b2476b..7e50ab09f 100644 --- a/harnesses/perp-fees/cmd/script/gains.go +++ b/harnesses/perp-fees/cmd/script/gains.go @@ -14,7 +14,7 @@ import ( "time" ) -// Gains Network v8 on Base. We read fees and spread directly from the +// Gains (gTrade) v8 on Base. We read fees and spread directly from the // PairsStorage facet on the Diamond contract via Base public RPC. No // hardcoded fee schedule — everything comes from on-chain. // @@ -136,7 +136,7 @@ func gainsReadPair(client *http.Client, pairIndex int) (*gainsPair, error) { // to the start of the struct content. from := extractAsciiString(res, fromOff+32) to := extractAsciiString(res, toOff+32) - // Pair struct slot layout (re-verified against the gains.trade backend + // Pair struct slot layout (re-verified against the Gains backend // /trading-variables on 2026-07-08): // slot 7 → spreadP (full bid-ask spread, 1e10 precision) // slot 8 → groupIndex @@ -313,7 +313,7 @@ func fetchGains(v VenueConfig, _ string) PerpSample { // 350_000_000 means 0.035% = 3.5 bps. bps = raw / 1e8. openFeeF, _ := new(big.Float).Quo(new(big.Float).SetInt(openFeeP), big.NewFloat(1e8)).Float64() // spreadP is the FULL bid-ask spread; the taker only crosses half of - // it, so half-spread = spreadP / (2 × 1e8). gains.trade UI displays + // it, so half-spread = spreadP / (2 × 1e8). the Gains UI displays // this same half-spread (e.g. spreadP=1e8 → 0.005% = 0.5 bps). spreadF, _ := new(big.Float).Quo(new(big.Float).SetInt(pair.SpreadP), big.NewFloat(2e8)).Float64() diff --git a/harnesses/perp-fees/cmd/script/walk.go b/harnesses/perp-fees/cmd/script/walk.go index 6f7076610..2d18709ac 100644 --- a/harnesses/perp-fees/cmd/script/walk.go +++ b/harnesses/perp-fees/cmd/script/walk.go @@ -125,7 +125,7 @@ func applyBookTiersCapped(s *PerpSample, levels []bookLevel, mid, maxFillRatio f } // applyFlatTiers publishes the same all-in figure at every tier. Used by the -// oracle-priced venues (GMX v2, gains.trade) where the harness-read cost is +// oracle-priced venues (GMX v2, Gains) where the harness-read cost is // a fixed fraction of position size, so the bps figure does not change with // notional. Callers document per venue why that is correct. func applyFlatTiers(s *PerpSample) { diff --git a/harnesses/perp-liq-rate/cmd/script/source_gains.go b/harnesses/perp-liq-rate/cmd/script/source_gains.go index b44af2f67..b4453a99e 100644 --- a/harnesses/perp-liq-rate/cmd/script/source_gains.go +++ b/harnesses/perp-liq-rate/cmd/script/source_gains.go @@ -1,6 +1,6 @@ package main -// source_gains.go — Gains Network (gTrade) on Base mainnet. +// source_gains.go — Gains (gTrade) on Base mainnet. // // Liquidations: eth_getLogs on the diamond for TradeClosed events, keeping // only those whose cancelReason (last uint8 word of the event data) == 1. diff --git a/harnesses/perp-protocol-longevity/cmd/script/registry.go b/harnesses/perp-protocol-longevity/cmd/script/registry.go index 7136464c8..4e20992ea 100644 --- a/harnesses/perp-protocol-longevity/cmd/script/registry.go +++ b/harnesses/perp-protocol-longevity/cmd/script/registry.go @@ -29,7 +29,7 @@ type venueRecord struct { var registry = []venueRecord{ { Slug: "gains", - Name: "gains.trade", + Name: "Gains", Launched: time.Date(2021, 12, 1, 0, 0, 0, 0, time.UTC), // No incidents recorded in DeFiLlama or public post-mortems as of 2026-08-03. Incidents: []incidentRecord{}, diff --git a/harnesses/perp-token-metrics/README.md b/harnesses/perp-token-metrics/README.md new file mode 100644 index 000000000..697d9d240 --- /dev/null +++ b/harnesses/perp-token-metrics/README.md @@ -0,0 +1,79 @@ +# perp-token-metrics + +Hourly valuation snapshot for perp DEX tokens. Feeds two benches: + +- **234 `perp-pe-ratio`**: FDV / annualized protocol revenue. +- **265 `perp-pf-ratio`**: market cap / annualized fees, plus FDV/F, P/S, float, open interest. + +## Inputs + +| Source | Endpoint | What we take | +|---|---|---| +| DeFiLlama | `GET /summary/fees/{slug}?dataType=dailyFees` | `total24h`, `totalDataChart` (we cut 30d / days 31-60 / 365d ourselves) | +| DeFiLlama | `GET /summary/fees/{slug}?dataType=dailyRevenue` | same, revenue. Flat-zero or missing series = "no revenue data", not zero | +| DeFiLlama | `GET /overview/open-interest` | `protocols[].total24h` keyed by perps child slug | +| CoinGecko | `GET /coins/markets?ids=…` | `market_cap`, `fully_diluted_valuation` (falls back to mcap), `circulating_supply`, `total_supply` | + +`/summary/fees/{parent}` aggregates every child product (verified: `hyperliquid` = perps + spot + HLP, `jupiter` = perps + aggregator + lend). The token is measured against its parent so market cap and fees share one scope; the perps-only child is published as `perp_protocol_perp_fees_30d_usd`. + +DeFiLlama's derivatives *volume* summary is paid-tier; volume is not read here (the perp-cohort-stats harness publishes `perp_venue_volume_30d_usd`). + +## Formulas + +``` +annual(x) = sum(last 30 daily points) * 365 / 30 +P/F = mcap / annual(fees) +FDV/F = fdv / annual(fees) +P/S = mcap / annual(revenue) +P/E = fdv / annual(revenue) (bench 234) +rev share = 100 * rev30d / fees30d +float % = 100 * circulating / total supply +fees / OI = annual(fees) / open interest +``` + +A ratio whose denominator is zero, or whose token has no listing, is **deleted** from the gauge vector rather than set to 0, so a missing value never reads as a zero valuation. + +## Coverage (verified 2026-09-12) + +| slug | DeFiLlama (token scope) | perps child | CoinGecko | notes | +|---|---|---|---|---| +| hyperliquid | hyperliquid | hyperliquid-perps | hyperliquid | | +| gmx | gmx | gmx-v2-perps | gmx | | +| gains | gains-network | = | gains-network | 100 % float | +| dydx | dydx | dydx-v4 | dydx-chain | | +| drift | drift-trade | = | drift-protocol | adapter reports 0 fees; ratios undefined | +| jupiter | jupiter | jupiter-perpetual-exchange | jupiter-exchange-solana | perps ≈ half of parent fees | +| aster | aster | aster-perps | aster-2 | no revenue series | +| lighter | lighter | lighter-perps | lighter | parent adds Robinhood perps | +| avantis | avantis | = | avantis | no revenue series | +| apex | apex-protocol | apex-omni | apex-token-2 | | +| orderly | orderly-perps | = | orderly-network | parent slug returns null; no revenue | +| synfutures | synfutures | synfutures-v3 | synfutures | | +| derive | derive | derive-v2 | derive | parent adds options | +| ostium, pacifica, extended, edgex, paradex | see main.go | | none | fees + OI only | + +Dropped: vertex (no data since acquisition), aevo / grvt / backpack (no DeFiLlama fees series), mux / perpetual-protocol (fees ≈ 0). + +## Metrics + +All gauges carry `protocol=""`. + +``` +perp_protocol_pf_ratio perp_protocol_fees_24h_usd perp_protocol_mcap_usd +perp_protocol_pf_fdv_ratio perp_protocol_fees_30d_usd perp_protocol_fdv_usd +perp_protocol_ps_ratio perp_protocol_fees_prev_30d_usd perp_protocol_float_pct +perp_protocol_pe_ratio perp_protocol_fees_1y_usd perp_protocol_oi_usd +perp_protocol_rev_share_pct perp_protocol_annual_fees_usd perp_protocol_fees_to_oi_ratio +perp_protocol_rev_24h_usd perp_protocol_perp_fees_30d_usd perp_protocol_health +perp_protocol_rev_30d_usd perp_protocol_annual_rev_usd +``` + +## Run + +``` +go test ./... # unit tests, fake upstream +ONESHOT=1 METRICS_ADDR=:2112 go run ./cmd/script # one live poll, then exit +curl -s localhost:2112/metrics | grep perp_protocol_pf_ratio +``` + +~40 DeFiLlama calls and one CoinGecko call per hour; both public tiers allow this comfortably. diff --git a/harnesses/perp-token-metrics/cmd/script/fetcher.go b/harnesses/perp-token-metrics/cmd/script/fetcher.go index 2ec5a9d26..4a572ad58 100644 --- a/harnesses/perp-token-metrics/cmd/script/fetcher.go +++ b/harnesses/perp-token-metrics/cmd/script/fetcher.go @@ -4,12 +4,19 @@ import ( "encoding/json" "fmt" "net/http" + "sort" "strings" "time" ) var httpClient = &http.Client{Timeout: 30 * time.Second} +// Base URLs are variables so tests can point them at an httptest server. +var ( + llamaBase = "https://api.llama.fi" + cgBase = "https://api.coingecko.com/api/v3" +) + func get(url string) (*http.Response, error) { req, err := http.NewRequest("GET", url, nil) if err != nil { @@ -19,12 +26,28 @@ func get(url string) (*http.Response, error) { return httpClient.Do(req) } -// fetchLlamaRevenue returns (rev24h, avg30dDaily, ok). -func fetchLlamaRevenue(slug string) (float64, float64, bool) { - url := fmt.Sprintf("https://api.llama.fi/summary/fees/%s?dataType=dailyRevenue", slug) +// llamaSeries is what we keep from one /summary/fees/?dataType=… +// response: the headline 24h number and the trailing windows we derive +// from totalDataChart ourselves, so every protocol is cut the same way +// regardless of which convenience fields the adapter happens to fill. +type llamaSeries struct { + Total24h float64 + Sum30d float64 // last 30 daily points + SumPrev30 float64 // the 30 points before those (days 31-60) + Sum1y float64 // last 365 daily points + Avg30d float64 // Sum30d / non-zero days in the window + Days30 int // non-zero days counted in Avg30d +} + +// fetchLlamaSeries returns the trailing windows for one DeFiLlama slug and +// data type ("dailyFees" or "dailyRevenue"). ok is false on transport or +// decode failure, or when the chart is empty; a chart of zeros is ok=true +// with zero sums, which is a real observation (adapter live, nothing earned). +func fetchLlamaSeries(slug, dataType string) (llamaSeries, bool) { + url := fmt.Sprintf("%s/summary/fees/%s?dataType=%s", llamaBase, slug, dataType) resp, err := get(url) if err != nil || resp.StatusCode != 200 { - return 0, 0, false + return llamaSeries{}, false } defer resp.Body.Close() @@ -33,40 +56,74 @@ func fetchLlamaRevenue(slug string) (float64, float64, bool) { TotalDataChart [][2]json.Number `json:"totalDataChart"` } if err := json.NewDecoder(resp.Body).Decode(&d); err != nil { - return 0, 0, false + return llamaSeries{}, false + } + if len(d.TotalDataChart) == 0 { + return llamaSeries{}, false } + return windowsFromChart(d.Total24h, d.TotalDataChart), true +} - chart := d.TotalDataChart - start := len(chart) - 30 - if start < 0 { - start = 0 - } - var sum float64 - var count int - for _, pt := range chart[start:] { - v, err := pt[1].Float64() - if err != nil || v <= 0 { +// windowsFromChart cuts the trailing windows out of a DeFiLlama +// totalDataChart ([[unixDay, value], …]). Points are sorted by timestamp +// first because the API does not promise order. The current partial day +// is not special-cased: DeFiLlama's own 30d figure includes it too. +func windowsFromChart(total24h float64, chart [][2]json.Number) llamaSeries { + type pt struct { + t int64 + v float64 + } + pts := make([]pt, 0, len(chart)) + for _, c := range chart { + t, err1 := c[0].Int64() + v, err2 := c[1].Float64() + if err1 != nil || err2 != nil { continue } - sum += v - count++ + pts = append(pts, pt{t, v}) + } + sort.Slice(pts, func(i, j int) bool { return pts[i].t < pts[j].t }) + + s := llamaSeries{Total24h: total24h} + n := len(pts) + for i := n - 1; i >= 0 && i >= n-365; i-- { + v := pts[i].v + s.Sum1y += v + back := n - 1 - i // 0 = latest day + switch { + case back < 30: + s.Sum30d += v + if v > 0 { + s.Days30++ + } + case back < 60: + s.SumPrev30 += v + } } - if count == 0 { - return d.Total24h, 0, false + if s.Days30 > 0 { + s.Avg30d = s.Sum30d / float64(s.Days30) } - return d.Total24h, sum / float64(count), true + return s } -// fetchCGFdvBatch fetches FDV for multiple CoinGecko IDs in one request. -func fetchCGFdvBatch(ids []string) map[string]float64 { - result := map[string]float64{} +// cgMarket is the slice of CoinGecko /coins/markets we use. +type cgMarket struct { + Mcap float64 + FDV float64 + Circ float64 + TotalSupply float64 +} + +// fetchCGMarkets fetches market cap, FDV and supply for many CoinGecko IDs +// in one request. Missing IDs are simply absent from the map. +func fetchCGMarkets(ids []string) map[string]cgMarket { + result := map[string]cgMarket{} if len(ids) == 0 { return result } - url := fmt.Sprintf( - "https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&ids=%s&per_page=50&sparkline=false", - strings.Join(ids, ","), + "%s/coins/markets?vs_currency=usd&ids=%s&per_page=100&sparkline=false", + cgBase, strings.Join(ids, ","), ) resp, err := get(url) if err != nil || resp.StatusCode != 200 { @@ -75,21 +132,64 @@ func fetchCGFdvBatch(ids []string) map[string]float64 { defer resp.Body.Close() var coins []struct { - ID string `json:"id"` - FullyDilutedVal *float64 `json:"fully_diluted_valuation"` - MarketCap *float64 `json:"market_cap"` + ID string `json:"id"` + MarketCap *float64 `json:"market_cap"` + FullyDilutedVal *float64 `json:"fully_diluted_valuation"` + Circulating *float64 `json:"circulating_supply"` + TotalSupply *float64 `json:"total_supply"` } if err := json.NewDecoder(resp.Body).Decode(&coins); err != nil { return result } - + deref := func(p *float64) float64 { + if p == nil { + return 0 + } + return *p + } for _, c := range coins { - fdv := c.FullyDilutedVal - if fdv == nil { - fdv = c.MarketCap + m := cgMarket{ + Mcap: deref(c.MarketCap), + FDV: deref(c.FullyDilutedVal), + Circ: deref(c.Circulating), + TotalSupply: deref(c.TotalSupply), + } + // CoinGecko leaves FDV null when total supply is unknown; the + // existing P/E bench fell back to mcap and we keep that. + if m.FDV == 0 { + m.FDV = m.Mcap } - if fdv != nil && *fdv > 0 { - result[c.ID] = *fdv + if m.Mcap > 0 || m.FDV > 0 { + result[c.ID] = m + } + } + return result +} + +// fetchLlamaOpenInterest returns current open interest in USD keyed by +// DeFiLlama perps slug (the overview lists child protocols only). An +// empty map on failure just leaves the OI-derived gauges unset. +func fetchLlamaOpenInterest() map[string]float64 { + result := map[string]float64{} + url := llamaBase + "/overview/open-interest?excludeTotalDataChart=true&excludeTotalDataChartBreakdown=true" + resp, err := get(url) + if err != nil || resp.StatusCode != 200 { + return result + } + defer resp.Body.Close() + + var d struct { + Protocols []struct { + Slug string `json:"slug"` + Total24h *float64 `json:"total24h"` + } `json:"protocols"` + } + if err := json.NewDecoder(resp.Body).Decode(&d); err != nil { + return result + } + for _, p := range d.Protocols { + if p.Total24h != nil && *p.Total24h > 0 { + result[p.Slug] = *p.Total24h } } return result diff --git a/harnesses/perp-token-metrics/cmd/script/main.go b/harnesses/perp-token-metrics/cmd/script/main.go index 1eb747f1c..6d32cbd23 100644 --- a/harnesses/perp-token-metrics/cmd/script/main.go +++ b/harnesses/perp-token-metrics/cmd/script/main.go @@ -8,18 +8,58 @@ import ( "time" ) +// protocol is one row of the valuation table. llamaSlug is the DeFiLlama +// entity the token accrues value from (the parent when the protocol has +// several products, so mcap and fees are measured on the same scope, the +// way defillama.com/pf does it). llamaPerpSlug is the perps-only child, +// used for the perp-share column and the open-interest join; empty means +// the parent is perps-only. cgID empty means the protocol has no listed +// token: fees are still published, every valuation ratio is skipped. type protocol struct { - slug string // bench slug / prometheus label - llamaSlug string // DeFiLlama fees slug - cgID string // CoinGecko coin ID (empty = no FDV) + slug string // bench slug / prometheus label + llamaSlug string // DeFiLlama fees slug, token scope (parent) + llamaPerpSlug string // DeFiLlama fees slug, perps-only child ("" = same as llamaSlug) + cgID string // CoinGecko coin ID ("" = no token) } +// Verified against api.llama.fi/summary/fees/ and CoinGecko +// /coins/markets on 2026-09-12 (see harness README for the coverage +// table). Slugs that DeFiLlama no longer serves (vertex, aevo, grvt, +// backpack) are deliberately absent rather than left to fail every hour. var protocols = []protocol{ - {slug: "hyperliquid", llamaSlug: "hyperliquid", cgID: "hyperliquid"}, - {slug: "gmx", llamaSlug: "gmx", cgID: "gmx"}, + {slug: "hyperliquid", llamaSlug: "hyperliquid", llamaPerpSlug: "hyperliquid-perps", cgID: "hyperliquid"}, + {slug: "gmx", llamaSlug: "gmx", llamaPerpSlug: "gmx-v2-perps", cgID: "gmx"}, {slug: "gains", llamaSlug: "gains-network", cgID: "gains-network"}, - {slug: "dydx", llamaSlug: "dydx", cgID: "dydx"}, - {slug: "drift", llamaSlug: "drift", cgID: "drift-protocol"}, + {slug: "dydx", llamaSlug: "dydx", llamaPerpSlug: "dydx-v4", cgID: "dydx-chain"}, + // Drift's DeFiLlama parent aggregates the staked-SOL product; the perps + // adapter (drift-trade) is what the token is priced on, even while it + // reports zero and leaves every ratio undefined. + {slug: "drift", llamaSlug: "drift-trade", cgID: "drift-protocol"}, + {slug: "jupiter", llamaSlug: "jupiter", llamaPerpSlug: "jupiter-perpetual-exchange", cgID: "jupiter-exchange-solana"}, + {slug: "aster", llamaSlug: "aster", llamaPerpSlug: "aster-perps", cgID: "aster-2"}, + {slug: "lighter", llamaSlug: "lighter", llamaPerpSlug: "lighter-perps", cgID: "lighter"}, + {slug: "avantis", llamaSlug: "avantis", cgID: "avantis"}, + {slug: "apex", llamaSlug: "apex-protocol", llamaPerpSlug: "apex-omni", cgID: "apex-token-2"}, + {slug: "orderly", llamaSlug: "orderly-perps", cgID: "orderly-network"}, + {slug: "synfutures", llamaSlug: "synfutures", llamaPerpSlug: "synfutures-v3", cgID: "synfutures"}, + {slug: "derive", llamaSlug: "derive", llamaPerpSlug: "derive-v2", cgID: "derive"}, + // edgeX (EDGE) and Paradex (DIME) listed on CoinGecko; verified 2026-09-14. + {slug: "edgex", llamaSlug: "edgex", llamaPerpSlug: "edgex-v2", cgID: "edgex"}, + {slug: "paradex", llamaSlug: "paradex-perps", cgID: "paradex"}, + // Pre-TGE venues: fees and open interest only, shown unranked on the + // bench so the fee base of a future token is visible before it lists. + {slug: "ostium", llamaSlug: "ostium"}, + {slug: "pacifica", llamaSlug: "pacifica", llamaPerpSlug: "pacifica-perps"}, + {slug: "extended", llamaSlug: "extended", llamaPerpSlug: "extended-perps"}, + {slug: "nado", llamaSlug: "nado", llamaPerpSlug: "nado-perps"}, + {slug: "decibel", llamaSlug: "decibel"}, +} + +func (p protocol) perpSlug() string { + if p.llamaPerpSlug != "" { + return p.llamaPerpSlug + } + return p.llamaSlug } func addr() string { @@ -31,7 +71,7 @@ func addr() string { func main() { fmt.Println("=== perp-token-metrics harness ===") - fmt.Println("OpenChainBench #234 — perp DEX token P/E ratios") + fmt.Println("OpenChainBench #234 (P/E) + perp DEX valuation (P/F, P/S)") fmt.Printf("Protocols: %d | poll interval: 1h\n", len(protocols)) go func() { @@ -43,6 +83,10 @@ func main() { poll() + if os.Getenv("ONESHOT") == "1" { + return + } + ticker := time.NewTicker(60 * time.Minute) defer ticker.Stop() @@ -63,42 +107,74 @@ func main() { func poll() { fmt.Printf("[%s] polling DeFiLlama + CoinGecko\n", time.Now().UTC().Format(time.RFC3339)) - // collect CoinGecko FDVs in one batch call + // One batch call for every token, one call for every open-interest row. cgIDs := []string{} for _, p := range protocols { if p.cgID != "" { cgIDs = append(cgIDs, p.cgID) } } - fdvMap := fetchCGFdvBatch(cgIDs) + markets := fetchCGMarkets(cgIDs) + oi := fetchLlamaOpenInterest() for _, p := range protocols { - rev24h, avg30d, ok := fetchLlamaRevenue(p.llamaSlug) - if !ok { - fmt.Printf(" [%s] DeFiLlama fetch failed\n", p.slug) + fees, feesOK := fetchLlamaSeries(p.llamaSlug, "dailyFees") + if !feesOK { + fmt.Printf(" [%s] DeFiLlama fees fetch failed\n", p.slug) protocolHealth.WithLabelValues(p.slug).Set(0) continue } - - annualRev := avg30d * 365 - fdv, hasFDV := fdvMap[p.cgID] - var pe float64 - hasPE := hasFDV && annualRev > 0 - if hasPE { - pe = fdv / annualRev + // Revenue is optional: several adapters publish fees only, and an + // adapter that answers with a flat zero series is the same thing. + rev, revOK := fetchLlamaSeries(p.llamaSlug, "dailyRevenue") + revOK = revOK && rev.Sum30d > 0 + perpFees := fees + if p.perpSlug() != p.llamaSlug { + if pf, ok := fetchLlamaSeries(p.perpSlug(), "dailyFees"); ok { + perpFees = pf + } } - protocolRev24h.WithLabelValues(p.slug).Set(rev24h) - protocolAnnualRev.WithLabelValues(p.slug).Set(annualRev) - if hasFDV { - protocolFDV.WithLabelValues(p.slug).Set(fdv) - } - if hasPE { - protocolPE.WithLabelValues(p.slug).Set(pe) - } + v := computeValuation(fees, rev, revOK, markets[p.cgID], p.cgID != "") + publish(p, v, perpFees, oi[p.perpSlug()]) protocolHealth.WithLabelValues(p.slug).Set(1) - fmt.Printf(" [%s] rev24h=$%.0f avg30d=$%.0f annual=$%.0f fdv=$%.0f pe=%.1fx\n", - p.slug, rev24h, avg30d, annualRev, fdv, pe) + fmt.Printf(" [%s] fees30d=$%.0f rev30d=$%.0f mcap=$%.0f fdv=$%.0f pf=%.1fx ps=%.1fx pe=%.1fx\n", + p.slug, fees.Sum30d, rev.Sum30d, v.Mcap, v.FDV, v.PF, v.PS, v.PE) + } +} + +// publish writes one protocol's row into the gauge vectors. Ratios that +// could not be computed (no token, zero fees) are removed from the vector +// so a stale value from a previous poll never survives a source outage. +func publish(p protocol, v valuation, perpFees llamaSeries, oiUSD float64) { + l := labelsFor(p.slug) + + protocolFees24h.With(l).Set(v.Fees24h) + protocolFees30d.With(l).Set(v.Fees30d) + protocolFeesPrev30d.With(l).Set(v.FeesPrev30d) + protocolFees1y.With(l).Set(v.Fees1y) + protocolAnnualFees.With(l).Set(v.AnnualFees) + protocolPerpFees30d.With(l).Set(perpFees.Sum30d) + + setOrDelete(protocolRev24h, l, v.Rev24h, v.HasRev) + setOrDelete(protocolRev30d, l, v.Rev30d, v.HasRev) + setOrDelete(protocolAnnualRev, l, v.AnnualRev, v.HasRev) + setOrDelete(protocolRevShare, l, v.RevSharePct, v.HasRev && v.Fees30d > 0) + + setOrDelete(protocolMcap, l, v.Mcap, v.HasToken && v.Mcap > 0) + setOrDelete(protocolFDV, l, v.FDV, v.HasToken && v.FDV > 0) + setOrDelete(protocolFloat, l, v.FloatPct, v.HasToken && v.FloatPct > 0) + + setOrDelete(protocolPF, l, v.PF, v.HasPF) + setOrDelete(protocolPFfdv, l, v.PFfdv, v.HasPFfdv) + setOrDelete(protocolPS, l, v.PS, v.HasPS) + setOrDelete(protocolPE, l, v.PE, v.HasPE) + + setOrDelete(protocolOI, l, oiUSD, oiUSD > 0) + feesToOI := 0.0 + if oiUSD > 0 { + feesToOI = v.AnnualFees / oiUSD } + setOrDelete(protocolFeesToOI, l, feesToOI, feesToOI > 0) } diff --git a/harnesses/perp-token-metrics/cmd/script/metrics.go b/harnesses/perp-token-metrics/cmd/script/metrics.go index 0986f667f..3cab387c2 100644 --- a/harnesses/perp-token-metrics/cmd/script/metrics.go +++ b/harnesses/perp-token-metrics/cmd/script/metrics.go @@ -7,43 +7,66 @@ import ( "github.com/prometheus/client_golang/prometheus/promhttp" ) +var labels = []string{"protocol"} + +func gauge(name, help string) *prometheus.GaugeVec { + return prometheus.NewGaugeVec(prometheus.GaugeOpts{Name: name, Help: help}, labels) +} + var ( - protocolPE = prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Name: "perp_protocol_pe_ratio", - Help: "P/E ratio per perp DEX protocol (FDV / annualized 30d avg daily revenue).", - }, []string{"protocol"}) - - protocolFDV = prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Name: "perp_protocol_fdv_usd", - Help: "Fully diluted valuation in USD per perp DEX protocol (CoinGecko).", - }, []string{"protocol"}) - - protocolAnnualRev = prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Name: "perp_protocol_annual_rev_usd", - Help: "Annualized protocol revenue in USD (DeFiLlama 30d avg x 365).", - }, []string{"protocol"}) - - protocolRev24h = prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Name: "perp_protocol_rev_24h_usd", - Help: "24h protocol revenue in USD (DeFiLlama dailyRevenue).", - }, []string{"protocol"}) - - protocolHealth = prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Name: "perp_protocol_health", - Help: "1 if this protocol returned data in the last poll.", - }, []string{"protocol"}) + // Bench 234 gauges (names unchanged so the live spec keeps resolving). + protocolPE = gauge("perp_protocol_pe_ratio", "P/E ratio per perp DEX protocol (FDV / annualized 30d revenue).") + protocolFDV = gauge("perp_protocol_fdv_usd", "Fully diluted valuation in USD (CoinGecko; market cap when FDV is unknown).") + protocolAnnualRev = gauge("perp_protocol_annual_rev_usd", "Annualized protocol revenue in USD (DeFiLlama dailyRevenue, trailing 30d x 365/30).") + protocolRev24h = gauge("perp_protocol_rev_24h_usd", "24h protocol revenue in USD (DeFiLlama dailyRevenue).") + protocolHealth = gauge("perp_protocol_health", "1 if this protocol returned fee data in the last poll.") + + // Valuation gauges (P/F page). + protocolFees24h = gauge("perp_protocol_fees_24h_usd", "24h total fees paid by users in USD (DeFiLlama dailyFees, token scope).") + protocolFees30d = gauge("perp_protocol_fees_30d_usd", "Trailing 30d fees in USD (DeFiLlama dailyFees, token scope).") + protocolFeesPrev30d = gauge("perp_protocol_fees_prev_30d_usd", "Fees in USD for days 31-60 back, for month-over-month trend.") + protocolFees1y = gauge("perp_protocol_fees_1y_usd", "Trailing 365d fees in USD.") + protocolAnnualFees = gauge("perp_protocol_annual_fees_usd", "Annualized fees in USD (trailing 30d x 365/30).") + protocolPerpFees30d = gauge("perp_protocol_perp_fees_30d_usd", "Trailing 30d fees of the perps product only, in USD (equals fees_30d for single-product protocols).") + protocolRev30d = gauge("perp_protocol_rev_30d_usd", "Trailing 30d protocol revenue in USD (DeFiLlama dailyRevenue).") + protocolRevShare = gauge("perp_protocol_rev_share_pct", "Share of 30d fees that is protocol revenue, in percent.") + + protocolMcap = gauge("perp_protocol_mcap_usd", "Circulating market cap in USD (CoinGecko).") + protocolFloat = gauge("perp_protocol_float_pct", "Circulating supply as a percent of total supply (CoinGecko).") + + protocolPF = gauge("perp_protocol_pf_ratio", "Price-to-fees: market cap / annualized fees.") + protocolPFfdv = gauge("perp_protocol_pf_fdv_ratio", "FDV-to-fees: fully diluted valuation / annualized fees.") + protocolPS = gauge("perp_protocol_ps_ratio", "Price-to-sales: market cap / annualized protocol revenue.") + + protocolOI = gauge("perp_protocol_oi_usd", "Open interest in USD on the perps product (DeFiLlama open-interest overview).") + protocolFeesToOI = gauge("perp_protocol_fees_to_oi_ratio", "Annualized fees / open interest: how hard the book is worked per dollar of OI.") ) func init() { prometheus.MustRegister( - protocolPE, - protocolFDV, - protocolAnnualRev, - protocolRev24h, - protocolHealth, + protocolPE, protocolFDV, protocolAnnualRev, protocolRev24h, protocolHealth, + protocolFees24h, protocolFees30d, protocolFeesPrev30d, protocolFees1y, protocolAnnualFees, + protocolPerpFees30d, protocolRev30d, protocolRevShare, + protocolMcap, protocolFloat, + protocolPF, protocolPFfdv, protocolPS, + protocolOI, protocolFeesToOI, ) } +func labelsFor(slug string) prometheus.Labels { + return prometheus.Labels{"protocol": slug} +} + +// setOrDelete publishes v when defined, otherwise removes the series so a +// stale value from an earlier poll cannot outlive the data that produced it. +func setOrDelete(g *prometheus.GaugeVec, l prometheus.Labels, v float64, defined bool) { + if defined { + g.With(l).Set(v) + return + } + g.Delete(l) +} + func startMetricsServer(addr string) error { mux := http.NewServeMux() mux.Handle("/metrics", promhttp.Handler()) diff --git a/harnesses/perp-token-metrics/cmd/script/valuation.go b/harnesses/perp-token-metrics/cmd/script/valuation.go new file mode 100644 index 000000000..9c3739ea8 --- /dev/null +++ b/harnesses/perp-token-metrics/cmd/script/valuation.go @@ -0,0 +1,77 @@ +package main + +// valuation is the computed row for one protocol. Has* flags say which +// ratios are defined; the caller deletes the gauge when a flag is false +// instead of publishing zero, so "no token" and "no data" never read as +// "worth nothing". +type valuation struct { + Fees24h, Fees30d, FeesPrev30d, Fees1y, AnnualFees float64 + Rev24h, Rev30d, AnnualRev, RevSharePct float64 + HasRev bool + + HasToken bool + Mcap, FDV, FloatPct float64 + + PF, PFfdv, PS, PE float64 + HasPF, HasPFfdv, HasPS, HasPE bool +} + +// annualize turns a trailing-30-day sum into a yearly run rate. Zero days +// inside the window count: a live adapter that recorded nothing is a +// real observation, and the same cut is applied to every protocol. +func annualize(sum30d float64) float64 { + return sum30d * 365 / 30 +} + +// computeValuation derives every ratio from one fees series, an optional +// revenue series, and the CoinGecko market row. hasToken=false (no listing) +// publishes fees only. +// +// P/F = market cap / annualized fees (defillama.com/pf convention) +// FDV/F = fully diluted valuation / annualized fees +// P/S = market cap / annualized revenue (revenue = protocol + holders share) +// P/E = FDV / annualized revenue (bench 234, unchanged definition) +func computeValuation(fees, rev llamaSeries, hasRev bool, m cgMarket, hasToken bool) valuation { + v := valuation{ + Fees24h: fees.Total24h, + Fees30d: fees.Sum30d, + FeesPrev30d: fees.SumPrev30, + Fees1y: fees.Sum1y, + AnnualFees: annualize(fees.Sum30d), + HasRev: hasRev, + HasToken: hasToken, + } + if hasRev { + v.Rev24h = rev.Total24h + v.Rev30d = rev.Sum30d + v.AnnualRev = annualize(rev.Sum30d) + if v.Fees30d > 0 { + v.RevSharePct = 100 * v.Rev30d / v.Fees30d + } + } + if !hasToken { + return v + } + v.Mcap = m.Mcap + v.FDV = m.FDV + if m.TotalSupply > 0 && m.Circ > 0 { + v.FloatPct = 100 * m.Circ / m.TotalSupply + } + if v.AnnualFees > 0 { + if v.Mcap > 0 { + v.PF, v.HasPF = v.Mcap/v.AnnualFees, true + } + if v.FDV > 0 { + v.PFfdv, v.HasPFfdv = v.FDV/v.AnnualFees, true + } + } + if hasRev && v.AnnualRev > 0 { + if v.Mcap > 0 { + v.PS, v.HasPS = v.Mcap/v.AnnualRev, true + } + if v.FDV > 0 { + v.PE, v.HasPE = v.FDV/v.AnnualRev, true + } + } + return v +} diff --git a/harnesses/perp-token-metrics/cmd/script/valuation_test.go b/harnesses/perp-token-metrics/cmd/script/valuation_test.go new file mode 100644 index 000000000..f99a013a0 --- /dev/null +++ b/harnesses/perp-token-metrics/cmd/script/valuation_test.go @@ -0,0 +1,226 @@ +package main + +import ( + "encoding/json" + "fmt" + "math" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func approx(t *testing.T, name string, got, want float64) { + t.Helper() + if math.Abs(got-want) > 1e-6*math.Max(1, math.Abs(want)) { + t.Fatalf("%s: got %v want %v", name, got, want) + } +} + +// chart builds a DeFiLlama-style totalDataChart of n daily points, latest +// last, where day i (0 = oldest) is worth f(i). +func chart(n int, f func(i int) float64) [][2]json.Number { + out := make([][2]json.Number, 0, n) + for i := 0; i < n; i++ { + t := int64(1_700_000_000 + i*86400) + out = append(out, [2]json.Number{json.Number(fmt.Sprint(t)), json.Number(fmt.Sprintf("%.2f", f(i)))}) + } + return out +} + +func TestWindowsFromChart_Cuts30_60_365(t *testing.T) { + // 400 days of $1/day, except the most recent 30 days at $2/day. + c := chart(400, func(i int) float64 { + if i >= 370 { + return 2 + } + return 1 + }) + s := windowsFromChart(2, c) + approx(t, "Sum30d", s.Sum30d, 60) + approx(t, "SumPrev30", s.SumPrev30, 30) + approx(t, "Sum1y", s.Sum1y, 60+335) + approx(t, "Avg30d", s.Avg30d, 2) + if s.Days30 != 30 { + t.Fatalf("Days30: got %d want 30", s.Days30) + } +} + +func TestWindowsFromChart_UnsortedAndShort(t *testing.T) { + c := chart(10, func(i int) float64 { return float64(i + 1) }) // 1..10 + // Shuffle: move the latest point to the front. + c = append(c[9:], c[:9]...) + s := windowsFromChart(10, c) + approx(t, "Sum30d", s.Sum30d, 55) + approx(t, "SumPrev30", s.SumPrev30, 0) + approx(t, "Sum1y", s.Sum1y, 55) +} + +func TestWindowsFromChart_ZeroDaysCountInSumNotAvg(t *testing.T) { + c := chart(30, func(i int) float64 { + if i%2 == 0 { + return 0 + } + return 10 + }) + s := windowsFromChart(0, c) + approx(t, "Sum30d", s.Sum30d, 150) + if s.Days30 != 15 { + t.Fatalf("Days30: got %d want 15", s.Days30) + } + approx(t, "Avg30d", s.Avg30d, 10) + // Annualization is on the sum, so the zero days lower the run rate. + approx(t, "annualize", annualize(s.Sum30d), 150*365/30) +} + +func TestComputeValuation_AllRatios(t *testing.T) { + fees := llamaSeries{Total24h: 20_000, Sum30d: 600_000} + rev := llamaSeries{Total24h: 15_000, Sum30d: 450_000} + m := cgMarket{Mcap: 10_000_000, FDV: 12_000_000, Circ: 25, TotalSupply: 30} + v := computeValuation(fees, rev, true, m, true) + + annualFees := 600_000.0 * 365 / 30 + annualRev := 450_000.0 * 365 / 30 + approx(t, "AnnualFees", v.AnnualFees, annualFees) + approx(t, "AnnualRev", v.AnnualRev, annualRev) + approx(t, "RevSharePct", v.RevSharePct, 75) + approx(t, "FloatPct", v.FloatPct, 100*25.0/30) + approx(t, "PF", v.PF, 10_000_000/annualFees) + approx(t, "PFfdv", v.PFfdv, 12_000_000/annualFees) + approx(t, "PS", v.PS, 10_000_000/annualRev) + approx(t, "PE", v.PE, 12_000_000/annualRev) + for name, ok := range map[string]bool{"HasPF": v.HasPF, "HasPFfdv": v.HasPFfdv, "HasPS": v.HasPS, "HasPE": v.HasPE, "HasRev": v.HasRev} { + if !ok { + t.Fatalf("%s should be true", name) + } + } +} + +func TestComputeValuation_TokenlessPublishesFeesOnly(t *testing.T) { + fees := llamaSeries{Total24h: 1, Sum30d: 30} + v := computeValuation(fees, llamaSeries{}, false, cgMarket{}, false) + approx(t, "Fees30d", v.Fees30d, 30) + if v.HasPF || v.HasPFfdv || v.HasPS || v.HasPE || v.HasRev { + t.Fatalf("no ratio may be defined without a token: %+v", v) + } + if v.Mcap != 0 || v.FDV != 0 || v.FloatPct != 0 { + t.Fatalf("market fields must stay zero without a token: %+v", v) + } +} + +func TestComputeValuation_ZeroFeesDefinesNoRatio(t *testing.T) { + v := computeValuation(llamaSeries{}, llamaSeries{}, true, cgMarket{Mcap: 1, FDV: 1}, true) + if v.HasPF || v.HasPFfdv || v.HasPS || v.HasPE { + t.Fatalf("ratios must be undefined when the denominator is zero: %+v", v) + } + if v.RevSharePct != 0 { + t.Fatalf("rev share undefined on zero fees, got %v", v.RevSharePct) + } +} + +func TestComputeValuation_NoRevenueKeepsFeeRatios(t *testing.T) { + fees := llamaSeries{Sum30d: 300} + v := computeValuation(fees, llamaSeries{}, false, cgMarket{Mcap: 3650, FDV: 7300}, true) + approx(t, "PF", v.PF, 1) + approx(t, "PFfdv", v.PFfdv, 2) + if v.HasPS || v.HasPE { + t.Fatalf("P/S and P/E need revenue: %+v", v) + } +} + +// fakeUpstream serves the three upstream shapes the harness reads. +func fakeUpstream(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/summary/fees/", func(w http.ResponseWriter, r *http.Request) { + slug := strings.TrimPrefix(r.URL.Path, "/summary/fees/") + dt := r.URL.Query().Get("dataType") + per := map[string]float64{"dailyFees": 100, "dailyRevenue": 40}[dt] + if slug == "broken" { + http.Error(w, "Not found", 404) + return + } + if slug == "empty" { + _ = json.NewEncoder(w).Encode(map[string]any{"total24h": 0, "totalDataChart": [][2]any{}}) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "total24h": per, + "totalDataChart": chart(90, func(int) float64 { return per }), + }) + }) + mux.HandleFunc("/coins/markets", func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.RawQuery, "ids=") { + http.Error(w, "missing ids", 400) + return + } + fmt.Fprint(w, `[ + {"id":"tok","market_cap":1000000,"fully_diluted_valuation":2000000,"circulating_supply":50,"total_supply":100}, + {"id":"nofdv","market_cap":500000,"fully_diluted_valuation":null,"circulating_supply":null,"total_supply":null} + ]`) + }) + mux.HandleFunc("/overview/open-interest", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"protocols":[{"slug":"tok-perps","total24h":250000},{"slug":"dead","total24h":null}]}`) + }) + return httptest.NewServer(mux) +} + +func TestFetchers_AgainstFakeUpstream(t *testing.T) { + srv := fakeUpstream(t) + defer srv.Close() + oldL, oldC := llamaBase, cgBase + llamaBase, cgBase = srv.URL, srv.URL + defer func() { llamaBase, cgBase = oldL, oldC }() + + fees, ok := fetchLlamaSeries("tok", "dailyFees") + if !ok { + t.Fatal("fees fetch should succeed") + } + approx(t, "fees.Sum30d", fees.Sum30d, 3000) + approx(t, "fees.SumPrev30", fees.SumPrev30, 3000) + approx(t, "fees.Sum1y", fees.Sum1y, 9000) + + if _, ok := fetchLlamaSeries("broken", "dailyFees"); ok { + t.Fatal("404 must report !ok") + } + if _, ok := fetchLlamaSeries("empty", "dailyFees"); ok { + t.Fatal("empty chart must report !ok") + } + + m := fetchCGMarkets([]string{"tok", "nofdv", "missing"}) + if len(m) != 2 { + t.Fatalf("expected 2 market rows, got %d", len(m)) + } + approx(t, "tok.FDV", m["tok"].FDV, 2_000_000) + approx(t, "nofdv.FDV falls back to mcap", m["nofdv"].FDV, 500_000) + if _, present := m["missing"]; present { + t.Fatal("unknown id must be absent") + } + if fetchCGMarkets(nil) == nil { + t.Fatal("empty id list must return an empty map, not nil") + } + + oi := fetchLlamaOpenInterest() + approx(t, "oi tok-perps", oi["tok-perps"], 250_000) + if _, present := oi["dead"]; present { + t.Fatal("null OI must be absent") + } +} + +func TestProtocolTable_NoDuplicateSlugsOrIDs(t *testing.T) { + seen := map[string]bool{} + for _, p := range protocols { + for _, k := range []string{"slug:" + p.slug, "cg:" + p.cgID} { + if strings.HasSuffix(k, ":") { + continue + } + if seen[k] { + t.Fatalf("duplicate %s", k) + } + seen[k] = true + } + if p.llamaSlug == "" { + t.Fatalf("%s: llamaSlug required", p.slug) + } + } +} diff --git a/public/logos/apex.svg b/public/logos/apex.svg new file mode 100644 index 000000000..88a02e910 --- /dev/null +++ b/public/logos/apex.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/logos/avantis.svg b/public/logos/avantis.svg new file mode 100644 index 000000000..df2c5a2bd --- /dev/null +++ b/public/logos/avantis.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/logos/decibel.svg b/public/logos/decibel.svg new file mode 100644 index 000000000..856129f19 --- /dev/null +++ b/public/logos/decibel.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/logos/derive.svg b/public/logos/derive.svg new file mode 100644 index 000000000..57ecd3aa7 --- /dev/null +++ b/public/logos/derive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/logos/nado.svg b/public/logos/nado.svg new file mode 100644 index 000000000..8f24ff696 --- /dev/null +++ b/public/logos/nado.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/components/benchmark-body.tsx b/src/components/benchmark-body.tsx index 905eb0063..96fa5271b 100644 --- a/src/components/benchmark-body.tsx +++ b/src/components/benchmark-body.tsx @@ -842,6 +842,7 @@ export function BenchmarkBody({ @@ -889,6 +890,7 @@ export function BenchmarkBody({ diff --git a/src/components/ledger-table.tsx b/src/components/ledger-table.tsx index 8f1b28963..386d02897 100644 --- a/src/components/ledger-table.tsx +++ b/src/components/ledger-table.tsx @@ -261,10 +261,29 @@ export function LedgerTable({ .filter((r) => r.unresponsive) .sort((a, b) => b.successRate - a.successRate); + // Spec-declared unranked members (provider.unranked, e.g. "Pre-TGE" on + // a valuation bench): no headline value by design. Listed below the + // ranked field with their companion-panel values, ordered by the + // first panel-backed column so the block reads like a mini ledger. + const unrankedRows = (() => { + const rows = results.filter((r) => !!r.unrankedLabel && !r.unresponsive); + // Sort key: the first panel-backed column that actually has a value + // for these rows (ratio columns are empty for them by definition; the + // fees column is the one that carries their story). + const col = customCols?.find( + (c) => c.panel && rows.some((r) => colValueW(r, c) != null), + ); + return rows.sort((a, b) => { + const av = col ? (colValueW(a, col) ?? -Infinity) : 0; + const bv = col ? (colValueW(b, col) ?? -Infinity) : 0; + return bv - av; + }); + })(); + const sortedAll = [...results] .filter((r) => { - // Unresponsive rows render in their own unranked block below. - if (r.unresponsive) return false; + // Unresponsive and unranked rows render in their own blocks below. + if (r.unresponsive || r.unrankedLabel) return false; // Sample-health gate. Rows tagged "insufficient" by the load path // (sampleSize < 0.1 × expectedN) drop out of the ranking entirely // so the leaderboard cannot assert a position from a wildly @@ -620,6 +639,35 @@ export function LedgerTable({ embedKind={embedKind} /> ))} + {unrankedRows.map((r) => ( + ({ + v: idx === 0 ? null : colValueW(r, c), + unit: colUnit(c), + }))} + series={[]} + sparkMin={sparkMin} + sparkMax={sparkMax} + color={colors.get(r.slug) ?? "var(--color-ink-soft)"} + benchmark={benchmark} + embedChain={embedChain} + embedRegion={embedRegion} + embedKind={embedKind} + /> + ))} {unresponsiveRows.map((r) => ( 0 ? ((value - fieldValue) / fieldValue) * 100 : 0; const deltaSign = deltaPct > 0 ? "+" : deltaPct < 0 ? "−" : "±"; @@ -724,7 +776,7 @@ function Row({ {/* Unresponsive rows never take a rank number: best/worst SEO copy and badge claims are computed from healthy rows only, and a numbered dead row would contradict them. */} - {isUnresponsive ? "—" : String(i + 1).padStart(2, "0")} + {isUnresponsive || isUnranked ? "—" : String(i + 1).padStart(2, "0")} {/* itemScope/itemType marks each row as a named entity so Google's knowledge graph can link the leaderboard back to that provider. @@ -811,6 +863,14 @@ function Row({ )} + {isUnranked && ( + + + + {r.unrankedLabel} + + + )} {isUnresponsive && ( @@ -984,13 +1044,13 @@ function Row({ )} - {fieldValue > 0 ? `${deltaSign}${Math.abs(deltaPct).toFixed(0)}%` : "-"} + {isUnranked ? "—" : fieldValue > 0 ? `${deltaSign}${Math.abs(deltaPct).toFixed(0)}%` : "-"} - {r.successRate.toFixed(2)}% + {isUnranked ? "—" : `${r.successRate.toFixed(2)}%`} - {errorCount(r)?.toLocaleString("en-US") ?? "—"} + {isUnranked ? "—" : (errorCount(r)?.toLocaleString("en-US") ?? "—")} diff --git a/src/components/metric-view-tabs.tsx b/src/components/metric-view-tabs.tsx index dea39f3f6..0419a5f66 100644 --- a/src/components/metric-view-tabs.tsx +++ b/src/components/metric-view-tabs.tsx @@ -1,5 +1,6 @@ "use client"; +import { useId, useState } from "react"; import type { MetricPanel } from "@/types/benchmark"; /** @@ -7,64 +8,110 @@ import type { MetricPanel } from "@/types/benchmark"; * metric panel, plus a "Default" pill at the front that switches the chart * back to the bench's main spec-defined metric. * - * Pure presentation: holds no state. The parent (benchmark-body) owns the - * active panel id and passes it down with the swap callback. + * Each pill carries the panel's spec `description` as a tooltip (hover and + * keyboard focus), and the active view's description is repeated as a + * caption under the row so the definition is readable on touch screens, + * where there is no hover. The headline tab reads `panel_main_description`. + * + * Pure presentation: holds no state beyond which tooltip is open. The + * parent (benchmark-body) owns the active panel id and passes it down with + * the swap callback. */ export function MetricViewTabs({ panels, mainLabel, + mainDescription, activeId, onSelect, }: { panels: MetricPanel[]; mainLabel: string; + mainDescription?: string; activeId: string | null; onSelect: (id: string | null) => void; }) { + const active = activeId == null ? null : panels.find((p) => p.id === activeId); + const activeDescription = + activeId == null ? mainDescription : active?.description; + return ( -
- - View - - onSelect(null)} /> - {panels.map((p) => ( +
+
+ + View + onSelect(p.id)} - title={p.metric} + label={mainLabel} + description={mainDescription} + active={activeId == null} + onClick={() => onSelect(null)} /> - ))} + {panels.map((p) => ( + onSelect(p.id)} + /> + ))} +
+ {activeDescription && ( +

+ {activeDescription} +

+ )}
); } function Tab({ label, + description, active, onClick, - title, }: { label: string; + description?: string; active: boolean; onClick: () => void; - title?: string; }) { + const [open, setOpen] = useState(false); + const tipId = useId(); + const hasTip = !!description; + return ( - + + + {hasTip && open && ( + + + {label}:{" "} + {description} + + + )} + ); } diff --git a/src/data/bench-published.json b/src/data/bench-published.json index 0da710e19..304c1c823 100644 --- a/src/data/bench-published.json +++ b/src/data/bench-published.json @@ -55,6 +55,7 @@ "perp-funding": "2026-06-11T16:01:46.000Z", "perp-funding-stability": "2026-06-24T13:14:37.000Z", "perp-open-interest": "2026-06-24T13:14:37.000Z", + "perp-pf-ratio": "2026-09-12T13:32:43.000Z", "perp-volume-share": "2026-06-24T13:14:37.000Z", "pm-api-latency": "2026-06-16T11:48:27.000Z", "pm-rate-limits": "2026-06-16T11:48:27.000Z", diff --git a/src/data/provider-registry.ts b/src/data/provider-registry.ts index 2cab49e85..9abea557b 100644 --- a/src/data/provider-registry.ts +++ b/src/data/provider-registry.ts @@ -1753,6 +1753,37 @@ export const PROVIDER_REGISTRY: Record = { "SynFutures is an oAMM-based (Oyster AMM) perpetuals DEX on Base and Arbitrum. Traders open positions against a concentrated liquidity pool rather than a matching engine; any ERC-20 can be listed permissionlessly.", twitter: "@SynFuturesDefi", }, + // ─── Perp DEX valuation (bench 265 perp-pf-ratio) ───────────── + avantis: { + url: "https://avantisfi.com", + description: + "Avantis is a synthetic perpetuals DEX on Base covering crypto, forex and commodities. Trades settle against a USDC vault with oracle pricing; AVNT is the protocol token.", + twitter: "@avantisfi", + }, + apex: { + url: "https://apex.exchange", + description: + "ApeX is an orderbook perpetuals exchange (ApeX Omni, formerly ApeX Pro) with omnichain deposits and off-chain matching. APEX is the protocol token.", + twitter: "@OfficialApeXdex", + }, + derive: { + url: "https://derive.xyz", + description: + "Derive (formerly Lyra) is an options and perpetuals exchange on its own OP Stack rollup with an off-chain orderbook and on-chain settlement. DRV is the protocol token.", + twitter: "@derivexyz", + }, + nado: { + url: "https://nado.xyz", + description: + "Nado is an orderbook perpetuals DEX on Ink, the Kraken-backed L2, with a central limit orderbook and cross-margin. No token has launched yet.", + twitter: "@nadoHQ", + }, + decibel: { + url: "https://decibel.trade", + description: + "Decibel is a perpetuals DEX on Aptos with an on-chain central limit orderbook. No token has launched yet.", + twitter: "@DecibelTrade", + }, kiloex: { url: "https://kiloex.io", description: diff --git a/src/lib/logo-manifest.ts b/src/lib/logo-manifest.ts index ec94aba15..4c4414ca4 100644 --- a/src/lib/logo-manifest.ts +++ b/src/lib/logo-manifest.ts @@ -114,6 +114,13 @@ const RAW: Record = { kiloex: "/logos/kiloex.png", orderly: "/logos/orderly.webp", backpack: "/logos/backpack.webp", + // Perp DEX valuation bench (perp-pf-ratio); marks from each brand's own site SVGs. + avantis: "/logos/avantis.svg", + apex: "/logos/apex.svg", + derive: "/logos/derive.svg", + // Pre-TGE rows on the same bench; marks from each site, on a white tile. + nado: "/logos/nado.svg", + decibel: "/logos/decibel.svg", drift: "/logos/drift.png", vertex: "/logos/vertex.png", edgex: "/logos/edgex.jpg", diff --git a/src/lib/materialize/load.ts b/src/lib/materialize/load.ts index 760c93ce3..f1ba968b8 100644 --- a/src/lib/materialize/load.ts +++ b/src/lib/materialize/load.ts @@ -137,6 +137,7 @@ export function buildEditorial( editorialStatus: spec.status, metric: spec.metric, panelMainLabel: spec.panel_main_label, + panelMainDescription: spec.panel_main_description, stackedShare: spec.stacked_share, unit: spec.unit, higherIsBetter: spec.higher_is_better, @@ -255,6 +256,27 @@ export async function specToBenchmark( if (hasPanelData) r.availability = "live"; } } + // Spec-declared unranked members (provider.unranked, e.g. a perp + // DEX with no token yet on a valuation bench). They have no + // headline value by design, so leave ms at zero (every ranking + // helper skips zero rows) but carry the label so the ledger can + // list them in their own block instead of dropping them, and + // never let them read as unresponsive: the absence is intended. + const unrankedBySlug = new Map( + spec.providers + .filter((p) => p.unranked) + .map((p) => [p.slug.toLowerCase(), p.unranked as string]), + ); + if (unrankedBySlug.size > 0) { + for (const r of live.results) { + const label = unrankedBySlug.get(r.slug.toLowerCase()); + if (!label) continue; + r.unrankedLabel = label; + r.unresponsive = undefined; + r.availability = "live"; + r.ms = { p50: 0, p90: 0, p99: 0, mean: 0 }; + } + } } // Per-chain leaders/trailers: computed only on the unfiltered "All" // view of benches that declare `dimensions.chain`. Fan out one extra diff --git a/src/lib/providers.ts b/src/lib/providers.ts index 544a2c4e4..075c716d4 100644 --- a/src/lib/providers.ts +++ b/src/lib/providers.ts @@ -104,6 +104,10 @@ const CANONICAL_NAMES: Record = { // brand casing. gram: "Gram", tonapi: "TonAPI", + // Gains (formerly gains.trade / Gains Network, rebranded 2026-09). + // Blobs materialized before the rename still carry the old name on + // some appearances; the override wins over every one of them. + gains: "Gains", // Brand casings the title-case fallback butchers ("Drpc", "Usdc", // "Meowrpc" — SEO audit 2026-07-08). Profiles built from bench specs // now inherit the spec's provider `name` field, but canonicalize() is diff --git a/src/lib/spec-schema.ts b/src/lib/spec-schema.ts index 3dea9b58d..8ec044445 100644 --- a/src/lib/spec-schema.ts +++ b/src/lib/spec-schema.ts @@ -146,6 +146,18 @@ const provider = z.object({ * L1 and L2 providers (network-fees) renders an L1/L2/All toggle pill * that filters the ledger by this field. */ layer: ProviderLayer.optional(), + /** Declares a cohort member that has no headline value by design + * (a perp DEX with no token yet on a valuation bench). The ledger + * lists it in an "Unranked ·