From ec70743f2cdab225ec7f96d2fe340d1761f4998f Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 19 Aug 2026 23:00:39 +0800 Subject: [PATCH 1/2] fix(perps): report a lone partial TP/SL price on the position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A partial (quantity-scoped) take profit cannot be position-bound, so it is placed as a standalone reduce-only trigger. The scalar summary fields takeProfitPrice/stopLossPrice were only ever scanned from position-bound triggers, so a position whose only take profit was partial reported takeProfitCount: 1 with no price, and clients rendering the scalar showed none. Add resolvePositionTriggerSummaryPrice and use it wherever the summary is assembled — REST getPositions, getUserDataSnapshot, and the WebSocket merge — so a lone trigger reports its own price whatever its grouping. Two or more triggers keep the scanned value, since clients render the count instead. --- packages/perps-controller/CHANGELOG.md | 10 +++ .../src/providers/HyperLiquidProvider.ts | 38 ++++++--- .../HyperLiquidSubscriptionService.ts | 13 ++- packages/perps-controller/src/types/index.ts | 16 ++-- .../perps-controller/src/utils/orderTypes.ts | 32 +++++++ ...yperLiquidProvider.advanced-orders.test.ts | 72 +++++++++++++++- .../HyperLiquidProvider.standalone.test.ts | 71 ++++++++++++++++ ...uidSubscriptionService.market-data.test.ts | 85 +++++++++++++++++++ .../tests/src/utils/orderTypes.test.ts | 67 ++++++++++++++- 9 files changed, 384 insertions(+), 20 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index bec8b6a9ddb..ef1148599be 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `resolvePositionTriggerSummaryPrice` to `@metamask/perps-controller/utils`, which resolves the scalar TP/SL summary price a position reports for one direction from its trigger orders ([#0000](https://github.com/MetaMask/core/pull/0000)) + +### Fixed + +- Report the take profit (or stop loss) price on a `Position` when its only trigger for that direction is a partial, quantity-scoped one ([#0000](https://github.com/MetaMask/core/pull/0000)) + - `takeProfitPrice`/`stopLossPrice` were only ever scanned from position-bound triggers, so a position whose sole take profit closed it partially reported `takeProfitCount: 1` with no price, and clients rendering the scalar showed none. Applies to the REST `getPositions`, `getUserDataSnapshot`, and WebSocket position paths alike. + - Two or more triggers in a direction still report the scanned price, because no single price describes them and clients render the count instead. + ## [12.1.0] ### Added diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 9c9803165a3..80e82b84e5d 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -195,6 +195,7 @@ import { isLimitExecutionOrderType, isStrategyOrderType, isTriggerOrderType, + resolvePositionTriggerSummaryPrice, toSDKTimeInForce, } from '../utils/orderTypes.js'; import { @@ -673,16 +674,27 @@ function collectPositionTriggerOrders(params: { }); const triggerOrders = Array.from(byOrderId.values()); + const takeProfitOrders = triggerOrders.filter( + (order) => order.direction === 'take_profit', + ); + const stopLossOrders = triggerOrders.filter( + (order) => order.direction !== 'take_profit', + ); + + const takeProfitSummaryPrice = resolvePositionTriggerSummaryPrice({ + triggerOrders: takeProfitOrders, + scannedPrice: takeProfitPrice, + }); + const stopLossSummaryPrice = resolvePositionTriggerSummaryPrice({ + triggerOrders: stopLossOrders, + scannedPrice: stopLossPrice, + }); return { - takeProfitOrders: triggerOrders.filter( - (order) => order.direction === 'take_profit', - ), - stopLossOrders: triggerOrders.filter( - (order) => order.direction !== 'take_profit', - ), - ...(takeProfitPrice && { takeProfitPrice }), - ...(stopLossPrice && { stopLossPrice }), + takeProfitOrders, + stopLossOrders, + ...(takeProfitSummaryPrice && { takeProfitPrice: takeProfitSummaryPrice }), + ...(stopLossSummaryPrice && { stopLossPrice: stopLossSummaryPrice }), }; } @@ -7851,8 +7863,14 @@ export class HyperLiquidProvider implements PerpsProvider { return { ...position, - takeProfitPrice, - stopLossPrice, + takeProfitPrice: resolvePositionTriggerSummaryPrice({ + triggerOrders: takeProfitOrders, + scannedPrice: takeProfitPrice, + }), + stopLossPrice: resolvePositionTriggerSummaryPrice({ + triggerOrders: stopLossOrders, + scannedPrice: stopLossPrice, + }), takeProfitCount: takeProfitOrders.length, stopLossCount: stopLossOrders.length, takeProfitOrders, diff --git a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts index 2a2fcdfe422..d3916924b34 100644 --- a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts +++ b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts @@ -68,6 +68,7 @@ import { import { buildPositionTriggerOrderFromOrder, hashTriggerOrders, + resolvePositionTriggerSummaryPrice, } from '../utils/orderTypes.js'; import type { HyperLiquidClientService } from './HyperLiquidClientService.js'; import type { HyperLiquidWalletService } from './HyperLiquidWalletService.js'; @@ -1182,8 +1183,16 @@ export class HyperLiquidSubscriptionService { return { ...position, - takeProfitPrice: tpsl.takeProfitPrice ?? undefined, - stopLossPrice: tpsl.stopLossPrice ?? undefined, + // The scanned prices only ever come from position-bound triggers, so a + // lone quantity-scoped trigger has to be read off the array instead. + takeProfitPrice: resolvePositionTriggerSummaryPrice({ + triggerOrders: takeProfitOrders, + scannedPrice: tpsl.takeProfitPrice, + }), + stopLossPrice: resolvePositionTriggerSummaryPrice({ + triggerOrders: stopLossOrders, + scannedPrice: tpsl.stopLossPrice, + }), // Counts come from the same arrays as the REST path, so both transports // report one definition. Orders whose placement type the exchange did // not name (HyperLiquid's ambiguous 'Trigger') are absent from both, diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index a5e425843b9..00693ee77d9 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -355,11 +355,17 @@ export type Position = { /** * Take profit price (if set). * - * Legacy summary field: it may also reflect a TP/SL child of a *pending* order - * on this market, which `takeProfitOrders` and `takeProfitCount` deliberately - * exclude because such a child protects that order rather than the position. - * A position can therefore report a price here with an empty array and a count - * of `0`. Prefer `takeProfitOrders` for anything that must be exact. + * Summary field, resolved for the common case a client renders: when + * `takeProfitOrders` holds exactly one order this is that order's trigger + * price, whether or not it covers the whole position. With two or more orders + * no single price describes them, so this falls back to the position-bound + * trigger — clients render `takeProfitCount` there instead. + * + * It may also reflect a TP/SL child of a *pending* order on this market, which + * `takeProfitOrders` and `takeProfitCount` deliberately exclude because such a + * child protects that order rather than the position. A position can therefore + * report a price here with an empty array and a count of `0`. Prefer + * `takeProfitOrders` for anything that must be exact. */ takeProfitPrice?: string; /** diff --git a/packages/perps-controller/src/utils/orderTypes.ts b/packages/perps-controller/src/utils/orderTypes.ts index fecb704fe69..15434042e69 100644 --- a/packages/perps-controller/src/utils/orderTypes.ts +++ b/packages/perps-controller/src/utils/orderTypes.ts @@ -252,6 +252,38 @@ export function buildPositionTriggerOrderFromOrder(params: { }; } +/** + * Resolve the scalar TP/SL summary price a position reports for one direction. + * + * The scalar fields are only ever scanned from position-bound triggers, so a + * position whose only take profit (or stop loss) is quantity-scoped reported a + * count of 1 with no price — and a client that renders the scalar showed + * nothing. When the direction has exactly one trigger order, that order is the + * price, whether or not it is position-bound. + * + * Two or more triggers keep the scanned value: no single price describes them, + * and clients render the count instead. Zero triggers keep it too, because it + * still carries the TP/SL of a *pending* order on the market, which the arrays + * deliberately exclude. + * + * @param params - Resolution parameters + * @param params.triggerOrders - Trigger orders attached to the position for one direction + * @param params.scannedPrice - Price scanned from position-bound triggers, if any + * @returns The price to report, or undefined when there is none + */ +export function resolvePositionTriggerSummaryPrice(params: { + triggerOrders: PositionTriggerOrder[]; + scannedPrice?: string; +}): string | undefined { + const { triggerOrders, scannedPrice } = params; + + if (triggerOrders.length === 1) { + return triggerOrders[0].triggerPrice; + } + + return scannedPrice; +} + /** * Build a trigger order type from its two independent dimensions. * diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.advanced-orders.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.advanced-orders.test.ts index 40a1f1c30b8..1dc635db4d8 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.advanced-orders.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.advanced-orders.test.ts @@ -1684,8 +1684,76 @@ describe('HyperLiquidProvider', () => { reduceOnly: true, }, ]); - // The scalar summary field stays position-bound-only, which is exactly why - // the array exists. + // A lone trigger is the position's take profit whether or not it is + // position-bound, so the scalar summary field reports its price. + expect(position?.takeProfitPrice).toBe('60000'); + expect(position?.takeProfitCount).toBe(1); + }); + + it('leaves the summary price unset when two partial take profits share the position', async () => { + const partialTakeProfit = (oid: number, triggerPx: string) => ({ + coin: 'BTC', + side: 'A', + limitPx: triggerPx, + sz: '0.04', + origSz: '0.04', + oid, + timestamp: 1_700_000_000_000, + triggerCondition: `Price above ${triggerPx}`, + isTrigger: true, + triggerPx, + children: [], + isPositionTpsl: false, + reduceOnly: true, + orderType: 'Take Profit Limit', + }); + + mockClientService.getInfoClient.mockReturnValue( + createMockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '500', accountValue: '10500' }, + crossMarginSummary: { + totalMarginUsed: '500', + accountValue: '10500', + }, + withdrawable: '9500', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.1', + entryPx: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + maxLeverage: 50, + returnOnEquity: '20', + cumFunding: { + allTime: '10', + sinceOpen: '5', + sinceChange: '2', + }, + }, + type: 'oneWay', + }, + ], + }), + frontendOpenOrders: jest + .fn() + .mockResolvedValue([ + partialTakeProfit(701, '60000'), + partialTakeProfit(702, '62000'), + ]), + }) as unknown as ReturnType, + ); + + const positions = await provider.getPositions({ skipCache: true }); + const position = positions.find((pos) => pos.symbol === 'BTC'); + + // No single price describes two triggers; the count is what a client shows. + expect(position?.takeProfitCount).toBe(2); expect(position?.takeProfitPrice).toBeUndefined(); }); }); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts index 29e3832fc6b..23c7ce4e0c9 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts @@ -871,6 +871,77 @@ describe('HyperLiquidProvider', () => { }); }); + it('reports a lone partial take profit as the position take profit price', async () => { + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.5', + entryPx: '45000', + positionValue: '22500', + unrealizedPnl: '500', + marginUsed: '2250', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '40000', + maxLeverage: 50, + returnOnEquity: '22.22', + cumFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + }, + type: 'oneWay', + }, + ], + marginSummary: { + totalMarginUsed: '2250', + accountValue: '25000', + }, + withdrawable: '22750', + }); + // A quantity-scoped take profit is placed with 'na' grouping, so it is + // a standalone reduce-only trigger rather than a position-bound one. + mockStandaloneInfoClient.frontendOpenOrders.mockResolvedValue([ + { + coin: 'BTC', + oid: 301, + side: 'A', + limitPx: '55000', + triggerPx: '55000', + sz: '0.2', + origSz: '0.2', + timestamp: Date.now(), + orderType: 'Take Profit Limit', + isTrigger: true, + reduceOnly: true, + isPositionTpsl: false, + cloid: undefined, + children: [], + }, + ]); + + const result = await provider.getUserDataSnapshot({ + userAddress: mockUserAddress, + identity: { + provider: 'hyperliquid', + network: 'mainnet', + hip3ConfigVersion: 0, + dexes: ['main'], + }, + }); + + expect(result.positions[0]).toEqual( + expect.objectContaining({ + takeProfitPrice: '55000', + takeProfitCount: 1, + stopLossCount: 0, + takeProfitOrders: [ + expect.objectContaining({ orderId: '301', isPartial: true }), + ], + stopLossOrders: [], + }), + ); + expect(result.positions[0].stopLossPrice).toBeUndefined(); + }); + it('ignores child triggers from the inactive TP/SL grouping', async () => { mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ assetPositions: [ diff --git a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.market-data.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.market-data.test.ts index 600f70849e4..e1d3f447891 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.market-data.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.market-data.test.ts @@ -809,6 +809,91 @@ describe('HyperLiquidSubscriptionService', () => { unsubscribe(); }); + it('reports a lone partial take profit as the position take profit price', async () => { + const mockCallback = jest.fn(); + + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { + position: { szi: '1.0', coin: 'BTC' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + // A quantity-scoped take profit is placed with 'na' grouping, so it is a + // standalone reduce-only trigger and never reaches the position-bound scan. + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [ + { + oid: 321, + coin: 'BTC', + side: 'S', + sz: '0.4', + triggerPx: '55000', + orderType: 'Take Profit Limit', + reduceOnly: true, + isPositionTpsl: false, + limitPx: '55000', + origSz: '0.4', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + ], + }); + }, 5); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalledWith([ + expect.objectContaining({ + symbol: 'BTC', + takeProfitPrice: '55000', + takeProfitCount: 1, + stopLossCount: 0, + takeProfitOrders: [ + expect.objectContaining({ orderId: '321', isPartial: true }), + ], + }), + ]); + + unsubscribe(); + }); + it('should process Stop Loss orders correctly', async () => { const mockCallback = jest.fn(); diff --git a/packages/perps-controller/tests/src/utils/orderTypes.test.ts b/packages/perps-controller/tests/src/utils/orderTypes.test.ts index c8bdc032b73..97b4a585170 100644 --- a/packages/perps-controller/tests/src/utils/orderTypes.test.ts +++ b/packages/perps-controller/tests/src/utils/orderTypes.test.ts @@ -1,4 +1,4 @@ -import type { Order } from '../../../src/types/index.js'; +import type { Order, PositionTriggerOrder } from '../../../src/types/index.js'; import type { OrderType, TriggerOrderType, @@ -13,6 +13,7 @@ import { getTriggerExecution, isLimitExecutionOrderType, isTriggerOrderType, + resolvePositionTriggerSummaryPrice, } from '../../../src/utils/orderTypes.js'; const createOrder = (overrides: Partial = {}): Order => ({ @@ -346,4 +347,68 @@ describe('orderTypes', () => { expect(result?.reduceOnly).toBe(false); }); }); + + describe('resolvePositionTriggerSummaryPrice', () => { + const createTriggerOrder = ( + overrides: Partial = {}, + ): PositionTriggerOrder => ({ + orderId: '901', + direction: 'take_profit', + orderType: 'take_profit_limit', + triggerPrice: '60000', + size: '0.04', + isPartial: true, + reduceOnly: true, + ...overrides, + }); + + it('reports the price of a lone trigger order, partial or not', () => { + expect( + resolvePositionTriggerSummaryPrice({ + triggerOrders: [createTriggerOrder()], + }), + ).toBe('60000'); + }); + + it('prefers the lone trigger order over a differing scanned price', () => { + expect( + resolvePositionTriggerSummaryPrice({ + triggerOrders: [createTriggerOrder({ triggerPrice: '61000' })], + scannedPrice: '60000', + }), + ).toBe('61000'); + }); + + it('keeps the scanned price when several trigger orders share a direction', () => { + expect( + resolvePositionTriggerSummaryPrice({ + triggerOrders: [ + createTriggerOrder({ orderId: '901', triggerPrice: '60000' }), + createTriggerOrder({ orderId: '902', triggerPrice: '61000' }), + ], + scannedPrice: '59000', + }), + ).toBe('59000'); + }); + + it('reports nothing when several trigger orders share a direction and none was scanned', () => { + expect( + resolvePositionTriggerSummaryPrice({ + triggerOrders: [ + createTriggerOrder({ orderId: '901', triggerPrice: '60000' }), + createTriggerOrder({ orderId: '902', triggerPrice: '61000' }), + ], + }), + ).toBeUndefined(); + }); + + it('keeps the scanned price when there is no trigger order, which is how a pending order TP/SL still reports', () => { + expect( + resolvePositionTriggerSummaryPrice({ + triggerOrders: [], + scannedPrice: '60000', + }), + ).toBe('60000'); + }); + }); }); From 66261717bb0eb741bb1cdef14f91d297db2e943b Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 20 Aug 2026 01:50:58 +0800 Subject: [PATCH 2/2] chore(perps): link changelog entries to the PR The Unreleased entries carried placeholder #0000 links, which the Check changelog workflow rejects: it requires each entry to link to the current PR. --- packages/perps-controller/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index ef1148599be..d46e0debaef 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,11 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `resolvePositionTriggerSummaryPrice` to `@metamask/perps-controller/utils`, which resolves the scalar TP/SL summary price a position reports for one direction from its trigger orders ([#0000](https://github.com/MetaMask/core/pull/0000)) +- Add `resolvePositionTriggerSummaryPrice` to `@metamask/perps-controller/utils`, which resolves the scalar TP/SL summary price a position reports for one direction from its trigger orders ([#9912](https://github.com/MetaMask/core/pull/9912)) ### Fixed -- Report the take profit (or stop loss) price on a `Position` when its only trigger for that direction is a partial, quantity-scoped one ([#0000](https://github.com/MetaMask/core/pull/0000)) +- Report the take profit (or stop loss) price on a `Position` when its only trigger for that direction is a partial, quantity-scoped one ([#9912](https://github.com/MetaMask/core/pull/9912)) - `takeProfitPrice`/`stopLossPrice` were only ever scanned from position-bound triggers, so a position whose sole take profit closed it partially reported `takeProfitCount: 1` with no price, and clients rendering the scalar showed none. Applies to the REST `getPositions`, `getUserDataSnapshot`, and WebSocket position paths alike. - Two or more triggers in a direction still report the scanned price, because no single price describes them and clients render the count instead.