diff --git a/app/components/UI/Charts/AdvancedChart/AdvancedChart.tsx b/app/components/UI/Charts/AdvancedChart/AdvancedChart.tsx index d928a70aefc..dc6a031fcfa 100644 --- a/app/components/UI/Charts/AdvancedChart/AdvancedChart.tsx +++ b/app/components/UI/Charts/AdvancedChart/AdvancedChart.tsx @@ -110,6 +110,7 @@ const AdvancedChart = forwardRef( lineChrome, subPaneHeightRatio, useSubscriptPriceFormat, + priceDecimals, visibleFromMs, visibleToMs, lineColorOverride, @@ -209,6 +210,7 @@ const AdvancedChart = forwardRef( disabledFeatures, lineChrome, useSubscriptPriceFormat, + priceDecimals, hidePaneSeparator, gridLineColorOverride, lineColorOverride, @@ -230,6 +232,7 @@ const AdvancedChart = forwardRef( disabledFeatures, lineChrome, useSubscriptPriceFormat, + priceDecimals, labelStyleOverrides, hidePaneSeparator, gridLineColorOverride, diff --git a/app/components/UI/Charts/AdvancedChart/AdvancedChart.types.ts b/app/components/UI/Charts/AdvancedChart/AdvancedChart.types.ts index 3b6e77b9938..f459c52b848 100644 --- a/app/components/UI/Charts/AdvancedChart/AdvancedChart.types.ts +++ b/app/components/UI/Charts/AdvancedChart/AdvancedChart.types.ts @@ -749,6 +749,12 @@ export interface AdvancedChartProps { */ useSubscriptPriceFormat?: boolean; + /** + * Optional maximum decimal precision for TV built-in price scale labels and pills. + * Omit to keep TradingView defaults unless `useSubscriptPriceFormat` is enabled. + */ + priceDecimals?: number; + /** Callback when chart is ready */ onChartReady?: () => void; /** diff --git a/app/components/UI/Charts/AdvancedChart/AdvancedChartTemplate.ts b/app/components/UI/Charts/AdvancedChart/AdvancedChartTemplate.ts index f96a1c75071..f8c068ba703 100644 --- a/app/components/UI/Charts/AdvancedChart/AdvancedChartTemplate.ts +++ b/app/components/UI/Charts/AdvancedChart/AdvancedChartTemplate.ts @@ -71,6 +71,7 @@ interface ChartFeatures { disabledFeatures?: string[]; lineChrome?: LineChromeOptions; useSubscriptPriceFormat?: boolean; + priceDecimals?: number; hidePaneSeparator?: boolean; gridLineColorOverride?: string; lineColorOverride?: string; @@ -151,6 +152,7 @@ window.CONFIG = { }, legendOverlay: ${JSON.stringify(features.legendOverlay ?? { enabled: false })}, useSubscriptPriceFormat: ${features.useSubscriptPriceFormat ? 'true' : 'false'}, + priceDecimals: ${typeof features.priceDecimals === 'number' && Number.isFinite(features.priceDecimals) ? Math.max(0, features.priceDecimals) : 'null'}, indicatorColors: ${JSON.stringify(getIndicatorColorsForWebview(theme.themeAppearance))} }; `; diff --git a/app/components/UI/Charts/AdvancedChart/__tests__/AdvancedChart.test.tsx b/app/components/UI/Charts/AdvancedChart/__tests__/AdvancedChart.test.tsx index 66fd26b69e7..618dc43a2ed 100644 --- a/app/components/UI/Charts/AdvancedChart/__tests__/AdvancedChart.test.tsx +++ b/app/components/UI/Charts/AdvancedChart/__tests__/AdvancedChart.test.tsx @@ -63,6 +63,16 @@ describe('AdvancedChart', () => { expect(getByTestId('advanced-chart-skeleton')).toBeOnTheScreen(); }); + it('serializes configured price decimals into the WebView template', () => { + const { getByTestId } = render( + , + ); + + const webView = getByTestId('mock-webview'); + + expect(webView.props.source.html).toMatch(/priceDecimals:\s*4/); + }); + it('keeps loading overlay while isLoading until parent clears it', () => { const { getByTestId, queryByTestId, rerender } = render( , diff --git a/app/components/UI/Charts/AdvancedChart/__tests__/AdvancedChartTemplate.test.ts b/app/components/UI/Charts/AdvancedChart/__tests__/AdvancedChartTemplate.test.ts index cb326e2acf2..6904a935eae 100644 --- a/app/components/UI/Charts/AdvancedChart/__tests__/AdvancedChartTemplate.test.ts +++ b/app/components/UI/Charts/AdvancedChart/__tests__/AdvancedChartTemplate.test.ts @@ -14,6 +14,20 @@ describe('createAdvancedChartTemplate', () => { expect(html).toContain('useSubscriptPriceFormat: true'); }); + it('defaults priceDecimals to null in CONFIG', () => { + const html = createAdvancedChartTemplate(mockTheme); + + expect(html).toContain('priceDecimals: null'); + }); + + it('bakes priceDecimals when provided', () => { + const html = createAdvancedChartTemplate(mockTheme, { + priceDecimals: 4, + }); + + expect(html).toContain('priceDecimals: 4'); + }); + it('uses current-price override for the last-close label background', () => { const currentPriceColor = '#ffffff0a'; diff --git a/app/components/UI/Charts/AdvancedChart/webview/chartLogic.js b/app/components/UI/Charts/AdvancedChart/webview/chartLogic.js index e96d9688095..3ed8a8e54d0 100644 --- a/app/components/UI/Charts/AdvancedChart/webview/chartLogic.js +++ b/app/components/UI/Charts/AdvancedChart/webview/chartLogic.js @@ -1640,6 +1640,36 @@ function formatSubscriptNotationCrosshair(abs) { return null; } +function getConfiguredPriceDecimals() { + let decimals = window.CONFIG?.priceDecimals; + if (typeof decimals !== 'number' || !Number.isFinite(decimals)) { + return null; + } + return Math.max(0, Math.floor(decimals)); +} + +function formatPriceWithConfiguredDecimals(price, maxDecimals) { + let p = Number(price); + if (p === 0) { + return '0'; + } + + let abs = Math.abs(p); + let decimals = maxDecimals; + + if (abs >= 1) { + let integerDigits = Math.floor(Math.log10(abs)) + 1; + decimals = Math.min(maxDecimals, Math.max(0, 5 - integerDigits)); + } + + let rounded = Number(p.toFixed(decimals)); + return new Intl.NumberFormat('en-US', { + style: 'decimal', + minimumFractionDigits: 0, + maximumFractionDigits: decimals, + }).format(rounded); +} + /** * Shared price text for custom DOM pills and TV built-in scale labels (via * `custom_formatters.priceFormatterFactory` on widget init). Number only — no currency symbol. @@ -1657,6 +1687,10 @@ function formatCrosshairPrice(price) { if (sub) { return p < 0 ? '-' + sub : sub; } + let configuredPriceDecimals = getConfiguredPriceDecimals(); + if (configuredPriceDecimals !== null) { + return formatPriceWithConfiguredDecimals(p, configuredPriceDecimals); + } return new Intl.NumberFormat('en-US', { style: 'decimal', minimumFractionDigits: 2, @@ -1672,7 +1706,13 @@ function advancedChartPriceFormatterFactory(symbolInfo, minTick) { if (symbolInfo === null || symbolInfo.format === 'volume') { return null; } - if (!(window.CONFIG && window.CONFIG.useSubscriptPriceFormat)) { + if ( + !( + window.CONFIG && + (window.CONFIG.useSubscriptPriceFormat || + getConfiguredPriceDecimals() !== null) + ) + ) { return null; } return { @@ -5277,7 +5317,7 @@ function handleToggleVolume(payload) { * This replaces a manual pricescale computation and adapts automatically * as prices change (e.g. meme token pumps from $0.0001 to $1). */ -let VARIABLE_TICK_SIZE = [ +let DEFAULT_VARIABLE_TICK_SIZE = [ '0.0000000001', '0.000001', // prices < $0.000001 → 10 dp '0.00000001', @@ -5291,6 +5331,28 @@ let VARIABLE_TICK_SIZE = [ '0.1', // prices ≥ $10000 → 1 dp ].join(' '); +let PERPS_VARIABLE_TICK_SIZE = [ + '0.0000000001', + '0.000001', // prices < $0.000001 → 10 dp + '0.00000001', + '0.0001', // prices < $0.0001 → 8 dp + '0.000001', + '0.01', // prices < $0.01 → 6 dp + '0.0001', + '10000', // prices < $10000 → 4 dp + '1', // prices ≥ $10000 → whole-dollar ticks +].join(' '); + +function getVariableTickSize() { + return getConfiguredPriceDecimals() !== null + ? PERPS_VARIABLE_TICK_SIZE + : DEFAULT_VARIABLE_TICK_SIZE; +} + +function getPriceScale() { + return getConfiguredPriceDecimals() !== null ? 10000000000 : 100; +} + function filterBarsForRange(fromMs, toMs, countBack) { let barsInRange = []; for (let i = 0; i < window.ohlcvData.length; i++) { @@ -5667,8 +5729,8 @@ let customDatafeed = { timezone: 'Etc/UTC', exchange: '', minmov: 1, - pricescale: 100, - variable_tick_size: VARIABLE_TICK_SIZE, + pricescale: getPriceScale(), + variable_tick_size: getVariableTickSize(), has_intraday: true, has_daily: true, has_weekly_and_monthly: true, diff --git a/app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts b/app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts index bd1a655905b..38be9da1625 100644 --- a/app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts +++ b/app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts @@ -1649,6 +1649,36 @@ function formatSubscriptNotationCrosshair(abs) { return null; } +function getConfiguredPriceDecimals() { + let decimals = window.CONFIG?.priceDecimals; + if (typeof decimals !== 'number' || !Number.isFinite(decimals)) { + return null; + } + return Math.max(0, Math.floor(decimals)); +} + +function formatPriceWithConfiguredDecimals(price, maxDecimals) { + let p = Number(price); + if (p === 0) { + return '0'; + } + + let abs = Math.abs(p); + let decimals = maxDecimals; + + if (abs >= 1) { + let integerDigits = Math.floor(Math.log10(abs)) + 1; + decimals = Math.min(maxDecimals, Math.max(0, 5 - integerDigits)); + } + + let rounded = Number(p.toFixed(decimals)); + return new Intl.NumberFormat('en-US', { + style: 'decimal', + minimumFractionDigits: 0, + maximumFractionDigits: decimals, + }).format(rounded); +} + /** * Shared price text for custom DOM pills and TV built-in scale labels (via * \`custom_formatters.priceFormatterFactory\` on widget init). Number only — no currency symbol. @@ -1666,6 +1696,10 @@ function formatCrosshairPrice(price) { if (sub) { return p < 0 ? '-' + sub : sub; } + let configuredPriceDecimals = getConfiguredPriceDecimals(); + if (configuredPriceDecimals !== null) { + return formatPriceWithConfiguredDecimals(p, configuredPriceDecimals); + } return new Intl.NumberFormat('en-US', { style: 'decimal', minimumFractionDigits: 2, @@ -1681,7 +1715,13 @@ function advancedChartPriceFormatterFactory(symbolInfo, minTick) { if (symbolInfo === null || symbolInfo.format === 'volume') { return null; } - if (!(window.CONFIG && window.CONFIG.useSubscriptPriceFormat)) { + if ( + !( + window.CONFIG && + (window.CONFIG.useSubscriptPriceFormat || + getConfiguredPriceDecimals() !== null) + ) + ) { return null; } return { @@ -5286,7 +5326,7 @@ function handleToggleVolume(payload) { * This replaces a manual pricescale computation and adapts automatically * as prices change (e.g. meme token pumps from $0.0001 to $1). */ -let VARIABLE_TICK_SIZE = [ +let DEFAULT_VARIABLE_TICK_SIZE = [ '0.0000000001', '0.000001', // prices < $0.000001 → 10 dp '0.00000001', @@ -5300,6 +5340,28 @@ let VARIABLE_TICK_SIZE = [ '0.1', // prices ≥ $10000 → 1 dp ].join(' '); +let PERPS_VARIABLE_TICK_SIZE = [ + '0.0000000001', + '0.000001', // prices < $0.000001 → 10 dp + '0.00000001', + '0.0001', // prices < $0.0001 → 8 dp + '0.000001', + '0.01', // prices < $0.01 → 6 dp + '0.0001', + '10000', // prices < $10000 → 4 dp + '1', // prices ≥ $10000 → whole-dollar ticks +].join(' '); + +function getVariableTickSize() { + return getConfiguredPriceDecimals() !== null + ? PERPS_VARIABLE_TICK_SIZE + : DEFAULT_VARIABLE_TICK_SIZE; +} + +function getPriceScale() { + return getConfiguredPriceDecimals() !== null ? 10000000000 : 100; +} + function filterBarsForRange(fromMs, toMs, countBack) { let barsInRange = []; for (let i = 0; i < window.ohlcvData.length; i++) { @@ -5676,8 +5738,8 @@ let customDatafeed = { timezone: 'Etc/UTC', exchange: '', minmov: 1, - pricescale: 100, - variable_tick_size: VARIABLE_TICK_SIZE, + pricescale: getPriceScale(), + variable_tick_size: getVariableTickSize(), has_intraday: true, has_daily: true, has_weekly_and_monthly: true, diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx index c152416c5ca..7140e166da1 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx @@ -666,10 +666,11 @@ jest.mock('../../components/PerpsPositionCard', () => ({ default: (props: { onAutoClosePress?: () => void; onMarginPress?: () => void; + szDecimals?: number; }) => { const { View, TouchableOpacity, Text } = jest.requireActual('react-native'); return ( - + {props.onAutoClosePress && ( { }); }); + it('forwards market szDecimals to advanced chart and position card', () => { + const { useSelector } = jest.requireMock('react-redux'); + const { usePerpsMarketData } = jest.requireMock('../../hooks'); + const mockSelectPerpsEligibility = jest.requireMock( + '../../selectors/perpsController', + ).selectPerpsEligibility; + + useSelector.mockImplementation((selector: unknown) => { + if (selector === mockSelectPerpsEligibility) { + return true; + } + if (selector === selectPerpsAdvancedChartEnabledFlag) { + return true; + } + if (selector === selectPerpsRelatedMarketsEnabledFlag) { + return false; + } + return undefined; + }); + usePerpsMarketData.mockReturnValue({ + marketData: { szDecimals: 2, maxLeverage: 50 }, + isLoading: false, + error: null, + refetch: jest.fn(), + }); + mockUseHasExistingPosition.mockReturnValue({ + hasPosition: true, + isLoading: false, + error: null, + existingPosition: { + symbol: 'BTC', + size: '0.5', + entryPrice: '44000', + positionValue: '22000', + unrealizedPnl: '50', + marginUsed: '500', + leverage: { type: 'isolated', value: 5 }, + liquidationPrice: '40000', + maxLeverage: 40, + returnOnEquity: '10', + cumulativeFunding: { + allTime: '0', + sinceOpen: '0', + sinceChange: '0', + }, + }, + refreshPosition: jest.fn(), + positionOpenedTimestamp: undefined, + }); + + const { getByTestId } = renderWithProvider( + + + , + { + state: initialState, + }, + ); + + expect(getByTestId('mock-perps-advanced-chart').props.szDecimals).toBe(2); + expect(getByTestId('perps-position-card').props.szDecimals).toBe(2); + }); + it('logs and tracks advanced chart errors from market details', () => { const mockTrack = jest.fn(); const { usePerpsEventTracking: mockUsePerpsEventTrackingFn } = diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index 616ce0c21ee..af97350368e 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -1346,6 +1346,7 @@ const PerpsMarketDetailsView: React.FC = () => { height={PERPS_CHART_CONFIG.LAYOUT.DETAIL_VIEW_HEIGHT} tpslLines={tpslLines} positionSize={existingPosition?.size} + szDecimals={marketData?.szDecimals} onCrosshairDataChange={setOhlcData} onLatestPriceChange={setAdvancedChartCurrentPrice} onError={handleChartError} @@ -1438,6 +1439,7 @@ const PerpsMarketDetailsView: React.FC = () => { void; onLatestPriceChange?: (price: number | undefined) => void; onError?: (error: string) => void; @@ -167,6 +169,7 @@ const PerpsAdvancedChart: React.FC = ({ height, tpslLines, positionSize, + szDecimals, onCrosshairDataChange, onLatestPriceChange, onError, @@ -226,6 +229,13 @@ const PerpsAdvancedChart: React.FC = ({ [colors], ); + const priceDecimals = useMemo(() => { + if (typeof szDecimals !== 'number' || !Number.isFinite(szDecimals)) { + return undefined; + } + return Math.max(0, 6 - szDecimals); + }, [szDecimals]); + const volumeColors = useMemo(() => getPerpsVolumeColors(colors), [colors]); const webViewInstanceKey = useMemo(() => `${symbol}|perps`, [symbol]); @@ -417,6 +427,7 @@ const PerpsAdvancedChart: React.FC = ({ showVolume volumeOverlay={false} hidePaneSeparator + priceDecimals={priceDecimals} gridLineColorOverride={colors.border.muted} isLoading={isLoading} positionLines={positionLines} diff --git a/app/components/UI/Perps/components/PerpsAdvancedChart/__tests__/PerpsAdvancedChart.test.tsx b/app/components/UI/Perps/components/PerpsAdvancedChart/__tests__/PerpsAdvancedChart.test.tsx index 065619d3527..9aac18020f2 100644 --- a/app/components/UI/Perps/components/PerpsAdvancedChart/__tests__/PerpsAdvancedChart.test.tsx +++ b/app/components/UI/Perps/components/PerpsAdvancedChart/__tests__/PerpsAdvancedChart.test.tsx @@ -118,6 +118,28 @@ describe('PerpsAdvancedChart', () => { ); }); + it('passes market-aware price decimals to AdvancedChart', () => { + renderChart({ szDecimals: 2 }); + + expect(mockAdvancedChart).toHaveBeenCalledWith( + expect.objectContaining({ + priceDecimals: 4, + }), + ); + }); + + it('omits market-aware price decimals when szDecimals is not provided', () => { + renderChart(); + + expect(advancedChartProps().priceDecimals).toBeUndefined(); + }); + + it('passes zero price decimals when szDecimals uses full Hyperliquid precision', () => { + renderChart({ szDecimals: 6 }); + + expect(advancedChartProps().priceDecimals).toBe(0); + }); + it('passes pagination duration into the adapter', () => { renderChart({ paginationDuration: TimeDuration.YearToDate }); diff --git a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.test.tsx b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.test.tsx index 18bdbcc47e2..6ccf10b6b41 100644 --- a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.test.tsx +++ b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.test.tsx @@ -1,7 +1,11 @@ import React from 'react'; import { render, screen } from '@testing-library/react-native'; import { PerpsPositionCardSelectorsIDs } from '../../Perps.testIds'; -import { PERPS_CONSTANTS, type Position } from '@metamask/perps-controller'; +import { + PERPS_CONSTANTS, + type Order, + type Position, +} from '@metamask/perps-controller'; import PerpsPositionCard from './PerpsPositionCard'; import { selectPrivacyMode } from '../../../../../selectors/preferencesController'; @@ -317,6 +321,61 @@ describe('PerpsPositionCard', () => { ).toHaveTextContent(/long\s+10x/); }); + it('formats auto-close stop loss with market-aware price precision', () => { + const litPosition: Position = { + ...mockPosition, + symbol: 'LIT', + stopLossPrice: '2.1946', + stopLossCount: 1, + }; + + render(); + + expect(screen.getByText('perps.order.sl $2.1946')).toBeOnTheScreen(); + }); + + it('formats auto-close take profit with market-aware price precision', () => { + const litPosition: Position = { + ...mockPosition, + symbol: 'LIT', + takeProfitPrice: '2.1946', + takeProfitCount: 1, + }; + + render(); + + expect(screen.getByText('perps.order.tp $2.1946')).toBeOnTheScreen(); + }); + + it('formats order-level take profit and stop loss with market-aware price precision', () => { + const litPosition: Position = { + ...mockPosition, + symbol: 'LIT', + takeProfitCount: 1, + stopLossCount: 1, + }; + const orders = [ + { + symbol: 'LIT', + isTrigger: false, + takeProfitPrice: '2.1946', + stopLossPrice: '2.1234', + } as Order, + ]; + + render( + , + ); + + expect( + screen.getByText('perps.order.tp $2.1946, perps.order.sl $2.1234'), + ).toBeOnTheScreen(); + }); + it('renders SHORT position correctly', () => { // Arrange const shortPosition = { diff --git a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx index 17de8b5ea32..68cb741e79a 100644 --- a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx +++ b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx @@ -36,6 +36,7 @@ import { formatPnl, formatPositionSize, formatPercentage, + formatPerpsPrice, PRICE_RANGES_MINIMAL_VIEW, PRICE_RANGES_UNIVERSAL, } from '../../utils/formatUtils'; @@ -98,6 +99,8 @@ interface PerpsPositionCardProps { iconSize?: number; /** When true, shows a small skeleton placeholder for the TP/SL field instead of "No TP/SL" */ tpSlLoading?: boolean; + /** Market size decimals, used to derive Hyperliquid price precision for TP/SL display. */ + szDecimals?: number | null; } const PerpsPositionCard: React.FC = ({ @@ -115,6 +118,7 @@ const PerpsPositionCard: React.FC = ({ testID, iconSize = 40, tpSlLoading = false, + szDecimals, }) => { const { styles } = useStyles(styleSheet, { iconSize }); const [showSizeInUSD, setShowSizeInUSD] = useState(false); @@ -517,15 +521,15 @@ const PerpsPositionCard: React.FC = ({ const parts: string[] = []; if (hasTakeProfit && takeProfitPrice) { - const tpPrice = formatPerpsFiat(parseFloat(takeProfitPrice), { - ranges: PRICE_RANGES_UNIVERSAL, + const tpPrice = formatPerpsPrice(takeProfitPrice, { + szDecimals, }); parts.push(`${strings('perps.order.tp')} ${tpPrice}`); } if (hasStopLoss && stopLossPrice) { - const slPrice = formatPerpsFiat(parseFloat(stopLossPrice), { - ranges: PRICE_RANGES_UNIVERSAL, + const slPrice = formatPerpsPrice(stopLossPrice, { + szDecimals, }); parts.push(`${strings('perps.order.sl')} ${slPrice}`); } diff --git a/app/components/UI/Perps/utils/formatUtils.test.ts b/app/components/UI/Perps/utils/formatUtils.test.ts index f742b9963c3..3374a59f7a6 100644 --- a/app/components/UI/Perps/utils/formatUtils.test.ts +++ b/app/components/UI/Perps/utils/formatUtils.test.ts @@ -20,6 +20,7 @@ import { PRICE_RANGES_UNIVERSAL, PRICE_RANGES_MINIMAL_VIEW, formatPositiveFiat, + formatPerpsPrice, } from './formatUtils'; import { countSignificantFigures, @@ -1410,6 +1411,37 @@ describe('formatUtils', () => { }); describe('PRICE_RANGES_UNIVERSAL (comprehensive formatting)', () => { + describe('formatPerpsPrice', () => { + it('uses market-aware Hyperliquid price precision when szDecimals is known', () => { + expect(formatPerpsPrice('2.1946', { szDecimals: 2 })).toBe('$2.1946'); + }); + + it('returns market-aware price without currency symbol when requested', () => { + expect( + formatPerpsPrice('2.1946', { + szDecimals: 2, + includeCurrencySymbol: false, + }), + ).toBe('2.1946'); + }); + + it('falls back to universal price ranges when szDecimals is null', () => { + expect(formatPerpsPrice(95123.45, { szDecimals: null })).toBe( + '$95,123', + ); + }); + + it('falls back to universal price ranges when Hyperliquid formatting rejects the price', () => { + expect(formatPerpsPrice('not-a-price', { szDecimals: 2 })).toBe( + '$---', + ); + }); + + it('falls back to universal price ranges when szDecimals is unknown', () => { + expect(formatPerpsPrice(95123.45)).toBe('$95,123'); + }); + }); + it('should format high-value BTC prices without decimals (> $10k)', () => { // BTC at $126k - no decimals for cleaner display expect( diff --git a/app/components/UI/Perps/utils/formatUtils.ts b/app/components/UI/Perps/utils/formatUtils.ts index f5d408104dd..0434e695579 100644 --- a/app/components/UI/Perps/utils/formatUtils.ts +++ b/app/components/UI/Perps/utils/formatUtils.ts @@ -7,6 +7,8 @@ import { getIntlDateTimeFormatter } from '../../../../util/intl'; import { type FiatRangeConfig, formatPerpsFiat, + formatHyperLiquidPrice, + PRICE_RANGES_UNIVERSAL, } from '@metamask/perps-controller'; // Decimal formatters moved to controller for cross-platform sharing @@ -23,6 +25,44 @@ export { } from '@metamask/perps-controller'; export { formatPerpsFiat }; // re-export via local import (needed by formatPositiveFiat below) +/** + * Formats a perps market price for display using Hyperliquid price precision + * when market size decimals are known. Falls back to the shared fiat formatter + * for generic fiat display sites. + */ +export const formatPerpsPrice = ( + price: number | string, + options?: { + szDecimals?: number | null; + includeCurrencySymbol?: boolean; + }, +): string => { + const includeCurrencySymbol = options?.includeCurrencySymbol ?? true; + const numericPrice = + typeof price === 'number' ? price : Number.parseFloat(String(price)); + + if (Number.isNaN(numericPrice) || !Number.isFinite(numericPrice)) { + return '$---'; + } + + const fallback = () => + formatPerpsFiat(price, { ranges: PRICE_RANGES_UNIVERSAL }); + + if (options?.szDecimals === undefined || options.szDecimals === null) { + return fallback(); + } + + try { + const formattedPrice = formatHyperLiquidPrice({ + price, + szDecimals: options.szDecimals, + }); + return includeCurrencySymbol ? `$${formattedPrice}` : formattedPrice; + } catch { + return fallback(); + } +}; + /** * Truncates a number to 2 decimal places without rounding up. * Uses BigNumber to avoid IEEE 754 floating-point errors