Skip to content
Open
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
4 changes: 4 additions & 0 deletions app/_locales/en/messages.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions app/_locales/en_GB/messages.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,24 @@ describe('AutoCloseSection', () => {
);
});

it('shows liquidation-boundary error when long SL is below liquidation price', () => {
renderWithProvider(
<AutoCloseSection
{...defaultProps}
enabled={true}
direction="long"
currentPrice={50000}
liquidationPrice={45000}
stopLossPrice="44000"
/>,
mockStore,
);

expect(screen.getByTestId('sl-validation-error')).toHaveTextContent(
/above.*liquidation/iu,
);
});

it('uses limit price as reference for limit orders', () => {
renderWithProvider(
<AutoCloseSection
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,8 @@ import type { AutoCloseSectionProps } from '../../order-entry.types';
import { isSignedDecimalInput, isUnsignedDecimalInput } from '../../utils';
import {
isValidTakeProfitPrice,
isValidStopLossPrice,
getStopLossValidationResult,
getTakeProfitErrorDirection,
getStopLossErrorDirection,
} from '../../../utils/tpslValidation';
import { formatRoePercent, getPnlDisplayColor } from '../../../utils';

Expand Down Expand Up @@ -55,6 +54,7 @@ import { formatRoePercent, getPnlDisplayColor } from '../../../utils';
* @param props.limitPrice - Limit price string; used as the baseline for both validation and % ↔ price conversions on limit orders
* @param props.leverage - Leverage multiplier for RoE% calculation
* @param props.asset - Asset symbol for fetching dynamic closing fee rates
* @param props.liquidationPrice
*/
export const AutoCloseSection: React.FC<AutoCloseSectionProps> = ({
enabled,
Expand All @@ -65,6 +65,7 @@ export const AutoCloseSection: React.FC<AutoCloseSectionProps> = ({
onStopLossPriceChange,
direction,
currentPrice,
liquidationPrice,
entryPrice: entryPriceProp,
estimatedSize,
orderType,
Expand Down Expand Up @@ -352,17 +353,24 @@ export const AutoCloseSection: React.FC<AutoCloseSectionProps> = ({
[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(() => {
Expand All @@ -379,11 +387,18 @@ export const AutoCloseSection: React.FC<AutoCloseSectionProps> = ({
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 (
<Box flexDirection={BoxFlexDirection.Column} gap={3}>
Expand Down
1 change: 1 addition & 0 deletions ui/components/app/perps/order-entry/order-entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,7 @@ export const OrderEntry: React.FC<OrderEntryProps> = ({
onStopLossPriceChange={handleStopLossPriceChange}
direction={formState.direction}
currentPrice={currentPrice}
liquidationPrice={calculations.liquidationPriceRaw}
leverage={formState.leverage}
entryPrice={undefined}
estimatedSize={estimatedSize}
Expand Down
2 changes: 2 additions & 0 deletions ui/components/app/perps/order-entry/order-entry.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
46 changes: 35 additions & 11 deletions ui/components/app/perps/update-tpsl/update-tpsl-modal-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -250,17 +249,35 @@ export const UpdateTPSLModalContent: React.FC<UpdateTPSLModalContentProps> = ({
[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;
Expand Down Expand Up @@ -755,10 +772,17 @@ export const UpdateTPSLModalContent: React.FC<UpdateTPSLModalContentProps> = ({
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[],
)}
</Text>
)}
</Box>
Expand Down
97 changes: 97 additions & 0 deletions ui/components/app/perps/utils/tpslValidation.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
isValidTakeProfitPrice,
isValidStopLossPrice,
getStopLossValidationResult,
getTakeProfitErrorDirection,
getStopLossErrorDirection,
} from './tpslValidation';
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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', {
Expand All @@ -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');
Expand Down
Loading
Loading