chore(trading): handle positions with market data gql errors (#2884)
This commit is contained in:
parent
2e8dd294de
commit
4dd63da62b
@ -1,3 +1,6 @@
|
||||
import { aliasGQLQuery } from '@vegaprotocol/cypress';
|
||||
import { marketsDataQuery } from '@vegaprotocol/mock';
|
||||
|
||||
beforeEach(() => {
|
||||
cy.mockTradingPage();
|
||||
cy.mockSubscription();
|
||||
@ -17,6 +20,47 @@ describe('positions', { tags: '@smoke' }, () => {
|
||||
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() {
|
||||
cy.getByTestId('tab-positions').should('be.visible');
|
||||
cy.getByTestId('tab-positions').within(() => {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import type { GraphQLError } from 'graphql';
|
||||
import type { RouteHandler } from 'cypress/types/net-stubbing';
|
||||
import type { CyHttpMessages } from 'cypress/types/net-stubbing';
|
||||
|
||||
@ -30,14 +31,15 @@ export const aliasGQLQuery = (
|
||||
req: CyHttpMessages.IncomingHttpRequest,
|
||||
operationName: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data?: any
|
||||
data?: any,
|
||||
errors?: Partial<GraphQLError>[]
|
||||
) => {
|
||||
if (hasOperationName(req, operationName)) {
|
||||
req.alias = operationName;
|
||||
if (data !== undefined) {
|
||||
if (data !== undefined || errors !== undefined) {
|
||||
req.reply({
|
||||
statusCode: 200,
|
||||
body: { data },
|
||||
body: { ...(data && { data }), ...(errors && { errors }) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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 { MarketsDataDocument } from './__generated__/markets-data';
|
||||
import type { MarketData } from './market-data-provider';
|
||||
@ -16,4 +19,5 @@ export const marketsDataProvider = makeDataProvider<
|
||||
>({
|
||||
query: MarketsDataDocument,
|
||||
getData,
|
||||
errorPolicyGuard: marketDataErrorPolicyGuard,
|
||||
});
|
||||
|
@ -45,22 +45,22 @@ export interface Position {
|
||||
averageEntryPrice: string;
|
||||
marginAccountBalance: string;
|
||||
capitalUtilisation: number;
|
||||
currentLeverage: number;
|
||||
currentLeverage: number | undefined;
|
||||
decimals: number;
|
||||
marketDecimalPlaces: number;
|
||||
positionDecimalPlaces: number;
|
||||
totalBalance: string;
|
||||
assetSymbol: string;
|
||||
liquidationPrice: string;
|
||||
liquidationPrice: string | undefined;
|
||||
lowMarginLevel: boolean;
|
||||
marketId: string;
|
||||
marketTradingMode: Schema.MarketTradingMode;
|
||||
markPrice: string;
|
||||
notional: string;
|
||||
markPrice: string | undefined;
|
||||
notional: string | undefined;
|
||||
openVolume: string;
|
||||
realisedPNL: string;
|
||||
unrealisedPNL: string;
|
||||
searchPrice: string;
|
||||
searchPrice: string | undefined;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
@ -84,7 +84,7 @@ export const getMetrics = (
|
||||
const marginAccount = accounts?.find((account) => {
|
||||
return account.market?.id === market?.id;
|
||||
});
|
||||
if (!marginAccount || !marginLevel || !market || !marketData) {
|
||||
if (!marginAccount || !marginLevel || !market) {
|
||||
return;
|
||||
}
|
||||
const generalAccount = accounts?.find(
|
||||
@ -102,15 +102,22 @@ export const getMetrics = (
|
||||
generalAccount?.balance ?? 0,
|
||||
decimals
|
||||
);
|
||||
const markPrice = toBigNum(marketData.markPrice, marketDecimalPlaces);
|
||||
|
||||
const notional = (
|
||||
openVolume.isGreaterThan(0) ? openVolume : openVolume.multipliedBy(-1)
|
||||
).multipliedBy(markPrice);
|
||||
const markPrice = marketData
|
||||
? toBigNum(marketData.markPrice, marketDecimalPlaces)
|
||||
: undefined;
|
||||
const notional = markPrice
|
||||
? (openVolume.isGreaterThan(0)
|
||||
? openVolume
|
||||
: openVolume.multipliedBy(-1)
|
||||
).multipliedBy(markPrice)
|
||||
: undefined;
|
||||
const totalBalance = marginAccountBalance.plus(generalAccountBalance);
|
||||
const currentLeverage = totalBalance.isEqualTo(0)
|
||||
? new BigNumber(0)
|
||||
: notional.dividedBy(totalBalance);
|
||||
const currentLeverage = notional
|
||||
? totalBalance.isEqualTo(0)
|
||||
? new BigNumber(0)
|
||||
: notional.dividedBy(totalBalance)
|
||||
: undefined;
|
||||
const capitalUtilisation = totalBalance.isEqualTo(0)
|
||||
? new BigNumber(0)
|
||||
: marginAccountBalance.dividedBy(totalBalance).multipliedBy(100);
|
||||
@ -119,19 +126,23 @@ export const getMetrics = (
|
||||
const marginSearch = toBigNum(marginLevel.searchLevel, decimals);
|
||||
const marginInitial = toBigNum(marginLevel.initialLevel, decimals);
|
||||
|
||||
const searchPrice = marginSearch
|
||||
.minus(marginAccountBalance)
|
||||
.dividedBy(openVolume)
|
||||
.plus(markPrice);
|
||||
const searchPrice = markPrice
|
||||
? marginSearch
|
||||
.minus(marginAccountBalance)
|
||||
.dividedBy(openVolume)
|
||||
.plus(markPrice)
|
||||
: undefined;
|
||||
|
||||
const liquidationPrice = BigNumber.maximum(
|
||||
0,
|
||||
marginMaintenance
|
||||
.minus(marginAccountBalance)
|
||||
.minus(generalAccountBalance)
|
||||
.dividedBy(openVolume)
|
||||
.plus(markPrice)
|
||||
);
|
||||
const liquidationPrice = markPrice
|
||||
? BigNumber.maximum(
|
||||
0,
|
||||
marginMaintenance
|
||||
.minus(marginAccountBalance)
|
||||
.minus(generalAccountBalance)
|
||||
.dividedBy(openVolume)
|
||||
.plus(markPrice)
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const lowMarginLevel =
|
||||
marginAccountBalance.isLessThan(
|
||||
@ -143,7 +154,7 @@ export const getMetrics = (
|
||||
averageEntryPrice: position.averageEntryPrice,
|
||||
marginAccountBalance: marginAccount.balance,
|
||||
capitalUtilisation: Math.round(capitalUtilisation.toNumber()),
|
||||
currentLeverage: currentLeverage.toNumber(),
|
||||
currentLeverage: currentLeverage ? currentLeverage.toNumber() : undefined,
|
||||
marketDecimalPlaces,
|
||||
positionDecimalPlaces,
|
||||
decimals,
|
||||
@ -152,18 +163,20 @@ export const getMetrics = (
|
||||
totalBalance: totalBalance.multipliedBy(10 ** decimals).toFixed(),
|
||||
lowMarginLevel,
|
||||
liquidationPrice: liquidationPrice
|
||||
.multipliedBy(10 ** marketDecimalPlaces)
|
||||
.toFixed(0),
|
||||
? liquidationPrice.multipliedBy(10 ** marketDecimalPlaces).toFixed(0)
|
||||
: undefined,
|
||||
marketId: market.id,
|
||||
marketTradingMode: market.tradingMode,
|
||||
markPrice: marketData.markPrice,
|
||||
notional: notional.multipliedBy(10 ** marketDecimalPlaces).toFixed(0),
|
||||
markPrice: marketData ? marketData.markPrice : undefined,
|
||||
notional: notional
|
||||
? notional.multipliedBy(10 ** marketDecimalPlaces).toFixed(0)
|
||||
: undefined,
|
||||
openVolume: position.openVolume,
|
||||
realisedPNL: position.realisedPNL,
|
||||
unrealisedPNL: position.unrealisedPNL,
|
||||
searchPrice: searchPrice
|
||||
.multipliedBy(10 ** marketDecimalPlaces)
|
||||
.toFixed(0),
|
||||
? searchPrice.multipliedBy(10 ** marketDecimalPlaces).toFixed(0)
|
||||
: undefined,
|
||||
updatedAt: position.updatedAt || null,
|
||||
});
|
||||
});
|
||||
|
@ -49,7 +49,7 @@ export const AmountCell = ({ valueFormatted }: AmountCellProps) => {
|
||||
}
|
||||
const { openVolume, positionDecimalPlaces, marketDecimalPlaces, notional } =
|
||||
valueFormatted;
|
||||
return valueFormatted ? (
|
||||
return valueFormatted && notional ? (
|
||||
<div className="leading-tight font-mono">
|
||||
<div
|
||||
className={classNames('text-right', signedNumberCssClass(openVolume))}
|
||||
@ -115,15 +115,15 @@ export const PositionsTable = forwardRef<AgGridReact, Props>(
|
||||
valueGetter={({
|
||||
data,
|
||||
}: VegaValueGetterParams<Position, 'notional'>) => {
|
||||
return data?.notional === undefined
|
||||
return !data?.notional
|
||||
? undefined
|
||||
: toBigNum(data?.notional, data.marketDecimalPlaces).toNumber();
|
||||
: toBigNum(data.notional, data.marketDecimalPlaces).toNumber();
|
||||
}}
|
||||
valueFormatter={({
|
||||
data,
|
||||
}: VegaValueFormatterParams<Position, 'notional'>) => {
|
||||
return !data
|
||||
? undefined
|
||||
return !data || !data.notional
|
||||
? '-'
|
||||
: addDecimalsFormatNumber(
|
||||
data.notional,
|
||||
data.marketDecimalPlaces
|
||||
@ -173,6 +173,7 @@ export const PositionsTable = forwardRef<AgGridReact, Props>(
|
||||
data,
|
||||
}: VegaValueGetterParams<Position, 'markPrice'>) => {
|
||||
return !data ||
|
||||
!data.markPrice ||
|
||||
data.marketTradingMode ===
|
||||
Schema.MarketTradingMode.TRADING_MODE_OPENING_AUCTION
|
||||
? undefined
|
||||
@ -180,14 +181,14 @@ export const PositionsTable = forwardRef<AgGridReact, Props>(
|
||||
}}
|
||||
valueFormatter={({
|
||||
data,
|
||||
node,
|
||||
}: VegaValueFormatterParams<Position, 'markPrice'>) => {
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
!data.markPrice ||
|
||||
data.marketTradingMode ===
|
||||
Schema.MarketTradingMode.TRADING_MODE_OPENING_AUCTION
|
||||
Schema.MarketTradingMode.TRADING_MODE_OPENING_AUCTION
|
||||
) {
|
||||
return '-';
|
||||
}
|
||||
@ -220,7 +221,6 @@ export const PositionsTable = forwardRef<AgGridReact, Props>(
|
||||
}}
|
||||
valueFormatter={({
|
||||
data,
|
||||
node,
|
||||
}: VegaValueFormatterParams<Position, 'averageEntryPrice'>):
|
||||
| string
|
||||
| undefined => {
|
||||
@ -258,7 +258,7 @@ export const PositionsTable = forwardRef<AgGridReact, Props>(
|
||||
}: VegaValueFormatterParams<Position, 'liquidationPrice'>):
|
||||
| string
|
||||
| undefined => {
|
||||
if (!data) {
|
||||
if (!data || data?.liquidationPrice === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return addDecimalsFormatNumber(
|
||||
|
@ -2,6 +2,7 @@ import type { ApolloError } from '@apollo/client';
|
||||
import type { GraphQLErrors } from '@apollo/client/errors';
|
||||
|
||||
const NOT_FOUND = 'NotFound';
|
||||
const INTERNAL = 'Internal';
|
||||
|
||||
const isApolloGraphQLError = (
|
||||
error: ApolloError | Error | undefined
|
||||
@ -9,21 +10,31 @@ const isApolloGraphQLError = (
|
||||
return !!error && !!(error as ApolloError).graphQLErrors;
|
||||
};
|
||||
|
||||
const hasNotFoundGraphQLErrors = (errors: GraphQLErrors, path?: string) => {
|
||||
const hasNotFoundGraphQLErrors = (errors: GraphQLErrors, path?: string[]) => {
|
||||
return errors.some(
|
||||
(e) =>
|
||||
e.extensions &&
|
||||
e.extensions['type'] === NOT_FOUND &&
|
||||
(!path || e.path?.[0] === path)
|
||||
(!path || path.every((item, i) => item === e?.path?.[i]))
|
||||
);
|
||||
};
|
||||
|
||||
export const isNotFoundGraphQLError = (
|
||||
error: Error | ApolloError | undefined,
|
||||
path?: string
|
||||
path?: string[]
|
||||
) => {
|
||||
return (
|
||||
isApolloGraphQLError(error) &&
|
||||
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]))
|
||||
);
|
||||
};
|
||||
|
@ -19,11 +19,12 @@ import type {
|
||||
OperationVariables,
|
||||
ApolloQueryResult,
|
||||
QueryOptions,
|
||||
ApolloError,
|
||||
} from '@apollo/client';
|
||||
import { ApolloError } from '@apollo/client';
|
||||
import type { GraphQLErrors } from '@apollo/client/errors';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import type { Subscription, Observable } from 'zen-observable-ts';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
|
||||
type Item = {
|
||||
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(
|
||||
[paginatedSubscribe, subscribe],
|
||||
combineData,
|
||||
@ -537,6 +555,34 @@ describe('data provider', () => {
|
||||
expect(lastCallbackArgs[0].totalCount).toBe(100);
|
||||
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', () => {
|
||||
|
@ -5,7 +5,10 @@ import type {
|
||||
OperationVariables,
|
||||
TypedDocumentNode,
|
||||
FetchResult,
|
||||
ErrorPolicy,
|
||||
ApolloQueryResult,
|
||||
} from '@apollo/client';
|
||||
import type { GraphQLErrors } from '@apollo/client/errors';
|
||||
import type { Subscription } from 'zen-observable-ts';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import { isNotFoundGraphQLError } from './apollo-client';
|
||||
@ -178,6 +181,7 @@ interface DataProviderParams<
|
||||
fetchPolicy?: FetchPolicy;
|
||||
resetDelay?: number;
|
||||
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 getDelta transforms delta data to format that will be stored in data provider
|
||||
* @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
|
||||
*/
|
||||
function makeDataProviderInternal<
|
||||
@ -204,6 +211,7 @@ function makeDataProviderInternal<
|
||||
fetchPolicy,
|
||||
resetDelay,
|
||||
additionalContext,
|
||||
errorPolicyGuard,
|
||||
}: DataProviderParams<
|
||||
QueryData,
|
||||
Data,
|
||||
@ -248,6 +256,30 @@ function makeDataProviderInternal<
|
||||
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) => {
|
||||
if (!pagination) {
|
||||
return Promise.reject();
|
||||
@ -276,15 +308,9 @@ function makeDataProviderInternal<
|
||||
} else if (!pageInfo?.hasNextPage) {
|
||||
return null;
|
||||
}
|
||||
const res = await client.query<QueryData>({
|
||||
query,
|
||||
variables: {
|
||||
...variables,
|
||||
pagination: paginationVariables,
|
||||
},
|
||||
fetchPolicy: fetchPolicy || 'no-cache',
|
||||
context: additionalContext,
|
||||
});
|
||||
|
||||
const res = await call(paginationVariables);
|
||||
|
||||
const insertionData = getData(res.data, variables);
|
||||
const insertionPageInfo = pagination.getPageInfo(res.data);
|
||||
({ data, totalCount } = pagination.append(
|
||||
@ -313,15 +339,11 @@ function makeDataProviderInternal<
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
const paginationVariables = pagination
|
||||
? { first: pagination.first }
|
||||
: undefined;
|
||||
try {
|
||||
const res = await client.query<QueryData>({
|
||||
query,
|
||||
variables: pagination
|
||||
? { ...variables, pagination: { first: pagination.first } }
|
||||
: variables,
|
||||
fetchPolicy: fetchPolicy || 'no-cache',
|
||||
context: additionalContext,
|
||||
});
|
||||
const res = await call(paginationVariables);
|
||||
data = getData(res.data, variables);
|
||||
if (data && pagination) {
|
||||
if (!(data instanceof Array)) {
|
||||
@ -355,7 +377,7 @@ function makeDataProviderInternal<
|
||||
}
|
||||
loaded = true;
|
||||
} catch (e) {
|
||||
if (isNotFoundGraphQLError(e as Error, 'party')) {
|
||||
if (isNotFoundGraphQLError(e as Error, ['party'])) {
|
||||
data = getData(null, variables);
|
||||
loaded = true;
|
||||
return;
|
||||
@ -495,7 +517,7 @@ const memoize = <
|
||||
Delta,
|
||||
Variables extends OperationVariables = OperationVariables
|
||||
>(
|
||||
fn: (variables?: Variables) => Subscribe<Data, Delta, Variables>
|
||||
fn: () => Subscribe<Data, Delta, Variables>
|
||||
) => {
|
||||
const cache: {
|
||||
subscribe: Subscribe<Data, Delta, Variables>;
|
||||
@ -506,7 +528,7 @@ const memoize = <
|
||||
if (cached) {
|
||||
return cached.subscribe;
|
||||
}
|
||||
const subscribe = fn(variables);
|
||||
const subscribe = fn();
|
||||
cache.push({ subscribe, variables });
|
||||
return subscribe;
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user