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:
Maciek 2023-06-07 10:58:34 +02:00 committed by GitHub
parent aae5c44fa4
commit 43d3754c64
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 379 additions and 1578 deletions

View File

@ -296,7 +296,7 @@ const MainGrid = memo(
</TradeGridChild>
</ResizableGridPanel>
<ResizableGridPanel
preferredSize={sizesMiddle[2] || 430}
preferredSize={sizesMiddle[2] || 300}
minSize={200}
>
<TradeGridChild>

View File

@ -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}
>

View File

@ -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) {

View File

@ -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>
) : (

View File

@ -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);
});
});

View File

@ -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;
if (dataType === VolumeType.bid) {
for (let i = 0; i <= maxIndex; i++) {
data[i].cumulativeVol.bid =
data[i].bid + (i !== 0 ? data[i - 1].cumulativeVol.bid : 0);
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.ask =
data[i].ask + (i !== maxIndex ? data[i + 1].cumulativeVol.ask : 0);
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,
};
};

View File

@ -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>
);

View File

@ -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"
/>
<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)}
/>
<CumulativeVol
testId={`cumulative-vol-${price}`}
positionDecimalPlaces={positionDecimalPlaces}
bid={cumulativeBid}
ask={cumulativeAsk}
relativeAsk={cumulativeRelativeAsk}
relativeBid={cumulativeRelativeBid}
indicativeVolume={indicativeVolume}
/>
</>
);
className={
type === VolumeType.ask
? '!text-vega-pink dark:text-vega-pink'
: 'text-vega-green-550 dark:text-vega-green'
}
);
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}`}
/>
<NumericCell
testId={`${txtId}-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}
cumulativeValue={cumulativeValue}
/>
</>
</div>
</div>
);
}
);
OrderbookContinuousRow.displayName = 'OrderbookContinuousRow';
OrderbookRow.displayName = 'OrderbookRow';

View File

@ -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');
});
});

View File

@ -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,
};

View File

@ -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;
}
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;
}) => (
<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>
);
bids: PriceLevelFieldsFragment[];
asks: PriceLevelFieldsFragment[];
}
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,
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(() => {
}: {
rows: OrderbookRowData[];
resolution: number;
decimalPlaces: number;
positionDecimalPlaces: number;
type: VolumeType;
onClick?: (price: string) => void;
}) => {
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}
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'
)
}
>
{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 ? (
<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
<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}
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
}
value={data.value}
cumulativeValue={data.cumulativeVol.value}
cumulativeRelativeValue={data.cumulativeVol.relativeValue}
type={type}
/>
))}
</div>
) : null;
</div>
);
};
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 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="h-full relative pl-1 text-xs"
ref={rootElement}
onDoubleClick={() => setDebug(!debug)}
className={`overflow-hidden grid grid-rows-[1fr_${midHeight}px_1fr]`}
data-testid="orderbook-grid-element"
style={{ height: height + 'px' }}
>
{tableHeader}
<TinyScroll
className="h-full overflow-auto relative"
onScroll={onScroll}
ref={scrollElement}
data-testid="scroll"
{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}`}
>
<div
className="relative text-right min-h-full overflow-hidden"
style={{
paddingTop,
paddingBottom,
background: tableBody ? gradientStyles : 'none',
}}
ref={gridElement}
>
{tableBody || (
{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>
{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>
}
/>
);
}}
</ReactVirtualizedAutoSizer>
</div>
<div className="col-start-3">
<div className="border-t border-default">
<select
onChange={(e) => onResolutionChange(Number(e.currentTarget.value))}
onChange={(e) => {
setResolution(Number(e.currentTarget.value));
}}
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"
>
{resolutions.map((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>
))}
</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>
</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;