Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ describe('AutoCloseSection', () => {
});

it('calculates RoE% for long SL position', () => {
// (45000 - 40500) / 45000 * 10 * 100 = 100%
// SL below entry: (40500 - 45000) / 45000 * 10 * 100 = -100% (loss = negative RoE)
renderWithProvider(
<AutoCloseSection
{...defaultProps}
Expand All @@ -315,7 +315,7 @@ describe('AutoCloseSection', () => {

const container = screen.getByTestId('sl-percent-input');
const percentInput = container.querySelector('input');
expect(percentInput).toHaveValue('100');
expect(percentInput).toHaveValue('-100');
});

it('shows empty percent when TP price is empty', () => {
Expand Down Expand Up @@ -384,7 +384,8 @@ describe('AutoCloseSection', () => {
});

it('updates price when RoE% is entered for SL (long)', () => {
// 10% RoE at leverage=10: priceChange = 10/(10*100) = 1% -> 45000 * 0.99 = 44550
// -10% RoE at leverage=10: priceChange = -10/(10*100) = -1% -> 45000 * 0.99 = 44550
// Negative RoE = loss direction = SL below entry for long
const onStopLossPriceChange = jest.fn();
renderWithProvider(
<AutoCloseSection
Expand All @@ -402,15 +403,16 @@ describe('AutoCloseSection', () => {
const input = container.querySelector('input');
expect(input).not.toBeNull();
fireEvent.change(input as HTMLInputElement, {
target: { value: '10' },
target: { value: '-10' },
});

expect(onStopLossPriceChange).toHaveBeenCalledWith('44550');
});

it('uses limit price as baseline when typing SL % on a limit order', () => {
// currentPrice=$3,000 but limitPrice=$2,000 (below-market limit buy).
// 10% RoE at leverage=10: priceChange = 10/(10*100) = 1% -> $2,000 * 0.99 = $1,980 (not $2,970)
// -10% RoE at leverage=10: priceChange = -1% -> $2,000 * 0.99 = $1,980 (not $2,970)
// Negative RoE = loss direction = SL below entry
const onStopLossPriceChange = jest.fn();
renderWithProvider(
<AutoCloseSection
Expand All @@ -429,7 +431,7 @@ describe('AutoCloseSection', () => {
const container = screen.getByTestId('sl-percent-input');
const input = container.querySelector('input');
expect(input).not.toBeNull();
fireEvent.change(input as HTMLInputElement, { target: { value: '10' } });
fireEvent.change(input as HTMLInputElement, { target: { value: '-10' } });

expect(onStopLossPriceChange).toHaveBeenCalledWith('1980');
});
Expand Down Expand Up @@ -462,7 +464,7 @@ describe('AutoCloseSection', () => {

it('displays SL % relative to limit price when a price is pre-set on a limit order', () => {
// SL at $1,980 with limit entry $2,000 at 10x leverage:
// RoE% = (2000 - 1980) / 2000 * 10 * 100 = 10% (not relative to $3,000)
// RoE% = (1980 - 2000) / 2000 * 10 * 100 = -10% (loss = negative RoE for long below entry)
renderWithProvider(
<AutoCloseSection
{...defaultProps}
Expand All @@ -479,12 +481,13 @@ describe('AutoCloseSection', () => {

const container = screen.getByTestId('sl-percent-input');
const percentInput = container.querySelector('input');
expect(percentInput).toHaveValue('10');
expect(percentInput).toHaveValue('-10');
});

it('falls back to current price for % calculation when limit price is empty', () => {
// Same as regular market order when limitPrice is empty
// 10% RoE at 10x from $3,000: SL = $3,000 * 0.99 = $2,970
// -10% RoE at 10x from $3,000: SL = $3,000 * 0.99 = $2,970
// Negative RoE = loss direction = SL below entry for long
const onStopLossPriceChange = jest.fn();
renderWithProvider(
<AutoCloseSection
Expand All @@ -503,7 +506,7 @@ describe('AutoCloseSection', () => {
const container = screen.getByTestId('sl-percent-input');
const input = container.querySelector('input');
expect(input).not.toBeNull();
fireEvent.change(input as HTMLInputElement, { target: { value: '10' } });
fireEvent.change(input as HTMLInputElement, { target: { value: '-10' } });

expect(onStopLossPriceChange).toHaveBeenCalledWith('2970');
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,13 @@ export const AutoCloseSection: React.FC<AutoCloseSectionProps> = ({
const [isSlPercentFocused, setIsSlPercentFocused] = useState(false);

/**
* Convert a target price to a RoE% for display.
* Convert a target price to a signed RoE% for display.
* Positive = profitable (above entry for long / below entry for short).
* Negative = at a loss.
* RoE% = ((targetPrice - entryPrice) / entryPrice) * leverage * 100
*/
const priceToPercent = useCallback(
(price: string, isTP: boolean): string => {
(price: string): string => {
if (!price || !entryPrice) {
return '';
}
Expand All @@ -119,35 +121,30 @@ export const AutoCloseSection: React.FC<AutoCloseSectionProps> = ({
const diff = priceNum - entryPrice;
const percentChange = (diff / entryPrice) * leverage * 100;

// For long: TP is above entry (positive RoE%), SL is below entry (show as positive loss%)
// For short: TP is below entry (show as positive profit%), SL is above entry (show as positive loss%)
if (direction === 'long') {
return formatRoePercent(isTP ? percentChange : -percentChange);
}
return formatRoePercent(isTP ? -percentChange : percentChange);
// For long: positive when price > entry (profit). For short: negate (profit when price < entry).
return formatRoePercent(
direction === 'long' ? percentChange : -percentChange,
);
},
[entryPrice, leverage, direction],
);

/**
* Convert a RoE% to a target price.
* targetPrice = entryPrice * (1 + roePercent / (leverage * 100))
* Convert a signed RoE% to a target price.
* Positive percent = profitable direction (above entry for long / below entry for short).
* Negative percent = loss direction.
* targetPrice = entryPrice * (1 + signedRoe / (leverage * 100)) [long]
* targetPrice = entryPrice * (1 - signedRoe / (leverage * 100)) [short]
*/
const percentToPrice = useCallback(
(percent: number, isTP: boolean): string => {
(percent: number): string => {
if (!entryPrice || percent === 0) {
return '';
}

// For long: TP = entry * (1 + roe/lev), SL = entry * (1 - roe/lev)
// For short: TP = entry * (1 - roe/lev), SL = entry * (1 + roe/lev)
const priceChangeRatio = percent / (leverage * 100);
let multiplier: number;
if (direction === 'long') {
multiplier = isTP ? 1 + priceChangeRatio : 1 - priceChangeRatio;
} else {
multiplier = isTP ? 1 - priceChangeRatio : 1 + priceChangeRatio;
}
const multiplier =
direction === 'long' ? 1 + priceChangeRatio : 1 - priceChangeRatio;

const price = entryPrice * multiplier;
const normalizedPrice = Number.parseFloat(price.toFixed(8));
Expand Down Expand Up @@ -198,10 +195,10 @@ export const AutoCloseSection: React.FC<AutoCloseSectionProps> = ({
if (value === '' || isSignedDecimalInput(value)) {
setRawTpPercent(value);
const numValue = parseFloat(value);
if (value === '' || value === '-') {
if (value === '' || value === '-' || value === '+') {
onTakeProfitPriceChange('');
} else if (!isNaN(numValue)) {
const newPrice = percentToPrice(numValue, true);
const newPrice = percentToPrice(numValue);
onTakeProfitPriceChange(newPrice);
}
}
Expand All @@ -211,7 +208,7 @@ export const AutoCloseSection: React.FC<AutoCloseSectionProps> = ({

const handleTpPercentFocus = useCallback(() => {
// Seed raw value from current derived percent so the cursor lands on existing content
const derived = priceToPercent(takeProfitPrice, true);
const derived = priceToPercent(takeProfitPrice);
setRawTpPercent(derived);
setIsTpPercentFocused(true);
}, [priceToPercent, takeProfitPrice]);
Expand Down Expand Up @@ -255,10 +252,10 @@ export const AutoCloseSection: React.FC<AutoCloseSectionProps> = ({
if (value === '' || isSignedDecimalInput(value)) {
setRawSlPercent(value);
const numValue = parseFloat(value);
if (value === '' || value === '-') {
if (value === '' || value === '-' || value === '+') {
onStopLossPriceChange('');
} else if (!isNaN(numValue)) {
const newPrice = percentToPrice(numValue, false);
const newPrice = percentToPrice(numValue);
onStopLossPriceChange(newPrice);
}
}
Expand All @@ -267,7 +264,7 @@ export const AutoCloseSection: React.FC<AutoCloseSectionProps> = ({
);

const handleSlPercentFocus = useCallback(() => {
const derived = priceToPercent(stopLossPrice, false);
const derived = priceToPercent(stopLossPrice);
setRawSlPercent(derived);
setIsSlPercentFocused(true);
}, [priceToPercent, stopLossPrice]);
Expand All @@ -279,12 +276,12 @@ export const AutoCloseSection: React.FC<AutoCloseSectionProps> = ({

// Calculate current RoE percentages for display (used when fields are not focused)
const tpPercent = useMemo(
() => priceToPercent(takeProfitPrice, true),
() => priceToPercent(takeProfitPrice),
[priceToPercent, takeProfitPrice],
);

const slPercent = useMemo(
() => priceToPercent(stopLossPrice, false),
() => priceToPercent(stopLossPrice),
[priceToPercent, stopLossPrice],
);

Expand Down
8 changes: 8 additions & 0 deletions ui/components/app/perps/order-entry/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,19 @@ describe('order-entry utils', () => {
expect(isSignedDecimalInput('12.5')).toBe(true);
});

it('accepts + prefix and intermediate states', () => {
expect(isSignedDecimalInput('+')).toBe(true);
expect(isSignedDecimalInput('+.')).toBe(true);
expect(isSignedDecimalInput('+12.5')).toBe(true);
expect(isSignedDecimalInput('+15')).toBe(true);
});

it('rejects invalid signed decimal values', () => {
expect(isSignedDecimalInput('--1')).toBe(false);
expect(isSignedDecimalInput('-1-2')).toBe(false);
expect(isSignedDecimalInput('1.2.3')).toBe(false);
expect(isSignedDecimalInput('1a')).toBe(false);
expect(isSignedDecimalInput('++1')).toBe(false);
});
});
});
6 changes: 3 additions & 3 deletions ui/components/app/perps/order-entry/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,13 @@ export const isUnsignedDecimalInput = (value: string): boolean => {
};

/**
* Linear-time signed decimal validation with optional leading minus and
* optional single decimal point. Accepts intermediate states like "-" and "-.".
* Linear-time signed decimal validation with optional leading sign (+ or -) and
* optional single decimal point. Accepts intermediate states like "-", "+", "-.", "+.".
* @param value
*/
export const isSignedDecimalInput = (value: string): boolean => {
let startIndex = 0;
if (value.startsWith('-')) {
if (value.startsWith('-') || value.startsWith('+')) {
startIndex = 1;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -395,14 +395,14 @@ describe('UpdateTPSLModalContent', () => {
expect(numValue).toBeCloseTo(3325, 0);
});

it('updates SL price when a RoE% value is typed', () => {
// ETH: entry=2850, leverage=3, -50% RoE -> 2850 * (1 - 50/300) = 2850 * 0.8333 = 2375
it('updates SL price when a negative RoE% is typed (signed convention)', () => {
// ETH: entry=2850, leverage=3, -50% signed RoE -> 2850 * (1 + (-50)/300) = 2850 * 0.8333 = 2375
renderTpslModalContent();

const percentInputs = screen.getAllByPlaceholderText('0');
const slPercentInput = percentInputs[1];
fireEvent.focus(slPercentInput);
fireEvent.change(slPercentInput, { target: { value: '50' } });
fireEvent.change(slPercentInput, { target: { value: '-50' } });

const slPriceInput = screen.getAllByPlaceholderText(
'0.00',
Expand All @@ -411,6 +411,22 @@ describe('UpdateTPSLModalContent', () => {
expect(numValue).toBeCloseTo(2375, 0);
});

it('updates SL price when a positive RoE% is typed (SL above entry)', () => {
// SOL: entry=95, leverage=10, +15% signed RoE -> 95 * (1 + 15/1000) = 95 * 1.015 = 96.425
renderTpslModalContent({ position: positionWithoutTPSL });

const percentInputs = screen.getAllByPlaceholderText('0');
const slPercentInput = percentInputs[1];
fireEvent.focus(slPercentInput);
fireEvent.change(slPercentInput, { target: { value: '15' } });

const slPriceInput = screen.getAllByPlaceholderText(
'0.00',
)[1] as HTMLInputElement;
const numValue = parseFloat(slPriceInput.value.replace(/,/gu, ''));
expect(numValue).toBeCloseTo(96.425, 0);
});

it('clears TP price when percent input is cleared', () => {
renderTpslModalContent();

Expand Down Expand Up @@ -450,7 +466,51 @@ describe('UpdateTPSLModalContent', () => {
expect(slPriceInput.value).toBe('');
});

it('clears TP price when only a plus sign is typed in TP percent input', () => {
renderTpslModalContent();

const tpPercentInput = screen.getAllByPlaceholderText('0')[0];
fireEvent.focus(tpPercentInput);
fireEvent.change(tpPercentInput, { target: { value: '+' } });

const tpPriceInput = screen.getAllByPlaceholderText(
'0.00',
)[0] as HTMLInputElement;
expect(tpPriceInput.value).toBe('');
});

it('clears SL price when only a plus sign is typed in SL percent input', () => {
renderTpslModalContent();

const slPercentInput = screen.getAllByPlaceholderText('0')[1];
fireEvent.focus(slPercentInput);
fireEvent.change(slPercentInput, { target: { value: '+' } });

const slPriceInput = screen.getAllByPlaceholderText(
'0.00',
)[1] as HTMLInputElement;
expect(slPriceInput.value).toBe('');
});

it('accepts + prefix in TP percent input', () => {
// SOL: entry=95, leverage=10. +25% signed RoE -> 95*(1+25/1000) = 95*1.025 = 97.375
renderTpslModalContent({ position: positionWithoutTPSL });

const tpPercentInput = screen.getAllByPlaceholderText('0')[0];
fireEvent.focus(tpPercentInput);
fireEvent.change(tpPercentInput, { target: { value: '+25' } });

const tpPriceInput = screen.getAllByPlaceholderText(
'0.00',
)[0] as HTMLInputElement;
const numValue = parseFloat(tpPriceInput.value.replace(/,/gu, ''));
expect(numValue).toBeCloseTo(97.375, 0);
});

it('shows raw input while SL percent is focused and formatted value after blur', () => {
// SOL: entry=95, leverage=10. Typing +10 (SL above entry for lock-in-profit scenario)
// -> price = 95*(1+10/1000) = 95.95 -> blur shows priceToPercent("95.95") for long
// -> (95.95-95)/95*10*100 = 10 -> "10" (positive, no sign)
renderTpslModalContent({ position: positionWithoutTPSL });

const slPercentInput = screen.getAllByPlaceholderText('0')[1];
Expand All @@ -462,7 +522,8 @@ describe('UpdateTPSLModalContent', () => {
fireEvent.blur(slPercentInput);

const blurredValue = (slPercentInput as HTMLInputElement).value;
expect(blurredValue).toMatch(/^\d+(\.\d+)?$/u);
// After blur, shows signed RoE: positive percent stays positive (no sign prefix)
expect(blurredValue).toMatch(/^-?\d+(\.\d+)?$/u);
});

it('rejects non-numeric characters in TP percent input', () => {
Expand Down Expand Up @@ -501,12 +562,12 @@ describe('UpdateTPSLModalContent', () => {

fireEvent.blur(tpPercentInput);

// After blur: shows derived formatted value
// After blur: shows derived signed RoE value
// SOL: entry=95, leverage=10
// price = 95 * (1 + 25/1000) = 95 * 1.025 = 97.375 -> formatted as "97.38"
// priceToPercent('97.38', long TP): (97.38-95)/95 * 10 * 100 = 25.05 -> "25.05"
// priceToPercent('97.38', long): (97.38-95)/95 * 10 * 100 = 25.05 -> "25.05"
const blurredValue = (tpPercentInput as HTMLInputElement).value;
expect(blurredValue).toMatch(/^\d+(\.\d+)?$/u);
expect(blurredValue).toMatch(/^-?\d+(\.\d+)?$/u);
});
});

Expand Down
Loading
Loading