Skip to content

Commit c1497aa

Browse files
authored
fix(perps): positionTPSL shouldn't appear in order section (#42661)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** Placing a market order with TP/SL via the perps order-entry UI sent both the market order and the TP/SL inputs in a single `perpsPlaceOrder` call. The `@metamask/perps-controller` default for that path is `grouping: 'normalTpsl'` (`orderCalculations.cjs:282`), so Hyperliquid returned the resulting trigger orders with `isPositionTpsl: false`. That left `position.takeProfitPrice/stopLossPrice` unpopulated (the controller's `extractTPSLFromOrders` skips orders without the flag) and the predicate behind the market-detail Orders section let the trigger orders through — TP/SL ended up in the Orders list instead of the Auto-close row. Mobile already routes the same scenario through a separate `updatePositionTPSL` call (`PerpsOrderView.tsx:1126`) which the controller submits under `grouping: 'positionTpsl'` (`HyperLiquidProvider.cjs:1098`). The fix mirrors that split on the extension: when a market order with TP/SL targets a new (or flipping) position, we strip the TP/SL from `perpsPlaceOrder` and follow up with `perpsUpdatePositionTPSL`. The resulting trigger orders come back tagged `isPositionTpsl: true`, so the existing partition logic works as designed. Defense in depth: `isOrderAssociatedWithFullPosition` now tolerates the rare `isPositionTpsl: false` flag on TP/SL trigger orders (size match wins), and the market-detail page derives Auto-close prices from the matching positionTPSL orders when the controller has not surfaced them. These guard against legacy on-chain orders and any future WS update path that drops the flag. ## **Changelog** CHANGELOG entry: Fixed a perps bug where market orders submitted with TP/SL left the Auto-close section empty and surfaced the TP/SL orders in the Orders section of the market detail page. ## **Related issues** Fixes: [TAT-3065](https://consensyssoftware.atlassian.net/browse/TAT-3065) ## **Manual testing steps** 1. Unlock the wallet and open the Perps tab. 2. Pick a market where you do not currently hold a position (e.g. AVAX). 3. From the market detail page, tap **Long** to open the order entry screen. 4. Enter a notional (e.g. `10`), enable Auto-close, set a TP price above the entry, a SL price below the entry but above liquidation, and submit. 5. After the toast confirms the order filled, navigate back to the same market detail page. 6. **Expected**: the Auto-close row shows the TP/SL values you just entered, and the Orders section does not list any TP/SL trigger orders. 7. (Optional) Repeat with a limit order plus TP/SL. The TP/SL orders should appear in the Orders section while the limit is open and migrate to Auto-close once the limit fills. ## **Screenshots/Recordings** <table> <tr><td align="center" width="50%"><strong>Screenshots/evidence Ac1 Real Avax Market Tpsl.png 1778835137152</strong><br/><img src="https://raw.githubusercontent.com/abretonc7s/mm-extension-farm-artifacts/main/fixes/42661/screenshots/evidence-ac1-real-avax-market-tpsl.png-1778835137152.png" alt="Screenshots/evidence Ac1 Real Avax Market Tpsl.png 1778835137152" width="400" /></td><td align="center" width="50%"><strong>Screenshots/perps Tab 1778835132382</strong><br/><img src="https://raw.githubusercontent.com/abretonc7s/mm-extension-farm-artifacts/main/fixes/42661/screenshots/perps-tab-1778835132382.png" alt="Screenshots/perps Tab 1778835132382" width="400" /><br/><sub>caption confidence: LOW — generic filename — no state-specific suffix</sub></td></tr> </table> ## **Validation Recipe** `temp/tasks/fix/tat-3065-0513-153038/artifacts/recipe.json` (verify) and `recipe-baseline.json` (buggy main capture). Both drive the same UI flow against a live Hyperliquid mainnet account — no injected channel state. The verify recipe passes 22/22 on the fix branch; the baseline recipe passes 22/22 against vanilla main and asserts the buggy values (`isPositionTpsl: false`, `TP -, SL -`, `leakCount > 0`). <details> <summary>recipe.json (verify)</summary> ```json { "title": "TAT-3065 real $10 AVAX market+TPSL via UI", "schema_version": 1, "validate": { "workflow": { "pre_conditions": ["wallet.unlocked", "perps.feature_enabled"], "entry": "gate-nav-perps", "teardown": [ { "id": "teardown-close-avax", "action": "eval_async", "expression": "perpsClosePosition({symbol:'AVAX',orderType:'market'})" } ], "nodes": { "gate-nav-perps": { "action": "call", "ref": "perps/navigate-perps-tab" }, "gate-cleanup-pre-existing-avax": { "action": "eval_async", "expression": "perpsClosePosition AVAX if present" }, "setup-nav-market": { "action": "call", "ref": "perps/navigate-to-market-detail", "params": { "symbol": "AVAX" } }, "setup-press-long": { "action": "press", "test_id": "perps-long-cta-button" }, "setup-set-amount": { "action": "set_input", "test_id": "amount-input-field", "value": "10" }, "setup-toggle-autoclose": { "action": "eval_sync", "expression": "click .toggle-button label" }, "setup-set-tp": { "action": "set_input", "test_id": "tp-price-input", "value": "11" }, "setup-set-sl": { "action": "set_input", "test_id": "sl-price-input", "value": "9" }, "setup-submit": { "action": "press", "test_id": "submit-order-button" }, "setup-wait-fill": { "action": "eval_async", "expression": "poll perpsGetPositions until AVAX appears" }, "setup-wait-tpsl-orders": { "action": "eval_async", "assert": { "all": [{ "operator": "eq", "field": "allPositionTpsl", "value": true }] } }, "ac1-assert-autoclose-shows-positiontpsl": { "action": "eval_sync", "assert": { "all": [ { "operator": "eq", "field": "hasTpPrice", "value": true }, { "operator": "eq", "field": "hasSlPrice", "value": true }, { "operator": "eq", "field": "hasPlaceholderTp", "value": false }, { "operator": "eq", "field": "hasPlaceholderSl", "value": false } ]} }, "ac2-assert-positiontpsl-not-in-orders": { "action": "eval_async", "assert": { "all": [{ "operator": "eq", "field": "leakCount", "value": 0 }] } }, "ac-screenshot-final": { "action": "screenshot", "filename": "evidence-ac1-real-avax-market-tpsl.png" } } } } } ``` Full recipe + baseline are checked in alongside this PR under `temp/tasks/fix/tat-3065-0513-153038/artifacts/`. </details> ## **Recipe Workflow** <details> <summary>workflow.mmd</summary> ```mermaid flowchart TD ENTRY([ENTRY]) --> gate_nav_perps gate_nav_perps[[gate-nav-perps perps/navigate-perps-tab]] --> gate_cleanup_pre_existing_avax gate_cleanup_pre_existing_avax[gate-cleanup-pre-existing-avax eval_async] --> setup_nav_market setup_nav_market[[setup-nav-market perps/navigate-to-market-detail]] --> setup_press_long setup_press_long[setup-press-long press] --> setup_set_amount setup_set_amount[setup-set-amount set_input 10] --> setup_toggle_autoclose setup_toggle_autoclose[setup-toggle-autoclose eval_sync] --> setup_set_tp setup_set_tp[setup-set-tp set_input 11] --> setup_set_sl setup_set_sl[setup-set-sl set_input 9] --> setup_submit setup_submit[setup-submit press submit-order-button] --> setup_wait_fill setup_wait_fill[setup-wait-fill eval_async poll position] --> setup_wait_tpsl_orders setup_wait_tpsl_orders[setup-wait-tpsl-orders allPositionTpsl=true] --> setup_nav_avax_detail setup_nav_avax_detail[[setup-nav-avax-detail perps/navigate-to-market-detail]] --> ac1 ac1[ac1-assert-autoclose-shows-positiontpsl] --> ac2 ac2[ac2-assert-positiontpsl-not-in-orders leakCount=0] --> screenshot screenshot[ac-screenshot-final] --> done([end]) ``` </details> ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. [TAT-3065]: https://consensyssoftware.atlassian.net/browse/TAT-3065?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes perps order-submission and order/position association logic, including a new two-step background call flow (`perpsPlaceOrder` then `perpsUpdatePositionTPSL`) that could affect trade submission and TP/SL attachment behavior. > > **Overview** > Fixes market orders submitted with TP/SL so they create *position-level* TP/SL triggers: when a market order includes TP/SL and would open a new position (or flip an existing one), the UI now submits `perpsPlaceOrder` **without** TP/SL and follows up with `perpsUpdatePositionTPSL` (with failure handled as “order filled but TP/SL attach failed”). > > Hardens market-detail display when Hyperliquid/controller data is incomplete: `isOrderAssociatedWithFullPosition` now treats explicit `isPositionTpsl: false` as authoritative (avoids misclassifying limit-order TP/SL children) while also handling zero-size reduce-only triggers as position-bound; the market detail page derives effective TP/SL prices from matching trigger orders when `position.takeProfitPrice`/`stopLossPrice` are missing, and passes these through to chart lines and the `UpdateTPSLModal`. > > Exports are updated so `willFlipPosition` is available consistently via `utils.ts`/`utils/index.ts`, and tests add coverage for the new flip logic, TP/SL derivation, and the two-step market+TP/SL submission flow. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 5941831. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent a4bbe61 commit c1497aa

7 files changed

Lines changed: 523 additions & 23 deletions

File tree

ui/components/app/perps/utils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ import {
1212
PERPS_CONSTANTS,
1313
} from './constants';
1414

15+
// Re-exported here because callers importing `'../../components/app/perps/utils'`
16+
// resolve to this file (TypeScript prefers sibling `utils.ts` over the
17+
// `utils/index.ts` barrel). Keep the surface area in sync with `utils/index.ts`.
18+
export { willFlipPosition } from './utils/orderUtils';
19+
1520
/**
1621
* Extract display name from symbol (strips DEX prefix for HIP-3 markets)
1722
* e.g., "xyz:TSLA" -> "TSLA", "BTC" -> "BTC"

ui/components/app/perps/utils/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export {
2121
shouldDisplayOrderInMarketDetailsOrders,
2222
buildDisplayOrdersWithSyntheticTpsl,
2323
isOrderAssociatedWithFullPosition,
24+
derivePositionTpslPricesFromOrders,
25+
willFlipPosition,
2426
formatOrderLabel,
2527
} from './orderUtils';
2628

ui/components/app/perps/utils/orderUtils.test.ts

Lines changed: 216 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import type { Order, Position } from '@metamask/perps-controller';
1+
import type { Order, OrderParams, Position } from '@metamask/perps-controller';
22
import {
33
isOrderAssociatedWithFullPosition,
44
shouldDisplayOrderInMarketDetailsOrders,
55
buildDisplayOrdersWithSyntheticTpsl,
66
normalizeMarketDetailsOrders,
7+
derivePositionTpslPricesFromOrders,
8+
willFlipPosition,
79
formatOrderLabel,
810
} from './orderUtils';
911

@@ -66,7 +68,7 @@ describe('orderUtils', () => {
6668
expect(isOrderAssociatedWithFullPosition(order, position)).toBe(false);
6769
});
6870

69-
it('returns false when isPositionTpsl is explicitly false', () => {
71+
it('returns false when isPositionTpsl is explicitly false on a non-trigger order', () => {
7072
const order = makeOrder({
7173
reduceOnly: true,
7274
isPositionTpsl: false,
@@ -78,6 +80,27 @@ describe('orderUtils', () => {
7880
expect(isOrderAssociatedWithFullPosition(order, position)).toBe(false);
7981
});
8082

83+
it('returns false for a TP/SL trigger with isPositionTpsl=false even when size matches the position (limit-order child)', () => {
84+
// A limit-order's TP/SL children also carry isPositionTpsl=false with
85+
// isTrigger=true. If the limit size coincidentally equals the active
86+
// position size, size-matching would misclassify them as full-position
87+
// and hide them from the Orders section. The explicit `false` flag is
88+
// authoritative — trust the provider over size coincidence.
89+
const order = makeOrder({
90+
reduceOnly: true,
91+
isPositionTpsl: false,
92+
isTrigger: true,
93+
symbol: 'ETH',
94+
side: 'sell',
95+
size: '1.0',
96+
originalSize: '1.0',
97+
triggerPrice: '3300',
98+
detailedOrderType: 'Take Profit Limit',
99+
});
100+
const position = makePosition({ symbol: 'ETH', size: '1.0' });
101+
expect(isOrderAssociatedWithFullPosition(order, position)).toBe(false);
102+
});
103+
81104
it('returns true when reduce-only order matches full position size (sell closing long)', () => {
82105
const order = makeOrder({
83106
reduceOnly: true,
@@ -165,6 +188,197 @@ describe('orderUtils', () => {
165188
});
166189
expect(shouldDisplayOrderInMarketDetailsOrders(positionTpsl)).toBe(false);
167190
});
191+
192+
it('excludes zero-size reduce-only trigger orders matching the position even without isPositionTpsl flag', () => {
193+
// Hyperliquid positionTPSL trigger orders carry size '0' (whole-position
194+
// trigger). WebSocket order updates omit isPositionTpsl, so the UI must
195+
// still recognise the order as full-position when size is zero and the
196+
// trigger matches the active position.
197+
const positionTpslNoFlag = makeOrder({
198+
reduceOnly: true,
199+
isTrigger: true,
200+
symbol: 'ETH',
201+
side: 'sell',
202+
size: '0',
203+
originalSize: '0',
204+
triggerPrice: '3200.00',
205+
detailedOrderType: 'Take Profit Market',
206+
});
207+
const position = makePosition({ symbol: 'ETH', size: '1.0' });
208+
expect(
209+
shouldDisplayOrderInMarketDetailsOrders(positionTpslNoFlag, position),
210+
).toBe(false);
211+
});
212+
213+
it('keeps a flag-less zero-size reduce-only trigger visible when no matching position exists', () => {
214+
const orphanTpsl = makeOrder({
215+
reduceOnly: true,
216+
isTrigger: true,
217+
symbol: 'ETH',
218+
side: 'sell',
219+
size: '0',
220+
originalSize: '0',
221+
triggerPrice: '3200.00',
222+
});
223+
expect(shouldDisplayOrderInMarketDetailsOrders(orphanTpsl)).toBe(true);
224+
});
225+
226+
it('keeps a flag-less zero-size reduce-only order without isTrigger visible (cannot infer position binding)', () => {
227+
const zeroSizeNonTrigger = makeOrder({
228+
reduceOnly: true,
229+
symbol: 'ETH',
230+
side: 'sell',
231+
size: '0',
232+
originalSize: '0',
233+
});
234+
const position = makePosition({ symbol: 'ETH', size: '1.0' });
235+
expect(
236+
shouldDisplayOrderInMarketDetailsOrders(zeroSizeNonTrigger, position),
237+
).toBe(true);
238+
});
239+
});
240+
241+
describe('willFlipPosition', () => {
242+
const makeParams = (overrides: Partial<OrderParams> = {}): OrderParams =>
243+
({
244+
symbol: 'ETH',
245+
isBuy: false,
246+
size: '2.0',
247+
orderType: 'market',
248+
...overrides,
249+
}) as OrderParams;
250+
251+
it('returns false for reduce-only orders', () => {
252+
expect(
253+
willFlipPosition(
254+
makePosition({ size: '1.0' }),
255+
makeParams({ reduceOnly: true }),
256+
),
257+
).toBe(false);
258+
});
259+
260+
it('returns false for limit orders', () => {
261+
expect(
262+
willFlipPosition(
263+
makePosition({ size: '1.0' }),
264+
makeParams({ orderType: 'limit' }),
265+
),
266+
).toBe(false);
267+
});
268+
269+
it('returns false when order direction matches position direction', () => {
270+
expect(
271+
willFlipPosition(
272+
makePosition({ size: '1.0' }),
273+
makeParams({ isBuy: true, size: '5.0' }),
274+
),
275+
).toBe(false);
276+
});
277+
278+
it('returns true when opposing market order exceeds the position size', () => {
279+
expect(
280+
willFlipPosition(
281+
makePosition({ size: '1.0' }),
282+
makeParams({ isBuy: false, size: '2.0' }),
283+
),
284+
).toBe(true);
285+
});
286+
287+
it('returns false when opposing market order matches the position size (full close, no flip)', () => {
288+
expect(
289+
willFlipPosition(
290+
makePosition({ size: '1.0' }),
291+
makeParams({ isBuy: false, size: '1.0' }),
292+
),
293+
).toBe(false);
294+
});
295+
296+
it('strips thousand separators from position and order sizes before comparing', () => {
297+
expect(
298+
willFlipPosition(
299+
makePosition({ size: '1,234.5' }),
300+
makeParams({ isBuy: false, size: '2,000' }),
301+
),
302+
).toBe(true);
303+
expect(
304+
willFlipPosition(
305+
makePosition({ size: '1,234.5' }),
306+
makeParams({ isBuy: false, size: '500' }),
307+
),
308+
).toBe(false);
309+
});
310+
311+
it('returns false when the current position size is zero (no phantom direction)', () => {
312+
expect(
313+
willFlipPosition(
314+
makePosition({ size: '0' }),
315+
makeParams({ isBuy: false, size: '1.0' }),
316+
),
317+
).toBe(false);
318+
expect(
319+
willFlipPosition(
320+
makePosition({ size: '0' }),
321+
makeParams({ isBuy: true, size: '1.0' }),
322+
),
323+
).toBe(false);
324+
});
325+
});
326+
327+
describe('derivePositionTpslPricesFromOrders', () => {
328+
it('returns empty when no position is provided', () => {
329+
const order = makeOrder({
330+
reduceOnly: true,
331+
isTrigger: true,
332+
isPositionTpsl: true,
333+
triggerPrice: '3200',
334+
detailedOrderType: 'Take Profit Market',
335+
});
336+
expect(derivePositionTpslPricesFromOrders([order])).toEqual({});
337+
});
338+
339+
it('returns TP and SL prices from positionTPSL trigger orders matching the position', () => {
340+
const position = makePosition({ symbol: 'ETH', size: '1.0' });
341+
const tp = makeOrder({
342+
orderId: 'tp',
343+
reduceOnly: true,
344+
isTrigger: true,
345+
isPositionTpsl: true,
346+
symbol: 'ETH',
347+
side: 'sell',
348+
triggerPrice: '3200',
349+
detailedOrderType: 'Take Profit Market',
350+
});
351+
const sl = makeOrder({
352+
orderId: 'sl',
353+
reduceOnly: true,
354+
isTrigger: true,
355+
symbol: 'ETH',
356+
side: 'sell',
357+
size: '0',
358+
originalSize: '0',
359+
triggerPrice: '2800',
360+
detailedOrderType: 'Stop Market',
361+
});
362+
expect(derivePositionTpslPricesFromOrders([tp, sl], position)).toEqual({
363+
takeProfitPrice: '3200',
364+
stopLossPrice: '2800',
365+
});
366+
});
367+
368+
it('ignores non-positionTPSL trigger orders', () => {
369+
const position = makePosition({ symbol: 'ETH', size: '1.0' });
370+
const limitTrigger = makeOrder({
371+
reduceOnly: false,
372+
isTrigger: true,
373+
symbol: 'ETH',
374+
side: 'sell',
375+
triggerPrice: '3300',
376+
detailedOrderType: 'Stop Limit',
377+
});
378+
expect(
379+
derivePositionTpslPricesFromOrders([limitTrigger], position),
380+
).toEqual({});
381+
});
168382
});
169383

170384
describe('buildDisplayOrdersWithSyntheticTpsl', () => {

ui/components/app/perps/utils/orderUtils.ts

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import BigNumber from 'bignumber.js';
22
import { capitalize } from 'lodash';
3-
import type { Order, Position } from '@metamask/perps-controller';
3+
import type { Order, OrderParams, Position } from '@metamask/perps-controller';
44

55
const FULL_POSITION_SIZE_TOLERANCE = new BigNumber('0.00000001');
66
const ORDER_PRICE_MATCH_TOLERANCE = new BigNumber('0.00000001');
@@ -153,19 +153,119 @@ export const isOrderAssociatedWithFullPosition = (
153153
return false;
154154
}
155155

156+
// Only fall back to size matching when the provider did not send the flag.
157+
// An explicit `isPositionTpsl: false` (e.g. normalTpsl grouping from a
158+
// limit-order's TP/SL children) is authoritative — do not size-match,
159+
// otherwise a limit-order TP/SL whose size coincidentally equals the
160+
// current position would be misclassified as full-position.
156161
if (order.isPositionTpsl === false) {
157162
return false;
158163
}
159164

160165
const orderSize = getAbsoluteOrderSize(order);
161166
const positionSize = getAbsolutePositionSize(position);
162-
if (!orderSize || !positionSize) {
167+
168+
// Hyperliquid positionTPSL trigger orders may also be placed with size 0
169+
// (the trigger acts on whatever the current position size is). When the
170+
// adapter cannot back-fill the size, treat a reduce-only trigger on a
171+
// matching position+closing-side as position-bound.
172+
if (!orderSize) {
173+
return order.isTrigger === true;
174+
}
175+
176+
if (!positionSize) {
163177
return false;
164178
}
165179

166180
return orderSize.minus(positionSize).abs().lte(FULL_POSITION_SIZE_TOLERANCE);
167181
};
168182

183+
/**
184+
* Returns true when a non-reduce-only market order is large enough to flip the
185+
* current position (close the existing side and open the opposite side).
186+
* Mirrors `app/components/UI/Perps/utils/orderUtils.ts:willFlipPosition` on
187+
* mobile so the order-entry page can replicate the two-step market+TPSL
188+
* flow that yields `grouping: 'positionTpsl'` on Hyperliquid.
189+
*
190+
* @param currentPosition - The existing position
191+
* @param orderParams - The order about to be submitted
192+
* @returns Whether the order will flip the position
193+
*/
194+
export const willFlipPosition = (
195+
currentPosition: Position,
196+
orderParams: OrderParams,
197+
): boolean => {
198+
if (orderParams.reduceOnly === true) {
199+
return false;
200+
}
201+
if (orderParams.orderType !== 'market') {
202+
return false;
203+
}
204+
// Hyperliquid position/order size strings can carry thousand separators
205+
// (e.g. `'1,234.5'`); strip commas before parseFloat so the magnitude
206+
// comparison stays correct for large positions.
207+
const currentPositionSize = parseFloat(
208+
currentPosition.size.replaceAll(',', ''),
209+
);
210+
// A zero-size position is not a position — short-circuit so a sell market
211+
// does not match the phantom `'short'` direction below and falsely report
212+
// "no flip". The caller also guards on size === 0, but keep this safe in
213+
// isolation.
214+
if (currentPositionSize === 0 || !Number.isFinite(currentPositionSize)) {
215+
return false;
216+
}
217+
const positionDirection = currentPositionSize > 0 ? 'long' : 'short';
218+
const orderDirection = orderParams.isBuy ? 'long' : 'short';
219+
if (positionDirection === orderDirection) {
220+
return false;
221+
}
222+
const orderSize = parseFloat(orderParams.size.replaceAll(',', ''));
223+
return orderSize > Math.abs(currentPositionSize);
224+
};
225+
226+
/**
227+
* Derives the take-profit and stop-loss prices for the active position by
228+
* inspecting full-position reduce-only trigger orders. Used as a UI fallback
229+
* when the controller fails to populate `position.takeProfitPrice` /
230+
* `position.stopLossPrice` (e.g. Hyperliquid WebSocket order updates that
231+
* omit `isPositionTpsl`).
232+
*
233+
* @param orders - The orders to inspect
234+
* @param position - The current position
235+
* @returns Object with takeProfitPrice / stopLossPrice when discoverable
236+
*/
237+
export const derivePositionTpslPricesFromOrders = (
238+
orders: Order[],
239+
position?: Position,
240+
): { takeProfitPrice?: string; stopLossPrice?: string } => {
241+
if (!position) {
242+
return {};
243+
}
244+
245+
const result: { takeProfitPrice?: string; stopLossPrice?: string } = {};
246+
for (const order of orders) {
247+
if (
248+
!order.isTrigger ||
249+
!isOrderAssociatedWithFullPosition(order, position)
250+
) {
251+
continue;
252+
}
253+
254+
const triggerPrice = getOrderTriggerPrice(order)?.toFixed();
255+
if (!triggerPrice) {
256+
continue;
257+
}
258+
259+
const detailedType = (order.detailedOrderType ?? '').toLowerCase();
260+
if (!result.takeProfitPrice && detailedType.includes('take profit')) {
261+
result.takeProfitPrice = triggerPrice;
262+
} else if (!result.stopLossPrice && detailedType.includes('stop')) {
263+
result.stopLossPrice = triggerPrice;
264+
}
265+
}
266+
return result;
267+
};
268+
169269
/**
170270
* Determines whether an order should be shown in Market Details > Orders
171271
* (the perps asset / position detail screen).

0 commit comments

Comments
 (0)