From 30cc9e4b156eb2d427fb2cc9bff2ffc3f02799d9 Mon Sep 17 00:00:00 2001 From: assumptaamara Date: Wed, 21 Oct 2026 17:49:48 +0100 Subject: [PATCH] Add key depth chart to key detail page --- src/components/common/KeyDepthChart.tsx | 380 ++++++++++++++++++ .../common/__tests__/KeyDepthChart.test.tsx | 159 ++++++++ src/hooks/__tests__/useKeyDepth.test.tsx | 109 +++++ src/hooks/useKeyDepth.ts | 33 ++ src/lib/queryKeys.ts | 3 + src/pages/CreatorDetailPage.tsx | 67 +-- ...orDetailPage.depthTab.integration.test.tsx | 136 +++++++ .../__tests__/keyDepth.service.test.ts | 163 ++++++++ src/services/keyDepth.service.ts | 217 ++++++++++ .../__tests__/keyDepthChart.utils.test.ts | 202 ++++++++++ src/utils/keyDepthChart.utils.ts | 224 +++++++++++ 11 files changed, 1660 insertions(+), 33 deletions(-) create mode 100644 src/components/common/KeyDepthChart.tsx create mode 100644 src/components/common/__tests__/KeyDepthChart.test.tsx create mode 100644 src/hooks/__tests__/useKeyDepth.test.tsx create mode 100644 src/hooks/useKeyDepth.ts create mode 100644 src/pages/__tests__/CreatorDetailPage.depthTab.integration.test.tsx create mode 100644 src/services/__tests__/keyDepth.service.test.ts create mode 100644 src/services/keyDepth.service.ts create mode 100644 src/utils/__tests__/keyDepthChart.utils.test.ts create mode 100644 src/utils/keyDepthChart.utils.ts diff --git a/src/components/common/KeyDepthChart.tsx b/src/components/common/KeyDepthChart.tsx new file mode 100644 index 00000000..5e921c75 --- /dev/null +++ b/src/components/common/KeyDepthChart.tsx @@ -0,0 +1,380 @@ +import { useMemo } from 'react'; +import { + Area, + AreaChart, + CartesianGrid, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import Skeleton from '@/components/ui/skeleton'; +import { cn } from '@/lib/utils'; +import { useIsMobile } from '@/hooks/useIsMobile'; +import { useKeyDepth } from '@/hooks/useKeyDepth'; +import { KEY_DEPTH_LEVELS } from '@/services/keyDepth.service'; +import { + buildDepthChartModel, + formatDepthPrice, + formatDepthPriceTick, + type DepthChartPoint, +} from '@/utils/keyDepthChart.utils'; +import { formatCompactNumber, formatNumber } from '@/utils/numberFormat.utils'; + +export interface KeyDepthChartProps { + /** Key whose order book is charted. */ + keyId: string; + /** Number of price levels requested per side of the book. */ + levels?: number; + className?: string; +} + +const BID_COLOR = '#34d399'; +const ASK_COLOR = '#f87171'; + +/** + * Market depth chart for a key (#1220). + * + * Renders the open buy orders (bids, green) and sell listings (asks, red) as + * two cumulative step areas mirrored around the mid price: the bid ladder + * climbs to the left of the mid marker, the ask ladder climbs to the right, + * and the x-axis shows the price levels. The snapshot is polled every five + * seconds and updated in place, so the chart never re-renders from scratch. + */ +export function KeyDepthChart({ + keyId, + levels = KEY_DEPTH_LEVELS, + className, +}: KeyDepthChartProps) { + const isMobile = useIsMobile(); + const { data, isLoading, isError, isFetching } = useKeyDepth(keyId, levels); + + const model = useMemo(() => buildDepthChartModel(data), [data]); + + // The mid marker is centred on the plot area, so the overlay that carries + // the spread badge has to use the very same insets as the chart itself. + const yAxisWidth = isMobile ? 40 : 48; + const chartMargin = { top: 44, right: 12, left: 0, bottom: 0 }; + const plotCentreStyle = { + left: `calc(${yAxisWidth}px + (100% - ${yAxisWidth + chartMargin.right}px) / 2)`, + }; + + const hasData = model.points.length > 0; + const showSkeleton = isLoading && !data; + const showError = isError && !data && !isLoading; + const showEmpty = !showSkeleton && !showError && !hasData; + + const spreadAmount = formatDepthPrice(model.spread); + const spreadPercentLabel = + model.spreadPercent == null ? '' : ` (${model.spreadPercent.toFixed(2)}%)`; + const spreadLabel = + model.spread == null ? '—' : `${spreadAmount}${spreadPercentLabel}`; + + return ( +
+
+
+

+ Market depth +

+ {isFetching && !isLoading && ( + + + )} +
+ +
+ + + + +
+
+ + {/* Book summary above the chart (#1220) */} +
+
+ + Best bid + + + {hasData ? formatDepthPrice(model.bestBid) : '—'} + +
+ +
+ + Best ask + + + {hasData ? formatDepthPrice(model.bestAsk) : '—'} + +
+ +
+ + Mid price + + + {hasData ? formatDepthPrice(model.midPrice) : '—'} + +
+ +
+ + Spread + + + {hasData ? spreadLabel : '—'} + +
+
+ + {showSkeleton ? ( +
+ +
+ ) : showError ? ( +
+ Market depth is temporarily unavailable. +
+ ) : showEmpty ? ( +
+ No open orders for this key yet +
+ ) : ( +
+ + + + + + + + + + + + + + + + + formatCompactNumber(value) + } + /> + + formatDepthPrice(Number(label)) + } + formatter={( + value: unknown, + name: unknown, + item: unknown + ): [string, string] | null => { + if (value == null) return null; + + const point = (item as { payload?: DepthChartPoint }) + ?.payload; + const isBid = name === 'bidVolume'; + const levelVolume = point + ? isBid + ? point.bidAmount + : point.askAmount + : 0; + const cumulative = + typeof value === 'number' + ? value + : Number(value); + + return [ + `${formatNumber(levelVolume)} keys · ${formatNumber(cumulative)} cumulative`, + isBid ? 'Bids' : 'Asks', + ]; + }} + /> + + {model.midPrice != null && ( + + )} + + + + + + + {/* Mid-price marker and spread badge, centred on the plot (#1220) */} + {model.midPrice != null && ( + + )} +
+ )} + +

+ Price per fraction (XLM) · Cumulative volume (keys) · refreshed every 5s +

+
+ ); +} + +export default KeyDepthChart; diff --git a/src/components/common/__tests__/KeyDepthChart.test.tsx b/src/components/common/__tests__/KeyDepthChart.test.tsx new file mode 100644 index 00000000..5d55b21a --- /dev/null +++ b/src/components/common/__tests__/KeyDepthChart.test.tsx @@ -0,0 +1,159 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { KeyDepthChart } from '../KeyDepthChart'; +import { fetchKeyDepth, type KeyDepthSnapshot } from '@/services/keyDepth.service'; +import { queryKeys } from '@/lib/queryKeys'; + +vi.mock('@/services/keyDepth.service', () => ({ + fetchKeyDepth: vi.fn(), + KEY_DEPTH_LEVELS: 20, +})); + +const SNAPSHOT = { + bids: [ + { price: 1.05, volume: 10 }, + { price: 1.0, volume: 20 }, + ], + asks: [ + { price: 1.15, volume: 5 }, + { price: 1.2, volume: 15 }, + ], + midPrice: null, + spread: null, +}; + +function makeWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + return { + queryClient, + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }; +} + +describe('KeyDepthChart (#1220)', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fetchKeyDepth).mockResolvedValue(SNAPSHOT); + }); + + it('shows a loading skeleton while the initial fetch is pending', () => { + vi.mocked(fetchKeyDepth).mockImplementation( + () => new Promise(() => {}) + ); + const { wrapper } = makeWrapper(); + + render(, { wrapper }); + + expect(screen.getByTestId('key-depth-chart-skeleton')).toBeInTheDocument(); + expect(screen.getByTestId('key-depth-best-bid')).toHaveTextContent('—'); + }); + + it('requests 20 levels from the depth endpoint', async () => { + const { wrapper } = makeWrapper(); + + render(, { wrapper }); + + await waitFor(() => { + expect(fetchKeyDepth).toHaveBeenCalledWith('key-1', 20); + }); + }); + + it('renders the book summary once the depth resolves', async () => { + const { wrapper } = makeWrapper(); + + render(, { wrapper }); + + await waitFor(() => { + expect(screen.getByTestId('key-depth-chart-series')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('key-depth-best-bid')).toHaveTextContent('1.05 XLM'); + expect(screen.getByTestId('key-depth-best-ask')).toHaveTextContent('1.15 XLM'); + expect(screen.getByTestId('key-depth-mid-price')).toHaveTextContent('1.10 XLM'); + expect(screen.getByTestId('key-depth-spread')).toHaveTextContent( + '0.1000 XLM (9.09%)' + ); + }); + + it('marks the mid price on the chart and shows the spread at the centre', async () => { + const { wrapper } = makeWrapper(); + + render(, { wrapper }); + + await waitFor(() => { + expect(screen.getByTestId('key-depth-chart-mid-marker')).toBeInTheDocument(); + }); + + const marker = screen.getByTestId('key-depth-chart-mid-marker'); + expect(marker).toHaveAttribute('data-mid-price', '1.1'); + expect(marker).toHaveAttribute('data-best-bid', '1.05'); + expect(marker).toHaveAttribute('data-best-ask', '1.15'); + expect( + screen.getByTestId('key-depth-chart-spread-marker') + ).toHaveTextContent('Spread 0.1000 XLM'); + }); + + it('shows the empty state when the key has no open orders', async () => { + vi.mocked(fetchKeyDepth).mockResolvedValue({ + bids: [], + asks: [], + midPrice: null, + spread: null, + }); + const { wrapper } = makeWrapper(); + + render(, { wrapper }); + + await waitFor(() => { + expect(screen.getByTestId('key-depth-chart-empty')).toBeInTheDocument(); + }); + expect(screen.queryByTestId('key-depth-chart-series')).not.toBeInTheDocument(); + }); + + it('shows an error state when the depth request fails', async () => { + vi.mocked(fetchKeyDepth).mockRejectedValue(new Error('Network error')); + const { wrapper } = makeWrapper(); + + render(, { wrapper }); + + await waitFor(() => { + expect(screen.getByTestId('key-depth-chart-error')).toBeInTheDocument(); + }); + }); + + it('keeps the previous snapshot on screen while a refresh is in flight', async () => { + const { wrapper, queryClient } = makeWrapper(); + + render(, { wrapper }); + await waitFor(() => { + expect(screen.getByTestId('key-depth-chart-series')).toBeInTheDocument(); + }); + + // The next poll never resolves: the chart must not fall back to the + // skeleton, it keeps rendering the last snapshot. + vi.mocked(fetchKeyDepth).mockImplementationOnce( + () => new Promise(() => {}) + ); + + await act(async () => { + void queryClient.invalidateQueries({ + queryKey: queryKeys.creators.depth('key-1', 20), + }); + }); + + expect(screen.getByTestId('key-depth-chart-series')).toBeInTheDocument(); + expect(screen.queryByTestId('key-depth-chart-skeleton')).not.toBeInTheDocument(); + await waitFor(() => { + expect( + screen.getByTestId('key-depth-chart-refreshing') + ).toBeInTheDocument(); + }); + }); +}); diff --git a/src/hooks/__tests__/useKeyDepth.test.tsx b/src/hooks/__tests__/useKeyDepth.test.tsx new file mode 100644 index 00000000..d0ab3283 --- /dev/null +++ b/src/hooks/__tests__/useKeyDepth.test.tsx @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import { + useKeyDepth, + KEY_DEPTH_REFRESH_INTERVAL_MS, +} from '@/hooks/useKeyDepth'; +import { fetchKeyDepth, type KeyDepthSnapshot } from '@/services/keyDepth.service'; +import { queryKeys } from '@/lib/queryKeys'; + +vi.mock('@/services/keyDepth.service', () => ({ + fetchKeyDepth: vi.fn(), + KEY_DEPTH_LEVELS: 20, +})); + +const mockFetchKeyDepth = vi.mocked(fetchKeyDepth); + +const baseSnapshot: KeyDepthSnapshot = { + bids: [{ price: 1.05, volume: 10 }], + asks: [{ price: 1.15, volume: 5 }], + midPrice: null, + spread: null, +}; + +describe('useKeyDepth (#1220)', () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + mockFetchKeyDepth.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + queryClient.clear(); + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + it('fetches the depth for the key and configures a 5 second refresh', async () => { + mockFetchKeyDepth.mockResolvedValue(baseSnapshot); + + const { result } = renderHook(() => useKeyDepth('key-1'), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual(baseSnapshot); + expect(mockFetchKeyDepth).toHaveBeenCalledWith('key-1', 20); + + const query = queryClient + .getQueryCache() + .find({ queryKey: queryKeys.creators.depth('key-1', 20) }); + expect(KEY_DEPTH_REFRESH_INTERVAL_MS).toBe(5_000); + expect(query?.options.staleTime).toBe(5_000); + expect(query?.options.refetchInterval).toBe(5_000); + }); + + it('refetches after 5 seconds and keeps previous data while refreshing', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + mockFetchKeyDepth.mockResolvedValueOnce(baseSnapshot); + + const { result } = renderHook(() => useKeyDepth('key-1'), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const updated: KeyDepthSnapshot = { + ...baseSnapshot, + bids: [{ price: 1.06, volume: 12 }], + }; + let resolveRefetch!: (value: KeyDepthSnapshot) => void; + mockFetchKeyDepth.mockReturnValueOnce( + new Promise(resolve => { + resolveRefetch = resolve; + }) + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000); + }); + + expect(mockFetchKeyDepth).toHaveBeenCalledTimes(2); + // Background refresh must not drop existing data (no skeleton flash). + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toEqual(baseSnapshot); + + await act(async () => { + resolveRefetch(updated); + }); + await waitFor(() => expect(result.current.data).toEqual(updated)); + }); + + it('does not fetch when the key id is empty', () => { + renderHook(() => useKeyDepth(''), { wrapper }); + expect(mockFetchKeyDepth).not.toHaveBeenCalled(); + }); + + it('surfaces errors', async () => { + mockFetchKeyDepth.mockRejectedValue(new Error('Network error')); + + const { result } = renderHook(() => useKeyDepth('key-1'), { wrapper }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error?.message).toBe('Network error'); + }); +}); diff --git a/src/hooks/useKeyDepth.ts b/src/hooks/useKeyDepth.ts new file mode 100644 index 00000000..688495b9 --- /dev/null +++ b/src/hooks/useKeyDepth.ts @@ -0,0 +1,33 @@ +import { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { + fetchKeyDepth, + KEY_DEPTH_LEVELS, +} from '@/services/keyDepth.service'; + +/** + * How often the order-book depth is refreshed (#1220). + * + * Five seconds keeps the ladder close to live without hammering the backend. + */ +export const KEY_DEPTH_REFRESH_INTERVAL_MS = 5_000; + +/** + * Order-book depth for a key. + * + * The snapshot is polled every {@link KEY_DEPTH_REFRESH_INTERVAL_MS}; React + * Query keeps the previous data in place while a refresh is in flight, so the + * chart updates in place instead of flashing a skeleton. + */ +export function useKeyDepth(keyId: string, levels: number = KEY_DEPTH_LEVELS) { + return useQuery({ + queryKey: queryKeys.creators.depth(keyId, levels), + queryFn: () => fetchKeyDepth(keyId, levels), + enabled: Boolean(keyId), + staleTime: KEY_DEPTH_REFRESH_INTERVAL_MS, + refetchInterval: KEY_DEPTH_REFRESH_INTERVAL_MS, + retry: false, + }); +} + +export default useKeyDepth; diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index 0a23d78c..612a30eb 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -91,6 +91,9 @@ export const queryKeys = { ['creators', creatorId, 'early-incentive'] as const, secondaryPrices: (creatorId: string, range: string) => ['creators', creatorId, 'secondary-prices', range] as const, + // Order-book depth for the market depth chart (#1220). + depth: (creatorId: string, levels: number) => + ['creators', creatorId, 'depth', levels] as const, paymentSchedule: (creatorId: string) => ['creators', creatorId, 'payment-schedule'] as const, publicProfile: (address: string) => diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx index 7d708a4a..95f71eb6 100644 --- a/src/pages/CreatorDetailPage.tsx +++ b/src/pages/CreatorDetailPage.tsx @@ -66,6 +66,7 @@ import ShareTwitterButton from '@/components/common/ShareTwitterButton'; import { PriceHistoryChart } from '@/components/common/PriceHistoryChart'; import { BondingCurvePriceChart } from '@/components/common/BondingCurvePriceChart'; import SecondaryMarketPriceChart from '@/components/common/SecondaryMarketPriceChart'; +import { KeyDepthChart } from '@/components/common/KeyDepthChart'; import EarlyInvestorBonusChip from '@/components/common/EarlyInvestorBonusChip'; import PaymentScheduleTimeline from '@/components/common/PaymentScheduleTimeline'; import { useEarlyIncentive } from '@/hooks/useEarlyIncentive'; @@ -125,6 +126,15 @@ import { formatUnlockCountdown } from '@/utils/lockupCountdown.utils'; import KeyLockupBadge from '@/components/common/KeyLockupBadge'; import { useAccount } from 'wagmi'; +/** Series shown in the key detail price panel (#1220 adds the depth tab). */ +type PriceChartTab = 'primary' | 'secondary' | 'depth'; + +const PRICE_CHART_TABS: Array<{ id: PriceChartTab; label: string }> = [ + { id: 'primary', label: 'Price history' }, + { id: 'secondary', label: 'Secondary market' }, + { id: 'depth', label: 'Depth' }, +]; + function CreatorDetailPageContent() { usePurchaseConfetti(); @@ -146,9 +156,7 @@ function CreatorDetailPageContent() { const [tradeSide, setTradeSide] = useState('buy'); const [tradeSubmitting, setTradeSubmitting] = useState(false); const [interval, setInterval] = useState('24h'); - const [priceChartTab, setPriceChartTab] = useState<'primary' | 'secondary'>( - 'primary' - ); + const [priceChartTab, setPriceChartTab] = useState('primary'); const [secondaryRange, setSecondaryRange] = useState('7d'); @@ -684,40 +692,29 @@ function CreatorDetailPageContent() { {keyFreeze && } - {/* Primary / secondary market price tabs (#1234) */} + {/* Primary / secondary market price tabs (#1234) and depth tab (#1220) */}
- - + {PRICE_CHART_TABS.map(tab => ( + + ))}
{priceChartTab === 'primary' ? ( @@ -729,7 +726,7 @@ function CreatorDetailPageContent() { onIntervalChange={setInterval} /> - ) : ( + ) : priceChartTab === 'secondary' ? ( + ) : ( + + + )} {/* Live bonding-curve price chart (#1115) */} diff --git a/src/pages/__tests__/CreatorDetailPage.depthTab.integration.test.tsx b/src/pages/__tests__/CreatorDetailPage.depthTab.integration.test.tsx new file mode 100644 index 00000000..67d1f6aa --- /dev/null +++ b/src/pages/__tests__/CreatorDetailPage.depthTab.integration.test.tsx @@ -0,0 +1,136 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { MemoryRouter, Route, Routes } from 'react-router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import CreatorDetailPage from '../CreatorDetailPage'; +import { useCreatorDetail } from '@/hooks/useCreators'; +import { fetchKeyDepth } from '@/services/keyDepth.service'; + +vi.mock('@/hooks/useCreators', async importOriginal => { + const original = await importOriginal(); + return { + ...original, + useCreatorDetail: vi.fn(), + useSetCoCreator: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), + }; +}); + +vi.mock('@/services/course.service', () => ({ + courseService: { + getCourse: vi.fn(), + getPriceHistory: vi.fn(), + getHoldersPage: vi.fn(), + }, +})); + +vi.mock('@/services/keyDepth.service', () => ({ + fetchKeyDepth: vi.fn(), + KEY_DEPTH_LEVELS: 20, +})); + +vi.mock('@/hooks/useCreatorProfileStaleIndicator', () => ({ + useCreatorProfileStaleIndicator: () => ({ + shouldShowBadge: false, + handleRefetch: vi.fn(), + }), +})); + +vi.mock('@/hooks/useNavigationTiming', () => ({ + useNavigationTiming: vi.fn(), +})); + +const DEPTH_SNAPSHOT = { + bids: [ + { price: 1.05, volume: 10 }, + { price: 1.0, volume: 20 }, + ], + asks: [ + { price: 1.15, volume: 5 }, + { price: 1.2, volume: 15 }, + ], + midPrice: null, + spread: null, +}; + +const creator = { + id: 'creator-1', + title: 'Creator One', + description: 'Creator One Bio', + price: 0.5, + priceStroops: 5_000_000, + instructorId: 'inst-1', + socialHandle: 'creatorone', + isVerified: true, + thumbnail: 'https://example.com/avatar.jpg', + category: 'Tech', + level: 'BEGINNER' as const, + creatorFeeBps: 250, + protocolFeeBps: 250, + creatorShareSupply: 100, + volume24h: 10_000_000, + priceHistory: [1_000_000, 2_000_000, 3_000_000], +}; + +function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +} + +function renderPage() { + return render( + + + + } /> + + + + ); +} + +describe('CreatorDetailPage depth tab (#1220)', () => { + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(useCreatorDetail).mockReturnValue({ + data: creator, + isLoading: false, + error: null, + isFetching: false, + refetch: vi.fn(), + } as unknown as ReturnType); + + vi.mocked(fetchKeyDepth).mockResolvedValue(DEPTH_SNAPSHOT); + }); + + it('offers a depth tab next to the price charts', async () => { + renderPage(); + + const depthTab = await screen.findByTestId('price-chart-tab-depth'); + expect(depthTab).toHaveAttribute('aria-selected', 'false'); + expect(screen.getByTestId('price-chart-tab-primary')).toHaveAttribute( + 'aria-selected', + 'true' + ); + }); + + it('renders the market depth chart when the depth tab is selected', async () => { + const user = userEvent.setup(); + renderPage(); + + const depthTab = await screen.findByTestId('price-chart-tab-depth'); + await user.click(depthTab); + + await waitFor(() => { + expect(screen.getByTestId('key-depth-chart')).toBeInTheDocument(); + }); + + expect(depthTab).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByTestId('key-depth-best-bid')).toHaveTextContent('1.05 XLM'); + expect(screen.getByTestId('key-depth-best-ask')).toHaveTextContent('1.15 XLM'); + expect(screen.getByTestId('key-depth-mid-price')).toHaveTextContent('1.10 XLM'); + expect(fetchKeyDepth).toHaveBeenCalledWith('creator-1', 20); + }); +}); diff --git a/src/services/__tests__/keyDepth.service.test.ts b/src/services/__tests__/keyDepth.service.test.ts new file mode 100644 index 00000000..dd3b78e5 --- /dev/null +++ b/src/services/__tests__/keyDepth.service.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ApiError } from '../api.service'; + +const { mockGet } = vi.hoisted(() => ({ mockGet: vi.fn() })); + +vi.mock('axios', () => ({ + default: { + create: vi.fn(() => ({ + get: mockGet, + post: vi.fn(), + interceptors: { + request: { use: vi.fn() }, + response: { use: vi.fn() }, + }, + })), + isAxiosError: (err: unknown): boolean => + err !== null && + typeof err === 'object' && + (err as Record).isAxiosError === true, + }, +})); + +import { keyDepthService, normalizeKeyDepth } from '../keyDepth.service'; + +describe('keyDepthService (#1220)', () => { + beforeEach(() => { + mockGet.mockReset(); + }); + + it('requests 20 levels from the depth endpoint', async () => { + mockGet.mockResolvedValueOnce({ + data: { success: true, data: { bids: [], asks: [] } }, + }); + + await keyDepthService.getKeyDepth('key-1'); + + expect(mockGet).toHaveBeenCalledWith('/keys/key-1/depth', { + params: { levels: 20 }, + }); + }); + + it('honours an explicit level count', async () => { + mockGet.mockResolvedValueOnce({ + data: { success: true, data: { bids: [], asks: [] } }, + }); + + await keyDepthService.getKeyDepth('key-1', 50); + + expect(mockGet).toHaveBeenCalledWith('/keys/key-1/depth', { + params: { levels: 50 }, + }); + }); + + it('sorts the ladders with the best price first', async () => { + mockGet.mockResolvedValueOnce({ + data: { + success: true, + data: { + bids: [ + { price: 0.9, volume: 5 }, + { price: 1.1, volume: 7 }, + ], + asks: [ + { price: 1.4, volume: 3 }, + { price: 1.2, volume: 6 }, + ], + }, + }, + }); + + const snapshot = await keyDepthService.getKeyDepth('key-1'); + + expect(snapshot.bids.map(level => level.price)).toEqual([1.1, 0.9]); + expect(snapshot.asks.map(level => level.price)).toEqual([1.2, 1.4]); + }); + + it('handles server errors and wraps them in ApiError', async () => { + mockGet.mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 500, data: { success: false, message: 'Server error' } }, + message: 'Server error', + }); + + await expect(keyDepthService.getKeyDepth('key-1')).rejects.toThrow(ApiError); + }); +}); + +describe('normalizeKeyDepth (#1220)', () => { + it('reads the ladders from a plain payload', () => { + const snapshot = normalizeKeyDepth({ + bids: [{ price: 1.05, amount: 10 }], + asks: [{ price: 1.15, amount: 4 }], + }); + + expect(snapshot.bids).toEqual([{ price: 1.05, volume: 10 }]); + expect(snapshot.asks).toEqual([{ price: 1.15, volume: 4 }]); + }); + + it('reads a price-keyed ladder', () => { + const snapshot = normalizeKeyDepth({ + bids: { '1.05': 10, '1': 20 }, + asks: {}, + }); + + expect(snapshot.bids).toEqual([ + { price: 1.05, volume: 10 }, + { price: 1, volume: 20 }, + ]); + }); + + it('converts stroop-denominated prices to XLM', () => { + const snapshot = normalizeKeyDepth({ + bids: [{ priceStroops: 10_500_000, volume: 3 }], + asks: [], + }); + + expect(snapshot.bids[0].price).toBeCloseTo(1.05, 10); + }); + + it('reads a payload nested under depth', () => { + const snapshot = normalizeKeyDepth({ + depth: { + bids: [{ price: 1.05, volume: 2 }], + asks: [{ price: 1.1, volume: 1 }], + }, + }); + + expect(snapshot.bids).toHaveLength(1); + expect(snapshot.asks).toHaveLength(1); + }); + + it('drops levels without a usable price', () => { + const snapshot = normalizeKeyDepth({ + bids: [ + { price: 0, volume: 5 }, + { price: 1.05, volume: 2 }, + ], + asks: [], + }); + + expect(snapshot.bids).toHaveLength(1); + }); + + it('keeps the server-reported mid price and spread', () => { + const snapshot = normalizeKeyDepth({ + bids: [], + asks: [], + midPrice: 1.02, + spread: 0.04, + }); + + expect(snapshot.midPrice).toBe(1.02); + expect(snapshot.spread).toBe(0.04); + }); + + it('returns empty ladders for an unexpected payload', () => { + const snapshot = normalizeKeyDepth(null); + + expect(snapshot.bids).toEqual([]); + expect(snapshot.asks).toEqual([]); + expect(snapshot.midPrice).toBeNull(); + }); +}); diff --git a/src/services/keyDepth.service.ts b/src/services/keyDepth.service.ts new file mode 100644 index 00000000..9fded84f --- /dev/null +++ b/src/services/keyDepth.service.ts @@ -0,0 +1,217 @@ +import { BaseApiService, type APIResponse } from './api.service'; +import { STROOPS_PER_XLM } from '@/constants/stellar'; + +/** + * One price level of the secondary-market order book. + * + * Prices are per fraction and expressed in XLM; volumes count the fractions + * resting at the level. + */ +export interface KeyDepthLevel { + /** Price per fraction, in XLM. */ + price: number; + /** Volume resting at this level, in fractions. */ + volume: number; +} + +/** + * Normalized order-book snapshot for a key (#1220). + * + * `bids` are the open buy orders (best/highest price first) and `asks` are the + * open sell listings (best/lowest price first). + */ +export interface KeyDepthSnapshot { + /** Open buy orders, best (highest) price first. */ + bids: KeyDepthLevel[]; + /** Open sell listings, best (lowest) price first. */ + asks: KeyDepthLevel[]; + /** Server-reported mid price, when the backend provides one. */ + midPrice: number | null; + /** Server-reported absolute spread, when the backend provides one. */ + spread: number | null; +} + +/** Number of price levels requested per side of the book. */ +export const KEY_DEPTH_LEVELS = 20; + +type UnknownRecord = Record; + +/** Payload keys that may hold the bid ladder, in priority order. */ +const BID_KEYS = ['bids', 'buyOrders', 'buys', 'buy', 'bid'] as const; + +/** Payload keys that may hold the ask ladder, in priority order. */ +const ASK_KEYS = [ + 'asks', + 'sellOrders', + 'sells', + 'sell', + 'ask', + 'listings', + 'offers', +] as const; + +function asRecord(value: unknown): UnknownRecord { + return typeof value === 'object' && value !== null + ? (value as UnknownRecord) + : {}; +} + +function asNumber(value: unknown, fallback = 0): number { + const parsed = typeof value === 'number' ? value : Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function asOptionalNumber(value: unknown): number | null { + if (value == null || value === '') return null; + const parsed = typeof value === 'number' ? value : Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +/** + * Reads a level price, accepting either XLM (`price`) or stroops + * (`priceStroops`), and normalises it to XLM. + */ +function readLevelPrice(raw: UnknownRecord): number { + const stroops = asOptionalNumber(raw.priceStroops); + if (stroops != null) return stroops / STROOPS_PER_XLM; + + return asNumber(raw.price ?? raw.pricePerFraction ?? raw.rate); +} + +/** Reads the resting volume of a level, in fractions. */ +function readLevelVolume(raw: UnknownRecord): number { + const volume = + raw.volume ?? + raw.amount ?? + raw.quantity ?? + raw.size ?? + raw.total ?? + raw.fractions; + + return asNumber(volume); +} + +/** + * Normalises a single order-book level. + * + * Levels without a usable (positive) price carry no information for the + * chart and are dropped; a level with no volume is kept so the snapshot still + * reflects the book the server returned. + */ +export function normalizeDepthLevel(value: unknown): KeyDepthLevel | null { + const raw = asRecord(value); + const price = readLevelPrice(raw); + + if (!Number.isFinite(price) || price <= 0) return null; + + return { price, volume: Math.max(readLevelVolume(raw), 0) }; +} + +/** Sorts levels by price: descending for bids, ascending for asks. */ +function sortLevels( + levels: KeyDepthLevel[], + direction: 'asc' | 'desc' +): KeyDepthLevel[] { + return [...levels].sort((a, b) => + direction === 'asc' ? a.price - b.price : b.price - a.price + ); +} + +/** + * Reads a ladder that may be an array of levels or a price-keyed map + * (`{ "1.05": 12 }`). + */ +function readLadder( + payload: UnknownRecord, + keys: readonly string[] +): KeyDepthLevel[] { + for (const key of keys) { + const value = payload[key]; + + if (Array.isArray(value)) { + return value + .map(normalizeDepthLevel) + .filter((level): level is KeyDepthLevel => level !== null); + } + + if (value && typeof value === 'object') { + return Object.entries(value as UnknownRecord) + .map(([price, volume]) => normalizeDepthLevel({ price, volume })) + .filter((level): level is KeyDepthLevel => level !== null); + } + } + + return []; +} + +/** + * Flattens the payload envelope so the ladders can be read regardless of + * whether the backend nests them under `depth` / `orderBook` / `book`. + */ +function resolveDepthPayload(value: unknown): UnknownRecord { + const root = asRecord(value); + const nested = asRecord(root.depth ?? root.orderBook ?? root.book); + + if (Object.keys(nested).length === 0) return root; + + return { ...root, ...nested }; +} + +/** Reads the mid price the backend reports, when it reports one. */ +function readMidPrice(payload: UnknownRecord): number | null { + return asOptionalNumber(payload.midPrice ?? payload.mid ?? payload.lastPrice); +} + +/** Reads the absolute spread the backend reports, when it reports one. */ +function readSpread(payload: UnknownRecord): number | null { + return asOptionalNumber(payload.spread ?? payload.spreadAmount); +} + +/** + * Normalises the raw depth payload into a {@link KeyDepthSnapshot}. + * + * Exported so the shape can be unit-tested without an HTTP round trip. + */ +export function normalizeKeyDepth(value: unknown): KeyDepthSnapshot { + const payload = resolveDepthPayload(value); + + return { + bids: sortLevels(readLadder(payload, BID_KEYS), 'desc'), + asks: sortLevels(readLadder(payload, ASK_KEYS), 'asc'), + midPrice: readMidPrice(payload), + spread: readSpread(payload), + }; +} + +class KeyDepthService extends BaseApiService { + /** + * Order-book depth for a key — GET /keys/:keyId/depth?levels=20 (#1220). + * + * Returns the open buy orders (bids) and sell listings (asks) with the + * volume resting at each price level, normalised for the depth chart. + */ + async getKeyDepth( + keyId: string, + levels: number = KEY_DEPTH_LEVELS + ): Promise { + try { + const response = await this.api.get>( + `/keys/${encodeURIComponent(keyId)}/depth`, + { params: { levels } } + ); + + return normalizeKeyDepth(response.data.data); + } catch (error) { + throw this.handleError(error); + } + } +} + +export const keyDepthService = new KeyDepthService(); + +export function fetchKeyDepth( + keyId: string, + levels: number = KEY_DEPTH_LEVELS +): Promise { + return keyDepthService.getKeyDepth(keyId, levels); +} diff --git a/src/utils/__tests__/keyDepthChart.utils.test.ts b/src/utils/__tests__/keyDepthChart.utils.test.ts new file mode 100644 index 00000000..1132ed96 --- /dev/null +++ b/src/utils/__tests__/keyDepthChart.utils.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from 'vitest'; +import { + buildDepthChartModel, + formatDepthPrice, + formatDepthPriceTick, +} from '../keyDepthChart.utils'; +import type { KeyDepthSnapshot } from '@/services/keyDepth.service'; + +const SNAPSHOT: KeyDepthSnapshot = { + bids: [ + { price: 1.05, volume: 10 }, + { price: 1.0, volume: 20 }, + { price: 0.95, volume: 30 }, + ], + asks: [ + { price: 1.15, volume: 5 }, + { price: 1.2, volume: 15 }, + ], + midPrice: null, + spread: null, +}; + +describe('buildDepthChartModel (#1220)', () => { + it('returns an empty model for a missing snapshot', () => { + const model = buildDepthChartModel(null); + + expect(model.points).toEqual([]); + expect(model.bestBid).toBeNull(); + expect(model.bestAsk).toBeNull(); + expect(model.midPrice).toBeNull(); + expect(model.spread).toBeNull(); + }); + + it('returns an empty model when the book has no levels', () => { + const model = buildDepthChartModel({ + bids: [], + asks: [], + midPrice: null, + spread: null, + }); + + expect(model.points).toEqual([]); + expect(model.maxCumulativeVolume).toBe(0); + }); + + it('cumulates bid volume from the best bid downwards', () => { + const model = buildDepthChartModel(SNAPSHOT); + + const bids = model.points.filter(point => point.side === 'bid'); + expect(bids.map(point => [point.price, point.bidVolume])).toEqual([ + [1.05, 10], + [1.0, 30], + [0.95, 60], + ]); + expect(bids.every(point => point.askVolume === null)).toBe(true); + }); + + it('cumulates ask volume from the best ask upwards', () => { + const model = buildDepthChartModel(SNAPSHOT); + + const asks = model.points.filter(point => point.side === 'ask'); + expect(asks.map(point => [point.price, point.askVolume])).toEqual([ + [1.15, 5], + [1.2, 20], + ]); + expect(asks.every(point => point.bidVolume === null)).toBe(true); + }); + + it('keeps the level volume next to the cumulative volume', () => { + const model = buildDepthChartModel(SNAPSHOT); + + const bestBid = model.points.find(point => point.price === 1.05); + expect(bestBid?.bidAmount).toBe(10); + expect(bestBid?.bidVolume).toBe(10); + + const bestAsk = model.points.find(point => point.price === 1.15); + expect(bestAsk?.askAmount).toBe(5); + expect(bestAsk?.askVolume).toBe(5); + }); + + it('orders the plotted levels by ascending price', () => { + const model = buildDepthChartModel(SNAPSHOT); + const prices = model.points.map(point => point.price); + + expect(prices).toEqual([...prices].sort((a, b) => a - b)); + expect(prices).toEqual([0.95, 1.0, 1.05, 1.15, 1.2]); + }); + + it('resolves the best bid, best ask and mid price', () => { + const model = buildDepthChartModel(SNAPSHOT); + + expect(model.bestBid).toBe(1.05); + expect(model.bestAsk).toBe(1.15); + expect(model.midPrice).toBe(1.1); + }); + + it('derives the spread from the best bid and ask', () => { + const model = buildDepthChartModel(SNAPSHOT); + + expect(model.spread).toBeCloseTo(0.1, 10); + expect(model.spreadPercent).toBeCloseTo((0.1 / 1.1) * 100, 10); + }); + + it('prefers the derived spread over a server-provided one', () => { + const model = buildDepthChartModel({ + ...SNAPSHOT, + midPrice: 99, + spread: 42, + }); + + expect(model.midPrice).toBe(1.1); + expect(model.spread).toBeCloseTo(0.1, 10); + }); + + it('falls back to the server mid price when one side of the book is empty', () => { + const model = buildDepthChartModel({ + bids: SNAPSHOT.bids, + asks: [], + midPrice: 1.07, + spread: null, + }); + + expect(model.bestAsk).toBeNull(); + expect(model.midPrice).toBe(1.07); + expect(model.spread).toBeNull(); + }); + + it('totals the volume resting on each side', () => { + const model = buildDepthChartModel(SNAPSHOT); + + expect(model.totalBidVolume).toBe(60); + expect(model.totalAskVolume).toBe(20); + expect(model.maxCumulativeVolume).toBe(60); + }); + + it('centres the domain on the mid price so the marker sits mid-chart', () => { + const model = buildDepthChartModel(SNAPSHOT); + const [min, max] = model.domain; + + expect((min + max) / 2).toBeCloseTo(model.midPrice ?? 0, 10); + // The wider ladder gets the extra room. + expect(min).toBeCloseTo(0.95, 10); + expect(max).toBeCloseTo(1.25, 10); + }); + + it('keeps a usable domain for a single price level', () => { + const model = buildDepthChartModel({ + bids: [{ price: 2, volume: 1 }], + asks: [], + midPrice: null, + spread: null, + }); + + const [min, max] = model.domain; + expect(max).toBeGreaterThan(min); + expect((min + max) / 2).toBeCloseTo(2, 10); + }); + + it('ignores levels without volume or a usable price', () => { + const model = buildDepthChartModel({ + bids: [ + { price: 1.05, volume: 10 }, + { price: 1.0, volume: 0 }, + { price: 0, volume: 5 }, + ], + asks: [], + midPrice: null, + spread: null, + }); + + expect(model.points).toHaveLength(1); + expect(model.points[0].price).toBe(1.05); + }); +}); + +describe('formatDepthPrice (#1220)', () => { + it('formats prices with two decimals from one XLM up', () => { + expect(formatDepthPrice(1.5)).toBe('1.50 XLM'); + expect(formatDepthPrice(12)).toBe('12.00 XLM'); + }); + + it('keeps four decimals for sub-XLM prices', () => { + expect(formatDepthPrice(0.1234)).toBe('0.1234 XLM'); + }); + + it('falls back to a placeholder for missing prices', () => { + expect(formatDepthPrice(null)).toBe('—'); + expect(formatDepthPrice(undefined)).toBe('—'); + expect(formatDepthPrice(Number.NaN)).toBe('—'); + }); +}); + +describe('formatDepthPriceTick (#1220)', () => { + it('omits the currency suffix', () => { + expect(formatDepthPriceTick(1.5)).toBe('1.50'); + expect(formatDepthPriceTick(0.1234)).toBe('0.1234'); + }); + + it('returns an empty string for missing prices', () => { + expect(formatDepthPriceTick(null)).toBe(''); + }); +}); diff --git a/src/utils/keyDepthChart.utils.ts b/src/utils/keyDepthChart.utils.ts new file mode 100644 index 00000000..3ccf8b95 --- /dev/null +++ b/src/utils/keyDepthChart.utils.ts @@ -0,0 +1,224 @@ +import type { + KeyDepthLevel, + KeyDepthSnapshot, +} from '@/services/keyDepth.service'; + +/** + * A single plotted price level of the depth chart. + * + * Bid levels carry a cumulative bid volume and a null ask volume; ask levels + * are the mirror image. The nulls are what create the gap between the two + * ladders around the mid price. + */ +export interface DepthChartPoint { + /** Price level on the x-axis, in XLM per fraction. */ + price: number; + /** Cumulative bid volume at (and above) this level; null on the ask side. */ + bidVolume: number | null; + /** Cumulative ask volume at (and above) this level; null on the bid side. */ + askVolume: number | null; + /** Volume resting exactly at this level (bid side). */ + bidAmount: number; + /** Volume resting exactly at this level (ask side). */ + askAmount: number; + /** Which side of the book the level belongs to. */ + side: 'bid' | 'ask'; +} + +/** Everything the depth chart needs in order to render. */ +export interface DepthChartModel { + /** Plotted levels, ascending by price. */ + points: DepthChartPoint[]; + /** Highest bid price, in XLM. */ + bestBid: number | null; + /** Lowest ask price, in XLM. */ + bestAsk: number | null; + /** Mid price between the best bid and the best ask, in XLM. */ + midPrice: number | null; + /** Absolute spread between the best ask and the best bid, in XLM. */ + spread: number | null; + /** Spread as a percentage of the mid price. */ + spreadPercent: number | null; + /** Total volume resting on the bid side. */ + totalBidVolume: number; + /** Total volume resting on the ask side. */ + totalAskVolume: number; + /** Largest cumulative volume on either side; drives the y-axis scale. */ + maxCumulativeVolume: number; + /** Price bounds of the plotted domain, symmetric around the mid price. */ + domain: [number, number]; +} + +const EMPTY_MODEL: DepthChartModel = { + points: [], + bestBid: null, + bestAsk: null, + midPrice: null, + spread: null, + spreadPercent: null, + totalBidVolume: 0, + totalAskVolume: 0, + maxCumulativeVolume: 0, + domain: [0, 0], +}; + +/** Keeps the levels that can be plotted: a positive price and resting volume. */ +function plottableLevels(levels: KeyDepthLevel[]): KeyDepthLevel[] { + return levels.filter( + level => + Number.isFinite(level.price) && + level.price > 0 && + Number.isFinite(level.volume) && + level.volume > 0 + ); +} + +/** + * Builds the cumulative ladders. + * + * Bids are walked from the best (highest) price downwards and asks from the + * best (lowest) price upwards, so the running total at a level is the volume + * that can be filled by walking the book towards that level. + */ +function buildLadder( + levels: KeyDepthLevel[], + side: 'bid' | 'ask' +): DepthChartPoint[] { + let cumulative = 0; + + return levels.map(level => { + cumulative += level.volume; + + return { + price: level.price, + bidVolume: side === 'bid' ? cumulative : null, + askVolume: side === 'ask' ? cumulative : null, + bidAmount: side === 'bid' ? level.volume : 0, + askAmount: side === 'ask' ? level.volume : 0, + side, + }; + }); +} + +/** + * Turns a depth snapshot into the series rendered by the depth chart. + * + * The two ladders are merged into a single price-ascending series so the + * chart can use one numeric x-axis: bids sit to the left of the mid price and + * asks to the right, each drawn as a cumulative step area. The plotted domain + * is symmetric around the mid price, which keeps the mid marker exactly at the + * centre of the chart. + */ +export function buildDepthChartModel( + snapshot: KeyDepthSnapshot | null | undefined +): DepthChartModel { + if (!snapshot) return EMPTY_MODEL; + + // Bids are walked from the best (highest) price downwards and asks from + // the best (lowest) price upwards, so the ladders are sorted here rather + // than relying on the caller to hand them over in order. + const bids = plottableLevels(snapshot.bids ?? []).sort( + (a, b) => b.price - a.price + ); + const asks = plottableLevels(snapshot.asks ?? []).sort( + (a, b) => a.price - b.price + ); + + if (bids.length === 0 && asks.length === 0) return EMPTY_MODEL; + + const bidPoints = buildLadder(bids, 'bid'); + const askPoints = buildLadder(asks, 'ask'); + + const points = [...bidPoints, ...askPoints].sort((a, b) => a.price - b.price); + + const bestBid = bids.length > 0 ? bids[0].price : null; + const bestAsk = asks.length > 0 ? asks[0].price : null; + + const derivedMid = + bestBid != null && bestAsk != null ? (bestBid + bestAsk) / 2 : null; + const midPrice = derivedMid ?? snapshot.midPrice ?? bestBid ?? bestAsk ?? null; + + const derivedSpread = + bestBid != null && bestAsk != null ? bestAsk - bestBid : null; + const spread = derivedSpread ?? snapshot.spread ?? null; + + const spreadPercent = + spread != null && midPrice != null && midPrice !== 0 + ? (spread / midPrice) * 100 + : null; + + const lastBidPoint = bidPoints[bidPoints.length - 1]; + const lastAskPoint = askPoints[askPoints.length - 1]; + const totalBidVolume = lastBidPoint?.bidVolume ?? 0; + const totalAskVolume = lastAskPoint?.askVolume ?? 0; + + const maxCumulativeVolume = Math.max(totalBidVolume, totalAskVolume); + + const prices = points.map(point => point.price); + const minPrice = Math.min(...prices); + const maxPrice = Math.max(...prices); + + // Symmetric domain around the mid price: the green ladder and the red + // ladder get the same horizontal space, so the mid marker lands exactly at + // the centre of the plot. + let halfWidth = 0; + if (midPrice != null) { + halfWidth = Math.max(midPrice - minPrice, maxPrice - midPrice); + } + + if (halfWidth <= 0) { + const reference = midPrice ?? maxPrice; + halfWidth = Math.max(reference, 1) * 0.01; + } + + const domain: [number, number] = + midPrice != null + ? [midPrice - halfWidth, midPrice + halfWidth] + : [minPrice, maxPrice]; + + return { + points, + bestBid, + bestAsk, + midPrice, + spread, + spreadPercent, + totalBidVolume, + totalAskVolume, + maxCumulativeVolume, + domain, + }; +} + +/** + * Formats a per-fraction price for display. + * + * Sub-XLM prices keep four decimals so small quotes stay readable; anything + * from 1 XLM up is shown with two. + */ +export function formatDepthPrice(value: number | null | undefined): string { + if (value == null || !Number.isFinite(value)) return '—'; + + const magnitude = Math.abs(value); + const decimals = magnitude > 0 && magnitude < 1 ? 4 : 2; + + return `${value.toLocaleString(undefined, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + })} XLM`; +} + +/** + * Formats a price for an axis tick, without the currency suffix. + */ +export function formatDepthPriceTick(value: number | null | undefined): string { + if (value == null || !Number.isFinite(value)) return ''; + + const magnitude = Math.abs(value); + const decimals = magnitude > 0 && magnitude < 1 ? 4 : 2; + + return value.toLocaleString(undefined, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); +}