fix(market-depth): fix order book priceInCenter calculation and scroll to price (#2901)
This commit is contained in:
parent
ce6d4cb35d
commit
4bb57e9c47
@ -42,10 +42,18 @@ export const update: Update<
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
if (delta.buy) {
|
if (delta.buy) {
|
||||||
updatedData.depth.buy = updateLevels(data.depth.buy ?? [], delta.buy);
|
updatedData.depth.buy = updateLevels(
|
||||||
|
data.depth.buy ?? [],
|
||||||
|
delta.buy,
|
||||||
|
false
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (delta.sell) {
|
if (delta.sell) {
|
||||||
updatedData.depth.sell = updateLevels(data.depth.sell ?? [], delta.sell);
|
updatedData.depth.sell = updateLevels(
|
||||||
|
data.depth.sell ?? [],
|
||||||
|
delta.sell,
|
||||||
|
true
|
||||||
|
);
|
||||||
}
|
}
|
||||||
updatedData.depth.sequenceNumber = delta.sequenceNumber;
|
updatedData.depth.sequenceNumber = delta.sequenceNumber;
|
||||||
return updatedData;
|
return updatedData;
|
||||||
|
@ -26,9 +26,19 @@ export interface OrderbookRowData {
|
|||||||
|
|
||||||
type PartialOrderbookRowData = Pick<OrderbookRowData, 'price' | 'ask' | 'bid'>;
|
type PartialOrderbookRowData = Pick<OrderbookRowData, 'price' | 'ask' | 'bid'>;
|
||||||
|
|
||||||
export type OrderbookData = Partial<
|
type OrderbookMarketData = Pick<
|
||||||
Omit<MarketData, '__typename' | 'market'>
|
MarketData,
|
||||||
> & { rows: OrderbookRowData[] | null };
|
| 'bestStaticBidPrice'
|
||||||
|
| 'bestStaticOfferPrice'
|
||||||
|
| 'indicativePrice'
|
||||||
|
| 'indicativeVolume'
|
||||||
|
| 'marketTradingMode'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type OrderbookData = Partial<OrderbookMarketData> & {
|
||||||
|
rows: OrderbookRowData[] | null;
|
||||||
|
midPrice?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const getPriceLevel = (price: string | bigint, resolution: number) => {
|
export const getPriceLevel = (price: string | bigint, resolution: number) => {
|
||||||
const p = BigInt(price);
|
const p = BigInt(price);
|
||||||
@ -40,6 +50,18 @@ export const getPriceLevel = (price: string | bigint, resolution: number) => {
|
|||||||
return priceLevel.toString();
|
return priceLevel.toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getMidPrice = (
|
||||||
|
sell: PriceLevelFieldsFragment[] | null | undefined,
|
||||||
|
buy: PriceLevelFieldsFragment[] | null | undefined,
|
||||||
|
resolution: number
|
||||||
|
) =>
|
||||||
|
buy?.length && sell?.length
|
||||||
|
? getPriceLevel(
|
||||||
|
(BigInt(buy[0].price) + BigInt(sell[0].price)) / BigInt(2),
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const getMaxVolumes = (orderbookData: OrderbookRowData[]) => ({
|
const getMaxVolumes = (orderbookData: OrderbookRowData[]) => ({
|
||||||
bid: Math.max(...orderbookData.map((data) => data.bid)),
|
bid: Math.max(...orderbookData.map((data) => data.bid)),
|
||||||
ask: Math.max(...orderbookData.map((data) => data.ask)),
|
ask: Math.max(...orderbookData.map((data) => data.ask)),
|
||||||
@ -157,8 +179,15 @@ export const compactRows = (
|
|||||||
}
|
}
|
||||||
orderbookData.push(row);
|
orderbookData.push(row);
|
||||||
});
|
});
|
||||||
// order by price, it's safe to cast to number price diff should not exceed Number.MAX_SAFE_INTEGER
|
orderbookData.sort((a, b) => {
|
||||||
orderbookData.sort((a, b) => Number(BigInt(b.price) - BigInt(a.price)));
|
if (a === b) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (BigInt(a.price) > BigInt(b.price)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
// count cumulative volumes
|
// count cumulative volumes
|
||||||
if (orderbookData.length > 1) {
|
if (orderbookData.length > 1) {
|
||||||
const maxIndex = orderbookData.length - 1;
|
const maxIndex = orderbookData.length - 1;
|
||||||
@ -253,28 +282,6 @@ export const updateCompactedRows = (
|
|||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mapMarketData = (
|
|
||||||
data: Pick<
|
|
||||||
MarketData,
|
|
||||||
| 'staticMidPrice'
|
|
||||||
| 'bestStaticBidPrice'
|
|
||||||
| 'bestStaticOfferPrice'
|
|
||||||
| 'indicativePrice'
|
|
||||||
> | null,
|
|
||||||
resolution: number
|
|
||||||
) => ({
|
|
||||||
staticMidPrice:
|
|
||||||
data?.staticMidPrice && getPriceLevel(data?.staticMidPrice, resolution),
|
|
||||||
bestStaticBidPrice:
|
|
||||||
data?.bestStaticBidPrice &&
|
|
||||||
getPriceLevel(data?.bestStaticBidPrice, resolution),
|
|
||||||
bestStaticOfferPrice:
|
|
||||||
data?.bestStaticOfferPrice &&
|
|
||||||
getPriceLevel(data?.bestStaticOfferPrice, resolution),
|
|
||||||
indicativePrice:
|
|
||||||
data?.indicativePrice && getPriceLevel(data?.indicativePrice, resolution),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates raw data with new data received from subscription - mutates input
|
* Updates raw data with new data received from subscription - mutates input
|
||||||
* @param levels
|
* @param levels
|
||||||
@ -283,7 +290,8 @@ export const mapMarketData = (
|
|||||||
*/
|
*/
|
||||||
export const updateLevels = (
|
export const updateLevels = (
|
||||||
draft: PriceLevelFieldsFragment[],
|
draft: PriceLevelFieldsFragment[],
|
||||||
updates: (PriceLevelFieldsFragment | PriceLevelFieldsFragment)[]
|
updates: (PriceLevelFieldsFragment | PriceLevelFieldsFragment)[],
|
||||||
|
ascending = true
|
||||||
) => {
|
) => {
|
||||||
const levels = [...draft];
|
const levels = [...draft];
|
||||||
updates.forEach((update) => {
|
updates.forEach((update) => {
|
||||||
@ -295,8 +303,10 @@ export const updateLevels = (
|
|||||||
levels[index] = update;
|
levels[index] = update;
|
||||||
}
|
}
|
||||||
} else if (update.volume !== '0') {
|
} else if (update.volume !== '0') {
|
||||||
index = levels.findIndex(
|
index = levels.findIndex((level) =>
|
||||||
(level) => BigInt(level.price) > BigInt(update.price)
|
ascending
|
||||||
|
? BigInt(level.price) > BigInt(update.price)
|
||||||
|
: BigInt(level.price) < BigInt(update.price)
|
||||||
);
|
);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
levels.splice(index, 0, update);
|
levels.splice(index, 0, update);
|
||||||
@ -346,22 +356,20 @@ export const generateMockData = ({
|
|||||||
numberOfOrders: '',
|
numberOfOrders: '',
|
||||||
}));
|
}));
|
||||||
const rows = compactRows(sell, buy, resolution);
|
const rows = compactRows(sell, buy, resolution);
|
||||||
|
const marketTradingMode =
|
||||||
|
overlap > 0
|
||||||
|
? Schema.MarketTradingMode.TRADING_MODE_BATCH_AUCTION
|
||||||
|
: Schema.MarketTradingMode.TRADING_MODE_CONTINUOUS;
|
||||||
return {
|
return {
|
||||||
rows,
|
rows,
|
||||||
resolution,
|
resolution,
|
||||||
indicativeVolume: indicativeVolume?.toString(),
|
indicativeVolume: indicativeVolume?.toString(),
|
||||||
marketTradingMode:
|
marketTradingMode,
|
||||||
overlap > 0
|
midPrice: ((bestStaticBidPrice + bestStaticOfferPrice) / 2).toString(),
|
||||||
? Schema.MarketTradingMode.TRADING_MODE_BATCH_AUCTION
|
bestStaticBidPrice: bestStaticBidPrice.toString(),
|
||||||
: Schema.MarketTradingMode.TRADING_MODE_CONTINUOUS,
|
bestStaticOfferPrice: bestStaticOfferPrice.toString(),
|
||||||
...mapMarketData(
|
indicativePrice: indicativePrice
|
||||||
{
|
? getPriceLevel(indicativePrice.toString(), resolution)
|
||||||
staticMidPrice: '',
|
: undefined,
|
||||||
bestStaticBidPrice: bestStaticBidPrice.toString(),
|
|
||||||
bestStaticOfferPrice: bestStaticOfferPrice.toString(),
|
|
||||||
indicativePrice: indicativePrice?.toString() ?? '',
|
|
||||||
},
|
|
||||||
resolution
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import React from 'react';
|
||||||
import throttle from 'lodash/throttle';
|
import throttle from 'lodash/throttle';
|
||||||
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
|
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
|
||||||
import { Orderbook } from './orderbook';
|
import { Orderbook } from './orderbook';
|
||||||
@ -8,12 +9,14 @@ import type { MarketData } from '@vegaprotocol/market-list';
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type {
|
import type {
|
||||||
MarketDepthUpdateSubscription,
|
MarketDepthUpdateSubscription,
|
||||||
|
MarketDepthQuery,
|
||||||
PriceLevelFieldsFragment,
|
PriceLevelFieldsFragment,
|
||||||
} from './__generated__/MarketDepth';
|
} from './__generated__/MarketDepth';
|
||||||
import {
|
import {
|
||||||
compactRows,
|
compactRows,
|
||||||
updateCompactedRows,
|
updateCompactedRows,
|
||||||
mapMarketData,
|
getMidPrice,
|
||||||
|
getPriceLevel,
|
||||||
} from './orderbook-data';
|
} from './orderbook-data';
|
||||||
import type { OrderbookData } from './orderbook-data';
|
import type { OrderbookData } from './orderbook-data';
|
||||||
import { usePersistedOrderStore } from '@vegaprotocol/orders';
|
import { usePersistedOrderStore } from '@vegaprotocol/orders';
|
||||||
@ -31,6 +34,7 @@ export const OrderbookManager = ({ marketId }: OrderbookManagerProps) => {
|
|||||||
});
|
});
|
||||||
const dataRef = useRef<OrderbookData>({ rows: null });
|
const dataRef = useRef<OrderbookData>({ rows: null });
|
||||||
const marketDataRef = useRef<MarketData | null>(null);
|
const marketDataRef = useRef<MarketData | null>(null);
|
||||||
|
const rawDataRef = useRef<MarketDepthQuery['market'] | null>(null);
|
||||||
const deltaRef = useRef<{
|
const deltaRef = useRef<{
|
||||||
sell: PriceLevelFieldsFragment[];
|
sell: PriceLevelFieldsFragment[];
|
||||||
buy: PriceLevelFieldsFragment[];
|
buy: PriceLevelFieldsFragment[];
|
||||||
@ -42,7 +46,17 @@ export const OrderbookManager = ({ marketId }: OrderbookManagerProps) => {
|
|||||||
throttle(() => {
|
throttle(() => {
|
||||||
dataRef.current = {
|
dataRef.current = {
|
||||||
...marketDataRef.current,
|
...marketDataRef.current,
|
||||||
...mapMarketData(marketDataRef.current, resolutionRef.current),
|
indicativePrice: marketDataRef.current?.indicativePrice
|
||||||
|
? getPriceLevel(
|
||||||
|
marketDataRef.current.indicativePrice,
|
||||||
|
resolutionRef.current
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
midPrice: getMidPrice(
|
||||||
|
rawDataRef.current?.depth.sell,
|
||||||
|
rawDataRef.current?.depth.buy,
|
||||||
|
resolution
|
||||||
|
),
|
||||||
rows:
|
rows:
|
||||||
deltaRef.current.buy.length || deltaRef.current.sell.length
|
deltaRef.current.buy.length || deltaRef.current.sell.length
|
||||||
? updateCompactedRows(
|
? updateCompactedRows(
|
||||||
@ -56,14 +70,16 @@ export const OrderbookManager = ({ marketId }: OrderbookManagerProps) => {
|
|||||||
deltaRef.current.buy = [];
|
deltaRef.current.buy = [];
|
||||||
deltaRef.current.sell = [];
|
deltaRef.current.sell = [];
|
||||||
setOrderbookData(dataRef.current);
|
setOrderbookData(dataRef.current);
|
||||||
}, 1000)
|
}, 250)
|
||||||
);
|
);
|
||||||
|
|
||||||
const update = useCallback(
|
const update = useCallback(
|
||||||
({
|
({
|
||||||
delta: deltas,
|
delta: deltas,
|
||||||
|
data: rawData,
|
||||||
}: {
|
}: {
|
||||||
delta?: MarketDepthUpdateSubscription['marketsDepthUpdate'];
|
delta?: MarketDepthUpdateSubscription['marketsDepthUpdate'];
|
||||||
|
data?: MarketDepthQuery['market'];
|
||||||
}) => {
|
}) => {
|
||||||
if (!dataRef.current.rows) {
|
if (!dataRef.current.rows) {
|
||||||
return false;
|
return false;
|
||||||
@ -78,6 +94,7 @@ export const OrderbookManager = ({ marketId }: OrderbookManagerProps) => {
|
|||||||
if (delta.buy) {
|
if (delta.buy) {
|
||||||
deltaRef.current.buy.push(...delta.buy);
|
deltaRef.current.buy.push(...delta.buy);
|
||||||
}
|
}
|
||||||
|
rawDataRef.current = rawData;
|
||||||
updateOrderbookData.current();
|
updateOrderbookData.current();
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@ -134,9 +151,14 @@ export const OrderbookManager = ({ marketId }: OrderbookManagerProps) => {
|
|||||||
}
|
}
|
||||||
dataRef.current = {
|
dataRef.current = {
|
||||||
...marketDataRef.current,
|
...marketDataRef.current,
|
||||||
...mapMarketData(marketDataRef.current, resolution),
|
indicativePrice: getPriceLevel(
|
||||||
|
marketDataRef.current.indicativePrice,
|
||||||
|
resolution
|
||||||
|
),
|
||||||
|
midPrice: getMidPrice(data.depth.sell, data.depth.buy, resolution),
|
||||||
rows: compactRows(data.depth.sell, data.depth.buy, resolution),
|
rows: compactRows(data.depth.sell, data.depth.buy, resolution),
|
||||||
};
|
};
|
||||||
|
rawDataRef.current = data;
|
||||||
setOrderbookData(dataRef.current);
|
setOrderbookData(dataRef.current);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
@ -92,7 +92,7 @@ describe('Orderbook', () => {
|
|||||||
expect(result.getByTestId('scroll').scrollTop).toBe(90 * rowHeight);
|
expect(result.getByTestId('scroll').scrollTop).toBe(90 * rowHeight);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should should keep price it the middle', async () => {
|
it('should keep price it the middle', async () => {
|
||||||
window.innerHeight = 11 * rowHeight;
|
window.innerHeight = 11 * rowHeight;
|
||||||
const result = render(
|
const result = render(
|
||||||
<Orderbook
|
<Orderbook
|
||||||
@ -106,7 +106,7 @@ describe('Orderbook', () => {
|
|||||||
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
|
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
|
||||||
const scrollElement = result.getByTestId('scroll');
|
const scrollElement = result.getByTestId('scroll');
|
||||||
expect(scrollElement.scrollTop).toBe(91 * rowHeight);
|
expect(scrollElement.scrollTop).toBe(91 * rowHeight);
|
||||||
scrollElement.scrollTop = 92 * rowHeight;
|
scrollElement.scrollTop = 92 * rowHeight + 0.01;
|
||||||
fireEvent.scroll(scrollElement);
|
fireEvent.scroll(scrollElement);
|
||||||
result.rerender(
|
result.rerender(
|
||||||
<Orderbook
|
<Orderbook
|
||||||
@ -121,10 +121,10 @@ describe('Orderbook', () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
|
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
|
||||||
expect(result.getByTestId('scroll').scrollTop).toBe(91 * rowHeight);
|
expect(result.getByTestId('scroll').scrollTop).toBe(91 * rowHeight + 0.01);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should should get back to mid price on click', async () => {
|
it('should get back to mid price on click', async () => {
|
||||||
window.innerHeight = 11 * rowHeight;
|
window.innerHeight = 11 * rowHeight;
|
||||||
const result = render(
|
const result = render(
|
||||||
<Orderbook
|
<Orderbook
|
||||||
@ -138,15 +138,15 @@ describe('Orderbook', () => {
|
|||||||
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
|
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
|
||||||
const scrollElement = result.getByTestId('scroll');
|
const scrollElement = result.getByTestId('scroll');
|
||||||
expect(scrollElement.scrollTop).toBe(91 * rowHeight);
|
expect(scrollElement.scrollTop).toBe(91 * rowHeight);
|
||||||
scrollElement.scrollTop = 0;
|
scrollElement.scrollTop = 1;
|
||||||
fireEvent.scroll(scrollElement);
|
fireEvent.scroll(scrollElement);
|
||||||
expect(result.getByTestId('scroll').scrollTop).toBe(0);
|
expect(result.getByTestId('scroll').scrollTop).toBe(1);
|
||||||
const scrollToMidPriceButton = result.getByTestId('scroll-to-midprice');
|
const scrollToMidPriceButton = result.getByTestId('scroll-to-midprice');
|
||||||
fireEvent.click(scrollToMidPriceButton);
|
fireEvent.click(scrollToMidPriceButton);
|
||||||
expect(result.getByTestId('scroll').scrollTop).toBe(91 * rowHeight);
|
expect(result.getByTestId('scroll').scrollTop).toBe(91 * rowHeight + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should should get back to mid price on resolution change', async () => {
|
it('should get back to mid price on resolution change', async () => {
|
||||||
window.innerHeight = 11 * rowHeight;
|
window.innerHeight = 11 * rowHeight;
|
||||||
const result = render(
|
const result = render(
|
||||||
<Orderbook
|
<Orderbook
|
||||||
@ -160,9 +160,9 @@ describe('Orderbook', () => {
|
|||||||
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
|
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
|
||||||
const scrollElement = result.getByTestId('scroll');
|
const scrollElement = result.getByTestId('scroll');
|
||||||
expect(scrollElement.scrollTop).toBe(91 * rowHeight);
|
expect(scrollElement.scrollTop).toBe(91 * rowHeight);
|
||||||
scrollElement.scrollTop = 0;
|
scrollElement.scrollTop = 1;
|
||||||
fireEvent.scroll(scrollElement);
|
fireEvent.scroll(scrollElement);
|
||||||
expect(result.getByTestId('scroll').scrollTop).toBe(0);
|
expect(result.getByTestId('scroll').scrollTop).toBe(1);
|
||||||
const resolutionSelect = result.getByTestId(
|
const resolutionSelect = result.getByTestId(
|
||||||
'resolution'
|
'resolution'
|
||||||
) as HTMLSelectElement;
|
) as HTMLSelectElement;
|
||||||
@ -181,6 +181,6 @@ describe('Orderbook', () => {
|
|||||||
onResolutionChange={onResolutionChange}
|
onResolutionChange={onResolutionChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
expect(result.getByTestId('scroll').scrollTop).toBe(5 * rowHeight);
|
expect(result.getByTestId('scroll').scrollTop).toBe(6 * rowHeight);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,15 +1,7 @@
|
|||||||
import styles from './orderbook.module.scss';
|
import styles from './orderbook.module.scss';
|
||||||
import colors from 'tailwindcss/colors';
|
import colors from 'tailwindcss/colors';
|
||||||
|
|
||||||
import {
|
import { useEffect, useRef, useState, useCallback, Fragment } from 'react';
|
||||||
useEffect,
|
|
||||||
useLayoutEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
useMemo,
|
|
||||||
useCallback,
|
|
||||||
Fragment,
|
|
||||||
} from 'react';
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -21,7 +13,7 @@ import {
|
|||||||
} from '@vegaprotocol/react-helpers';
|
} from '@vegaprotocol/react-helpers';
|
||||||
import * as Schema from '@vegaprotocol/types';
|
import * as Schema from '@vegaprotocol/types';
|
||||||
import { OrderbookRow } from './orderbook-row';
|
import { OrderbookRow } from './orderbook-row';
|
||||||
import { createRow, getPriceLevel } from './orderbook-data';
|
import { createRow } from './orderbook-data';
|
||||||
import { Checkbox, Icon, Splash } from '@vegaprotocol/ui-toolkit';
|
import { Checkbox, Icon, Splash } from '@vegaprotocol/ui-toolkit';
|
||||||
import type { OrderbookData, OrderbookRowData } from './orderbook-data';
|
import type { OrderbookData, OrderbookRowData } from './orderbook-data';
|
||||||
|
|
||||||
@ -36,7 +28,7 @@ interface OrderbookProps extends OrderbookData {
|
|||||||
|
|
||||||
const HorizontalLine = ({ top, testId }: { top: string; testId: string }) => (
|
const HorizontalLine = ({ top, testId }: { top: string; testId: string }) => (
|
||||||
<div
|
<div
|
||||||
className="absolute border-b border-default inset-x-0"
|
className="absolute border-b border-default inset-x-0 hidden"
|
||||||
style={{ top }}
|
style={{ top }}
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
/>
|
/>
|
||||||
@ -97,7 +89,12 @@ const getRowsToRender = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 17px of row height plus 5px gap
|
// 17px of row height plus 5px gap
|
||||||
|
export const gridGap = 5;
|
||||||
export const rowHeight = 22;
|
export const rowHeight = 22;
|
||||||
|
// top padding to make space for header
|
||||||
|
const headerPadding = 30;
|
||||||
|
// bottom padding to make space for footer
|
||||||
|
const footerPadding = 25;
|
||||||
// buffer size in rows
|
// buffer size in rows
|
||||||
const bufferSize = 30;
|
const bufferSize = 30;
|
||||||
// margin size in px, when reached scrollOffset will be updated
|
// margin size in px, when reached scrollOffset will be updated
|
||||||
@ -112,30 +109,30 @@ const getBestStaticBidPriceLinePosition = (
|
|||||||
rows: OrderbookRowData[] | null
|
rows: OrderbookRowData[] | null
|
||||||
) => {
|
) => {
|
||||||
let bestStaticBidPriceLinePosition = '';
|
let bestStaticBidPriceLinePosition = '';
|
||||||
if (maxPriceLevel !== '0' && minPriceLevel !== '0') {
|
if (
|
||||||
if (
|
rows?.length &&
|
||||||
bestStaticBidPrice &&
|
bestStaticBidPrice &&
|
||||||
BigInt(bestStaticBidPrice) < BigInt(maxPriceLevel) &&
|
BigInt(bestStaticBidPrice) < BigInt(maxPriceLevel) &&
|
||||||
BigInt(bestStaticBidPrice) > BigInt(minPriceLevel)
|
BigInt(bestStaticBidPrice) > BigInt(minPriceLevel)
|
||||||
) {
|
) {
|
||||||
if (fillGaps) {
|
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 = (
|
bestStaticBidPriceLinePosition = (
|
||||||
((BigInt(maxPriceLevel) - BigInt(bestStaticBidPrice)) /
|
index * rowHeight +
|
||||||
BigInt(resolution) +
|
headerPadding -
|
||||||
BigInt(1)) *
|
3
|
||||||
BigInt(rowHeight) +
|
|
||||||
BigInt(1)
|
|
||||||
).toString();
|
).toString();
|
||||||
} else {
|
|
||||||
const index = rows?.findIndex(
|
|
||||||
(row) => BigInt(row.price) <= BigInt(bestStaticBidPrice)
|
|
||||||
);
|
|
||||||
if (index !== undefined && index !== -1) {
|
|
||||||
bestStaticBidPriceLinePosition = (
|
|
||||||
(index + 1) * rowHeight +
|
|
||||||
1
|
|
||||||
).toString();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -151,6 +148,7 @@ const getBestStaticOfferPriceLinePosition = (
|
|||||||
) => {
|
) => {
|
||||||
let bestStaticOfferPriceLinePosition = '';
|
let bestStaticOfferPriceLinePosition = '';
|
||||||
if (
|
if (
|
||||||
|
rows?.length &&
|
||||||
bestStaticOfferPrice &&
|
bestStaticOfferPrice &&
|
||||||
BigInt(bestStaticOfferPrice) <= BigInt(maxPriceLevel) &&
|
BigInt(bestStaticOfferPrice) <= BigInt(maxPriceLevel) &&
|
||||||
BigInt(bestStaticOfferPrice) > BigInt(minPriceLevel)
|
BigInt(bestStaticOfferPrice) > BigInt(minPriceLevel)
|
||||||
@ -159,9 +157,10 @@ const getBestStaticOfferPriceLinePosition = (
|
|||||||
bestStaticOfferPriceLinePosition = (
|
bestStaticOfferPriceLinePosition = (
|
||||||
((BigInt(maxPriceLevel) - BigInt(bestStaticOfferPrice)) /
|
((BigInt(maxPriceLevel) - BigInt(bestStaticOfferPrice)) /
|
||||||
BigInt(resolution) +
|
BigInt(resolution) +
|
||||||
BigInt(2)) *
|
BigInt(1)) *
|
||||||
BigInt(rowHeight) +
|
BigInt(rowHeight) +
|
||||||
BigInt(1)
|
BigInt(headerPadding) -
|
||||||
|
BigInt(3)
|
||||||
).toString();
|
).toString();
|
||||||
} else {
|
} else {
|
||||||
const index = rows?.findIndex(
|
const index = rows?.findIndex(
|
||||||
@ -169,8 +168,9 @@ const getBestStaticOfferPriceLinePosition = (
|
|||||||
);
|
);
|
||||||
if (index !== undefined && index !== -1) {
|
if (index !== undefined && index !== -1) {
|
||||||
bestStaticOfferPriceLinePosition = (
|
bestStaticOfferPriceLinePosition = (
|
||||||
(index + 2) * rowHeight +
|
(index + 1) * rowHeight +
|
||||||
1
|
headerPadding -
|
||||||
|
3
|
||||||
).toString();
|
).toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -187,7 +187,7 @@ const OrderbookDebugInfo = ({
|
|||||||
bestStaticOfferPrice,
|
bestStaticOfferPrice,
|
||||||
maxPriceLevel,
|
maxPriceLevel,
|
||||||
minPriceLevel,
|
minPriceLevel,
|
||||||
resolution,
|
midPrice,
|
||||||
}: {
|
}: {
|
||||||
decimalPlaces: number;
|
decimalPlaces: number;
|
||||||
numberOfRows: number;
|
numberOfRows: number;
|
||||||
@ -198,7 +198,7 @@ const OrderbookDebugInfo = ({
|
|||||||
bestStaticOfferPrice?: string;
|
bestStaticOfferPrice?: string;
|
||||||
maxPriceLevel: string;
|
maxPriceLevel: string;
|
||||||
minPriceLevel: string;
|
minPriceLevel: string;
|
||||||
resolution: number;
|
midPrice?: string;
|
||||||
}) => (
|
}) => (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<div
|
<div
|
||||||
@ -247,16 +247,7 @@ const OrderbookDebugInfo = ({
|
|||||||
decimalPlaces
|
decimalPlaces
|
||||||
),
|
),
|
||||||
midPrice: addDecimalsFixedFormatNumber(
|
midPrice: addDecimalsFixedFormatNumber(
|
||||||
(bestStaticOfferPrice &&
|
midPrice ?? '0',
|
||||||
bestStaticBidPrice &&
|
|
||||||
getPriceLevel(
|
|
||||||
BigInt(bestStaticOfferPrice) +
|
|
||||||
(BigInt(bestStaticBidPrice) -
|
|
||||||
BigInt(bestStaticOfferPrice)) /
|
|
||||||
BigInt(2),
|
|
||||||
resolution
|
|
||||||
)) ??
|
|
||||||
'0',
|
|
||||||
decimalPlaces
|
decimalPlaces
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -270,6 +261,7 @@ const OrderbookDebugInfo = ({
|
|||||||
|
|
||||||
export const Orderbook = ({
|
export const Orderbook = ({
|
||||||
rows,
|
rows,
|
||||||
|
midPrice,
|
||||||
bestStaticBidPrice,
|
bestStaticBidPrice,
|
||||||
bestStaticOfferPrice,
|
bestStaticOfferPrice,
|
||||||
marketTradingMode,
|
marketTradingMode,
|
||||||
@ -295,21 +287,36 @@ export const Orderbook = ({
|
|||||||
// price level which is rendered in center of viewport, need to preserve price level when rows will be added or removed
|
// 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
|
// if undefined then we render mid price in center
|
||||||
const priceInCenter = useRef<string>();
|
const priceInCenter = useRef<string>();
|
||||||
|
// by default mid price is rendered in center - view locked on mid price
|
||||||
const [lockOnMidPrice, setLockOnMidPrice] = useState(true);
|
const [lockOnMidPrice, setLockOnMidPrice] = useState(true);
|
||||||
const resolutionRef = useRef(resolution);
|
const resolutionRef = useRef(resolution);
|
||||||
const [viewportHeight, setViewportHeight] = useState(window.innerHeight);
|
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 [fillGaps, setFillGaps] = useState(!!initialFillGaps);
|
||||||
const numberOfRows = useMemo(
|
|
||||||
() => (fillGaps ? getNumberOfRows(rows, resolution) : rows?.length ?? 0),
|
|
||||||
[rows, resolution, fillGaps]
|
|
||||||
);
|
|
||||||
const maxPriceLevel = rows?.[0]?.price ?? '0';
|
|
||||||
const minPriceLevel = (
|
|
||||||
fillGaps
|
|
||||||
? BigInt(maxPriceLevel) - BigInt(Math.floor(numberOfRows * resolution))
|
|
||||||
: BigInt(rows?.[rows.length - 1]?.price ?? '0')
|
|
||||||
).toString();
|
|
||||||
const [debug, setDebug] = useState(false);
|
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(
|
const updateScrollOffset = useCallback(
|
||||||
(scrollTop: number) => {
|
(scrollTop: number) => {
|
||||||
if (Math.abs(scrollOffset - scrollTop) > marginSize) {
|
if (Math.abs(scrollOffset - scrollTop) > marginSize) {
|
||||||
@ -318,23 +325,35 @@ export const Orderbook = ({
|
|||||||
},
|
},
|
||||||
[scrollOffset]
|
[scrollOffset]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onScroll = useCallback(
|
const onScroll = useCallback(
|
||||||
(event: React.UIEvent<HTMLDivElement>) => {
|
(event: React.UIEvent<HTMLDivElement>) => {
|
||||||
const { scrollTop } = event.currentTarget;
|
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
|
||||||
updateScrollOffset(scrollTop);
|
updateScrollOffset(scrollTop);
|
||||||
if (scrollTop === scrollTopRef.current) {
|
if (scrollTop === scrollTopRef.current) {
|
||||||
return;
|
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();
|
||||||
}
|
}
|
||||||
const offsetTop = Math.floor(
|
|
||||||
(scrollTop + Math.floor(viewportHeight / 2)) / rowHeight
|
|
||||||
);
|
|
||||||
priceInCenter.current = fillGaps
|
|
||||||
? (
|
|
||||||
BigInt(resolution) + // extra row on very top - sticky header
|
|
||||||
BigInt(maxPriceLevel) -
|
|
||||||
BigInt(offsetTop) * BigInt(resolution)
|
|
||||||
).toString()
|
|
||||||
: rows?.[Math.min(offsetTop, rows.length - 1)]?.price?.toString();
|
|
||||||
if (lockOnMidPrice) {
|
if (lockOnMidPrice) {
|
||||||
setLockOnMidPrice(false);
|
setLockOnMidPrice(false);
|
||||||
}
|
}
|
||||||
@ -361,24 +380,22 @@ export const Orderbook = ({
|
|||||||
(Number(
|
(Number(
|
||||||
(BigInt(maxPriceLevel) - BigInt(price)) / BigInt(resolution)
|
(BigInt(maxPriceLevel) - BigInt(price)) / BigInt(resolution)
|
||||||
) +
|
) +
|
||||||
1) * // add one row for sticky header
|
1) *
|
||||||
rowHeight +
|
rowHeight;
|
||||||
rowHeight / 2 -
|
|
||||||
(viewportHeight % rowHeight);
|
|
||||||
} else if (rows) {
|
} else if (rows) {
|
||||||
const index = rows.findIndex(
|
const index = rows.findIndex(
|
||||||
(row) => BigInt(row.price) <= BigInt(price)
|
(row) => BigInt(row.price) <= BigInt(price)
|
||||||
);
|
);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
scrollTop =
|
scrollTop = rowHeight * (index + 1);
|
||||||
index * rowHeight + rowHeight / 2 - (viewportHeight % rowHeight);
|
if (index !== 0) {
|
||||||
if (
|
const diffToCurrentRow =
|
||||||
price === rows[index].price ||
|
BigInt(price) - BigInt(rows[index].price);
|
||||||
index === 0 ||
|
const diffToPreviousRow =
|
||||||
BigInt(rows[index].price) - BigInt(price) <
|
BigInt(rows[index - 1].price) - BigInt(price);
|
||||||
BigInt(price) - BigInt(rows[index - 1].price)
|
if (diffToPreviousRow < diffToCurrentRow) {
|
||||||
) {
|
scrollTop -= rowHeight;
|
||||||
scrollTop += rowHeight;
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -389,7 +406,13 @@ export const Orderbook = ({
|
|||||||
(scrollTopRef.current % rowHeight) - (scrollTop % rowHeight);
|
(scrollTopRef.current % rowHeight) - (scrollTop % rowHeight);
|
||||||
const priceCenterScrollOffset = Math.max(
|
const priceCenterScrollOffset = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(scrollTop, numberOfRows * rowHeight - viewportHeight)
|
Math.min(
|
||||||
|
scrollTop,
|
||||||
|
numberOfRows * rowHeight +
|
||||||
|
headerPadding +
|
||||||
|
footerPadding +
|
||||||
|
-viewportHeight
|
||||||
|
)
|
||||||
);
|
);
|
||||||
if (scrollTopRef.current !== priceCenterScrollOffset) {
|
if (scrollTopRef.current !== priceCenterScrollOffset) {
|
||||||
updateScrollOffset(priceCenterScrollOffset);
|
updateScrollOffset(priceCenterScrollOffset);
|
||||||
@ -410,72 +433,31 @@ export const Orderbook = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const scrollToMidPrice = useCallback(() => {
|
const scrollToMidPrice = useCallback(() => {
|
||||||
if (!bestStaticOfferPrice || !bestStaticBidPrice) {
|
if (!midPrice) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
priceInCenter.current = undefined;
|
priceInCenter.current = undefined;
|
||||||
let midPrice = getPriceLevel(
|
|
||||||
BigInt(bestStaticOfferPrice) +
|
|
||||||
(BigInt(bestStaticBidPrice) - BigInt(bestStaticOfferPrice)) / BigInt(2),
|
|
||||||
resolution
|
|
||||||
);
|
|
||||||
if (BigInt(midPrice) > BigInt(maxPriceLevel)) {
|
|
||||||
midPrice = maxPriceLevel;
|
|
||||||
} else {
|
|
||||||
if (BigInt(midPrice) < BigInt(minPriceLevel)) {
|
|
||||||
midPrice = minPriceLevel.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
scrollToPrice(midPrice);
|
scrollToPrice(midPrice);
|
||||||
setLockOnMidPrice(true);
|
setLockOnMidPrice(true);
|
||||||
}, [
|
}, [midPrice, scrollToPrice]);
|
||||||
bestStaticOfferPrice,
|
|
||||||
bestStaticBidPrice,
|
|
||||||
scrollToPrice,
|
|
||||||
resolution,
|
|
||||||
maxPriceLevel,
|
|
||||||
minPriceLevel,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// adjust scroll position to keep selected price in center
|
// adjust scroll position to keep selected price in center
|
||||||
useLayoutEffect(() => {
|
useEffect(() => {
|
||||||
|
if (priceInCenter.current) {
|
||||||
|
scrollToPrice(priceInCenter.current);
|
||||||
|
} else if (lockOnMidPrice && midPrice) {
|
||||||
|
scrollToPrice(midPrice);
|
||||||
|
}
|
||||||
|
}, [midPrice, scrollToPrice, lockOnMidPrice]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (resolutionRef.current !== resolution) {
|
if (resolutionRef.current !== resolution) {
|
||||||
priceInCenter.current = undefined;
|
priceInCenter.current = undefined;
|
||||||
resolutionRef.current = resolution;
|
resolutionRef.current = resolution;
|
||||||
|
setLockOnMidPrice(true);
|
||||||
}
|
}
|
||||||
if (priceInCenter.current) {
|
}, [resolution]);
|
||||||
scrollToPrice(priceInCenter.current);
|
|
||||||
} else {
|
|
||||||
scrollToMidPrice();
|
|
||||||
}
|
|
||||||
}, [scrollToMidPrice, scrollToPrice, resolution]);
|
|
||||||
|
|
||||||
// handles window resize
|
|
||||||
useEffect(() => {
|
|
||||||
function handleResize() {
|
|
||||||
if (rootElement.current) {
|
|
||||||
setViewportHeight(
|
|
||||||
rootElement.current.clientHeight || window.innerHeight
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
handleResize();
|
|
||||||
return () => window.removeEventListener('resize', handleResize);
|
|
||||||
}, []);
|
|
||||||
// sets the correct width of header and footer
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (
|
|
||||||
!gridElement.current ||
|
|
||||||
!headerElement.current ||
|
|
||||||
!footerElement.current
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const gridWidth = gridElement.current.clientWidth;
|
|
||||||
headerElement.current.style.width = `${gridWidth}px`;
|
|
||||||
footerElement.current.style.width = `${gridWidth}px`;
|
|
||||||
}, [headerElement, footerElement, gridElement]);
|
|
||||||
// handles resizing of the Allotment.Pane (x-axis)
|
// handles resizing of the Allotment.Pane (x-axis)
|
||||||
// adjusts the header and footer width
|
// adjusts the header and footer width
|
||||||
const gridResizeHandler: ResizeObserverCallback = useCallback(
|
const gridResizeHandler: ResizeObserverCallback = useCallback(
|
||||||
@ -509,20 +491,6 @@ export const Orderbook = ({
|
|||||||
useResizeObserver(gridElement.current, gridResizeHandler);
|
useResizeObserver(gridElement.current, gridResizeHandler);
|
||||||
useResizeObserver(rootElement.current, rootElementResizeHandler);
|
useResizeObserver(rootElement.current, rootElementResizeHandler);
|
||||||
|
|
||||||
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;
|
|
||||||
const paddingBottom = (numberOfRows - offset - limit) * rowHeight;
|
|
||||||
const tableBody =
|
const tableBody =
|
||||||
data && data.length !== 0 ? (
|
data && data.length !== 0 ? (
|
||||||
<div
|
<div
|
||||||
@ -603,16 +571,16 @@ export const Orderbook = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`h-full overflow-auto relative ${styles['scroll']} pt-[26px] pb-[17px]`}
|
className={`h-full overflow-auto relative ${styles['scroll']}`}
|
||||||
onScroll={onScroll}
|
onScroll={onScroll}
|
||||||
ref={scrollElement}
|
ref={scrollElement}
|
||||||
data-testid="scroll"
|
data-testid="scroll"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="relative text-right min-h-full"
|
className="relative text-right min-h-full overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
paddingTop: paddingTop,
|
paddingTop,
|
||||||
paddingBottom: paddingBottom,
|
paddingBottom,
|
||||||
background: tableBody ? gradientStyles : 'none',
|
background: tableBody ? gradientStyles : 'none',
|
||||||
}}
|
}}
|
||||||
ref={gridElement}
|
ref={gridElement}
|
||||||
@ -685,7 +653,7 @@ export const Orderbook = ({
|
|||||||
{debug && (
|
{debug && (
|
||||||
<OrderbookDebugInfo
|
<OrderbookDebugInfo
|
||||||
decimalPlaces={decimalPlaces}
|
decimalPlaces={decimalPlaces}
|
||||||
resolution={resolution}
|
midPrice={midPrice}
|
||||||
numberOfRows={numberOfRows}
|
numberOfRows={numberOfRows}
|
||||||
viewportHeight={viewportHeight}
|
viewportHeight={viewportHeight}
|
||||||
lockOnMidPrice={lockOnMidPrice}
|
lockOnMidPrice={lockOnMidPrice}
|
||||||
|
Loading…
Reference in New Issue
Block a user