From 8ed79838d2df08dbb08b6d032fd5473b564040ab Mon Sep 17 00:00:00 2001 From: Koko Bhadra Date: Mon, 13 Jul 2026 16:51:24 -0400 Subject: [PATCH 1/2] fix: correct calculateLiquidationPrice to use current-notional margin ratio The margin ratio is defined by IMarginRatios as equity / position value, where value is the *current* notional (size * price) -- not the entry notional. The old formula subtracted `(margin - liqRatio * entryNotional) / size` from the entry price, which is wrong. Solving `equity == liqRatio * size * liqPrice` gives: long: (entry - margin/size) / (1 - liqRatio) short: (entry + margin/size) / (1 + liqRatio) Also returns null for a long when liqRatio >= 100% (no positive liq price). Updated the unit tests with the corrected formula and exact-value locks (short = 142.857, high-lev long = 42.105). Full CI green (267 unit tests, tsc, biome). Mirrors the same fix in perpcity-zig-sdk so both SDKs report identical liquidation prices. --- .../unit/position-calculations.test.ts | 18 ++++++++---------- src/functions/position.ts | 12 +++++++++--- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/__tests__/unit/position-calculations.test.ts b/src/__tests__/unit/position-calculations.test.ts index a406f8e..570d224 100644 --- a/src/__tests__/unit/position-calculations.test.ts +++ b/src/__tests__/unit/position-calculations.test.ts @@ -361,11 +361,10 @@ describe("Position Calculation Functions", () => { const liqPrice = calculateLiquidationPrice(rawData, true); - // For long: liqPrice = entryPrice - (margin - liqRatio * entryNotional) / size - // entryPrice = 50, margin = 100, liqRatio = 0.05, entryNotional = 1 * 50 = 50 - // liqPrice = 50 - (100 - 0.05 * 50) / 1 = 50 - 97.5 = -47.5, but max(0, -47.5) = 0 + // long: (entry - margin/size) / (1 - liqRatio) + // = (50 - 100/1) / (1 - 0.05) = -50/0.95 < 0 -> clamped to 0 expect(liqPrice).not.toBeNull(); - expect(liqPrice).toBeGreaterThanOrEqual(0); + expect(liqPrice).toBe(0); }); it("should calculate liquidation price for short position", () => { @@ -381,11 +380,10 @@ describe("Position Calculation Functions", () => { const liqPrice = calculateLiquidationPrice(rawData, false); - // For short: liqPrice = entryPrice + (margin - liqRatio * entryNotional) / size - // entryPrice = 50, margin = 100, liqRatio = 0.05, entryNotional = 1 * 50 = 50 - // liqPrice = 50 + (100 - 0.05 * 50) / 1 = 50 + 97.5 = 147.5 + // short: (entry + margin/size) / (1 + liqRatio) + // = (50 + 100/1) / (1 + 0.05) = 150/1.05 = 142.857 expect(liqPrice).not.toBeNull(); - expect(liqPrice).toBeGreaterThan(50); + expect(liqPrice).toBeCloseTo(142.857, 2); }); it("should return null for zero position size", () => { @@ -433,9 +431,9 @@ describe("Position Calculation Functions", () => { const liqPrice = calculateLiquidationPrice(rawData, true); - // For high leverage longs, liquidation price should be close to entry + // long: (50 - 10/1) / (1 - 0.05) = 40/0.95 = 42.105 expect(liqPrice).not.toBeNull(); - expect(liqPrice).toBeGreaterThanOrEqual(0); + expect(liqPrice).toBeCloseTo(42.105, 2); expect(liqPrice).toBeLessThan(50); }); diff --git a/src/functions/position.ts b/src/functions/position.ts index e82ec0a..d8ab0cc 100644 --- a/src/functions/position.ts +++ b/src/functions/position.ts @@ -101,12 +101,18 @@ export function calculateLiquidationPrice( const margin = effectiveMargin ?? rawData.margin; if (positionSize === 0 || margin <= 0) return null; + // Per IMarginRatios, the margin ratio is equity / value against the *current* + // notional (size * price). Solving `equity == liqRatio * size * liqPrice` for + // the liquidation price gives: + // long: (entry - margin/size) / (1 - liqRatio) + // short: (entry + margin/size) / (1 + liqRatio) const liqMarginRatio = rawData.marginRatios.liq / 1e6; - const entryNotional = positionSize * entryPrice; if (isLong) { - return Math.max(0, entryPrice - (margin - liqMarginRatio * entryNotional) / positionSize); + const denom = 1 - liqMarginRatio; + if (denom <= 0) return null; // liq ratio >= 100%: no positive liquidation price + return Math.max(0, (entryPrice - margin / positionSize) / denom); } - return entryPrice + (margin - liqMarginRatio * entryNotional) / positionSize; + return (entryPrice + margin / positionSize) / (1 + liqMarginRatio); } export function calculatePnlPercentage(pnl: number, funding: number, margin: number): number { From 2e515d91352aa0f629bfaf5d6065b1443854c2f8 Mon Sep 17 00:00:00 2001 From: Koko Bhadra Date: Mon, 13 Jul 2026 17:03:01 -0400 Subject: [PATCH 2/2] test: add regression test for long liq margin ratio >= 100% boundary The denom <= 0 branch (liq ratio >= 100% -> null) introduced by this fix was untested. Adds a long-position case with liq: 1_000_000 asserting null, addressing the CodeRabbit nitpick. --- .../unit/position-calculations.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/__tests__/unit/position-calculations.test.ts b/src/__tests__/unit/position-calculations.test.ts index 570d224..009c2a5 100644 --- a/src/__tests__/unit/position-calculations.test.ts +++ b/src/__tests__/unit/position-calculations.test.ts @@ -490,6 +490,23 @@ describe("Position Calculation Functions", () => { expect(liqPrice).not.toBeNull(); expect(liqPrice).toBeGreaterThanOrEqual(0); }); + + it("should return null for long when liq margin ratio is >= 100%", () => { + const rawData: PositionRawData = { + perpId: "0x123" as any, + positionId: 1n, + margin: 100, + entryPerpDelta: 1000000n, // 1 perp token (1e6) + entryUsdDelta: 50000000n, // $50 entry (1e6) + marginRatios: { liq: 1000000, backstop: 500000 }, // liq ratio = 1.0 (100%) + makerDetails: null, + }; + + const liqPrice = calculateLiquidationPrice(rawData, true); + + // long denom = 1 - liqRatio = 0 -> no positive liquidation price + expect(liqPrice).toBeNull(); + }); }); describe("Integration: Position metrics", () => {