feat(trading): 3945 orderbook enhancements (#4016)
Co-authored-by: Bartłomiej Głownia <bglownia@gmail.com> Co-authored-by: Matthew Russell <mattrussell36@gmail.com>
This commit is contained in:
parent
aae5c44fa4
commit
43d3754c64
@ -296,7 +296,7 @@ const MainGrid = memo(
|
|||||||
</TradeGridChild>
|
</TradeGridChild>
|
||||||
</ResizableGridPanel>
|
</ResizableGridPanel>
|
||||||
<ResizableGridPanel
|
<ResizableGridPanel
|
||||||
preferredSize={sizesMiddle[2] || 430}
|
preferredSize={sizesMiddle[2] || 300}
|
||||||
minSize={200}
|
minSize={200}
|
||||||
>
|
>
|
||||||
<TradeGridChild>
|
<TradeGridChild>
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
import { forwardRef } from 'react';
|
import { forwardRef } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
import { getDecimalSeparator, isNumeric } from '@vegaprotocol/utils';
|
import { getDecimalSeparator, isNumeric } from '@vegaprotocol/utils';
|
||||||
|
|
||||||
interface NumericCellProps {
|
interface NumericCellProps {
|
||||||
value: number | bigint | null | undefined;
|
value: number | bigint | null | undefined;
|
||||||
valueFormatted: string;
|
valueFormatted: string;
|
||||||
testId?: string;
|
testId?: string;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -12,7 +14,7 @@ interface NumericCellProps {
|
|||||||
* use, right aligned, monospace and decimals deemphasised
|
* use, right aligned, monospace and decimals deemphasised
|
||||||
*/
|
*/
|
||||||
export const NumericCell = forwardRef<HTMLSpanElement, NumericCellProps>(
|
export const NumericCell = forwardRef<HTMLSpanElement, NumericCellProps>(
|
||||||
({ value, valueFormatted, testId }, ref) => {
|
({ value, valueFormatted, testId, className }, ref) => {
|
||||||
if (!isNumeric(value)) {
|
if (!isNumeric(value)) {
|
||||||
return (
|
return (
|
||||||
<span ref={ref} data-testid={testId}>
|
<span ref={ref} data-testid={testId}>
|
||||||
@ -29,7 +31,10 @@ export const NumericCell = forwardRef<HTMLSpanElement, NumericCellProps>(
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="font-mono relative text-black dark:text-white whitespace-nowrap overflow-hidden text-ellipsis text-right rtl-dir"
|
className={classNames(
|
||||||
|
'font-mono relative text-black dark:text-white whitespace-nowrap overflow-hidden text-ellipsis text-right rtl-dir',
|
||||||
|
className
|
||||||
|
)}
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
title={valueFormatted}
|
title={valueFormatted}
|
||||||
>
|
>
|
||||||
|
@ -16,7 +16,7 @@ export const OrderTypeCell = ({
|
|||||||
data: order,
|
data: order,
|
||||||
onClick,
|
onClick,
|
||||||
}: OrderTypeCellProps) => {
|
}: OrderTypeCellProps) => {
|
||||||
const id = order ? order.market.id : '';
|
const id = order?.market?.id ?? '';
|
||||||
|
|
||||||
const label = useMemo(() => {
|
const label = useMemo(() => {
|
||||||
if (!order) {
|
if (!order) {
|
||||||
|
@ -6,11 +6,15 @@ export interface IPriceCellProps {
|
|||||||
valueFormatted: string;
|
valueFormatted: string;
|
||||||
testId?: string;
|
testId?: string;
|
||||||
onClick?: (price?: string | number) => void;
|
onClick?: (price?: string | number) => void;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PriceCell = memo(
|
export const PriceCell = memo(
|
||||||
forwardRef<HTMLSpanElement, IPriceCellProps>(
|
forwardRef<HTMLSpanElement, IPriceCellProps>(
|
||||||
({ value, valueFormatted, testId, onClick }: IPriceCellProps, ref) => {
|
(
|
||||||
|
{ value, valueFormatted, testId, onClick, className }: IPriceCellProps,
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
if (!isNumeric(value)) {
|
if (!isNumeric(value)) {
|
||||||
return (
|
return (
|
||||||
<span data-testid="price" ref={ref}>
|
<span data-testid="price" ref={ref}>
|
||||||
@ -27,6 +31,7 @@ export const PriceCell = memo(
|
|||||||
value={value}
|
value={value}
|
||||||
valueFormatted={valueFormatted}
|
valueFormatted={valueFormatted}
|
||||||
testId={testId || 'price'}
|
testId={testId || 'price'}
|
||||||
|
className={className}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
|
@ -1,9 +1,4 @@
|
|||||||
import {
|
import { compactRows, updateLevels, VolumeType } from './orderbook-data';
|
||||||
compactRows,
|
|
||||||
updateLevels,
|
|
||||||
updateCompactedRows,
|
|
||||||
} from './orderbook-data';
|
|
||||||
import type { OrderbookRowData } from './orderbook-data';
|
|
||||||
import type { PriceLevelFieldsFragment } from './__generated__/MarketDepth';
|
import type { PriceLevelFieldsFragment } from './__generated__/MarketDepth';
|
||||||
|
|
||||||
describe('compactRows', () => {
|
describe('compactRows', () => {
|
||||||
@ -26,51 +21,31 @@ describe('compactRows', () => {
|
|||||||
numberOfOrders: (numberOfRows - i).toString(),
|
numberOfOrders: (numberOfRows - i).toString(),
|
||||||
}));
|
}));
|
||||||
it('groups data by price and resolution', () => {
|
it('groups data by price and resolution', () => {
|
||||||
expect(compactRows(sell, buy, 1).length).toEqual(200);
|
expect(compactRows(sell, VolumeType.ask, 1).length).toEqual(100);
|
||||||
expect(compactRows(sell, buy, 5).length).toEqual(41);
|
expect(compactRows(buy, VolumeType.bid, 1).length).toEqual(100);
|
||||||
expect(compactRows(sell, buy, 10).length).toEqual(21);
|
expect(compactRows(sell, VolumeType.ask, 5).length).toEqual(21);
|
||||||
|
expect(compactRows(buy, VolumeType.bid, 5).length).toEqual(21);
|
||||||
|
expect(compactRows(sell, VolumeType.ask, 10).length).toEqual(11);
|
||||||
|
expect(compactRows(buy, VolumeType.bid, 10).length).toEqual(11);
|
||||||
});
|
});
|
||||||
it('counts cumulative vol', () => {
|
it('counts cumulative vol', () => {
|
||||||
const orderbookRows = compactRows(sell, buy, 10);
|
const asks = compactRows(sell, VolumeType.ask, 10);
|
||||||
expect(orderbookRows[0].cumulativeVol.ask).toEqual(4950);
|
const bids = compactRows(buy, VolumeType.bid, 10);
|
||||||
expect(orderbookRows[0].cumulativeVol.bid).toEqual(0);
|
expect(asks[0].cumulativeVol.value).toEqual(4950);
|
||||||
expect(orderbookRows[10].cumulativeVol.ask).toEqual(390);
|
expect(bids[0].cumulativeVol.value).toEqual(579);
|
||||||
expect(orderbookRows[10].cumulativeVol.bid).toEqual(579);
|
expect(asks[10].cumulativeVol.value).toEqual(390);
|
||||||
expect(orderbookRows[orderbookRows.length - 1].cumulativeVol.bid).toEqual(
|
expect(bids[10].cumulativeVol.value).toEqual(4950);
|
||||||
4950
|
expect(bids[bids.length - 1].cumulativeVol.value).toEqual(4950);
|
||||||
);
|
expect(asks[asks.length - 1].cumulativeVol.value).toEqual(390);
|
||||||
expect(orderbookRows[orderbookRows.length - 1].cumulativeVol.ask).toEqual(
|
|
||||||
0
|
|
||||||
);
|
|
||||||
});
|
|
||||||
it('stores volume by level', () => {
|
|
||||||
const orderbookRows = compactRows(sell, buy, 10);
|
|
||||||
expect(orderbookRows[0].askByLevel).toEqual({
|
|
||||||
'1095': 5,
|
|
||||||
'1096': 4,
|
|
||||||
'1097': 3,
|
|
||||||
'1098': 2,
|
|
||||||
'1099': 1,
|
|
||||||
});
|
|
||||||
expect(orderbookRows[orderbookRows.length - 1].bidByLevel).toEqual({
|
|
||||||
'902': 1,
|
|
||||||
'903': 2,
|
|
||||||
'904': 3,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates relative data', () => {
|
it('updates relative data', () => {
|
||||||
const orderbookRows = compactRows(sell, buy, 10);
|
const asks = compactRows(sell, VolumeType.ask, 10);
|
||||||
expect(orderbookRows[0].cumulativeVol.relativeAsk).toEqual(100);
|
const bids = compactRows(buy, VolumeType.bid, 10);
|
||||||
expect(orderbookRows[0].cumulativeVol.relativeBid).toEqual(0);
|
expect(asks[0].cumulativeVol.relativeValue).toEqual(100);
|
||||||
expect(orderbookRows[0].relativeAsk).toEqual(2);
|
expect(bids[0].cumulativeVol.relativeValue).toEqual(12);
|
||||||
expect(orderbookRows[0].relativeBid).toEqual(0);
|
expect(asks[10].cumulativeVol.relativeValue).toEqual(8);
|
||||||
expect(orderbookRows[10].cumulativeVol.relativeAsk).toEqual(8);
|
expect(bids[10].cumulativeVol.relativeValue).toEqual(100);
|
||||||
expect(orderbookRows[10].cumulativeVol.relativeBid).toEqual(12);
|
|
||||||
expect(orderbookRows[10].relativeAsk).toEqual(44);
|
|
||||||
expect(orderbookRows[10].relativeBid).toEqual(64);
|
|
||||||
expect(orderbookRows[orderbookRows.length - 1].relativeAsk).toEqual(0);
|
|
||||||
expect(orderbookRows[orderbookRows.length - 1].relativeBid).toEqual(1);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -130,167 +105,3 @@ describe('updateLevels', () => {
|
|||||||
expect(updateLevels([], [updateLastRow])).toEqual([updateLastRow]);
|
expect(updateLevels([], [updateLastRow])).toEqual([updateLastRow]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('updateCompactedRows', () => {
|
|
||||||
const orderbookRows: OrderbookRowData[] = [
|
|
||||||
{
|
|
||||||
price: '120',
|
|
||||||
cumulativeVol: {
|
|
||||||
ask: 50,
|
|
||||||
relativeAsk: 100,
|
|
||||||
bid: 0,
|
|
||||||
relativeBid: 0,
|
|
||||||
},
|
|
||||||
askByLevel: {
|
|
||||||
'121': 10,
|
|
||||||
},
|
|
||||||
bidByLevel: {},
|
|
||||||
ask: 10,
|
|
||||||
bid: 0,
|
|
||||||
relativeAsk: 25,
|
|
||||||
relativeBid: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
price: '100',
|
|
||||||
cumulativeVol: {
|
|
||||||
ask: 40,
|
|
||||||
relativeAsk: 80,
|
|
||||||
bid: 40,
|
|
||||||
relativeBid: 80,
|
|
||||||
},
|
|
||||||
askByLevel: {
|
|
||||||
'101': 10,
|
|
||||||
'102': 30,
|
|
||||||
},
|
|
||||||
bidByLevel: {
|
|
||||||
'99': 10,
|
|
||||||
'98': 30,
|
|
||||||
},
|
|
||||||
ask: 40,
|
|
||||||
bid: 40,
|
|
||||||
relativeAsk: 100,
|
|
||||||
relativeBid: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
price: '80',
|
|
||||||
cumulativeVol: {
|
|
||||||
ask: 0,
|
|
||||||
relativeAsk: 0,
|
|
||||||
bid: 50,
|
|
||||||
relativeBid: 100,
|
|
||||||
},
|
|
||||||
askByLevel: {},
|
|
||||||
bidByLevel: {
|
|
||||||
'79': 10,
|
|
||||||
},
|
|
||||||
ask: 0,
|
|
||||||
bid: 10,
|
|
||||||
relativeAsk: 0,
|
|
||||||
relativeBid: 25,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const resolution = 10;
|
|
||||||
|
|
||||||
it('update volume', () => {
|
|
||||||
const sell: PriceLevelFieldsFragment = {
|
|
||||||
__typename: 'PriceLevel',
|
|
||||||
price: '120',
|
|
||||||
volume: '10',
|
|
||||||
numberOfOrders: '10',
|
|
||||||
};
|
|
||||||
const buy: PriceLevelFieldsFragment = {
|
|
||||||
__typename: 'PriceLevel',
|
|
||||||
price: '80',
|
|
||||||
volume: '10',
|
|
||||||
numberOfOrders: '10',
|
|
||||||
};
|
|
||||||
const updatedRows = updateCompactedRows(
|
|
||||||
orderbookRows,
|
|
||||||
[sell],
|
|
||||||
[buy],
|
|
||||||
resolution
|
|
||||||
);
|
|
||||||
expect(updatedRows[0].ask).toEqual(20);
|
|
||||||
expect(updatedRows[0].askByLevel?.[120]).toEqual(10);
|
|
||||||
expect(updatedRows[0].cumulativeVol.ask).toEqual(60);
|
|
||||||
expect(updatedRows[2].bid).toEqual(20);
|
|
||||||
expect(updatedRows[2].bidByLevel?.[80]).toEqual(10);
|
|
||||||
expect(updatedRows[2].cumulativeVol.bid).toEqual(60);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('remove row', () => {
|
|
||||||
const sell: PriceLevelFieldsFragment = {
|
|
||||||
__typename: 'PriceLevel',
|
|
||||||
price: '121',
|
|
||||||
volume: '0',
|
|
||||||
numberOfOrders: '0',
|
|
||||||
};
|
|
||||||
const buy: PriceLevelFieldsFragment = {
|
|
||||||
__typename: 'PriceLevel',
|
|
||||||
price: '79',
|
|
||||||
volume: '0',
|
|
||||||
numberOfOrders: '0',
|
|
||||||
};
|
|
||||||
const updatedRows = updateCompactedRows(
|
|
||||||
orderbookRows,
|
|
||||||
[sell],
|
|
||||||
[buy],
|
|
||||||
resolution
|
|
||||||
);
|
|
||||||
expect(updatedRows.length).toEqual(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('add new row at the end', () => {
|
|
||||||
const sell: PriceLevelFieldsFragment = {
|
|
||||||
__typename: 'PriceLevel',
|
|
||||||
price: '131',
|
|
||||||
volume: '5',
|
|
||||||
numberOfOrders: '5',
|
|
||||||
};
|
|
||||||
const buy: PriceLevelFieldsFragment = {
|
|
||||||
__typename: 'PriceLevel',
|
|
||||||
price: '59',
|
|
||||||
volume: '5',
|
|
||||||
numberOfOrders: '5',
|
|
||||||
};
|
|
||||||
const updatedRows = updateCompactedRows(
|
|
||||||
orderbookRows,
|
|
||||||
[sell],
|
|
||||||
[buy],
|
|
||||||
resolution
|
|
||||||
);
|
|
||||||
expect(updatedRows.length).toEqual(5);
|
|
||||||
expect(updatedRows[0].price).toEqual('130');
|
|
||||||
expect(updatedRows[0].cumulativeVol.ask).toEqual(55);
|
|
||||||
expect(updatedRows[4].price).toEqual('60');
|
|
||||||
expect(updatedRows[4].cumulativeVol.bid).toEqual(55);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('add new row in the middle', () => {
|
|
||||||
const sell: PriceLevelFieldsFragment = {
|
|
||||||
__typename: 'PriceLevel',
|
|
||||||
price: '111',
|
|
||||||
volume: '5',
|
|
||||||
numberOfOrders: '5',
|
|
||||||
};
|
|
||||||
const buy: PriceLevelFieldsFragment = {
|
|
||||||
__typename: 'PriceLevel',
|
|
||||||
price: '91',
|
|
||||||
volume: '5',
|
|
||||||
numberOfOrders: '5',
|
|
||||||
};
|
|
||||||
const updatedRows = updateCompactedRows(
|
|
||||||
orderbookRows,
|
|
||||||
[sell],
|
|
||||||
[buy],
|
|
||||||
resolution
|
|
||||||
);
|
|
||||||
expect(updatedRows.length).toEqual(5);
|
|
||||||
expect(updatedRows[1].price).toEqual('110');
|
|
||||||
expect(updatedRows[1].cumulativeVol.ask).toEqual(45);
|
|
||||||
expect(updatedRows[0].cumulativeVol.ask).toEqual(55);
|
|
||||||
expect(updatedRows[3].price).toEqual('90');
|
|
||||||
expect(updatedRows[3].cumulativeVol.bid).toEqual(45);
|
|
||||||
expect(updatedRows[4].cumulativeVol.bid).toEqual(55);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
@ -1,9 +1,4 @@
|
|||||||
import groupBy from 'lodash/groupBy';
|
import groupBy from 'lodash/groupBy';
|
||||||
import uniqBy from 'lodash/uniqBy';
|
|
||||||
import reverse from 'lodash/reverse';
|
|
||||||
import cloneDeep from 'lodash/cloneDeep';
|
|
||||||
import * as Schema from '@vegaprotocol/types';
|
|
||||||
import type { MarketData } from '@vegaprotocol/markets';
|
|
||||||
import type { PriceLevelFieldsFragment } from './__generated__/MarketDepth';
|
import type { PriceLevelFieldsFragment } from './__generated__/MarketDepth';
|
||||||
|
|
||||||
export enum VolumeType {
|
export enum VolumeType {
|
||||||
@ -11,39 +6,16 @@ export enum VolumeType {
|
|||||||
ask,
|
ask,
|
||||||
}
|
}
|
||||||
export interface CumulativeVol {
|
export interface CumulativeVol {
|
||||||
bid: number;
|
value: number;
|
||||||
relativeBid?: number;
|
relativeValue?: number;
|
||||||
ask: number;
|
|
||||||
relativeAsk?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrderbookRowData {
|
export interface OrderbookRowData {
|
||||||
price: string;
|
price: string;
|
||||||
bid: number;
|
value: number;
|
||||||
bidByLevel: Record<string, number>;
|
|
||||||
relativeBid?: number;
|
|
||||||
ask: number;
|
|
||||||
askByLevel: Record<string, number>;
|
|
||||||
relativeAsk?: number;
|
|
||||||
cumulativeVol: CumulativeVol;
|
cumulativeVol: CumulativeVol;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PartialOrderbookRowData = Pick<OrderbookRowData, 'price' | 'ask' | 'bid'>;
|
|
||||||
|
|
||||||
type OrderbookMarketData = Pick<
|
|
||||||
MarketData,
|
|
||||||
| 'bestStaticBidPrice'
|
|
||||||
| 'bestStaticOfferPrice'
|
|
||||||
| 'indicativePrice'
|
|
||||||
| 'indicativeVolume'
|
|
||||||
| 'marketTradingMode'
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type OrderbookData = Partial<OrderbookMarketData> & {
|
|
||||||
rows: OrderbookRowData[] | null;
|
|
||||||
midPrice?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getPriceLevel = (price: string | bigint, resolution: number) => {
|
export const getPriceLevel = (price: string | bigint, resolution: number) => {
|
||||||
const p = BigInt(price);
|
const p = BigInt(price);
|
||||||
const r = BigInt(resolution);
|
const r = BigInt(resolution);
|
||||||
@ -54,135 +26,66 @@ export const getPriceLevel = (price: string | bigint, resolution: number) => {
|
|||||||
return priceLevel.toString();
|
return priceLevel.toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getMidPrice = (
|
|
||||||
sell: PriceLevelFieldsFragment[] | null | undefined,
|
|
||||||
buy: PriceLevelFieldsFragment[] | null | undefined,
|
|
||||||
resolution: number
|
|
||||||
) =>
|
|
||||||
buy?.length && sell?.length
|
|
||||||
? getPriceLevel(
|
|
||||||
(BigInt(buy[0].price) + BigInt(sell[0].price)) / BigInt(2),
|
|
||||||
resolution
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const getMaxVolumes = (orderbookData: OrderbookRowData[]) => ({
|
const getMaxVolumes = (orderbookData: OrderbookRowData[]) => ({
|
||||||
bid: Math.max(...orderbookData.map((data) => data.bid)),
|
|
||||||
ask: Math.max(...orderbookData.map((data) => data.ask)),
|
|
||||||
cumulativeVol: Math.max(
|
cumulativeVol: Math.max(
|
||||||
orderbookData[0]?.cumulativeVol.ask,
|
orderbookData[0]?.cumulativeVol.value,
|
||||||
orderbookData[orderbookData.length - 1]?.cumulativeVol.bid
|
orderbookData[orderbookData.length - 1]?.cumulativeVol.value
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
// round instead of ceil so we will not show 0 if value if different than 0
|
// round instead of ceil so we will not show 0 if value if different than 0
|
||||||
const toPercentValue = (value?: number) => Math.ceil((value ?? 0) * 100);
|
const toPercentValue = (value?: number) => Math.ceil((value ?? 0) * 100);
|
||||||
|
|
||||||
/**
|
|
||||||
* @summary Updates relativeAsk, relativeBid, cumulativeVol.relativeAsk, cumulativeVol.relativeBid
|
|
||||||
*/
|
|
||||||
const updateRelativeData = (data: OrderbookRowData[]) => {
|
const updateRelativeData = (data: OrderbookRowData[]) => {
|
||||||
const { bid, ask, cumulativeVol } = getMaxVolumes(data);
|
const { cumulativeVol } = getMaxVolumes(data);
|
||||||
const maxBidAsk = Math.max(bid, ask);
|
|
||||||
data.forEach((data, i) => {
|
data.forEach((data, i) => {
|
||||||
data.relativeAsk = toPercentValue(data.ask / maxBidAsk);
|
data.cumulativeVol.relativeValue = toPercentValue(
|
||||||
data.relativeBid = toPercentValue(data.bid / maxBidAsk);
|
data.cumulativeVol.value / cumulativeVol
|
||||||
data.cumulativeVol.relativeAsk = toPercentValue(
|
|
||||||
data.cumulativeVol.ask / cumulativeVol
|
|
||||||
);
|
|
||||||
data.cumulativeVol.relativeBid = toPercentValue(
|
|
||||||
data.cumulativeVol.bid / cumulativeVol
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateCumulativeVolume = (data: OrderbookRowData[]) => {
|
const updateCumulativeVolumeByType = (
|
||||||
if (data.length > 1) {
|
data: OrderbookRowData[],
|
||||||
|
dataType: VolumeType
|
||||||
|
) => {
|
||||||
|
if (data.length) {
|
||||||
const maxIndex = data.length - 1;
|
const maxIndex = data.length - 1;
|
||||||
|
if (dataType === VolumeType.bid) {
|
||||||
for (let i = 0; i <= maxIndex; i++) {
|
for (let i = 0; i <= maxIndex; i++) {
|
||||||
data[i].cumulativeVol.bid =
|
data[i].cumulativeVol.value =
|
||||||
data[i].bid + (i !== 0 ? data[i - 1].cumulativeVol.bid : 0);
|
data[i].value + (i !== 0 ? data[i - 1].cumulativeVol.value : 0);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
for (let i = maxIndex; i >= 0; i--) {
|
for (let i = maxIndex; i >= 0; i--) {
|
||||||
data[i].cumulativeVol.ask =
|
data[i].cumulativeVol.value =
|
||||||
data[i].ask + (i !== maxIndex ? data[i + 1].cumulativeVol.ask : 0);
|
data[i].value +
|
||||||
|
(i !== maxIndex ? data[i + 1].cumulativeVol.value : 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createPartialRow = (
|
|
||||||
price: string,
|
|
||||||
volume = 0,
|
|
||||||
dataType?: VolumeType
|
|
||||||
): PartialOrderbookRowData => ({
|
|
||||||
price,
|
|
||||||
ask: dataType === VolumeType.ask ? volume : 0,
|
|
||||||
bid: dataType === VolumeType.bid ? volume : 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const extendRow = (row: PartialOrderbookRowData): OrderbookRowData =>
|
|
||||||
Object.assign(row, {
|
|
||||||
cumulativeVol: {
|
|
||||||
ask: 0,
|
|
||||||
bid: 0,
|
|
||||||
},
|
|
||||||
askByLevel: row.ask ? { [row.price]: row.ask } : {},
|
|
||||||
bidByLevel: row.bid ? { [row.price]: row.bid } : {},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const createRow = (
|
|
||||||
price: string,
|
|
||||||
volume = 0,
|
|
||||||
dataType?: VolumeType
|
|
||||||
): OrderbookRowData => extendRow(createPartialRow(price, volume, dataType));
|
|
||||||
|
|
||||||
const mapRawData =
|
|
||||||
(dataType: VolumeType.ask | VolumeType.bid) =>
|
|
||||||
(data: PriceLevelFieldsFragment): PartialOrderbookRowData =>
|
|
||||||
createPartialRow(data.price, Number(data.volume), dataType);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @summary merges sell amd buy data, orders by price desc, group by price level, counts cumulative and relative values
|
|
||||||
*/
|
|
||||||
export const compactRows = (
|
export const compactRows = (
|
||||||
sell: PriceLevelFieldsFragment[] | null | undefined,
|
data: PriceLevelFieldsFragment[] | null | undefined,
|
||||||
buy: PriceLevelFieldsFragment[] | null | undefined,
|
dataType: VolumeType,
|
||||||
resolution: number
|
resolution: number
|
||||||
) => {
|
) => {
|
||||||
// map raw sell data to OrderbookData
|
const groupedByLevel = groupBy(data, (row) =>
|
||||||
const askOrderbookData = [...(sell ?? [])].map<PartialOrderbookRowData>(
|
getPriceLevel(row.price, resolution)
|
||||||
mapRawData(VolumeType.ask)
|
|
||||||
);
|
|
||||||
// map raw buy data to OrderbookData
|
|
||||||
const bidOrderbookData = [...(buy ?? [])].map<PartialOrderbookRowData>(
|
|
||||||
mapRawData(VolumeType.bid)
|
|
||||||
);
|
|
||||||
// group by price level
|
|
||||||
const groupedByLevel = groupBy<PartialOrderbookRowData>(
|
|
||||||
[...askOrderbookData, ...bidOrderbookData],
|
|
||||||
(row) => getPriceLevel(row.price, resolution)
|
|
||||||
);
|
);
|
||||||
const orderbookData: OrderbookRowData[] = [];
|
const orderbookData: OrderbookRowData[] = [];
|
||||||
Object.keys(groupedByLevel).forEach((price) => {
|
Object.keys(groupedByLevel).forEach((price) => {
|
||||||
const row = extendRow(
|
const { volume } = groupedByLevel[price].pop() as PriceLevelFieldsFragment;
|
||||||
groupedByLevel[price].pop() as PartialOrderbookRowData
|
let value = Number(volume);
|
||||||
);
|
let subRow: { volume: string } | undefined = groupedByLevel[price].pop();
|
||||||
row.price = price;
|
|
||||||
let subRow: PartialOrderbookRowData | undefined =
|
|
||||||
groupedByLevel[price].pop();
|
|
||||||
while (subRow) {
|
while (subRow) {
|
||||||
row.ask += subRow.ask;
|
value += Number(subRow.volume);
|
||||||
row.bid += subRow.bid;
|
|
||||||
if (subRow.ask) {
|
|
||||||
row.askByLevel[subRow.price] = subRow.ask;
|
|
||||||
}
|
|
||||||
if (subRow.bid) {
|
|
||||||
row.bidByLevel[subRow.price] = subRow.bid;
|
|
||||||
}
|
|
||||||
subRow = groupedByLevel[price].pop();
|
subRow = groupedByLevel[price].pop();
|
||||||
}
|
}
|
||||||
orderbookData.push(row);
|
orderbookData.push({ price, value, cumulativeVol: { value: 0 } });
|
||||||
});
|
});
|
||||||
|
|
||||||
orderbookData.sort((a, b) => {
|
orderbookData.sort((a, b) => {
|
||||||
if (a === b) {
|
if (a === b) {
|
||||||
return 0;
|
return 0;
|
||||||
@ -192,100 +95,11 @@ export const compactRows = (
|
|||||||
}
|
}
|
||||||
return 1;
|
return 1;
|
||||||
});
|
});
|
||||||
// count cumulative volumes
|
updateCumulativeVolumeByType(orderbookData, dataType);
|
||||||
if (orderbookData.length > 1) {
|
|
||||||
const maxIndex = orderbookData.length - 1;
|
|
||||||
for (let i = 0; i <= maxIndex; i++) {
|
|
||||||
orderbookData[i].cumulativeVol.bid =
|
|
||||||
orderbookData[i].bid +
|
|
||||||
(i !== 0 ? orderbookData[i - 1].cumulativeVol.bid : 0);
|
|
||||||
}
|
|
||||||
for (let i = maxIndex; i >= 0; i--) {
|
|
||||||
orderbookData[i].cumulativeVol.ask =
|
|
||||||
orderbookData[i].ask +
|
|
||||||
(i !== maxIndex ? orderbookData[i + 1].cumulativeVol.ask : 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateCumulativeVolume(orderbookData);
|
|
||||||
// count relative volumes
|
|
||||||
updateRelativeData(orderbookData);
|
updateRelativeData(orderbookData);
|
||||||
return orderbookData;
|
return orderbookData;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param type
|
|
||||||
* @param draft
|
|
||||||
* @param delta
|
|
||||||
* @param resolution
|
|
||||||
* @param modifiedIndex
|
|
||||||
* @returns max (sell) or min (buy) modified index in draft data, mutates draft
|
|
||||||
*/
|
|
||||||
const partiallyUpdateCompactedRows = (
|
|
||||||
dataType: VolumeType,
|
|
||||||
data: OrderbookRowData[],
|
|
||||||
delta: PriceLevelFieldsFragment,
|
|
||||||
resolution: number
|
|
||||||
) => {
|
|
||||||
const { price } = delta;
|
|
||||||
const volume = Number(delta.volume);
|
|
||||||
const priceLevel = getPriceLevel(price, resolution);
|
|
||||||
const isAskDataType = dataType === VolumeType.ask;
|
|
||||||
const volKey = isAskDataType ? 'ask' : 'bid';
|
|
||||||
const volByLevelKey = isAskDataType ? 'askByLevel' : 'bidByLevel';
|
|
||||||
let index = data.findIndex((row) => row.price === priceLevel);
|
|
||||||
if (index !== -1) {
|
|
||||||
data[index][volKey] =
|
|
||||||
data[index][volKey] - (data[index][volByLevelKey][price] || 0) + volume;
|
|
||||||
data[index][volByLevelKey][price] = volume;
|
|
||||||
} else {
|
|
||||||
const newData: OrderbookRowData = createRow(priceLevel, volume, dataType);
|
|
||||||
index = data.findIndex((row) => BigInt(row.price) < BigInt(priceLevel));
|
|
||||||
if (index !== -1) {
|
|
||||||
data.splice(index, 0, newData);
|
|
||||||
} else {
|
|
||||||
data.push(newData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates OrderbookData[] with new data received from subscription - mutates input
|
|
||||||
*
|
|
||||||
* @param rows
|
|
||||||
* @param sell
|
|
||||||
* @param buy
|
|
||||||
* @param resolution
|
|
||||||
* @returns void
|
|
||||||
*/
|
|
||||||
export const updateCompactedRows = (
|
|
||||||
rows: Readonly<OrderbookRowData[]>,
|
|
||||||
sell: Readonly<PriceLevelFieldsFragment[]> | null,
|
|
||||||
buy: Readonly<PriceLevelFieldsFragment[]> | null,
|
|
||||||
resolution: number
|
|
||||||
) => {
|
|
||||||
const data = cloneDeep(rows as OrderbookRowData[]);
|
|
||||||
uniqBy(reverse(sell || []), 'price')?.forEach((delta) => {
|
|
||||||
partiallyUpdateCompactedRows(VolumeType.ask, data, delta, resolution);
|
|
||||||
});
|
|
||||||
uniqBy(reverse(buy || []), 'price')?.forEach((delta) => {
|
|
||||||
partiallyUpdateCompactedRows(VolumeType.bid, data, delta, resolution);
|
|
||||||
});
|
|
||||||
updateCumulativeVolume(data);
|
|
||||||
let index = 0;
|
|
||||||
// remove levels that do not have any volume
|
|
||||||
while (index < data.length) {
|
|
||||||
if (!data[index].ask && !data[index].bid) {
|
|
||||||
data.splice(index, 1);
|
|
||||||
} else {
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// count relative volumes
|
|
||||||
updateRelativeData(data);
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates raw data with new data received from subscription - mutates input
|
* Updates raw data with new data received from subscription - mutates input
|
||||||
* @param levels
|
* @param levels
|
||||||
@ -326,12 +140,9 @@ export interface MockDataGeneratorParams {
|
|||||||
numberOfSellRows: number;
|
numberOfSellRows: number;
|
||||||
numberOfBuyRows: number;
|
numberOfBuyRows: number;
|
||||||
overlap: number;
|
overlap: number;
|
||||||
midPrice: number;
|
midPrice?: string;
|
||||||
bestStaticBidPrice: number;
|
bestStaticBidPrice: number;
|
||||||
bestStaticOfferPrice: number;
|
bestStaticOfferPrice: number;
|
||||||
indicativePrice?: number;
|
|
||||||
indicativeVolume?: number;
|
|
||||||
resolution: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const generateMockData = ({
|
export const generateMockData = ({
|
||||||
@ -341,12 +152,10 @@ export const generateMockData = ({
|
|||||||
overlap,
|
overlap,
|
||||||
bestStaticBidPrice,
|
bestStaticBidPrice,
|
||||||
bestStaticOfferPrice,
|
bestStaticOfferPrice,
|
||||||
indicativePrice,
|
|
||||||
indicativeVolume,
|
|
||||||
resolution,
|
|
||||||
}: MockDataGeneratorParams) => {
|
}: MockDataGeneratorParams) => {
|
||||||
let matrix = new Array(numberOfSellRows).fill(undefined);
|
let matrix = new Array(numberOfSellRows).fill(undefined);
|
||||||
let price = midPrice + (numberOfSellRows - Math.ceil(overlap / 2) + 1);
|
let price =
|
||||||
|
Number(midPrice) + (numberOfSellRows - Math.ceil(overlap / 2) + 1);
|
||||||
const sell: PriceLevelFieldsFragment[] = matrix.map((row, i) => ({
|
const sell: PriceLevelFieldsFragment[] = matrix.map((row, i) => ({
|
||||||
price: (price -= 1).toString(),
|
price: (price -= 1).toString(),
|
||||||
volume: (numberOfSellRows - i + 1).toString(),
|
volume: (numberOfSellRows - i + 1).toString(),
|
||||||
@ -359,21 +168,11 @@ export const generateMockData = ({
|
|||||||
volume: (i + 2).toString(),
|
volume: (i + 2).toString(),
|
||||||
numberOfOrders: '',
|
numberOfOrders: '',
|
||||||
}));
|
}));
|
||||||
const rows = compactRows(sell, buy, resolution);
|
|
||||||
const marketTradingMode =
|
|
||||||
overlap > 0
|
|
||||||
? Schema.MarketTradingMode.TRADING_MODE_BATCH_AUCTION
|
|
||||||
: Schema.MarketTradingMode.TRADING_MODE_CONTINUOUS;
|
|
||||||
return {
|
return {
|
||||||
rows,
|
asks: sell,
|
||||||
resolution,
|
bids: buy,
|
||||||
indicativeVolume: indicativeVolume?.toString(),
|
midPrice,
|
||||||
marketTradingMode,
|
|
||||||
midPrice: ((bestStaticBidPrice + bestStaticOfferPrice) / 2).toString(),
|
|
||||||
bestStaticBidPrice: bestStaticBidPrice.toString(),
|
bestStaticBidPrice: bestStaticBidPrice.toString(),
|
||||||
bestStaticOfferPrice: bestStaticOfferPrice.toString(),
|
bestStaticOfferPrice: bestStaticOfferPrice.toString(),
|
||||||
indicativePrice: indicativePrice
|
|
||||||
? getPriceLevel(indicativePrice.toString(), resolution)
|
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -1,119 +1,34 @@
|
|||||||
import throttle from 'lodash/throttle';
|
|
||||||
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
|
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
|
||||||
import { Orderbook } from './orderbook';
|
import { Orderbook } from './orderbook';
|
||||||
import { useDataProvider } from '@vegaprotocol/data-provider';
|
import { useDataProvider } from '@vegaprotocol/data-provider';
|
||||||
import { marketDepthProvider } from './market-depth-provider';
|
import { marketDepthProvider } from './market-depth-provider';
|
||||||
import { marketDataProvider, marketProvider } from '@vegaprotocol/markets';
|
import { marketDataProvider, marketProvider } from '@vegaprotocol/markets';
|
||||||
import type { MarketData } from '@vegaprotocol/markets';
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import type {
|
import type {
|
||||||
MarketDepthUpdateSubscription,
|
|
||||||
MarketDepthQuery,
|
MarketDepthQuery,
|
||||||
MarketDepthQueryVariables,
|
MarketDepthQueryVariables,
|
||||||
|
MarketDepthUpdateSubscription,
|
||||||
|
PriceLevelFieldsFragment,
|
||||||
} from './__generated__/MarketDepth';
|
} from './__generated__/MarketDepth';
|
||||||
import type { PriceLevelFieldsFragment } from './__generated__/MarketDepth';
|
|
||||||
import {
|
|
||||||
compactRows,
|
|
||||||
updateCompactedRows,
|
|
||||||
getMidPrice,
|
|
||||||
getPriceLevel,
|
|
||||||
} from './orderbook-data';
|
|
||||||
import type { OrderbookData } from './orderbook-data';
|
|
||||||
import { useOrderStore } from '@vegaprotocol/orders';
|
import { useOrderStore } from '@vegaprotocol/orders';
|
||||||
|
|
||||||
|
export type OrderbookData = {
|
||||||
|
asks: PriceLevelFieldsFragment[];
|
||||||
|
bids: PriceLevelFieldsFragment[];
|
||||||
|
};
|
||||||
|
|
||||||
interface OrderbookManagerProps {
|
interface OrderbookManagerProps {
|
||||||
marketId: string;
|
marketId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OrderbookManager = ({ marketId }: OrderbookManagerProps) => {
|
export const OrderbookManager = ({ marketId }: OrderbookManagerProps) => {
|
||||||
const [resolution, setResolution] = useState(1);
|
|
||||||
const variables = { marketId };
|
const variables = { marketId };
|
||||||
const resolutionRef = useRef(resolution);
|
|
||||||
const [orderbookData, setOrderbookData] = useState<OrderbookData>({
|
|
||||||
rows: null,
|
|
||||||
});
|
|
||||||
const dataRef = useRef<OrderbookData>({ rows: null });
|
|
||||||
const marketDataRef = useRef<MarketData | null>(null);
|
|
||||||
const rawDataRef = useRef<MarketDepthQuery['market'] | null>(null);
|
|
||||||
const deltaRef = useRef<{
|
|
||||||
sell: PriceLevelFieldsFragment[];
|
|
||||||
buy: PriceLevelFieldsFragment[];
|
|
||||||
}>({
|
|
||||||
sell: [],
|
|
||||||
buy: [],
|
|
||||||
});
|
|
||||||
const updateOrderbookData = useRef(
|
|
||||||
throttle(() => {
|
|
||||||
dataRef.current = {
|
|
||||||
...marketDataRef.current,
|
|
||||||
indicativePrice:
|
|
||||||
marketDataRef.current?.indicativePrice &&
|
|
||||||
getPriceLevel(
|
|
||||||
marketDataRef.current.indicativePrice,
|
|
||||||
resolutionRef.current
|
|
||||||
),
|
|
||||||
midPrice: getMidPrice(
|
|
||||||
rawDataRef.current?.depth.sell,
|
|
||||||
rawDataRef.current?.depth.buy,
|
|
||||||
resolution
|
|
||||||
),
|
|
||||||
rows:
|
|
||||||
deltaRef.current.buy.length || deltaRef.current.sell.length
|
|
||||||
? updateCompactedRows(
|
|
||||||
dataRef.current.rows ?? [],
|
|
||||||
deltaRef.current.sell,
|
|
||||||
deltaRef.current.buy,
|
|
||||||
resolutionRef.current
|
|
||||||
)
|
|
||||||
: dataRef.current.rows,
|
|
||||||
};
|
|
||||||
deltaRef.current.buy = [];
|
|
||||||
deltaRef.current.sell = [];
|
|
||||||
setOrderbookData(dataRef.current);
|
|
||||||
}, 250)
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const { data, error, loading, reload } = useDataProvider<
|
||||||
deltaRef.current.buy = [];
|
|
||||||
deltaRef.current.sell = [];
|
|
||||||
}, [marketId]);
|
|
||||||
|
|
||||||
const update = useCallback(
|
|
||||||
({
|
|
||||||
delta: deltas,
|
|
||||||
data: rawData,
|
|
||||||
}: {
|
|
||||||
delta?: MarketDepthUpdateSubscription['marketsDepthUpdate'] | null;
|
|
||||||
data: NonNullable<MarketDepthQuery['market']> | null | undefined;
|
|
||||||
}) => {
|
|
||||||
if (!dataRef.current.rows) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
for (const delta of deltas || []) {
|
|
||||||
if (delta.marketId !== marketId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (delta.sell) {
|
|
||||||
deltaRef.current.sell.push(...delta.sell);
|
|
||||||
}
|
|
||||||
if (delta.buy) {
|
|
||||||
deltaRef.current.buy.push(...delta.buy);
|
|
||||||
}
|
|
||||||
rawDataRef.current = rawData;
|
|
||||||
updateOrderbookData.current();
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
[marketId, updateOrderbookData]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data, error, loading, flush, reload } = useDataProvider<
|
|
||||||
MarketDepthQuery['market'] | undefined,
|
MarketDepthQuery['market'] | undefined,
|
||||||
MarketDepthUpdateSubscription['marketsDepthUpdate'] | null,
|
MarketDepthUpdateSubscription['marketsDepthUpdate'] | null,
|
||||||
MarketDepthQueryVariables
|
MarketDepthQueryVariables
|
||||||
>({
|
>({
|
||||||
dataProvider: marketDepthProvider,
|
dataProvider: marketDepthProvider,
|
||||||
update,
|
|
||||||
variables,
|
variables,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -127,57 +42,15 @@ export const OrderbookManager = ({ marketId }: OrderbookManagerProps) => {
|
|||||||
variables,
|
variables,
|
||||||
});
|
});
|
||||||
|
|
||||||
const marketDataUpdate = useCallback(
|
|
||||||
({ data }: { data: MarketData | null }) => {
|
|
||||||
marketDataRef.current = data;
|
|
||||||
updateOrderbookData.current();
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: marketData,
|
data: marketData,
|
||||||
error: marketDataError,
|
error: marketDataError,
|
||||||
loading: marketDataLoading,
|
loading: marketDataLoading,
|
||||||
} = useDataProvider({
|
} = useDataProvider({
|
||||||
dataProvider: marketDataProvider,
|
dataProvider: marketDataProvider,
|
||||||
update: marketDataUpdate,
|
|
||||||
variables,
|
variables,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!marketDataRef.current && marketData) {
|
|
||||||
marketDataRef.current = marketData;
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const throttleRunner = updateOrderbookData.current;
|
|
||||||
if (!data) {
|
|
||||||
dataRef.current = { rows: null };
|
|
||||||
setOrderbookData(dataRef.current);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
dataRef.current = {
|
|
||||||
...marketDataRef.current,
|
|
||||||
indicativePrice:
|
|
||||||
marketDataRef.current?.indicativePrice &&
|
|
||||||
getPriceLevel(marketDataRef.current.indicativePrice, resolution),
|
|
||||||
midPrice: getMidPrice(data.depth.sell, data.depth.buy, resolution),
|
|
||||||
rows: compactRows(data.depth.sell, data.depth.buy, resolution),
|
|
||||||
};
|
|
||||||
rawDataRef.current = data;
|
|
||||||
setOrderbookData(dataRef.current);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
throttleRunner.cancel();
|
|
||||||
};
|
|
||||||
}, [data, resolution]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
resolutionRef.current = resolution;
|
|
||||||
flush();
|
|
||||||
}, [resolution, flush]);
|
|
||||||
|
|
||||||
const updateOrder = useOrderStore((store) => store.update);
|
const updateOrder = useOrderStore((store) => store.update);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -188,16 +61,16 @@ export const OrderbookManager = ({ marketId }: OrderbookManagerProps) => {
|
|||||||
reload={reload}
|
reload={reload}
|
||||||
>
|
>
|
||||||
<Orderbook
|
<Orderbook
|
||||||
{...orderbookData}
|
bids={data?.depth.buy ?? []}
|
||||||
|
asks={data?.depth.sell ?? []}
|
||||||
decimalPlaces={market?.decimalPlaces ?? 0}
|
decimalPlaces={market?.decimalPlaces ?? 0}
|
||||||
positionDecimalPlaces={market?.positionDecimalPlaces ?? 0}
|
positionDecimalPlaces={market?.positionDecimalPlaces ?? 0}
|
||||||
resolution={resolution}
|
|
||||||
onResolutionChange={(resolution: number) => setResolution(resolution)}
|
|
||||||
onClick={(price: string) => {
|
onClick={(price: string) => {
|
||||||
if (price) {
|
if (price) {
|
||||||
updateOrder(marketId, { price });
|
updateOrder(marketId, { price });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
midPrice={marketData?.midPrice}
|
||||||
/>
|
/>
|
||||||
</AsyncRenderer>
|
</AsyncRenderer>
|
||||||
);
|
);
|
||||||
|
@ -1,130 +1,118 @@
|
|||||||
import React from 'react';
|
import React, { memo } from 'react';
|
||||||
import { addDecimal, addDecimalsFixedFormatNumber } from '@vegaprotocol/utils';
|
import { addDecimal, addDecimalsFixedFormatNumber } from '@vegaprotocol/utils';
|
||||||
import { PriceCell, VolCell, CumulativeVol } from '@vegaprotocol/datagrid';
|
import { NumericCell, PriceCell } from '@vegaprotocol/datagrid';
|
||||||
|
import { VolumeType } from './orderbook-data';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
interface OrderbookRowProps {
|
interface OrderbookRowProps {
|
||||||
ask: number;
|
value: number;
|
||||||
bid: number;
|
cumulativeValue?: number;
|
||||||
cumulativeAsk?: number;
|
cumulativeRelativeValue?: number;
|
||||||
cumulativeBid?: number;
|
|
||||||
cumulativeRelativeAsk?: number;
|
|
||||||
cumulativeRelativeBid?: number;
|
|
||||||
decimalPlaces: number;
|
decimalPlaces: number;
|
||||||
positionDecimalPlaces: number;
|
positionDecimalPlaces: number;
|
||||||
indicativeVolume?: string;
|
|
||||||
price: string;
|
price: string;
|
||||||
relativeAsk?: number;
|
|
||||||
relativeBid?: number;
|
|
||||||
onClick?: (price: string) => void;
|
onClick?: (price: string) => void;
|
||||||
|
type: VolumeType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CumulationBar = ({
|
||||||
|
cumulativeValue = 0,
|
||||||
|
type,
|
||||||
|
}: {
|
||||||
|
cumulativeValue?: number;
|
||||||
|
type: VolumeType;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid={`${VolumeType.bid === type ? 'bid' : 'ask'}-bar`}
|
||||||
|
className={classNames(
|
||||||
|
'absolute top-0 left-0 h-full transition-all',
|
||||||
|
type === VolumeType.bid
|
||||||
|
? 'bg-vega-green/20 dark:bg-vega-green/50'
|
||||||
|
: 'bg-vega-pink/20 dark:bg-vega-pink/30'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
width: `${cumulativeValue}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CumulativeVol = memo(
|
||||||
|
({
|
||||||
|
testId,
|
||||||
|
positionDecimalPlaces,
|
||||||
|
cumulativeValue,
|
||||||
|
}: {
|
||||||
|
ask?: number;
|
||||||
|
bid?: number;
|
||||||
|
cumulativeValue?: number;
|
||||||
|
testId?: string;
|
||||||
|
className?: string;
|
||||||
|
positionDecimalPlaces: number;
|
||||||
|
}) => {
|
||||||
|
const volume = cumulativeValue ? (
|
||||||
|
<NumericCell
|
||||||
|
value={cumulativeValue}
|
||||||
|
valueFormatted={addDecimalsFixedFormatNumber(
|
||||||
|
cumulativeValue,
|
||||||
|
positionDecimalPlaces ?? 0
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pr-1" data-testid={testId}>
|
||||||
|
{volume}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
CumulativeVol.displayName = 'OrderBookCumulativeVol';
|
||||||
|
|
||||||
export const OrderbookRow = React.memo(
|
export const OrderbookRow = React.memo(
|
||||||
({
|
({
|
||||||
ask,
|
value,
|
||||||
bid,
|
cumulativeValue,
|
||||||
cumulativeAsk,
|
cumulativeRelativeValue,
|
||||||
cumulativeBid,
|
|
||||||
cumulativeRelativeAsk,
|
|
||||||
cumulativeRelativeBid,
|
|
||||||
decimalPlaces,
|
decimalPlaces,
|
||||||
positionDecimalPlaces,
|
positionDecimalPlaces,
|
||||||
indicativeVolume,
|
|
||||||
price,
|
price,
|
||||||
relativeAsk,
|
|
||||||
relativeBid,
|
|
||||||
onClick,
|
onClick,
|
||||||
|
type,
|
||||||
}: OrderbookRowProps) => {
|
}: OrderbookRowProps) => {
|
||||||
|
const txtId = type === VolumeType.bid ? 'bid' : 'ask';
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="relative">
|
||||||
<VolCell
|
<CumulationBar cumulativeValue={cumulativeRelativeValue} type={type} />
|
||||||
testId={`bid-vol-${price}`}
|
<div className="grid gap-1 text-right grid-cols-3">
|
||||||
value={bid}
|
|
||||||
valueFormatted={addDecimalsFixedFormatNumber(
|
|
||||||
bid,
|
|
||||||
positionDecimalPlaces
|
|
||||||
)}
|
|
||||||
relativeValue={relativeBid}
|
|
||||||
type="bid"
|
|
||||||
/>
|
|
||||||
<VolCell
|
|
||||||
testId={`ask-vol-${price}`}
|
|
||||||
value={ask}
|
|
||||||
valueFormatted={addDecimalsFixedFormatNumber(
|
|
||||||
ask,
|
|
||||||
positionDecimalPlaces
|
|
||||||
)}
|
|
||||||
relativeValue={relativeAsk}
|
|
||||||
type="ask"
|
|
||||||
/>
|
|
||||||
<PriceCell
|
<PriceCell
|
||||||
testId={`price-${price}`}
|
testId={`price-${price}`}
|
||||||
value={BigInt(price)}
|
value={BigInt(price)}
|
||||||
onClick={() => onClick && onClick(addDecimal(price, decimalPlaces))}
|
onClick={() => onClick && onClick(addDecimal(price, decimalPlaces))}
|
||||||
valueFormatted={addDecimalsFixedFormatNumber(price, decimalPlaces)}
|
valueFormatted={addDecimalsFixedFormatNumber(price, decimalPlaces)}
|
||||||
/>
|
className={
|
||||||
<CumulativeVol
|
type === VolumeType.ask
|
||||||
testId={`cumulative-vol-${price}`}
|
? '!text-vega-pink dark:text-vega-pink'
|
||||||
positionDecimalPlaces={positionDecimalPlaces}
|
: 'text-vega-green-550 dark:text-vega-green'
|
||||||
bid={cumulativeBid}
|
|
||||||
ask={cumulativeAsk}
|
|
||||||
relativeAsk={cumulativeRelativeAsk}
|
|
||||||
relativeBid={cumulativeRelativeBid}
|
|
||||||
indicativeVolume={indicativeVolume}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
);
|
/>
|
||||||
OrderbookRow.displayName = 'OrderbookRow';
|
<NumericCell
|
||||||
|
testId={`${txtId}-vol-${price}`}
|
||||||
export const OrderbookContinuousRow = React.memo(
|
|
||||||
({
|
|
||||||
ask,
|
|
||||||
bid,
|
|
||||||
cumulativeAsk,
|
|
||||||
cumulativeBid,
|
|
||||||
cumulativeRelativeAsk,
|
|
||||||
cumulativeRelativeBid,
|
|
||||||
decimalPlaces,
|
|
||||||
positionDecimalPlaces,
|
|
||||||
indicativeVolume,
|
|
||||||
price,
|
|
||||||
relativeAsk,
|
|
||||||
relativeBid,
|
|
||||||
onClick,
|
|
||||||
}: OrderbookRowProps) => {
|
|
||||||
const type = bid ? 'bid' : 'ask';
|
|
||||||
const value = bid || ask;
|
|
||||||
const relativeValue = bid ? relativeBid : relativeAsk;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<VolCell
|
|
||||||
testId={`bid-ask-vol-${price}`}
|
|
||||||
value={value}
|
value={value}
|
||||||
valueFormatted={addDecimalsFixedFormatNumber(
|
valueFormatted={addDecimalsFixedFormatNumber(
|
||||||
value,
|
value,
|
||||||
positionDecimalPlaces
|
positionDecimalPlaces
|
||||||
)}
|
)}
|
||||||
relativeValue={relativeValue}
|
|
||||||
type={type}
|
|
||||||
/>
|
|
||||||
<PriceCell
|
|
||||||
testId={`price-${price}`}
|
|
||||||
value={BigInt(price)}
|
|
||||||
onClick={() => onClick && onClick(addDecimal(price, decimalPlaces))}
|
|
||||||
valueFormatted={addDecimalsFixedFormatNumber(price, decimalPlaces)}
|
|
||||||
/>
|
/>
|
||||||
<CumulativeVol
|
<CumulativeVol
|
||||||
testId={`cumulative-vol-${price}`}
|
testId={`cumulative-vol-${price}`}
|
||||||
positionDecimalPlaces={positionDecimalPlaces}
|
positionDecimalPlaces={positionDecimalPlaces}
|
||||||
bid={cumulativeBid}
|
cumulativeValue={cumulativeValue}
|
||||||
ask={cumulativeAsk}
|
|
||||||
relativeAsk={cumulativeRelativeAsk}
|
|
||||||
relativeBid={cumulativeRelativeBid}
|
|
||||||
indicativeVolume={indicativeVolume}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
OrderbookContinuousRow.displayName = 'OrderbookContinuousRow';
|
OrderbookRow.displayName = 'OrderbookRow';
|
||||||
|
@ -1,260 +1,89 @@
|
|||||||
import { render, fireEvent, waitFor, screen } from '@testing-library/react';
|
import { render, fireEvent, waitFor, screen } from '@testing-library/react';
|
||||||
import { generateMockData } from './orderbook-data';
|
import { generateMockData, VolumeType } from './orderbook-data';
|
||||||
import { Orderbook, rowHeight } from './orderbook';
|
import { Orderbook } from './orderbook';
|
||||||
|
import * as orderbookData from './orderbook-data';
|
||||||
|
|
||||||
|
function mockOffsetSize(width: number, height: number) {
|
||||||
|
Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', {
|
||||||
|
configurable: true,
|
||||||
|
value: () => ({ height, width }),
|
||||||
|
});
|
||||||
|
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
|
||||||
|
configurable: true,
|
||||||
|
value: height,
|
||||||
|
});
|
||||||
|
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
|
||||||
|
configurable: true,
|
||||||
|
value: width,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe('Orderbook', () => {
|
describe('Orderbook', () => {
|
||||||
const params = {
|
const params = {
|
||||||
numberOfSellRows: 100,
|
numberOfSellRows: 100,
|
||||||
numberOfBuyRows: 100,
|
numberOfBuyRows: 100,
|
||||||
step: 1,
|
step: 1,
|
||||||
midPrice: 122900,
|
midPrice: '122900',
|
||||||
bestStaticBidPrice: 122905,
|
bestStaticBidPrice: 122905,
|
||||||
bestStaticOfferPrice: 122895,
|
bestStaticOfferPrice: 122895,
|
||||||
decimalPlaces: 3,
|
decimalPlaces: 3,
|
||||||
overlap: 10,
|
overlap: 0,
|
||||||
indicativePrice: 122900,
|
|
||||||
indicativeVolume: 11,
|
|
||||||
resolution: 1,
|
resolution: 1,
|
||||||
};
|
};
|
||||||
const onResolutionChange = jest.fn();
|
|
||||||
const decimalPlaces = 3;
|
const decimalPlaces = 3;
|
||||||
it('should scroll to mid price on init', async () => {
|
|
||||||
window.innerHeight = 11 * rowHeight;
|
beforeEach(() => {
|
||||||
|
mockOffsetSize(800, 768);
|
||||||
|
});
|
||||||
|
it('markPrice should be in the middle', async () => {
|
||||||
render(
|
render(
|
||||||
<Orderbook
|
<Orderbook
|
||||||
decimalPlaces={decimalPlaces}
|
decimalPlaces={decimalPlaces}
|
||||||
positionDecimalPlaces={0}
|
positionDecimalPlaces={0}
|
||||||
fillGaps
|
|
||||||
{...generateMockData(params)}
|
{...generateMockData(params)}
|
||||||
onResolutionChange={onResolutionChange}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
|
await waitFor(() =>
|
||||||
expect(screen.getByTestId('scroll').scrollTop).toBe(91 * rowHeight);
|
screen.getByTestId(`middle-mark-price-${params.midPrice}`)
|
||||||
});
|
|
||||||
|
|
||||||
it('should keep mid price row in the middle', async () => {
|
|
||||||
window.innerHeight = 11 * rowHeight;
|
|
||||||
const result = render(
|
|
||||||
<Orderbook
|
|
||||||
decimalPlaces={decimalPlaces}
|
|
||||||
positionDecimalPlaces={0}
|
|
||||||
fillGaps
|
|
||||||
{...generateMockData(params)}
|
|
||||||
onResolutionChange={onResolutionChange}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
|
expect(
|
||||||
expect(screen.getByTestId('scroll').scrollTop).toBe(91 * rowHeight);
|
screen.getByTestId(`middle-mark-price-${params.midPrice}`)
|
||||||
result.rerender(
|
).toHaveTextContent('122.90');
|
||||||
<Orderbook
|
|
||||||
decimalPlaces={decimalPlaces}
|
|
||||||
positionDecimalPlaces={0}
|
|
||||||
fillGaps
|
|
||||||
{...generateMockData({
|
|
||||||
...params,
|
|
||||||
numberOfSellRows: params.numberOfSellRows - 1,
|
|
||||||
})}
|
|
||||||
onResolutionChange={onResolutionChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
|
|
||||||
expect(result.getByTestId('scroll').scrollTop).toBe(90 * rowHeight);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should scroll to mid price when it will change', async () => {
|
|
||||||
window.innerHeight = 11 * rowHeight;
|
|
||||||
const result = render(
|
|
||||||
<Orderbook
|
|
||||||
decimalPlaces={decimalPlaces}
|
|
||||||
positionDecimalPlaces={0}
|
|
||||||
fillGaps
|
|
||||||
{...generateMockData(params)}
|
|
||||||
onResolutionChange={onResolutionChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
|
|
||||||
expect(result.getByTestId('scroll').scrollTop).toBe(91 * rowHeight);
|
|
||||||
result.rerender(
|
|
||||||
<Orderbook
|
|
||||||
decimalPlaces={decimalPlaces}
|
|
||||||
positionDecimalPlaces={0}
|
|
||||||
fillGaps
|
|
||||||
{...generateMockData({
|
|
||||||
...params,
|
|
||||||
bestStaticBidPrice: params.bestStaticBidPrice + 1,
|
|
||||||
bestStaticOfferPrice: params.bestStaticOfferPrice + 1,
|
|
||||||
})}
|
|
||||||
onResolutionChange={onResolutionChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
|
|
||||||
expect(result.getByTestId('scroll').scrollTop).toBe(90 * rowHeight);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should keep price it the middle', async () => {
|
|
||||||
window.innerHeight = 11 * rowHeight;
|
|
||||||
const result = render(
|
|
||||||
<Orderbook
|
|
||||||
decimalPlaces={decimalPlaces}
|
|
||||||
positionDecimalPlaces={0}
|
|
||||||
fillGaps
|
|
||||||
{...generateMockData(params)}
|
|
||||||
onResolutionChange={onResolutionChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
|
|
||||||
const scrollElement = result.getByTestId('scroll');
|
|
||||||
expect(scrollElement.scrollTop).toBe(91 * rowHeight);
|
|
||||||
scrollElement.scrollTop = 92 * rowHeight + 0.01;
|
|
||||||
fireEvent.scroll(scrollElement);
|
|
||||||
result.rerender(
|
|
||||||
<Orderbook
|
|
||||||
decimalPlaces={decimalPlaces}
|
|
||||||
positionDecimalPlaces={0}
|
|
||||||
fillGaps
|
|
||||||
{...generateMockData({
|
|
||||||
...params,
|
|
||||||
numberOfSellRows: params.numberOfSellRows - 1,
|
|
||||||
})}
|
|
||||||
onResolutionChange={onResolutionChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
|
|
||||||
expect(screen.getByTestId('scroll').scrollTop).toBe(91 * rowHeight + 0.01);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should get back to mid price on click', async () => {
|
|
||||||
window.innerHeight = 11 * rowHeight;
|
|
||||||
const result = render(
|
|
||||||
<Orderbook
|
|
||||||
decimalPlaces={decimalPlaces}
|
|
||||||
positionDecimalPlaces={0}
|
|
||||||
fillGaps
|
|
||||||
{...generateMockData(params)}
|
|
||||||
onResolutionChange={onResolutionChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
|
|
||||||
const scrollElement = result.getByTestId('scroll');
|
|
||||||
expect(scrollElement.scrollTop).toBe(91 * rowHeight);
|
|
||||||
scrollElement.scrollTop = 1;
|
|
||||||
fireEvent.scroll(scrollElement);
|
|
||||||
expect(result.getByTestId('scroll').scrollTop).toBe(1);
|
|
||||||
const scrollToMidPriceButton = result.getByTestId('scroll-to-midprice');
|
|
||||||
fireEvent.click(scrollToMidPriceButton);
|
|
||||||
expect(screen.getByTestId('scroll').scrollTop).toBe(91 * rowHeight + 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should get back to mid price on resolution change', async () => {
|
|
||||||
window.innerHeight = 11 * rowHeight;
|
|
||||||
const result = render(
|
|
||||||
<Orderbook
|
|
||||||
decimalPlaces={decimalPlaces}
|
|
||||||
positionDecimalPlaces={0}
|
|
||||||
fillGaps
|
|
||||||
{...generateMockData(params)}
|
|
||||||
onResolutionChange={onResolutionChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
|
|
||||||
const scrollElement = screen.getByTestId('scroll');
|
|
||||||
expect(scrollElement.scrollTop).toBe(91 * rowHeight);
|
|
||||||
scrollElement.scrollTop = 1;
|
|
||||||
fireEvent.scroll(scrollElement);
|
|
||||||
expect(screen.getByTestId('scroll').scrollTop).toBe(1);
|
|
||||||
const resolutionSelect = screen.getByTestId(
|
|
||||||
'resolution'
|
|
||||||
) as HTMLSelectElement;
|
|
||||||
fireEvent.change(resolutionSelect, { target: { value: '10' } });
|
|
||||||
expect(onResolutionChange.mock.calls.length).toBe(1);
|
|
||||||
expect(onResolutionChange.mock.calls[0][0]).toBe(10);
|
|
||||||
result.rerender(
|
|
||||||
<Orderbook
|
|
||||||
decimalPlaces={decimalPlaces}
|
|
||||||
positionDecimalPlaces={0}
|
|
||||||
fillGaps
|
|
||||||
{...generateMockData({
|
|
||||||
...params,
|
|
||||||
resolution: 10,
|
|
||||||
})}
|
|
||||||
onResolutionChange={onResolutionChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(screen.getByTestId('scroll').scrollTop).toBe(6 * rowHeight);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should format correctly the numbers on resolution change', async () => {
|
it('should format correctly the numbers on resolution change', async () => {
|
||||||
const onClickSpy = jest.fn();
|
const onClickSpy = jest.fn();
|
||||||
const result = render(
|
jest.spyOn(orderbookData, 'compactRows');
|
||||||
|
const mockedData = generateMockData(params);
|
||||||
|
render(
|
||||||
<Orderbook
|
<Orderbook
|
||||||
decimalPlaces={decimalPlaces}
|
decimalPlaces={decimalPlaces}
|
||||||
positionDecimalPlaces={0}
|
positionDecimalPlaces={0}
|
||||||
onClick={onClickSpy}
|
onClick={onClickSpy}
|
||||||
fillGaps
|
{...mockedData}
|
||||||
{...generateMockData(params)}
|
|
||||||
onResolutionChange={onResolutionChange}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
await screen.findByTestId(`bid-vol-${params.midPrice}`)
|
await screen.findByTestId(`middle-mark-price-${params.midPrice}`)
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
// Before resolution change the price is 122.934
|
// Before resolution change the price is 122.934
|
||||||
await fireEvent.click(await screen.getByTestId('price-122934'));
|
await fireEvent.click(await screen.getByTestId('price-122901'));
|
||||||
expect(onClickSpy).toBeCalledWith('122.934');
|
expect(onClickSpy).toBeCalledWith('122.901');
|
||||||
const resolutionSelect = screen.getByTestId(
|
const resolutionSelect = screen.getByTestId(
|
||||||
'resolution'
|
'resolution'
|
||||||
) as HTMLSelectElement;
|
) as HTMLSelectElement;
|
||||||
await fireEvent.change(resolutionSelect, { target: { value: '10' } });
|
await fireEvent.change(resolutionSelect, { target: { value: '10' } });
|
||||||
await result.rerender(
|
expect(orderbookData.compactRows).toHaveBeenCalledWith(
|
||||||
<Orderbook
|
mockedData.bids,
|
||||||
decimalPlaces={decimalPlaces}
|
VolumeType.bid,
|
||||||
positionDecimalPlaces={0}
|
10
|
||||||
onClick={onClickSpy}
|
|
||||||
fillGaps
|
|
||||||
{...generateMockData({
|
|
||||||
...params,
|
|
||||||
resolution: 10,
|
|
||||||
})}
|
|
||||||
onResolutionChange={onResolutionChange}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
await fireEvent.click(await screen.getByTestId('price-12299'));
|
expect(orderbookData.compactRows).toHaveBeenCalledWith(
|
||||||
// After resolution change the price is 122.99
|
mockedData.asks,
|
||||||
expect(onResolutionChange.mock.calls[0][0]).toBe(10);
|
VolumeType.ask,
|
||||||
expect(onClickSpy).toBeCalledWith('122.99');
|
10
|
||||||
});
|
|
||||||
|
|
||||||
it('should have three or four columns', async () => {
|
|
||||||
window.innerHeight = 11 * rowHeight;
|
|
||||||
const { rerender } = render(
|
|
||||||
<Orderbook
|
|
||||||
decimalPlaces={decimalPlaces}
|
|
||||||
positionDecimalPlaces={0}
|
|
||||||
fillGaps
|
|
||||||
{...generateMockData({
|
|
||||||
...params,
|
|
||||||
overlap: 0,
|
|
||||||
})}
|
|
||||||
onResolutionChange={onResolutionChange}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
await waitFor(() => {
|
await fireEvent.click(await screen.getByTestId('price-12294'));
|
||||||
expect(screen.queryByText('Bid / Ask vol')).toBeInTheDocument();
|
expect(onClickSpy).toBeCalledWith('122.94');
|
||||||
});
|
|
||||||
rerender(
|
|
||||||
<Orderbook
|
|
||||||
decimalPlaces={decimalPlaces}
|
|
||||||
positionDecimalPlaces={0}
|
|
||||||
fillGaps
|
|
||||||
{...generateMockData(params)}
|
|
||||||
onResolutionChange={onResolutionChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Bid vol')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Ask vol')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
await expect(screen.queryByText('Bid / Ask vol')).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -2,14 +2,12 @@ import type { Story, Meta } from '@storybook/react';
|
|||||||
import { generateMockData } from './orderbook-data';
|
import { generateMockData } from './orderbook-data';
|
||||||
import type { MockDataGeneratorParams } from './orderbook-data';
|
import type { MockDataGeneratorParams } from './orderbook-data';
|
||||||
import { Orderbook } from './orderbook';
|
import { Orderbook } from './orderbook';
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
type Props = Omit<MockDataGeneratorParams, 'resolution'> & {
|
type Props = Omit<MockDataGeneratorParams, 'resolution'> & {
|
||||||
decimalPlaces: number;
|
decimalPlaces: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const OrderbookMockDataProvider = ({ decimalPlaces, ...props }: Props) => {
|
const OrderbookMockDataProvider = ({ decimalPlaces, ...props }: Props) => {
|
||||||
const [resolution, setResolution] = useState(1);
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 dark:bg-black dark:text-neutral-200 bg-white text-neutral-800">
|
<div className="absolute inset-0 dark:bg-black dark:text-neutral-200 bg-white text-neutral-800">
|
||||||
<div
|
<div
|
||||||
@ -18,9 +16,8 @@ const OrderbookMockDataProvider = ({ decimalPlaces, ...props }: Props) => {
|
|||||||
>
|
>
|
||||||
<Orderbook
|
<Orderbook
|
||||||
positionDecimalPlaces={0}
|
positionDecimalPlaces={0}
|
||||||
onResolutionChange={setResolution}
|
|
||||||
decimalPlaces={decimalPlaces}
|
decimalPlaces={decimalPlaces}
|
||||||
{...generateMockData({ ...props, resolution })}
|
{...generateMockData({ ...props })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -54,8 +51,6 @@ Auction.args = {
|
|||||||
bestStaticOfferPrice: 122895,
|
bestStaticOfferPrice: 122895,
|
||||||
decimalPlaces: 3,
|
decimalPlaces: 3,
|
||||||
overlap: 10,
|
overlap: 10,
|
||||||
indicativePrice: 122900,
|
|
||||||
indicativeVolume: 11,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Empty = Template.bind({});
|
export const Empty = Template.bind({});
|
||||||
@ -66,6 +61,4 @@ Empty.args = {
|
|||||||
bestStaticOfferPrice: 0,
|
bestStaticOfferPrice: 0,
|
||||||
decimalPlaces: 3,
|
decimalPlaces: 3,
|
||||||
overlap: 0,
|
overlap: 0,
|
||||||
indicativePrice: 0,
|
|
||||||
indicativeVolume: 0,
|
|
||||||
};
|
};
|
||||||
|
@ -1,680 +1,178 @@
|
|||||||
import colors from 'tailwindcss/colors';
|
import { useMemo } from 'react';
|
||||||
|
import ReactVirtualizedAutoSizer from 'react-virtualized-auto-sizer';
|
||||||
import {
|
import {
|
||||||
useEffect,
|
addDecimalsFormatNumber,
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
useCallback,
|
|
||||||
Fragment,
|
|
||||||
useMemo,
|
|
||||||
} from 'react';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import {
|
|
||||||
addDecimalsFixedFormatNumber,
|
|
||||||
formatNumberFixed,
|
formatNumberFixed,
|
||||||
} from '@vegaprotocol/utils';
|
} from '@vegaprotocol/utils';
|
||||||
import { t } from '@vegaprotocol/i18n';
|
import { t } from '@vegaprotocol/i18n';
|
||||||
import {
|
import { OrderbookRow } from './orderbook-row';
|
||||||
useResizeObserver,
|
import type { OrderbookRowData } from './orderbook-data';
|
||||||
useThemeSwitcher,
|
import { compactRows, VolumeType } from './orderbook-data';
|
||||||
} from '@vegaprotocol/react-helpers';
|
import { Splash } from '@vegaprotocol/ui-toolkit';
|
||||||
import * as Schema from '@vegaprotocol/types';
|
import classNames from 'classnames';
|
||||||
import { OrderbookRow, OrderbookContinuousRow } from './orderbook-row';
|
import { useState } from 'react';
|
||||||
import { createRow } from './orderbook-data';
|
import type { PriceLevelFieldsFragment } from './__generated__/MarketDepth';
|
||||||
import { Checkbox, Icon, Splash, TinyScroll } from '@vegaprotocol/ui-toolkit';
|
|
||||||
import type { OrderbookData, OrderbookRowData } from './orderbook-data';
|
|
||||||
|
|
||||||
interface OrderbookProps extends OrderbookData {
|
interface OrderbookProps {
|
||||||
decimalPlaces: number;
|
decimalPlaces: number;
|
||||||
positionDecimalPlaces: number;
|
positionDecimalPlaces: number;
|
||||||
resolution: number;
|
|
||||||
onResolutionChange: (resolution: number) => void;
|
|
||||||
onClick?: (price: string) => void;
|
onClick?: (price: string) => void;
|
||||||
fillGaps?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const HorizontalLine = ({ top, testId }: { top: string; testId: string }) => (
|
|
||||||
<div
|
|
||||||
className="absolute border-b border-default inset-x-0 hidden"
|
|
||||||
style={{ top }}
|
|
||||||
data-testid={testId}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const getNumberOfRows = (
|
|
||||||
rows: OrderbookRowData[] | null,
|
|
||||||
resolution: number
|
|
||||||
) => {
|
|
||||||
if (!rows || !rows.length) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (rows.length === 1) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
Number(BigInt(rows[0].price) - BigInt(rows[rows.length - 1].price)) /
|
|
||||||
resolution +
|
|
||||||
1
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRowsToRender = (
|
|
||||||
rows: OrderbookRowData[] | null,
|
|
||||||
resolution: number,
|
|
||||||
offset: number,
|
|
||||||
limit: number
|
|
||||||
): OrderbookRowData[] | null => {
|
|
||||||
if (!rows || !rows.length) {
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
if (rows.length === 1) {
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
const selectedRows: OrderbookRowData[] = [];
|
|
||||||
let price = BigInt(rows[0].price) - BigInt(offset * resolution);
|
|
||||||
let index = Math.max(
|
|
||||||
rows.findIndex((row) => BigInt(row.price) <= price) - 1,
|
|
||||||
-1
|
|
||||||
);
|
|
||||||
while (selectedRows.length < limit && index + 1 < rows.length) {
|
|
||||||
if (rows[index + 1].price === price.toString()) {
|
|
||||||
selectedRows.push(rows[index + 1]);
|
|
||||||
index += 1;
|
|
||||||
} else {
|
|
||||||
const row = createRow(price.toString());
|
|
||||||
row.cumulativeVol = {
|
|
||||||
bid: rows[index].cumulativeVol.bid,
|
|
||||||
relativeBid: rows[index].cumulativeVol.relativeBid,
|
|
||||||
ask: rows[index + 1].cumulativeVol.ask,
|
|
||||||
relativeAsk: rows[index + 1].cumulativeVol.relativeAsk,
|
|
||||||
};
|
|
||||||
selectedRows.push(row);
|
|
||||||
}
|
|
||||||
price -= BigInt(resolution);
|
|
||||||
}
|
|
||||||
return selectedRows;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 17px of row height plus 4px gap
|
|
||||||
export const gridGap = 4;
|
|
||||||
export const rowHeight = 21;
|
|
||||||
// top padding to make space for header
|
|
||||||
const headerPadding = 30;
|
|
||||||
// bottom padding to make space for footer
|
|
||||||
const footerPadding = 25;
|
|
||||||
// buffer size in rows
|
|
||||||
const bufferSize = 30;
|
|
||||||
// margin size in px, when reached scrollOffset will be updated
|
|
||||||
const marginSize = bufferSize * 0.9 * rowHeight;
|
|
||||||
|
|
||||||
const getBestStaticBidPriceLinePosition = (
|
|
||||||
bestStaticBidPrice: string | undefined,
|
|
||||||
fillGaps: boolean,
|
|
||||||
maxPriceLevel: string,
|
|
||||||
minPriceLevel: string,
|
|
||||||
resolution: number,
|
|
||||||
rows: OrderbookRowData[] | null
|
|
||||||
) => {
|
|
||||||
let bestStaticBidPriceLinePosition = '';
|
|
||||||
if (
|
|
||||||
rows?.length &&
|
|
||||||
bestStaticBidPrice &&
|
|
||||||
BigInt(bestStaticBidPrice) < BigInt(maxPriceLevel) &&
|
|
||||||
BigInt(bestStaticBidPrice) > BigInt(minPriceLevel)
|
|
||||||
) {
|
|
||||||
if (fillGaps) {
|
|
||||||
bestStaticBidPriceLinePosition = (
|
|
||||||
((BigInt(maxPriceLevel) - BigInt(bestStaticBidPrice)) /
|
|
||||||
BigInt(resolution)) *
|
|
||||||
BigInt(rowHeight) +
|
|
||||||
BigInt(headerPadding) -
|
|
||||||
BigInt(3)
|
|
||||||
).toString();
|
|
||||||
} else {
|
|
||||||
const index = rows?.findIndex(
|
|
||||||
(row) => BigInt(row.price) <= BigInt(bestStaticBidPrice)
|
|
||||||
);
|
|
||||||
if (index !== undefined && index !== -1) {
|
|
||||||
bestStaticBidPriceLinePosition = (
|
|
||||||
index * rowHeight +
|
|
||||||
headerPadding -
|
|
||||||
3
|
|
||||||
).toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return bestStaticBidPriceLinePosition;
|
|
||||||
};
|
|
||||||
const getBestStaticOfferPriceLinePosition = (
|
|
||||||
bestStaticOfferPrice: string | undefined,
|
|
||||||
fillGaps: boolean,
|
|
||||||
maxPriceLevel: string,
|
|
||||||
minPriceLevel: string,
|
|
||||||
resolution: number,
|
|
||||||
rows: OrderbookRowData[] | null
|
|
||||||
) => {
|
|
||||||
let bestStaticOfferPriceLinePosition = '';
|
|
||||||
if (
|
|
||||||
rows?.length &&
|
|
||||||
bestStaticOfferPrice &&
|
|
||||||
BigInt(bestStaticOfferPrice) <= BigInt(maxPriceLevel) &&
|
|
||||||
BigInt(bestStaticOfferPrice) > BigInt(minPriceLevel)
|
|
||||||
) {
|
|
||||||
if (fillGaps) {
|
|
||||||
bestStaticOfferPriceLinePosition = (
|
|
||||||
((BigInt(maxPriceLevel) - BigInt(bestStaticOfferPrice)) /
|
|
||||||
BigInt(resolution) +
|
|
||||||
BigInt(1)) *
|
|
||||||
BigInt(rowHeight) +
|
|
||||||
BigInt(headerPadding) -
|
|
||||||
BigInt(3)
|
|
||||||
).toString();
|
|
||||||
} else {
|
|
||||||
const index = rows?.findIndex(
|
|
||||||
(row) => BigInt(row.price) <= BigInt(bestStaticOfferPrice)
|
|
||||||
);
|
|
||||||
if (index !== undefined && index !== -1) {
|
|
||||||
bestStaticOfferPriceLinePosition = (
|
|
||||||
(index + 1) * rowHeight +
|
|
||||||
headerPadding -
|
|
||||||
3
|
|
||||||
).toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return bestStaticOfferPriceLinePosition;
|
|
||||||
};
|
|
||||||
const OrderbookDebugInfo = ({
|
|
||||||
decimalPlaces,
|
|
||||||
numberOfRows,
|
|
||||||
viewportHeight,
|
|
||||||
lockOnMidPrice,
|
|
||||||
priceInCenter,
|
|
||||||
bestStaticBidPrice,
|
|
||||||
bestStaticOfferPrice,
|
|
||||||
maxPriceLevel,
|
|
||||||
minPriceLevel,
|
|
||||||
midPrice,
|
|
||||||
}: {
|
|
||||||
decimalPlaces: number;
|
|
||||||
numberOfRows: number;
|
|
||||||
viewportHeight: number;
|
|
||||||
lockOnMidPrice: boolean;
|
|
||||||
priceInCenter?: string;
|
|
||||||
bestStaticBidPrice?: string;
|
|
||||||
bestStaticOfferPrice?: string;
|
|
||||||
maxPriceLevel: string;
|
|
||||||
minPriceLevel: string;
|
|
||||||
midPrice?: string;
|
midPrice?: string;
|
||||||
}) => (
|
bids: PriceLevelFieldsFragment[];
|
||||||
<Fragment>
|
asks: PriceLevelFieldsFragment[];
|
||||||
<div className="absolute top-1/2 left-0 border-t border-t-black w-full" />
|
}
|
||||||
<div className="text-xs p-2 bg-black/80 text-white absolute left-0 bottom-6 font-mono">
|
|
||||||
<pre>
|
|
||||||
{JSON.stringify(
|
|
||||||
{
|
|
||||||
numberOfRows,
|
|
||||||
viewportHeight,
|
|
||||||
lockOnMidPrice,
|
|
||||||
priceInCenter: priceInCenter
|
|
||||||
? addDecimalsFixedFormatNumber(priceInCenter, decimalPlaces)
|
|
||||||
: '-',
|
|
||||||
maxPriceLevel: addDecimalsFixedFormatNumber(
|
|
||||||
maxPriceLevel ?? '0',
|
|
||||||
decimalPlaces
|
|
||||||
),
|
|
||||||
bestStaticBidPrice: addDecimalsFixedFormatNumber(
|
|
||||||
bestStaticBidPrice ?? '0',
|
|
||||||
decimalPlaces
|
|
||||||
),
|
|
||||||
bestStaticOfferPrice: addDecimalsFixedFormatNumber(
|
|
||||||
bestStaticOfferPrice ?? '0',
|
|
||||||
decimalPlaces
|
|
||||||
),
|
|
||||||
minPriceLevel: addDecimalsFixedFormatNumber(
|
|
||||||
minPriceLevel ?? '0',
|
|
||||||
decimalPlaces
|
|
||||||
),
|
|
||||||
midPrice: addDecimalsFixedFormatNumber(
|
|
||||||
midPrice ?? '0',
|
|
||||||
decimalPlaces
|
|
||||||
),
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const Orderbook = ({
|
// Sets row height, will be used to calculate number of rows that can be
|
||||||
|
// displayed each side of the book without overflow
|
||||||
|
export const rowHeight = 17;
|
||||||
|
const midHeight = 30;
|
||||||
|
|
||||||
|
const OrderbookTable = ({
|
||||||
rows,
|
rows,
|
||||||
midPrice,
|
resolution,
|
||||||
bestStaticBidPrice,
|
type,
|
||||||
bestStaticOfferPrice,
|
|
||||||
marketTradingMode,
|
|
||||||
indicativeVolume,
|
|
||||||
indicativePrice,
|
|
||||||
decimalPlaces,
|
decimalPlaces,
|
||||||
positionDecimalPlaces,
|
positionDecimalPlaces,
|
||||||
resolution,
|
|
||||||
fillGaps: initialFillGaps,
|
|
||||||
onResolutionChange,
|
|
||||||
onClick,
|
onClick,
|
||||||
}: OrderbookProps) => {
|
}: {
|
||||||
const { theme } = useThemeSwitcher();
|
rows: OrderbookRowData[];
|
||||||
const scrollElement = useRef<HTMLDivElement>(null);
|
resolution: number;
|
||||||
const rootElement = useRef<HTMLDivElement>(null);
|
decimalPlaces: number;
|
||||||
const gridElement = useRef<HTMLDivElement>(null);
|
positionDecimalPlaces: number;
|
||||||
const headerElement = useRef<HTMLDivElement>(null);
|
type: VolumeType;
|
||||||
const footerElement = useRef<HTMLDivElement>(null);
|
onClick?: (price: string) => void;
|
||||||
// scroll offset for which rendered rows are selected, will change after user will scroll to margin of rendered data
|
}) => {
|
||||||
const [scrollOffset, setScrollOffset] = useState(0);
|
|
||||||
// actual scrollTop of scrollElement current element
|
|
||||||
const scrollTopRef = useRef(0);
|
|
||||||
// price level which is rendered in center of viewport, need to preserve price level when rows will be added or removed
|
|
||||||
// if undefined then we render mid price in center
|
|
||||||
const priceInCenter = useRef<string>();
|
|
||||||
// by default mid price is rendered in center - view locked on mid price
|
|
||||||
const [lockOnMidPrice, setLockOnMidPrice] = useState(true);
|
|
||||||
const resolutionRef = useRef(resolution);
|
|
||||||
const [viewportHeight, setViewportHeight] = useState(window.innerHeight);
|
|
||||||
// show price levels with no orders, can lead to enormous number of rows
|
|
||||||
const [fillGaps, setFillGaps] = useState(!!initialFillGaps);
|
|
||||||
const [debug, setDebug] = useState(false);
|
|
||||||
|
|
||||||
const numberOfRows = fillGaps
|
|
||||||
? getNumberOfRows(rows, resolution)
|
|
||||||
: rows?.length ?? 0;
|
|
||||||
const maxPriceLevel = rows?.[0]?.price ?? '0';
|
|
||||||
const minPriceLevel = rows?.[rows.length - 1]?.price ?? '0';
|
|
||||||
|
|
||||||
let offset = Math.max(0, Math.round(scrollOffset / rowHeight));
|
|
||||||
const prependingBufferSize = Math.min(bufferSize, offset);
|
|
||||||
offset -= prependingBufferSize;
|
|
||||||
const viewportSize = Math.round(viewportHeight / rowHeight);
|
|
||||||
const limit = Math.min(
|
|
||||||
prependingBufferSize + viewportSize + bufferSize,
|
|
||||||
numberOfRows - offset
|
|
||||||
);
|
|
||||||
const data = fillGaps
|
|
||||||
? getRowsToRender(rows, resolution, offset, limit)
|
|
||||||
: rows?.slice(offset, offset + limit) ?? [];
|
|
||||||
|
|
||||||
const paddingTop = offset * rowHeight + headerPadding;
|
|
||||||
const paddingBottom =
|
|
||||||
(numberOfRows - offset - limit) * rowHeight + footerPadding;
|
|
||||||
|
|
||||||
const updateScrollOffset = useCallback(
|
|
||||||
(scrollTop: number) => {
|
|
||||||
if (Math.abs(scrollOffset - scrollTop) > marginSize) {
|
|
||||||
setScrollOffset(scrollTop);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[scrollOffset]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onScroll = useCallback(
|
|
||||||
(event: React.UIEvent<HTMLDivElement>) => {
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
|
|
||||||
updateScrollOffset(scrollTop);
|
|
||||||
if (scrollTop === scrollTopRef.current) {
|
|
||||||
return;
|
|
||||||
} else if ((scrollTop - scrollTopRef.current) % rowHeight === 0) {
|
|
||||||
if (scrollElement.current) {
|
|
||||||
scrollElement.current.scrollTop = scrollTopRef.current;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (scrollTop === 0 || scrollHeight === clientHeight + scrollTop) {
|
|
||||||
priceInCenter.current = undefined;
|
|
||||||
} else {
|
|
||||||
// top offset in rows to row in the middle
|
|
||||||
const offsetTop = Math.floor(
|
|
||||||
(scrollTop +
|
|
||||||
Math.floor((viewportHeight - footerPadding - headerPadding) / 2)) /
|
|
||||||
rowHeight
|
|
||||||
);
|
|
||||||
priceInCenter.current = fillGaps
|
|
||||||
? (
|
|
||||||
BigInt(maxPriceLevel) -
|
|
||||||
BigInt(offsetTop) * BigInt(resolution)
|
|
||||||
).toString()
|
|
||||||
: rows?.[Math.min(offsetTop, rows.length - 1)].price.toString();
|
|
||||||
}
|
|
||||||
if (lockOnMidPrice) {
|
|
||||||
setLockOnMidPrice(false);
|
|
||||||
}
|
|
||||||
scrollTopRef.current = scrollTop;
|
|
||||||
},
|
|
||||||
[
|
|
||||||
resolution,
|
|
||||||
lockOnMidPrice,
|
|
||||||
maxPriceLevel,
|
|
||||||
viewportHeight,
|
|
||||||
updateScrollOffset,
|
|
||||||
fillGaps,
|
|
||||||
rows,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const scrollToPrice = useCallback(
|
|
||||||
(price: string) => {
|
|
||||||
if (scrollElement.current && maxPriceLevel !== '0') {
|
|
||||||
let scrollTop = 0;
|
|
||||||
if (fillGaps) {
|
|
||||||
scrollTop =
|
|
||||||
// distance in rows between given price and first row price * row Height
|
|
||||||
(Number(
|
|
||||||
(BigInt(maxPriceLevel) - BigInt(price)) / BigInt(resolution)
|
|
||||||
) +
|
|
||||||
1) *
|
|
||||||
rowHeight;
|
|
||||||
} else if (rows) {
|
|
||||||
const index = rows.findIndex(
|
|
||||||
(row) => BigInt(row.price) <= BigInt(price)
|
|
||||||
);
|
|
||||||
if (index !== -1) {
|
|
||||||
scrollTop = rowHeight * (index + 1);
|
|
||||||
if (index !== 0) {
|
|
||||||
const diffToCurrentRow =
|
|
||||||
BigInt(price) - BigInt(rows[index].price);
|
|
||||||
const diffToPreviousRow =
|
|
||||||
BigInt(rows[index - 1].price) - BigInt(price);
|
|
||||||
if (diffToPreviousRow < diffToCurrentRow) {
|
|
||||||
scrollTop -= rowHeight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// minus half height of viewport plus half of row
|
|
||||||
scrollTop -= Math.ceil((viewportHeight - rowHeight) / 2);
|
|
||||||
// adjust to current rows position
|
|
||||||
scrollTop +=
|
|
||||||
(scrollTopRef.current % rowHeight) - (scrollTop % rowHeight);
|
|
||||||
const priceCenterScrollOffset = Math.max(
|
|
||||||
0,
|
|
||||||
Math.min(
|
|
||||||
scrollTop,
|
|
||||||
numberOfRows * rowHeight +
|
|
||||||
headerPadding +
|
|
||||||
footerPadding +
|
|
||||||
-viewportHeight -
|
|
||||||
gridGap
|
|
||||||
)
|
|
||||||
);
|
|
||||||
if (scrollTopRef.current !== priceCenterScrollOffset) {
|
|
||||||
updateScrollOffset(priceCenterScrollOffset);
|
|
||||||
scrollTopRef.current = priceCenterScrollOffset;
|
|
||||||
scrollElement.current.scrollTop = priceCenterScrollOffset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
maxPriceLevel,
|
|
||||||
resolution,
|
|
||||||
viewportHeight,
|
|
||||||
numberOfRows,
|
|
||||||
updateScrollOffset,
|
|
||||||
fillGaps,
|
|
||||||
rows,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const scrollToMidPrice = useCallback(() => {
|
|
||||||
if (!midPrice) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
priceInCenter.current = undefined;
|
|
||||||
scrollToPrice(midPrice);
|
|
||||||
setLockOnMidPrice(true);
|
|
||||||
}, [midPrice, scrollToPrice]);
|
|
||||||
|
|
||||||
// adjust scroll position to keep selected price in center
|
|
||||||
useEffect(() => {
|
|
||||||
if (priceInCenter.current) {
|
|
||||||
scrollToPrice(priceInCenter.current);
|
|
||||||
} else if (lockOnMidPrice && midPrice) {
|
|
||||||
scrollToPrice(midPrice);
|
|
||||||
}
|
|
||||||
}, [midPrice, scrollToPrice, lockOnMidPrice]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (resolutionRef.current !== resolution) {
|
|
||||||
priceInCenter.current = undefined;
|
|
||||||
resolutionRef.current = resolution;
|
|
||||||
setLockOnMidPrice(true);
|
|
||||||
}
|
|
||||||
}, [resolution]);
|
|
||||||
|
|
||||||
// handles resizing of the Allotment.Pane (x-axis)
|
|
||||||
// adjusts the header and footer width
|
|
||||||
const gridResizeHandler: ResizeObserverCallback = useCallback(
|
|
||||||
(entries) => {
|
|
||||||
if (
|
|
||||||
!headerElement.current ||
|
|
||||||
!footerElement.current ||
|
|
||||||
entries.length === 0
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const {
|
|
||||||
contentRect: { width },
|
|
||||||
} = entries[0];
|
|
||||||
headerElement.current.style.width = `${width}px`;
|
|
||||||
footerElement.current.style.width = `${width}px`;
|
|
||||||
},
|
|
||||||
[headerElement, footerElement]
|
|
||||||
);
|
|
||||||
// handles resizing of the Allotment.Pane (y-axis)
|
|
||||||
// adjusts the scroll height
|
|
||||||
const rootElementResizeHandler: ResizeObserverCallback = useCallback(
|
|
||||||
(entries) => {
|
|
||||||
if (!rootElement.current || entries.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setViewportHeight(entries[0].contentRect.height);
|
|
||||||
},
|
|
||||||
[setViewportHeight, rootElement]
|
|
||||||
);
|
|
||||||
useResizeObserver(gridElement.current, gridResizeHandler);
|
|
||||||
useResizeObserver(rootElement.current, rootElementResizeHandler);
|
|
||||||
const isContinuousMode =
|
|
||||||
marketTradingMode === Schema.MarketTradingMode.TRADING_MODE_CONTINUOUS;
|
|
||||||
const tableHeader = useMemo(() => {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={
|
||||||
'absolute top-0 grid auto-rows-[17px] gap-2 text-right border-b pt-2 bg-white dark:bg-black z-10 border-default w-full',
|
// position the ask side to the bottow of the top section and the bid side to the top of the bottom section
|
||||||
isContinuousMode ? 'grid-cols-3' : 'grid-cols-4'
|
classNames(
|
||||||
)}
|
'flex flex-col',
|
||||||
ref={headerElement}
|
type === VolumeType.ask ? 'justify-end' : 'justify-start'
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isContinuousMode ? (
|
<div className={`grid auto-rows-[${rowHeight}px]`}>
|
||||||
<div>{t('Bid / Ask vol')}</div>
|
{rows.map((data) => (
|
||||||
) : (
|
<OrderbookRow
|
||||||
<>
|
|
||||||
<div>{t('Bid vol')}</div>
|
|
||||||
<div>{t('Ask vol')}</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div>{t('Price')}</div>
|
|
||||||
<div className="pr-1 whitespace-nowrap overflow-hidden text-ellipsis">
|
|
||||||
{t('Cumulative vol')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}, [isContinuousMode]);
|
|
||||||
|
|
||||||
const OrderBookRowComponent = isContinuousMode
|
|
||||||
? OrderbookContinuousRow
|
|
||||||
: OrderbookRow;
|
|
||||||
|
|
||||||
const tableBody = data?.length ? (
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
'grid grid-cols-4 gap-1 text-right auto-rows-[17px]',
|
|
||||||
isContinuousMode ? 'grid-cols-3' : 'grid-cols-4'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{data.map((data, i) => (
|
|
||||||
<OrderBookRowComponent
|
|
||||||
key={data.price}
|
key={data.price}
|
||||||
price={(BigInt(data.price) / BigInt(resolution)).toString()}
|
price={(BigInt(data.price) / BigInt(resolution)).toString()}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
decimalPlaces={decimalPlaces - Math.log10(resolution)}
|
decimalPlaces={decimalPlaces - Math.log10(resolution)}
|
||||||
positionDecimalPlaces={positionDecimalPlaces}
|
positionDecimalPlaces={positionDecimalPlaces}
|
||||||
bid={data.bid}
|
value={data.value}
|
||||||
relativeBid={data.relativeBid}
|
cumulativeValue={data.cumulativeVol.value}
|
||||||
cumulativeBid={data.cumulativeVol.bid}
|
cumulativeRelativeValue={data.cumulativeVol.relativeValue}
|
||||||
cumulativeRelativeBid={data.cumulativeVol.relativeBid}
|
type={type}
|
||||||
ask={data.ask}
|
|
||||||
relativeAsk={data.relativeAsk}
|
|
||||||
cumulativeAsk={data.cumulativeVol.ask}
|
|
||||||
cumulativeRelativeAsk={data.cumulativeVol.relativeAsk}
|
|
||||||
indicativeVolume={
|
|
||||||
marketTradingMode !==
|
|
||||||
Schema.MarketTradingMode.TRADING_MODE_CONTINUOUS &&
|
|
||||||
indicativePrice === data.price
|
|
||||||
? indicativeVolume
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const c = theme === 'dark' ? colors.neutral[600] : colors.neutral[300];
|
export const Orderbook = ({
|
||||||
const gradientStyles = isContinuousMode
|
decimalPlaces,
|
||||||
? `linear-gradient(${c},${c}) 33.4% 0/1px 100% no-repeat, linear-gradient(${c},${c}) 66.7% 0/1px 100% no-repeat`
|
positionDecimalPlaces,
|
||||||
: `linear-gradient(${c},${c}) 25% 0/1px 100% no-repeat, linear-gradient(${c},${c}) 50% 0/1px 100% no-repeat, linear-gradient(${c},${c}) 75% 0/1px 100% no-repeat`;
|
onClick,
|
||||||
|
midPrice,
|
||||||
const resolutions = new Array(decimalPlaces + 1)
|
asks,
|
||||||
|
bids,
|
||||||
|
}: OrderbookProps) => {
|
||||||
|
const [resolution, setResolution] = useState(1);
|
||||||
|
const resolutions = new Array(
|
||||||
|
Math.max(midPrice?.toString().length ?? 0, decimalPlaces + 1)
|
||||||
|
)
|
||||||
.fill(null)
|
.fill(null)
|
||||||
.map((v, i) => Math.pow(10, i));
|
.map((v, i) => Math.pow(10, i));
|
||||||
|
|
||||||
const bestStaticBidPriceLinePosition = getBestStaticBidPriceLinePosition(
|
const groupedAsks = useMemo(() => {
|
||||||
bestStaticBidPrice,
|
return compactRows(asks, VolumeType.ask, resolution);
|
||||||
fillGaps,
|
}, [asks, resolution]);
|
||||||
maxPriceLevel,
|
|
||||||
minPriceLevel,
|
|
||||||
resolution,
|
|
||||||
rows
|
|
||||||
);
|
|
||||||
|
|
||||||
const bestStaticOfferPriceLinePosition = getBestStaticOfferPriceLinePosition(
|
const groupedBids = useMemo(() => {
|
||||||
bestStaticOfferPrice,
|
return compactRows(bids, VolumeType.bid, resolution);
|
||||||
fillGaps,
|
}, [bids, resolution]);
|
||||||
maxPriceLevel,
|
|
||||||
minPriceLevel,
|
|
||||||
resolution,
|
|
||||||
rows
|
|
||||||
);
|
|
||||||
|
|
||||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
return (
|
||||||
|
<div className="h-full pl-1 text-xs grid grid-rows-[1fr_min-content]">
|
||||||
|
<div>
|
||||||
|
<ReactVirtualizedAutoSizer disableWidth>
|
||||||
|
{({ height }) => {
|
||||||
|
const limit = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor((height - midHeight) / 2 / rowHeight)
|
||||||
|
);
|
||||||
|
const askRows = groupedAsks?.slice(limit * -1) ?? [];
|
||||||
|
const bidRows = groupedBids?.slice(0, limit) ?? [];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="h-full relative pl-1 text-xs"
|
className={`overflow-hidden grid grid-rows-[1fr_${midHeight}px_1fr]`}
|
||||||
ref={rootElement}
|
data-testid="orderbook-grid-element"
|
||||||
onDoubleClick={() => setDebug(!debug)}
|
style={{ height: height + 'px' }}
|
||||||
>
|
>
|
||||||
{tableHeader}
|
{askRows.length || bidRows.length ? (
|
||||||
<TinyScroll
|
<>
|
||||||
className="h-full overflow-auto relative"
|
<OrderbookTable
|
||||||
onScroll={onScroll}
|
rows={askRows}
|
||||||
ref={scrollElement}
|
type={VolumeType.ask}
|
||||||
data-testid="scroll"
|
resolution={resolution}
|
||||||
|
decimalPlaces={decimalPlaces}
|
||||||
|
positionDecimalPlaces={positionDecimalPlaces}
|
||||||
|
onClick={onClick}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-center text-lg">
|
||||||
|
{midPrice && (
|
||||||
|
<span
|
||||||
|
className="font-mono"
|
||||||
|
data-testid={`middle-mark-price-${midPrice}`}
|
||||||
>
|
>
|
||||||
<div
|
{addDecimalsFormatNumber(midPrice, decimalPlaces)}
|
||||||
className="relative text-right min-h-full overflow-hidden"
|
</span>
|
||||||
style={{
|
)}
|
||||||
paddingTop,
|
</div>
|
||||||
paddingBottom,
|
<OrderbookTable
|
||||||
background: tableBody ? gradientStyles : 'none',
|
rows={bidRows}
|
||||||
}}
|
type={VolumeType.bid}
|
||||||
ref={gridElement}
|
resolution={resolution}
|
||||||
>
|
decimalPlaces={decimalPlaces}
|
||||||
{tableBody || (
|
positionDecimalPlaces={positionDecimalPlaces}
|
||||||
|
onClick={onClick}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<div className="inset-0 absolute">
|
<div className="inset-0 absolute">
|
||||||
<Splash>{t('No data')}</Splash>
|
<Splash>{t('No data')}</Splash>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{bestStaticBidPriceLinePosition && (
|
);
|
||||||
<HorizontalLine
|
}}
|
||||||
top={`${bestStaticBidPriceLinePosition}px`}
|
</ReactVirtualizedAutoSizer>
|
||||||
testId="best-static-bid-price"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{bestStaticOfferPriceLinePosition && (
|
|
||||||
<HorizontalLine
|
|
||||||
top={`${bestStaticOfferPriceLinePosition}px`}
|
|
||||||
testId={'best-static-offer-price'}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</TinyScroll>
|
|
||||||
<div
|
|
||||||
className="absolute bottom-0 grid grid-cols-4 gap-2 border-t border-default mt-2 z-10 bg-white dark:bg-black w-full"
|
|
||||||
ref={footerElement}
|
|
||||||
>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<Checkbox
|
|
||||||
name="empty-prices"
|
|
||||||
checked={fillGaps}
|
|
||||||
onCheckedChange={() => setFillGaps((curr) => !curr)}
|
|
||||||
label={
|
|
||||||
<span className="text-xs">{t('Show prices with no orders')}</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="col-start-3">
|
<div className="border-t border-default">
|
||||||
<select
|
<select
|
||||||
onChange={(e) => onResolutionChange(Number(e.currentTarget.value))}
|
onChange={(e) => {
|
||||||
|
setResolution(Number(e.currentTarget.value));
|
||||||
|
}}
|
||||||
value={resolution}
|
value={resolution}
|
||||||
className="block bg-neutral-100 dark:bg-neutral-700 font-mono text-right w-full h-full"
|
className="block bg-neutral-100 dark:bg-neutral-700 font-mono text-right"
|
||||||
data-testid="resolution"
|
data-testid="resolution"
|
||||||
>
|
>
|
||||||
{resolutions.map((r) => (
|
{resolutions.map((r) => (
|
||||||
<option key={r} value={r}>
|
<option key={r} value={r}>
|
||||||
{formatNumberFixed(0, decimalPlaces - Math.log10(r))}
|
{formatNumberFixed(
|
||||||
|
Math.log10(r) - decimalPlaces > 0
|
||||||
|
? Math.pow(10, Math.log10(r) - decimalPlaces)
|
||||||
|
: 0,
|
||||||
|
decimalPlaces - Math.log10(r)
|
||||||
|
)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-start-4 whitespace-nowrap overflow-hidden text-ellipsis">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={scrollToMidPrice}
|
|
||||||
className={classNames('w-full h-full', {
|
|
||||||
hidden: lockOnMidPrice,
|
|
||||||
block: !lockOnMidPrice,
|
|
||||||
})}
|
|
||||||
data-testid="scroll-to-midprice"
|
|
||||||
>
|
|
||||||
{t('Go to mid')}
|
|
||||||
<span className="ml-4">
|
|
||||||
<Icon name="th-derived" />
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{debug && (
|
|
||||||
<OrderbookDebugInfo
|
|
||||||
decimalPlaces={decimalPlaces}
|
|
||||||
midPrice={midPrice}
|
|
||||||
numberOfRows={numberOfRows}
|
|
||||||
viewportHeight={viewportHeight}
|
|
||||||
lockOnMidPrice={lockOnMidPrice}
|
|
||||||
priceInCenter={priceInCenter.current}
|
|
||||||
maxPriceLevel={maxPriceLevel}
|
|
||||||
bestStaticBidPrice={bestStaticBidPrice}
|
|
||||||
bestStaticOfferPrice={bestStaticOfferPrice}
|
|
||||||
minPriceLevel={minPriceLevel}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
/* eslint-enable jsx-a11y/no-static-element-interactions */
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Orderbook;
|
export default Orderbook;
|
||||||
|
Loading…
Reference in New Issue
Block a user