Skip to content
Merged
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
10 changes: 10 additions & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ([#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 ([#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.

## [12.1.0]

### Added
Expand Down
38 changes: 28 additions & 10 deletions packages/perps-controller/src/providers/HyperLiquidProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ import {
isLimitExecutionOrderType,
isStrategyOrderType,
isTriggerOrderType,
resolvePositionTriggerSummaryPrice,
toSDKTimeInForce,
} from '../utils/orderTypes.js';
import {
Expand Down Expand Up @@ -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 }),
};
}

Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 11 additions & 5 deletions packages/perps-controller/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down
32 changes: 32 additions & 0 deletions packages/perps-controller/src/utils/orderTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof mockClientService.getInfoClient>,
);

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();
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
Loading