diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index c786d0c8f65a..b6987f8582ba 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -6013,6 +6013,9 @@ "perpsTrades": { "message": "Trades" }, + "perpsTriggerPrice": { + "message": "Trigger price" + }, "perpsTutorialChooseLeverageDescription": { "message": "Leverage amplifies both gains and losses. With 40x leverage, a 1% price move equals a 40% gain or loss on your margin." }, diff --git a/app/_locales/en_GB/messages.json b/app/_locales/en_GB/messages.json index c786d0c8f65a..b6987f8582ba 100644 --- a/app/_locales/en_GB/messages.json +++ b/app/_locales/en_GB/messages.json @@ -6013,6 +6013,9 @@ "perpsTrades": { "message": "Trades" }, + "perpsTriggerPrice": { + "message": "Trigger price" + }, "perpsTutorialChooseLeverageDescription": { "message": "Leverage amplifies both gains and losses. With 40x leverage, a 1% price move equals a 40% gain or loss on your margin." }, diff --git a/ui/components/app/perps/order-card/order-card.test.tsx b/ui/components/app/perps/order-card/order-card.test.tsx index 23999c388aff..ba921f066b2d 100644 --- a/ui/components/app/perps/order-card/order-card.test.tsx +++ b/ui/components/app/perps/order-card/order-card.test.tsx @@ -50,25 +50,118 @@ describe('OrderCard', () => { expect(screen.getByText('TSLA')).toBeInTheDocument(); }); - it('displays Long for buy side order', () => { - const order = createMockOrder({ side: 'buy' }); - renderWithProvider(, mockStore); + describe('order label (formatOrderLabel)', () => { + it('displays "Limit long" for a plain buy limit order', () => { + const order = createMockOrder({ side: 'buy', orderType: 'limit' }); + renderWithProvider(, mockStore); - expect(screen.getByText(messages.perpsLong.message)).toBeInTheDocument(); - }); + expect(screen.getByText('Limit long')).toBeInTheDocument(); + }); - it('displays Short for sell side order', () => { - const order = createMockOrder({ side: 'sell' }); - renderWithProvider(, mockStore); + it('displays "Limit short" for a plain sell limit order', () => { + const order = createMockOrder({ side: 'sell', orderType: 'limit' }); + renderWithProvider(, mockStore); - expect(screen.getByText(messages.perpsShort.message)).toBeInTheDocument(); - }); + expect(screen.getByText('Limit short')).toBeInTheDocument(); + }); - it('displays the order type', () => { - const order = createMockOrder({ orderType: 'limit' }); - renderWithProvider(, mockStore); + it('displays "Market long" for a plain buy market order', () => { + const order = createMockOrder({ + side: 'buy', + orderType: 'market', + price: '0', + }); + renderWithProvider(, mockStore); + + expect(screen.getByText('Market long')).toBeInTheDocument(); + }); + + it('displays "Limit close long" for a reduce-only sell limit order', () => { + const order = createMockOrder({ + side: 'sell', + orderType: 'limit', + reduceOnly: true, + }); + renderWithProvider(, mockStore); + + expect(screen.getByText('Limit close long')).toBeInTheDocument(); + }); + + it('displays "Limit close short" for a reduce-only buy limit order', () => { + const order = createMockOrder({ + side: 'buy', + orderType: 'limit', + reduceOnly: true, + }); + renderWithProvider(, mockStore); + + expect(screen.getByText('Limit close short')).toBeInTheDocument(); + }); - expect(screen.getByText(messages.perpsLimit.message)).toBeInTheDocument(); + it('displays "Take profit limit close long" for a TP trigger (sell side)', () => { + const order = createMockOrder({ + side: 'sell', + orderType: 'limit', + isTrigger: true, + reduceOnly: true, + detailedOrderType: 'Take Profit Limit', + }); + renderWithProvider(, mockStore); + + expect( + screen.getByText('Take profit limit close long'), + ).toBeInTheDocument(); + }); + + it('shows market symbol only after size for TP/SL, not before the label', () => { + const order = createMockOrder({ + side: 'sell', + orderType: 'limit', + symbol: 'ETH', + size: '1.5', + isTrigger: true, + reduceOnly: true, + detailedOrderType: 'Take Profit Limit', + triggerPrice: '3200', + price: '3200', + }); + renderWithProvider(, mockStore); + + expect( + screen.getByText('Take profit limit close long'), + ).toBeInTheDocument(); + expect(screen.getByText('1.5 ETH')).toBeInTheDocument(); + expect(screen.queryByText(/^ETH$/u)).not.toBeInTheDocument(); + }); + + it('shows "Trigger price" subtitle for TP/SL orders', () => { + const order = createMockOrder({ + side: 'sell', + isTrigger: true, + reduceOnly: true, + detailedOrderType: 'Take Profit Limit', + triggerPrice: '3200', + price: '3200', + }); + renderWithProvider(, mockStore); + + expect( + screen.getByText(messages.perpsTriggerPrice.message), + ).toBeInTheDocument(); + }); + + it('displays "Stop market close short" for a SL trigger (buy side)', () => { + const order = createMockOrder({ + side: 'buy', + orderType: 'market', + isTrigger: true, + reduceOnly: true, + detailedOrderType: 'Stop Market', + }); + renderWithProvider(, mockStore); + + expect(screen.getByText('Stop market close short')).toBeInTheDocument(); + }); }); it('displays the order size with symbol', () => { @@ -90,6 +183,23 @@ describe('OrderCard', () => { expect(screen.getByText('$3,500.00')).toBeInTheDocument(); }); + it('displays TP/SL trigger price, not size × price notional', () => { + const order = createMockOrder({ + orderType: 'limit', + side: 'sell', + isTrigger: true, + reduceOnly: true, + detailedOrderType: 'Take Profit Limit', + triggerPrice: '3200.00', + price: '3200.00', + size: '2.0', + }); + renderWithProvider(, mockStore); + + expect(screen.getByText('$3,200.00')).toBeInTheDocument(); + expect(screen.queryByText('$6,400.00')).not.toBeInTheDocument(); + }); + it('displays Market label when order value is zero', () => { const order = createMockOrder({ orderType: 'market', @@ -98,9 +208,7 @@ describe('OrderCard', () => { }); renderWithProvider(, mockStore); - // "Market" appears in both the value slot and the order type slot - const marketElements = screen.getAllByText(messages.perpsMarket.message); - expect(marketElements.length).toBeGreaterThanOrEqual(1); + expect(screen.getByText(messages.perpsMarket.message)).toBeInTheDocument(); }); it('renders the token logo', () => { diff --git a/ui/components/app/perps/order-card/order-card.tsx b/ui/components/app/perps/order-card/order-card.tsx index e41d17eaa473..0e5c13f7e04c 100644 --- a/ui/components/app/perps/order-card/order-card.tsx +++ b/ui/components/app/perps/order-card/order-card.tsx @@ -15,7 +15,8 @@ import { useNavigate } from 'react-router-dom'; import { useI18nContext } from '../../../../hooks/useI18nContext'; import { useFormatters } from '../../../../hooks/useFormatters'; import { PerpsTokenLogo } from '../perps-token-logo'; -import { formatOrderType, getDisplayName } from '../utils'; +import { getDisplayName } from '../utils'; +import { formatOrderLabel } from '../utils/orderUtils'; import type { Order } from '../types'; import { PERPS_MARKET_DETAIL_ROUTE } from '../../../../helpers/constants/routes'; @@ -27,7 +28,8 @@ export type OrderCardProps = { /** * OrderCard component displays individual order information - * Two rows: symbol/type/side + size on left, USD value + limit price on right + * Two rows on the left: symbol + order label (TP/SL: label only; symbol follows size below), + * trigger or notional value on the right * * @param options0 - Component props * @param options0.order - The order data to display @@ -42,8 +44,9 @@ export const OrderCard: React.FC = ({ const navigate = useNavigate(); const t = useI18nContext(); const { formatCurrencyWithMinThreshold } = useFormatters(); - const isBuy = order.side === 'buy'; const displayName = getDisplayName(order.symbol); + const isTriggerBasedOrder = + order.isTrigger === true || order.isPositionTpsl === true; const handleClick = useCallback(() => { if (onClick) { @@ -56,17 +59,34 @@ export const OrderCard: React.FC = ({ } }, [navigate, order, onClick]); - // Calculate order value in USD (size * price), formatted like position values + // Limit/market: notional (size × limit price). TP/SL: trigger level (take-profit / stop-loss price). const orderValueUsd = useMemo(() => { + if (isTriggerBasedOrder) { + const triggerLevel = + parseFloat(order.triggerPrice || order.price || '0') || 0; + if (triggerLevel > 0) { + return formatCurrencyWithMinThreshold(triggerLevel, 'USD'); + } + } + const size = parseFloat(order.size) || 0; const price = parseFloat(order.price) || 0; if (size > 0 && price > 0) { return formatCurrencyWithMinThreshold(size * price, 'USD'); } return null; - }, [order.size, order.price, formatCurrencyWithMinThreshold]); + }, [ + isTriggerBasedOrder, + order.triggerPrice, + order.size, + order.price, + formatCurrencyWithMinThreshold, + ]); - const baseStyles = 'cursor-pointer pt-2 pb-2 px-4 h-[62px]'; + const baseStyles = 'cursor-pointer pt-2 pb-2 px-4'; + // Non-trigger rows keep the fixed 62 px height to match the position/token tabs. + // Trigger-based (TP/SL) rows grow with content; min-h keeps the floor at 62 px. + const heightStyle = isTriggerBasedOrder ? 'h-auto min-h-[62px]' : 'h-[62px]'; const variantStyles = variant === 'muted' ? 'bg-muted hover:bg-muted-hover active:bg-muted-pressed' @@ -77,9 +97,11 @@ export const OrderCard: React.FC = ({ className={twMerge( // Reset ButtonBase defaults for card layout 'justify-start rounded-none min-w-0', - // Card styles (matches tokens tab: 62px height, 8px v-padding, 16px h-padding, 16px gap) - 'gap-4 text-left', + // items-center keeps each column's content block centered in the card height, + // whether the label fits on one line or wraps to two. + 'gap-4 text-left items-center', baseStyles, + heightStyle, variantStyles, )} isFullWidth @@ -100,22 +122,31 @@ export const OrderCard: React.FC = ({ alignItems={BoxAlignItems.Start} gap={1} > - - {displayName} - - {isBuy ? t('perpsLong') : t('perpsShort')} - - + {isTriggerBasedOrder ? ( + // TP/SL: render label directly in the column so it wraps freely. + // The symbol is redundant here — it appears after the size below. + {formatOrderLabel(order)} + ) : ( + + {displayName} + + {formatOrderLabel(order)} + + + )} {order.size} {displayName} - {/* Right side: USD value + limit price */} + {/* Right side: USD value */} = ({ {orderValueUsd ?? t('perpsMarket')} - - {formatOrderType(order.orderType)} - + {isTriggerBasedOrder && orderValueUsd && ( + + {t('perpsTriggerPrice')} + + )} ); diff --git a/ui/components/app/perps/perps-view.test.tsx b/ui/components/app/perps/perps-view.test.tsx index 4ad1807b3ff0..6cf50ca88ef0 100644 --- a/ui/components/app/perps/perps-view.test.tsx +++ b/ui/components/app/perps/perps-view.test.tsx @@ -177,6 +177,15 @@ describe('PerpsView', () => { expect(screen.getByTestId('order-card-order-001')).toBeInTheDocument(); }); + it('filters TP/SL trigger orders from Open orders on the Perps tab', () => { + renderWithProvider(, mockStore); + + expect(screen.getByTestId('order-card-order-001')).toBeInTheDocument(); + expect( + screen.queryByTestId('order-card-order-004'), + ).not.toBeInTheDocument(); + }); + it('displays position section header', () => { renderWithProvider(, mockStore); diff --git a/ui/components/app/perps/perps-view.tsx b/ui/components/app/perps/perps-view.tsx index d675189b1ba1..4f1946f66b04 100644 --- a/ui/components/app/perps/perps-view.tsx +++ b/ui/components/app/perps/perps-view.tsx @@ -93,8 +93,9 @@ export const PerpsView: React.FC = () => { } = usePerpsTransactionHistory(); // Recent Activity shows only trade executions, deposits, and withdrawals. - // Open orders are already surfaced in PerpsPositionsOrders above. - // Funding payments belong in the full activity page. + // Open limit/market orders (excluding TP/SL triggers) are in PerpsPositionsOrders; + // TP/SL trigger rows are listed on the per-asset market detail page only. + // Funding payments belong on the full activity page. const recentActivityTransactions = useMemo( () => allRecentActivityTransactions.filter( @@ -106,14 +107,15 @@ export const PerpsView: React.FC = () => { [allRecentActivityTransactions], ); - // Show only user-placed limit orders resting on the orderbook. - // Excludes: - // - isTrigger: TP/SL trigger orders - // - isSynthetic: synthetic/virtual orders not placed directly by the user + // Open orders on the Perps tab: user-placed limits/markets on the book only. + // Excludes TP/SL (isTrigger / isPositionTpsl — those list on market detail) and synthetics. const orders = useMemo(() => { return allOrders.filter( (order) => - order.status === 'open' && !order.isTrigger && !order.isSynthetic, + order.status === 'open' && + !order.isTrigger && + order.isPositionTpsl !== true && + !order.isSynthetic, ); }, [allOrders]); diff --git a/ui/components/app/perps/utils/index.ts b/ui/components/app/perps/utils/index.ts index 6a93fba07f32..c223c8b552e8 100644 --- a/ui/components/app/perps/utils/index.ts +++ b/ui/components/app/perps/utils/index.ts @@ -21,6 +21,7 @@ export { shouldDisplayOrderInMarketDetailsOrders, buildDisplayOrdersWithSyntheticTpsl, isOrderAssociatedWithFullPosition, + formatOrderLabel, } from './orderUtils'; export { formatPerpsPrice, type PerpsPriceRange } from './formatPerpsPrice'; diff --git a/ui/components/app/perps/utils/orderUtils.test.ts b/ui/components/app/perps/utils/orderUtils.test.ts index 9a1d04921482..a56522ccb4f0 100644 --- a/ui/components/app/perps/utils/orderUtils.test.ts +++ b/ui/components/app/perps/utils/orderUtils.test.ts @@ -4,6 +4,7 @@ import { shouldDisplayOrderInMarketDetailsOrders, buildDisplayOrdersWithSyntheticTpsl, normalizeMarketDetailsOrders, + formatOrderLabel, } from './orderUtils'; const makeOrder = (overrides: Partial = {}): Order => ({ @@ -115,27 +116,24 @@ describe('orderUtils', () => { }); describe('shouldDisplayOrderInMarketDetailsOrders', () => { - it('shows non-reduce-only orders', () => { - const order = makeOrder({ reduceOnly: false }); - expect(shouldDisplayOrderInMarketDetailsOrders(order)).toBe(true); - }); - - it('shows limit orders', () => { - const order = makeOrder({ orderType: 'limit', reduceOnly: false }); - expect(shouldDisplayOrderInMarketDetailsOrders(order)).toBe(true); + it('includes non-reduce-only and limit orders', () => { + expect(shouldDisplayOrderInMarketDetailsOrders(makeOrder())).toBe(true); + expect( + shouldDisplayOrderInMarketDetailsOrders( + makeOrder({ orderType: 'limit', reduceOnly: false }), + ), + ).toBe(true); }); - it('shows trigger orders that are not position-attached', () => { - const order = makeOrder({ + it('includes trigger orders and partial reduce-only closes', () => { + const triggerOrder = makeOrder({ isTrigger: true, reduceOnly: false, triggerPrice: '3200.00', }); - expect(shouldDisplayOrderInMarketDetailsOrders(order)).toBe(true); - }); + expect(shouldDisplayOrderInMarketDetailsOrders(triggerOrder)).toBe(true); - it('shows reduce-only orders not associated with full position', () => { - const order = makeOrder({ + const partialClose = makeOrder({ reduceOnly: true, symbol: 'ETH', side: 'sell', @@ -143,13 +141,13 @@ describe('orderUtils', () => { originalSize: '0.5', }); const position = makePosition({ symbol: 'ETH', size: '1.0' }); - expect(shouldDisplayOrderInMarketDetailsOrders(order, position)).toBe( - true, - ); + expect( + shouldDisplayOrderInMarketDetailsOrders(partialClose, position), + ).toBe(true); }); - it('hides reduce-only orders associated with full position', () => { - const order = makeOrder({ + it('includes full-position reduce-only and isPositionTpsl orders', () => { + const fullClose = makeOrder({ reduceOnly: true, symbol: 'ETH', side: 'sell', @@ -157,17 +155,15 @@ describe('orderUtils', () => { originalSize: '1.0', }); const position = makePosition({ symbol: 'ETH', size: '1.0' }); - expect(shouldDisplayOrderInMarketDetailsOrders(order, position)).toBe( - false, + expect(shouldDisplayOrderInMarketDetailsOrders(fullClose, position)).toBe( + true, ); - }); - it('hides orders with isPositionTpsl true', () => { - const order = makeOrder({ + const positionTpsl = makeOrder({ reduceOnly: true, isPositionTpsl: true, }); - expect(shouldDisplayOrderInMarketDetailsOrders(order)).toBe(false); + expect(shouldDisplayOrderInMarketDetailsOrders(positionTpsl)).toBe(true); }); }); @@ -289,7 +285,7 @@ describe('orderUtils', () => { expect(result).toHaveLength(1); }); - it('hides full-position TP/SL reduce-only orders', () => { + it('includes full-position TP/SL reduce-only orders', () => { const tpslOrder = makeOrder({ reduceOnly: true, isPositionTpsl: true, @@ -298,7 +294,8 @@ describe('orderUtils', () => { detailedOrderType: 'Take Profit Limit', }); const result = normalizeMarketDetailsOrders({ orders: [tpslOrder] }); - expect(result).toHaveLength(0); + expect(result).toHaveLength(1); + expect(result[0].orderId).toBe('order-1'); }); it('shows partial-close reduce-only orders', () => { @@ -317,7 +314,7 @@ describe('orderUtils', () => { expect(result).toHaveLength(1); }); - it('adds synthetic TP/SL rows and filters them through position check', () => { + it('adds synthetic TP/SL rows and keeps them in the list', () => { const limitOrder = makeOrder({ orderType: 'limit', reduceOnly: false, @@ -330,4 +327,116 @@ describe('orderUtils', () => { expect(result).toHaveLength(3); }); }); + + describe('formatOrderLabel', () => { + it('returns "Limit long" for a plain buy limit order', () => { + const order = makeOrder({ side: 'buy', orderType: 'limit' }); + expect(formatOrderLabel(order)).toBe('Limit long'); + }); + + it('returns "Limit short" for a plain sell limit order', () => { + const order = makeOrder({ side: 'sell', orderType: 'limit' }); + expect(formatOrderLabel(order)).toBe('Limit short'); + }); + + it('returns "Market long" for a plain buy market order', () => { + const order = makeOrder({ side: 'buy', orderType: 'market' }); + expect(formatOrderLabel(order)).toBe('Market long'); + }); + + it('returns "Market short" for a plain sell market order', () => { + const order = makeOrder({ side: 'sell', orderType: 'market' }); + expect(formatOrderLabel(order)).toBe('Market short'); + }); + + it('returns "Limit close long" for a reduce-only sell limit order', () => { + const order = makeOrder({ + side: 'sell', + orderType: 'limit', + reduceOnly: true, + }); + expect(formatOrderLabel(order)).toBe('Limit close long'); + }); + + it('returns "Limit close short" for a reduce-only buy limit order', () => { + const order = makeOrder({ + side: 'buy', + orderType: 'limit', + reduceOnly: true, + }); + expect(formatOrderLabel(order)).toBe('Limit close short'); + }); + + it('returns "Market close long" for a reduce-only sell market order', () => { + const order = makeOrder({ + side: 'sell', + orderType: 'market', + reduceOnly: true, + }); + expect(formatOrderLabel(order)).toBe('Market close long'); + }); + + it('returns "Market close short" for a reduce-only buy market order', () => { + const order = makeOrder({ + side: 'buy', + orderType: 'market', + reduceOnly: true, + }); + expect(formatOrderLabel(order)).toBe('Market close short'); + }); + + it('returns "Take profit limit close long" for a TP trigger (sell)', () => { + const order = makeOrder({ + side: 'sell', + orderType: 'limit', + isTrigger: true, + reduceOnly: true, + detailedOrderType: 'Take Profit Limit', + }); + expect(formatOrderLabel(order)).toBe('Take profit limit close long'); + }); + + it('returns "Take profit market close short" for a TP trigger (buy)', () => { + const order = makeOrder({ + side: 'buy', + orderType: 'market', + isTrigger: true, + reduceOnly: true, + detailedOrderType: 'Take Profit Market', + }); + expect(formatOrderLabel(order)).toBe('Take profit market close short'); + }); + + it('returns "Stop limit close long" for a SL trigger (sell)', () => { + const order = makeOrder({ + side: 'sell', + orderType: 'limit', + isTrigger: true, + reduceOnly: true, + detailedOrderType: 'Stop Limit', + }); + expect(formatOrderLabel(order)).toBe('Stop limit close long'); + }); + + it('returns "Stop market close short" for a SL trigger (buy)', () => { + const order = makeOrder({ + side: 'buy', + orderType: 'market', + isTrigger: true, + reduceOnly: true, + detailedOrderType: 'Stop Market', + }); + expect(formatOrderLabel(order)).toBe('Stop market close short'); + }); + + it('treats isTrigger alone as closing (no reduceOnly)', () => { + const order = makeOrder({ + side: 'sell', + orderType: 'market', + isTrigger: true, + detailedOrderType: 'Stop Market', + }); + expect(formatOrderLabel(order)).toBe('Stop market close long'); + }); + }); }); diff --git a/ui/components/app/perps/utils/orderUtils.ts b/ui/components/app/perps/utils/orderUtils.ts index d993ae6f9812..048c031d6b4f 100644 --- a/ui/components/app/perps/utils/orderUtils.ts +++ b/ui/components/app/perps/utils/orderUtils.ts @@ -1,4 +1,5 @@ import BigNumber from 'bignumber.js'; +import { capitalize } from 'lodash'; import type { Order, Position } from '@metamask/perps-controller'; const FULL_POSITION_SIZE_TOLERANCE = new BigNumber('0.00000001'); @@ -166,24 +167,21 @@ export const isOrderAssociatedWithFullPosition = ( }; /** - * Determines whether an order should be shown in Market Details > Orders. + * Determines whether an order should be shown in Market Details > Orders + * (the perps asset / position detail screen). * - * - All non-reduce-only orders are shown. - * - Reduce-only orders are shown only when they are NOT full-position TP/SL. + * Full-position TP/SL and `isPositionTpsl` rows are included here so users can + * see and act on them alongside other open orders, in addition to the position + * summary. `isOrderAssociatedWithFullPosition` remains for other call sites. * - * @param order - The order to check - * @param position - The current position (if any) + * @param _order - The order to check (reserved for future filtering) + * @param _position - The current position (reserved for future filtering) * @returns Whether the order should be displayed */ export const shouldDisplayOrderInMarketDetailsOrders = ( - order: Order, - position?: Position, -): boolean => { - if (!order.reduceOnly) { - return true; - } - return !isOrderAssociatedWithFullPosition(order, position); -}; + _order: Order, + _position?: Position, +): boolean => true; const buildSyntheticTriggerOrder = ( parentOrder: Order, @@ -305,7 +303,7 @@ export const buildDisplayOrdersWithSyntheticTpsl = ( * Normalizes orders for the Perps Market Details > Orders section. * * Composes display-order enrichment (synthetic TP/SL rows) with visibility - * filtering (hides full-position TP/SL, keeps everything else). + * filtering (currently all enriched orders are shown, including full-position TP/SL). * * @param options0 - Options object * @param options0.orders - The orders to normalize @@ -325,3 +323,41 @@ export const normalizeMarketDetailsOrders = ({ shouldDisplayOrderInMarketDetailsOrders(order, existingPosition), ); }; + +/** + * Formats an order label following the pattern: [Type] [close?] [direction] + * Matches the mobile canonical formatter in app/components/UI/Perps/utils/orderUtils.ts. + * + * Examples: + * - "Market long" / "Limit short" + * - "Limit close long" / "Market close short" + * - "Stop market close long" / "Take profit limit close short" + * + * Rules: + * - isClosing = reduceOnly || isTrigger + * - direction for closing: sell → long, buy → short + * - direction for opening: buy → long, sell → short + * - typeString = detailedOrderType if present, otherwise "Limit" or "Market" + * - lodash capitalize: uppercases first char, lowercases the rest + * + * @param order - The order to label + * @returns Formatted label string in sentence case + */ +export const formatOrderLabel = (order: Order): string => { + const { side, detailedOrderType, orderType, reduceOnly, isTrigger } = order; + const isClosing = Boolean(reduceOnly || isTrigger); + + let direction: string; + if (isClosing) { + direction = side === 'sell' ? 'long' : 'short'; + } else { + direction = side === 'buy' ? 'long' : 'short'; + } + + const typeString = + detailedOrderType || (orderType === 'limit' ? 'Limit' : 'Market'); + + return isClosing + ? capitalize(`${typeString} close ${direction}`) + : capitalize(`${typeString} ${direction}`); +}; diff --git a/ui/components/app/perps/utils/transactionTransforms.ts b/ui/components/app/perps/utils/transactionTransforms.ts index a0170f00016c..d3e4bf0ffe27 100644 --- a/ui/components/app/perps/utils/transactionTransforms.ts +++ b/ui/components/app/perps/utils/transactionTransforms.ts @@ -21,6 +21,7 @@ import { type PerpsTransaction, } from '../types/transactionHistory'; import { getDisplaySymbol } from '../utils'; +import { formatOrderLabel } from './orderUtils'; /** * Determines the close direction category for aggregation purposes. @@ -192,53 +193,6 @@ export function aggregateFillsByTimestamp(fills: OrderFill[]): OrderFill[] { return allFills; } -/** - * Format an order label following the pattern: [Type] [Close?] [Direction] - * - * Examples: - * - Market Long - * - Market Close Long - * - Limit Short - * - Limit Close Short - * - Stop Market Close Long - * - Take Profit Limit Close Short - * - * @param order - The order object - * @returns Formatted order label string - */ -function formatOrderLabel(order: Order): string { - const { side, detailedOrderType, orderType, reduceOnly, isTrigger } = order; - - // Determine if this is a closing order - const isClosing = Boolean(reduceOnly || isTrigger); - - // Determine direction based on whether it's closing or not - let direction: string; - if (isClosing) { - // For closing orders: sell closes long, buy closes short - direction = side === 'sell' ? 'long' : 'short'; - } else { - // For opening orders: buy is long, sell is short - direction = side === 'buy' ? 'long' : 'short'; - } - - // Get the order type string - // Use detailedOrderType if available (e.g., "Stop Market", "Take Profit Limit") - // Otherwise fall back to basic orderType - const typeString = - detailedOrderType || (orderType === 'limit' ? 'Limit' : 'Market'); - - // Build the label: [Type] [Close?] [Direction] - // Capitalize first letter - const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); - - if (isClosing) { - return capitalize(`${typeString} close ${direction}`); - } - - return capitalize(`${typeString} ${direction}`); -} - /** * Transform abstract OrderFill objects to PerpsTransaction format. * Close fills that occur at the same timestamp for the same asset are automatically diff --git a/ui/pages/perps/perps-market-detail-page.tsx b/ui/pages/perps/perps-market-detail-page.tsx index 7c42be962ff5..d6ca74de73cf 100644 --- a/ui/pages/perps/perps-market-detail-page.tsx +++ b/ui/pages/perps/perps-market-detail-page.tsx @@ -457,8 +457,8 @@ const PerpsMarketDetailPage: React.FC = () => { }, [position]); // Filter and sort open orders for this market, then normalize for display. - // Normalization adds synthetic TP/SL rows for parent orders and filters out - // full-position TP/SL (those stay on the position card / auto-close section). + // Normalization adds synthetic TP/SL rows for parent orders when no matching + // real trigger exists; full-position TP/SL also appear in this Orders list. const orders = useMemo(() => { if (!decodedSymbol) { return [];