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..8582105a8637 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 @@ -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( { 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', () => { @@ -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( { const input = container.querySelector('input'); expect(input).not.toBeNull(); fireEvent.change(input as HTMLInputElement, { - target: { value: '10' }, + target: { value: '-10' }, }); expect(onStopLossPriceChange).toHaveBeenCalledWith('44550'); @@ -410,7 +411,8 @@ describe('AutoCloseSection', () => { 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( { 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'); }); @@ -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( { 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( { 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'); }); diff --git a/ui/components/app/perps/order-entry/components/auto-close-section/auto-close-section.tsx b/ui/components/app/perps/order-entry/components/auto-close-section/auto-close-section.tsx index 90ee48c81089..43feff8bd754 100644 --- a/ui/components/app/perps/order-entry/components/auto-close-section/auto-close-section.tsx +++ b/ui/components/app/perps/order-entry/components/auto-close-section/auto-close-section.tsx @@ -104,11 +104,13 @@ export const AutoCloseSection: React.FC = ({ 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 ''; } @@ -120,35 +122,30 @@ export const AutoCloseSection: React.FC = ({ 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)); @@ -199,10 +196,10 @@ export const AutoCloseSection: React.FC = ({ 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); } } @@ -212,7 +209,7 @@ export const AutoCloseSection: React.FC = ({ 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]); @@ -256,10 +253,10 @@ export const AutoCloseSection: React.FC = ({ 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); } } @@ -268,7 +265,7 @@ export const AutoCloseSection: React.FC = ({ ); const handleSlPercentFocus = useCallback(() => { - const derived = priceToPercent(stopLossPrice, false); + const derived = priceToPercent(stopLossPrice); setRawSlPercent(derived); setIsSlPercentFocused(true); }, [priceToPercent, stopLossPrice]); @@ -280,12 +277,12 @@ export const AutoCloseSection: React.FC = ({ // 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], ); diff --git a/ui/components/app/perps/order-entry/utils.test.ts b/ui/components/app/perps/order-entry/utils.test.ts index 87ec898dbb24..770fff5d9a28 100644 --- a/ui/components/app/perps/order-entry/utils.test.ts +++ b/ui/components/app/perps/order-entry/utils.test.ts @@ -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); }); }); }); diff --git a/ui/components/app/perps/order-entry/utils.ts b/ui/components/app/perps/order-entry/utils.ts index e19c1c8fd772..f1290101fd65 100644 --- a/ui/components/app/perps/order-entry/utils.ts +++ b/ui/components/app/perps/order-entry/utils.ts @@ -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; } 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..29083b7e04fa 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 @@ -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', @@ -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(); @@ -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]; @@ -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', () => { @@ -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); }); }); 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 23f7dc8cc8f9..84eefd18db14 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 @@ -145,11 +145,13 @@ export const UpdateTPSLModalContent: React.FC = ({ ); /** - * 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 priceToPercentForEdit = useCallback( - (price: string, isTP: boolean): string => { + (price: string): string => { if (!price || !entryPriceForEdit) { return ''; } @@ -160,30 +162,31 @@ export const UpdateTPSLModalContent: React.FC = ({ } const diff = priceNum - entryPriceForEdit; const percentChange = (diff / entryPriceForEdit) * leverageForEdit * 100; - if (positionDirection === '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( + positionDirection === 'long' ? percentChange : -percentChange, + ); }, [entryPriceForEdit, leverageForEdit, positionDirection], ); /** - * 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 percentToPriceForEdit = useCallback( - (percent: number, isTP: boolean): string => { + (percent: number): string => { if (!entryPriceForEdit || percent === 0) { return ''; } const priceChangeRatio = percent / (leverageForEdit * 100); - let multiplier: number; - if (positionDirection === 'long') { - multiplier = isTP ? 1 + priceChangeRatio : 1 - priceChangeRatio; - } else { - multiplier = isTP ? 1 - priceChangeRatio : 1 + priceChangeRatio; - } + const multiplier = + positionDirection === 'long' + ? 1 + priceChangeRatio + : 1 - priceChangeRatio; const price = entryPriceForEdit * multiplier; return formatEditPrice(price); }, @@ -191,12 +194,12 @@ export const UpdateTPSLModalContent: React.FC = ({ ); const editingTpPercent = useMemo( - () => priceToPercentForEdit(editingTpPrice, true), + () => priceToPercentForEdit(editingTpPrice), [priceToPercentForEdit, editingTpPrice], ); const editingSlPercent = useMemo( - () => priceToPercentForEdit(editingSlPrice, false), + () => priceToPercentForEdit(editingSlPrice), [priceToPercentForEdit, editingSlPrice], ); @@ -275,7 +278,8 @@ export const UpdateTPSLModalContent: React.FC = ({ const handleTpPresetClick = useCallback( (percent: number) => { - const newPrice = percentToPriceForEdit(percent, true); + // TP presets are always positive RoE (profitable direction) + const newPrice = percentToPriceForEdit(percent); setEditingTpPrice(newPrice); // Preserve the exact preset value for display — avoids round-trip drift // caused by the price being rounded to 2 decimal places @@ -288,9 +292,11 @@ export const UpdateTPSLModalContent: React.FC = ({ const handleSlPresetClick = useCallback( (percent: number) => { - const newPrice = percentToPriceForEdit(percent, false); + // SL presets represent the loss magnitude; negate to express as signed RoE + const signedPercent = -percent; + const newPrice = percentToPriceForEdit(signedPercent); setEditingSlPrice(newPrice); - const presetStr = String(percent); + const presetStr = String(signedPercent); setRawSlPercent(presetStr); setSlPresetPercent(presetStr); }, @@ -300,14 +306,14 @@ export const UpdateTPSLModalContent: React.FC = ({ const handleTpPercentInputChange = useCallback( (event: React.ChangeEvent) => { const { value } = event.target; - if (value === '' || /^-?\d*(?:\.\d*)?$/u.test(value)) { + if (value === '' || /^[+-]?\d*(?:\.\d*)?$/u.test(value)) { setRawTpPercent(value); setTpPresetPercent(null); const numValue = Number.parseFloat(value); - if (value === '' || value === '-') { + if (value === '' || value === '-' || value === '+') { setEditingTpPrice(''); } else if (!Number.isNaN(numValue)) { - const newPrice = percentToPriceForEdit(numValue, true); + const newPrice = percentToPriceForEdit(numValue); setEditingTpPrice(newPrice); } } @@ -329,14 +335,14 @@ export const UpdateTPSLModalContent: React.FC = ({ const handleSlPercentInputChange = useCallback( (event: React.ChangeEvent) => { const { value } = event.target; - if (value === '' || /^-?\d*(?:\.\d*)?$/u.test(value)) { + if (value === '' || /^[+-]?\d*(?:\.\d*)?$/u.test(value)) { setRawSlPercent(value); setSlPresetPercent(null); const numValue = Number.parseFloat(value); - if (value === '' || value === '-') { + if (value === '' || value === '-' || value === '+') { setEditingSlPrice(''); } else if (!Number.isNaN(numValue)) { - const newPrice = percentToPriceForEdit(numValue, false); + const newPrice = percentToPriceForEdit(numValue); setEditingSlPrice(newPrice); } } diff --git a/ui/components/app/perps/utils.test.ts b/ui/components/app/perps/utils.test.ts index 3193c9bd8524..8e9633dcd1bb 100644 --- a/ui/components/app/perps/utils.test.ts +++ b/ui/components/app/perps/utils.test.ts @@ -20,6 +20,7 @@ import { getPnlDisplayColor, parseVolume, hasVolume, + formatRoePercent, } from './utils'; import { HYPERLIQUID_ASSET_ICONS_BASE_URL, @@ -674,4 +675,30 @@ describe('Perps Utils', () => { expect(hasVolume(createMockMarket({ volume: '--' }))).toBe(false); }); }); + + describe('formatRoePercent', () => { + it('formats positive integers without decimals', () => { + expect(formatRoePercent(10)).toBe('10'); + expect(formatRoePercent(100)).toBe('100'); + }); + + it('formats positive non-integers with 2 decimal places', () => { + expect(formatRoePercent(25.5)).toBe('25.50'); + expect(formatRoePercent(3.33)).toBe('3.33'); + }); + + it('formats negative values with sign preserved', () => { + expect(formatRoePercent(-25.5)).toBe('-25.50'); + expect(formatRoePercent(-100)).toBe('-100'); + }); + + it('formats zero as "0" without sign', () => { + expect(formatRoePercent(0)).toBe('0'); + }); + + it('returns "0" (not "-0") when a small negative value rounds to zero', () => { + expect(formatRoePercent(-0.004)).toBe('0'); + expect(formatRoePercent(-0.001)).toBe('0'); + }); + }); }); diff --git a/ui/components/app/perps/utils.ts b/ui/components/app/perps/utils.ts index 45222fc6c9e9..524b4392799e 100644 --- a/ui/components/app/perps/utils.ts +++ b/ui/components/app/perps/utils.ts @@ -511,15 +511,19 @@ export function getPnlDisplayColor(pnl: number): TextColor { * non-integers with 2 decimal places ("25.50"). * * @param value - The numeric percentage value to format - * @returns The formatted percentage string + * @returns The formatted percentage string (sign preserved for negative values) * @example * formatRoePercent(10) => '10' - * formatRoePercent(-25.5) => '25.50' + * formatRoePercent(-25.5) => '-25.50' * formatRoePercent(0) => '0' */ export const formatRoePercent = (value: number): string => { - const rounded = Math.round(Math.abs(value) * 100) / 100; - return Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(2); + const abs = Math.abs(value); + const rounded = Math.round(abs * 100) / 100; + const formatted = Number.isInteger(rounded) + ? rounded.toFixed(0) + : rounded.toFixed(2); + return value < 0 && rounded !== 0 ? `-${formatted}` : formatted; }; const volumeMultipliers: Record = {