feat(market-depth): additional orderbook grouping levels (#4658)
This commit is contained in:
parent
b5ba4f99d9
commit
1208d3f2a8
@ -1,117 +0,0 @@
|
||||
const orderbookTab = 'Orderbook';
|
||||
const orderbookTable = 'tab-orderbook';
|
||||
const askPrice = 'price-9894185';
|
||||
const bidPrice = 'price-9889001';
|
||||
const askVolume = 'ask-vol-9894185';
|
||||
const bidVolume = 'bid-vol-9889001';
|
||||
const askCumulative = 'cumulative-vol-9894185';
|
||||
const bidCumulative = 'cumulative-vol-9889001';
|
||||
const midPrice = 'last-traded-4612690000';
|
||||
const priceResolution = 'resolution';
|
||||
const dealTicketPrice = 'order-price';
|
||||
const dealTicketSize = 'order-size';
|
||||
const resPrice = 'price-990';
|
||||
|
||||
describe('order book', { tags: '@smoke' }, () => {
|
||||
before(() => {
|
||||
cy.setOnBoardingViewed();
|
||||
cy.mockTradingPage();
|
||||
cy.mockSubscription();
|
||||
cy.visit('/#/markets/market-0');
|
||||
cy.wait('@Markets');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
cy.mockTradingPage();
|
||||
});
|
||||
|
||||
it('show order book', () => {
|
||||
// 6003-ORDB-001
|
||||
// 6003-ORDB-002
|
||||
cy.getByTestId(orderbookTab).click();
|
||||
cy.getByTestId(orderbookTable).should('be.visible');
|
||||
cy.getByTestId(orderbookTable).should('not.be.empty');
|
||||
});
|
||||
|
||||
it('show orders prices', () => {
|
||||
// 6003-ORDB-003
|
||||
cy.getByTestId(askPrice).should('have.text', '98.94185');
|
||||
cy.getByTestId(bidPrice).should('have.text', '98.89001');
|
||||
});
|
||||
|
||||
it('show prices volumes', () => {
|
||||
// 6003-ORDB-004
|
||||
cy.getByTestId(askVolume).should('have.text', '1');
|
||||
cy.getByTestId(bidVolume).should('have.text', '1');
|
||||
});
|
||||
|
||||
it('show prices cumulative volumes', () => {
|
||||
// 6003-ORDB-005
|
||||
cy.getByTestId(askCumulative).should('have.text', '38');
|
||||
cy.getByTestId(bidCumulative).should('have.text', '7');
|
||||
});
|
||||
|
||||
it('show mid price', () => {
|
||||
// 6003-ORDB-006
|
||||
cy.getByTestId(midPrice).should('have.text', '46,126.90');
|
||||
});
|
||||
|
||||
it('sort prices descending', () => {
|
||||
// 6003-ORDB-007
|
||||
const prices: number[] = [];
|
||||
cy.getByTestId(orderbookTable).within(() => {
|
||||
cy.get('[data-testid*=price]')
|
||||
.each(($el) => {
|
||||
prices.push(Number($el.text()));
|
||||
})
|
||||
.then(() => {
|
||||
expect(prices).to.deep.equal(prices.sort((a, b) => b - a));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('copy price to deal ticket form', () => {
|
||||
// 6003-ORDB-009
|
||||
cy.getByTestId(askPrice).click();
|
||||
cy.getByTestId(dealTicketPrice).should('have.value', '98.94185');
|
||||
});
|
||||
|
||||
it('copy size to deal ticket form', () => {
|
||||
// 6003-ORDB-009
|
||||
cy.getByTestId(bidCumulative).click();
|
||||
cy.getByTestId(dealTicketSize).should('have.value', '7');
|
||||
});
|
||||
|
||||
it('copy size to deal ticket form', () => {
|
||||
// 6003-ORDB-009
|
||||
cy.getByTestId(bidVolume).click();
|
||||
cy.getByTestId(dealTicketSize).should('have.value', '1');
|
||||
});
|
||||
|
||||
it('change price resolution', () => {
|
||||
// 6003-ORDB-008
|
||||
const resolutions = [
|
||||
'0.00000',
|
||||
'0.0000',
|
||||
'0.000',
|
||||
'0.00',
|
||||
'0.0',
|
||||
'0',
|
||||
'10',
|
||||
'100',
|
||||
'1,000',
|
||||
'10,000',
|
||||
];
|
||||
cy.getByTestId(priceResolution).click();
|
||||
cy.get('[role="menu"]')
|
||||
.find('[role="menuitem"]')
|
||||
.each(($el, index) => {
|
||||
expect($el.text()).to.equal(resolutions[index]);
|
||||
});
|
||||
|
||||
cy.get('[role="menuitem"]').eq(4).click();
|
||||
cy.getByTestId(resPrice).should('have.text', '99.0');
|
||||
cy.getByTestId(askPrice).should('not.exist');
|
||||
cy.getByTestId(bidPrice).should('not.exist');
|
||||
});
|
||||
});
|
@ -7,7 +7,7 @@ import {
|
||||
TradingDropdownContent,
|
||||
TradingDropdownItem,
|
||||
} from '@vegaprotocol/ui-toolkit';
|
||||
import { formatNumberFixed } from '@vegaprotocol/utils';
|
||||
import { addDecimalsFormatNumber } from '@vegaprotocol/utils';
|
||||
|
||||
export const OrderbookControls = ({
|
||||
lastTradedPrice,
|
||||
@ -15,27 +15,14 @@ export const OrderbookControls = ({
|
||||
decimalPlaces,
|
||||
setResolution,
|
||||
}: {
|
||||
lastTradedPrice: string | undefined;
|
||||
lastTradedPrice: string;
|
||||
resolution: number;
|
||||
decimalPlaces: number;
|
||||
setResolution: (resolution: number) => void;
|
||||
}) => {
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
|
||||
const resolutions = new Array(
|
||||
Math.max(lastTradedPrice?.toString().length ?? 0, decimalPlaces + 1)
|
||||
)
|
||||
.fill(null)
|
||||
.map((v, i) => Math.pow(10, i));
|
||||
|
||||
const formatResolution = (r: number) => {
|
||||
return formatNumberFixed(
|
||||
Math.log10(r) - decimalPlaces > 0
|
||||
? Math.pow(10, Math.log10(r) - decimalPlaces)
|
||||
: 0,
|
||||
decimalPlaces - Math.log10(r)
|
||||
);
|
||||
};
|
||||
const resolutions = createResolutions(lastTradedPrice, decimalPlaces);
|
||||
|
||||
const increaseResolution = () => {
|
||||
const index = resolutions.indexOf(resolution);
|
||||
@ -56,7 +43,7 @@ export const OrderbookControls = ({
|
||||
<button
|
||||
onClick={increaseResolution}
|
||||
disabled={resolutions.indexOf(resolution) >= resolutions.length - 1}
|
||||
className="flex items-center px-2 border-r cursor-pointer border-default"
|
||||
className="flex items-center px-2 border-r cursor-pointer border-default disabled:cursor-default"
|
||||
data-testid="plus-button"
|
||||
>
|
||||
<VegaIcon size={12} name={VegaIconNames.PLUS} />
|
||||
@ -67,12 +54,14 @@ export const OrderbookControls = ({
|
||||
trigger={
|
||||
<TradingDropdownTrigger data-testid="resolution">
|
||||
<button
|
||||
className="flex items-center px-2 text-left gap-1"
|
||||
className="flex items-center justify-between px-2 gap-1"
|
||||
style={{
|
||||
minWidth: `${
|
||||
Math.max.apply(
|
||||
null,
|
||||
resolutions.map((item) => formatResolution(item).length)
|
||||
resolutions.map(
|
||||
(item) => formatResolution(item, decimalPlaces).length
|
||||
)
|
||||
) + 5
|
||||
}ch`,
|
||||
}}
|
||||
@ -83,15 +72,19 @@ export const OrderbookControls = ({
|
||||
isOpen ? VegaIconNames.CHEVRON_UP : VegaIconNames.CHEVRON_DOWN
|
||||
}
|
||||
/>
|
||||
{formatResolution(resolution)}
|
||||
{formatResolution(resolution, decimalPlaces)}
|
||||
</button>
|
||||
</TradingDropdownTrigger>
|
||||
}
|
||||
>
|
||||
<TradingDropdownContent align="start">
|
||||
{resolutions.map((r) => (
|
||||
<TradingDropdownItem key={r} onClick={() => setResolution(r)}>
|
||||
{formatResolution(r)}
|
||||
<TradingDropdownItem
|
||||
key={r}
|
||||
onClick={() => setResolution(r)}
|
||||
className="justify-end"
|
||||
>
|
||||
{formatResolution(r, decimalPlaces)}
|
||||
</TradingDropdownItem>
|
||||
))}
|
||||
</TradingDropdownContent>
|
||||
@ -99,7 +92,7 @@ export const OrderbookControls = ({
|
||||
<button
|
||||
onClick={decreaseResolution}
|
||||
disabled={resolutions.indexOf(resolution) <= 0}
|
||||
className="flex items-center px-2 cursor-pointer border-x border-default"
|
||||
className="flex items-center px-2 cursor-pointer border-x border-default disabled:cursor-default"
|
||||
data-testid="minus-button"
|
||||
>
|
||||
<VegaIcon size={12} name={VegaIconNames.MINUS} />
|
||||
@ -107,3 +100,49 @@ export const OrderbookControls = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const formatResolution = (r: number, decimalPlaces: number) => {
|
||||
let num = addDecimalsFormatNumber(r, decimalPlaces);
|
||||
|
||||
// Remove trailing zeroes
|
||||
num = num.replace(/\.?0+$/, '');
|
||||
|
||||
return num;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a list of resolutions based on the largest and smallest
|
||||
* possible values using the last traded price and the market
|
||||
* decimal places
|
||||
*/
|
||||
export const createResolutions = (
|
||||
lastTradedPrice: string,
|
||||
decimalPlaces: number
|
||||
) => {
|
||||
// number of levels determined by either the number
|
||||
// of digits in the last traded price OR the number of decimal
|
||||
// places. For example:
|
||||
//
|
||||
// last traded = 1 (0.001)
|
||||
// dps = 3
|
||||
// result = 3
|
||||
//
|
||||
// last traded = 100001 (1000.01
|
||||
// dps = 2
|
||||
// result = 6
|
||||
const levelCount = Math.max(lastTradedPrice.length ?? 0, decimalPlaces + 1);
|
||||
const generatedResolutions = new Array(levelCount)
|
||||
.fill(null)
|
||||
.map((_, i) => Math.pow(10, i));
|
||||
const customResolutions = [2, 5, 20, 50, 200, 500];
|
||||
const combined = customResolutions.concat(generatedResolutions);
|
||||
combined.sort((a, b) => a - b);
|
||||
|
||||
// Remove any resolutions higher than the generated ones as
|
||||
// we dont want a custom resolution higher than necessary
|
||||
const resolutions = combined.filter((r) => {
|
||||
return r <= generatedResolutions[generatedResolutions.length - 1];
|
||||
});
|
||||
|
||||
return resolutions;
|
||||
};
|
||||
|
@ -12,7 +12,7 @@ export interface OrderbookRowData {
|
||||
cumulativeVol: number;
|
||||
}
|
||||
|
||||
export const getPriceLevel = (price: string | bigint, resolution: number) => {
|
||||
export const getPriceLevel = (price: string, resolution: number) => {
|
||||
const p = BigInt(price);
|
||||
const r = BigInt(resolution);
|
||||
let priceLevel = (p / r) * r;
|
||||
@ -43,7 +43,7 @@ const updateCumulativeVolumeByType = (
|
||||
};
|
||||
|
||||
export const compactRows = (
|
||||
data: PriceLevelFieldsFragment[] | null | undefined,
|
||||
data: PriceLevelFieldsFragment[],
|
||||
dataType: VolumeType,
|
||||
resolution: number
|
||||
) => {
|
||||
|
@ -13,6 +13,7 @@ interface OrderbookRowProps {
|
||||
cumulativeVolume: number;
|
||||
decimalPlaces: number;
|
||||
positionDecimalPlaces: number;
|
||||
priceFormatDecimalPlaces: number;
|
||||
price: string;
|
||||
onClick: (args: { price?: string; size?: string }) => void;
|
||||
type: VolumeType;
|
||||
@ -26,6 +27,7 @@ export const OrderbookRow = memo(
|
||||
cumulativeVolume,
|
||||
decimalPlaces,
|
||||
positionDecimalPlaces,
|
||||
priceFormatDecimalPlaces,
|
||||
price,
|
||||
onClick,
|
||||
type,
|
||||
@ -35,6 +37,7 @@ export const OrderbookRow = memo(
|
||||
const txtId = type === VolumeType.bid ? 'bid' : 'ask';
|
||||
const cols =
|
||||
width >= HIDE_CUMULATIVE_VOL_WIDTH ? 3 : width >= HIDE_VOL_WIDTH ? 2 : 1;
|
||||
|
||||
return (
|
||||
<div className="relative px-1">
|
||||
<CumulationBar
|
||||
@ -54,7 +57,8 @@ export const OrderbookRow = memo(
|
||||
value={BigInt(price)}
|
||||
valueFormatted={addDecimalsFixedFormatNumber(
|
||||
price,
|
||||
decimalPlaces
|
||||
decimalPlaces,
|
||||
priceFormatDecimalPlaces
|
||||
)}
|
||||
className={classNames({
|
||||
'text-market-red dark:text-market-red': type === VolumeType.ask,
|
||||
|
@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { generateMockData, VolumeType } from './orderbook-data';
|
||||
import { Orderbook, OrderbookMid } from './orderbook';
|
||||
import * as orderbookData from './orderbook-data';
|
||||
import { createResolutions, formatResolution } from './orderbook-controls';
|
||||
|
||||
function mockOffsetSize(width: number, height: number) {
|
||||
Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', {
|
||||
@ -37,7 +38,8 @@ describe('Orderbook', () => {
|
||||
jest.clearAllMocks();
|
||||
mockOffsetSize(800, 768);
|
||||
});
|
||||
it('markPrice should be in the middle', async () => {
|
||||
|
||||
it('lastTradedPrice should be in the middle', async () => {
|
||||
render(
|
||||
<Orderbook
|
||||
decimalPlaces={decimalPlaces}
|
||||
@ -71,6 +73,7 @@ describe('Orderbook', () => {
|
||||
expect(
|
||||
await screen.findByTestId(`last-traded-${params.lastTradedPrice}`)
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Before resolution change the price is 122.934
|
||||
await userEvent.click(screen.getByTestId('price-122901'));
|
||||
expect(onClickSpy).toBeCalledWith({ price: '122.901' });
|
||||
@ -86,15 +89,16 @@ describe('Orderbook', () => {
|
||||
expect(orderbookData.compactRows).toHaveBeenCalledWith(
|
||||
mockedData.bids,
|
||||
VolumeType.bid,
|
||||
10
|
||||
2
|
||||
);
|
||||
expect(orderbookData.compactRows).toHaveBeenCalledWith(
|
||||
mockedData.asks,
|
||||
VolumeType.ask,
|
||||
10
|
||||
2
|
||||
);
|
||||
await userEvent.click(screen.getByTestId('price-12294'));
|
||||
expect(onClickSpy).toBeCalledWith({ price: '122.94' });
|
||||
|
||||
await userEvent.click(screen.getByTestId('price-122938'));
|
||||
expect(onClickSpy).toBeCalledWith({ price: '122.938' });
|
||||
});
|
||||
|
||||
it('plus - minus buttons should change resolution', async () => {
|
||||
@ -114,26 +118,30 @@ describe('Orderbook', () => {
|
||||
1
|
||||
);
|
||||
expect(screen.getByTestId('minus-button')).toBeDisabled();
|
||||
userEvent.click(screen.getByTestId('plus-button'));
|
||||
await userEvent.click(screen.getByTestId('plus-button'));
|
||||
expect((orderbookData.compactRows as jest.Mock).mock.lastCall[2]).toEqual(
|
||||
2
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByTestId('plus-button'));
|
||||
expect((orderbookData.compactRows as jest.Mock).mock.lastCall[2]).toEqual(
|
||||
5
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('minus-button')).not.toBeDisabled();
|
||||
await userEvent.click(screen.getByTestId('minus-button'));
|
||||
await waitFor(() => {
|
||||
expect((orderbookData.compactRows as jest.Mock).mock.lastCall[2]).toEqual(
|
||||
10
|
||||
2
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('minus-button')).not.toBeDisabled();
|
||||
userEvent.click(screen.getByTestId('minus-button'));
|
||||
await waitFor(() => {
|
||||
expect((orderbookData.compactRows as jest.Mock).mock.lastCall[2]).toEqual(
|
||||
1
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('minus-button')).toBeDisabled();
|
||||
await userEvent.click(screen.getByTestId('resolution'));
|
||||
|
||||
await userEvent.click(screen.getByTestId('resolution'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('menu')).toBeInTheDocument();
|
||||
});
|
||||
await userEvent.click(screen.getAllByRole('menuitem')[5]);
|
||||
await userEvent.click(screen.getAllByRole('menuitem')[11]);
|
||||
await waitFor(() => {
|
||||
expect((orderbookData.compactRows as jest.Mock).mock.lastCall[2]).toEqual(
|
||||
100000
|
||||
@ -223,3 +231,58 @@ describe('OrderbookMid', () => {
|
||||
expect(screen.getByTestId('icon-arrow-down')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createResolutions', () => {
|
||||
it('create resolutions relative to the market', () => {
|
||||
expect(
|
||||
createResolutions(
|
||||
'1', // 0.001
|
||||
3
|
||||
)
|
||||
).toEqual([1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]);
|
||||
|
||||
expect(
|
||||
createResolutions(
|
||||
'190017', // 1900.17
|
||||
2
|
||||
)
|
||||
).toEqual([1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 10000, 100000]);
|
||||
|
||||
expect(
|
||||
createResolutions(
|
||||
'123456789', // 1234.56789
|
||||
5
|
||||
)
|
||||
).toEqual([
|
||||
1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 10000, 100000, 1000000,
|
||||
10000000, 100000000,
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes resolutions that arent precise enough for the market', () => {
|
||||
expect(
|
||||
createResolutions(
|
||||
'1', // 0.01
|
||||
2
|
||||
)
|
||||
).toEqual([1, 2, 5, 10, 20, 50, 100]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatResolution', () => {
|
||||
it('formats less than 1', () => {
|
||||
expect(formatResolution(1, 2)).toEqual('0.01');
|
||||
expect(formatResolution(1, 3)).toEqual('0.001');
|
||||
expect(formatResolution(2, 4)).toEqual('0.0002');
|
||||
expect(formatResolution(5, 8)).toEqual('0.00000005');
|
||||
expect(formatResolution(10000, 5)).toEqual('0.1');
|
||||
});
|
||||
|
||||
it('formats greater than 1', () => {
|
||||
expect(formatResolution(1000, 2)).toEqual('10');
|
||||
expect(formatResolution(100000, 4)).toEqual('10');
|
||||
expect(formatResolution(10000000, 2)).toEqual('100,000');
|
||||
expect(formatResolution(500, 2)).toEqual('5');
|
||||
expect(formatResolution(500, 1)).toEqual('50');
|
||||
});
|
||||
});
|
||||
|
@ -23,6 +23,7 @@ const OrderbookSide = ({
|
||||
type,
|
||||
decimalPlaces,
|
||||
positionDecimalPlaces,
|
||||
priceFormatDecimalPlaces,
|
||||
onClick,
|
||||
width,
|
||||
maxVol,
|
||||
@ -31,6 +32,7 @@ const OrderbookSide = ({
|
||||
resolution: number;
|
||||
decimalPlaces: number;
|
||||
positionDecimalPlaces: number;
|
||||
priceFormatDecimalPlaces: number;
|
||||
type: VolumeType;
|
||||
onClick: (args: { price?: string; size?: string }) => void;
|
||||
width: number;
|
||||
@ -53,10 +55,11 @@ const OrderbookSide = ({
|
||||
{rows.map((data) => (
|
||||
<OrderbookRow
|
||||
key={data.price}
|
||||
price={(BigInt(data.price) / BigInt(resolution)).toString()}
|
||||
price={data.price}
|
||||
onClick={onClick}
|
||||
decimalPlaces={decimalPlaces - Math.log10(resolution)}
|
||||
decimalPlaces={decimalPlaces}
|
||||
positionDecimalPlaces={positionDecimalPlaces}
|
||||
priceFormatDecimalPlaces={priceFormatDecimalPlaces}
|
||||
volume={data.volume}
|
||||
cumulativeVolume={data.cumulativeVol}
|
||||
type={type}
|
||||
@ -165,6 +168,12 @@ export const Orderbook = ({
|
||||
const bestAskPrice = asks[0] ? asks[0].price : '0';
|
||||
const bestBidPrice = bids[0] ? bids[0].price : '0';
|
||||
|
||||
// we'll want to only display a relevant number of dps based on the
|
||||
// current resolution selection
|
||||
const priceFormatDecimalPlaces = Math.ceil(
|
||||
decimalPlaces - Math.log10(resolution)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full text-xs grid grid-rows-[1fr_min-content]">
|
||||
<div>
|
||||
@ -203,6 +212,7 @@ export const Orderbook = ({
|
||||
resolution={resolution}
|
||||
decimalPlaces={decimalPlaces}
|
||||
positionDecimalPlaces={positionDecimalPlaces}
|
||||
priceFormatDecimalPlaces={priceFormatDecimalPlaces}
|
||||
onClick={onClick}
|
||||
width={width}
|
||||
maxVol={maxVol}
|
||||
@ -220,6 +230,7 @@ export const Orderbook = ({
|
||||
resolution={resolution}
|
||||
decimalPlaces={decimalPlaces}
|
||||
positionDecimalPlaces={positionDecimalPlaces}
|
||||
priceFormatDecimalPlaces={priceFormatDecimalPlaces}
|
||||
onClick={onClick}
|
||||
width={width}
|
||||
maxVol={maxVol}
|
||||
|
Loading…
Reference in New Issue
Block a user