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:
Bartłomiej Głownia 2022-04-26 17:26:28 +02:00 committed by GitHub
parent b5788379a3
commit d0ec016adc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
62 changed files with 1671 additions and 737 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -40,16 +40,13 @@ export const DealTicketContainer = ({ marketId }: DealTicketContainerProps) => {
return (
<AsyncRenderer<DealTicketQuery> data={data} loading={loading} error={error}>
{(data) => {
if (!data.market) {
return (
<Splash>
<p>{t('Could not load market')}</p>
</Splash>
);
}
return <DealTicketManager market={data.market} />;
}}
{data && data.market ? (
<DealTicketManager market={data.market} />
) : (
<Splash>
<p>{t('Could not load market')}</p>
</Splash>
)}
</AsyncRenderer>
);
};

View File

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

View File

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

View File

@ -1,4 +0,0 @@
{
"name": "@vegaprotocol/depth-chart",
"version": "0.0.1"
}

View File

@ -1 +0,0 @@
export * from './lib/depth-chart';

View File

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

View File

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

View File

@ -1 +0,0 @@
export * from './update-depth-update';

View File

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

View File

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

View File

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

View File

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

View 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).

View 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'],
};

View File

@ -0,0 +1,4 @@
{
"name": "@vegaprotocol/market-depth",
"version": "0.0.1"
}

View File

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

View File

@ -0,0 +1,2 @@
export * from './lib/depth-chart';
export * from './lib/orderbook-container';

View File

@ -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
@ -84,14 +96,14 @@ export interface marketDepth_market {
/**
* decimalPlaces indicates the number of decimal places that an integer must be shifted by in order to get a correct
* number denominated in the currency of the Market. (uint64)
*
*
* Examples:
* Currency Balance decimalPlaces Real Balance
* GBP 100 0 GBP 100
* GBP 100 2 GBP 1.00
* GBP 100 4 GBP 0.01
* GBP 1 4 GBP 0.0001 ( 0.01p )
*
*
* GBX (pence) 100 0 GBP 1.00 (100p )
* GBX (pence) 100 2 GBP 0.01 ( 1p )
* GBX (pence) 100 4 GBP 0.0001 ( 0.01p )
@ -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 orderbook for this market
* 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;
}

View File

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

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

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

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

View 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} />
</>
);
};

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

View 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;
};

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

View 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;

View 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();
});
});

View 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;

View File

@ -0,0 +1 @@
import '@testing-library/jest-dom';

View File

@ -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",

View File

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

View File

@ -53,15 +53,13 @@ export const MarketsContainer = () => {
return (
<AsyncRenderer loading={loading} error={error} data={data}>
{(data) => (
<MarketListTable
ref={gridRef}
data={data}
onRowClicked={(id) =>
push(`${pathname}/${id}?portfolio=orders&trade=orderbook`)
}
/>
)}
<MarketListTable
ref={gridRef}
data={data}
onRowClicked={(id) =>
push(`${pathname}/${id}?portfolio=orders&trade=orderbook`)
}
/>
</AsyncRenderer>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 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) {
if (subscription || loading) {
return;
}
loading = true;
@ -88,64 +155,50 @@ function makeDataProviderInternal<QueryData, Data, SubscriptionData, Delta>(
variables,
fetchPolicy,
})
.subscribe(({ data: subscriptionData }) => {
if (!subscriptionData) {
return;
}
const delta = getDelta(subscriptionData);
if (loading || !data) {
updateQueue.push(delta);
} else {
const newData = produce(data, (draft) => {
update(draft, delta);
});
if (newData === data) {
.subscribe(
({ data: subscriptionData }) => {
if (!subscriptionData) {
return;
}
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);
const delta = getDelta(subscriptionData);
if (loading || !data) {
updateQueue.push(delta);
} else {
const newData = produce(data, (draft) => {
update(draft, delta, restart);
});
if (newData === data) {
return;
}
data = newData;
notifyAll(delta);
}
});
}
} catch (e) {
error = e as Error;
subscription.unsubscribe();
subscription = undefined;
} finally {
loading = false;
notifyAll();
}
},
() => restart()
);
await initalFetch();
};
const reset = () => {
if (subscription) {
subscription.unsubscribe();
subscription = undefined;
}
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) {
if (subscription) {
subscription.unsubscribe();
subscription = undefined;
}
data = null;
error = undefined;
loading = false;
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>,

View 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';

View File

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

View File

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

View File

@ -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))) {
return <span data-testid="price">-</span>;
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 relative" data-testid="price">
{valueFormatted}
</span>
);
}
return (
<span className="font-mono" data-testid="price">
<FlashCell value={Number(value)}>{valueFormatted}</FlashCell>
</span>
);
};
);
PriceCell.displayName = 'PriceCell';

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

View 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';

View 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';

View File

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

View File

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

View File

@ -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"],

View File

@ -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",