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>
|
||||
</ResizableGridPanel>
|
||||
<ResizableGridPanel
|
||||
preferredSize={sizesMiddle[2] || 430}
|
||||
preferredSize={sizesMiddle[2] || 300}
|
||||
minSize={200}
|
||||
>
|
||||
<TradeGridChild>
|
||||
|
@ -1,10 +1,12 @@
|
||||
import { forwardRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { getDecimalSeparator, isNumeric } from '@vegaprotocol/utils';
|
||||
|
||||
interface NumericCellProps {
|
||||
value: number | bigint | null | undefined;
|
||||
valueFormatted: string;
|
||||
testId?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -12,7 +14,7 @@ interface NumericCellProps {
|
||||
* use, right aligned, monospace and decimals deemphasised
|
||||
*/
|
||||
export const NumericCell = forwardRef<HTMLSpanElement, NumericCellProps>(
|
||||
({ value, valueFormatted, testId }, ref) => {
|
||||
({ value, valueFormatted, testId, className }, ref) => {
|
||||
if (!isNumeric(value)) {
|
||||
return (
|
||||
<span ref={ref} data-testid={testId}>
|
||||
@ -29,7 +31,10 @@ export const NumericCell = forwardRef<HTMLSpanElement, NumericCellProps>(
|
||||
return (
|
||||
<span
|
||||
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}
|
||||
title={valueFormatted}
|
||||
>
|
||||
|
@ -16,7 +16,7 @@ export const OrderTypeCell = ({
|
||||
data: order,
|
||||
onClick,
|
||||
}: OrderTypeCellProps) => {
|
||||
const id = order ? order.market.id : '';
|
||||
const id = order?.market?.id ?? '';
|
||||
|
||||
const label = useMemo(() => {
|
||||
if (!order) {
|
||||
|
@ -6,11 +6,15 @@ export interface IPriceCellProps {
|
||||
valueFormatted: string;
|
||||
testId?: string;
|
||||
onClick?: (price?: string | number) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const PriceCell = memo(
|
||||
forwardRef<HTMLSpanElement, IPriceCellProps>(
|
||||
({ value, valueFormatted, testId, onClick }: IPriceCellProps, ref) => {
|
||||
(
|
||||
{ value, valueFormatted, testId, onClick, className }: IPriceCellProps,
|
||||
ref
|
||||
) => {
|
||||
if (!isNumeric(value)) {
|
||||
return (
|
||||
<span data-testid="price" ref={ref}>
|
||||
@ -27,6 +31,7 @@ export const PriceCell = memo(
|
||||
value={value}
|
||||
valueFormatted={valueFormatted}
|
||||
testId={testId || 'price'}
|
||||
className={className}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
|
@ -1,9 +1,4 @@
|
||||
import {
|
||||
compactRows,
|
||||
updateLevels,
|
||||
updateCompactedRows,
|
||||
} from './orderbook-data';
|
||||
import type { OrderbookRowData } from './orderbook-data';
|
||||
import { compactRows, updateLevels, VolumeType } from './orderbook-data';
|
||||
import type { PriceLevelFieldsFragment } from './__generated__/MarketDepth';
|
||||
|
||||
describe('compactRows', () => {
|
||||
@ -26,51 +21,31 @@ describe('compactRows', () => {
|
||||
numberOfOrders: (numberOfRows - i).toString(),
|
||||
}));
|
||||
it('groups data by price and resolution', () => {
|
||||
expect(compactRows(sell, buy, 1).length).toEqual(200);
|
||||
expect(compactRows(sell, buy, 5).length).toEqual(41);
|
||||
expect(compactRows(sell, buy, 10).length).toEqual(21);
|
||||
expect(compactRows(sell, VolumeType.ask, 1).length).toEqual(100);
|
||||
expect(compactRows(buy, VolumeType.bid, 1).length).toEqual(100);
|
||||
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', () => {
|
||||
const orderbookRows = compactRows(sell, buy, 10);
|
||||
expect(orderbookRows[0].cumulativeVol.ask).toEqual(4950);
|
||||
expect(orderbookRows[0].cumulativeVol.bid).toEqual(0);
|
||||
expect(orderbookRows[10].cumulativeVol.ask).toEqual(390);
|
||||
expect(orderbookRows[10].cumulativeVol.bid).toEqual(579);
|
||||
expect(orderbookRows[orderbookRows.length - 1].cumulativeVol.bid).toEqual(
|
||||
4950
|
||||
);
|
||||
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,
|
||||
});
|
||||
const asks = compactRows(sell, VolumeType.ask, 10);
|
||||
const bids = compactRows(buy, VolumeType.bid, 10);
|
||||
expect(asks[0].cumulativeVol.value).toEqual(4950);
|
||||
expect(bids[0].cumulativeVol.value).toEqual(579);
|
||||
expect(asks[10].cumulativeVol.value).toEqual(390);
|
||||
expect(bids[10].cumulativeVol.value).toEqual(4950);
|
||||
expect(bids[bids.length - 1].cumulativeVol.value).toEqual(4950);
|
||||
expect(asks[asks.length - 1].cumulativeVol.value).toEqual(390);
|
||||
});
|
||||
|
||||
it('updates relative data', () => {
|
||||
const orderbookRows = compactRows(sell, buy, 10);
|
||||
expect(orderbookRows[0].cumulativeVol.relativeAsk).toEqual(100);
|
||||
expect(orderbookRows[0].cumulativeVol.relativeBid).toEqual(0);
|
||||
expect(orderbookRows[0].relativeAsk).toEqual(2);
|
||||
expect(orderbookRows[0].relativeBid).toEqual(0);
|
||||
expect(orderbookRows[10].cumulativeVol.relativeAsk).toEqual(8);
|
||||
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);
|
||||
const asks = compactRows(sell, VolumeType.ask, 10);
|
||||
const bids = compactRows(buy, VolumeType.bid, 10);
|
||||
expect(asks[0].cumulativeVol.relativeValue).toEqual(100);
|
||||
expect(bids[0].cumulativeVol.relativeValue).toEqual(12);
|
||||
expect(asks[10].cumulativeVol.relativeValue).toEqual(8);
|
||||
expect(bids[10].cumulativeVol.relativeValue).toEqual(100);
|
||||
});
|
||||
});
|
||||
|
||||
@ -130,167 +105,3 @@ describe('updateLevels', () => {
|
||||
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 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';
|
||||
|
||||
export enum VolumeType {
|
||||
@ -11,39 +6,16 @@ export enum VolumeType {
|
||||
ask,
|
||||
}
|
||||
export interface CumulativeVol {
|
||||
bid: number;
|
||||
relativeBid?: number;
|
||||
ask: number;
|
||||
relativeAsk?: number;
|
||||
value: number;
|
||||
relativeValue?: number;
|
||||
}
|
||||
|
||||
export interface OrderbookRowData {
|
||||
price: string;
|
||||
bid: number;
|
||||
bidByLevel: Record<string, number>;
|
||||
relativeBid?: number;
|
||||
ask: number;
|
||||
askByLevel: Record<string, number>;
|
||||
relativeAsk?: number;
|
||||
value: number;
|
||||
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) => {
|
||||
const p = BigInt(price);
|
||||
const r = BigInt(resolution);
|
||||
@ -54,135 +26,66 @@ export const getPriceLevel = (price: string | bigint, resolution: number) => {
|
||||
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[]) => ({
|
||||
bid: Math.max(...orderbookData.map((data) => data.bid)),
|
||||
ask: Math.max(...orderbookData.map((data) => data.ask)),
|
||||
cumulativeVol: Math.max(
|
||||
orderbookData[0]?.cumulativeVol.ask,
|
||||
orderbookData[orderbookData.length - 1]?.cumulativeVol.bid
|
||||
orderbookData[0]?.cumulativeVol.value,
|
||||
orderbookData[orderbookData.length - 1]?.cumulativeVol.value
|
||||
),
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
/**
|
||||
* @summary Updates relativeAsk, relativeBid, cumulativeVol.relativeAsk, cumulativeVol.relativeBid
|
||||
*/
|
||||
const updateRelativeData = (data: OrderbookRowData[]) => {
|
||||
const { bid, ask, cumulativeVol } = getMaxVolumes(data);
|
||||
const maxBidAsk = Math.max(bid, ask);
|
||||
const { cumulativeVol } = getMaxVolumes(data);
|
||||
data.forEach((data, i) => {
|
||||
data.relativeAsk = toPercentValue(data.ask / maxBidAsk);
|
||||
data.relativeBid = toPercentValue(data.bid / maxBidAsk);
|
||||
data.cumulativeVol.relativeAsk = toPercentValue(
|
||||
data.cumulativeVol.ask / cumulativeVol
|
||||
);
|
||||
data.cumulativeVol.relativeBid = toPercentValue(
|
||||
data.cumulativeVol.bid / cumulativeVol
|
||||
data.cumulativeVol.relativeValue = toPercentValue(
|
||||
data.cumulativeVol.value / cumulativeVol
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const updateCumulativeVolume = (data: OrderbookRowData[]) => {
|
||||
if (data.length > 1) {
|
||||
const updateCumulativeVolumeByType = (
|
||||
data: OrderbookRowData[],
|
||||
dataType: VolumeType
|
||||
) => {
|
||||
if (data.length) {
|
||||
const maxIndex = data.length - 1;
|
||||
for (let i = 0; i <= maxIndex; i++) {
|
||||
data[i].cumulativeVol.bid =
|
||||
data[i].bid + (i !== 0 ? data[i - 1].cumulativeVol.bid : 0);
|
||||
}
|
||||
for (let i = maxIndex; i >= 0; i--) {
|
||||
data[i].cumulativeVol.ask =
|
||||
data[i].ask + (i !== maxIndex ? data[i + 1].cumulativeVol.ask : 0);
|
||||
if (dataType === VolumeType.bid) {
|
||||
for (let i = 0; i <= maxIndex; i++) {
|
||||
data[i].cumulativeVol.value =
|
||||
data[i].value + (i !== 0 ? data[i - 1].cumulativeVol.value : 0);
|
||||
}
|
||||
} else {
|
||||
for (let i = maxIndex; i >= 0; i--) {
|
||||
data[i].cumulativeVol.value =
|
||||
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 = (
|
||||
sell: PriceLevelFieldsFragment[] | null | undefined,
|
||||
buy: PriceLevelFieldsFragment[] | null | undefined,
|
||||
data: PriceLevelFieldsFragment[] | null | undefined,
|
||||
dataType: VolumeType,
|
||||
resolution: number
|
||||
) => {
|
||||
// map raw sell data to OrderbookData
|
||||
const askOrderbookData = [...(sell ?? [])].map<PartialOrderbookRowData>(
|
||||
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 groupedByLevel = groupBy(data, (row) =>
|
||||
getPriceLevel(row.price, resolution)
|
||||
);
|
||||
const orderbookData: OrderbookRowData[] = [];
|
||||
Object.keys(groupedByLevel).forEach((price) => {
|
||||
const row = extendRow(
|
||||
groupedByLevel[price].pop() as PartialOrderbookRowData
|
||||
);
|
||||
row.price = price;
|
||||
let subRow: PartialOrderbookRowData | undefined =
|
||||
groupedByLevel[price].pop();
|
||||
const { volume } = groupedByLevel[price].pop() as PriceLevelFieldsFragment;
|
||||
let value = Number(volume);
|
||||
let subRow: { volume: string } | undefined = groupedByLevel[price].pop();
|
||||
while (subRow) {
|
||||
row.ask += subRow.ask;
|
||||
row.bid += subRow.bid;
|
||||
if (subRow.ask) {
|
||||
row.askByLevel[subRow.price] = subRow.ask;
|
||||
}
|
||||
if (subRow.bid) {
|
||||
row.bidByLevel[subRow.price] = subRow.bid;
|
||||
}
|
||||
value += Number(subRow.volume);
|
||||
subRow = groupedByLevel[price].pop();
|
||||
}
|
||||
orderbookData.push(row);
|
||||
orderbookData.push({ price, value, cumulativeVol: { value: 0 } });
|
||||
});
|
||||
|
||||
orderbookData.sort((a, b) => {
|
||||
if (a === b) {
|
||||
return 0;
|
||||
@ -192,100 +95,11 @@ export const compactRows = (
|
||||
}
|
||||
return 1;
|
||||
});
|
||||
// count cumulative volumes
|
||||
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
|
||||
updateCumulativeVolumeByType(orderbookData, dataType);
|
||||
updateRelativeData(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
|
||||
* @param levels
|
||||
@ -326,12 +140,9 @@ export interface MockDataGeneratorParams {
|
||||
numberOfSellRows: number;
|
||||
numberOfBuyRows: number;
|
||||
overlap: number;
|
||||
midPrice: number;
|
||||
midPrice?: string;
|
||||
bestStaticBidPrice: number;
|
||||
bestStaticOfferPrice: number;
|
||||
indicativePrice?: number;
|
||||
indicativeVolume?: number;
|
||||
resolution: number;
|
||||
}
|
||||
|
||||
export const generateMockData = ({
|
||||
@ -341,12 +152,10 @@ export const generateMockData = ({
|
||||
overlap,
|
||||
bestStaticBidPrice,
|
||||
bestStaticOfferPrice,
|
||||
indicativePrice,
|
||||
indicativeVolume,
|
||||
resolution,
|
||||
}: MockDataGeneratorParams) => {
|
||||
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) => ({
|
||||
price: (price -= 1).toString(),
|
||||
volume: (numberOfSellRows - i + 1).toString(),
|
||||
@ -359,21 +168,11 @@ export const generateMockData = ({
|
||||
volume: (i + 2).toString(),
|
||||
numberOfOrders: '',
|
||||
}));
|
||||
const rows = compactRows(sell, buy, resolution);
|
||||
const marketTradingMode =
|
||||
overlap > 0
|
||||
? Schema.MarketTradingMode.TRADING_MODE_BATCH_AUCTION
|
||||
: Schema.MarketTradingMode.TRADING_MODE_CONTINUOUS;
|
||||
return {
|
||||
rows,
|
||||
resolution,
|
||||
indicativeVolume: indicativeVolume?.toString(),
|
||||
marketTradingMode,
|
||||
midPrice: ((bestStaticBidPrice + bestStaticOfferPrice) / 2).toString(),
|
||||
asks: sell,
|
||||
bids: buy,
|
||||
midPrice,
|
||||
bestStaticBidPrice: bestStaticBidPrice.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 { Orderbook } from './orderbook';
|
||||
import { useDataProvider } from '@vegaprotocol/data-provider';
|
||||
import { marketDepthProvider } from './market-depth-provider';
|
||||
import { marketDataProvider, marketProvider } from '@vegaprotocol/markets';
|
||||
import type { MarketData } from '@vegaprotocol/markets';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type {
|
||||
MarketDepthUpdateSubscription,
|
||||
MarketDepthQuery,
|
||||
MarketDepthQueryVariables,
|
||||
MarketDepthUpdateSubscription,
|
||||
PriceLevelFieldsFragment,
|
||||
} 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';
|
||||
|
||||
export type OrderbookData = {
|
||||
asks: PriceLevelFieldsFragment[];
|
||||
bids: PriceLevelFieldsFragment[];
|
||||
};
|
||||
|
||||
interface OrderbookManagerProps {
|
||||
marketId: string;
|
||||
}
|
||||
|
||||
export const OrderbookManager = ({ marketId }: OrderbookManagerProps) => {
|
||||
const [resolution, setResolution] = useState(1);
|
||||
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(() => {
|
||||
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<
|
||||
const { data, error, loading, reload } = useDataProvider<
|
||||
MarketDepthQuery['market'] | undefined,
|
||||
MarketDepthUpdateSubscription['marketsDepthUpdate'] | null,
|
||||
MarketDepthQueryVariables
|
||||
>({
|
||||
dataProvider: marketDepthProvider,
|
||||
update,
|
||||
variables,
|
||||
});
|
||||
|
||||
@ -127,57 +42,15 @@ export const OrderbookManager = ({ marketId }: OrderbookManagerProps) => {
|
||||
variables,
|
||||
});
|
||||
|
||||
const marketDataUpdate = useCallback(
|
||||
({ data }: { data: MarketData | null }) => {
|
||||
marketDataRef.current = data;
|
||||
updateOrderbookData.current();
|
||||
return true;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const {
|
||||
data: marketData,
|
||||
error: marketDataError,
|
||||
loading: marketDataLoading,
|
||||
} = useDataProvider({
|
||||
dataProvider: marketDataProvider,
|
||||
update: marketDataUpdate,
|
||||
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);
|
||||
|
||||
return (
|
||||
@ -188,16 +61,16 @@ export const OrderbookManager = ({ marketId }: OrderbookManagerProps) => {
|
||||
reload={reload}
|
||||
>
|
||||
<Orderbook
|
||||
{...orderbookData}
|
||||
bids={data?.depth.buy ?? []}
|
||||
asks={data?.depth.sell ?? []}
|
||||
decimalPlaces={market?.decimalPlaces ?? 0}
|
||||
positionDecimalPlaces={market?.positionDecimalPlaces ?? 0}
|
||||
resolution={resolution}
|
||||
onResolutionChange={(resolution: number) => setResolution(resolution)}
|
||||
onClick={(price: string) => {
|
||||
if (price) {
|
||||
updateOrder(marketId, { price });
|
||||
}
|
||||
}}
|
||||
midPrice={marketData?.midPrice}
|
||||
/>
|
||||
</AsyncRenderer>
|
||||
);
|
||||
|
@ -1,130 +1,118 @@
|
||||
import React from 'react';
|
||||
import React, { memo } from 'react';
|
||||
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 {
|
||||
ask: number;
|
||||
bid: number;
|
||||
cumulativeAsk?: number;
|
||||
cumulativeBid?: number;
|
||||
cumulativeRelativeAsk?: number;
|
||||
cumulativeRelativeBid?: number;
|
||||
value: number;
|
||||
cumulativeValue?: number;
|
||||
cumulativeRelativeValue?: number;
|
||||
decimalPlaces: number;
|
||||
positionDecimalPlaces: number;
|
||||
indicativeVolume?: string;
|
||||
price: string;
|
||||
relativeAsk?: number;
|
||||
relativeBid?: number;
|
||||
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(
|
||||
({
|
||||
ask,
|
||||
bid,
|
||||
cumulativeAsk,
|
||||
cumulativeBid,
|
||||
cumulativeRelativeAsk,
|
||||
cumulativeRelativeBid,
|
||||
value,
|
||||
cumulativeValue,
|
||||
cumulativeRelativeValue,
|
||||
decimalPlaces,
|
||||
positionDecimalPlaces,
|
||||
indicativeVolume,
|
||||
price,
|
||||
relativeAsk,
|
||||
relativeBid,
|
||||
onClick,
|
||||
type,
|
||||
}: OrderbookRowProps) => {
|
||||
const txtId = type === VolumeType.bid ? 'bid' : 'ask';
|
||||
return (
|
||||
<>
|
||||
<VolCell
|
||||
testId={`bid-vol-${price}`}
|
||||
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
|
||||
testId={`price-${price}`}
|
||||
value={BigInt(price)}
|
||||
onClick={() => onClick && onClick(addDecimal(price, decimalPlaces))}
|
||||
valueFormatted={addDecimalsFixedFormatNumber(price, decimalPlaces)}
|
||||
/>
|
||||
<CumulativeVol
|
||||
testId={`cumulative-vol-${price}`}
|
||||
positionDecimalPlaces={positionDecimalPlaces}
|
||||
bid={cumulativeBid}
|
||||
ask={cumulativeAsk}
|
||||
relativeAsk={cumulativeRelativeAsk}
|
||||
relativeBid={cumulativeRelativeBid}
|
||||
indicativeVolume={indicativeVolume}
|
||||
/>
|
||||
</>
|
||||
<div className="relative">
|
||||
<CumulationBar cumulativeValue={cumulativeRelativeValue} type={type} />
|
||||
<div className="grid gap-1 text-right grid-cols-3">
|
||||
<PriceCell
|
||||
testId={`price-${price}`}
|
||||
value={BigInt(price)}
|
||||
onClick={() => onClick && onClick(addDecimal(price, decimalPlaces))}
|
||||
valueFormatted={addDecimalsFixedFormatNumber(price, decimalPlaces)}
|
||||
className={
|
||||
type === VolumeType.ask
|
||||
? '!text-vega-pink dark:text-vega-pink'
|
||||
: 'text-vega-green-550 dark:text-vega-green'
|
||||
}
|
||||
/>
|
||||
<NumericCell
|
||||
testId={`${txtId}-vol-${price}`}
|
||||
value={value}
|
||||
valueFormatted={addDecimalsFixedFormatNumber(
|
||||
value,
|
||||
positionDecimalPlaces
|
||||
)}
|
||||
/>
|
||||
<CumulativeVol
|
||||
testId={`cumulative-vol-${price}`}
|
||||
positionDecimalPlaces={positionDecimalPlaces}
|
||||
cumulativeValue={cumulativeValue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
OrderbookRow.displayName = 'OrderbookRow';
|
||||
|
||||
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}
|
||||
valueFormatted={addDecimalsFixedFormatNumber(
|
||||
value,
|
||||
positionDecimalPlaces
|
||||
)}
|
||||
relativeValue={relativeValue}
|
||||
type={type}
|
||||
/>
|
||||
<PriceCell
|
||||
testId={`price-${price}`}
|
||||
value={BigInt(price)}
|
||||
onClick={() => onClick && onClick(addDecimal(price, decimalPlaces))}
|
||||
valueFormatted={addDecimalsFixedFormatNumber(price, decimalPlaces)}
|
||||
/>
|
||||
<CumulativeVol
|
||||
testId={`cumulative-vol-${price}`}
|
||||
positionDecimalPlaces={positionDecimalPlaces}
|
||||
bid={cumulativeBid}
|
||||
ask={cumulativeAsk}
|
||||
relativeAsk={cumulativeRelativeAsk}
|
||||
relativeBid={cumulativeRelativeBid}
|
||||
indicativeVolume={indicativeVolume}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
OrderbookContinuousRow.displayName = 'OrderbookContinuousRow';
|
||||
|
@ -1,260 +1,89 @@
|
||||
import { render, fireEvent, waitFor, screen } from '@testing-library/react';
|
||||
import { generateMockData } from './orderbook-data';
|
||||
import { Orderbook, rowHeight } from './orderbook';
|
||||
import { generateMockData, VolumeType } from './orderbook-data';
|
||||
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', () => {
|
||||
const params = {
|
||||
numberOfSellRows: 100,
|
||||
numberOfBuyRows: 100,
|
||||
step: 1,
|
||||
midPrice: 122900,
|
||||
midPrice: '122900',
|
||||
bestStaticBidPrice: 122905,
|
||||
bestStaticOfferPrice: 122895,
|
||||
decimalPlaces: 3,
|
||||
overlap: 10,
|
||||
indicativePrice: 122900,
|
||||
indicativeVolume: 11,
|
||||
overlap: 0,
|
||||
resolution: 1,
|
||||
};
|
||||
const onResolutionChange = jest.fn();
|
||||
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(
|
||||
<Orderbook
|
||||
decimalPlaces={decimalPlaces}
|
||||
positionDecimalPlaces={0}
|
||||
fillGaps
|
||||
{...generateMockData(params)}
|
||||
onResolutionChange={onResolutionChange}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
|
||||
expect(screen.getByTestId('scroll').scrollTop).toBe(91 * rowHeight);
|
||||
});
|
||||
|
||||
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(`middle-mark-price-${params.midPrice}`)
|
||||
);
|
||||
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
|
||||
expect(screen.getByTestId('scroll').scrollTop).toBe(91 * rowHeight);
|
||||
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(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);
|
||||
expect(
|
||||
screen.getByTestId(`middle-mark-price-${params.midPrice}`)
|
||||
).toHaveTextContent('122.90');
|
||||
});
|
||||
|
||||
it('should format correctly the numbers on resolution change', async () => {
|
||||
const onClickSpy = jest.fn();
|
||||
const result = render(
|
||||
jest.spyOn(orderbookData, 'compactRows');
|
||||
const mockedData = generateMockData(params);
|
||||
render(
|
||||
<Orderbook
|
||||
decimalPlaces={decimalPlaces}
|
||||
positionDecimalPlaces={0}
|
||||
onClick={onClickSpy}
|
||||
fillGaps
|
||||
{...generateMockData(params)}
|
||||
onResolutionChange={onResolutionChange}
|
||||
{...mockedData}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
await screen.findByTestId(`bid-vol-${params.midPrice}`)
|
||||
await screen.findByTestId(`middle-mark-price-${params.midPrice}`)
|
||||
).toBeInTheDocument();
|
||||
// Before resolution change the price is 122.934
|
||||
await fireEvent.click(await screen.getByTestId('price-122934'));
|
||||
expect(onClickSpy).toBeCalledWith('122.934');
|
||||
await fireEvent.click(await screen.getByTestId('price-122901'));
|
||||
expect(onClickSpy).toBeCalledWith('122.901');
|
||||
const resolutionSelect = screen.getByTestId(
|
||||
'resolution'
|
||||
) as HTMLSelectElement;
|
||||
await fireEvent.change(resolutionSelect, { target: { value: '10' } });
|
||||
await result.rerender(
|
||||
<Orderbook
|
||||
decimalPlaces={decimalPlaces}
|
||||
positionDecimalPlaces={0}
|
||||
onClick={onClickSpy}
|
||||
fillGaps
|
||||
{...generateMockData({
|
||||
...params,
|
||||
resolution: 10,
|
||||
})}
|
||||
onResolutionChange={onResolutionChange}
|
||||
/>
|
||||
expect(orderbookData.compactRows).toHaveBeenCalledWith(
|
||||
mockedData.bids,
|
||||
VolumeType.bid,
|
||||
10
|
||||
);
|
||||
await fireEvent.click(await screen.getByTestId('price-12299'));
|
||||
// After resolution change the price is 122.99
|
||||
expect(onResolutionChange.mock.calls[0][0]).toBe(10);
|
||||
expect(onClickSpy).toBeCalledWith('122.99');
|
||||
});
|
||||
|
||||
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}
|
||||
/>
|
||||
expect(orderbookData.compactRows).toHaveBeenCalledWith(
|
||||
mockedData.asks,
|
||||
VolumeType.ask,
|
||||
10
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Bid / Ask vol')).toBeInTheDocument();
|
||||
});
|
||||
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();
|
||||
await fireEvent.click(await screen.getByTestId('price-12294'));
|
||||
expect(onClickSpy).toBeCalledWith('122.94');
|
||||
});
|
||||
});
|
||||
|
@ -2,14 +2,12 @@ import type { Story, Meta } from '@storybook/react';
|
||||
import { generateMockData } from './orderbook-data';
|
||||
import type { MockDataGeneratorParams } from './orderbook-data';
|
||||
import { Orderbook } from './orderbook';
|
||||
import { useState } from 'react';
|
||||
|
||||
type Props = Omit<MockDataGeneratorParams, 'resolution'> & {
|
||||
decimalPlaces: number;
|
||||
};
|
||||
|
||||
const OrderbookMockDataProvider = ({ decimalPlaces, ...props }: Props) => {
|
||||
const [resolution, setResolution] = useState(1);
|
||||
return (
|
||||
<div className="absolute inset-0 dark:bg-black dark:text-neutral-200 bg-white text-neutral-800">
|
||||
<div
|
||||
@ -18,9 +16,8 @@ const OrderbookMockDataProvider = ({ decimalPlaces, ...props }: Props) => {
|
||||
>
|
||||
<Orderbook
|
||||
positionDecimalPlaces={0}
|
||||
onResolutionChange={setResolution}
|
||||
decimalPlaces={decimalPlaces}
|
||||
{...generateMockData({ ...props, resolution })}
|
||||
{...generateMockData({ ...props })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -54,8 +51,6 @@ Auction.args = {
|
||||
bestStaticOfferPrice: 122895,
|
||||
decimalPlaces: 3,
|
||||
overlap: 10,
|
||||
indicativePrice: 122900,
|
||||
indicativeVolume: 11,
|
||||
};
|
||||
|
||||
export const Empty = Template.bind({});
|
||||
@ -66,6 +61,4 @@ Empty.args = {
|
||||
bestStaticOfferPrice: 0,
|
||||
decimalPlaces: 3,
|
||||
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 {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
Fragment,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import classNames from 'classnames';
|
||||
import {
|
||||
addDecimalsFixedFormatNumber,
|
||||
addDecimalsFormatNumber,
|
||||
formatNumberFixed,
|
||||
} from '@vegaprotocol/utils';
|
||||
import { t } from '@vegaprotocol/i18n';
|
||||
import {
|
||||
useResizeObserver,
|
||||
useThemeSwitcher,
|
||||
} from '@vegaprotocol/react-helpers';
|
||||
import * as Schema from '@vegaprotocol/types';
|
||||
import { OrderbookRow, OrderbookContinuousRow } from './orderbook-row';
|
||||
import { createRow } from './orderbook-data';
|
||||
import { Checkbox, Icon, Splash, TinyScroll } from '@vegaprotocol/ui-toolkit';
|
||||
import type { OrderbookData, OrderbookRowData } from './orderbook-data';
|
||||
import { OrderbookRow } from './orderbook-row';
|
||||
import type { OrderbookRowData } from './orderbook-data';
|
||||
import { compactRows, VolumeType } from './orderbook-data';
|
||||
import { Splash } from '@vegaprotocol/ui-toolkit';
|
||||
import classNames from 'classnames';
|
||||
import { useState } from 'react';
|
||||
import type { PriceLevelFieldsFragment } from './__generated__/MarketDepth';
|
||||
|
||||
interface OrderbookProps extends OrderbookData {
|
||||
interface OrderbookProps {
|
||||
decimalPlaces: number;
|
||||
positionDecimalPlaces: number;
|
||||
resolution: number;
|
||||
onResolutionChange: (resolution: number) => void;
|
||||
onClick?: (price: string) => void;
|
||||
fillGaps?: boolean;
|
||||
midPrice?: string;
|
||||
bids: PriceLevelFieldsFragment[];
|
||||
asks: PriceLevelFieldsFragment[];
|
||||
}
|
||||
|
||||
const HorizontalLine = ({ top, testId }: { top: string; testId: string }) => (
|
||||
<div
|
||||
className="absolute border-b border-default inset-x-0 hidden"
|
||||
style={{ top }}
|
||||
data-testid={testId}
|
||||
/>
|
||||
);
|
||||
// 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 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;
|
||||
}) => (
|
||||
<Fragment>
|
||||
<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 = ({
|
||||
const OrderbookTable = ({
|
||||
rows,
|
||||
midPrice,
|
||||
bestStaticBidPrice,
|
||||
bestStaticOfferPrice,
|
||||
marketTradingMode,
|
||||
indicativeVolume,
|
||||
indicativePrice,
|
||||
resolution,
|
||||
type,
|
||||
decimalPlaces,
|
||||
positionDecimalPlaces,
|
||||
resolution,
|
||||
fillGaps: initialFillGaps,
|
||||
onResolutionChange,
|
||||
onClick,
|
||||
}: OrderbookProps) => {
|
||||
const { theme } = useThemeSwitcher();
|
||||
const scrollElement = useRef<HTMLDivElement>(null);
|
||||
const rootElement = useRef<HTMLDivElement>(null);
|
||||
const gridElement = useRef<HTMLDivElement>(null);
|
||||
const headerElement = useRef<HTMLDivElement>(null);
|
||||
const footerElement = useRef<HTMLDivElement>(null);
|
||||
// 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 (
|
||||
<div
|
||||
className={classNames(
|
||||
'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',
|
||||
isContinuousMode ? 'grid-cols-3' : 'grid-cols-4'
|
||||
)}
|
||||
ref={headerElement}
|
||||
>
|
||||
{isContinuousMode ? (
|
||||
<div>{t('Bid / Ask vol')}</div>
|
||||
) : (
|
||||
<>
|
||||
<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 ? (
|
||||
}: {
|
||||
rows: OrderbookRowData[];
|
||||
resolution: number;
|
||||
decimalPlaces: number;
|
||||
positionDecimalPlaces: number;
|
||||
type: VolumeType;
|
||||
onClick?: (price: string) => void;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'grid grid-cols-4 gap-1 text-right auto-rows-[17px]',
|
||||
isContinuousMode ? 'grid-cols-3' : 'grid-cols-4'
|
||||
)}
|
||||
className={
|
||||
// position the ask side to the bottow of the top section and the bid side to the top of the bottom section
|
||||
classNames(
|
||||
'flex flex-col',
|
||||
type === VolumeType.ask ? 'justify-end' : 'justify-start'
|
||||
)
|
||||
}
|
||||
>
|
||||
{data.map((data, i) => (
|
||||
<OrderBookRowComponent
|
||||
key={data.price}
|
||||
price={(BigInt(data.price) / BigInt(resolution)).toString()}
|
||||
onClick={onClick}
|
||||
decimalPlaces={decimalPlaces - Math.log10(resolution)}
|
||||
positionDecimalPlaces={positionDecimalPlaces}
|
||||
bid={data.bid}
|
||||
relativeBid={data.relativeBid}
|
||||
cumulativeBid={data.cumulativeVol.bid}
|
||||
cumulativeRelativeBid={data.cumulativeVol.relativeBid}
|
||||
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 className={`grid auto-rows-[${rowHeight}px]`}>
|
||||
{rows.map((data) => (
|
||||
<OrderbookRow
|
||||
key={data.price}
|
||||
price={(BigInt(data.price) / BigInt(resolution)).toString()}
|
||||
onClick={onClick}
|
||||
decimalPlaces={decimalPlaces - Math.log10(resolution)}
|
||||
positionDecimalPlaces={positionDecimalPlaces}
|
||||
value={data.value}
|
||||
cumulativeValue={data.cumulativeVol.value}
|
||||
cumulativeRelativeValue={data.cumulativeVol.relativeValue}
|
||||
type={type}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
);
|
||||
};
|
||||
|
||||
const c = theme === 'dark' ? colors.neutral[600] : colors.neutral[300];
|
||||
const gradientStyles = isContinuousMode
|
||||
? `linear-gradient(${c},${c}) 33.4% 0/1px 100% no-repeat, linear-gradient(${c},${c}) 66.7% 0/1px 100% no-repeat`
|
||||
: `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`;
|
||||
|
||||
const resolutions = new Array(decimalPlaces + 1)
|
||||
export const Orderbook = ({
|
||||
decimalPlaces,
|
||||
positionDecimalPlaces,
|
||||
onClick,
|
||||
midPrice,
|
||||
asks,
|
||||
bids,
|
||||
}: OrderbookProps) => {
|
||||
const [resolution, setResolution] = useState(1);
|
||||
const resolutions = new Array(
|
||||
Math.max(midPrice?.toString().length ?? 0, decimalPlaces + 1)
|
||||
)
|
||||
.fill(null)
|
||||
.map((v, i) => Math.pow(10, i));
|
||||
|
||||
const bestStaticBidPriceLinePosition = getBestStaticBidPriceLinePosition(
|
||||
bestStaticBidPrice,
|
||||
fillGaps,
|
||||
maxPriceLevel,
|
||||
minPriceLevel,
|
||||
resolution,
|
||||
rows
|
||||
);
|
||||
const groupedAsks = useMemo(() => {
|
||||
return compactRows(asks, VolumeType.ask, resolution);
|
||||
}, [asks, resolution]);
|
||||
|
||||
const bestStaticOfferPriceLinePosition = getBestStaticOfferPriceLinePosition(
|
||||
bestStaticOfferPrice,
|
||||
fillGaps,
|
||||
maxPriceLevel,
|
||||
minPriceLevel,
|
||||
resolution,
|
||||
rows
|
||||
);
|
||||
const groupedBids = useMemo(() => {
|
||||
return compactRows(bids, VolumeType.bid, resolution);
|
||||
}, [bids, resolution]);
|
||||
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
return (
|
||||
<div
|
||||
className="h-full relative pl-1 text-xs"
|
||||
ref={rootElement}
|
||||
onDoubleClick={() => setDebug(!debug)}
|
||||
>
|
||||
{tableHeader}
|
||||
<TinyScroll
|
||||
className="h-full overflow-auto relative"
|
||||
onScroll={onScroll}
|
||||
ref={scrollElement}
|
||||
data-testid="scroll"
|
||||
>
|
||||
<div
|
||||
className="relative text-right min-h-full overflow-hidden"
|
||||
style={{
|
||||
paddingTop,
|
||||
paddingBottom,
|
||||
background: tableBody ? gradientStyles : 'none',
|
||||
<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 (
|
||||
<div
|
||||
className={`overflow-hidden grid grid-rows-[1fr_${midHeight}px_1fr]`}
|
||||
data-testid="orderbook-grid-element"
|
||||
style={{ height: height + 'px' }}
|
||||
>
|
||||
{askRows.length || bidRows.length ? (
|
||||
<>
|
||||
<OrderbookTable
|
||||
rows={askRows}
|
||||
type={VolumeType.ask}
|
||||
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}`}
|
||||
>
|
||||
{addDecimalsFormatNumber(midPrice, decimalPlaces)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<OrderbookTable
|
||||
rows={bidRows}
|
||||
type={VolumeType.bid}
|
||||
resolution={resolution}
|
||||
decimalPlaces={decimalPlaces}
|
||||
positionDecimalPlaces={positionDecimalPlaces}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="inset-0 absolute">
|
||||
<Splash>{t('No data')}</Splash>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
ref={gridElement}
|
||||
>
|
||||
{tableBody || (
|
||||
<div className="inset-0 absolute">
|
||||
<Splash>{t('No data')}</Splash>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{bestStaticBidPriceLinePosition && (
|
||||
<HorizontalLine
|
||||
top={`${bestStaticBidPriceLinePosition}px`}
|
||||
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 className="col-start-3">
|
||||
<select
|
||||
onChange={(e) => onResolutionChange(Number(e.currentTarget.value))}
|
||||
value={resolution}
|
||||
className="block bg-neutral-100 dark:bg-neutral-700 font-mono text-right w-full h-full"
|
||||
data-testid="resolution"
|
||||
>
|
||||
{resolutions.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{formatNumberFixed(0, decimalPlaces - Math.log10(r))}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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>
|
||||
</ReactVirtualizedAutoSizer>
|
||||
</div>
|
||||
<div className="border-t border-default">
|
||||
<select
|
||||
onChange={(e) => {
|
||||
setResolution(Number(e.currentTarget.value));
|
||||
}}
|
||||
value={resolution}
|
||||
className="block bg-neutral-100 dark:bg-neutral-700 font-mono text-right"
|
||||
data-testid="resolution"
|
||||
>
|
||||
{resolutions.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{formatNumberFixed(
|
||||
Math.log10(r) - decimalPlaces > 0
|
||||
? Math.pow(10, Math.log10(r) - decimalPlaces)
|
||||
: 0,
|
||||
decimalPlaces - Math.log10(r)
|
||||
)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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>
|
||||
);
|
||||
/* eslint-enable jsx-a11y/no-static-element-interactions */
|
||||
};
|
||||
|
||||
export default Orderbook;
|
||||
|
Loading…
Reference in New Issue
Block a user