diff --git a/libs/positions/src/lib/Positions.graphql b/libs/positions/src/lib/Positions.graphql index d13ade8e0..cc80a0aea 100644 --- a/libs/positions/src/lib/Positions.graphql +++ b/libs/positions/src/lib/Positions.graphql @@ -4,6 +4,8 @@ fragment PositionFields on Position { unrealisedPNL averageEntryPrice updatedAt + positionStatus + lossSocializationAmount market { id } @@ -30,6 +32,8 @@ subscription PositionsSubscription($partyId: ID!) { averageEntryPrice updatedAt marketId + lossSocializationAmount + positionStatus } } diff --git a/libs/positions/src/lib/__generated__/Positions.ts b/libs/positions/src/lib/__generated__/Positions.ts index 5abf64ea6..96a130c3a 100644 --- a/libs/positions/src/lib/__generated__/Positions.ts +++ b/libs/positions/src/lib/__generated__/Positions.ts @@ -3,21 +3,21 @@ import * as Types from '@vegaprotocol/types'; import { gql } from '@apollo/client'; import * as Apollo from '@apollo/client'; const defaultOptions = {} as const; -export type PositionFieldsFragment = { __typename?: 'Position', realisedPNL: string, openVolume: string, unrealisedPNL: string, averageEntryPrice: string, updatedAt?: any | null, market: { __typename?: 'Market', id: string } }; +export type PositionFieldsFragment = { __typename?: 'Position', realisedPNL: string, openVolume: string, unrealisedPNL: string, averageEntryPrice: string, updatedAt?: any | null, positionStatus: Types.PositionStatus, lossSocializationAmount: string, market: { __typename?: 'Market', id: string } }; export type PositionsQueryVariables = Types.Exact<{ partyId: Types.Scalars['ID']; }>; -export type PositionsQuery = { __typename?: 'Query', party?: { __typename?: 'Party', id: string, positionsConnection?: { __typename?: 'PositionConnection', edges?: Array<{ __typename?: 'PositionEdge', node: { __typename?: 'Position', realisedPNL: string, openVolume: string, unrealisedPNL: string, averageEntryPrice: string, updatedAt?: any | null, market: { __typename?: 'Market', id: string } } }> | null } | null } | null }; +export type PositionsQuery = { __typename?: 'Query', party?: { __typename?: 'Party', id: string, positionsConnection?: { __typename?: 'PositionConnection', edges?: Array<{ __typename?: 'PositionEdge', node: { __typename?: 'Position', realisedPNL: string, openVolume: string, unrealisedPNL: string, averageEntryPrice: string, updatedAt?: any | null, positionStatus: Types.PositionStatus, lossSocializationAmount: string, market: { __typename?: 'Market', id: string } } }> | null } | null } | null }; export type PositionsSubscriptionSubscriptionVariables = Types.Exact<{ partyId: Types.Scalars['ID']; }>; -export type PositionsSubscriptionSubscription = { __typename?: 'Subscription', positions: Array<{ __typename?: 'PositionUpdate', realisedPNL: string, openVolume: string, unrealisedPNL: string, averageEntryPrice: string, updatedAt?: any | null, marketId: string }> }; +export type PositionsSubscriptionSubscription = { __typename?: 'Subscription', positions: Array<{ __typename?: 'PositionUpdate', realisedPNL: string, openVolume: string, unrealisedPNL: string, averageEntryPrice: string, updatedAt?: any | null, marketId: string, lossSocializationAmount: string, positionStatus: Types.PositionStatus }> }; export type MarginFieldsFragment = { __typename?: 'MarginLevels', maintenanceLevel: string, searchLevel: string, initialLevel: string, collateralReleaseLevel: string, asset: { __typename?: 'Asset', id: string }, market: { __typename?: 'Market', id: string } }; @@ -42,6 +42,8 @@ export const PositionFieldsFragmentDoc = gql` unrealisedPNL averageEntryPrice updatedAt + positionStatus + lossSocializationAmount market { id } @@ -112,6 +114,8 @@ export const PositionsSubscriptionDocument = gql` averageEntryPrice updatedAt marketId + lossSocializationAmount + positionStatus } } `; diff --git a/libs/positions/src/lib/positions-data-providers.spec.ts b/libs/positions/src/lib/positions-data-providers.spec.ts index 9dbbd80c9..798bb6734 100644 --- a/libs/positions/src/lib/positions-data-providers.spec.ts +++ b/libs/positions/src/lib/positions-data-providers.spec.ts @@ -6,6 +6,7 @@ import type { MarginFieldsFragment, } from './__generated__/Positions'; import { getMetrics, rejoinPositionData } from './positions-data-providers'; +import { PositionStatus } from '@vegaprotocol/types'; const accounts = [ { @@ -78,6 +79,8 @@ const positions: PositionFieldsFragment[] = [ __typename: 'Market', id: '5e6035fe6a6df78c9ec44b333c231e63d357acef0a0620d2c243f5865d1dc0d8', }, + lossSocializationAmount: '0', + positionStatus: PositionStatus.POSITION_STATUS_UNSPECIFIED, }, { __typename: 'Position', @@ -90,6 +93,8 @@ const positions: PositionFieldsFragment[] = [ __typename: 'Market', id: '10c4b1114d2f6fda239b73d018bca55888b6018f0ac70029972a17fea0a6a56e', }, + lossSocializationAmount: '100', + positionStatus: PositionStatus.POSITION_STATUS_ORDERS_CLOSED, }, ]; @@ -225,6 +230,10 @@ describe('getMetrics && rejoinPositionData', () => { expect(metrics[0].totalBalance).toEqual('926178496'); expect(metrics[0].unrealisedPNL).toEqual('43804770'); expect(metrics[0].updatedAt).toEqual('2022-07-28T14:53:54.725477Z'); + expect(metrics[0].lossSocializationAmount).toEqual( + positions[0].lossSocializationAmount + ); + expect(metrics[0].status).toEqual(positions[0].positionStatus); expect(metrics[1].assetSymbol).toEqual('tDAI'); expect(metrics[1].averageEntryPrice).toEqual('840158'); @@ -248,5 +257,9 @@ describe('getMetrics && rejoinPositionData', () => { expect(metrics[1].totalBalance).toEqual('896098819'); expect(metrics[1].unrealisedPNL).toEqual('-9112700'); expect(metrics[1].updatedAt).toEqual('2022-07-28T15:09:34.441143Z'); + expect(metrics[1].lossSocializationAmount).toEqual( + positions[1].lossSocializationAmount + ); + expect(metrics[1].status).toEqual(positions[1].positionStatus); }); }); diff --git a/libs/positions/src/lib/positions-data-providers.ts b/libs/positions/src/lib/positions-data-providers.ts index 902ae62cd..8582e078b 100644 --- a/libs/positions/src/lib/positions-data-providers.ts +++ b/libs/positions/src/lib/positions-data-providers.ts @@ -24,6 +24,7 @@ import { PositionsSubscriptionDocument, } from './__generated__/Positions'; import { marginsDataProvider } from './margin-data-provider'; +import type { PositionStatus } from '@vegaprotocol/types'; type PositionMarginLevel = Pick< MarginFieldsFragment, @@ -38,6 +39,8 @@ interface PositionRejoined { updatedAt?: string | null; market: MarketMaybeWithData | null; margins: PositionMarginLevel | null; + lossSocializationAmount: string | null; + status: PositionStatus; } export interface Position { @@ -62,6 +65,8 @@ export interface Position { unrealisedPNL: string; searchPrice: string | undefined; updatedAt: string | null; + lossSocializationAmount: string; + status: PositionStatus; } export interface Data { @@ -178,6 +183,8 @@ export const getMetrics = ( ? searchPrice.multipliedBy(10 ** marketDecimalPlaces).toFixed(0) : undefined, updatedAt: position.updatedAt || null, + lossSocializationAmount: position.lossSocializationAmount || '0', + status: position.status, }); }); return metrics; @@ -201,6 +208,8 @@ export const update = ( openVolume: delta.openVolume, averageEntryPrice: delta.averageEntryPrice, updatedAt: delta.updatedAt, + lossSocializationAmount: delta.lossSocializationAmount, + positionStatus: delta.positionStatus, }; } else { draft.unshift({ @@ -267,6 +276,8 @@ export const rejoinPositionData = ( market: marketsData?.find((market) => market.id === node.market.id) || null, margins: upgradeMarginsConnection(node.market.id, margins), + lossSocializationAmount: node.lossSocializationAmount, + status: node.positionStatus, }; }); } diff --git a/libs/positions/src/lib/positions-table.spec.tsx b/libs/positions/src/lib/positions-table.spec.tsx index f4f093bc6..2dde68a29 100644 --- a/libs/positions/src/lib/positions-table.spec.tsx +++ b/libs/positions/src/lib/positions-table.spec.tsx @@ -1,8 +1,11 @@ import type { RenderResult } from '@testing-library/react'; -import { act, render, screen } from '@testing-library/react'; -import PositionsTable from './positions-table'; +import { act, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import PositionsTable, { OpenVolumeCell, PNLCell } from './positions-table'; import type { Position } from './positions-data-providers'; import * as Schema from '@vegaprotocol/types'; +import { PositionStatus, PositionStatusMapping } from '@vegaprotocol/types'; +import type { ICellRendererParams } from 'ag-grid-community'; const singleRow: Position = { marketName: 'ETH/BTC (31 july 2022)', @@ -26,6 +29,8 @@ const singleRow: Position = { searchPrice: '0', updatedAt: '2022-07-27T15:02:58.400Z', marginAccountBalance: '12345600', + status: PositionStatus.POSITION_STATUS_UNSPECIFIED, + lossSocializationAmount: '0', }; const singleRowData = [singleRow]; @@ -219,3 +224,97 @@ it('do not display close button if openVolume is zero', async () => { const cells = screen.getAllByRole('gridcell'); expect(cells[12].textContent).toEqual(''); }); + +describe('PNLCell', () => { + const props = { + data: undefined, + valueFormatted: '100', + }; + it('renders a dash if no data', () => { + render(); + expect(screen.getByText('-')).toBeInTheDocument(); + }); + + it('renders value if no loss socialisation has occurred', () => { + const props = { + data: { + ...singleRow, + lossSocialisationAmount: '0', + }, + valueFormatted: '100', + }; + render(); + expect(screen.getByText(props.valueFormatted)).toBeInTheDocument(); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + }); + + it('renders value with warning tooltip if loss socialisation occurred', async () => { + const props = { + data: { + ...singleRow, + lossSocializationAmount: '500', + decimals: 2, + }, + valueFormatted: '100', + }; + render(); + const content = screen.getByText(props.valueFormatted); + expect(content).toBeInTheDocument(); + expect(screen.getByRole('img')).toBeInTheDocument(); + + await userEvent.hover(content); + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toBeInTheDocument(); + expect( + // using within as radix renders tooltip content twice + within(tooltip).getByText('Lifetime loss socialisation deductions: 5.00') + ).toBeInTheDocument(); + }); +}); + +describe('OpenVolumeCell', () => { + const props = { + data: undefined, + valueFormatted: '100', + }; + it('renders a dash if no data', () => { + render(); + expect(screen.getByText('-')).toBeInTheDocument(); + }); + + it('renders value if no status is normal', () => { + const props = { + data: { + ...singleRow, + status: PositionStatus.POSITION_STATUS_UNSPECIFIED, + }, + valueFormatted: '100', + }; + render(); + expect(screen.getByText(props.valueFormatted)).toBeInTheDocument(); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + }); + + it('renders status with warning tooltip if not normal', async () => { + const props = { + data: { + ...singleRow, + status: PositionStatus.POSITION_STATUS_ORDERS_CLOSED, + }, + valueFormatted: '100', + }; + render(); + const content = screen.getByText(props.valueFormatted); + expect(content).toBeInTheDocument(); + expect(screen.getByRole('img')).toBeInTheDocument(); + await userEvent.hover(content); + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toBeInTheDocument(); + expect( + // using within as radix renders tooltip content twice + within(tooltip).getByText( + `Status: ${PositionStatusMapping[props.data.status]}` + ) + ).toBeInTheDocument(); + }); +}); diff --git a/libs/positions/src/lib/positions-table.tsx b/libs/positions/src/lib/positions-table.tsx index 0acec30bd..58a808cfe 100644 --- a/libs/positions/src/lib/positions-table.tsx +++ b/libs/positions/src/lib/positions-table.tsx @@ -1,6 +1,6 @@ import classNames from 'classnames'; import { forwardRef } from 'react'; -import type { CSSProperties } from 'react'; +import type { CSSProperties, ReactNode } from 'react'; import type { CellRendererSelectorResult } from 'ag-grid-community'; import type { VegaValueFormatterParams, @@ -15,6 +15,15 @@ import { signedNumberCssClass, signedNumberCssClassRules, } from '@vegaprotocol/datagrid'; +import { + ButtonLink, + Tooltip, + TooltipCellComponent, + Link, + ExternalLink, + Icon, + ProgressBarCell, +} from '@vegaprotocol/ui-toolkit'; import { volumePrefix, toBigNum, @@ -27,13 +36,9 @@ import { AgGridColumn } from 'ag-grid-react'; import type { AgGridReact } from 'ag-grid-react'; import type { Position } from './positions-data-providers'; import * as Schema from '@vegaprotocol/types'; -import { - ButtonLink, - Link, - ProgressBarCell, - TooltipCellComponent, -} from '@vegaprotocol/ui-toolkit'; import { getRowId } from './use-positions-data'; +import { PositionStatus, PositionStatusMapping } from '@vegaprotocol/types'; +import { useEnvironment } from '@vegaprotocol/environment'; interface Props extends TypedDataAgGrid { onClose?: (data: Position) => void; @@ -167,6 +172,7 @@ export const PositionsTable = forwardRef( ) ); }} + cellRenderer={OpenVolumeCell} /> ( }} valueFormatter={({ data, - node, }: VegaValueFormatterParams): | string | undefined => { @@ -329,7 +334,7 @@ export const PositionsTable = forwardRef( field="realisedPNL" type="rightAligned" cellClassRules={signedNumberCssClassRules} - cellClass="text-right font-mono" + cellClass="font-mono text-right" filter="agNumberColumnFilter" valueGetter={({ data, @@ -348,13 +353,14 @@ export const PositionsTable = forwardRef( headerTooltip={t( 'Profit or loss is realised whenever your position is reduced to zero and the margin is released back to your collateral balance. P&L excludes any fees paid.' )} + cellRenderer={PNLCell} /> ( headerTooltip={t( 'Unrealised profit is the current profit on your open position. Margin is still allocated to your position.' )} + cellRenderer={PNLCell} /> ( ); export default PositionsTable; + +export const PNLCell = ({ + valueFormatted, + data, +}: VegaICellRendererParams) => { + const { VEGA_DOCS_URL } = useEnvironment(); + + if (!data) { + return <>-; + } + + const losses = parseInt(data?.lossSocializationAmount ?? '0'); + if (losses <= 0) { + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>{valueFormatted}; + } + + const lossesFormatted = addDecimalsFormatNumber( + data.lossSocializationAmount, + data.decimals + ); + + return ( + +

+ {t('Lifetime loss socialisation deductions: %s', lossesFormatted)} +

+ {VEGA_DOCS_URL && ( + + {t('Read more about loss socialisation')} + + )} + + } + > + {valueFormatted} +
+ ); +}; + +export const OpenVolumeCell = ({ + valueFormatted, + data, +}: VegaICellRendererParams) => { + const { VEGA_DOCS_URL } = useEnvironment(); + + if (!data) { + return <>-; + } + + if (data.status === PositionStatus.POSITION_STATUS_UNSPECIFIED) { + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>{valueFormatted}; + } + + return ( + +

+ {t('Your position was affected by market conditions')} +

+

+ {t( + 'Status: %s', + PositionStatusMapping[ + PositionStatus.POSITION_STATUS_ORDERS_CLOSED + ] + )} +

+ {VEGA_DOCS_URL && ( + + {t('Read more about position resolution')} + + )} + + } + > + {valueFormatted} +
+ ); +}; + +const WarningCell = ({ + children, + tooltipContent, +}: { + children: ReactNode; + tooltipContent: ReactNode; +}) => { + return ( + +
+ + + + {children} +
+
+ ); +}; diff --git a/libs/positions/src/lib/positions.mock.ts b/libs/positions/src/lib/positions.mock.ts index 87735f88b..95c24bdfa 100644 --- a/libs/positions/src/lib/positions.mock.ts +++ b/libs/positions/src/lib/positions.mock.ts @@ -1,3 +1,4 @@ +import { PositionStatus } from '@vegaprotocol/types'; import merge from 'lodash/merge'; import type { PartialDeep } from 'type-fest'; import type { @@ -58,6 +59,8 @@ const positionFields: PositionFieldsFragment[] = [ id: 'market-0', __typename: 'Market', }, + lossSocializationAmount: '0', + positionStatus: PositionStatus.POSITION_STATUS_UNSPECIFIED, }, { __typename: 'Position', @@ -70,8 +73,11 @@ const positionFields: PositionFieldsFragment[] = [ id: 'market-1', __typename: 'Market', }, + lossSocializationAmount: '0', + positionStatus: PositionStatus.POSITION_STATUS_UNSPECIFIED, }, { + __typename: 'Position', realisedPNL: '230000', openVolume: '1', unrealisedPNL: '-22519', @@ -81,7 +87,8 @@ const positionFields: PositionFieldsFragment[] = [ id: 'market-2', __typename: 'Market', }, - __typename: 'Position', + lossSocializationAmount: '0', + positionStatus: PositionStatus.POSITION_STATUS_UNSPECIFIED, }, ]; diff --git a/libs/positions/src/setup-tests.ts b/libs/positions/src/setup-tests.ts index 7b0828bfa..68773380a 100644 --- a/libs/positions/src/setup-tests.ts +++ b/libs/positions/src/setup-tests.ts @@ -1 +1,4 @@ import '@testing-library/jest-dom'; +import ResizeObserver from 'resize-observer-polyfill'; + +global.ResizeObserver = ResizeObserver; diff --git a/libs/types/src/global-types-mappings.ts b/libs/types/src/global-types-mappings.ts index 09bd6ced9..ddd07cb61 100644 --- a/libs/types/src/global-types-mappings.ts +++ b/libs/types/src/global-types-mappings.ts @@ -12,6 +12,7 @@ import type { OrderStatus, OrderTimeInForce, OrderType, + PositionStatus, ProposalRejectionReason, ProposalState, Side, @@ -445,3 +446,11 @@ export const DispatchMetricLabels: DispatchMetricLabel = { DISPATCH_METRIC_MAKER_FEES_RECEIVED: 'Price maker fees earned', DISPATCH_METRIC_MARKET_VALUE: 'Total market Value', }; + +export const PositionStatusMapping: { + [T in PositionStatus]: string; +} = { + POSITION_STATUS_CLOSED_OUT: 'Closed by network', + POSITION_STATUS_ORDERS_CLOSED: 'Maintained by network', + POSITION_STATUS_UNSPECIFIED: 'Normal', +};