feat(positions): positions losses and maintenance warnings (#2985)

This commit is contained in:
Matthew Russell 2023-03-06 08:01:31 -08:00 committed by GitHub
parent 56f48671cc
commit f54a629179
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 276 additions and 16 deletions

View File

@ -4,6 +4,8 @@ fragment PositionFields on Position {
unrealisedPNL unrealisedPNL
averageEntryPrice averageEntryPrice
updatedAt updatedAt
positionStatus
lossSocializationAmount
market { market {
id id
} }
@ -30,6 +32,8 @@ subscription PositionsSubscription($partyId: ID!) {
averageEntryPrice averageEntryPrice
updatedAt updatedAt
marketId marketId
lossSocializationAmount
positionStatus
} }
} }

View File

@ -3,21 +3,21 @@ import * as Types from '@vegaprotocol/types';
import { gql } from '@apollo/client'; import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client'; import * as Apollo from '@apollo/client';
const defaultOptions = {} as const; 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<{ export type PositionsQueryVariables = Types.Exact<{
partyId: Types.Scalars['ID']; 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<{ export type PositionsSubscriptionSubscriptionVariables = Types.Exact<{
partyId: Types.Scalars['ID']; 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 } }; 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 unrealisedPNL
averageEntryPrice averageEntryPrice
updatedAt updatedAt
positionStatus
lossSocializationAmount
market { market {
id id
} }
@ -112,6 +114,8 @@ export const PositionsSubscriptionDocument = gql`
averageEntryPrice averageEntryPrice
updatedAt updatedAt
marketId marketId
lossSocializationAmount
positionStatus
} }
} }
`; `;

View File

@ -6,6 +6,7 @@ import type {
MarginFieldsFragment, MarginFieldsFragment,
} from './__generated__/Positions'; } from './__generated__/Positions';
import { getMetrics, rejoinPositionData } from './positions-data-providers'; import { getMetrics, rejoinPositionData } from './positions-data-providers';
import { PositionStatus } from '@vegaprotocol/types';
const accounts = [ const accounts = [
{ {
@ -78,6 +79,8 @@ const positions: PositionFieldsFragment[] = [
__typename: 'Market', __typename: 'Market',
id: '5e6035fe6a6df78c9ec44b333c231e63d357acef0a0620d2c243f5865d1dc0d8', id: '5e6035fe6a6df78c9ec44b333c231e63d357acef0a0620d2c243f5865d1dc0d8',
}, },
lossSocializationAmount: '0',
positionStatus: PositionStatus.POSITION_STATUS_UNSPECIFIED,
}, },
{ {
__typename: 'Position', __typename: 'Position',
@ -90,6 +93,8 @@ const positions: PositionFieldsFragment[] = [
__typename: 'Market', __typename: 'Market',
id: '10c4b1114d2f6fda239b73d018bca55888b6018f0ac70029972a17fea0a6a56e', 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].totalBalance).toEqual('926178496');
expect(metrics[0].unrealisedPNL).toEqual('43804770'); expect(metrics[0].unrealisedPNL).toEqual('43804770');
expect(metrics[0].updatedAt).toEqual('2022-07-28T14:53:54.725477Z'); 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].assetSymbol).toEqual('tDAI');
expect(metrics[1].averageEntryPrice).toEqual('840158'); expect(metrics[1].averageEntryPrice).toEqual('840158');
@ -248,5 +257,9 @@ describe('getMetrics && rejoinPositionData', () => {
expect(metrics[1].totalBalance).toEqual('896098819'); expect(metrics[1].totalBalance).toEqual('896098819');
expect(metrics[1].unrealisedPNL).toEqual('-9112700'); expect(metrics[1].unrealisedPNL).toEqual('-9112700');
expect(metrics[1].updatedAt).toEqual('2022-07-28T15:09:34.441143Z'); 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);
}); });
}); });

View File

@ -24,6 +24,7 @@ import {
PositionsSubscriptionDocument, PositionsSubscriptionDocument,
} from './__generated__/Positions'; } from './__generated__/Positions';
import { marginsDataProvider } from './margin-data-provider'; import { marginsDataProvider } from './margin-data-provider';
import type { PositionStatus } from '@vegaprotocol/types';
type PositionMarginLevel = Pick< type PositionMarginLevel = Pick<
MarginFieldsFragment, MarginFieldsFragment,
@ -38,6 +39,8 @@ interface PositionRejoined {
updatedAt?: string | null; updatedAt?: string | null;
market: MarketMaybeWithData | null; market: MarketMaybeWithData | null;
margins: PositionMarginLevel | null; margins: PositionMarginLevel | null;
lossSocializationAmount: string | null;
status: PositionStatus;
} }
export interface Position { export interface Position {
@ -62,6 +65,8 @@ export interface Position {
unrealisedPNL: string; unrealisedPNL: string;
searchPrice: string | undefined; searchPrice: string | undefined;
updatedAt: string | null; updatedAt: string | null;
lossSocializationAmount: string;
status: PositionStatus;
} }
export interface Data { export interface Data {
@ -178,6 +183,8 @@ export const getMetrics = (
? searchPrice.multipliedBy(10 ** marketDecimalPlaces).toFixed(0) ? searchPrice.multipliedBy(10 ** marketDecimalPlaces).toFixed(0)
: undefined, : undefined,
updatedAt: position.updatedAt || null, updatedAt: position.updatedAt || null,
lossSocializationAmount: position.lossSocializationAmount || '0',
status: position.status,
}); });
}); });
return metrics; return metrics;
@ -201,6 +208,8 @@ export const update = (
openVolume: delta.openVolume, openVolume: delta.openVolume,
averageEntryPrice: delta.averageEntryPrice, averageEntryPrice: delta.averageEntryPrice,
updatedAt: delta.updatedAt, updatedAt: delta.updatedAt,
lossSocializationAmount: delta.lossSocializationAmount,
positionStatus: delta.positionStatus,
}; };
} else { } else {
draft.unshift({ draft.unshift({
@ -267,6 +276,8 @@ export const rejoinPositionData = (
market: market:
marketsData?.find((market) => market.id === node.market.id) || null, marketsData?.find((market) => market.id === node.market.id) || null,
margins: upgradeMarginsConnection(node.market.id, margins), margins: upgradeMarginsConnection(node.market.id, margins),
lossSocializationAmount: node.lossSocializationAmount,
status: node.positionStatus,
}; };
}); });
} }

View File

@ -1,8 +1,11 @@
import type { RenderResult } from '@testing-library/react'; import type { RenderResult } from '@testing-library/react';
import { act, render, screen } from '@testing-library/react'; import { act, render, screen, within } from '@testing-library/react';
import PositionsTable from './positions-table'; import userEvent from '@testing-library/user-event';
import PositionsTable, { OpenVolumeCell, PNLCell } from './positions-table';
import type { Position } from './positions-data-providers'; import type { Position } from './positions-data-providers';
import * as Schema from '@vegaprotocol/types'; import * as Schema from '@vegaprotocol/types';
import { PositionStatus, PositionStatusMapping } from '@vegaprotocol/types';
import type { ICellRendererParams } from 'ag-grid-community';
const singleRow: Position = { const singleRow: Position = {
marketName: 'ETH/BTC (31 july 2022)', marketName: 'ETH/BTC (31 july 2022)',
@ -26,6 +29,8 @@ const singleRow: Position = {
searchPrice: '0', searchPrice: '0',
updatedAt: '2022-07-27T15:02:58.400Z', updatedAt: '2022-07-27T15:02:58.400Z',
marginAccountBalance: '12345600', marginAccountBalance: '12345600',
status: PositionStatus.POSITION_STATUS_UNSPECIFIED,
lossSocializationAmount: '0',
}; };
const singleRowData = [singleRow]; const singleRowData = [singleRow];
@ -219,3 +224,97 @@ it('do not display close button if openVolume is zero', async () => {
const cells = screen.getAllByRole('gridcell'); const cells = screen.getAllByRole('gridcell');
expect(cells[12].textContent).toEqual(''); expect(cells[12].textContent).toEqual('');
}); });
describe('PNLCell', () => {
const props = {
data: undefined,
valueFormatted: '100',
};
it('renders a dash if no data', () => {
render(<PNLCell {...(props as ICellRendererParams)} />);
expect(screen.getByText('-')).toBeInTheDocument();
});
it('renders value if no loss socialisation has occurred', () => {
const props = {
data: {
...singleRow,
lossSocialisationAmount: '0',
},
valueFormatted: '100',
};
render(<PNLCell {...(props as ICellRendererParams)} />);
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(<PNLCell {...(props as ICellRendererParams)} />);
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(<OpenVolumeCell {...(props as ICellRendererParams)} />);
expect(screen.getByText('-')).toBeInTheDocument();
});
it('renders value if no status is normal', () => {
const props = {
data: {
...singleRow,
status: PositionStatus.POSITION_STATUS_UNSPECIFIED,
},
valueFormatted: '100',
};
render(<OpenVolumeCell {...(props as ICellRendererParams)} />);
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(<OpenVolumeCell {...(props as ICellRendererParams)} />);
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();
});
});

View File

@ -1,6 +1,6 @@
import classNames from 'classnames'; import classNames from 'classnames';
import { forwardRef } from 'react'; import { forwardRef } from 'react';
import type { CSSProperties } from 'react'; import type { CSSProperties, ReactNode } from 'react';
import type { CellRendererSelectorResult } from 'ag-grid-community'; import type { CellRendererSelectorResult } from 'ag-grid-community';
import type { import type {
VegaValueFormatterParams, VegaValueFormatterParams,
@ -15,6 +15,15 @@ import {
signedNumberCssClass, signedNumberCssClass,
signedNumberCssClassRules, signedNumberCssClassRules,
} from '@vegaprotocol/datagrid'; } from '@vegaprotocol/datagrid';
import {
ButtonLink,
Tooltip,
TooltipCellComponent,
Link,
ExternalLink,
Icon,
ProgressBarCell,
} from '@vegaprotocol/ui-toolkit';
import { import {
volumePrefix, volumePrefix,
toBigNum, toBigNum,
@ -27,13 +36,9 @@ import { AgGridColumn } from 'ag-grid-react';
import type { AgGridReact } from 'ag-grid-react'; import type { AgGridReact } from 'ag-grid-react';
import type { Position } from './positions-data-providers'; import type { Position } from './positions-data-providers';
import * as Schema from '@vegaprotocol/types'; import * as Schema from '@vegaprotocol/types';
import {
ButtonLink,
Link,
ProgressBarCell,
TooltipCellComponent,
} from '@vegaprotocol/ui-toolkit';
import { getRowId } from './use-positions-data'; import { getRowId } from './use-positions-data';
import { PositionStatus, PositionStatusMapping } from '@vegaprotocol/types';
import { useEnvironment } from '@vegaprotocol/environment';
interface Props extends TypedDataAgGrid<Position> { interface Props extends TypedDataAgGrid<Position> {
onClose?: (data: Position) => void; onClose?: (data: Position) => void;
@ -167,6 +172,7 @@ export const PositionsTable = forwardRef<AgGridReact, Props>(
) )
); );
}} }}
cellRenderer={OpenVolumeCell}
/> />
<AgGridColumn <AgGridColumn
headerName={t('Mark price')} headerName={t('Mark price')}
@ -311,7 +317,6 @@ export const PositionsTable = forwardRef<AgGridReact, Props>(
}} }}
valueFormatter={({ valueFormatter={({
data, data,
node,
}: VegaValueFormatterParams<Position, 'marginAccountBalance'>): }: VegaValueFormatterParams<Position, 'marginAccountBalance'>):
| string | string
| undefined => { | undefined => {
@ -329,7 +334,7 @@ export const PositionsTable = forwardRef<AgGridReact, Props>(
field="realisedPNL" field="realisedPNL"
type="rightAligned" type="rightAligned"
cellClassRules={signedNumberCssClassRules} cellClassRules={signedNumberCssClassRules}
cellClass="text-right font-mono" cellClass="font-mono text-right"
filter="agNumberColumnFilter" filter="agNumberColumnFilter"
valueGetter={({ valueGetter={({
data, data,
@ -348,13 +353,14 @@ export const PositionsTable = forwardRef<AgGridReact, Props>(
headerTooltip={t( 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.' '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}
/> />
<AgGridColumn <AgGridColumn
headerName={t('Unrealised PNL')} headerName={t('Unrealised PNL')}
field="unrealisedPNL" field="unrealisedPNL"
type="rightAligned" type="rightAligned"
cellClassRules={signedNumberCssClassRules} cellClassRules={signedNumberCssClassRules}
cellClass="text-right font-mono" cellClass="font-mono text-right"
filter="agNumberColumnFilter" filter="agNumberColumnFilter"
valueGetter={({ valueGetter={({
data, data,
@ -373,6 +379,7 @@ export const PositionsTable = forwardRef<AgGridReact, Props>(
headerTooltip={t( headerTooltip={t(
'Unrealised profit is the current profit on your open position. Margin is still allocated to your position.' 'Unrealised profit is the current profit on your open position. Margin is still allocated to your position.'
)} )}
cellRenderer={PNLCell}
/> />
<AgGridColumn <AgGridColumn
headerName={t('Updated')} headerName={t('Updated')}
@ -409,3 +416,106 @@ export const PositionsTable = forwardRef<AgGridReact, Props>(
); );
export default PositionsTable; export default PositionsTable;
export const PNLCell = ({
valueFormatted,
data,
}: VegaICellRendererParams<Position, 'realisedPNL'>) => {
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 (
<WarningCell
tooltipContent={
<>
<p className="mb-2">
{t('Lifetime loss socialisation deductions: %s', lossesFormatted)}
</p>
{VEGA_DOCS_URL && (
<ExternalLink href={VEGA_DOCS_URL}>
{t('Read more about loss socialisation')}
</ExternalLink>
)}
</>
}
>
{valueFormatted}
</WarningCell>
);
};
export const OpenVolumeCell = ({
valueFormatted,
data,
}: VegaICellRendererParams<Position, 'openVolume'>) => {
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 (
<WarningCell
tooltipContent={
<>
<p className="mb-2">
{t('Your position was affected by market conditions')}
</p>
<p className="mb-2">
{t(
'Status: %s',
PositionStatusMapping[
PositionStatus.POSITION_STATUS_ORDERS_CLOSED
]
)}
</p>
{VEGA_DOCS_URL && (
<ExternalLink href={VEGA_DOCS_URL}>
{t('Read more about position resolution')}
</ExternalLink>
)}
</>
}
>
{valueFormatted}
</WarningCell>
);
};
const WarningCell = ({
children,
tooltipContent,
}: {
children: ReactNode;
tooltipContent: ReactNode;
}) => {
return (
<Tooltip description={tooltipContent}>
<div className="w-full flex items-center justify-between underline decoration-dashed underline-offest-2">
<span className="text-black dark:text-white mr-1">
<Icon name="warning-sign" size={3} />
</span>
<span className="text-ellipsis overflow-hidden">{children}</span>
</div>
</Tooltip>
);
};

View File

@ -1,3 +1,4 @@
import { PositionStatus } from '@vegaprotocol/types';
import merge from 'lodash/merge'; import merge from 'lodash/merge';
import type { PartialDeep } from 'type-fest'; import type { PartialDeep } from 'type-fest';
import type { import type {
@ -58,6 +59,8 @@ const positionFields: PositionFieldsFragment[] = [
id: 'market-0', id: 'market-0',
__typename: 'Market', __typename: 'Market',
}, },
lossSocializationAmount: '0',
positionStatus: PositionStatus.POSITION_STATUS_UNSPECIFIED,
}, },
{ {
__typename: 'Position', __typename: 'Position',
@ -70,8 +73,11 @@ const positionFields: PositionFieldsFragment[] = [
id: 'market-1', id: 'market-1',
__typename: 'Market', __typename: 'Market',
}, },
lossSocializationAmount: '0',
positionStatus: PositionStatus.POSITION_STATUS_UNSPECIFIED,
}, },
{ {
__typename: 'Position',
realisedPNL: '230000', realisedPNL: '230000',
openVolume: '1', openVolume: '1',
unrealisedPNL: '-22519', unrealisedPNL: '-22519',
@ -81,7 +87,8 @@ const positionFields: PositionFieldsFragment[] = [
id: 'market-2', id: 'market-2',
__typename: 'Market', __typename: 'Market',
}, },
__typename: 'Position', lossSocializationAmount: '0',
positionStatus: PositionStatus.POSITION_STATUS_UNSPECIFIED,
}, },
]; ];

View File

@ -1 +1,4 @@
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import ResizeObserver from 'resize-observer-polyfill';
global.ResizeObserver = ResizeObserver;

View File

@ -12,6 +12,7 @@ import type {
OrderStatus, OrderStatus,
OrderTimeInForce, OrderTimeInForce,
OrderType, OrderType,
PositionStatus,
ProposalRejectionReason, ProposalRejectionReason,
ProposalState, ProposalState,
Side, Side,
@ -445,3 +446,11 @@ export const DispatchMetricLabels: DispatchMetricLabel = {
DISPATCH_METRIC_MAKER_FEES_RECEIVED: 'Price maker fees earned', DISPATCH_METRIC_MAKER_FEES_RECEIVED: 'Price maker fees earned',
DISPATCH_METRIC_MARKET_VALUE: 'Total market Value', 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',
};