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
3 changes: 3 additions & 0 deletions app/components/UI/Charts/AdvancedChart/AdvancedChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ const AdvancedChart = forwardRef<AdvancedChartRef, AdvancedChartProps>(
lineChrome,
subPaneHeightRatio,
useSubscriptPriceFormat,
priceDecimals,
visibleFromMs,
visibleToMs,
lineColorOverride,
Expand Down Expand Up @@ -209,6 +210,7 @@ const AdvancedChart = forwardRef<AdvancedChartRef, AdvancedChartProps>(
disabledFeatures,
lineChrome,
useSubscriptPriceFormat,
priceDecimals,
hidePaneSeparator,
gridLineColorOverride,
lineColorOverride,
Expand All @@ -230,6 +232,7 @@ const AdvancedChart = forwardRef<AdvancedChartRef, AdvancedChartProps>(
disabledFeatures,
lineChrome,
useSubscriptPriceFormat,
priceDecimals,
labelStyleOverrides,
hidePaneSeparator,
gridLineColorOverride,
Expand Down
6 changes: 6 additions & 0 deletions app/components/UI/Charts/AdvancedChart/AdvancedChart.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ interface ChartFeatures {
disabledFeatures?: string[];
lineChrome?: LineChromeOptions;
useSubscriptPriceFormat?: boolean;
priceDecimals?: number;
hidePaneSeparator?: boolean;
gridLineColorOverride?: string;
lineColorOverride?: string;
Expand Down Expand Up @@ -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))}
};
`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ describe('AdvancedChart', () => {
expect(getByTestId('advanced-chart-skeleton')).toBeOnTheScreen();
});

it('serializes configured price decimals into the WebView template', () => {
const { getByTestId } = render(
<AdvancedChart ohlcvData={MOCK_BARS} priceDecimals={4} />,
);

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(
<AdvancedChart ohlcvData={MOCK_BARS} isLoading />,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
70 changes: 66 additions & 4 deletions app/components/UI/Charts/AdvancedChart/webview/chartLogic.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -1657,6 +1687,10 @@ function formatCrosshairPrice(price) {
if (sub) {
return p < 0 ? '-' + sub : sub;
}
let configuredPriceDecimals = getConfiguredPriceDecimals();
if (configuredPriceDecimals !== null) {
return formatPriceWithConfiguredDecimals(p, configuredPriceDecimals);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chart subscript diverges TP labels

Medium Severity

With perps priceDecimals enabled, the Advanced Chart custom formatter still prefers subscript notation for very small prices before market-aware decimals run. Auto-close TP/SL now uses formatPerpsPrice / Hyperliquid formatting without subscript. Y-axis and price pills can disagree with TP/SL text on tiny-price markets like PEPE.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit eabc62a. Configure here.

}
return new Intl.NumberFormat('en-US', {
style: 'decimal',
minimumFractionDigits: 2,
Expand All @@ -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 {
Expand Down Expand Up @@ -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',
Expand All @@ -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++) {
Expand Down Expand Up @@ -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,
Expand Down
70 changes: 66 additions & 4 deletions app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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',
Expand All @@ -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++) {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<View testID="perps-position-card">
<View testID="perps-position-card" {...props}>
{props.onAutoClosePress && (
<TouchableOpacity
testID="perps-position-card-auto-close-button"
Expand Down Expand Up @@ -1647,6 +1648,69 @@ describe('PerpsMarketDetailsView', () => {
});
});

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(
<PerpsConnectionProvider>
<PerpsMarketDetailsView />
</PerpsConnectionProvider>,
{
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 } =
Expand Down
Loading
Loading