Skip to content
Merged
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
130 changes: 127 additions & 3 deletions app/components/UI/Perps/hooks/usePerpsMarketStats.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { renderHook } from '@testing-library/react-hooks';
import { CandlePeriod } from '@metamask/perps-controller';
import { CandlePeriod, PERPS_CONSTANTS } from '@metamask/perps-controller';
import { usePerpsMarketStats } from './usePerpsMarketStats';

// Mock Engine
Expand All @@ -11,20 +11,36 @@ jest.mock('../../../../core/Engine', () => ({
},
}));

jest.mock('./usePerpsConnection', () => ({
usePerpsConnection: jest.fn(() => ({ isInitialized: true })),
}));

// Mock the dependent hooks
jest.mock('./stream/usePerpsLiveCandles');

import Engine from '../../../../core/Engine';
import { usePerpsLiveCandles } from './stream/usePerpsLiveCandles';
import { usePerpsConnection } from './usePerpsConnection';

const mockedUsePerpsLiveCandles = jest.mocked(usePerpsLiveCandles);
const mockedUsePerpsConnection = jest.mocked(usePerpsConnection);
const mockSubscribeToPrices = Engine.context.PerpsController
.subscribeToPrices as jest.Mock;

describe('usePerpsMarketStats', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
mockedUsePerpsConnection.mockReturnValue({
isInitialized: true,
isConnected: true,
isConnecting: false,
error: null,
connect: jest.fn(),
disconnect: jest.fn(),
resetError: jest.fn(),
reconnectWithNewContext: jest.fn(),
});
});

afterEach(() => {
Expand Down Expand Up @@ -138,8 +154,10 @@ describe('usePerpsMarketStats', () => {
// PRICE_RANGES_UNIVERSAL: trailing zeros removed, so $0.00 → $0
expect(result.current.high24h).toBe('$0');
expect(result.current.low24h).toBe('$0');
expect(result.current.volume24h).toBe('$0.00'); // formatVolume keeps .00 for zero
expect(result.current.openInterest).toBe('$0.00'); // formatLargeNumber keeps .00 for zero
expect(result.current.volume24h).toBe(PERPS_CONSTANTS.FallbackPriceDisplay);
expect(result.current.openInterest).toBe(
PERPS_CONSTANTS.FallbackPriceDisplay,
);
expect(result.current.fundingRate).toBe('0.0000%');
});

Expand Down Expand Up @@ -225,4 +243,110 @@ describe('usePerpsMarketStats', () => {

expect(result.current.fundingRate).toBe('-0.5000%');
});

it('displays formatted zero when volume and open interest are actually zero', () => {
// Arrange: confirmed zero volume and open interest (not missing data)
mockSubscribeToPrices.mockImplementation(({ callback }) => {
callback([
{
...mockPriceData.BTC,
volume24h: 0,
openInterest: 0,
},
]);
return jest.fn();
});
mockedUsePerpsLiveCandles.mockReturnValue({
candleData: mockCandleData,
isLoading: false,
isLoadingMore: false,
hasHistoricalData: true,
error: null,
fetchMoreHistory: jest.fn(),
});

// Act
const { result } = renderHook(() => usePerpsMarketStats('BTC'));

// Assert: actual zeros format as $0, not the missing-data placeholder
expect(result.current.volume24h).toBe('$0');
expect(result.current.openInterest).toBe('$0');
});

it('does not subscribe until the Perps connection is initialized', () => {
// Arrange: connection has not finished initializing
mockedUsePerpsConnection.mockReturnValue({
isInitialized: false,
isConnected: false,
isConnecting: true,
error: null,
connect: jest.fn(),
disconnect: jest.fn(),
resetError: jest.fn(),
reconnectWithNewContext: jest.fn(),
});
mockedUsePerpsLiveCandles.mockReturnValue({
candleData: mockCandleData,
isLoading: false,
isLoadingMore: false,
hasHistoricalData: true,
error: null,
fetchMoreHistory: jest.fn(),
});

// Act
renderHook(() => usePerpsMarketStats('BTC'));

// Assert: subscribe is deferred until init so a fast Perps open can retry
expect(mockSubscribeToPrices).not.toHaveBeenCalled();
});

it('subscribes after the Perps connection initializes', () => {
// Arrange: start uninitialized, then flip to initialized
mockedUsePerpsConnection.mockReturnValue({
isInitialized: false,
isConnected: false,
isConnecting: true,
error: null,
connect: jest.fn(),
disconnect: jest.fn(),
resetError: jest.fn(),
reconnectWithNewContext: jest.fn(),
});
mockedUsePerpsLiveCandles.mockReturnValue({
candleData: mockCandleData,
isLoading: false,
isLoadingMore: false,
hasHistoricalData: true,
error: null,
fetchMoreHistory: jest.fn(),
});
mockSubscribeToPrices.mockReturnValue(jest.fn());

// Act: first render before init, then rerender after init
const { rerender } = renderHook(() => usePerpsMarketStats('BTC'));

expect(mockSubscribeToPrices).not.toHaveBeenCalled();

mockedUsePerpsConnection.mockReturnValue({
isInitialized: true,
isConnected: true,
isConnecting: false,
error: null,
connect: jest.fn(),
disconnect: jest.fn(),
resetError: jest.fn(),
reconnectWithNewContext: jest.fn(),
});
rerender();

// Assert: subscribe retries once the client is ready
expect(mockSubscribeToPrices).toHaveBeenCalledTimes(1);
expect(mockSubscribeToPrices).toHaveBeenCalledWith(
expect.objectContaining({
symbols: ['BTC'],
includeMarketData: true,
}),
);
});
});
34 changes: 20 additions & 14 deletions app/components/UI/Perps/hooks/usePerpsMarketStats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Engine from '../../../../core/Engine';
import {
CandlePeriod,
PERPS_CONSTANTS,
TimeDuration,
calculate24hHighLow,
type PriceUpdate,
Expand All @@ -13,6 +14,7 @@ import {
LARGE_NUMBER_RANGES_DETAILED,
PRICE_RANGES_UNIVERSAL,
} from '../utils/formatUtils';
import { usePerpsConnection } from './usePerpsConnection';
import { usePerpsLiveCandles } from './stream/usePerpsLiveCandles';

interface MarketStats {
Expand Down Expand Up @@ -41,6 +43,7 @@ export interface UsePerpsMarketStatsReturn extends MarketStats {
export const usePerpsMarketStats = (
symbol: string,
): UsePerpsMarketStatsReturn => {
const { isInitialized } = usePerpsConnection();
const [marketData, setMarketData] = useState<MarketDataUpdate>({});
const [initialPrice, setInitialPrice] = useState<number | undefined>();
// Track whether the initial price has been captured without making it a
Expand All @@ -57,10 +60,11 @@ export const usePerpsMarketStats = (
throttleMs: 1000,
});

// Subscribe to market data updates (funding, open interest, volume)
// Note: We still subscribe to prices but only extract market metadata, not price itself
// Subscribe to market data updates (funding, open interest, volume).
// Gate on isInitialized so a fast Perps open after wallet unlock does not
// get a no-op subscribe with no retry (same pattern as usePerpsPrices).
useEffect(() => {
if (!symbol) return;
if (!symbol || !isInitialized) return;

let unsubscribe: (() => void) | undefined;
const findSymbol = (update: PriceUpdate) => update.symbol === symbol;
Expand Down Expand Up @@ -114,7 +118,7 @@ export const usePerpsMarketStats = (
unsubscribe();
}
};
}, [symbol]);
}, [symbol, isInitialized]);

// Calculate all statistics
const stats = useMemo<MarketStats>(() => {
Expand All @@ -135,16 +139,18 @@ export const usePerpsMarketStats = (
: formatPerpsFiat(fallbackPrice, {
ranges: PRICE_RANGES_UNIVERSAL,
}),
volume24h: marketData.volume24h
? `$${formatLargeNumber(marketData.volume24h, {
ranges: LARGE_NUMBER_RANGES_DETAILED,
})}`
: '$0.00',
openInterest: marketData.openInterest
? `$${formatLargeNumber(marketData.openInterest, {
ranges: LARGE_NUMBER_RANGES_DETAILED,
})}`
: '$0.00',
volume24h:
marketData.volume24h !== undefined
? `$${formatLargeNumber(marketData.volume24h, {
ranges: LARGE_NUMBER_RANGES_DETAILED,
})}`
: PERPS_CONSTANTS.FallbackPriceDisplay,
openInterest:
marketData.openInterest !== undefined
? `$${formatLargeNumber(marketData.openInterest, {
ranges: LARGE_NUMBER_RANGES_DETAILED,
})}`
: PERPS_CONSTANTS.FallbackPriceDisplay,
fundingRate: formatFundingRate(marketData.funding),
currentPrice: fallbackPrice,
isLoading: !candleData,
Expand Down
Loading