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
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
defaultPerpsClosePositionValidationMock,
defaultPerpsEventTrackingMock,
defaultPerpsLivePricesMock,
defaultPerpsTopOfBookMock,
defaultPerpsOrderFeesMock,
defaultPerpsPositionMock,
defaultPerpsRewardsMock,
Expand Down Expand Up @@ -51,6 +52,7 @@ jest.mock('../../hooks', () => ({

jest.mock('../../hooks/stream', () => ({
usePerpsLivePrices: jest.fn(),
usePerpsTopOfBook: jest.fn(),
}));

jest.mock('../../hooks/usePerpsEventTracking', () => ({
Expand Down Expand Up @@ -120,6 +122,9 @@ describe('PerpsClosePositionView', () => {
const usePerpsLivePricesMock = jest.mocked(
jest.requireMock('../../hooks/stream').usePerpsLivePrices,
);
const usePerpsTopOfBookMock = jest.mocked(
jest.requireMock('../../hooks/stream').usePerpsTopOfBook,
);
const usePerpsOrderFeesMock = jest.mocked(
jest.requireMock('../../hooks').usePerpsOrderFees,
);
Expand Down Expand Up @@ -162,6 +167,7 @@ describe('PerpsClosePositionView', () => {

// Setup hook mocks with default values
usePerpsLivePricesMock.mockReturnValue(defaultPerpsLivePricesMock);
usePerpsTopOfBookMock.mockReturnValue(defaultPerpsTopOfBookMock);
usePerpsOrderFeesMock.mockReturnValue(defaultPerpsOrderFeesMock);
usePerpsClosePositionValidationMock.mockReturnValue(
defaultPerpsClosePositionValidationMock,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import {
usePerpsToasts,
usePerpsMarketData,
} from '../../hooks';
import { usePerpsLivePrices } from '../../hooks/stream';
import { usePerpsLivePrices, usePerpsTopOfBook } from '../../hooks/stream';
import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking';
import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement';
import {
Expand Down Expand Up @@ -115,6 +115,11 @@ const PerpsClosePositionView: React.FC = () => {
? parseFloat(priceData[position.coin].price)
: parseFloat(position.entryPrice);

// Get top of book data for maker/taker fee determination
const currentTopOfBook = usePerpsTopOfBook({
symbol: position.coin,
});

// Determine position direction
const isLong = parseFloat(position.size) > 0;
const absSize = Math.abs(parseFloat(position.size));
Expand Down Expand Up @@ -185,20 +190,18 @@ const PerpsClosePositionView: React.FC = () => {
[closingValue],
);

const positionPriceData = priceData[position.coin];

const feeResults = usePerpsOrderFees({
orderType,
amount: closingValueString,
coin: position.coin,
isClosing: true,
limitPrice,
direction: isLong ? 'short' : 'long',
currentAskPrice: positionPriceData?.bestAsk
? Number.parseFloat(positionPriceData.bestAsk)
currentAskPrice: currentTopOfBook?.bestAsk
? Number.parseFloat(currentTopOfBook.bestAsk)
: undefined,
currentBidPrice: positionPriceData?.bestBid
? Number.parseFloat(positionPriceData.bestBid)
currentBidPrice: currentTopOfBook?.bestBid
? Number.parseFloat(currentTopOfBook.bestBid)
: undefined,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,28 @@ const createMockStreamManager = () => {
account: {
subscribe: jest.fn(() => jest.fn()),
},
topOfBook: {
subscribeToSymbol: ({
symbol: _symbol,
callback,
}: {
symbol: string;
callback: (data: unknown) => void;
}) => {
const id = Math.random().toString();
subscribers.set(id, callback);
// Immediately provide mock top of book data
const mockTopOfBook = {
bestBid: '2999',
bestAsk: '3001',
spread: '2',
};
callback(mockTopOfBook);
return () => {
subscribers.delete(id);
};
},
},
marketData: {
subscribe: jest.fn(() => jest.fn()),
getMarkets: jest.fn(),
Expand Down
19 changes: 14 additions & 5 deletions app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,11 @@ import {
usePerpsToasts,
usePerpsTrading,
} from '../../hooks';
import { usePerpsLiveAccount, usePerpsLivePrices } from '../../hooks/stream';
import {
usePerpsLiveAccount,
usePerpsLivePrices,
usePerpsTopOfBook,
} from '../../hooks/stream';
import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking';
import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement';
import {
Expand Down Expand Up @@ -271,6 +275,11 @@ const PerpsOrderViewContentBase: React.FC = () => {
});
const currentPrice = prices[orderForm.asset];

// Get top of book data for maker/taker fee determination
const currentTopOfBook = usePerpsTopOfBook({
symbol: orderForm.asset,
});

// Track screen load with unified hook
usePerpsMeasurement({
traceName: TraceName.PerpsOrderView,
Expand Down Expand Up @@ -299,11 +308,11 @@ const PerpsOrderViewContentBase: React.FC = () => {
isClosing: false,
limitPrice: orderForm.limitPrice,
direction: orderForm.direction,
currentAskPrice: currentPrice?.bestAsk
? Number.parseFloat(currentPrice.bestAsk)
currentAskPrice: currentTopOfBook?.bestAsk
? Number.parseFloat(currentTopOfBook.bestAsk)
: undefined,
currentBidPrice: currentPrice?.bestBid
? Number.parseFloat(currentPrice.bestBid)
currentBidPrice: currentTopOfBook?.bestBid
? Number.parseFloat(currentTopOfBook.bestBid)
: undefined,
});

Expand Down
6 changes: 6 additions & 0 deletions app/components/UI/Perps/__mocks__/perpsHooksMocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ export const defaultPerpsLivePricesMock = {
BTC: { price: '45000.00', change24h: 1.2 },
};

export const defaultPerpsTopOfBookMock = {
bestBid: '2999.00',
bestAsk: '3001.00',
spread: '2.00',
};

export const defaultPerpsOrderFeesMock = {
totalFee: 45,
protocolFee: 45,
Expand Down
5 changes: 5 additions & 0 deletions app/components/UI/Perps/hooks/stream/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export { usePerpsLiveOrders } from './usePerpsLiveOrders';
export { usePerpsLivePositions } from './usePerpsLivePositions';
export { usePerpsLiveFills } from './usePerpsLiveFills';
export { usePerpsLiveAccount } from './usePerpsLiveAccount';
export { usePerpsTopOfBook } from './usePerpsTopOfBook';

// Export types for convenience
export type { UsePerpsLivePricesOptions } from './usePerpsLivePrices';
Expand All @@ -14,6 +15,10 @@ export type {
UsePerpsLiveAccountOptions,
UsePerpsLiveAccountReturn,
} from './usePerpsLiveAccount';
export type {
UsePerpsTopOfBookOptions,
TopOfBookData,
} from './usePerpsTopOfBook';

// Re-export types from controllers
export type {
Expand Down
9 changes: 7 additions & 2 deletions app/components/UI/Perps/hooks/stream/usePerpsLivePrices.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { usePerpsStream } from '../../providers/PerpsStreamManager';
import type { PriceUpdate } from '../../controllers/types';

Expand Down Expand Up @@ -28,6 +28,9 @@ export function usePerpsLivePrices(
const stream = usePerpsStream();
const [prices, setPrices] = useState<Record<string, PriceUpdate>>({});

// Memoize joined symbols to prevent unnecessary effect re-runs
const symbolsKey = useMemo(() => symbols.join(','), [symbols]);

useEffect(() => {
if (symbols.length === 0) return;

Expand All @@ -45,8 +48,10 @@ export function usePerpsLivePrices(
return () => {
unsubscribe();
};
// symbolsKey captures symbols changes via memoization, so symbols is intentionally omitted
// to prevent re-subscriptions when array reference changes but content is the same
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stream, symbols.join(','), throttleMs]);
}, [stream, symbolsKey, throttleMs]);

return prices;
}
61 changes: 61 additions & 0 deletions app/components/UI/Perps/hooks/stream/usePerpsTopOfBook.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { renderHook } from '@testing-library/react-hooks';
import { usePerpsTopOfBook } from './usePerpsTopOfBook';
import { usePerpsStream } from '../../providers/PerpsStreamManager';

jest.mock('../../providers/PerpsStreamManager');

describe('usePerpsTopOfBook', () => {
const mockUnsubscribe = jest.fn();
const mockSubscribeToSymbol = jest.fn(() => mockUnsubscribe);

beforeEach(() => {
jest.clearAllMocks();
(usePerpsStream as jest.Mock).mockReturnValue({
topOfBook: {
subscribeToSymbol: mockSubscribeToSymbol,
},
});
});

it('subscribes to top of book for provided symbol', () => {
const symbol = 'BTC';

renderHook(() => usePerpsTopOfBook({ symbol }));

expect(mockSubscribeToSymbol).toHaveBeenCalledWith({
symbol,
callback: expect.any(Function),
});
});

it('returns undefined initially', () => {
const { result } = renderHook(() => usePerpsTopOfBook({ symbol: 'BTC' }));

expect(result.current).toBeUndefined();
});

it('does not subscribe when symbol is empty', () => {
renderHook(() => usePerpsTopOfBook({ symbol: '' }));

expect(mockSubscribeToSymbol).not.toHaveBeenCalled();
});

it('calls unsubscribe on unmount', () => {
const { unmount } = renderHook(() => usePerpsTopOfBook({ symbol: 'BTC' }));

unmount();

expect(mockUnsubscribe).toHaveBeenCalled();
});

it('resubscribes when symbol changes', () => {
const { rerender } = renderHook(
({ symbol }) => usePerpsTopOfBook({ symbol }),
{ initialProps: { symbol: 'BTC' } },
);

rerender({ symbol: 'ETH' });

expect(mockSubscribeToSymbol).toHaveBeenCalledTimes(2);
});
});
72 changes: 72 additions & 0 deletions app/components/UI/Perps/hooks/stream/usePerpsTopOfBook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { useEffect, useState } from 'react';
import { usePerpsStream } from '../../providers/PerpsStreamManager';

/**
* Top of book data (best bid/ask only)
*/
export interface TopOfBookData {
bestBid?: string;
bestAsk?: string;
spread?: string;
}

export interface UsePerpsTopOfBookOptions {
/** Symbol to subscribe to top of book data */
symbol: string;
}

/**
* Hook for top of book subscriptions (best bid/ask prices only)
*
* **Important**: Only use this hook when you need top of book data for fee calculations
* (maker/taker determination). Most views should use `usePerpsLivePrices` instead.
*
* Top of book data is used to determine if a limit order will be maker or taker:
* - Limit buy BELOW bestAsk = maker (provides liquidity, 0.015% fee)
* - Limit buy AT/ABOVE bestAsk = taker (removes liquidity, 0.045% fee)
*
* Currently needed by:
* - PerpsOrderView (for fee calculations when placing orders)
* - PerpsClosePositionView (for fee calculations when closing positions)
*
* @param options - Configuration options for the hook
* @returns Top of book data for the specified symbol with real-time updates
*
* @example
* ```typescript
* // In OrderView - need both prices and top of book
* const prices = usePerpsLivePrices({ symbols: ['BTC'] });
* const topOfBook = usePerpsTopOfBook({ symbol: 'BTC' });
*
* const currentPrice = prices['BTC'];
*
* // Use top of book for maker/taker determination
* const isMaker = topOfBook?.bestAsk && limitPrice < Number(topOfBook.bestAsk);
* ```
*/
export function usePerpsTopOfBook(
options: UsePerpsTopOfBookOptions,
): TopOfBookData | undefined {
const { symbol } = options;
const stream = usePerpsStream();
const [topOfBook, setTopOfBook] = useState<TopOfBookData | undefined>(
undefined,
);

useEffect(() => {
if (!symbol) return;

const unsubscribe = stream.topOfBook.subscribeToSymbol({
symbol,
callback: (newTopOfBook) => {
setTopOfBook(newTopOfBook);
},
});

return () => {
unsubscribe();
};
}, [stream, symbol]);

return topOfBook;
}
Loading
Loading