Feature/151 orderbook (#266)
* [#151] Add orderbook * [#151] Add orderbook components * [#151] Add market-depth data provider * [#151] Add orderbook manager * [#151] handle market depth updates in orderbook manager * [#151] Fix PageQueryContainer usage of AsyncRenderer * [#151] Move data handling logig out from orderbook manager * [#151] Use mocked data, fix data issues * [#151] Add compact order book data unit tests * [#151] Add updateData unit tests * [#151] Add updateCompactedData unit tests * [#151] update cummulative vol on update, move data ref handling from effect to manager * [#151] Update cummulative vol on update, fixes * [#151] Optimieze orderbook ag-grid render * [#151] Replace ag-grid with orderbook component * [#151] Use react memo to optimize orderbook rendering * [#151] Fix cummulative vol computation on compacted data update * [#151] Add resolution controls * [#151] Fix cumulative spelling mistake * [#151] Use number instead of string for cumulative values * [#151] Expose restart callback in useDataProvider hook * [#151] Update empty cumulative values * [#151] Add horizontal line under header in orderbook * [#151] reuse orderbook data in market-depth chart * [#151] restart market depth provider on sequenceNumber gap * [#151] use throttle in orderbook and depth-chart * [#151] Add comments, refactor data functions * [#151] Add comments, refactor data functions * [#151] move orderbook and depth-chart libs to market-depth
This commit is contained in:
parent
b5788379a3
commit
d0ec016adc
@ -7,19 +7,22 @@ import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
|
||||
interface PageQueryContainerProps<TData, TVariables> {
|
||||
query: DocumentNode;
|
||||
options?: QueryHookOptions<TData, TVariables>;
|
||||
children: (data: TData) => ReactNode;
|
||||
render: (data: TData) => ReactNode;
|
||||
}
|
||||
|
||||
export const PageQueryContainer = <TData, TVariables = OperationVariables>({
|
||||
query,
|
||||
options,
|
||||
children,
|
||||
render,
|
||||
}: PageQueryContainerProps<TData, TVariables>) => {
|
||||
const { data, loading, error } = useQuery<TData, TVariables>(query, options);
|
||||
|
||||
return (
|
||||
<AsyncRenderer<TData> loading={loading} error={error} data={data}>
|
||||
{(data) => children(data)}
|
||||
</AsyncRenderer>
|
||||
<AsyncRenderer<TData>
|
||||
loading={loading}
|
||||
error={error}
|
||||
data={data}
|
||||
render={render}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -46,8 +46,9 @@ interface Web3ContainerProps {
|
||||
export const Web3Container = ({ children }: Web3ContainerProps) => {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
return (
|
||||
<PageQueryContainer<NetworkParamsQuery> query={NETWORK_PARAMS_QUERY}>
|
||||
{(data) => {
|
||||
<PageQueryContainer<NetworkParamsQuery>
|
||||
query={NETWORK_PARAMS_QUERY}
|
||||
render={(data) => {
|
||||
const ethereumConfigParam = data.networkParameters?.find(
|
||||
(np) => np.key === 'blockchains.ethereumConfig'
|
||||
);
|
||||
@ -89,7 +90,7 @@ export const Web3Container = ({ children }: Web3ContainerProps) => {
|
||||
</Web3Provider>
|
||||
);
|
||||
}}
|
||||
</PageQueryContainer>
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -44,8 +44,7 @@ const MarketPage = () => {
|
||||
},
|
||||
fetchPolicy: 'network-only',
|
||||
}}
|
||||
>
|
||||
{({ market }) => {
|
||||
render={({ market }) => {
|
||||
if (!market) {
|
||||
return <Splash>{t('Market not found')}</Splash>;
|
||||
}
|
||||
@ -56,7 +55,7 @@ const MarketPage = () => {
|
||||
<TradePanels market={market} />
|
||||
);
|
||||
}}
|
||||
</PageQueryContainer>
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -6,25 +6,19 @@ import { GridTab, GridTabs } from './grid-tabs';
|
||||
import { DealTicketContainer } from '@vegaprotocol/deal-ticket';
|
||||
import { OrderListContainer } from '@vegaprotocol/order-list';
|
||||
import { TradesContainer } from '@vegaprotocol/trades';
|
||||
import { Splash } from '@vegaprotocol/ui-toolkit';
|
||||
import { PositionsContainer } from '@vegaprotocol/positions';
|
||||
import { OrderbookContainer } from '@vegaprotocol/market-depth';
|
||||
import type { Market_market } from './__generated__/Market';
|
||||
import { t } from '@vegaprotocol/react-helpers';
|
||||
import { AccountsContainer } from '@vegaprotocol/accounts';
|
||||
import { DepthChartContainer } from '@vegaprotocol/depth-chart';
|
||||
import { DepthChartContainer } from '@vegaprotocol/market-depth';
|
||||
import { CandlesChartContainer } from '@vegaprotocol/candles-chart';
|
||||
|
||||
const Orderbook = () => (
|
||||
<Splash>
|
||||
<p>{t('Orderbook')}</p>
|
||||
</Splash>
|
||||
);
|
||||
|
||||
const TradingViews = {
|
||||
Candles: CandlesChartContainer,
|
||||
Depth: DepthChartContainer,
|
||||
Ticket: DealTicketContainer,
|
||||
Orderbook: Orderbook,
|
||||
Orderbook: OrderbookContainer,
|
||||
Orders: OrderListContainer,
|
||||
Positions: PositionsContainer,
|
||||
Accounts: AccountsContainer,
|
||||
@ -70,7 +64,7 @@ export const TradeGrid = ({ market }: TradeGridProps) => {
|
||||
<TradingViews.Trades marketId={market.id} />
|
||||
</GridTab>
|
||||
<GridTab id="orderbook" name={t('Orderbook')}>
|
||||
<TradingViews.Orderbook />
|
||||
<TradingViews.Orderbook marketId={market.id} />
|
||||
</GridTab>
|
||||
</GridTabs>
|
||||
</TradeGridChild>
|
||||
|
@ -39,8 +39,9 @@ export const DepositContainer = ({
|
||||
assetId,
|
||||
}: DepositContainerProps) => {
|
||||
return (
|
||||
<PageQueryContainer<DepositPage> query={DEPOSIT_PAGE_QUERY}>
|
||||
{(data) => {
|
||||
<PageQueryContainer<DepositPage>
|
||||
query={DEPOSIT_PAGE_QUERY}
|
||||
render={(data) => {
|
||||
if (!data.assets?.length) {
|
||||
return (
|
||||
<Splash>
|
||||
@ -58,7 +59,7 @@ export const DepositContainer = ({
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</PageQueryContainer>
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -67,7 +67,7 @@ export const AccountsManager = ({ partyId }: AccountsManagerProps) => {
|
||||
>(accountsDataProvider, update, variables);
|
||||
return (
|
||||
<AsyncRenderer loading={loading} error={error} data={data}>
|
||||
{(data) => <AccountsTable ref={gridRef} data={data} />}
|
||||
<AccountsTable ref={gridRef} data={data} />
|
||||
</AsyncRenderer>
|
||||
);
|
||||
};
|
||||
|
@ -40,16 +40,13 @@ export const DealTicketContainer = ({ marketId }: DealTicketContainerProps) => {
|
||||
|
||||
return (
|
||||
<AsyncRenderer<DealTicketQuery> data={data} loading={loading} error={error}>
|
||||
{(data) => {
|
||||
if (!data.market) {
|
||||
return (
|
||||
{data && data.market ? (
|
||||
<DealTicketManager market={data.market} />
|
||||
) : (
|
||||
<Splash>
|
||||
<p>{t('Could not load market')}</p>
|
||||
</Splash>
|
||||
);
|
||||
}
|
||||
return <DealTicketManager market={data.market} />;
|
||||
}}
|
||||
)}
|
||||
</AsyncRenderer>
|
||||
);
|
||||
};
|
||||
|
@ -1,11 +0,0 @@
|
||||
# depth-chart
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
|
||||
## Building
|
||||
|
||||
Run `nx build depth-chart` to build the library.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `nx test depth-chart` to execute the unit tests via [Jest](https://jestjs.io).
|
@ -1,10 +0,0 @@
|
||||
module.exports = {
|
||||
displayName: 'depth-chart',
|
||||
preset: '../../jest.preset.js',
|
||||
transform: {
|
||||
'^.+\\.[tj]sx?$': 'babel-jest',
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
||||
coverageDirectory: '../../coverage/libs/depth-chart',
|
||||
setupFiles: ['jest-canvas-mock'],
|
||||
};
|
@ -1,4 +0,0 @@
|
||||
{
|
||||
"name": "@vegaprotocol/depth-chart",
|
||||
"version": "0.0.1"
|
||||
}
|
@ -1 +0,0 @@
|
||||
export * from './lib/depth-chart';
|
@ -1,67 +0,0 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
// ====================================================
|
||||
// GraphQL subscription operation: marketDepthSubscribe
|
||||
// ====================================================
|
||||
|
||||
export interface marketDepthSubscribe_marketDepth_sell {
|
||||
__typename: "PriceLevel";
|
||||
/**
|
||||
* The price of all the orders at this level (uint64)
|
||||
*/
|
||||
price: string;
|
||||
/**
|
||||
* The total remaining size of all orders at this level (uint64)
|
||||
*/
|
||||
volume: string;
|
||||
/**
|
||||
* The number of orders at this price level (uint64)
|
||||
*/
|
||||
numberOfOrders: string;
|
||||
}
|
||||
|
||||
export interface marketDepthSubscribe_marketDepth_buy {
|
||||
__typename: "PriceLevel";
|
||||
/**
|
||||
* The price of all the orders at this level (uint64)
|
||||
*/
|
||||
price: string;
|
||||
/**
|
||||
* The total remaining size of all orders at this level (uint64)
|
||||
*/
|
||||
volume: string;
|
||||
/**
|
||||
* The number of orders at this price level (uint64)
|
||||
*/
|
||||
numberOfOrders: string;
|
||||
}
|
||||
|
||||
export interface marketDepthSubscribe_marketDepth {
|
||||
__typename: "MarketDepth";
|
||||
/**
|
||||
* Sell side price levels (if available)
|
||||
*/
|
||||
sell: marketDepthSubscribe_marketDepth_sell[] | null;
|
||||
/**
|
||||
* Buy side price levels (if available)
|
||||
*/
|
||||
buy: marketDepthSubscribe_marketDepth_buy[] | null;
|
||||
/**
|
||||
* Sequence number for the current snapshot of the market depth
|
||||
*/
|
||||
sequenceNumber: string;
|
||||
}
|
||||
|
||||
export interface marketDepthSubscribe {
|
||||
/**
|
||||
* Subscribe to the market depths update
|
||||
*/
|
||||
marketDepth: marketDepthSubscribe_marketDepth;
|
||||
}
|
||||
|
||||
export interface marketDepthSubscribeVariables {
|
||||
marketId: string;
|
||||
}
|
@ -1,65 +0,0 @@
|
||||
import 'pennant/dist/style.css';
|
||||
|
||||
import { addDecimal, ThemeContext } from '@vegaprotocol/react-helpers';
|
||||
import { DepthChart } from 'pennant';
|
||||
import type { DepthChartProps } from 'pennant';
|
||||
import { Splash } from '@vegaprotocol/ui-toolkit';
|
||||
import type { marketDepthUpdateSubscribe_marketDepthUpdate_sell } from './__generated__/marketDepthUpdateSubscribe';
|
||||
|
||||
import { useContext } from 'react';
|
||||
import { useDepthUpdate } from './hooks/use-depth-update';
|
||||
|
||||
type PriceLevel = Pick<
|
||||
marketDepthUpdateSubscribe_marketDepthUpdate_sell,
|
||||
'price' | 'volume'
|
||||
>;
|
||||
|
||||
export type DepthChartContainerProps = {
|
||||
marketId: string;
|
||||
};
|
||||
|
||||
export const DepthChartContainer = ({ marketId }: DepthChartContainerProps) => {
|
||||
const theme = useContext(ThemeContext);
|
||||
|
||||
const { data, loading, error } = useDepthUpdate({ marketId }, 500);
|
||||
|
||||
if (error) {
|
||||
return <Splash>Error</Splash>;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Splash>Loading...</Splash>;
|
||||
}
|
||||
|
||||
if (!data || !data.market) {
|
||||
return <Splash>No Data</Splash>;
|
||||
}
|
||||
|
||||
const market = data.market;
|
||||
const decimalPlaces = data.market.decimalPlaces;
|
||||
const depthData: DepthChartProps['data'] = { buy: [], sell: [] };
|
||||
|
||||
if (market.depth) {
|
||||
if (market.depth.buy) {
|
||||
depthData.buy = market?.depth.buy?.map((priceLevel: PriceLevel) => ({
|
||||
price: Number(addDecimal(priceLevel.price, decimalPlaces)),
|
||||
volume: Number(priceLevel.volume),
|
||||
}));
|
||||
}
|
||||
|
||||
if (market.depth.sell) {
|
||||
depthData.sell = market?.depth.sell?.map((priceLevel: PriceLevel) => ({
|
||||
price: Number(addDecimal(priceLevel.price, decimalPlaces)),
|
||||
volume: Number(priceLevel.volume),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
let midPrice: number | undefined = undefined;
|
||||
|
||||
if (market.data?.midPrice) {
|
||||
midPrice = Number(addDecimal(market.data.midPrice, decimalPlaces));
|
||||
}
|
||||
|
||||
return <DepthChart data={depthData} midPrice={midPrice} theme={theme} />;
|
||||
};
|
@ -1 +0,0 @@
|
||||
export * from './update-depth-update';
|
@ -1,110 +0,0 @@
|
||||
import { updateDepthUpdate } from './update-depth-update';
|
||||
|
||||
describe('updateDepthUpdate', () => {
|
||||
it('Updates typical case', () => {
|
||||
const prev = createMarketDepth([{ price: '100', volume: '10' }], null);
|
||||
const update = createMarketDepthUpdate(
|
||||
[{ price: '200', volume: '20' }],
|
||||
null
|
||||
);
|
||||
|
||||
const expected = createMarketDepth(
|
||||
[
|
||||
{ price: '200', volume: '20' },
|
||||
{ price: '100', volume: '10' },
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
expect(updateDepthUpdate(prev, update)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('Removes price level', () => {
|
||||
const prev = createMarketDepth(
|
||||
[
|
||||
{ price: '200', volume: '20' },
|
||||
{ price: '100', volume: '10' },
|
||||
],
|
||||
null
|
||||
);
|
||||
|
||||
const update = createMarketDepthUpdate(
|
||||
[{ price: '200', volume: '0' }],
|
||||
null
|
||||
);
|
||||
|
||||
const expected = createMarketDepth([{ price: '100', volume: '10' }], []);
|
||||
|
||||
expect(updateDepthUpdate(prev, update)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
function createMarketDepth(
|
||||
buy: { price: string; volume: string }[] | null,
|
||||
sell: { price: string; volume: string }[] | null
|
||||
) {
|
||||
return {
|
||||
market: {
|
||||
__typename: 'Market' as const,
|
||||
id: 'id',
|
||||
decimalPlaces: 0,
|
||||
data: { __typename: 'MarketData' as const, midPrice: '100' },
|
||||
depth: {
|
||||
__typename: 'MarketDepth' as const,
|
||||
lastTrade: { __typename: 'Trade' as const, price: '100' },
|
||||
sell: sell
|
||||
? sell.map((priceLevel) => ({
|
||||
__typename: 'PriceLevel' as const,
|
||||
price: priceLevel.price,
|
||||
volume: priceLevel.volume,
|
||||
numberOfOrders: '20',
|
||||
}))
|
||||
: null,
|
||||
buy: buy
|
||||
? buy.map((priceLevel) => ({
|
||||
__typename: 'PriceLevel' as const,
|
||||
price: priceLevel.price,
|
||||
volume: priceLevel.volume,
|
||||
numberOfOrders: '20',
|
||||
}))
|
||||
: null,
|
||||
sequenceNumber: '0',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createMarketDepthUpdate(
|
||||
buy: { price: string; volume: string }[] | null,
|
||||
sell: { price: string; volume: string }[] | null
|
||||
) {
|
||||
return {
|
||||
data: {
|
||||
marketDepthUpdate: {
|
||||
__typename: 'MarketDepthUpdate' as const,
|
||||
market: {
|
||||
__typename: 'Market' as const,
|
||||
id: 'id',
|
||||
data: { __typename: 'MarketData' as const, midPrice: '100' },
|
||||
},
|
||||
sell: sell
|
||||
? sell.map((priceLevel) => ({
|
||||
__typename: 'PriceLevel' as const,
|
||||
price: priceLevel.price,
|
||||
volume: priceLevel.volume,
|
||||
numberOfOrders: '20',
|
||||
}))
|
||||
: null,
|
||||
buy: buy
|
||||
? buy.map((priceLevel) => ({
|
||||
__typename: 'PriceLevel' as const,
|
||||
price: priceLevel.price,
|
||||
volume: priceLevel.volume,
|
||||
numberOfOrders: '20',
|
||||
}))
|
||||
: null,
|
||||
sequenceNumber: '1',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
@ -1,90 +0,0 @@
|
||||
import type {
|
||||
marketDepth,
|
||||
marketDepth_market_depth,
|
||||
} from '../__generated__/marketDepth';
|
||||
import type { marketDepthUpdateSubscribe } from '../__generated__/marketDepthUpdateSubscribe';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
|
||||
type MarketDepth = Pick<marketDepth_market_depth, 'buy' | 'sell'>;
|
||||
|
||||
export function updateDepthUpdate(
|
||||
prev: marketDepth,
|
||||
subscriptionData: { data: marketDepthUpdateSubscribe }
|
||||
): marketDepth {
|
||||
if (!subscriptionData.data.marketDepthUpdate || !prev.market) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
market: {
|
||||
...prev.market,
|
||||
...(prev.market.data && {
|
||||
data: {
|
||||
...prev.market.data,
|
||||
midPrice:
|
||||
subscriptionData.data.marketDepthUpdate.market.data?.midPrice ??
|
||||
prev.market.data.midPrice,
|
||||
},
|
||||
}),
|
||||
depth: {
|
||||
...prev.market.depth,
|
||||
...merge(prev.market.depth, subscriptionData.data.marketDepthUpdate),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function merge(snapshot: MarketDepth, update: MarketDepth): MarketDepth {
|
||||
let buy = snapshot.buy ? [...snapshot.buy] : null;
|
||||
let sell = snapshot.sell ? [...snapshot.sell] : null;
|
||||
|
||||
if (buy !== null) {
|
||||
if (update.buy !== null) {
|
||||
for (const priceLevel of update.buy) {
|
||||
const index = buy.findIndex(
|
||||
(level) => level.price === priceLevel.price
|
||||
);
|
||||
|
||||
if (index !== -1) {
|
||||
if (priceLevel.volume !== '0') {
|
||||
buy.splice(index, 1, priceLevel);
|
||||
} else {
|
||||
buy.splice(index, 1);
|
||||
}
|
||||
} else {
|
||||
buy.push(priceLevel);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
buy = update.buy;
|
||||
}
|
||||
|
||||
if (sell !== null) {
|
||||
if (update.sell !== null) {
|
||||
for (const priceLevel of update.sell) {
|
||||
const index = sell.findIndex(
|
||||
(level) => level.price === priceLevel.price
|
||||
);
|
||||
|
||||
if (index !== -1) {
|
||||
if (priceLevel.volume !== '0') {
|
||||
sell.splice(index, 1, priceLevel);
|
||||
} else {
|
||||
sell.splice(index, 1);
|
||||
}
|
||||
} else {
|
||||
sell.push(priceLevel);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sell = update.sell;
|
||||
}
|
||||
|
||||
return {
|
||||
buy: sortBy(buy, (d) => -parseInt(d.price)),
|
||||
sell: sortBy(sell, (d) => parseInt(d.price)),
|
||||
};
|
||||
}
|
@ -1,135 +0,0 @@
|
||||
import type { ApolloError } from '@apollo/client';
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
import throttle from 'lodash/throttle';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { updateDepthUpdate } from '../helpers';
|
||||
import {
|
||||
MARKET_DEPTH_QUERY,
|
||||
MARKET_DEPTH_UPDATE_SUB,
|
||||
} from '../queries/market-depth';
|
||||
import type {
|
||||
marketDepth,
|
||||
marketDepthVariables,
|
||||
} from '../__generated__/marketDepth';
|
||||
import type {
|
||||
marketDepthUpdateSubscribe,
|
||||
marketDepthUpdateSubscribeVariables,
|
||||
} from '../__generated__/marketDepthUpdateSubscribe';
|
||||
|
||||
export interface QueryResult<TData> {
|
||||
data: TData | undefined;
|
||||
loading: boolean;
|
||||
error?: ApolloError;
|
||||
}
|
||||
|
||||
export function useDepthUpdate({ marketId }: marketDepthVariables, wait = 0) {
|
||||
const queryResultRef = useRef<QueryResult<marketDepth>>({
|
||||
data: undefined,
|
||||
loading: true,
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const [queryResult, setQueryResult] = useState<QueryResult<marketDepth>>({
|
||||
data: undefined,
|
||||
loading: true,
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const sequenceNumber = useRef<null | number>(null);
|
||||
const [stallCount, setStallCount] = useState(0);
|
||||
|
||||
const client = useApolloClient();
|
||||
|
||||
const handleUpdate = useMemo(
|
||||
() => throttle(setQueryResult, wait, { leading: true }),
|
||||
[wait]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
const { data, loading, error } = await client.query<
|
||||
marketDepth,
|
||||
marketDepthVariables
|
||||
>({
|
||||
query: MARKET_DEPTH_QUERY,
|
||||
variables: { marketId },
|
||||
fetchPolicy: 'no-cache',
|
||||
});
|
||||
|
||||
if (data.market?.depth.sequenceNumber) {
|
||||
sequenceNumber.current = Number.parseInt(
|
||||
data.market?.depth.sequenceNumber
|
||||
);
|
||||
|
||||
queryResultRef.current = { data, loading, error };
|
||||
handleUpdate({ data, loading, error });
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [client, handleUpdate, marketId, stallCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!marketId) return;
|
||||
|
||||
const result = client.subscribe<
|
||||
marketDepthUpdateSubscribe,
|
||||
marketDepthUpdateSubscribeVariables
|
||||
>({
|
||||
query: MARKET_DEPTH_UPDATE_SUB,
|
||||
variables: { marketId },
|
||||
fetchPolicy: 'no-cache',
|
||||
errorPolicy: 'none',
|
||||
});
|
||||
|
||||
const subscription = result.subscribe((result) => {
|
||||
const prev = queryResultRef.current.data;
|
||||
const subscriptionData = result;
|
||||
|
||||
if (
|
||||
!prev ||
|
||||
!subscriptionData.data ||
|
||||
subscriptionData.data?.marketDepthUpdate?.market?.id !== prev.market?.id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSequenceNumber = Number.parseInt(
|
||||
subscriptionData.data.marketDepthUpdate.sequenceNumber
|
||||
);
|
||||
|
||||
if (
|
||||
prev.market &&
|
||||
subscriptionData.data?.marketDepthUpdate &&
|
||||
sequenceNumber.current !== null &&
|
||||
nextSequenceNumber !== sequenceNumber.current + 1
|
||||
) {
|
||||
console.log(
|
||||
`Refetching: Expected ${
|
||||
sequenceNumber.current + 1
|
||||
} but got ${nextSequenceNumber}`
|
||||
);
|
||||
|
||||
sequenceNumber.current = null;
|
||||
|
||||
// Trigger refetch
|
||||
setStallCount((count) => count + 1);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
sequenceNumber.current = nextSequenceNumber;
|
||||
|
||||
const depth = updateDepthUpdate(prev, { data: subscriptionData.data });
|
||||
|
||||
queryResultRef.current.data = depth;
|
||||
handleUpdate({ data: depth, loading: false });
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription && subscription.unsubscribe();
|
||||
};
|
||||
}, [client, handleUpdate, marketId]);
|
||||
|
||||
return queryResult;
|
||||
}
|
@ -1,53 +0,0 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const MARKET_DEPTH_QUERY = gql`
|
||||
query MarketDepth($marketId: ID!) {
|
||||
market(id: $marketId) {
|
||||
id
|
||||
decimalPlaces
|
||||
data {
|
||||
midPrice
|
||||
}
|
||||
depth {
|
||||
lastTrade {
|
||||
price
|
||||
}
|
||||
sell {
|
||||
price
|
||||
volume
|
||||
numberOfOrders
|
||||
}
|
||||
buy {
|
||||
price
|
||||
volume
|
||||
numberOfOrders
|
||||
}
|
||||
sequenceNumber
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const MARKET_DEPTH_UPDATE_SUB = gql`
|
||||
subscription MarketDepthUpdateSubscribe($marketId: ID!) {
|
||||
marketDepthUpdate(marketId: $marketId) {
|
||||
market {
|
||||
id
|
||||
data {
|
||||
midPrice
|
||||
}
|
||||
}
|
||||
sell {
|
||||
price
|
||||
volume
|
||||
numberOfOrders
|
||||
}
|
||||
buy {
|
||||
price
|
||||
volume
|
||||
numberOfOrders
|
||||
}
|
||||
sequenceNumber
|
||||
}
|
||||
}
|
||||
`;
|
7
libs/market-depth/README.md
Normal file
7
libs/market-depth/README.md
Normal file
@ -0,0 +1,7 @@
|
||||
# orderbook
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `nx test orderbook` to execute the unit tests via [Jest](https://jestjs.io).
|
15
libs/market-depth/jest.config.js
Normal file
15
libs/market-depth/jest.config.js
Normal file
@ -0,0 +1,15 @@
|
||||
module.exports = {
|
||||
displayName: 'orderbook',
|
||||
preset: '../../jest.preset.js',
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
},
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.[tj]sx?$': 'ts-jest',
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
||||
coverageDirectory: '../../coverage/libs/market-depth',
|
||||
setupFilesAfterEnv: ['./src/setup-tests.ts'],
|
||||
};
|
4
libs/market-depth/package.json
Normal file
4
libs/market-depth/package.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "@vegaprotocol/market-depth",
|
||||
"version": "0.0.1"
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"root": "libs/depth-chart",
|
||||
"sourceRoot": "libs/depth-chart/src",
|
||||
"root": "libs/market-depth",
|
||||
"sourceRoot": "libs/market-depth/src",
|
||||
"projectType": "library",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
@ -8,16 +8,16 @@
|
||||
"executor": "@nrwl/web:rollup",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/libs/depth-chart",
|
||||
"tsConfig": "libs/depth-chart/tsconfig.lib.json",
|
||||
"project": "libs/depth-chart/package.json",
|
||||
"entryFile": "libs/depth-chart/src/index.ts",
|
||||
"outputPath": "dist/libs/market-depth",
|
||||
"tsConfig": "libs/market-depth/tsconfig.lib.json",
|
||||
"project": "libs/market-depth/package.json",
|
||||
"entryFile": "libs/market-depth/src/index.ts",
|
||||
"external": ["react/jsx-runtime"],
|
||||
"rollupConfig": "@nrwl/react/plugins/bundle-rollup",
|
||||
"compiler": "babel",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "libs/depth-chart/README.md",
|
||||
"glob": "libs/market-depth/README.md",
|
||||
"input": ".",
|
||||
"output": "."
|
||||
}
|
||||
@ -28,14 +28,14 @@
|
||||
"executor": "@nrwl/linter:eslint",
|
||||
"outputs": ["{options.outputFile}"],
|
||||
"options": {
|
||||
"lintFilePatterns": ["libs/depth-chart/**/*.{ts,tsx,js,jsx}"]
|
||||
"lintFilePatterns": ["libs/market-depth/**/*.{ts,tsx,js,jsx}"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nrwl/jest:jest",
|
||||
"outputs": ["coverage/libs/depth-chart"],
|
||||
"outputs": ["coverage/libs/market-depth"],
|
||||
"options": {
|
||||
"jestConfig": "libs/depth-chart/jest.config.js",
|
||||
"jestConfig": "libs/market-depth/jest.config.js",
|
||||
"passWithNoTests": true
|
||||
}
|
||||
}
|
2
libs/market-depth/src/index.ts
Normal file
2
libs/market-depth/src/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './lib/depth-chart';
|
||||
export * from './lib/orderbook-container';
|
@ -4,18 +4,30 @@
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
// ====================================================
|
||||
// GraphQL query operation: marketDepth
|
||||
// GraphQL query operation: MarketDepth
|
||||
// ====================================================
|
||||
|
||||
export interface marketDepth_market_data {
|
||||
export interface MarketDepth_market_data_market {
|
||||
__typename: "Market";
|
||||
/**
|
||||
* Market ID
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface MarketDepth_market_data {
|
||||
__typename: "MarketData";
|
||||
/**
|
||||
* the arithmetic average of the best bid price and best offer price.
|
||||
*/
|
||||
midPrice: string;
|
||||
/**
|
||||
* market id of the associated mark price
|
||||
*/
|
||||
market: MarketDepth_market_data_market;
|
||||
}
|
||||
|
||||
export interface marketDepth_market_depth_lastTrade {
|
||||
export interface MarketDepth_market_depth_lastTrade {
|
||||
__typename: "Trade";
|
||||
/**
|
||||
* The price of the trade (probably initially the passive order price, other determination algorithms are possible though) (uint64)
|
||||
@ -23,7 +35,7 @@ export interface marketDepth_market_depth_lastTrade {
|
||||
price: string;
|
||||
}
|
||||
|
||||
export interface marketDepth_market_depth_sell {
|
||||
export interface MarketDepth_market_depth_sell {
|
||||
__typename: "PriceLevel";
|
||||
/**
|
||||
* The price of all the orders at this level (uint64)
|
||||
@ -39,7 +51,7 @@ export interface marketDepth_market_depth_sell {
|
||||
numberOfOrders: string;
|
||||
}
|
||||
|
||||
export interface marketDepth_market_depth_buy {
|
||||
export interface MarketDepth_market_depth_buy {
|
||||
__typename: "PriceLevel";
|
||||
/**
|
||||
* The price of all the orders at this level (uint64)
|
||||
@ -55,27 +67,27 @@ export interface marketDepth_market_depth_buy {
|
||||
numberOfOrders: string;
|
||||
}
|
||||
|
||||
export interface marketDepth_market_depth {
|
||||
export interface MarketDepth_market_depth {
|
||||
__typename: "MarketDepth";
|
||||
/**
|
||||
* Last trade for the given market (if available)
|
||||
*/
|
||||
lastTrade: marketDepth_market_depth_lastTrade | null;
|
||||
lastTrade: MarketDepth_market_depth_lastTrade | null;
|
||||
/**
|
||||
* Sell side price levels (if available)
|
||||
*/
|
||||
sell: marketDepth_market_depth_sell[] | null;
|
||||
sell: MarketDepth_market_depth_sell[] | null;
|
||||
/**
|
||||
* Buy side price levels (if available)
|
||||
*/
|
||||
buy: marketDepth_market_depth_buy[] | null;
|
||||
buy: MarketDepth_market_depth_buy[] | null;
|
||||
/**
|
||||
* Sequence number for the current snapshot of the market depth
|
||||
*/
|
||||
sequenceNumber: string;
|
||||
}
|
||||
|
||||
export interface marketDepth_market {
|
||||
export interface MarketDepth_market {
|
||||
__typename: "Market";
|
||||
/**
|
||||
* Market ID
|
||||
@ -101,20 +113,20 @@ export interface marketDepth_market {
|
||||
/**
|
||||
* marketData for the given market
|
||||
*/
|
||||
data: marketDepth_market_data | null;
|
||||
data: MarketDepth_market_data | null;
|
||||
/**
|
||||
* Current depth on the order book for this market
|
||||
*/
|
||||
depth: marketDepth_market_depth;
|
||||
depth: MarketDepth_market_depth;
|
||||
}
|
||||
|
||||
export interface marketDepth {
|
||||
export interface MarketDepth {
|
||||
/**
|
||||
* An instrument that is trading on the VEGA network
|
||||
*/
|
||||
market: marketDepth_market | null;
|
||||
market: MarketDepth_market | null;
|
||||
}
|
||||
|
||||
export interface marketDepthVariables {
|
||||
export interface MarketDepthVariables {
|
||||
marketId: string;
|
||||
}
|
@ -4,18 +4,30 @@
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
// ====================================================
|
||||
// GraphQL subscription operation: marketDepthUpdateSubscribe
|
||||
// GraphQL subscription operation: MarketDepthSubscription
|
||||
// ====================================================
|
||||
|
||||
export interface marketDepthUpdateSubscribe_marketDepthUpdate_market_data {
|
||||
export interface MarketDepthSubscription_marketDepthUpdate_market_data_market {
|
||||
__typename: "Market";
|
||||
/**
|
||||
* Market ID
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface MarketDepthSubscription_marketDepthUpdate_market_data {
|
||||
__typename: "MarketData";
|
||||
/**
|
||||
* the arithmetic average of the best bid price and best offer price.
|
||||
*/
|
||||
midPrice: string;
|
||||
/**
|
||||
* market id of the associated mark price
|
||||
*/
|
||||
market: MarketDepthSubscription_marketDepthUpdate_market_data_market;
|
||||
}
|
||||
|
||||
export interface marketDepthUpdateSubscribe_marketDepthUpdate_market {
|
||||
export interface MarketDepthSubscription_marketDepthUpdate_market {
|
||||
__typename: "Market";
|
||||
/**
|
||||
* Market ID
|
||||
@ -24,10 +36,10 @@ export interface marketDepthUpdateSubscribe_marketDepthUpdate_market {
|
||||
/**
|
||||
* marketData for the given market
|
||||
*/
|
||||
data: marketDepthUpdateSubscribe_marketDepthUpdate_market_data | null;
|
||||
data: MarketDepthSubscription_marketDepthUpdate_market_data | null;
|
||||
}
|
||||
|
||||
export interface marketDepthUpdateSubscribe_marketDepthUpdate_sell {
|
||||
export interface MarketDepthSubscription_marketDepthUpdate_sell {
|
||||
__typename: "PriceLevel";
|
||||
/**
|
||||
* The price of all the orders at this level (uint64)
|
||||
@ -43,7 +55,7 @@ export interface marketDepthUpdateSubscribe_marketDepthUpdate_sell {
|
||||
numberOfOrders: string;
|
||||
}
|
||||
|
||||
export interface marketDepthUpdateSubscribe_marketDepthUpdate_buy {
|
||||
export interface MarketDepthSubscription_marketDepthUpdate_buy {
|
||||
__typename: "PriceLevel";
|
||||
/**
|
||||
* The price of all the orders at this level (uint64)
|
||||
@ -59,33 +71,33 @@ export interface marketDepthUpdateSubscribe_marketDepthUpdate_buy {
|
||||
numberOfOrders: string;
|
||||
}
|
||||
|
||||
export interface marketDepthUpdateSubscribe_marketDepthUpdate {
|
||||
export interface MarketDepthSubscription_marketDepthUpdate {
|
||||
__typename: "MarketDepthUpdate";
|
||||
/**
|
||||
* Market id
|
||||
*/
|
||||
market: marketDepthUpdateSubscribe_marketDepthUpdate_market;
|
||||
market: MarketDepthSubscription_marketDepthUpdate_market;
|
||||
/**
|
||||
* Sell side price levels (if available)
|
||||
*/
|
||||
sell: marketDepthUpdateSubscribe_marketDepthUpdate_sell[] | null;
|
||||
sell: MarketDepthSubscription_marketDepthUpdate_sell[] | null;
|
||||
/**
|
||||
* Buy side price levels (if available)
|
||||
*/
|
||||
buy: marketDepthUpdateSubscribe_marketDepthUpdate_buy[] | null;
|
||||
buy: MarketDepthSubscription_marketDepthUpdate_buy[] | null;
|
||||
/**
|
||||
* Sequence number for the current snapshot of the market depth
|
||||
*/
|
||||
sequenceNumber: string;
|
||||
}
|
||||
|
||||
export interface marketDepthUpdateSubscribe {
|
||||
export interface MarketDepthSubscription {
|
||||
/**
|
||||
* Subscribe to price level market depth updates
|
||||
*/
|
||||
marketDepthUpdate: marketDepthUpdateSubscribe_marketDepthUpdate;
|
||||
marketDepthUpdate: MarketDepthSubscription_marketDepthUpdate;
|
||||
}
|
||||
|
||||
export interface marketDepthUpdateSubscribeVariables {
|
||||
export interface MarketDepthSubscriptionVariables {
|
||||
marketId: string;
|
||||
}
|
159
libs/market-depth/src/lib/depth-chart.tsx
Normal file
159
libs/market-depth/src/lib/depth-chart.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
import { DepthChart } from 'pennant';
|
||||
import { produce } from 'immer';
|
||||
import throttle from 'lodash/throttle';
|
||||
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
|
||||
import {
|
||||
useDataProvider,
|
||||
addDecimal,
|
||||
ThemeContext,
|
||||
} from '@vegaprotocol/react-helpers';
|
||||
import { marketDepthDataProvider } from './market-depth-data-provider';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useContext,
|
||||
} from 'react';
|
||||
import type {
|
||||
MarketDepthSubscription_marketDepthUpdate_buy,
|
||||
MarketDepthSubscription_marketDepthUpdate_sell,
|
||||
MarketDepthSubscription_marketDepthUpdate,
|
||||
} from './__generated__/MarketDepthSubscription';
|
||||
import type {
|
||||
MarketDepth_market_depth_buy,
|
||||
MarketDepth_market_depth_sell,
|
||||
} from './__generated__/MarketDepth';
|
||||
import type { DepthChartProps } from 'pennant';
|
||||
|
||||
interface DepthChartManagerProps {
|
||||
marketId: string;
|
||||
}
|
||||
|
||||
interface PriceLevel {
|
||||
price: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
const formatLevel = (
|
||||
priceLevel: MarketDepth_market_depth_buy | MarketDepth_market_depth_sell,
|
||||
decimalPlaces: number
|
||||
): PriceLevel => ({
|
||||
price: Number(addDecimal(priceLevel.price, decimalPlaces)),
|
||||
volume: Number(priceLevel.volume),
|
||||
});
|
||||
|
||||
const updateLevels = (
|
||||
levels: PriceLevel[],
|
||||
updates: (
|
||||
| MarketDepthSubscription_marketDepthUpdate_buy
|
||||
| MarketDepthSubscription_marketDepthUpdate_sell
|
||||
)[],
|
||||
decimalPlaces: number
|
||||
) => {
|
||||
updates.forEach((update) => {
|
||||
const updateLevel = formatLevel(update, decimalPlaces);
|
||||
let index = levels.findIndex((level) => level.price === updateLevel.price);
|
||||
if (index !== -1) {
|
||||
if (update.volume === '0') {
|
||||
levels.splice(index, 1);
|
||||
} else {
|
||||
Object.assign(levels[index], updateLevel);
|
||||
}
|
||||
} else if (update.volume !== '0') {
|
||||
index = levels.findIndex((level) => level.price > updateLevel.price);
|
||||
if (index !== -1) {
|
||||
levels.splice(index, 0, updateLevel);
|
||||
} else {
|
||||
levels.push(updateLevel);
|
||||
}
|
||||
}
|
||||
});
|
||||
return levels;
|
||||
};
|
||||
|
||||
const formatMidPrice = (midPrice: string, decimalPlaces: number) =>
|
||||
Number(addDecimal(midPrice, decimalPlaces));
|
||||
|
||||
type DepthData = Pick<DepthChartProps, 'data' | 'midPrice'>;
|
||||
|
||||
export const DepthChartContainer = ({ marketId }: DepthChartManagerProps) => {
|
||||
const theme = useContext(ThemeContext);
|
||||
const variables = useMemo(() => ({ marketId }), [marketId]);
|
||||
const [depthData, setDepthData] = useState<DepthData | null>(null);
|
||||
const decimalPlacesRef = useRef<number>(0);
|
||||
const dataRef = useRef<DepthData | null>(null);
|
||||
const setDepthDataThrottledRef = useRef(throttle(setDepthData, 1000));
|
||||
|
||||
// Apply updates to the table
|
||||
const update = useCallback(
|
||||
(delta: MarketDepthSubscription_marketDepthUpdate) => {
|
||||
if (!dataRef.current) {
|
||||
return false;
|
||||
}
|
||||
dataRef.current = produce(dataRef.current, (draft) => {
|
||||
if (delta.buy) {
|
||||
draft.data.buy = updateLevels(
|
||||
draft.data.buy,
|
||||
delta.buy,
|
||||
decimalPlacesRef.current
|
||||
);
|
||||
}
|
||||
if (delta.sell) {
|
||||
draft.data.sell = updateLevels(
|
||||
draft.data.sell,
|
||||
delta.sell,
|
||||
decimalPlacesRef.current
|
||||
);
|
||||
}
|
||||
draft.midPrice = delta.market.data?.midPrice
|
||||
? formatMidPrice(
|
||||
delta.market.data?.midPrice,
|
||||
decimalPlacesRef.current
|
||||
)
|
||||
: undefined;
|
||||
});
|
||||
setDepthDataThrottledRef.current(dataRef.current);
|
||||
return true;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const { data, error, loading } = useDataProvider(
|
||||
marketDepthDataProvider,
|
||||
update,
|
||||
variables
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
dataRef.current = null;
|
||||
setDepthData(dataRef.current);
|
||||
return;
|
||||
}
|
||||
dataRef.current = {
|
||||
midPrice: data.data?.midPrice
|
||||
? formatMidPrice(data.data?.midPrice, data.decimalPlaces)
|
||||
: undefined,
|
||||
data: {
|
||||
buy:
|
||||
data.depth.buy?.map((priceLevel) =>
|
||||
formatLevel(priceLevel, data.decimalPlaces)
|
||||
) ?? [],
|
||||
sell:
|
||||
data.depth.sell?.map((priceLevel) =>
|
||||
formatLevel(priceLevel, data.decimalPlaces)
|
||||
) ?? [],
|
||||
},
|
||||
};
|
||||
setDepthData(dataRef.current);
|
||||
decimalPlacesRef.current = data.decimalPlaces;
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<AsyncRenderer loading={loading} error={error} data={data}>
|
||||
{depthData && <DepthChart {...depthData} theme={theme} />}
|
||||
</AsyncRenderer>
|
||||
);
|
||||
};
|
116
libs/market-depth/src/lib/market-depth-data-provider.ts
Normal file
116
libs/market-depth/src/lib/market-depth-data-provider.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { gql } from '@apollo/client';
|
||||
import { makeDataProvider } from '@vegaprotocol/react-helpers';
|
||||
import { updateLevels } from './orderbook-data';
|
||||
import type { Update } from '@vegaprotocol/react-helpers';
|
||||
import type {
|
||||
MarketDepth,
|
||||
MarketDepth_market,
|
||||
} from './__generated__/MarketDepth';
|
||||
import type {
|
||||
MarketDepthSubscription,
|
||||
MarketDepthSubscription_marketDepthUpdate,
|
||||
} from './__generated__/MarketDepthSubscription';
|
||||
|
||||
const MARKET_DEPTH_QUERY = gql`
|
||||
query MarketDepth($marketId: ID!) {
|
||||
market(id: $marketId) {
|
||||
id
|
||||
decimalPlaces
|
||||
data {
|
||||
midPrice
|
||||
market {
|
||||
id
|
||||
}
|
||||
}
|
||||
depth {
|
||||
lastTrade {
|
||||
price
|
||||
}
|
||||
sell {
|
||||
price
|
||||
volume
|
||||
numberOfOrders
|
||||
}
|
||||
buy {
|
||||
price
|
||||
volume
|
||||
numberOfOrders
|
||||
}
|
||||
sequenceNumber
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const MARKET_DEPTH_SUBSCRIPTION_QUERY = gql`
|
||||
subscription MarketDepthSubscription($marketId: ID!) {
|
||||
marketDepthUpdate(marketId: $marketId) {
|
||||
market {
|
||||
id
|
||||
data {
|
||||
midPrice
|
||||
market {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
sell {
|
||||
price
|
||||
volume
|
||||
numberOfOrders
|
||||
}
|
||||
buy {
|
||||
price
|
||||
volume
|
||||
numberOfOrders
|
||||
}
|
||||
sequenceNumber
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const sequenceNumbers: Record<string, number> = {};
|
||||
|
||||
const update: Update<
|
||||
MarketDepth_market,
|
||||
MarketDepthSubscription_marketDepthUpdate
|
||||
> = (draft, delta, restart) => {
|
||||
if (delta.market.id !== draft.id) {
|
||||
return;
|
||||
}
|
||||
const sequenceNumber = Number(delta.sequenceNumber);
|
||||
if (sequenceNumber <= sequenceNumbers[delta.market.id]) {
|
||||
return;
|
||||
}
|
||||
if (sequenceNumber - 1 !== sequenceNumbers[delta.market.id]) {
|
||||
sequenceNumbers[delta.market.id] = 0;
|
||||
restart(true);
|
||||
return;
|
||||
}
|
||||
sequenceNumbers[delta.market.id] = sequenceNumber;
|
||||
if (delta.buy) {
|
||||
draft.depth.buy = updateLevels(draft.depth.buy ?? [], delta.buy);
|
||||
}
|
||||
if (delta.sell) {
|
||||
draft.depth.sell = updateLevels(draft.depth.sell ?? [], delta.sell);
|
||||
}
|
||||
};
|
||||
|
||||
const getData = (responseData: MarketDepth) => {
|
||||
if (responseData.market?.id) {
|
||||
sequenceNumbers[responseData.market.id] = Number(
|
||||
responseData.market.depth.sequenceNumber
|
||||
);
|
||||
}
|
||||
return responseData.market;
|
||||
};
|
||||
const getDelta = (subscriptionData: MarketDepthSubscription) =>
|
||||
subscriptionData.marketDepthUpdate;
|
||||
|
||||
export const marketDepthDataProvider = makeDataProvider(
|
||||
MARKET_DEPTH_QUERY,
|
||||
MARKET_DEPTH_SUBSCRIPTION_QUERY,
|
||||
update,
|
||||
getData,
|
||||
getDelta
|
||||
);
|
57
libs/market-depth/src/lib/market-depth-mock.ts
Normal file
57
libs/market-depth/src/lib/market-depth-mock.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import type {
|
||||
MarketDepth_market,
|
||||
MarketDepth_market_depth_sell,
|
||||
MarketDepth_market_depth_buy,
|
||||
} from './__generated__/MarketDepth';
|
||||
|
||||
const depthRow = (
|
||||
price: number
|
||||
): MarketDepth_market_depth_sell | MarketDepth_market_depth_buy => {
|
||||
return {
|
||||
__typename: 'PriceLevel',
|
||||
price: price.toString(),
|
||||
volume: Math.round(Math.random() * 100).toString(),
|
||||
numberOfOrders: Math.round(Math.random() * 20).toString(),
|
||||
};
|
||||
};
|
||||
|
||||
const sell = (
|
||||
price: number,
|
||||
numberOfRecords: number
|
||||
): MarketDepth_market_depth_sell[] => {
|
||||
const distance = Math.random() * price * 0.1;
|
||||
return new Array(numberOfRecords)
|
||||
.fill(null)
|
||||
.map(() => depthRow(price + Math.round(Math.random() * distance)));
|
||||
};
|
||||
|
||||
const buy = (
|
||||
price: number,
|
||||
numberOfRecords: number
|
||||
): MarketDepth_market_depth_buy[] => {
|
||||
const distance = Math.random() * price * 0.1;
|
||||
return new Array(numberOfRecords)
|
||||
.fill(null)
|
||||
.map(() => depthRow(price - Math.round(Math.random() * distance)));
|
||||
};
|
||||
|
||||
export const getMockedData = (id?: string): MarketDepth_market => ({
|
||||
__typename: 'Market',
|
||||
id: id || '',
|
||||
decimalPlaces: 2,
|
||||
// "positionDecimalPlaces": 0,
|
||||
data: {
|
||||
__typename: 'MarketData',
|
||||
midPrice: '0',
|
||||
},
|
||||
depth: {
|
||||
__typename: 'MarketDepth',
|
||||
lastTrade: {
|
||||
__typename: 'Trade',
|
||||
price: '12350',
|
||||
},
|
||||
sell: sell(12350 * 0.99, 100),
|
||||
buy: buy(12350, 100),
|
||||
sequenceNumber: '118118448',
|
||||
},
|
||||
});
|
31
libs/market-depth/src/lib/orderbook-container.tsx
Normal file
31
libs/market-depth/src/lib/orderbook-container.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { useState } from 'react';
|
||||
import { OrderbookManager } from './orderbook-manager';
|
||||
import { Button } from '@vegaprotocol/ui-toolkit';
|
||||
|
||||
export const OrderbookContainer = ({ marketId }: { marketId: string }) => {
|
||||
const [resolution, setResolution] = useState(1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-8">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setResolution(resolution * 10)}
|
||||
appendIconName="minus"
|
||||
className="flex-1"
|
||||
>
|
||||
Zoom out
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setResolution(Math.max(resolution / 10, 1))}
|
||||
appendIconName="plus"
|
||||
className="flex-1"
|
||||
>
|
||||
Zoom in
|
||||
</Button>
|
||||
</div>
|
||||
<OrderbookManager resolution={resolution} marketId={marketId} />
|
||||
</>
|
||||
);
|
||||
};
|
302
libs/market-depth/src/lib/orderbook-data.spec.ts
Normal file
302
libs/market-depth/src/lib/orderbook-data.spec.ts
Normal file
@ -0,0 +1,302 @@
|
||||
import {
|
||||
compactData,
|
||||
updateLevels,
|
||||
updateCompactedData,
|
||||
} from './orderbook-data';
|
||||
import type { OrderbookData } from './orderbook-data';
|
||||
import type { MarketDepth_market_depth_sell } from './__generated__/MarketDepth';
|
||||
import type {
|
||||
MarketDepthSubscription_marketDepthUpdate_sell,
|
||||
MarketDepthSubscription_marketDepthUpdate_buy,
|
||||
} from './__generated__/MarketDepthSubscription';
|
||||
|
||||
describe('compactData', () => {
|
||||
const numberOfRows = 100;
|
||||
const middle = 1000;
|
||||
const sell: MarketDepth_market_depth_sell[] = new Array(numberOfRows)
|
||||
.fill(null)
|
||||
.map((n, i) => ({
|
||||
__typename: 'PriceLevel',
|
||||
volume: i.toString(),
|
||||
price: (middle + numberOfRows - i).toString(),
|
||||
numberOfOrders: i.toString(),
|
||||
}));
|
||||
const buy: MarketDepth_market_depth_sell[] = new Array(numberOfRows)
|
||||
.fill(null)
|
||||
.map((n, i) => ({
|
||||
__typename: 'PriceLevel',
|
||||
volume: (numberOfRows - 1 - i).toString(),
|
||||
price: (middle - i).toString(),
|
||||
numberOfOrders: (numberOfRows - i).toString(),
|
||||
}));
|
||||
it('groups data by price and resolution', () => {
|
||||
expect(compactData(sell, buy, 1).length).toEqual(200);
|
||||
expect(compactData(sell, buy, 5).length).toEqual(41);
|
||||
expect(compactData(sell, buy, 10).length).toEqual(21);
|
||||
});
|
||||
it('counts cumulative vol', () => {
|
||||
const orderbookData = compactData(sell, buy, 10);
|
||||
expect(orderbookData[0].cumulativeVol.ask).toEqual(4950);
|
||||
expect(orderbookData[0].cumulativeVol.bid).toEqual(0);
|
||||
expect(orderbookData[10].cumulativeVol.ask).toEqual(390);
|
||||
expect(orderbookData[10].cumulativeVol.bid).toEqual(579);
|
||||
expect(orderbookData[orderbookData.length - 1].cumulativeVol.bid).toEqual(
|
||||
4950
|
||||
);
|
||||
expect(orderbookData[orderbookData.length - 1].cumulativeVol.ask).toEqual(
|
||||
0
|
||||
);
|
||||
});
|
||||
it('stores volume by level', () => {
|
||||
const orderbookData = compactData(sell, buy, 10);
|
||||
expect(orderbookData[0].askByLevel).toEqual({
|
||||
'1095': 5,
|
||||
'1096': 4,
|
||||
'1097': 3,
|
||||
'1098': 2,
|
||||
'1099': 1,
|
||||
'1100': 0,
|
||||
});
|
||||
expect(orderbookData[orderbookData.length - 1].bidByLevel).toEqual({
|
||||
'901': 0,
|
||||
'902': 1,
|
||||
'903': 2,
|
||||
'904': 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('updates relative data', () => {
|
||||
const orderbookData = compactData(sell, buy, 10);
|
||||
expect(orderbookData[0].cumulativeVol.relativeAsk).toEqual(100);
|
||||
expect(orderbookData[0].cumulativeVol.relativeBid).toEqual(0);
|
||||
expect(orderbookData[0].relativeAskVol).toEqual(2);
|
||||
expect(orderbookData[0].relativeBidVol).toEqual(0);
|
||||
expect(orderbookData[10].cumulativeVol.relativeAsk).toEqual(8);
|
||||
expect(orderbookData[10].cumulativeVol.relativeBid).toEqual(12);
|
||||
expect(orderbookData[10].relativeAskVol).toEqual(44);
|
||||
expect(orderbookData[10].relativeBidVol).toEqual(66);
|
||||
expect(orderbookData[orderbookData.length - 1].relativeAskVol).toEqual(0);
|
||||
expect(orderbookData[orderbookData.length - 1].relativeBidVol).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateLevels', () => {
|
||||
const levels: MarketDepth_market_depth_sell[] = new Array(10)
|
||||
.fill(null)
|
||||
.map((n, i) => ({
|
||||
__typename: 'PriceLevel',
|
||||
volume: ((i + 1) * 10).toString(),
|
||||
price: ((i + 1) * 10).toString(),
|
||||
numberOfOrders: ((i + 1) * 10).toString(),
|
||||
}));
|
||||
it('updates, removes and adds new items', () => {
|
||||
const removeFirstRow: MarketDepthSubscription_marketDepthUpdate_sell = {
|
||||
__typename: 'PriceLevel',
|
||||
price: '10',
|
||||
volume: '0',
|
||||
numberOfOrders: '0',
|
||||
};
|
||||
updateLevels(levels, [removeFirstRow]);
|
||||
expect(levels[0].price).toEqual('20');
|
||||
updateLevels(levels, [removeFirstRow]);
|
||||
expect(levels[0].price).toEqual('20');
|
||||
expect(updateLevels([], [removeFirstRow])).toEqual([]);
|
||||
const addFirstRow: MarketDepthSubscription_marketDepthUpdate_sell = {
|
||||
__typename: 'PriceLevel',
|
||||
price: '10',
|
||||
volume: '10',
|
||||
numberOfOrders: '10',
|
||||
};
|
||||
updateLevels(levels, [addFirstRow]);
|
||||
expect(levels[0].price).toEqual('10');
|
||||
const addBeforeLastRow: MarketDepthSubscription_marketDepthUpdate_sell = {
|
||||
__typename: 'PriceLevel',
|
||||
price: '95',
|
||||
volume: '95',
|
||||
numberOfOrders: '95',
|
||||
};
|
||||
updateLevels(levels, [addBeforeLastRow]);
|
||||
expect(levels[levels.length - 2].price).toEqual('95');
|
||||
const addAtTheEnd: MarketDepthSubscription_marketDepthUpdate_sell = {
|
||||
__typename: 'PriceLevel',
|
||||
price: '115',
|
||||
volume: '115',
|
||||
numberOfOrders: '115',
|
||||
};
|
||||
updateLevels(levels, [addAtTheEnd]);
|
||||
expect(levels[levels.length - 1].price).toEqual('115');
|
||||
const updateLastRow: MarketDepthSubscription_marketDepthUpdate_sell = {
|
||||
__typename: 'PriceLevel',
|
||||
price: '115',
|
||||
volume: '116',
|
||||
numberOfOrders: '115',
|
||||
};
|
||||
updateLevels(levels, [updateLastRow]);
|
||||
expect(levels[levels.length - 1]).toEqual(updateLastRow);
|
||||
expect(updateLevels([], [updateLastRow])).toEqual([updateLastRow]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCompactedData', () => {
|
||||
const orderbookData: OrderbookData[] = [
|
||||
{
|
||||
price: '120',
|
||||
cumulativeVol: {
|
||||
ask: 50,
|
||||
relativeAsk: 100,
|
||||
bid: 0,
|
||||
relativeBid: 0,
|
||||
},
|
||||
askByLevel: {
|
||||
'121': 10,
|
||||
},
|
||||
bidByLevel: {},
|
||||
ask: 10,
|
||||
bid: 0,
|
||||
relativeAskVol: 25,
|
||||
relativeBidVol: 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,
|
||||
relativeAskVol: 100,
|
||||
relativeBidVol: 100,
|
||||
},
|
||||
{
|
||||
price: '80',
|
||||
cumulativeVol: {
|
||||
ask: 0,
|
||||
relativeAsk: 0,
|
||||
bid: 50,
|
||||
relativeBid: 100,
|
||||
},
|
||||
askByLevel: {},
|
||||
bidByLevel: {
|
||||
'79': 10,
|
||||
},
|
||||
ask: 0,
|
||||
bid: 10,
|
||||
relativeAskVol: 0,
|
||||
relativeBidVol: 25,
|
||||
},
|
||||
];
|
||||
const resolution = 10;
|
||||
|
||||
it('update volume', () => {
|
||||
const sell: MarketDepthSubscription_marketDepthUpdate_sell = {
|
||||
__typename: 'PriceLevel',
|
||||
price: '120',
|
||||
volume: '10',
|
||||
numberOfOrders: '10',
|
||||
};
|
||||
const buy: MarketDepthSubscription_marketDepthUpdate_buy = {
|
||||
__typename: 'PriceLevel',
|
||||
price: '80',
|
||||
volume: '10',
|
||||
numberOfOrders: '10',
|
||||
};
|
||||
const updatedData = updateCompactedData(
|
||||
orderbookData,
|
||||
[sell],
|
||||
[buy],
|
||||
resolution
|
||||
);
|
||||
expect(updatedData[0].ask).toEqual(20);
|
||||
expect(updatedData[0].askByLevel?.[120]).toEqual(10);
|
||||
expect(updatedData[0].cumulativeVol.ask).toEqual(60);
|
||||
expect(updatedData[2].bid).toEqual(20);
|
||||
expect(updatedData[2].bidByLevel?.[80]).toEqual(10);
|
||||
expect(updatedData[2].cumulativeVol.bid).toEqual(60);
|
||||
});
|
||||
|
||||
it('remove row', () => {
|
||||
const sell: MarketDepthSubscription_marketDepthUpdate_sell = {
|
||||
__typename: 'PriceLevel',
|
||||
price: '121',
|
||||
volume: '0',
|
||||
numberOfOrders: '0',
|
||||
};
|
||||
const buy: MarketDepthSubscription_marketDepthUpdate_buy = {
|
||||
__typename: 'PriceLevel',
|
||||
price: '79',
|
||||
volume: '0',
|
||||
numberOfOrders: '0',
|
||||
};
|
||||
const updatedData = updateCompactedData(
|
||||
orderbookData,
|
||||
[sell],
|
||||
[buy],
|
||||
resolution
|
||||
);
|
||||
expect(updatedData.length).toEqual(1);
|
||||
});
|
||||
|
||||
it('add new row at the end', () => {
|
||||
const sell: MarketDepthSubscription_marketDepthUpdate_sell = {
|
||||
__typename: 'PriceLevel',
|
||||
price: '131',
|
||||
volume: '5',
|
||||
numberOfOrders: '5',
|
||||
};
|
||||
const buy: MarketDepthSubscription_marketDepthUpdate_buy = {
|
||||
__typename: 'PriceLevel',
|
||||
price: '59',
|
||||
volume: '5',
|
||||
numberOfOrders: '5',
|
||||
};
|
||||
const updatedData = updateCompactedData(
|
||||
orderbookData,
|
||||
[sell],
|
||||
[buy],
|
||||
resolution
|
||||
);
|
||||
expect(updatedData.length).toEqual(5);
|
||||
expect(updatedData[0].price).toEqual('130');
|
||||
expect(updatedData[0].cumulativeVol.ask).toEqual(55);
|
||||
expect(updatedData[4].price).toEqual('60');
|
||||
expect(updatedData[4].cumulativeVol.bid).toEqual(55);
|
||||
});
|
||||
|
||||
it('add new row in the middle', () => {
|
||||
const sell: MarketDepthSubscription_marketDepthUpdate_sell = {
|
||||
__typename: 'PriceLevel',
|
||||
price: '111',
|
||||
volume: '5',
|
||||
numberOfOrders: '5',
|
||||
};
|
||||
const buy: MarketDepthSubscription_marketDepthUpdate_buy = {
|
||||
__typename: 'PriceLevel',
|
||||
price: '91',
|
||||
volume: '5',
|
||||
numberOfOrders: '5',
|
||||
};
|
||||
const updatedData = updateCompactedData(
|
||||
orderbookData,
|
||||
[sell],
|
||||
[buy],
|
||||
resolution
|
||||
);
|
||||
expect(updatedData.length).toEqual(5);
|
||||
expect(updatedData[1].price).toEqual('110');
|
||||
expect(updatedData[1].cumulativeVol.ask).toEqual(45);
|
||||
expect(updatedData[0].cumulativeVol.ask).toEqual(55);
|
||||
expect(updatedData[3].price).toEqual('90');
|
||||
expect(updatedData[3].cumulativeVol.bid).toEqual(45);
|
||||
expect(updatedData[4].cumulativeVol.bid).toEqual(55);
|
||||
});
|
||||
});
|
319
libs/market-depth/src/lib/orderbook-data.ts
Normal file
319
libs/market-depth/src/lib/orderbook-data.ts
Normal file
@ -0,0 +1,319 @@
|
||||
import produce from 'immer';
|
||||
import groupBy from 'lodash/groupBy';
|
||||
import type {
|
||||
MarketDepth_market_depth_sell,
|
||||
MarketDepth_market_depth_buy,
|
||||
} from './__generated__/MarketDepth';
|
||||
import type {
|
||||
MarketDepthSubscription_marketDepthUpdate_sell,
|
||||
MarketDepthSubscription_marketDepthUpdate_buy,
|
||||
} from './__generated__/MarketDepthSubscription';
|
||||
|
||||
export interface CumulativeVol {
|
||||
bid: number;
|
||||
relativeBid?: number;
|
||||
ask: number;
|
||||
relativeAsk?: number;
|
||||
}
|
||||
|
||||
export interface OrderbookData {
|
||||
price: string;
|
||||
bid: number;
|
||||
bidByLevel: Record<string, number>;
|
||||
relativeBidVol?: number;
|
||||
ask: number;
|
||||
askByLevel: Record<string, number>;
|
||||
relativeAskVol?: number;
|
||||
cumulativeVol: CumulativeVol;
|
||||
}
|
||||
|
||||
const getGroupPrice = (price: string, resolution: number) => {
|
||||
const p = BigInt(price);
|
||||
const r = BigInt(resolution);
|
||||
let groupPrice = (p / r) * r;
|
||||
if (p - groupPrice >= resolution / 2) {
|
||||
groupPrice += BigInt(resolution);
|
||||
}
|
||||
return groupPrice.toString();
|
||||
};
|
||||
|
||||
const getMaxVolumes = (orderbookData: OrderbookData[]) => ({
|
||||
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
|
||||
),
|
||||
});
|
||||
|
||||
// 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 relativeAskVol, relativeBidVol, cumulativeVol.relativeAsk, cumulativeVol.relativeBid
|
||||
*/
|
||||
const updateRelativeData = (data: OrderbookData[]) => {
|
||||
const { bid, ask, cumulativeVol } = getMaxVolumes(data);
|
||||
data.forEach((data) => {
|
||||
data.relativeAskVol = toPercentValue(data.ask / ask);
|
||||
data.relativeBidVol = toPercentValue(data.bid / bid);
|
||||
data.cumulativeVol.relativeAsk = toPercentValue(
|
||||
data.cumulativeVol.ask / cumulativeVol
|
||||
);
|
||||
data.cumulativeVol.relativeBid = toPercentValue(
|
||||
data.cumulativeVol.bid / cumulativeVol
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const createData = (
|
||||
price: string,
|
||||
volume = 0,
|
||||
dataType?: 'sell' | 'buy'
|
||||
): OrderbookData => ({
|
||||
price,
|
||||
ask: dataType === 'sell' ? volume : 0,
|
||||
bid: dataType === 'buy' ? volume : 0,
|
||||
cumulativeVol: {
|
||||
ask: dataType === 'sell' ? volume : 0,
|
||||
bid: dataType === 'buy' ? volume : 0,
|
||||
},
|
||||
askByLevel: dataType === 'sell' ? { [price]: volume } : {},
|
||||
bidByLevel: dataType === 'buy' ? { [price]: volume } : {},
|
||||
});
|
||||
|
||||
const mapRawData =
|
||||
(dataType: 'sell' | 'buy') =>
|
||||
(
|
||||
data:
|
||||
| MarketDepth_market_depth_sell
|
||||
| MarketDepthSubscription_marketDepthUpdate_sell
|
||||
| MarketDepth_market_depth_buy
|
||||
| MarketDepthSubscription_marketDepthUpdate_buy
|
||||
): OrderbookData =>
|
||||
createData(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 compactData = (
|
||||
sell:
|
||||
| (
|
||||
| MarketDepth_market_depth_sell
|
||||
| MarketDepthSubscription_marketDepthUpdate_sell
|
||||
)[]
|
||||
| null,
|
||||
buy:
|
||||
| (
|
||||
| MarketDepth_market_depth_buy
|
||||
| MarketDepthSubscription_marketDepthUpdate_buy
|
||||
)[]
|
||||
| null,
|
||||
resolution: number
|
||||
) => {
|
||||
// map raw sell data to OrderbookData
|
||||
const askOrderbookData = [...(sell ?? [])].map<OrderbookData>(
|
||||
mapRawData('sell')
|
||||
);
|
||||
// map raw buy data to OrderbookData
|
||||
const bidOrderbookData = [...(buy ?? [])].map<OrderbookData>(
|
||||
mapRawData('buy')
|
||||
);
|
||||
|
||||
// group by price level
|
||||
const groupedByLevel = groupBy<OrderbookData>(
|
||||
[...askOrderbookData, ...bidOrderbookData],
|
||||
(row) => getGroupPrice(row.price, resolution)
|
||||
);
|
||||
|
||||
// create single OrderbookData from grouped OrderbookData[], sum volumes and atore volume by level
|
||||
const orderbookData = Object.keys(groupedByLevel).reduce<OrderbookData[]>(
|
||||
(rows, price) =>
|
||||
rows.concat(
|
||||
groupedByLevel[price].reduce<OrderbookData>(
|
||||
(a, c) => ({
|
||||
...a,
|
||||
ask: a.ask + c.ask,
|
||||
askByLevel: Object.assign(a.askByLevel, c.askByLevel),
|
||||
bid: (a.bid ?? 0) + (c.bid ?? 0),
|
||||
bidByLevel: Object.assign(a.bidByLevel, c.bidByLevel),
|
||||
}),
|
||||
createData(price)
|
||||
)
|
||||
),
|
||||
[]
|
||||
);
|
||||
// order by price, it's safe to cast to number price diff sould not exceed Number.MAX_SAFE_INTEGER
|
||||
orderbookData.sort((a, b) => Number(BigInt(b.price) - BigInt(a.price)));
|
||||
// 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--) {
|
||||
if (!orderbookData[i].cumulativeVol.ask) {
|
||||
orderbookData[i].cumulativeVol.ask =
|
||||
orderbookData[i].ask +
|
||||
(i !== maxIndex ? orderbookData[i + 1].cumulativeVol.ask : 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
// count relative volumes
|
||||
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 partiallyUpdateCompactedData = (
|
||||
dataType: 'sell' | 'buy',
|
||||
draft: OrderbookData[],
|
||||
delta:
|
||||
| MarketDepthSubscription_marketDepthUpdate_sell
|
||||
| MarketDepthSubscription_marketDepthUpdate_buy,
|
||||
resolution: number,
|
||||
modifiedIndex: number
|
||||
) => {
|
||||
const { price } = delta;
|
||||
const volume = Number(delta.volume);
|
||||
const groupPrice = getGroupPrice(price, resolution);
|
||||
const volKey = dataType === 'sell' ? 'ask' : 'bid';
|
||||
const oppositeVolKey = dataType === 'sell' ? 'bid' : 'ask';
|
||||
const volByLevelKey = dataType === 'sell' ? 'askByLevel' : 'bidByLevel';
|
||||
const resolveModifiedIndex = dataType === 'sell' ? Math.max : Math.min;
|
||||
let index = draft.findIndex((data) => data.price === groupPrice);
|
||||
if (index !== -1) {
|
||||
modifiedIndex = resolveModifiedIndex(modifiedIndex, index);
|
||||
draft[index][volKey] =
|
||||
draft[index][volKey] - (draft[index][volByLevelKey][price] || 0) + volume;
|
||||
draft[index][volByLevelKey][price] = volume;
|
||||
} else {
|
||||
const newData: OrderbookData = createData(groupPrice, volume, dataType);
|
||||
index = draft.findIndex((data) => BigInt(data.price) < BigInt(groupPrice));
|
||||
if (index !== -1) {
|
||||
draft.splice(index, 0, newData);
|
||||
newData.cumulativeVol[oppositeVolKey] =
|
||||
draft[index + (groupPrice === 'sell' ? -1 : 1)].cumulativeVol[
|
||||
oppositeVolKey
|
||||
];
|
||||
modifiedIndex = resolveModifiedIndex(modifiedIndex, index);
|
||||
} else {
|
||||
draft.push(newData);
|
||||
modifiedIndex = draft.length - 1;
|
||||
}
|
||||
}
|
||||
return modifiedIndex;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates OrderbookData[] with new data received from subscription - mutates input
|
||||
*
|
||||
* @param orderbookData
|
||||
* @param sell
|
||||
* @param buy
|
||||
* @param resolution
|
||||
* @returns void
|
||||
*/
|
||||
export const updateCompactedData = (
|
||||
orderbookData: OrderbookData[],
|
||||
sell: MarketDepthSubscription_marketDepthUpdate_sell[] | null,
|
||||
buy: MarketDepthSubscription_marketDepthUpdate_buy[] | null,
|
||||
resolution: number
|
||||
) =>
|
||||
produce(orderbookData, (draft) => {
|
||||
let sellModifiedIndex = -1;
|
||||
sell?.forEach((buy) => {
|
||||
sellModifiedIndex = partiallyUpdateCompactedData(
|
||||
'sell',
|
||||
draft,
|
||||
buy,
|
||||
resolution,
|
||||
sellModifiedIndex
|
||||
);
|
||||
});
|
||||
let buyModifiedIndex = draft.length;
|
||||
buy?.forEach((sell) => {
|
||||
buyModifiedIndex = partiallyUpdateCompactedData(
|
||||
'buy',
|
||||
draft,
|
||||
sell,
|
||||
resolution,
|
||||
buyModifiedIndex
|
||||
);
|
||||
});
|
||||
|
||||
// update cummulative ask only below hihgest modified price level
|
||||
if (sellModifiedIndex !== -1) {
|
||||
for (let i = Math.min(sellModifiedIndex, draft.length - 2); i >= 0; i--) {
|
||||
draft[i].cumulativeVol.ask =
|
||||
draft[i + 1].cumulativeVol.ask + draft[i].ask;
|
||||
}
|
||||
}
|
||||
// update cummulative bid only above lowest modified price level
|
||||
if (buyModifiedIndex !== draft.length) {
|
||||
for (
|
||||
let i = Math.max(buyModifiedIndex, 1), l = draft.length;
|
||||
i < l;
|
||||
i++
|
||||
) {
|
||||
draft[i].cumulativeVol.bid =
|
||||
draft[i - 1].cumulativeVol.bid + draft[i].bid;
|
||||
}
|
||||
}
|
||||
let index = 0;
|
||||
// remove levels that do not have any volume
|
||||
while (index < draft.length) {
|
||||
if (!draft[index].ask && !draft[index].bid) {
|
||||
draft.splice(index, 1);
|
||||
} else {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
// count relative volumes
|
||||
updateRelativeData(draft);
|
||||
});
|
||||
|
||||
/**
|
||||
* Updates raw data with new data received from subscription - mutates input
|
||||
* @param levels
|
||||
* @param updates
|
||||
* @returns
|
||||
*/
|
||||
export const updateLevels = (
|
||||
levels: (MarketDepth_market_depth_buy | MarketDepth_market_depth_sell)[],
|
||||
updates: (
|
||||
| MarketDepthSubscription_marketDepthUpdate_buy
|
||||
| MarketDepthSubscription_marketDepthUpdate_sell
|
||||
)[]
|
||||
) => {
|
||||
updates.forEach((update) => {
|
||||
let index = levels.findIndex((level) => level.price === update.price);
|
||||
if (index !== -1) {
|
||||
if (update.volume === '0') {
|
||||
levels.splice(index, 1);
|
||||
} else {
|
||||
Object.assign(levels[index], update);
|
||||
}
|
||||
} else if (update.volume !== '0') {
|
||||
index = levels.findIndex(
|
||||
(level) => Number(level.price) > Number(update.price)
|
||||
);
|
||||
if (index !== -1) {
|
||||
levels.splice(index, 0, update);
|
||||
} else {
|
||||
levels.push(update);
|
||||
}
|
||||
}
|
||||
});
|
||||
return levels;
|
||||
};
|
74
libs/market-depth/src/lib/orderbook-manager.tsx
Normal file
74
libs/market-depth/src/lib/orderbook-manager.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import throttle from 'lodash/throttle';
|
||||
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
|
||||
import { Orderbook } from './orderbook';
|
||||
import { useDataProvider } from '@vegaprotocol/react-helpers';
|
||||
import { marketDepthDataProvider } from './market-depth-data-provider';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { MarketDepthSubscription_marketDepthUpdate } from './__generated__/MarketDepthSubscription';
|
||||
import { compactData, updateCompactedData } from './orderbook-data';
|
||||
import type { OrderbookData } from './orderbook-data';
|
||||
|
||||
interface OrderbookManagerProps {
|
||||
marketId: string;
|
||||
resolution: number;
|
||||
}
|
||||
|
||||
export const OrderbookManager = ({
|
||||
marketId,
|
||||
resolution,
|
||||
}: OrderbookManagerProps) => {
|
||||
const variables = useMemo(() => ({ marketId }), [marketId]);
|
||||
const resolutionRef = useRef(resolution);
|
||||
const [orderbookData, setOrderbookData] = useState<OrderbookData[] | null>(
|
||||
null
|
||||
);
|
||||
const dataRef = useRef<OrderbookData[] | null>(null);
|
||||
const setOrderbookDataThrottled = useRef(throttle(setOrderbookData, 1000));
|
||||
|
||||
const update = useCallback(
|
||||
(delta: MarketDepthSubscription_marketDepthUpdate) => {
|
||||
if (!dataRef.current) {
|
||||
return false;
|
||||
}
|
||||
dataRef.current = updateCompactedData(
|
||||
dataRef.current,
|
||||
delta.sell,
|
||||
delta.buy,
|
||||
resolutionRef.current
|
||||
);
|
||||
setOrderbookDataThrottled.current(dataRef.current);
|
||||
return true;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const { data, error, loading, flush } = useDataProvider(
|
||||
marketDepthDataProvider,
|
||||
update,
|
||||
variables
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
dataRef.current = null;
|
||||
setOrderbookData(dataRef.current);
|
||||
return;
|
||||
}
|
||||
dataRef.current = compactData(data.depth.sell, data.depth.buy, resolution);
|
||||
setOrderbookData(dataRef.current);
|
||||
}, [data, resolution]);
|
||||
|
||||
useEffect(() => {
|
||||
resolutionRef.current = resolution;
|
||||
flush();
|
||||
}, [resolution, flush]);
|
||||
|
||||
return (
|
||||
<AsyncRenderer loading={loading} error={error} data={data}>
|
||||
<Orderbook
|
||||
data={orderbookData}
|
||||
decimalPlaces={data?.decimalPlaces ?? 0}
|
||||
/>
|
||||
</AsyncRenderer>
|
||||
);
|
||||
};
|
50
libs/market-depth/src/lib/orderbook-row.tsx
Normal file
50
libs/market-depth/src/lib/orderbook-row.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
PriceCell,
|
||||
Vol,
|
||||
CumulativeVol,
|
||||
formatNumber,
|
||||
} from '@vegaprotocol/react-helpers';
|
||||
|
||||
interface OrderbookRowProps {
|
||||
bid: number;
|
||||
relativeBidVol?: number;
|
||||
price: string;
|
||||
ask: number;
|
||||
relativeAskVol?: number;
|
||||
cumulativeRelativeAsk?: number;
|
||||
cumulativeRelativeBid?: number;
|
||||
decimalPlaces: number;
|
||||
}
|
||||
|
||||
export const OrderbookRow = React.memo(
|
||||
({
|
||||
bid,
|
||||
relativeBidVol,
|
||||
price,
|
||||
ask,
|
||||
relativeAskVol,
|
||||
decimalPlaces,
|
||||
cumulativeRelativeAsk,
|
||||
cumulativeRelativeBid,
|
||||
}: OrderbookRowProps) => {
|
||||
return (
|
||||
<>
|
||||
<Vol value={bid} relativeValue={relativeBidVol} type="bid" />
|
||||
<PriceCell
|
||||
value={BigInt(price)}
|
||||
valueFormatted={formatNumber(price, decimalPlaces)}
|
||||
/>
|
||||
<Vol value={ask} relativeValue={relativeAskVol} type="ask" />
|
||||
<CumulativeVol
|
||||
relativeAsk={cumulativeRelativeAsk}
|
||||
relativeBid={cumulativeRelativeBid}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
OrderbookRow.displayName = 'OrderbookRow';
|
||||
|
||||
export default OrderbookRow;
|
10
libs/market-depth/src/lib/orderbook.spec.tsx
Normal file
10
libs/market-depth/src/lib/orderbook.spec.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import Orderbook from './orderbook';
|
||||
|
||||
describe('Orderbook', () => {
|
||||
it('should render successfully', () => {
|
||||
const { baseElement } = render(<Orderbook data={null} decimalPlaces={4} />);
|
||||
expect(baseElement).toBeTruthy();
|
||||
});
|
||||
});
|
36
libs/market-depth/src/lib/orderbook.tsx
Normal file
36
libs/market-depth/src/lib/orderbook.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { t } from '@vegaprotocol/react-helpers';
|
||||
import { OrderbookRow } from './orderbook-row';
|
||||
import type { OrderbookData } from './orderbook-data';
|
||||
|
||||
interface OrderbookProps {
|
||||
data: OrderbookData[] | null;
|
||||
decimalPlaces: number;
|
||||
}
|
||||
|
||||
export const Orderbook = ({ data, decimalPlaces }: OrderbookProps) => (
|
||||
<>
|
||||
<div className="grid grid-cols-4 gap-4 border-b-1 text-ui-small mb-2 pb-2">
|
||||
<div>{t('Bid Vol')}</div>
|
||||
<div>{t('Price')}</div>
|
||||
<div>{t('Ask Vol')}</div>
|
||||
<div>{t('Cumulative Vol')}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4 text-right text-ui-small">
|
||||
{data?.map((data) => (
|
||||
<OrderbookRow
|
||||
key={data.price}
|
||||
price={data.price}
|
||||
decimalPlaces={decimalPlaces}
|
||||
bid={data.bid}
|
||||
relativeBidVol={data.relativeBidVol}
|
||||
cumulativeRelativeBid={data.cumulativeVol.relativeBid}
|
||||
ask={data.ask}
|
||||
relativeAskVol={data.relativeAskVol}
|
||||
cumulativeRelativeAsk={data.cumulativeVol.relativeAsk}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
export default Orderbook;
|
1
libs/market-depth/src/setup-tests.ts
Normal file
1
libs/market-depth/src/setup-tests.ts
Normal file
@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
@ -2,9 +2,12 @@
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": []
|
||||
"types": ["node"]
|
||||
},
|
||||
"files": [
|
||||
"../../node_modules/@nrwl/react/typings/cssmodule.d.ts",
|
||||
"../../node_modules/@nrwl/react/typings/image.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"**/*.spec.ts",
|
||||
"**/*.test.ts",
|
@ -1,6 +1,6 @@
|
||||
import { forwardRef } from 'react';
|
||||
import type { ValueFormatterParams } from 'ag-grid-community';
|
||||
import { PriceCell, formatNumber, t } from '@vegaprotocol/react-helpers';
|
||||
import { PriceFlashCell, formatNumber, t } from '@vegaprotocol/react-helpers';
|
||||
import { AgGridDynamic as AgGrid } from '@vegaprotocol/ui-toolkit';
|
||||
import type {
|
||||
Markets_markets,
|
||||
@ -37,7 +37,7 @@ export const MarketListTable = forwardRef<AgGridReact, MarketListTableProps>(
|
||||
onRowClicked={({ data }: { data: Markets_markets }) =>
|
||||
onRowClicked(data.id)
|
||||
}
|
||||
components={{ PriceCell }}
|
||||
components={{ PriceFlashCell }}
|
||||
>
|
||||
<AgGridColumn
|
||||
headerName={t('Market')}
|
||||
@ -58,7 +58,7 @@ export const MarketListTable = forwardRef<AgGridReact, MarketListTableProps>(
|
||||
headerName={t('Best bid')}
|
||||
field="data.bestBidPrice"
|
||||
type="rightAligned"
|
||||
cellRenderer="PriceCell"
|
||||
cellRenderer="PriceFlashCell"
|
||||
valueFormatter={({ value, data }: ValueFormatterParams) =>
|
||||
formatNumber(value, data.decimalPlaces)
|
||||
}
|
||||
@ -70,13 +70,13 @@ export const MarketListTable = forwardRef<AgGridReact, MarketListTableProps>(
|
||||
valueFormatter={({ value, data }: ValueFormatterParams) =>
|
||||
formatNumber(value, data.decimalPlaces)
|
||||
}
|
||||
cellRenderer="PriceCell"
|
||||
cellRenderer="PriceFlashCell"
|
||||
/>
|
||||
<AgGridColumn
|
||||
headerName={t('Mark price')}
|
||||
field="data.markPrice"
|
||||
type="rightAligned"
|
||||
cellRenderer="PriceCell"
|
||||
cellRenderer="PriceFlashCell"
|
||||
valueFormatter={({ value, data }: ValueFormatterParams) =>
|
||||
formatNumber(value, data.decimalPlaces)
|
||||
}
|
||||
|
@ -53,7 +53,6 @@ export const MarketsContainer = () => {
|
||||
|
||||
return (
|
||||
<AsyncRenderer loading={loading} error={error} data={data}>
|
||||
{(data) => (
|
||||
<MarketListTable
|
||||
ref={gridRef}
|
||||
data={data}
|
||||
@ -61,7 +60,6 @@ export const MarketsContainer = () => {
|
||||
push(`${pathname}/${id}?portfolio=orders&trade=orderbook`)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</AsyncRenderer>
|
||||
);
|
||||
};
|
||||
|
@ -73,7 +73,7 @@ export const OrderListManager = ({ partyId }: OrderListManagerProps) => {
|
||||
|
||||
return (
|
||||
<AsyncRenderer loading={loading} error={error} data={orders}>
|
||||
{(data) => <OrderList ref={gridRef} data={data} />}
|
||||
<OrderList ref={gridRef} data={data} />
|
||||
</AsyncRenderer>
|
||||
);
|
||||
};
|
||||
|
@ -55,7 +55,7 @@ export const PositionsManager = ({ partyId }: PositionsManagerProps) => {
|
||||
>(positionsDataProvider, update, variables);
|
||||
return (
|
||||
<AsyncRenderer loading={loading} error={error} data={data}>
|
||||
{(data) => <PositionsTable ref={gridRef} data={data} />}
|
||||
<PositionsTable ref={gridRef} data={data} />
|
||||
</AsyncRenderer>
|
||||
);
|
||||
};
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { forwardRef } from 'react';
|
||||
import type { ValueFormatterParams } from 'ag-grid-community';
|
||||
import {
|
||||
PriceCell,
|
||||
PriceFlashCell,
|
||||
formatNumber,
|
||||
volumePrefix,
|
||||
addDecimal,
|
||||
@ -77,7 +77,7 @@ export const PositionsTable = forwardRef<AgGridReact, PositionsTableProps>(
|
||||
],
|
||||
});
|
||||
}}
|
||||
components={{ PriceCell }}
|
||||
components={{ PriceFlashCell }}
|
||||
>
|
||||
<AgGridColumn
|
||||
headerName={t('Market')}
|
||||
@ -95,7 +95,7 @@ export const PositionsTable = forwardRef<AgGridReact, PositionsTableProps>(
|
||||
<AgGridColumn
|
||||
headerName={t('Average Entry Price')}
|
||||
field="averageEntryPrice"
|
||||
cellRenderer="PriceCell"
|
||||
cellRenderer="PriceFlashCell"
|
||||
valueFormatter={({
|
||||
value,
|
||||
data,
|
||||
@ -107,7 +107,7 @@ export const PositionsTable = forwardRef<AgGridReact, PositionsTableProps>(
|
||||
headerName={t('Mark Price')}
|
||||
field="market.data.markPrice"
|
||||
type="rightAligned"
|
||||
cellRenderer="PriceCell"
|
||||
cellRenderer="PriceFlashCell"
|
||||
valueFormatter={({
|
||||
value,
|
||||
data,
|
||||
@ -134,7 +134,7 @@ export const PositionsTable = forwardRef<AgGridReact, PositionsTableProps>(
|
||||
valueFormatter={({ value }: ValueFormatterParams) =>
|
||||
volumePrefix(value)
|
||||
}
|
||||
cellRenderer="PriceCell"
|
||||
cellRenderer="PriceFlashCell"
|
||||
/>
|
||||
</AgGrid>
|
||||
);
|
||||
|
@ -3,6 +3,13 @@ import { useApolloClient } from '@apollo/client';
|
||||
import type { OperationVariables } from '@apollo/client';
|
||||
import type { Subscribe } from '../lib/generic-data-provider';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param dataProvider subscribe function created by makeDataProvider
|
||||
* @param update optional function called on each delta received in subscription, if returns true updated data will be not passed from hook (component handles updates internally)
|
||||
* @param variables optional
|
||||
* @returns state: data, loading, error, methods: flush (pass updated data to update function without delta), restart: () => void}};
|
||||
*/
|
||||
export function useDataProvider<Data, Delta>(
|
||||
dataProvider: Subscribe<Data, Delta>,
|
||||
update?: (delta: Delta) => boolean,
|
||||
@ -12,12 +19,26 @@ export function useDataProvider<Data, Delta>(
|
||||
const [data, setData] = useState<Data | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<Error | undefined>(undefined);
|
||||
const flushRef = useRef<(() => void) | undefined>(undefined);
|
||||
const restartRef = useRef<((force?: boolean) => void) | undefined>(undefined);
|
||||
const initialized = useRef<boolean>(false);
|
||||
const flush = useCallback(() => {
|
||||
if (flushRef.current) {
|
||||
flushRef.current();
|
||||
}
|
||||
}, []);
|
||||
const restart = useCallback((force = false) => {
|
||||
if (restartRef.current) {
|
||||
restartRef.current(force);
|
||||
}
|
||||
}, []);
|
||||
const callback = useCallback(
|
||||
({ data, error, loading, delta }) => {
|
||||
setError(error);
|
||||
setLoading(loading);
|
||||
if (!error && !loading) {
|
||||
// if update function returns true it means that component handles updates
|
||||
// component can use flush() which will call callback without delta and cause data state update
|
||||
if (!initialized.current || !delta || !update || !update(delta)) {
|
||||
initialized.current = true;
|
||||
setData(data);
|
||||
@ -27,7 +48,14 @@ export function useDataProvider<Data, Delta>(
|
||||
[update]
|
||||
);
|
||||
useEffect(() => {
|
||||
return dataProvider(callback, client, variables);
|
||||
const { unsubscribe, flush, restart } = dataProvider(
|
||||
callback,
|
||||
client,
|
||||
variables
|
||||
);
|
||||
flushRef.current = flush;
|
||||
restartRef.current = restart;
|
||||
return unsubscribe;
|
||||
}, [client, initialized, dataProvider, callback, variables]);
|
||||
return { data, loading, error };
|
||||
return { data, loading, error, flush, restart };
|
||||
}
|
||||
|
@ -1,11 +1,10 @@
|
||||
export * from './lib/context';
|
||||
export * from './lib/format';
|
||||
export * from './lib/grid';
|
||||
export * from './lib/storage';
|
||||
export * from './lib/generic-data-provider';
|
||||
export * from './lib/i18n';
|
||||
export * from './lib/ethereum-error';
|
||||
export * from './lib/validate';
|
||||
export * from './lib/remove-0x';
|
||||
|
||||
export * from './hooks';
|
||||
export * from './lib/context';
|
||||
export * from './lib/ethereum-error';
|
||||
export * from './lib/format';
|
||||
export * from './lib/generic-data-provider';
|
||||
export * from './lib/grid';
|
||||
export * from './lib/i18n';
|
||||
export * from './lib/remove-0x';
|
||||
export * from './lib/storage';
|
||||
export * from './lib/validate';
|
||||
|
@ -3,11 +3,11 @@ import memoize from 'lodash/memoize';
|
||||
import { getUserLocale } from './utils';
|
||||
|
||||
export function addDecimal(
|
||||
value: string,
|
||||
value: string | number,
|
||||
decimals: number,
|
||||
decimalPrecision = decimals
|
||||
): string {
|
||||
if (!decimals) return value;
|
||||
if (!decimals) return value.toString();
|
||||
return new BigNumber(value || 0)
|
||||
.dividedBy(Math.pow(10, decimals))
|
||||
.toFixed(decimalPrecision);
|
||||
@ -28,7 +28,7 @@ export const getNumberFormat = memoize(
|
||||
);
|
||||
|
||||
export const formatNumber = (
|
||||
rawValue: string,
|
||||
rawValue: string | number,
|
||||
decimalPlaces: number,
|
||||
formatDecimals: number = decimalPlaces
|
||||
) => {
|
||||
|
@ -23,14 +23,18 @@ export interface Subscribe<Data, Delta> {
|
||||
callback: UpdateCallback<Data, Delta>,
|
||||
client: ApolloClient<object>,
|
||||
variables?: OperationVariables
|
||||
): () => void;
|
||||
): {
|
||||
unsubscribe: () => void;
|
||||
restart: (force?: boolean) => void;
|
||||
flush: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type Query<Result> = DocumentNode | TypedDocumentNode<Result, any>;
|
||||
|
||||
interface Update<Data, Delta> {
|
||||
(draft: Draft<Data>, delta: Delta): void;
|
||||
export interface Update<Data, Delta> {
|
||||
(draft: Draft<Data>, delta: Delta, restart: (force?: boolean) => void): void;
|
||||
}
|
||||
|
||||
interface GetData<QueryData, Data> {
|
||||
@ -41,6 +45,14 @@ interface GetDelta<SubscriptionData, Delta> {
|
||||
(subscriptionData: SubscriptionData): Delta;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param subscriptionQuery query that will beused for subscription
|
||||
* @param update function that will be execued on each onNext, it should update data base on delta, it can restart data provider
|
||||
* @param getData transforms received query data to format that will be stored in data provider
|
||||
* @param getDelta transforms delta data to format that will be stored in data provider
|
||||
* @param fetchPolicy
|
||||
* @returns subscribe function
|
||||
*/
|
||||
function makeDataProviderInternal<QueryData, Data, SubscriptionData, Delta>(
|
||||
query: Query<QueryData>,
|
||||
subscriptionQuery: Query<SubscriptionData>,
|
||||
@ -49,7 +61,9 @@ function makeDataProviderInternal<QueryData, Data, SubscriptionData, Delta>(
|
||||
getDelta: GetDelta<SubscriptionData, Delta>,
|
||||
fetchPolicy: FetchPolicy = 'no-cache'
|
||||
): Subscribe<Data, Delta> {
|
||||
// list of callbacks passed through subscribe call
|
||||
const callbacks: UpdateCallback<Data, Delta>[] = [];
|
||||
// subscription is started before inital query, all deltas that will arrive before inital query response are put on queue
|
||||
const updateQueue: Delta[] = [];
|
||||
|
||||
let variables: OperationVariables | undefined = undefined;
|
||||
@ -59,6 +73,7 @@ function makeDataProviderInternal<QueryData, Data, SubscriptionData, Delta>(
|
||||
let client: ApolloClient<object> | undefined = undefined;
|
||||
let subscription: Subscription | undefined = undefined;
|
||||
|
||||
// notify single callback about current state, delta is passes optionally only if notify was invoked onNext
|
||||
const notify = (callback: UpdateCallback<Data, Delta>, delta?: Delta) => {
|
||||
callback({
|
||||
data,
|
||||
@ -68,12 +83,64 @@ function makeDataProviderInternal<QueryData, Data, SubscriptionData, Delta>(
|
||||
});
|
||||
};
|
||||
|
||||
// notify all callbacks
|
||||
const notifyAll = (delta?: Delta) => {
|
||||
callbacks.forEach((callback) => notify(callback, delta));
|
||||
};
|
||||
|
||||
const initialize = async () => {
|
||||
const initalFetch = async () => {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await client.query<QueryData>({
|
||||
query,
|
||||
variables,
|
||||
fetchPolicy,
|
||||
});
|
||||
data = getData(res.data);
|
||||
// if there was some updates received from subscription during initial query loading apply them on just reveived data
|
||||
if (data && updateQueue && updateQueue.length > 0) {
|
||||
data = produce(data, (draft) => {
|
||||
while (updateQueue.length) {
|
||||
const delta = updateQueue.shift();
|
||||
if (delta) {
|
||||
update(draft, delta, restart);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// if error will occur data provider stops subscription
|
||||
error = e as Error;
|
||||
if (subscription) {
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
subscription = undefined;
|
||||
} finally {
|
||||
loading = false;
|
||||
notifyAll();
|
||||
}
|
||||
};
|
||||
|
||||
// restart function is passed to update and as a returned by subscribe function
|
||||
const restart = (hard = false) => {
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
// hard reset on demand or when there is no apollo subscription yet
|
||||
if (hard || !subscription) {
|
||||
reset();
|
||||
initialize();
|
||||
} else {
|
||||
loading = true;
|
||||
error = undefined;
|
||||
initalFetch();
|
||||
}
|
||||
};
|
||||
|
||||
const initialize = async () => {
|
||||
if (subscription || loading) {
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
@ -88,7 +155,8 @@ function makeDataProviderInternal<QueryData, Data, SubscriptionData, Delta>(
|
||||
variables,
|
||||
fetchPolicy,
|
||||
})
|
||||
.subscribe(({ data: subscriptionData }) => {
|
||||
.subscribe(
|
||||
({ data: subscriptionData }) => {
|
||||
if (!subscriptionData) {
|
||||
return;
|
||||
}
|
||||
@ -97,7 +165,7 @@ function makeDataProviderInternal<QueryData, Data, SubscriptionData, Delta>(
|
||||
updateQueue.push(delta);
|
||||
} else {
|
||||
const newData = produce(data, (draft) => {
|
||||
update(draft, delta);
|
||||
update(draft, delta, restart);
|
||||
});
|
||||
if (newData === data) {
|
||||
return;
|
||||
@ -105,37 +173,13 @@ function makeDataProviderInternal<QueryData, Data, SubscriptionData, Delta>(
|
||||
data = newData;
|
||||
notifyAll(delta);
|
||||
}
|
||||
});
|
||||
try {
|
||||
const res = await client.query<QueryData>({
|
||||
query,
|
||||
variables,
|
||||
fetchPolicy,
|
||||
});
|
||||
data = getData(res.data);
|
||||
if (data && updateQueue && updateQueue.length > 0) {
|
||||
data = produce(data, (draft) => {
|
||||
while (updateQueue.length) {
|
||||
const delta = updateQueue.shift();
|
||||
if (delta) {
|
||||
update(draft, delta);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
subscription.unsubscribe();
|
||||
subscription = undefined;
|
||||
} finally {
|
||||
loading = false;
|
||||
notifyAll();
|
||||
}
|
||||
},
|
||||
() => restart()
|
||||
);
|
||||
await initalFetch();
|
||||
};
|
||||
|
||||
const unsubscribe = (callback: UpdateCallback<Data, Delta>) => {
|
||||
callbacks.splice(callbacks.indexOf(callback), 1);
|
||||
if (callbacks.length === 0) {
|
||||
const reset = () => {
|
||||
if (subscription) {
|
||||
subscription.unsubscribe();
|
||||
subscription = undefined;
|
||||
@ -143,9 +187,18 @@ function makeDataProviderInternal<QueryData, Data, SubscriptionData, Delta>(
|
||||
data = null;
|
||||
error = undefined;
|
||||
loading = false;
|
||||
notifyAll();
|
||||
};
|
||||
|
||||
// remove callback from list, and unsubscribe if there is no more callbacks registered
|
||||
const unsubscribe = (callback: UpdateCallback<Data, Delta>) => {
|
||||
callbacks.splice(callbacks.indexOf(callback), 1);
|
||||
if (callbacks.length === 0) {
|
||||
reset();
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
return (callback, c, v) => {
|
||||
callbacks.push(callback);
|
||||
if (callbacks.length === 1) {
|
||||
@ -155,10 +208,20 @@ function makeDataProviderInternal<QueryData, Data, SubscriptionData, Delta>(
|
||||
} else {
|
||||
notify(callback);
|
||||
}
|
||||
return () => unsubscribe(callback);
|
||||
return {
|
||||
unsubscribe: () => unsubscribe(callback),
|
||||
restart,
|
||||
flush: () => notify(callback),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoizes data provider instances using query variables as cache key
|
||||
*
|
||||
* @param fn
|
||||
* @returns subscibe function
|
||||
*/
|
||||
const memoize = <Data, Delta>(
|
||||
fn: (variables?: OperationVariables) => Subscribe<Data, Delta>
|
||||
) => {
|
||||
@ -177,6 +240,30 @@ const memoize = <Data, Delta>(
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @param query Query<QueryData>
|
||||
* @param subscriptionQuery Query<SubscriptionData> query that will beused for subscription
|
||||
* @param update Update<Data, Delta> function that will be execued on each onNext, it should update data base on delta, it can restart data provider
|
||||
* @param getData transforms received query data to format that will be stored in data provider
|
||||
* @param getDelta transforms delta data to format that will be stored in data provider
|
||||
* @param fetchPolicy
|
||||
* @returns Subscribe<Data, Delta> subscribe function
|
||||
* @example
|
||||
* const marketMidPriceProvider = makeDataProvider<QueryData, Data, SubscriptionData, Delta>(
|
||||
* gql`query MarketMidPrice($marketId: ID!) { market(id: $marketId) { data { midPrice } } }`,
|
||||
* gql`subscription MarketMidPriceSubscription($marketId: ID!) { marketDepthUpdate(marketId: $marketId) { market { data { midPrice } } } }`,
|
||||
* (draft: Draft<Data>, delta: Delta, restart: (force?: boolean) => void) => { draft.midPrice = delta.midPrice }
|
||||
* (data:QueryData) => data.market.data.midPrice
|
||||
* (delta:SubscriptionData) => delta.marketData.market
|
||||
* )
|
||||
*
|
||||
* const { unsubscribe, flush, restart } = marketMidPriceProvider(
|
||||
* ({ data, error, loading, delta }) => { ... },
|
||||
* apolloClient,
|
||||
* { id: '1fd726454fa1220038acbf6ff9ac701d8b8bf3f2d77c93a4998544471dc58747' }
|
||||
* )
|
||||
*
|
||||
*/
|
||||
export function makeDataProvider<QueryData, Data, SubscriptionData, Delta>(
|
||||
query: Query<QueryData>,
|
||||
subscriptionQuery: Query<SubscriptionData>,
|
||||
|
67
libs/react-helpers/src/lib/grid/cumulative-vol-cell.tsx
Normal file
67
libs/react-helpers/src/lib/grid/cumulative-vol-cell.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import type { ICellRendererParams } from 'ag-grid-community';
|
||||
|
||||
import { BID_COLOR, ASK_COLOR } from './vol-cell';
|
||||
|
||||
const INTERSECT_COLOR = 'darkgray';
|
||||
|
||||
export interface CumulativeVolProps {
|
||||
relativeAsk?: number;
|
||||
relativeBid?: number;
|
||||
}
|
||||
|
||||
export interface ICumulativeVolCellProps extends ICellRendererParams {
|
||||
value: CumulativeVolProps;
|
||||
}
|
||||
|
||||
export const CumulativeVol = React.memo(
|
||||
({ relativeAsk, relativeBid }: CumulativeVolProps) => {
|
||||
const bid = relativeBid ? (
|
||||
<div
|
||||
className="h-full absolute top-0 right-0"
|
||||
style={{
|
||||
width: `${relativeBid}%`,
|
||||
backgroundColor:
|
||||
relativeAsk && relativeAsk > relativeBid
|
||||
? INTERSECT_COLOR
|
||||
: BID_COLOR,
|
||||
}}
|
||||
></div>
|
||||
) : null;
|
||||
const ask = relativeAsk ? (
|
||||
<div
|
||||
className="h-full absolute top-0 left-0"
|
||||
style={{
|
||||
width: `${relativeAsk}%`,
|
||||
backgroundColor:
|
||||
relativeBid && relativeBid > relativeAsk
|
||||
? INTERSECT_COLOR
|
||||
: ASK_COLOR,
|
||||
}}
|
||||
></div>
|
||||
) : null;
|
||||
return (
|
||||
<div className="h-full relative" data-testid="vol">
|
||||
{relativeBid && relativeAsk && relativeBid > relativeAsk ? (
|
||||
<>
|
||||
{ask}
|
||||
{bid}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{bid}
|
||||
{ask}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CumulativeVol.displayName = 'CumulativeVol';
|
||||
|
||||
export const CumulativeVolCell = ({ value }: ICumulativeVolCellProps) => (
|
||||
<CumulativeVol {...value} />
|
||||
);
|
||||
|
||||
CumulativeVolCell.displayName = 'CumulativeVolCell';
|
@ -1,3 +1,6 @@
|
||||
export * from './cumulative-vol-cell';
|
||||
export * from './flash-cell';
|
||||
export * from './price-cell';
|
||||
export * from './price-flash-cell';
|
||||
export * from './summary-rows';
|
||||
export * from './vol-cell';
|
||||
|
@ -2,20 +2,20 @@ import { render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import * as React from 'react';
|
||||
|
||||
import { PriceCell } from './price-cell';
|
||||
import { PriceFlashCell } from './price-flash-cell';
|
||||
|
||||
describe('<PriceCell />', () => {
|
||||
describe('<PriceFlashCell />', () => {
|
||||
it('Displays formatted value', () => {
|
||||
render(<PriceCell value={100} valueFormatted="100.00" />);
|
||||
render(<PriceFlashCell value={100} valueFormatted="100.00" />);
|
||||
expect(screen.getByTestId('price')).toHaveTextContent('100.00');
|
||||
});
|
||||
it('Displays 0', () => {
|
||||
render(<PriceCell value={0} valueFormatted="0.00" />);
|
||||
render(<PriceFlashCell value={0} valueFormatted="0.00" />);
|
||||
expect(screen.getByTestId('price')).toHaveTextContent('0.00');
|
||||
});
|
||||
|
||||
it('Displays - if value is not a number', () => {
|
||||
render(<PriceCell value={null} valueFormatted="" />);
|
||||
render(<PriceFlashCell value={null} valueFormatted="" />);
|
||||
expect(screen.getByTestId('price')).toHaveTextContent('-');
|
||||
});
|
||||
});
|
||||
|
@ -1,19 +1,23 @@
|
||||
import { FlashCell } from './flash-cell';
|
||||
|
||||
import React from 'react';
|
||||
export interface IPriceCellProps {
|
||||
value: number | bigint | null | undefined;
|
||||
valueFormatted: string;
|
||||
}
|
||||
|
||||
export const PriceCell = ({ value, valueFormatted }: IPriceCellProps) => {
|
||||
if ((!value && value !== 0) || isNaN(Number(value))) {
|
||||
export const PriceCell = React.memo(
|
||||
({ value, valueFormatted }: IPriceCellProps) => {
|
||||
if (
|
||||
(!value && value !== 0) ||
|
||||
(typeof value === 'number' && isNaN(Number(value)))
|
||||
) {
|
||||
return <span data-testid="price">-</span>;
|
||||
}
|
||||
return (
|
||||
<span className="font-mono" data-testid="price">
|
||||
<FlashCell value={Number(value)}>{valueFormatted}</FlashCell>
|
||||
<span className="font-mono relative" data-testid="price">
|
||||
{valueFormatted}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
PriceCell.displayName = 'PriceCell';
|
||||
|
21
libs/react-helpers/src/lib/grid/price-flash-cell.test.tsx
Normal file
21
libs/react-helpers/src/lib/grid/price-flash-cell.test.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import * as React from 'react';
|
||||
|
||||
import { PriceFlashCell } from './price-flash-cell';
|
||||
|
||||
describe('<PriceFlashCell />', () => {
|
||||
it('Displays formatted value', () => {
|
||||
render(<PriceFlashCell value={100} valueFormatted="100.00" />);
|
||||
expect(screen.getByTestId('price')).toHaveTextContent('100.00');
|
||||
});
|
||||
it('Displays 0', () => {
|
||||
render(<PriceFlashCell value={0} valueFormatted="0.00" />);
|
||||
expect(screen.getByTestId('price')).toHaveTextContent('0.00');
|
||||
});
|
||||
|
||||
it('Displays - if value is not a number', () => {
|
||||
render(<PriceFlashCell value={null} valueFormatted="" />);
|
||||
expect(screen.getByTestId('price')).toHaveTextContent('-');
|
||||
});
|
||||
});
|
22
libs/react-helpers/src/lib/grid/price-flash-cell.tsx
Normal file
22
libs/react-helpers/src/lib/grid/price-flash-cell.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { FlashCell } from './flash-cell';
|
||||
|
||||
export interface IPriceFlashCellProps {
|
||||
value: number | bigint | null | undefined;
|
||||
valueFormatted: string;
|
||||
}
|
||||
|
||||
export const PriceFlashCell = ({
|
||||
value,
|
||||
valueFormatted,
|
||||
}: IPriceFlashCellProps) => {
|
||||
if ((!value && value !== 0) || isNaN(Number(value))) {
|
||||
return <span data-testid="price">-</span>;
|
||||
}
|
||||
return (
|
||||
<span className="font-mono" data-testid="price">
|
||||
<FlashCell value={Number(value)}>{valueFormatted}</FlashCell>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
PriceFlashCell.displayName = 'PriceFlashCell';
|
42
libs/react-helpers/src/lib/grid/vol-cell.tsx
Normal file
42
libs/react-helpers/src/lib/grid/vol-cell.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import type { ICellRendererParams } from 'ag-grid-community';
|
||||
import { PriceCell } from './price-cell';
|
||||
|
||||
export interface VolProps {
|
||||
value: number | bigint | null | undefined;
|
||||
relativeValue?: number;
|
||||
type: 'bid' | 'ask';
|
||||
}
|
||||
export interface IVolCellProps extends ICellRendererParams {
|
||||
value: number | bigint | null | undefined;
|
||||
valueFormatted: Omit<VolProps, 'value'>;
|
||||
}
|
||||
|
||||
export const BID_COLOR = 'darkgreen';
|
||||
export const ASK_COLOR = 'maroon';
|
||||
|
||||
export const Vol = React.memo(({ value, relativeValue, type }: VolProps) => {
|
||||
if ((!value && value !== 0) || isNaN(Number(value))) {
|
||||
return <div data-testid="vol">-</div>;
|
||||
}
|
||||
return (
|
||||
<div className="relative" data-testid="vol">
|
||||
<div
|
||||
className="h-full absolute top-0 left-0"
|
||||
style={{
|
||||
width: relativeValue ? `${relativeValue}%` : '0%',
|
||||
backgroundColor: type === 'bid' ? BID_COLOR : ASK_COLOR,
|
||||
}}
|
||||
></div>
|
||||
<PriceCell value={value} valueFormatted={value.toString()} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Vol.displayName = 'Vol';
|
||||
|
||||
export const VolCell = ({ value, valueFormatted }: IVolCellProps) => (
|
||||
<Vol value={value} {...valueFormatted} />
|
||||
);
|
||||
|
||||
VolCell.displayName = 'VolCell';
|
@ -50,9 +50,12 @@ export const TradesContainer = ({ marketId }: TradesContainerProps) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<AsyncRenderer loading={loading} error={error} data={data}>
|
||||
{(data) => <TradesTable ref={gridRef} data={data} />}
|
||||
</AsyncRenderer>
|
||||
<AsyncRenderer
|
||||
loading={loading}
|
||||
error={error}
|
||||
data={data}
|
||||
render={(data) => <TradesTable ref={gridRef} data={data} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -1,11 +1,12 @@
|
||||
import { Splash } from '../splash';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
|
||||
interface AsyncRendererProps<T> {
|
||||
loading: boolean;
|
||||
error: Error | undefined | null;
|
||||
data: T | undefined;
|
||||
children: (data: T) => ReactNode;
|
||||
children?: ReactElement | null;
|
||||
render?: (data: T) => ReactNode;
|
||||
}
|
||||
|
||||
export function AsyncRenderer<T = object>({
|
||||
@ -13,6 +14,7 @@ export function AsyncRenderer<T = object>({
|
||||
error,
|
||||
data,
|
||||
children,
|
||||
render,
|
||||
}: AsyncRendererProps<T>) {
|
||||
if (error) {
|
||||
return <Splash>Something went wrong: {error.message}</Splash>;
|
||||
@ -25,6 +27,6 @@ export function AsyncRenderer<T = object>({
|
||||
if (!data) {
|
||||
return <Splash>No data</Splash>;
|
||||
}
|
||||
|
||||
return <>{children(data)}</>;
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return <>{render ? render(data) : children}</>;
|
||||
}
|
||||
|
@ -20,7 +20,7 @@
|
||||
"@vegaprotocol/cypress": ["libs/cypress/src/index.ts"],
|
||||
"@vegaprotocol/deal-ticket": ["libs/deal-ticket/src/index.ts"],
|
||||
"@vegaprotocol/deposits": ["libs/deposits/src/index.ts"],
|
||||
"@vegaprotocol/depth-chart": ["libs/depth-chart/src/index.ts"],
|
||||
"@vegaprotocol/market-depth": ["libs/market-depth/src/index.ts"],
|
||||
"@vegaprotocol/market-list": ["libs/market-list/src/index.ts"],
|
||||
"@vegaprotocol/network-stats": ["libs/network-stats/src/index.ts"],
|
||||
"@vegaprotocol/order-list": ["libs/order-list/src/index.ts"],
|
||||
|
@ -6,9 +6,9 @@
|
||||
"cypress": "libs/cypress",
|
||||
"deal-ticket": "libs/deal-ticket",
|
||||
"deposits": "libs/deposits",
|
||||
"depth-chart": "libs/depth-chart",
|
||||
"explorer": "apps/explorer",
|
||||
"explorer-e2e": "apps/explorer-e2e",
|
||||
"market-depth": "libs/market-depth",
|
||||
"market-list": "libs/market-list",
|
||||
"network-stats": "libs/network-stats",
|
||||
"order-list": "libs/order-list",
|
||||
|
Loading…
Reference in New Issue
Block a user