diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 37f40082a30..ef743a2258c 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Stop `previewPositionModify` from re-margining an open isolated position when the order selects a different leverage + - The preview applied the selected leverage to the whole resulting position, modelling `updateLeverage` as if it reallocated collateral already posted. HyperLiquid does not do this: leverage is checked when a position is opened, and changing it afterwards leaves `marginUsed` where it is. Adding to a position at a higher leverage was therefore projected as a margin _release_ — an over-collateralized position adding $15 at 28x previewed $3.43 → $0.85 where the venue settled at $3.95. + - The selected leverage now sizes the margin posted for the added fill only, and a partial decrease releases a proportional share of the position's current collateral. Because posted collateral is untouched, `resulting.leverage` can sit below (or above) the leverage the order selected; clients showing a before → after margin should expect an increase to raise it. - Handle zero minimum order amounts and margin fractions reported by Lighter for inactive markets by omitting unusable retired rows, while keeping valid delisted metadata and active market values strict. ([#10110](https://github.com/MetaMask/core/pull/10110)) ## [16.1.0] diff --git a/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts b/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts index 7dcf2d452a1..be329465008 100644 --- a/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts +++ b/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts @@ -301,18 +301,24 @@ const resultingLeverage = (params: { /** * Projects the isolated position that would remain after a proposed order. * - * Models HyperLiquid's isolated `updateLeverage` (the selected leverage is - * applied to the whole asset before the fill) and maintenance tiers at the - * resulting liquidation notional. Liquidation uses the projected mark, not - * average entry, because isolated `marginUsed` is mark-based equity. - * Cross-margin positions return `{ status: 'unsupported', reason: 'cross_margin' }`. + * The selected leverage sizes the margin posted for the new fill only. + * HyperLiquid's isolated `updateLeverage` does not re-margin collateral that is + * already posted, so a leverage change leaves the open position's `marginUsed` + * where it is — raising leverage before adding to a position does not release + * margin, it only makes the added size cheaper. The resulting position can + * therefore sit at a lower effective leverage than the one selected. + * + * Maintenance tiers are applied at the resulting liquidation notional. + * Liquidation uses the projected mark, not average entry, because isolated + * `marginUsed` is mark-based equity. Cross-margin positions return + * `{ status: 'unsupported', reason: 'cross_margin' }`. * * `price` is the fill or resting-limit price the caller expects. A marketable * order should pass its execution price; a limit should pass the limit. The * preview does not distinguish order types itself, does not model whether a * resting limit would fill, and treats scale/TWAP/chase as one aggregated fill. - * Decrease margin is the remaining isolated collateral after leverage - * reallocation; close fees and realized PnL settle to the account, not the + * Decrease margin is the proportional share of the position's current isolated + * collateral; close fees and realized PnL settle to the account, not the * leftover margin. * * @param params - Live isolated position, proposed order, and optional tiers. @@ -368,12 +374,6 @@ export function previewHyperLiquidIsolatedPositionModify( ? positionValue : currentSize * currentEntry; - const leverageChanged = - Math.abs(selectedLeverage - currentLeverage) > SIZE_EPSILON; - const existingMarginAfterLeverage = leverageChanged - ? currentNotional / selectedLeverage - : currentMargin; - const withResultingLiquidation = (preview: { kind: 'increase' | 'decrease' | 'flip'; resultingDirection: 'long' | 'short'; @@ -430,10 +430,7 @@ export function previewHyperLiquidIsolatedPositionModify( const resultingSize = currentSize + orderSize; const resultingEntryPrice = (currentSize * currentEntry + orderSize * fillPrice) / resultingSize; - const newMargin = Math.max( - 0, - existingMarginAfterLeverage + orderMargin - feeAmountUsd, - ); + const newMargin = Math.max(0, currentMargin + orderMargin - feeAmountUsd); return withResultingLiquidation({ kind: 'increase', @@ -449,7 +446,7 @@ export function previewHyperLiquidIsolatedPositionModify( if (orderSize + SIZE_EPSILON < currentSize) { const remainingRatio = (currentSize - orderSize) / currentSize; const resultingSize = currentSize - orderSize; - const newMargin = Math.max(0, existingMarginAfterLeverage * remainingRatio); + const newMargin = Math.max(0, currentMargin * remainingRatio); const currentMarkPrice = currentNotional / currentSize; const resultingMarkPrice = fillPrice ?? currentMarkPrice; diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts index 340d32c988b..864e848b69d 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts @@ -1124,7 +1124,7 @@ describe('HyperLiquidProvider', () => { expect(result.kind).toBe('increase'); expect(result.resulting.margin).toStrictEqual({ available: true, - value: 300, + value: 500, }); expect(result.resulting.direction).toBe('long'); }); @@ -1160,7 +1160,7 @@ describe('HyperLiquidProvider', () => { } expect(result.resulting.margin).toStrictEqual({ available: true, - value: 300, + value: 500, }); expect(result.resulting.liquidationPrice).toStrictEqual({ available: false, diff --git a/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts b/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts index ff832636b81..f5ac66d60d0 100644 --- a/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts +++ b/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts @@ -217,7 +217,7 @@ describe('previewHyperLiquidIsolatedPositionModify', () => { }); }); - it('reallocates the existing isolated position when order leverage differs', () => { + it('margins only the added size when order leverage differs', () => { const result = previewHyperLiquidIsolatedPositionModify({ position: isolatedPosition(), direction: 'long', @@ -232,27 +232,21 @@ describe('previewHyperLiquidIsolatedPositionModify', () => { return; } expect(result.kind).toBe('increase'); - // Existing 5x $400 is reset to $200 at 10x, then $100 is added for the order. + // The existing 5x $400 stays posted; only the added $1000 is margined at + // 10x. Raising leverage does not hand any of the $400 back. expect(result.resulting.margin).toStrictEqual({ available: true, - value: 300, + value: 500, }); - expect(result.resulting.leverage).toBeCloseTo(10); - const liquidationPrice = availablePreviewValue( - result.resulting.liquidationPrice, - ); - const overstatedMarginLiq = estimateIsolatedLiquidationPrice({ - isLong: true, - markPrice: 2000, - margin: 500, - positionSize: 1.5, - maintenanceMarginRate: 1 / 50, - }); - expect(overstatedMarginLiq).not.toBeNull(); - expect(liquidationPrice).toBeGreaterThan(overstatedMarginLiq ?? 0); + // $3000 notional on $500 sits at 6x, below the 10x the order selected. + expect(result.resulting.leverage).toBeCloseTo(6); + // (2000 - 500/1.5) / (1 - 1/50) = 1700.680... + expect( + availablePreviewValue(result.resulting.liquidationPrice), + ).toBeCloseTo(1700.6802721088); }); - it('reports mark-based leverage when entry differs from mark after a leverage change', () => { + it('reports mark-based leverage when entry differs from mark', () => { const result = previewHyperLiquidIsolatedPositionModify({ position: isolatedPosition({ entryPrice: '2000', @@ -272,15 +266,15 @@ describe('previewHyperLiquidIsolatedPositionModify', () => { } expect(result.resulting.margin).toStrictEqual({ available: true, - value: 375, + value: 625, }); - expect(result.resulting.leverage).toBeCloseTo(10); + expect(result.resulting.leverage).toBeCloseTo(6); expect(result.resulting.entryPrice).toBeCloseTo(2166.6666667); - // Mark-based liq: (2500 - 375/1.5) / (1 - 1/50) = 2295.918... - // Entry-based liq would be ~1955.78 and is wrong for TP/SL. + // Mark-based liq: (2500 - 625/1.5) / (1 - 1/50) = 2125.850... + // Entry-based liq would be ~1785.71 and is wrong for TP/SL. expect( availablePreviewValue(result.resulting.liquidationPrice), - ).toBeCloseTo(2295.9183673469); + ).toBeCloseTo(2125.8503401361); }); it('projects a partial decrease using the remaining position direction', () => { @@ -337,7 +331,7 @@ describe('previewHyperLiquidIsolatedPositionModify', () => { ).toBeCloseTo(1428.5714285714); }); - it('reallocates before a partial decrease when leverage changes', () => { + it('releases margin proportionally on a partial decrease when leverage changes', () => { const result = previewHyperLiquidIsolatedPositionModify({ position: isolatedPosition(), direction: 'short', @@ -351,9 +345,10 @@ describe('previewHyperLiquidIsolatedPositionModify', () => { if (result.status !== 'open') { return; } + // 60% of the posted $400. A leverage change does not resize what is left. expect(result.resulting.margin).toStrictEqual({ available: true, - value: 120, + value: 240, }); expect(result.resulting.direction).toBe('long'); }); @@ -616,7 +611,7 @@ describe('previewHyperLiquidIsolatedPositionModify', () => { }); }); - it('strips extra isolated margin when leverage increases', () => { + it('keeps extra isolated margin when the order raises leverage', () => { const result = previewHyperLiquidIsolatedPositionModify({ position: isolatedPosition({ marginUsed: '800' }), direction: 'long', @@ -630,13 +625,55 @@ describe('previewHyperLiquidIsolatedPositionModify', () => { if (result.status !== 'open') { return; } + // An over-collateralized position raising leverage to add size still ends + // up with more margin than it started with, never less. expect(result.resulting.margin).toStrictEqual({ available: true, - value: 300, + value: 900, }); }); - it('adds isolated margin when selected leverage is lower than the position', () => { + it('projects a rise, not a drop, when an over-collateralized position adds size at higher leverage', () => { + // Reported case: a BTC long opened at 20x still holding $3.43 against a + // $9.50 notional, adding $15 at 28x. Re-margining the open position at 28x + // predicted $3.43 → $0.86; the venue settled at $3.95, because the $3.43 + // already posted is never handed back when leverage changes. + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + symbol: 'BTC', + size: '0.000095', + entryPrice: '100000', + positionValue: '9.5', + marginUsed: '3.43', + leverage: { type: 'isolated', value: 20 }, + liquidationPrice: '64703', + maxLeverage: 40, + }), + direction: 'long', + size: '0.00015', + price: '100000', + leverage: 28, + feeAmountUsd: 0.015, + marginTiers: [{ lowerBound: 0, maxLeverage: 40 }], + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('increase'); + expect(availablePreviewValue(result.current.margin)).toBeCloseTo(3.43, 2); + // $3.43 + $15/28 - $0.015 fee. Reallocating the $9.50 notional at 28x + // instead would give $9.50/28 + $15/28 - $0.015 = the reported $0.86. + expect(availablePreviewValue(result.resulting.margin)).toBeCloseTo(3.95, 2); + expect(availablePreviewValue(result.resulting.margin)).toBeGreaterThan( + availablePreviewValue(result.current.margin), + ); + // $24.50 notional on $3.95 is 6.2x, not the 28x the order asked for. + expect(result.resulting.leverage).toBeCloseTo(6.2, 1); + }); + + it('margins only the added size when selected leverage is lower than the position', () => { const result = previewHyperLiquidIsolatedPositionModify({ position: isolatedPosition({ leverage: { type: 'isolated', value: 10 }, @@ -653,12 +690,14 @@ describe('previewHyperLiquidIsolatedPositionModify', () => { if (result.status !== 'open') { return; } - // Existing $2000 / 5 = $400, plus 0.5 * 2000 / 5 = $200. + // The posted $200 stays as-is, plus 0.5 * 2000 / 5 = $200 for the add. + // Lowering leverage does not top up the collateral already posted. expect(result.resulting.margin).toStrictEqual({ available: true, - value: 600, + value: 400, }); - expect(result.resulting.leverage).toBeCloseTo(5); + // $3000 notional on $400 is 7.5x, above the 5x the order selected. + expect(result.resulting.leverage).toBeCloseTo(7.5); }); it('projects a short increase, keeping liquidation above entry', () => { @@ -690,7 +729,7 @@ describe('previewHyperLiquidIsolatedPositionModify', () => { ).toBeGreaterThan(2000); }); - it('reallocates a short when increasing at higher leverage', () => { + it('margins only the added size on a short increase at higher leverage', () => { const result = previewHyperLiquidIsolatedPositionModify({ position: isolatedPosition({ size: '-1', @@ -709,7 +748,7 @@ describe('previewHyperLiquidIsolatedPositionModify', () => { } expect(result.resulting.margin).toStrictEqual({ available: true, - value: 300, + value: 500, }); expect(result.resulting.direction).toBe('short'); });