diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 8e9f816880f4..214fded603b2 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -5887,6 +5887,10 @@ "message": "Stop loss must be $1 $2 price", "description": "Validation error when stop loss price is on the wrong side of the reference price. $1 is above/below, $2 is current/entry." }, + "perpsStopLossInvalidLiquidationPrice": { + "message": "Stop loss must be $1 liquidation price", + "description": "Validation error when stop loss crosses the liquidation price boundary. $1 is above/below." + }, "perpsSubmitting": { "message": "Submitting...", "description": "Loading text shown while an order is being submitted" diff --git a/app/_locales/en_GB/messages.json b/app/_locales/en_GB/messages.json index 8e9f816880f4..214fded603b2 100644 --- a/app/_locales/en_GB/messages.json +++ b/app/_locales/en_GB/messages.json @@ -5887,6 +5887,10 @@ "message": "Stop loss must be $1 $2 price", "description": "Validation error when stop loss price is on the wrong side of the reference price. $1 is above/below, $2 is current/entry." }, + "perpsStopLossInvalidLiquidationPrice": { + "message": "Stop loss must be $1 liquidation price", + "description": "Validation error when stop loss crosses the liquidation price boundary. $1 is above/below." + }, "perpsSubmitting": { "message": "Submitting...", "description": "Loading text shown while an order is being submitted" diff --git a/ui/components/app/perps/order-entry/components/auto-close-section/auto-close-section.test.tsx b/ui/components/app/perps/order-entry/components/auto-close-section/auto-close-section.test.tsx index f96d1c9821b3..642bbac630eb 100644 --- a/ui/components/app/perps/order-entry/components/auto-close-section/auto-close-section.test.tsx +++ b/ui/components/app/perps/order-entry/components/auto-close-section/auto-close-section.test.tsx @@ -646,6 +646,24 @@ describe('AutoCloseSection', () => { ); }); + it('shows liquidation-boundary error when long SL is below liquidation price', () => { + renderWithProvider( + , + mockStore, + ); + + expect(screen.getByTestId('sl-validation-error')).toHaveTextContent( + /above.*liquidation/iu, + ); + }); + it('uses limit price as reference for limit orders', () => { renderWithProvider( = ({ enabled, @@ -65,6 +65,7 @@ export const AutoCloseSection: React.FC = ({ onStopLossPriceChange, direction, currentPrice, + liquidationPrice, entryPrice: entryPriceProp, estimatedSize, orderType, @@ -352,17 +353,24 @@ export const AutoCloseSection: React.FC = ({ [takeProfitPrice, validationReferencePrice, direction], ); + const stopLossValidation = useMemo( + () => + getStopLossValidationResult(stopLossPrice, { + currentPrice: validationReferencePrice, + direction, + liquidationPrice, + }), + [stopLossPrice, validationReferencePrice, direction, liquidationPrice], + ); + const isSlInvalid = useMemo( () => Boolean( stopLossPrice.trim() && validationReferencePrice > 0 && - !isValidStopLossPrice(stopLossPrice, { - currentPrice: validationReferencePrice, - direction, - }), + !stopLossValidation.isValid, ), - [stopLossPrice, validationReferencePrice, direction], + [stopLossPrice, validationReferencePrice, stopLossValidation.isValid], ); const tpErrorMessage = useMemo(() => { @@ -379,11 +387,18 @@ export const AutoCloseSection: React.FC = ({ if (!isSlInvalid) { return null; } - return t('perpsStopLossInvalidPrice', [ - getStopLossErrorDirection(direction), - priceLabel, - ]); - }, [isSlInvalid, direction, priceLabel, t]); + return t( + stopLossValidation.referencePrice === 'liquidation' + ? 'perpsStopLossInvalidLiquidationPrice' + : 'perpsStopLossInvalidPrice', + [ + stopLossValidation.direction, + stopLossValidation.referencePrice === 'liquidation' + ? undefined + : priceLabel, + ].filter(Boolean) as string[], + ); + }, [isSlInvalid, stopLossValidation, priceLabel, t]); return ( diff --git a/ui/components/app/perps/order-entry/order-entry.tsx b/ui/components/app/perps/order-entry/order-entry.tsx index cd268b88c27e..e70c48c74078 100644 --- a/ui/components/app/perps/order-entry/order-entry.tsx +++ b/ui/components/app/perps/order-entry/order-entry.tsx @@ -352,6 +352,7 @@ export const OrderEntry: React.FC = ({ onStopLossPriceChange={handleStopLossPriceChange} direction={formState.direction} currentPrice={currentPrice} + liquidationPrice={calculations.liquidationPriceRaw} leverage={formState.leverage} entryPrice={undefined} estimatedSize={estimatedSize} diff --git a/ui/components/app/perps/order-entry/order-entry.types.ts b/ui/components/app/perps/order-entry/order-entry.types.ts index 0d7c00dd153e..a7ad9e2c6c8c 100644 --- a/ui/components/app/perps/order-entry/order-entry.types.ts +++ b/ui/components/app/perps/order-entry/order-entry.types.ts @@ -215,6 +215,8 @@ export type AutoCloseSectionProps = { direction: OrderDirection; /** Current asset price (for TP/SL calculations and new orders) */ currentPrice: number; + /** Estimated liquidation price as a raw number for stop-loss boundary validation */ + liquidationPrice?: number | null; /** Position entry price (for modify mode - use instead of currentPrice for accurate % calc) */ entryPrice?: number; /** Signed position size in asset units (positive=long, negative=short) for estimated PnL */ diff --git a/ui/components/app/perps/update-tpsl/update-tpsl-modal-content.test.tsx b/ui/components/app/perps/update-tpsl/update-tpsl-modal-content.test.tsx index 44ea70c5f3d8..aa392c12db77 100644 --- a/ui/components/app/perps/update-tpsl/update-tpsl-modal-content.test.tsx +++ b/ui/components/app/perps/update-tpsl/update-tpsl-modal-content.test.tsx @@ -167,6 +167,33 @@ describe('UpdateTPSLModalContent', () => { }); }); + describe('stop loss validation', () => { + it('shows a liquidation-price validation error and disables submit for short positions above liquidation', async () => { + const shortPosition = mockPositions[1]; + + renderTpslModalContent({ + position: shortPosition, + currentPrice: 45000, + }); + + const slInput = screen.getAllByPlaceholderText( + '0.00', + )[1] as HTMLInputElement; + + fireEvent.change(slInput, { target: { value: '50000' } }); + + expect(screen.getByTestId('sl-validation-error')).toHaveTextContent( + 'Stop loss must be below liquidation price', + ); + + await waitFor(() => { + expect( + screen.getByTestId('perps-update-tpsl-modal-submit'), + ).toBeDisabled(); + }); + }); + }); + describe('initialization', () => { it('initializes TP/SL prices from position data', () => { renderTpslModalContent(); diff --git a/ui/components/app/perps/update-tpsl/update-tpsl-modal-content.tsx b/ui/components/app/perps/update-tpsl/update-tpsl-modal-content.tsx index 4db68e021b2c..49e11eb5533c 100644 --- a/ui/components/app/perps/update-tpsl/update-tpsl-modal-content.tsx +++ b/ui/components/app/perps/update-tpsl/update-tpsl-modal-content.tsx @@ -48,9 +48,8 @@ import { import { PerpsGeoBlockModal } from '../perps-geo-block-modal'; import { isValidTakeProfitPrice, - isValidStopLossPrice, + getStopLossValidationResult, getTakeProfitErrorDirection, - getStopLossErrorDirection, } from '../utils/tpslValidation'; // RoE (Return on Equity) preset percentages - matching mobile @@ -250,17 +249,35 @@ export const UpdateTPSLModalContent: React.FC = ({ [editingTpPrice, currentPrice, positionDirection], ); + const liquidationPrice = useMemo(() => { + if (!position.liquidationPrice) { + return null; + } + + const parsedLiquidationPrice = Number.parseFloat( + position.liquidationPrice.replaceAll(',', ''), + ); + return Number.isNaN(parsedLiquidationPrice) ? null : parsedLiquidationPrice; + }, [position.liquidationPrice]); + + const stopLossValidation = useMemo( + () => + getStopLossValidationResult(editingSlPrice, { + currentPrice, + direction: positionDirection, + liquidationPrice, + }), + [editingSlPrice, currentPrice, positionDirection, liquidationPrice], + ); + const isSlInvalid = useMemo( () => Boolean( editingSlPrice.replaceAll(',', '').trim() && currentPrice > 0 && - !isValidStopLossPrice(editingSlPrice, { - currentPrice, - direction: positionDirection, - }), + !stopLossValidation.isValid, ), - [editingSlPrice, currentPrice, positionDirection], + [editingSlPrice, currentPrice, stopLossValidation.isValid], ); const hasInvalidTPSL = isTpInvalid || isSlInvalid; @@ -755,10 +772,17 @@ export const UpdateTPSLModalContent: React.FC = ({ color={TextColor.ErrorDefault} data-testid="sl-validation-error" > - {t('perpsStopLossInvalidPrice', [ - getStopLossErrorDirection(positionDirection), - 'current', - ])} + {t( + stopLossValidation.referencePrice === 'liquidation' + ? 'perpsStopLossInvalidLiquidationPrice' + : 'perpsStopLossInvalidPrice', + [ + stopLossValidation.direction, + stopLossValidation.referencePrice === 'liquidation' + ? undefined + : 'current', + ].filter(Boolean) as string[], + )} )} diff --git a/ui/components/app/perps/utils/tpslValidation.test.ts b/ui/components/app/perps/utils/tpslValidation.test.ts index 1c3067b19c8f..76351fedb4b9 100644 --- a/ui/components/app/perps/utils/tpslValidation.test.ts +++ b/ui/components/app/perps/utils/tpslValidation.test.ts @@ -1,6 +1,7 @@ import { isValidTakeProfitPrice, isValidStopLossPrice, + getStopLossValidationResult, getTakeProfitErrorDirection, getStopLossErrorDirection, } from './tpslValidation'; @@ -132,6 +133,26 @@ describe('tpslValidation', () => { }), ).toBe(false); }); + + it('returns false when SL is below liquidation price', () => { + expect( + isValidStopLossPrice('39000', { + currentPrice, + direction: 'long', + liquidationPrice: 40000, + }), + ).toBe(false); + }); + + it('returns true when SL is between current and liquidation price', () => { + expect( + isValidStopLossPrice('45000', { + currentPrice, + direction: 'long', + liquidationPrice: 40000, + }), + ).toBe(true); + }); }); describe('short direction', () => { @@ -152,6 +173,26 @@ describe('tpslValidation', () => { }), ).toBe(false); }); + + it('returns false when SL is above liquidation price', () => { + expect( + isValidStopLossPrice('61000', { + currentPrice, + direction: 'short', + liquidationPrice: 60000, + }), + ).toBe(false); + }); + + it('returns true when SL is between current and liquidation price', () => { + expect( + isValidStopLossPrice('55000', { + currentPrice, + direction: 'short', + liquidationPrice: 60000, + }), + ).toBe(true); + }); }); describe('edge cases', () => { @@ -180,6 +221,16 @@ describe('tpslValidation', () => { expect(isValidStopLossPrice('100', { currentPrice })).toBe(true); }); + it('falls back to current price validation when liquidation price is unavailable', () => { + expect( + isValidStopLossPrice('55000', { + currentPrice, + direction: 'short', + liquidationPrice: null, + }), + ).toBe(true); + }); + it('handles formatted prices with commas and dollar signs', () => { expect( isValidStopLossPrice('$45,000', { @@ -198,6 +249,52 @@ describe('tpslValidation', () => { }); }); + describe('getStopLossValidationResult', () => { + const currentPrice = 50000; + + it('reports current price boundary errors for short positions', () => { + expect( + getStopLossValidationResult('45000', { + currentPrice, + direction: 'short', + liquidationPrice: 60000, + }), + ).toStrictEqual({ + isValid: false, + direction: 'above', + referencePrice: 'current', + }); + }); + + it('reports liquidation price boundary errors for short positions', () => { + expect( + getStopLossValidationResult('61000', { + currentPrice, + direction: 'short', + liquidationPrice: 60000, + }), + ).toStrictEqual({ + isValid: false, + direction: 'below', + referencePrice: 'liquidation', + }); + }); + + it('reports liquidation price boundary errors for long positions', () => { + expect( + getStopLossValidationResult('39000', { + currentPrice, + direction: 'long', + liquidationPrice: 40000, + }), + ).toStrictEqual({ + isValid: false, + direction: 'above', + referencePrice: 'liquidation', + }); + }); + }); + describe('getTakeProfitErrorDirection', () => { it('returns "above" for long', () => { expect(getTakeProfitErrorDirection('long')).toBe('above'); diff --git a/ui/components/app/perps/utils/tpslValidation.ts b/ui/components/app/perps/utils/tpslValidation.ts index f37aacb3156f..bf027404534d 100644 --- a/ui/components/app/perps/utils/tpslValidation.ts +++ b/ui/components/app/perps/utils/tpslValidation.ts @@ -16,6 +16,13 @@ type Direction = 'long' | 'short'; type ValidationParams = { currentPrice: number; direction?: Direction; + liquidationPrice?: number | null; +}; + +export type StopLossValidationResult = { + isValid: boolean; + direction: 'above' | 'below' | ''; + referencePrice: 'current' | 'liquidation' | ''; }; /** @@ -52,21 +59,69 @@ export const isValidTakeProfitPrice = ( * @param params.currentPrice * @param params.direction */ -export const isValidStopLossPrice = ( +export function isValidStopLossPrice(price: string, params: ValidationParams) { + return getStopLossValidationResult(price, params).isValid; +} + +/** + * Validates stop loss against both the current price and, when available, + * the liquidation price. A valid stop loss must stay between those two + * boundaries for the current position direction. + * + * @param price - The stop loss price string (may include `$` / `,` formatting) + * @param params - Object with `currentPrice`, `direction`, and optional `liquidationPrice` + * @param params.currentPrice + * @param params.direction + * @param params.liquidationPrice + */ +export function getStopLossValidationResult( price: string, - { currentPrice, direction }: ValidationParams, -): boolean => { + { currentPrice, direction, liquidationPrice }: ValidationParams, +): StopLossValidationResult { if (!currentPrice || !direction || !price) { - return true; + return { isValid: true, direction: '', referencePrice: '' }; } const slPrice = Number.parseFloat(price.replaceAll(/[$,]/gu, '')); if (Number.isNaN(slPrice)) { - return true; + return { isValid: true, direction: '', referencePrice: '' }; } - return direction === 'long' ? slPrice < currentPrice : slPrice > currentPrice; -}; + const isWithinCurrentBoundary = + direction === 'long' ? slPrice < currentPrice : slPrice > currentPrice; + + if (!isWithinCurrentBoundary) { + return { + isValid: false, + direction: direction === 'long' ? 'below' : 'above', + referencePrice: 'current', + }; + } + + if ( + liquidationPrice === undefined || + liquidationPrice === null || + !Number.isFinite(liquidationPrice) || + liquidationPrice <= 0 + ) { + return { isValid: true, direction: '', referencePrice: '' }; + } + + const isWithinLiquidationBoundary = + direction === 'long' + ? slPrice > liquidationPrice + : slPrice < liquidationPrice; + + if (!isWithinLiquidationBoundary) { + return { + isValid: false, + direction: direction === 'long' ? 'above' : 'below', + referencePrice: 'liquidation', + }; + } + + return { isValid: true, direction: '', referencePrice: '' }; +} /** * Returns the directional word for a take-profit validation error message. diff --git a/ui/pages/perps/perps-order-entry-page.test.tsx b/ui/pages/perps/perps-order-entry-page.test.tsx index 74891fd8bb26..dfe89e0b2ad0 100644 --- a/ui/pages/perps/perps-order-entry-page.test.tsx +++ b/ui/pages/perps/perps-order-entry-page.test.tsx @@ -784,6 +784,33 @@ describe('PerpsOrderEntryPage', () => { expect(screen.getByTestId('submit-order-button')).toBeDisabled(); }); }); + + it('disables submit when auto-close stop loss crosses liquidation price', async () => { + const store = mockStore(createMockState()); + renderWithProvider(, store); + + const amountContainer = screen.getByTestId('amount-input-field'); + const amountInput = amountContainer.querySelector('input'); + fireEvent.change(amountInput as HTMLInputElement, { + target: { value: '1000' }, + }); + + fireEvent.click(screen.getByTestId('auto-close-toggle')); + + const slContainer = screen.getByTestId('sl-price-input'); + const slInput = slContainer.querySelector('input'); + fireEvent.change(slInput as HTMLInputElement, { + target: { value: '1' }, + }); + + expect(screen.getByTestId('sl-validation-error')).toHaveTextContent( + /liquidation/iu, + ); + + await waitFor(() => { + expect(screen.getByTestId('submit-order-button')).toBeDisabled(); + }); + }); }); describe('order submission', () => { diff --git a/ui/pages/perps/perps-order-entry-page.tsx b/ui/pages/perps/perps-order-entry-page.tsx index 72dbfcd86fd3..6cdb16448f53 100644 --- a/ui/pages/perps/perps-order-entry-page.tsx +++ b/ui/pages/perps/perps-order-entry-page.tsx @@ -88,7 +88,7 @@ import { } from '../../components/app/perps/order-entry/limit-price-warnings'; import { isValidTakeProfitPrice, - isValidStopLossPrice, + getStopLossValidationResult, } from '../../components/app/perps/utils/tpslValidation'; import { PerpsDetailPageSkeleton } from '../../components/app/perps/perps-skeletons'; import { @@ -514,14 +514,15 @@ const PerpsOrderEntryPage: React.FC = () => { ); const slInvalid = Boolean( sl?.trim() && - !isValidStopLossPrice(sl, { + !getStopLossValidationResult(sl, { currentPrice: referencePrice, direction: dir, - }), + liquidationPrice: orderCalculations?.liquidationPriceRaw, + }).isValid, ); return tpInvalid || slInvalid; - }, [orderFormState, orderType, currentPrice, orderMode]); + }, [orderFormState, orderType, currentPrice, orderMode, orderCalculations]); const isInsufficientFunds = useMemo(() => { if (!orderFormState || orderMode === 'close') {