chore(trading): handle positions with market data gql errors (#2884)

This commit is contained in:
Maciek 2023-02-09 14:26:23 +01:00 committed by GitHub
parent 2e8dd294de
commit 4dd63da62b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 212 additions and 70 deletions

View File

@ -1,3 +1,6 @@
import { aliasGQLQuery } from '@vegaprotocol/cypress';
import { marketsDataQuery } from '@vegaprotocol/mock';
beforeEach(() => { beforeEach(() => {
cy.mockTradingPage(); cy.mockTradingPage();
cy.mockSubscription(); cy.mockSubscription();
@ -17,6 +20,47 @@ describe('positions', { tags: '@smoke' }, () => {
validatePositionsDisplayed(); validatePositionsDisplayed();
}); });
it('renders position among some graphql errors', () => {
const errors = [
{
message: 'no market data for market: market-2',
path: ['market', 'data'],
extensions: {
code: 13,
type: 'Internal',
},
},
];
const marketData = marketsDataQuery();
const edges = marketData.marketsConnection?.edges.map((market) => {
const replace =
market.node.data?.market.id === 'market-2' ? null : market.node.data;
return { ...market, node: { ...market.node, data: replace } };
});
const overrides = {
...marketData,
marketsConnection: { ...marketData.marketsConnection, edges },
};
cy.mockGQL((req) => {
aliasGQLQuery(req, 'MarketsData', overrides, errors);
});
cy.visit('/#/markets/market-0');
const emptyCells = [
'notional',
'markPrice',
'liquidationPrice',
'currentLeverage',
'averageEntryPrice',
];
cy.getByTestId('tab-positions').within(() => {
cy.get('[row-id="market-2"]').within(() => {
emptyCells.forEach((cell) => {
cy.get(`[col-id="${cell}"]`).should('contain.text', '-');
});
});
});
});
function validatePositionsDisplayed() { function validatePositionsDisplayed() {
cy.getByTestId('tab-positions').should('be.visible'); cy.getByTestId('tab-positions').should('be.visible');
cy.getByTestId('tab-positions').within(() => { cy.getByTestId('tab-positions').within(() => {

View File

@ -1,3 +1,4 @@
import type { GraphQLError } from 'graphql';
import type { RouteHandler } from 'cypress/types/net-stubbing'; import type { RouteHandler } from 'cypress/types/net-stubbing';
import type { CyHttpMessages } from 'cypress/types/net-stubbing'; import type { CyHttpMessages } from 'cypress/types/net-stubbing';
@ -30,14 +31,15 @@ export const aliasGQLQuery = (
req: CyHttpMessages.IncomingHttpRequest, req: CyHttpMessages.IncomingHttpRequest,
operationName: string, operationName: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
data?: any data?: any,
errors?: Partial<GraphQLError>[]
) => { ) => {
if (hasOperationName(req, operationName)) { if (hasOperationName(req, operationName)) {
req.alias = operationName; req.alias = operationName;
if (data !== undefined) { if (data !== undefined || errors !== undefined) {
req.reply({ req.reply({
statusCode: 200, statusCode: 200,
body: { data }, body: { ...(data && { data }), ...(errors && { errors }) },
}); });
} }
} }

View File

@ -1,4 +1,7 @@
import { makeDataProvider } from '@vegaprotocol/react-helpers'; import {
makeDataProvider,
marketDataErrorPolicyGuard,
} from '@vegaprotocol/react-helpers';
import type { MarketsDataQuery } from './__generated__/markets-data'; import type { MarketsDataQuery } from './__generated__/markets-data';
import { MarketsDataDocument } from './__generated__/markets-data'; import { MarketsDataDocument } from './__generated__/markets-data';
import type { MarketData } from './market-data-provider'; import type { MarketData } from './market-data-provider';
@ -16,4 +19,5 @@ export const marketsDataProvider = makeDataProvider<
>({ >({
query: MarketsDataDocument, query: MarketsDataDocument,
getData, getData,
errorPolicyGuard: marketDataErrorPolicyGuard,
}); });

View File

@ -45,22 +45,22 @@ export interface Position {
averageEntryPrice: string; averageEntryPrice: string;
marginAccountBalance: string; marginAccountBalance: string;
capitalUtilisation: number; capitalUtilisation: number;
currentLeverage: number; currentLeverage: number | undefined;
decimals: number; decimals: number;
marketDecimalPlaces: number; marketDecimalPlaces: number;
positionDecimalPlaces: number; positionDecimalPlaces: number;
totalBalance: string; totalBalance: string;
assetSymbol: string; assetSymbol: string;
liquidationPrice: string; liquidationPrice: string | undefined;
lowMarginLevel: boolean; lowMarginLevel: boolean;
marketId: string; marketId: string;
marketTradingMode: Schema.MarketTradingMode; marketTradingMode: Schema.MarketTradingMode;
markPrice: string; markPrice: string | undefined;
notional: string; notional: string | undefined;
openVolume: string; openVolume: string;
realisedPNL: string; realisedPNL: string;
unrealisedPNL: string; unrealisedPNL: string;
searchPrice: string; searchPrice: string | undefined;
updatedAt: string | null; updatedAt: string | null;
} }
@ -84,7 +84,7 @@ export const getMetrics = (
const marginAccount = accounts?.find((account) => { const marginAccount = accounts?.find((account) => {
return account.market?.id === market?.id; return account.market?.id === market?.id;
}); });
if (!marginAccount || !marginLevel || !market || !marketData) { if (!marginAccount || !marginLevel || !market) {
return; return;
} }
const generalAccount = accounts?.find( const generalAccount = accounts?.find(
@ -102,15 +102,22 @@ export const getMetrics = (
generalAccount?.balance ?? 0, generalAccount?.balance ?? 0,
decimals decimals
); );
const markPrice = toBigNum(marketData.markPrice, marketDecimalPlaces);
const notional = ( const markPrice = marketData
openVolume.isGreaterThan(0) ? openVolume : openVolume.multipliedBy(-1) ? toBigNum(marketData.markPrice, marketDecimalPlaces)
).multipliedBy(markPrice); : undefined;
const notional = markPrice
? (openVolume.isGreaterThan(0)
? openVolume
: openVolume.multipliedBy(-1)
).multipliedBy(markPrice)
: undefined;
const totalBalance = marginAccountBalance.plus(generalAccountBalance); const totalBalance = marginAccountBalance.plus(generalAccountBalance);
const currentLeverage = totalBalance.isEqualTo(0) const currentLeverage = notional
? new BigNumber(0) ? totalBalance.isEqualTo(0)
: notional.dividedBy(totalBalance); ? new BigNumber(0)
: notional.dividedBy(totalBalance)
: undefined;
const capitalUtilisation = totalBalance.isEqualTo(0) const capitalUtilisation = totalBalance.isEqualTo(0)
? new BigNumber(0) ? new BigNumber(0)
: marginAccountBalance.dividedBy(totalBalance).multipliedBy(100); : marginAccountBalance.dividedBy(totalBalance).multipliedBy(100);
@ -119,19 +126,23 @@ export const getMetrics = (
const marginSearch = toBigNum(marginLevel.searchLevel, decimals); const marginSearch = toBigNum(marginLevel.searchLevel, decimals);
const marginInitial = toBigNum(marginLevel.initialLevel, decimals); const marginInitial = toBigNum(marginLevel.initialLevel, decimals);
const searchPrice = marginSearch const searchPrice = markPrice
.minus(marginAccountBalance) ? marginSearch
.dividedBy(openVolume) .minus(marginAccountBalance)
.plus(markPrice); .dividedBy(openVolume)
.plus(markPrice)
: undefined;
const liquidationPrice = BigNumber.maximum( const liquidationPrice = markPrice
0, ? BigNumber.maximum(
marginMaintenance 0,
.minus(marginAccountBalance) marginMaintenance
.minus(generalAccountBalance) .minus(marginAccountBalance)
.dividedBy(openVolume) .minus(generalAccountBalance)
.plus(markPrice) .dividedBy(openVolume)
); .plus(markPrice)
)
: undefined;
const lowMarginLevel = const lowMarginLevel =
marginAccountBalance.isLessThan( marginAccountBalance.isLessThan(
@ -143,7 +154,7 @@ export const getMetrics = (
averageEntryPrice: position.averageEntryPrice, averageEntryPrice: position.averageEntryPrice,
marginAccountBalance: marginAccount.balance, marginAccountBalance: marginAccount.balance,
capitalUtilisation: Math.round(capitalUtilisation.toNumber()), capitalUtilisation: Math.round(capitalUtilisation.toNumber()),
currentLeverage: currentLeverage.toNumber(), currentLeverage: currentLeverage ? currentLeverage.toNumber() : undefined,
marketDecimalPlaces, marketDecimalPlaces,
positionDecimalPlaces, positionDecimalPlaces,
decimals, decimals,
@ -152,18 +163,20 @@ export const getMetrics = (
totalBalance: totalBalance.multipliedBy(10 ** decimals).toFixed(), totalBalance: totalBalance.multipliedBy(10 ** decimals).toFixed(),
lowMarginLevel, lowMarginLevel,
liquidationPrice: liquidationPrice liquidationPrice: liquidationPrice
.multipliedBy(10 ** marketDecimalPlaces) ? liquidationPrice.multipliedBy(10 ** marketDecimalPlaces).toFixed(0)
.toFixed(0), : undefined,
marketId: market.id, marketId: market.id,
marketTradingMode: market.tradingMode, marketTradingMode: market.tradingMode,
markPrice: marketData.markPrice, markPrice: marketData ? marketData.markPrice : undefined,
notional: notional.multipliedBy(10 ** marketDecimalPlaces).toFixed(0), notional: notional
? notional.multipliedBy(10 ** marketDecimalPlaces).toFixed(0)
: undefined,
openVolume: position.openVolume, openVolume: position.openVolume,
realisedPNL: position.realisedPNL, realisedPNL: position.realisedPNL,
unrealisedPNL: position.unrealisedPNL, unrealisedPNL: position.unrealisedPNL,
searchPrice: searchPrice searchPrice: searchPrice
.multipliedBy(10 ** marketDecimalPlaces) ? searchPrice.multipliedBy(10 ** marketDecimalPlaces).toFixed(0)
.toFixed(0), : undefined,
updatedAt: position.updatedAt || null, updatedAt: position.updatedAt || null,
}); });
}); });

View File

@ -49,7 +49,7 @@ export const AmountCell = ({ valueFormatted }: AmountCellProps) => {
} }
const { openVolume, positionDecimalPlaces, marketDecimalPlaces, notional } = const { openVolume, positionDecimalPlaces, marketDecimalPlaces, notional } =
valueFormatted; valueFormatted;
return valueFormatted ? ( return valueFormatted && notional ? (
<div className="leading-tight font-mono"> <div className="leading-tight font-mono">
<div <div
className={classNames('text-right', signedNumberCssClass(openVolume))} className={classNames('text-right', signedNumberCssClass(openVolume))}
@ -115,15 +115,15 @@ export const PositionsTable = forwardRef<AgGridReact, Props>(
valueGetter={({ valueGetter={({
data, data,
}: VegaValueGetterParams<Position, 'notional'>) => { }: VegaValueGetterParams<Position, 'notional'>) => {
return data?.notional === undefined return !data?.notional
? undefined ? undefined
: toBigNum(data?.notional, data.marketDecimalPlaces).toNumber(); : toBigNum(data.notional, data.marketDecimalPlaces).toNumber();
}} }}
valueFormatter={({ valueFormatter={({
data, data,
}: VegaValueFormatterParams<Position, 'notional'>) => { }: VegaValueFormatterParams<Position, 'notional'>) => {
return !data return !data || !data.notional
? undefined ? '-'
: addDecimalsFormatNumber( : addDecimalsFormatNumber(
data.notional, data.notional,
data.marketDecimalPlaces data.marketDecimalPlaces
@ -173,6 +173,7 @@ export const PositionsTable = forwardRef<AgGridReact, Props>(
data, data,
}: VegaValueGetterParams<Position, 'markPrice'>) => { }: VegaValueGetterParams<Position, 'markPrice'>) => {
return !data || return !data ||
!data.markPrice ||
data.marketTradingMode === data.marketTradingMode ===
Schema.MarketTradingMode.TRADING_MODE_OPENING_AUCTION Schema.MarketTradingMode.TRADING_MODE_OPENING_AUCTION
? undefined ? undefined
@ -180,14 +181,14 @@ export const PositionsTable = forwardRef<AgGridReact, Props>(
}} }}
valueFormatter={({ valueFormatter={({
data, data,
node,
}: VegaValueFormatterParams<Position, 'markPrice'>) => { }: VegaValueFormatterParams<Position, 'markPrice'>) => {
if (!data) { if (!data) {
return undefined; return undefined;
} }
if ( if (
!data.markPrice ||
data.marketTradingMode === data.marketTradingMode ===
Schema.MarketTradingMode.TRADING_MODE_OPENING_AUCTION Schema.MarketTradingMode.TRADING_MODE_OPENING_AUCTION
) { ) {
return '-'; return '-';
} }
@ -220,7 +221,6 @@ export const PositionsTable = forwardRef<AgGridReact, Props>(
}} }}
valueFormatter={({ valueFormatter={({
data, data,
node,
}: VegaValueFormatterParams<Position, 'averageEntryPrice'>): }: VegaValueFormatterParams<Position, 'averageEntryPrice'>):
| string | string
| undefined => { | undefined => {
@ -258,7 +258,7 @@ export const PositionsTable = forwardRef<AgGridReact, Props>(
}: VegaValueFormatterParams<Position, 'liquidationPrice'>): }: VegaValueFormatterParams<Position, 'liquidationPrice'>):
| string | string
| undefined => { | undefined => {
if (!data) { if (!data || data?.liquidationPrice === undefined) {
return undefined; return undefined;
} }
return addDecimalsFormatNumber( return addDecimalsFormatNumber(

View File

@ -2,6 +2,7 @@ import type { ApolloError } from '@apollo/client';
import type { GraphQLErrors } from '@apollo/client/errors'; import type { GraphQLErrors } from '@apollo/client/errors';
const NOT_FOUND = 'NotFound'; const NOT_FOUND = 'NotFound';
const INTERNAL = 'Internal';
const isApolloGraphQLError = ( const isApolloGraphQLError = (
error: ApolloError | Error | undefined error: ApolloError | Error | undefined
@ -9,21 +10,31 @@ const isApolloGraphQLError = (
return !!error && !!(error as ApolloError).graphQLErrors; return !!error && !!(error as ApolloError).graphQLErrors;
}; };
const hasNotFoundGraphQLErrors = (errors: GraphQLErrors, path?: string) => { const hasNotFoundGraphQLErrors = (errors: GraphQLErrors, path?: string[]) => {
return errors.some( return errors.some(
(e) => (e) =>
e.extensions && e.extensions &&
e.extensions['type'] === NOT_FOUND && e.extensions['type'] === NOT_FOUND &&
(!path || e.path?.[0] === path) (!path || path.every((item, i) => item === e?.path?.[i]))
); );
}; };
export const isNotFoundGraphQLError = ( export const isNotFoundGraphQLError = (
error: Error | ApolloError | undefined, error: Error | ApolloError | undefined,
path?: string path?: string[]
) => { ) => {
return ( return (
isApolloGraphQLError(error) && isApolloGraphQLError(error) &&
hasNotFoundGraphQLErrors(error.graphQLErrors, path) hasNotFoundGraphQLErrors(error.graphQLErrors, path)
); );
}; };
export const marketDataErrorPolicyGuard = (errors: GraphQLErrors) => {
const path = ['market', 'data'];
return errors.every(
(e) =>
e.extensions &&
e.extensions['type'] === INTERNAL &&
(!path || path.every((item, i) => item === e?.path?.[i]))
);
};

View File

@ -19,11 +19,12 @@ import type {
OperationVariables, OperationVariables,
ApolloQueryResult, ApolloQueryResult,
QueryOptions, QueryOptions,
ApolloError,
} from '@apollo/client'; } from '@apollo/client';
import { ApolloError } from '@apollo/client';
import type { GraphQLErrors } from '@apollo/client/errors';
import { GraphQLError } from 'graphql'; import { GraphQLError } from 'graphql';
import type { Subscription, Observable } from 'zen-observable-ts'; import type { Subscription, Observable } from 'zen-observable-ts';
import { waitFor } from '@testing-library/react';
type Item = { type Item = {
cursor: string; cursor: string;
@ -108,6 +109,23 @@ const paginatedSubscribe = makeDataProvider<
}, },
}); });
const mockErrorPolicyGuard: (errors: GraphQLErrors) => boolean = jest
.fn()
.mockImplementation(() => true);
const errorGuardedSubscribe = makeDataProvider<
QueryData,
Data,
SubscriptionData,
Delta
>({
query,
subscriptionQuery,
update,
getData,
getDelta,
errorPolicyGuard: mockErrorPolicyGuard,
});
const derivedSubscribe = makeDerivedDataProvider( const derivedSubscribe = makeDerivedDataProvider(
[paginatedSubscribe, subscribe], [paginatedSubscribe, subscribe],
combineData, combineData,
@ -537,6 +555,34 @@ describe('data provider', () => {
expect(lastCallbackArgs[0].totalCount).toBe(100); expect(lastCallbackArgs[0].totalCount).toBe(100);
subscription.unsubscribe(); subscription.unsubscribe();
}); });
it('errorPolicyGuard should work properly', async () => {
const subscription = errorGuardedSubscribe(callback, client);
const graphQLError = new GraphQLError(
'',
undefined,
undefined,
undefined,
['market', 'data'],
undefined,
{
type: 'Internal',
}
);
const graphQLErrors = [graphQLError];
const error = new ApolloError({ graphQLErrors });
await rejectQuery(error);
const data = generateData(0, 5);
await resolveQuery({
data,
});
expect(mockErrorPolicyGuard).toHaveBeenNthCalledWith(1, graphQLErrors);
await waitFor(() =>
expect(getData).toHaveBeenCalledWith({ data }, undefined)
);
subscription.unsubscribe();
});
}); });
describe('derived data provider', () => { describe('derived data provider', () => {

View File

@ -5,7 +5,10 @@ import type {
OperationVariables, OperationVariables,
TypedDocumentNode, TypedDocumentNode,
FetchResult, FetchResult,
ErrorPolicy,
ApolloQueryResult,
} from '@apollo/client'; } from '@apollo/client';
import type { GraphQLErrors } from '@apollo/client/errors';
import type { Subscription } from 'zen-observable-ts'; import type { Subscription } from 'zen-observable-ts';
import isEqual from 'lodash/isEqual'; import isEqual from 'lodash/isEqual';
import { isNotFoundGraphQLError } from './apollo-client'; import { isNotFoundGraphQLError } from './apollo-client';
@ -178,6 +181,7 @@ interface DataProviderParams<
fetchPolicy?: FetchPolicy; fetchPolicy?: FetchPolicy;
resetDelay?: number; resetDelay?: number;
additionalContext?: Record<string, unknown>; additionalContext?: Record<string, unknown>;
errorPolicyGuard?: (graphqlErrors: GraphQLErrors) => boolean;
} }
/** /**
@ -186,6 +190,9 @@ interface DataProviderParams<
* @param getData transforms received query data to format that will be stored in 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 getDelta transforms delta data to format that will be stored in data provider
* @param fetchPolicy * @param fetchPolicy
* @param resetDelay
* @param additionalContext add property to the context of the query, ie. 'isEnlargedTimeout'
* @param errorPolicyGuard indicate which gql errors can be tolerate
* @returns subscribe function * @returns subscribe function
*/ */
function makeDataProviderInternal< function makeDataProviderInternal<
@ -204,6 +211,7 @@ function makeDataProviderInternal<
fetchPolicy, fetchPolicy,
resetDelay, resetDelay,
additionalContext, additionalContext,
errorPolicyGuard,
}: DataProviderParams< }: DataProviderParams<
QueryData, QueryData,
Data, Data,
@ -248,6 +256,30 @@ function makeDataProviderInternal<
callbacks.forEach((callback) => notify(callback, updateData)); callbacks.forEach((callback) => notify(callback, updateData));
}; };
const call = (
pagination?: Pagination,
policy?: ErrorPolicy
): Promise<ApolloQueryResult<QueryData>> =>
client
.query<QueryData>({
query,
variables: { ...variables, ...(pagination && { pagination }) },
fetchPolicy: fetchPolicy || 'no-cache',
context: additionalContext,
errorPolicy: policy || 'none',
})
.catch((err) => {
if (
err.graphQLErrors &&
errorPolicyGuard &&
errorPolicyGuard(err.graphQLErrors)
) {
return call(pagination, 'ignore');
} else {
throw err;
}
});
const load = async (start?: number, end?: number) => { const load = async (start?: number, end?: number) => {
if (!pagination) { if (!pagination) {
return Promise.reject(); return Promise.reject();
@ -276,15 +308,9 @@ function makeDataProviderInternal<
} else if (!pageInfo?.hasNextPage) { } else if (!pageInfo?.hasNextPage) {
return null; return null;
} }
const res = await client.query<QueryData>({
query, const res = await call(paginationVariables);
variables: {
...variables,
pagination: paginationVariables,
},
fetchPolicy: fetchPolicy || 'no-cache',
context: additionalContext,
});
const insertionData = getData(res.data, variables); const insertionData = getData(res.data, variables);
const insertionPageInfo = pagination.getPageInfo(res.data); const insertionPageInfo = pagination.getPageInfo(res.data);
({ data, totalCount } = pagination.append( ({ data, totalCount } = pagination.append(
@ -313,15 +339,11 @@ function makeDataProviderInternal<
if (!client) { if (!client) {
return; return;
} }
const paginationVariables = pagination
? { first: pagination.first }
: undefined;
try { try {
const res = await client.query<QueryData>({ const res = await call(paginationVariables);
query,
variables: pagination
? { ...variables, pagination: { first: pagination.first } }
: variables,
fetchPolicy: fetchPolicy || 'no-cache',
context: additionalContext,
});
data = getData(res.data, variables); data = getData(res.data, variables);
if (data && pagination) { if (data && pagination) {
if (!(data instanceof Array)) { if (!(data instanceof Array)) {
@ -355,7 +377,7 @@ function makeDataProviderInternal<
} }
loaded = true; loaded = true;
} catch (e) { } catch (e) {
if (isNotFoundGraphQLError(e as Error, 'party')) { if (isNotFoundGraphQLError(e as Error, ['party'])) {
data = getData(null, variables); data = getData(null, variables);
loaded = true; loaded = true;
return; return;
@ -495,7 +517,7 @@ const memoize = <
Delta, Delta,
Variables extends OperationVariables = OperationVariables Variables extends OperationVariables = OperationVariables
>( >(
fn: (variables?: Variables) => Subscribe<Data, Delta, Variables> fn: () => Subscribe<Data, Delta, Variables>
) => { ) => {
const cache: { const cache: {
subscribe: Subscribe<Data, Delta, Variables>; subscribe: Subscribe<Data, Delta, Variables>;
@ -506,7 +528,7 @@ const memoize = <
if (cached) { if (cached) {
return cached.subscribe; return cached.subscribe;
} }
const subscribe = fn(variables); const subscribe = fn();
cache.push({ subscribe, variables }); cache.push({ subscribe, variables });
return subscribe; return subscribe;
}; };