diff --git a/benchmarks/perp-max-leverage-terms.yml b/benchmarks/perp-max-leverage-terms.yml index 60d3a3df6..c05efeae8 100644 --- a/benchmarks/perp-max-leverage-terms.yml +++ b/benchmarks/perp-max-leverage-terms.yml @@ -2,10 +2,10 @@ slug: perp-max-leverage-terms number: "295" -title: "Perp DEX leverage terms: room to liquidation and collateral returned" +title: "Perp DEX leverage terms: room to liquidation and what is left" seo_title: "Perp leverage: how far price moves before liquidation" -seo_description: "{{best_name}} leaves {{best_p50}} of room before liquidation, {{worst_name}} {{worst_p50}}. Room, max leverage and collateral returned for {{ranked_count}} venues at every leverage setting, live." -subtitle: "Adverse price move that liquidates a position, read from each venue's own published parameters. The selector sets the leverage: the default puts each venue at its own maximum, and the fixed settings put every venue on the same leverage so the rows are comparable. Beside the room sit the venue's top leverage and the collateral it returns." +seo_description: "At 500x a Gains position is liquidated on a 0.12% move and a Papertrade one on 0.15%, both keeping nothing. Room, collateral lost and collateral returned for 4 venues at every leverage setting, read on chain." +subtitle: "Adverse price move that liquidates a position, read from each venue's own published parameters and checked against real liquidations. The selector sets the leverage: the default puts each venue at its own maximum, which is not a like for like reading, and the fixed settings put every venue on the same leverage. No row is ranked, because more room is not the same as better terms." category: Trading status: live metric: Room to liquidation @@ -36,70 +36,76 @@ aggregate_filters: kind: BTC disclaimer: | - Every figure is a parameter the venue publishes, not a measurement of a liquidation that happened. Gains: pairMaxLeverage and getPairLiquidationParams by eth_call on the Arbitrum diamond. Papertrade: the instrument tuple on /state/trading. dYdX: both margin fractions on the indexer. Hyperliquid publishes maxLeverage but not its maintenance requirement, which its docs fix at half the initial one, so that row is derived. Carry and the liquidation spread move the real trigger closer. + Parameters each venue publishes, converted to one model and checked against liquidations that happened. Gains: threshold, max leverage and the pair fee by eth_call on the Arbitrum diamond, checked against 11 liquidations. Papertrade: the instrument tuple on /state/trading, checked as a ceiling on 384 adverse closes. dYdX: both margin fractions on the indexer. Hyperliquid publishes no maintenance requirement, so that row is derived from its documented halving rule. seo_intro: | - Four-figure leverage is the headline every perp venue leads with, and it says - almost nothing on its own. Leverage is a denominator. What a trader needs to - know is the numerator: how far the price can move against the position before - the venue closes it, and how much of the collateral comes back when it does. - Every venue here publishes both, and neither follows from the number on the - sign. - - Each venue reduces to a single parameter, the maintenance requirement it holds - back as a share of the position's notional. The room is the margin posted - minus that requirement, and the share returned is the requirement itself. What - separates the venues is what sets it. Gains ties it to the collateral, so its - room falls in exact proportion to leverage. Papertrade fixes it in price at - about five basis points, so it costs almost nothing at 10x and roughly half - the margin at 1000x. Because of that the ranking inverts depending on which - leverage you ask about, which is why the leverage selector is part of the - measurement and not a convenience. + Four figure leverage is the headline every perp venue leads with, and it says + almost nothing on its own. Leverage is a denominator. What a trader needs is + the numerator: how far the price can move against the position before the + venue closes it, and how much of the collateral comes back when it does. + + Each venue reduces to one parameter, the maintenance requirement it holds back + as a share of the position's notional, plus whatever fees come out of the same + collateral. What separates them is what sets that requirement. Gains ties it + to the collateral, so its room falls close to proportionally with leverage. + Papertrade fixes it in price at about five basis points, so it costs almost + nothing at 10x and roughly half the margin at 1000x. The ranking inverts + depending on which leverage you ask about, which is why the leverage selector + is part of the measurement rather than a convenience. + + Three of the four venues publish every input. Hyperliquid publishes its + maximum leverage but not its maintenance requirement, which its documentation + fixes at half the initial requirement, so that row alone is derived. abstract: | For each venue the bench computes the adverse price move, in percent, that liquidates a position opened at the selected leverage on the market shown, - together with the venue's maximum leverage and the share of collateral - returned on liquidation. All three come from the venue's own published - parameters, re-read every 5 minutes by the perp-cohort-stats harness. - - One model covers all four venues. A position at leverage L posts 1/L of - notional as margin and is liquidated when its equity falls to the maintenance - requirement m, also a share of notional, so the room is 1/L minus m and the - share returned is m times L. dYdX publishes m directly as - maintenanceMarginFraction. Hyperliquid's is half its initial requirement at - maximum leverage, per its documentation. Gains publishes a liquidation - threshold as a share of collateral, which makes m the complement divided by - the leverage. Papertrade publishes a bust buffer that is already a share of - price, so m does not vary with leverage at all. - - Papertrade is also the one venue where m times L is not what comes back. Its - documentation calls crossing the bust price a hard bust that forfeits the full - isolated margin, even where a voluntary close a moment earlier would have - returned some equity, so its returned share is published as a real zero. + the same figure in the unit venues quote among themselves, the venue's maximum + leverage, and the share of collateral returned on liquidation. Everything is + re-read every 5 minutes by the perp-cohort-stats harness. + + One model covers all four. A position at leverage L posts 1/L of notional as + margin and is liquidated when its equity reaches the maintenance requirement + m, also a share of notional. Any fee charged on the way in and out comes out + of that same collateral, so the price has that much less distance to travel: + + room = 1/L - m - 2 x fee collateral lost = room x L + + dYdX publishes m directly. Hyperliquid's is half its initial requirement at + maximum leverage. Gains publishes a liquidation threshold as a share of + collateral, which makes m the complement divided by the leverage, and it + charges a position size fee on both legs. Papertrade publishes a bust buffer + already expressed as a share of price, and charges nothing on either leg. + + The fee term is not a modelling choice. Gains' threshold is 80 percent of + collateral on the x500 pairs, which without fees would put the room at 0.1600 + percent of price. Eleven real liquidations fired at a median 59.4 percent of + collateral, 0.1189 percent of price, and two legs of the 0.02 percent fee at + 500x are exactly the 20 point gap. methodology: - - "Cadence: every 5 minutes, on the perp-cohort-stats sweep. These are governance parameters, so they are read fresh rather than averaged; the 24h window on the chart shows when a venue changed one." - - "Leverage selector: the default view puts each venue at its own maximum, which is not a like-for-like comparison and is labelled as such. The fixed settings put every venue on the same leverage; a venue whose maximum is below the selected setting is absent from that view rather than shown at a leverage it does not offer." + - "Cadence: every 5 minutes, on the perp-cohort-stats sweep. These are governance parameters, so they are read fresh rather than averaged, and every series is cleared each tick so a venue that lowers its maximum stops publishing the settings it no longer offers." + - "Leverage selector: the default view puts each venue at its own maximum, which compares four different leverages and is not like for like. The fixed settings put every venue on the same leverage; a venue whose maximum is below the selected setting is absent from that view rather than shown at a leverage it does not offer." - "Markets: BTC and ETH only. They are the only two markets all four venues list, and the comparison means nothing across different assets." - - "Room formula, every venue: (1/leverage - maintenance requirement) * 100, where the maintenance requirement is a share of notional. A position at leverage L posts 1/L of notional and is liquidated when its equity reaches the requirement." - - "Collateral returned, every venue except Papertrade: maintenance requirement times leverage. It is a product, not a difference, because the collateral posted is 1/leverage of notional." - - "Gains: pairMaxLeverage(uint256) and getPairLiquidationParams(uint256) on the Arbitrum diamond 0xFF162c694eAA571f685030649814282eA457f169, pair 300 for BTC and 313 for ETH, the x500 cohort rather than the standard BTC/USD and ETH/USD pairs that cap at 200x. The threshold is a share of collateral and interpolates between startLeverage and endLeverage; both bounds sit at 500x on these pairs, so it is flat at 80 percent and the room comes out as 80 divided by the leverage." - - "Papertrade: the instrument tuple on exchange.papertrade.xyz/state/trading, field 14 for max leverage and field 16 for the bust buffer at 1e18 precision. The buffer is a share of price and is an offset BEFORE the zero-equity price, not the room itself: their documentation works the 1000x case and gives a move of about 0.052 percent, which is one tenth of a percent minus the buffer." - - "Papertrade, collateral returned: zero at every leverage. Their documentation states that crossing the bust price forfeits the full isolated margin even if a voluntary close immediately beforehand could return some equity. At 1000x that forgone equity is close to half the margin, which is the gap between being liquidated and closing one tick early." - - "dYdX: initialMarginFraction and maintenanceMarginFraction from indexer.dydx.trade/v4/perpetualMarkets. Both are published per market, so nothing is derived, and the maximum leverage is the reciprocal of the initial fraction." - - "Hyperliquid: maxLeverage from the info meta endpoint. The maintenance requirement is not in the payload; Hyperliquid documents it as half the initial requirement at maximum leverage, so this row alone has a derived input. Its share returned therefore reads 50 percent at its maximum on every market, by construction." - - "Leverage shown is the venue's maximum on that market, not a tier a large position would be pushed down to. dYdX and Hyperliquid both reduce leverage above a size threshold; the rows describe the small position the maximum applies to." - - "Excluded from the room: funding, borrow fees, and the liquidation spread (0.05 percent on Gains). All three move the real trigger closer than the figure here. A position held for days is liquidated by carry long before the price reaches these levels." - - "Cohort: perp venues in the harness that publish enough to compute all three numbers on BTC and ETH, 4 as of 2026-10-12. Venues that advertise high leverage but publish no maintenance parameter we can read are absent rather than estimated." + - "Room formula, every venue: (1/leverage - maintenance requirement - 2 x fee) x 100, every term a share of notional. Collateral lost is that room times the leverage, which is the same measurement in the unit venues quote among themselves." + - "Gains, parameters: pairMaxLeverage and getPairLiquidationParams on the Arbitrum diamond 0xFF162c694eAA571f685030649814282eA457f169, pairs 300 and 313, the x500 cohort rather than the standard pairs which cap at 200x. The fee comes from the pair's fee group, word 9 of pairs() then word 0 of fees() at 1e10, the same read the perp-fees harness runs in production." + - "Gains, checked against liquidations: percentProfit on the LimitExecuted log is the price move times leverage and nothing else, and our liquidation harness reproduces it on 120 of 120 real liquidations. Eleven liquidations on the two degen pairs give a median of 59.4 percent of collateral against the 60.0 percent this model predicts. The remaining gap is the borrowing fee, which accrues with holding time and is not a term at open." + - "Gains, collateral returned: zero at every leverage. amountSentToTrader is 0 on all 27 liquidations found across every pair from 7.5x to 500x. The threshold says when a position closes, not what comes back." + - "Papertrade: max leverage and the bust buffer from the instrument tuple on exchange.papertrade.xyz/state/trading. The buffer is a share of price and is an offset before the zero equity price, not the room itself: their documentation works the 1000x case and gives a move of about 0.052 percent, which is one tenth of a percent minus the buffer." + - "Papertrade, checked against closes: its contract is unverified, so the event fields were identified against external truth rather than an ABI. The price field matches the live Hyperliquid mid under the per instrument factor; the leverage field caps at exactly 1000, the documented maximum; and under the resulting reading the predicted bust distance is never crossed on 384 adverse closes, the closest reaching 92 percent of it." + - "Papertrade, collateral returned: zero. Their documentation calls crossing the bust price a hard bust that forfeits the full isolated margin even where a voluntary close immediately beforehand could return some equity. At 1000x that forgone equity is close to half the margin." + - "dYdX: initialMarginFraction and maintenanceMarginFraction from indexer.dydx.trade/v4/perpetualMarkets, both published per market, with the maximum leverage the reciprocal of the initial fraction. Neither venue's payload carries a fee we can read, so the dYdX and Hyperliquid rows carry no fee legs and their room is the parameter ceiling rather than a fee inclusive figure." + - "Hyperliquid: maxLeverage from the info meta endpoint. The maintenance requirement is not in the payload; its documentation fixes it at half the initial requirement at maximum leverage, so this row alone has a derived input and its share returned reads 50 percent at its maximum on every market by construction." + - "No row is ranked. More room before liquidation comes from holding back less maintenance margin, and the venue that holds back least is the one that keeps everything when it fires. A single ranking on this metric would reward that, whichever venue came out on top, so every row publishes without a rank." + - "Excluded: funding, borrowing, and the liquidation spread. All three move the real trigger closer than the figures here. A position held for days is liquidated by carry long before the price reaches these levels." + - "Cohort: perp venues in the harness that publish enough to compute the figures on BTC and ETH, 4 as of 2026-10-12. Venues that advertise high leverage but publish no maintenance parameter we can read are absent rather than estimated." findings: - - "{{best_name}} leaves the most room before liquidation in this view, {{best_p50}}." - - "At 500x, a setting both offer, Gains leaves {{p50:gains}} of room and returns a fifth of the collateral while Papertrade leaves slightly less and returns nothing. Same leverage, same market, and the venue with the lower headline leverage is the one that leaves you standing longer." - - "The ranking inverts with leverage, which is why the selector matters. At 10x Papertrade leaves the most room of the four, because its buffer is about five basis points while the others hold back more than a percent of notional. At each venue's own maximum it leaves the least." - - "Gains liquidates at a fixed share of collateral, so its room is the threshold divided by the leverage and its returned share never moves: a fifth of the collateral comes back at 10x and at 500x alike." - - "{{name:dydx}} and {{name:hyperliquid}} sit within a few hundredths of a percent of each other at any shared leverage, because their maintenance requirements are close. The difference shows at their maxima, where dYdX offers more leverage and still returns more collateral." - - "Papertrade returns nothing on every row. A voluntary close a moment before the trigger would return close to half the margin at 1000x, so the cost of being liquidated rather than closing is larger there than anywhere else on the board." + - "At 500x, the only setting Gains and Papertrade both offer, a Gains position is closed on a 0.12 percent move against it and a Papertrade one on 0.15 percent. Neither returns anything. Gains' figure is the one 11 real liquidations show, not the 0.16 percent its threshold alone would imply." + - "The fee is what separates the threshold from the outcome on Gains. Its contract liquidates at 80 percent of collateral, but the position pays its 0.02 percent fee on the way in and again on the way out, and at 500x those two legs are 20 points of collateral. Liquidations fire at a median 59.4 percent, so the room is a fifth smaller than the parameter suggests." + - "The order changes with the leverage, which is why the selector is part of the measurement. At each venue's own maximum, room shrinks as leverage grows. At 10x, which all four offer, Papertrade leaves the most room and Gains the least, because Gains liquidates at a share of collateral whatever the leverage while Papertrade's buffer is five basis points of price." + - "Neither high leverage venue gives anything back. Gains returns nothing on all 27 liquidations measured from 7.5x to 500x, and Papertrade forfeits the whole margin by rule. The two margin fraction venues do return collateral: dYdX 60 percent and Hyperliquid 50 percent at their own maxima." + - "Read the same rows in collateral terms and the picture changes again. At 1000x Papertrade busts after losing 52 percent of the margin, leaving 48 percent that the hard bust then takes. At 500x it busts at 76 percent against Gains at 60 percent, so the venue that closes you later in price closes you later in collateral too." source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/perp-cohort-stats @@ -108,6 +114,14 @@ prometheus: expected_freshness_seconds: 1800 metric_panels: + - id: collateral_lost + label: Collateral lost + metric: perp_liq_collateral_lost_pct{bucket="all"} + label_key: venue + unit: pct + higher_is_better: true + tab: false + description: "The same measurement as the room, in the unit venues quote among themselves: how much of the margin is already gone when the position is closed. Room times leverage." - id: max_leverage label: Max leverage metric: perp_leverage_max{bucket="all"} @@ -123,28 +137,27 @@ metric_panels: unit: pct higher_is_better: true tab: false - description: "Share of the collateral posted that comes back when the position is liquidated, at the selected leverage. Zero on Papertrade at every leverage: crossing the bust price forfeits the whole margin." + description: "Share of the collateral that comes back when the position is liquidated. Zero on Gains, measured on 27 liquidations, and zero on Papertrade, which forfeits the whole margin by rule." ledger_columns: - { label: "Room to liq", slot: p50 } + - { label: "Collateral lost", panel: collateral_lost } - { label: "Max leverage", panel: max_leverage } - { label: "Collateral returned", panel: collateral_kept } faq: - q: "What does room to liquidation mean?" - a: "It is the percentage the price has to move against a position before the venue liquidates it, for a position opened at the selected leverage with no margin added beyond the minimum. It is measured at the moment the position opens and excludes funding and borrow fees, which push the real trigger closer every hour the position is held." - - q: "Why does the ranking change when I change the leverage?" - a: "Because venues set their maintenance requirement against different quantities. Gains ties it to collateral, so its room falls in exact proportion to leverage. Papertrade fixes it in price at about five basis points, so it barely matters at 10x and consumes roughly half the margin at 1000x. At low leverage Papertrade leaves the most room of the four; at its own maximum it leaves the least." - - q: "Which view should I read?" - a: "The fixed leverage settings, if you want to compare venues: they put every row on the same leverage, which is the only like-for-like reading. The default view puts each venue at its own maximum, which answers a different question, namely what the headline leverage number actually buys." - - q: "Is a venue with more room the better venue?" - a: "Not as a general claim, and at four-figure leverage the question is close to meaningless: every row there is liquidated by an ordinary minute of price action. The ranking is useful for the narrow comparison it makes, which is that two venues advertising the same leverage can give materially different room and materially different recovery." - - q: "Why is Papertrade's collateral returned zero at every leverage?" - a: "Because its liquidation is what its documentation calls a hard bust: crossing the bust price forfeits the full isolated margin, even if a voluntary close immediately beforehand could return some equity. At 1000x that forgone equity is close to half the margin. The zero is a real value, not a missing one." - - q: "Why is Hyperliquid's collateral returned 50 percent at its maximum?" - a: "Because Hyperliquid does not publish a per-market maintenance requirement. Its documentation fixes it at half the initial requirement at maximum leverage, so the share returned is 50 percent there by construction on every market. That is the one derived input on this bench and it is why the Hyperliquid row carries a lower confidence tier than the other three." - - q: "Do fees change these numbers?" - a: "Yes, and always in the same direction. Opening and closing fees, funding, borrow fees and the liquidation spread all reduce the room. Gains charges a liquidation spread of 0.05 percent, which on a 500x position is a third of the room shown there. The figures are the parameter ceiling, not an outcome." + a: "It is the percentage the price has to move against a position before the venue closes it, for a position opened at the selected leverage with no margin added beyond the minimum. It is measured at the moment the position opens and excludes funding and borrowing, which push the real trigger closer every hour the position is held." + - q: "Why does the order change when I change the leverage?" + a: "Because venues set their maintenance requirement against different quantities. Gains ties it to collateral, so its room falls close to proportionally with leverage. Papertrade fixes it in price at about five basis points, so it barely matters at 10x and consumes roughly half the margin at 1000x. At low leverage Papertrade leaves the most room of the four; at its own maximum it leaves the least." + - q: "Why is nothing ranked?" + a: "Because more room is not the same as better terms. Room comes from holding back less maintenance margin, and the venue that holds back least is the one that keeps everything when it fires. A single ranking would reward that, whichever venue came out on top. The rows publish their figures and the reader picks the leverage that matches the question." + - q: "Why does Gains show less room than its own threshold implies?" + a: "Because the fee comes out of the same collateral the threshold is measured against. Its contract liquidates at 80 percent of collateral, which alone would be 0.16 percent of price at 500x, but the position pays 0.02 percent of notional on the way in and again on the way out, and at 500x those two legs are 20 points. Eleven real liquidations fired at a median 59.4 percent of collateral, which is 0.1189 percent of price, against the 0.12 percent this model predicts." + - q: "Does any venue give collateral back on a liquidation?" + a: "The two margin fraction venues do: dYdX returns 60 percent and Hyperliquid 50 percent at their own maxima. Neither high leverage venue does. Gains returned nothing on all 27 liquidations measured across every pair from 7.5x to 500x, and Papertrade's documentation calls crossing the bust price a hard bust that forfeits the full isolated margin." + - q: "How is the Papertrade row checked when its contract is unverified?" + a: "Against external truth rather than an ABI. Its price field matches the live Hyperliquid mid under the per instrument factor from its own instrument tuple, its leverage field caps at exactly the 1000 its documentation states, and under the resulting reading the predicted bust distance is never crossed on 384 adverse closes, the closest reaching 92 percent of it. A ceiling respected 384 times is a measurement, not a decode that happened to look right." - q: "Why only BTC and ETH?" a: "They are the only two markets all four venues list. Papertrade lists exactly two instruments; the other three list hundreds, but comparing one venue's BTC terms against another's altcoin terms would not mean anything." @@ -153,42 +166,46 @@ providers: name: Hyperliquid tag: Custom L1 CLOB, maintenance requirement derived from the documented halving rule confidence_tier: estimate - formula: "Room = (1/leverage - 1/(2*maxLeverage)) * 100, with maxLeverage from the info meta endpoint. The maintenance requirement is not published per market; Hyperliquid documents it as half the initial requirement at max leverage." + formula: "Room = (1/leverage - 1/(2*maxLeverage)) x 100, with maxLeverage from the info meta endpoint. The maintenance requirement is not published per market; the halving rule is documented. No fee is read from this payload." queries: p50: perp_liq_price_move_pct{venue="hyperliquid",bucket="all"} success: clamp_max(perp_leverage_max{venue="hyperliquid",bucket="all"}, 1) sample_size: count_over_time(perp_leverage_max{venue="hyperliquid",bucket="all"}[24h]) series: perp_liq_price_move_pct{venue="hyperliquid",bucket="all"} + ranked: vector(0) - slug: dydx name: dYdX v4 tag: Cosmos appchain orderbook, both margin fractions published per market - confidence_tier: onchain - formula: "Room = (1/leverage - maintenanceMarginFraction) * 100 and collateral returned = maintenanceMarginFraction * leverage, both fractions read from indexer.dydx.trade/v4/perpetualMarkets." + confidence_tier: published + formula: "Room = (1/leverage - maintenanceMarginFraction) x 100, collateral returned = maintenanceMarginFraction x leverage, both fractions from indexer.dydx.trade/v4/perpetualMarkets. No fee is read from this payload." queries: p50: perp_liq_price_move_pct{venue="dydx",bucket="all"} success: clamp_max(perp_leverage_max{venue="dydx",bucket="all"}, 1) sample_size: count_over_time(perp_leverage_max{venue="dydx",bucket="all"}[24h]) series: perp_liq_price_move_pct{venue="dydx",bucket="all"} + ranked: vector(0) - slug: gains name: Gains - tag: Synthetic perps on Arbitrum, x500 cohort, threshold is a share of collateral + tag: Synthetic perps on Arbitrum, x500 cohort, threshold and fee read on chain confidence_tier: onchain - formula: "Room = liquidation threshold / leverage, read with eth_call on the Arbitrum diamond: getPairLiquidationParams and pairMaxLeverage, pair 300 (BTC) and 313 (ETH). Collateral returned = 100 minus the threshold." + formula: "Room = (threshold - 2 x fee x leverage) / leverage, all read by eth_call on the Arbitrum diamond for pairs 300 and 313. Checked against 11 liquidations: median 59.4% of collateral against 60.0% predicted." queries: p50: perp_liq_price_move_pct{venue="gains",bucket="all"} success: clamp_max(perp_leverage_max{venue="gains",bucket="all"}, 1) sample_size: count_over_time(perp_leverage_max{venue="gains",bucket="all"}[24h]) series: perp_liq_price_move_pct{venue="gains",bucket="all"} + ranked: vector(0) - slug: papertrade name: Papertrade - tag: House-sided perps on Hyperliquid, bust buffer fixed in price, hard bust + tag: House sided perps on Hyperliquid, bust buffer fixed in price, hard bust confidence_tier: onchain - formula: "Room = (1/leverage - bust buffer) * 100, the buffer from the /state/trading instrument tuple as a share of price. It offsets the zero-equity price, it is not the room. Collateral returned = 0, a hard bust." + formula: "Room = (1/leverage - bust buffer) x 100, the buffer from the /state/trading instrument tuple as a share of price. Verified as a ceiling on 384 adverse closes with no exceedance. Collateral returned = 0, a hard bust." queries: p50: perp_liq_price_move_pct{venue="papertrade",bucket="all"} success: clamp_max(perp_leverage_max{venue="papertrade",bucket="all"}, 1) sample_size: count_over_time(perp_leverage_max{venue="papertrade",bucket="all"}[24h]) series: perp_liq_price_move_pct{venue="papertrade",bucket="all"} + ranked: vector(0) diff --git a/harnesses/perp-cohort-stats/cmd/script/metrics.go b/harnesses/perp-cohort-stats/cmd/script/metrics.go index 4eb04f460..0c0f41b42 100644 --- a/harnesses/perp-cohort-stats/cmd/script/metrics.go +++ b/harnesses/perp-cohort-stats/cmd/script/metrics.go @@ -114,6 +114,13 @@ var ( }, []string{"venue", "kind", "bucket"}, ) + perpLiqCollateralLostPct = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "perp_liq_collateral_lost_pct", + Help: "Share of the collateral, in percent, already lost when the position is liquidated at the leverage in `bucket`. The same measurement as perp_liq_price_move_pct in the unit venues quote among themselves: room times leverage.", + }, + []string{"venue", "kind", "bucket"}, + ) perpLiqCollateralKeptPct = prometheus.NewGaugeVec( prometheus.GaugeOpts{ Name: "perp_liq_collateral_kept_pct", @@ -193,7 +200,7 @@ func init() { perpVenueFees30dUsd, perpVenueActiveMarkets, perpVenueTopMarketVolume24hUsd, perpVenueMarketsByClass, perpVenueNoncoreMarketsTotal, perpVenueTvlUsd, - perpLeverageMax, perpLiqPriceMovePct, perpLiqCollateralKeptPct, + perpLeverageMax, perpLiqPriceMovePct, perpLiqCollateralLostPct, perpLiqCollateralKeptPct, perpVenueHealth, perpVenueFunding24hBps, perpVenueFundingIntervalHours, perpVenueFundingRefreshUnix, perpVenueLastRefreshUnix, perpCohortFetchErrors, perpCohortSourceUsed, perpCohortDataDivergence, diff --git a/harnesses/perp-cohort-stats/cmd/script/source_leverage_terms.go b/harnesses/perp-cohort-stats/cmd/script/source_leverage_terms.go index 5fc230432..c75993798 100644 --- a/harnesses/perp-cohort-stats/cmd/script/source_leverage_terms.go +++ b/harnesses/perp-cohort-stats/cmd/script/source_leverage_terms.go @@ -63,6 +63,8 @@ const ( // harness needs no hashing dependency. selPairMaxLev = "0x281b693c" // pairMaxLeverage(uint256), 1e3 selPairLiqParam = "0x6633ced6" // getPairLiquidationParams(uint256) + selPairs = "0xb91ac788" // pairs(uint256), word 9 is the fee index + selFees = "0x4acc79ed" // fees(uint256), word 0 is the size fee at 1e10 leverageTermsUA = "OpenChainBench-PerpCohort/1.0 contact@openchainbench.com" ) @@ -99,10 +101,19 @@ var leverageBuckets = []struct { // given leverage; it takes the leverage because Gains' threshold interpolates // with it while the other three are flat. type venueTerms struct { - venue string - market string - maxLev float64 + venue string + market string + maxLev float64 + // maintenance is the requirement as a share of notional at that leverage. maintenance func(lev float64) float64 + // costPerLeg is the venue's fee as a share of notional, charged on the way + // in and again on the way out. It sits INSIDE the liquidation trigger: a + // position pays it out of the same collateral the threshold is measured + // against, so the price does not have to travel as far. Measured on Gains: + // 11 liquidations at 500x fired at a 59.4% price loss against an 80% + // threshold, and 0.02% x 500 x 2 is the 20 point gap. + costPerLeg float64 + // forfeitsAll: nothing comes back whatever the arithmetic leaves. forfeitsAll bool } @@ -113,6 +124,15 @@ func (s *LeverageTermsSource) Fetch() (*SourceResult, error) { // express a per-market, per-leverage figure, so these three gauges are // written directly. Nothing here feeds a bench the router serves. res := newSourceResult() + // Clear every series before republishing. A gauge keeps its last value + // forever, so a venue that lowered its maximum leverage would keep a row + // on the tabs it no longer offers, at 100% success, contradicting the + // bench's own rule that a venue below the selected setting is absent. + perpLeverageMax.Reset() + perpLiqPriceMovePct.Reset() + perpLiqCollateralLostPct.Reset() + perpLiqCollateralKeptPct.Reset() + var all []venueTerms all = append(all, s.fetchGains()...) all = append(all, s.fetchPapertrade()...) @@ -137,17 +157,18 @@ func publishLeverageTerms(t venueTerms) { if lev > t.maxLev { continue // the venue does not offer this setting } - room, kept, ok := termsAt(t, lev) + room, lost, kept, ok := termsAt(t, lev) if !ok { perpCohortFetchErrors.WithLabelValues(t.venue, srcLeverageTerms, "parse").Inc() continue } perpLeverageMax.WithLabelValues(t.venue, t.market, b.label).Set(t.maxLev) perpLiqPriceMovePct.WithLabelValues(t.venue, t.market, b.label).Set(room) + perpLiqCollateralLostPct.WithLabelValues(t.venue, t.market, b.label).Set(lost) perpLiqCollateralKeptPct.WithLabelValues(t.venue, t.market, b.label).Set(kept) if b.label == "all" { - fmt.Printf("[perp-cohort][%s][%s] %s at max %.0fx: room=%.4f%% kept=%.1f%%\n", - t.venue, srcLeverageTerms, t.market, t.maxLev, room, kept) + fmt.Printf("[perp-cohort][%s][%s] %s at max %.0fx: room=%.4f%% lost=%.1f%% kept=%.1f%%\n", + t.venue, srcLeverageTerms, t.market, t.maxLev, room, lost, kept) } } } @@ -155,27 +176,41 @@ func publishLeverageTerms(t venueTerms) { // termsAt is the arithmetic of the model, kept separate from the gauge writes // so it can be checked against each venue's own worked example. // -// A position at leverage L posts 1/L of notional and is liquidated when equity -// reaches the maintenance requirement m, so the room is their difference and -// the share returned is m*L. A room at or below zero would describe a position -// liquidated before it opens, which is a unit error rather than a term, so it -// is refused rather than published. -func termsAt(t venueTerms, lev float64) (roomPct, keptPct float64, ok bool) { +// A position at leverage L posts 1/L of notional and is liquidated when its +// equity reaches the maintenance requirement m. Both fee legs come out of that +// same collateral, so the price has that much less distance to travel: +// +// room = 1/L - m - 2*cost (a share of price) +// lost = room * L (a share of the collateral) +// kept = 1 - lost (zero where the venue forfeits all) +// +// Checked against Gains: threshold 80% at 500x with a 0.02% fee gives +// (0.80 - 2*0.0002*500)/500 = 0.1400% of price and 70% of collateral, against a +// measured median of 59.4% of collateral. The remaining gap is the borrowing +// fee, which accrues with holding time and is not a term at open. +func termsAt(t venueTerms, lev float64) (roomPct, lostPct, keptPct float64, ok bool) { if lev <= 0 || lev > t.maxLev { - return 0, 0, false + return 0, 0, 0, false } m := t.maintenance(lev) if m <= 0 { - return 0, 0, false + return 0, 0, 0, false } - room := (1/lev - m) * 100 + room := (1/lev - m - 2*t.costPerLeg/100) * 100 if room <= 0 { - return 0, 0, false + // The fees alone exhaust the margin, so the position cannot be opened + // at this leverage on these terms. Refuse rather than publish a + // negative or a zero that would read as "liquidated instantly". + return 0, 0, 0, false + } + lost := room * lev + if lost <= 0 || lost > 100 { + return 0, 0, 0, false } if t.forfeitsAll { - return room, 0, true + return room, lost, 0, true } - return room, m * lev * 100, true + return room, lost, 100 - lost, true } // ---------------------------------------------------------------- Gains @@ -209,6 +244,12 @@ func (s *LeverageTermsSource) fetchGains() []venueTerms { perpCohortFetchErrors.WithLabelValues("gains", srcLeverageTerms, "parse").Inc() continue } + fee, err := s.gainsPairFee(idx) + if err != nil { + perpCohortFetchErrors.WithLabelValues("gains", srcLeverageTerms, "parse").Inc() + fmt.Printf("[perp-cohort][gains][%s] fee %s: %v\n", srcLeverageTerms, market, err) + continue + } out = append(out, venueTerms{ venue: "gains", market: market, @@ -219,11 +260,54 @@ func (s *LeverageTermsSource) fetchGains() []venueTerms { th := interpolateThreshold(startTh, endTh, startLev, endLev, lev) return (100 - th) / 100 / lev }, + costPerLeg: fee, + // Measured, not assumed: amountSentToTrader is 0 on all 27 + // liquidations found across every pair and every leverage from + // 7.5x to 500x. The threshold says when the position closes, not + // what comes back, and what comes back is nothing. + forfeitsAll: true, }) } return out } +// gainsPairFee reads the pair's position-size fee as a percentage of notional. +// +// pairs(uint256) returns the pair struct; word 9 is its fee index, the same +// offset the perp-fees harness reads in production. fees(uint256) then returns +// the fee group, whose word 0 is the size fee at 1e10 precision: 350_000_000 +// is 0.035%, which is the worked example in that harness. +// +// The fee matters here because it sits inside the liquidation trigger. On the +// x500 degen pairs it reads 0.02%, and two legs of it at 500x are 20 points of +// collateral, which is exactly the gap between the 80% threshold and the 59.4% +// median that 11 real liquidations show. +func (s *LeverageTermsSource) gainsPairFee(pairIdx int64) (float64, error) { + pw, err := s.gainsCall(selPairs, pairIdx) + if err != nil { + return 0, err + } + if len(pw) < 10 { + return 0, fmt.Errorf("pairs(%d) returned %d words", pairIdx, len(pw)) + } + feeIdx := pw[9].Int64() + fw, err := s.gainsCall(selFees, feeIdx) + if err != nil { + return 0, err + } + if len(fw) < 1 { + return 0, fmt.Errorf("fees(%d) returned nothing", feeIdx) + } + fee := bigToFloat(fw[0], 1e10) + // A fee at or above one percent of notional would be an order of magnitude + // out and would swallow the trigger at high leverage; refuse rather than + // publish a room computed from a misread scale. + if fee < 0 || fee > 1 { + return 0, fmt.Errorf("fee index %d reads %.6f%%, outside a plausible range", feeIdx, fee) + } + return fee, nil +} + // interpolateThreshold mirrors getLiqPnlThresholdP in the gTrade contracts: the // liquidation threshold is flat below startLeverage, flat above endLeverage, // and linear between the two. A degen pair sets both bounds to the same value, diff --git a/harnesses/perp-cohort-stats/cmd/script/source_leverage_terms_test.go b/harnesses/perp-cohort-stats/cmd/script/source_leverage_terms_test.go index 0f4298951..3f7b936de 100644 --- a/harnesses/perp-cohort-stats/cmd/script/source_leverage_terms_test.go +++ b/harnesses/perp-cohort-stats/cmd/script/source_leverage_terms_test.go @@ -56,7 +56,9 @@ func ptTuple(t *testing.T, raw string) []json.RawMessage { // gainsVenue builds the venueTerms fetchGains would build from a decoded // return, so the tests exercise the same closure the harness publishes from. -func gainsVenue(t *testing.T, hexStr string, maxLev float64) venueTerms { +// The fee is the pair's position-size fee read from its fee group: 0.02% on the +// degen pairs, 0.035% on the standard ones. +func gainsVenue(t *testing.T, hexStr string, maxLev, feePerLeg float64) venueTerms { t.Helper() w := words(t, hexStr) if len(w) != 5 { @@ -71,9 +73,23 @@ func gainsVenue(t *testing.T, hexStr string, maxLev float64) venueTerms { maintenance: func(lev float64) float64 { return (100 - interpolateThreshold(startTh, endTh, startLev, endLev, lev)) / 100 / lev }, + costPerLeg: feePerLeg, + forfeitsAll: true, } } +// papertradeVenue mirrors fetchPapertrade: no fee at either leg, a buffer fixed +// in price, and a hard bust. +func papertradeVenue(t *testing.T, raw string) venueTerms { + t.Helper() + sym, lev, buf, ok := papertradeTerms(ptTuple(t, raw)) + if !ok { + t.Fatalf("papertrade fixture rejected") + } + return venueTerms{venue: "papertrade", market: sym, maxLev: lev, + maintenance: func(float64) float64 { return buf }, forfeitsAll: true} +} + // TestGainsScaleCalibration is the anchor: the standard pair must read as the // documented 90 percent, which fixes the 1e10 scale for every other pair. func TestGainsScaleCalibration(t *testing.T) { @@ -107,24 +123,74 @@ func TestGainsThresholdInterpolates(t *testing.T) { } } -// TestGainsRoomScalesWithLeverage: because the threshold is a share of -// collateral and is flat on a degen pair, the room falls in exact proportion to -// leverage while the share returned never moves. That is the property the bench -// contrasts Papertrade against. +// TestGainsRoomMatchesWhatLiquidationsShow is the test the measurement earned. +// +// The contract's threshold is 80% of collateral, which would put the room at +// 0.1600% of price at 500x. Eleven real liquidations on pairs 300 and 313 fired +// at a median 59.4% of collateral, which is 0.1189% of price. The gap is both +// fee legs: 0.02% x 500 x 2 is 20 points of collateral, and 80 - 20 is 60. The +// model therefore has to predict 60.0% and 0.1200%, within a point of the +// measurement, the remainder being borrowing which accrues with holding time +// and is not a term at open. +func TestGainsRoomMatchesWhatLiquidationsShow(t *testing.T) { + g := gainsVenue(t, degenLiqParams, 500, 0.02) + room, lost, kept, ok := termsAt(g, 500) + if !ok { + t.Fatal("rejected at 500x") + } + approx(t, "room at 500x", room, 0.12, 1e-9) + approx(t, "collateral lost at 500x", lost, 60, 1e-9) + approx(t, "collateral kept", kept, 0, 0) + + // Against the measurement, loosely: the model must land within a point of + // the median, and must NOT reproduce the fee-free 0.16%. + const measuredLostPct = 59.44 + if diff := lost - measuredLostPct; diff < 0 || diff > 1.5 { + t.Errorf("model says %.2f%% of collateral, measured median %.2f%%, gap %.2f points", + lost, measuredLostPct, diff) + } + if room > 0.15 { + t.Errorf("room %.4f%% is back at the fee-free figure, the fee legs are missing", room) + } +} + +// TestGainsRoomScalesWithLeverage: the threshold is a share of collateral and +// flat on a degen pair, so the room falls close to proportionally with +// leverage. The fee legs bend it slightly, and the bend grows with leverage +// because the fee is a share of notional. func TestGainsRoomScalesWithLeverage(t *testing.T) { - g := gainsVenue(t, degenLiqParams, 500) - for _, c := range []struct{ lev, room, kept float64 }{ - {500, 0.16, 20}, // at the maximum - {100, 0.8, 20}, - {50, 1.6, 20}, - {10, 8, 20}, + g := gainsVenue(t, degenLiqParams, 500, 0.02) + for _, c := range []struct{ lev, room, lost float64 }{ + {500, 0.12, 60.0}, + {200, 0.36, 72.0}, + {100, 0.76, 76.0}, + {50, 1.56, 78.0}, + {10, 7.96, 79.6}, } { - room, kept, ok := termsAt(g, c.lev) + room, lost, kept, ok := termsAt(g, c.lev) if !ok { t.Fatalf("gains rejected at %.0fx", c.lev) } approx(t, "gains room", room, c.room, 1e-9) - approx(t, "gains kept", kept, c.kept, 1e-9) + approx(t, "gains collateral lost", lost, c.lost, 1e-9) + approx(t, "gains collateral kept", kept, 0, 0) + } +} + +// TestGainsReturnsNothing states the measurement plainly: amountSentToTrader +// was 0 on all 27 liquidations found, every pair, 7.5x to 500x. The 20 points +// the threshold leaves never reach the trader, so the column is a real zero at +// every leverage and not a figure derived from the threshold. +func TestGainsReturnsNothing(t *testing.T) { + g := gainsVenue(t, degenLiqParams, 500, 0.02) + for _, lev := range []float64{10, 50, 100, 500} { + _, _, kept, ok := termsAt(g, lev) + if !ok { + t.Fatalf("rejected at %.0fx", lev) + } + if kept != 0 { + t.Errorf("gains returned %.2f%% at %.0fx, measurement says nothing comes back", kept, lev) + } } } @@ -146,15 +212,17 @@ func TestPapertradeBufferIsAnOffsetNotTheRoom(t *testing.T) { // About 5 basis points of price, which is what the docs call it. approx(t, "buffer in percent", buffer*100, 0.0476041580087574, 1e-12) - pt := venueTerms{venue: "papertrade", market: sym, maxLev: lev, - maintenance: func(float64) float64 { return buffer }, forfeitsAll: true} + pt := papertradeVenue(t, ptBTCInstrument) - room, kept, ok := termsAt(pt, 1000) + room, lost, kept, ok := termsAt(pt, 1000) if !ok { t.Fatal("rejected at 1000x") } // The docs' own worked figure, to their stated precision. approx(t, "room at 1000x", room, 0.052, 5e-4) + // And in the unit venues quote among themselves: Sacha at Gains read this + // as "almost 50% of collat", which is this number. + approx(t, "collateral lost at 1000x", lost, 52.4, 0.1) // And not the buffer itself, which is the mistake this test guards. if math.Abs(room-buffer*100) < 1e-6 { t.Errorf("room %.6f equals the buffer, the offset was read as the room", room) @@ -181,13 +249,12 @@ func TestPapertradeBufferCostsMoreAsLeverageGrows(t *testing.T) { t.Errorf("at max leverage the buffer should eat most of the margin, got %.1f%%", buffer*lev*100) } - pt := venueTerms{venue: "papertrade", maxLev: lev, - maintenance: func(float64) float64 { return buffer }, forfeitsAll: true} + pt := papertradeVenue(t, ptBTCInstrument) // Room shrinks with leverage like everywhere else; it is the buffer that // is constant, not the room. The first version of this source had that // backwards and claimed the room was identical at 1x and 1000x. - r10, _, _ := termsAt(pt, 10) - r1000, _, _ := termsAt(pt, 1000) + r10, _, _, _ := termsAt(pt, 10) + r1000, _, _, _ := termsAt(pt, 1000) if !(r10 > r1000) { t.Errorf("room should shrink with leverage: 10x=%.4f 1000x=%.4f", r10, r1000) } @@ -224,23 +291,27 @@ func TestPapertradeTermsGuardsTupleShape(t *testing.T) { // TestMarginFractionVenues covers dYdX, whose two fractions are published, and // Hyperliquid, whose maintenance requirement comes from the documented halving -// rule. The trap is that the share returned is a ratio of the two fractions and -// not their difference, because the collateral posted is the initial one. +// rule. Neither publishes a fee in the payload we read, so their rows carry no +// fee legs and the bench says so. +// +// The trap is that the share returned is a ratio of the two fractions and not +// their difference, because the collateral posted is the initial one. func TestMarginFractionVenues(t *testing.T) { // dYdX BTC-USD, read live 2026-10-12: IMF 0.02, MMF 0.012. dydx := venueTerms{venue: "dydx", maxLev: 1 / 0.02, maintenance: func(float64) float64 { return 0.012 }} approx(t, "dydx max leverage", dydx.maxLev, 50, 1e-9) - for _, c := range []struct{ lev, room, kept float64 }{ - {50, 0.8, 60}, - {25, 2.8, 30}, - {10, 8.8, 12}, + for _, c := range []struct{ lev, room, lost, kept float64 }{ + {50, 0.8, 40, 60}, + {25, 2.8, 70, 30}, + {10, 8.8, 88, 12}, } { - room, kept, ok := termsAt(dydx, c.lev) + room, lost, kept, ok := termsAt(dydx, c.lev) if !ok { t.Fatalf("dydx rejected at %.0fx", c.lev) } approx(t, "dydx room", room, c.room, 1e-9) + approx(t, "dydx lost", lost, c.lost, 1e-9) approx(t, "dydx kept", kept, c.kept, 1e-9) } @@ -249,85 +320,102 @@ func TestMarginFractionVenues(t *testing.T) { // whatever the asset. hl := venueTerms{venue: "hyperliquid", maxLev: 40, maintenance: func(float64) float64 { return 1.0 / 80 }} - for _, c := range []struct{ lev, room, kept float64 }{ - {40, 1.25, 50}, - {10, 8.75, 12.5}, + for _, c := range []struct{ lev, room, lost, kept float64 }{ + {40, 1.25, 50, 50}, + {10, 8.75, 87.5, 12.5}, } { - room, kept, ok := termsAt(hl, c.lev) + room, lost, kept, ok := termsAt(hl, c.lev) if !ok { t.Fatalf("hyperliquid rejected at %.0fx", c.lev) } approx(t, "hl room", room, c.room, 1e-9) + approx(t, "hl lost", lost, c.lost, 1e-9) approx(t, "hl kept", kept, c.kept, 1e-9) } } -// TestTermsAtRefusesImpossible: a leverage the venue does not offer, or a -// maintenance requirement that swallows the whole margin, is refused rather -// than published. A venue that published a leverage but no room would read as -// the safest row on the board. +// TestTermsAtRefusesImpossible: a leverage the venue does not offer, or costs +// that swallow the whole margin, is refused rather than published. A venue that +// published a leverage but no room would read as the safest row on the board. func TestTermsAtRefusesImpossible(t *testing.T) { v := venueTerms{maxLev: 40, maintenance: func(float64) float64 { return 1.0 / 80 }} for _, lev := range []float64{0, -10, 41, 1000} { - if _, _, ok := termsAt(v, lev); ok { + if _, _, _, ok := termsAt(v, lev); ok { t.Errorf("accepted leverage %.0f on a 40x venue", lev) } } - // Maintenance at or above 1/L: liquidated before it opens. tight := venueTerms{maxLev: 100, maintenance: func(float64) float64 { return 0.02 }} - if _, _, ok := termsAt(tight, 100); ok { + if _, _, _, ok := termsAt(tight, 100); ok { t.Error("accepted a maintenance requirement above the margin posted") } - if _, _, ok := termsAt(venueTerms{maxLev: 10, maintenance: func(float64) float64 { return 0 }}, 10); ok { + if _, _, _, ok := termsAt(venueTerms{maxLev: 10, maintenance: func(float64) float64 { return 0 }}, 10); ok { t.Error("accepted a zero maintenance requirement") } + // Fees alone can exhaust the margin at high leverage: a 0.2% fee a leg at + // 250x is the whole collateral twice over. + feeHeavy := venueTerms{maxLev: 500, costPerLeg: 0.2, + maintenance: func(lev float64) float64 { return 0.2 / 100 / lev }} + if _, _, _, ok := termsAt(feeHeavy, 250); ok { + t.Error("accepted a position whose fees exceed its margin") + } } // TestRankingInvertsWithLeverage is the finding the leverage selector exists -// for, and the reason the bench cannot be published at max leverage alone. +// for, and the reason the bench publishes no single ranking. +// +// At each venue's OWN maximum the room shrinks as leverage grows, which reads +// as a ranking but compares four different leverages. At a leverage they all +// offer the order is different, and Papertrade leads because its maintenance +// requirement is five basis points of price where the others hold back more +// than a percent of notional. // -// At each venue's OWN maximum, Papertrade has the least room of the four. At a -// leverage they all offer, it has the MOST, because its buffer is five basis -// points while the others hold back more than a percent. The ranking inverts, -// and only the collateral column stays damning throughout. +// Measured, not modelled, on the Gains side: its room at 500x is the one 11 +// real liquidations show, and Papertrade's is a ceiling 384 adverse closes +// never crossed. func TestRankingInvertsWithLeverage(t *testing.T) { - _, ptLev, buffer, ok := papertradeTerms(ptTuple(t, ptBTCInstrument)) - if !ok { - t.Fatal("papertrade rejected") - } - pt := venueTerms{venue: "papertrade", maxLev: ptLev, - maintenance: func(float64) float64 { return buffer }, forfeitsAll: true} - gains := gainsVenue(t, degenLiqParams, 500) + pt := papertradeVenue(t, ptBTCInstrument) + gains := gainsVenue(t, degenLiqParams, 500, 0.02) dydx := venueTerms{venue: "dydx", maxLev: 50, maintenance: func(float64) float64 { return 0.012 }} hl := venueTerms{venue: "hyperliquid", maxLev: 40, maintenance: func(float64) float64 { return 1.0 / 80 }} // At each venue's own maximum: more leverage, less room, monotonically. - ptMax, _, _ := termsAt(pt, pt.maxLev) - gMax, gKeptMax, _ := termsAt(gains, gains.maxLev) - dMax, dKeptMax, _ := termsAt(dydx, dydx.maxLev) - hMax, hKeptMax, _ := termsAt(hl, hl.maxLev) + ptMax, _, ptKeptMax, _ := termsAt(pt, pt.maxLev) + gMax, _, gKeptMax, _ := termsAt(gains, gains.maxLev) + dMax, _, dKeptMax, _ := termsAt(dydx, dydx.maxLev) + hMax, _, hKeptMax, _ := termsAt(hl, hl.maxLev) if !(ptMax < gMax && gMax < dMax && dMax < hMax) { t.Errorf("at max leverage the room should shrink as leverage grows: pt=%.4f gains=%.4f dydx=%.4f hl=%.4f", ptMax, gMax, dMax, hMax) } - // At 10x, which all four offer, the order reverses for Papertrade. - pt10, pt10Kept, _ := termsAt(pt, 10) - g10, _, _ := termsAt(gains, 10) - d10, _, _ := termsAt(dydx, 10) - h10, _, _ := termsAt(hl, 10) - if !(pt10 > d10 && pt10 > h10 && pt10 > g10) { - t.Errorf("at 10x papertrade should have the most room: pt=%.4f gains=%.4f dydx=%.4f hl=%.4f", - pt10, g10, d10, h10) - } - if !(g10 < d10 && g10 < h10) { - t.Errorf("at 10x gains should have the least room, it liquidates at 80%% of collateral at any leverage: gains=%.4f dydx=%.4f hl=%.4f", - g10, d10, h10) - } - // The collateral column does not invert: Papertrade returns nothing at - // every leverage, and the other three always return something. - approx(t, "papertrade kept at 10x", pt10Kept, 0, 0) - for name, kept := range map[string]float64{"gains": gKeptMax, "dydx": dKeptMax, "hyperliquid": hKeptMax} { + // At 500x, the only setting Gains and Papertrade both offer, Papertrade + // leads once Gains' fee legs are counted. Before the fees were counted the + // bench published 0.1600 for Gains and had this the other way round. + g500, _, _, _ := termsAt(gains, 500) + p500, _, _, _ := termsAt(pt, 500) + approx(t, "gains at 500x", g500, 0.12, 1e-9) + approx(t, "papertrade at 500x", p500, 0.1523958419912426, 1e-9) + if !(p500 > g500) { + t.Errorf("at 500x papertrade should leave more room: pt=%.4f gains=%.4f", p500, g500) + } + + // At 10x, which all four offer, Gains is last: it liquidates at a share of + // collateral whatever the leverage, which is harsh when leverage is low. + pt10, _, _, _ := termsAt(pt, 10) + g10, _, _, _ := termsAt(gains, 10) + d10, _, _, _ := termsAt(dydx, 10) + h10, _, _, _ := termsAt(hl, 10) + if !(pt10 > d10 && d10 > h10 && h10 > g10) { + t.Errorf("at 10x expected pt > dydx > hl > gains, got pt=%.4f dydx=%.4f hl=%.4f gains=%.4f", + pt10, d10, h10, g10) + } + + // The collateral column does not invert, and it is the one where the two + // high-leverage venues are alike: neither returns anything, measured on + // both sides. The two margin-fraction venues do return something. + approx(t, "papertrade kept at its max", ptKeptMax, 0, 0) + approx(t, "gains kept at its max", gKeptMax, 0, 0) + for name, kept := range map[string]float64{"dydx": dKeptMax, "hyperliquid": hKeptMax} { if kept <= 0 { t.Errorf("%s should return collateral, got %.2f", name, kept) } @@ -379,7 +467,7 @@ func TestLeverageTermsLive(t *testing.T) { if v.maxLev < 10 { t.Errorf("%s/%s max leverage = %.0fx, implausibly low", venue, v.market, v.maxLev) } - room, kept, ok := termsAt(v, v.maxLev) + room, lost, kept, ok := termsAt(v, v.maxLev) if !ok { t.Errorf("%s/%s produced no terms at its own maximum", venue, v.market) continue @@ -387,10 +475,24 @@ func TestLeverageTermsLive(t *testing.T) { if room <= 0 || room > 15 { t.Errorf("%s/%s room = %.4f%%, outside a plausible range", venue, v.market, room) } + if lost <= 0 || lost > 100 { + t.Errorf("%s/%s collateral lost = %.2f%%, outside a plausible range", venue, v.market, lost) + } if kept < 0 || kept >= 100 { t.Errorf("%s/%s kept = %.2f%%, outside a plausible range", venue, v.market, kept) } - t.Logf("%-12s %-4s %5.0fx room=%.4f%% kept=%.1f%%", venue, v.market, v.maxLev, room, kept) + // The two units must agree by construction: lost is room times + // leverage. A mismatch means one of them was computed elsewhere. + if diff := lost - room*v.maxLev; diff > 1e-9 || diff < -1e-9 { + t.Errorf("%s/%s units disagree: lost=%.4f, room*lev=%.4f", venue, v.market, lost, room*v.maxLev) + } + // Gains is measured to return nothing; so is Papertrade by its + // hard bust rule. + if (venue == "gains" || venue == "papertrade") && kept != 0 { + t.Errorf("%s/%s returned %.2f%%, measurement says nothing comes back", venue, v.market, kept) + } + t.Logf("%-12s %-4s %5.0fx room=%.4f%% lost=%.1f%% kept=%.1f%%", + venue, v.market, v.maxLev, room, lost, kept) } } // The source must reach every venue over the network, not just parse.