Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
33 changes: 15 additions & 18 deletions packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand All @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down Expand Up @@ -1160,7 +1160,7 @@ describe('HyperLiquidProvider', () => {
}
expect(result.resulting.margin).toStrictEqual({
available: true,
value: 300,
value: 500,
});
expect(result.resulting.liquidationPrice).toStrictEqual({
available: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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',
Expand All @@ -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');
});
Expand Down Expand Up @@ -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',
Expand All @@ -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 },
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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',
Expand All @@ -709,7 +748,7 @@ describe('previewHyperLiquidIsolatedPositionModify', () => {
}
expect(result.resulting.margin).toStrictEqual({
available: true,
value: 300,
value: 500,
});
expect(result.resulting.direction).toBe('short');
});
Expand Down
Loading