From 611f8d74917b4d2c2e1fefa2b2bf27459309ca27 Mon Sep 17 00:00:00 2001 From: Matthew Russell Date: Thu, 17 Aug 2023 11:07:48 +0100 Subject: [PATCH 1/2] feat(positions): table layout changes (#4558) --- .../src/integration/trading-positions.cy.ts | 372 ----------- .../market-selector/market-selector-item.tsx | 10 +- .../src/lib/cells/market-name-cell.tsx | 33 +- libs/positions/src/lib/liquidation-price.tsx | 83 +-- .../src/lib/positions-data-providers.spec.ts | 8 +- .../src/lib/positions-data-providers.ts | 38 +- libs/positions/src/lib/positions-manager.tsx | 2 +- .../src/lib/positions-table.spec.tsx | 360 +++++------ libs/positions/src/lib/positions-table.tsx | 612 +++++++++--------- libs/positions/src/lib/stacked-cell.tsx | 18 + .../proposals-list/use-column-defs.tsx | 25 +- .../src/utils/get-market-product-type.spec.ts | 58 -- .../src/utils/get-market-product-type.ts | 16 - libs/types/src/global-types-mappings.ts | 18 + libs/types/src/index.ts | 1 + libs/types/src/product.ts | 7 + .../src/components/tooltip/tooltip.tsx | 6 +- libs/utils/src/lib/format/number.spec.ts | 2 + 18 files changed, 635 insertions(+), 1034 deletions(-) delete mode 100644 apps/trading-e2e/src/integration/trading-positions.cy.ts create mode 100644 libs/positions/src/lib/stacked-cell.tsx delete mode 100644 libs/proposals/src/utils/get-market-product-type.spec.ts delete mode 100644 libs/proposals/src/utils/get-market-product-type.ts create mode 100644 libs/types/src/product.ts diff --git a/apps/trading-e2e/src/integration/trading-positions.cy.ts b/apps/trading-e2e/src/integration/trading-positions.cy.ts deleted file mode 100644 index 3ab6b46d3..000000000 --- a/apps/trading-e2e/src/integration/trading-positions.cy.ts +++ /dev/null @@ -1,372 +0,0 @@ -import { checkSorting, aliasGQLQuery } from '@vegaprotocol/cypress'; -import { marketsDataQuery } from '@vegaprotocol/mock'; -import { positionsQuery } from '@vegaprotocol/mock'; - -// #region consts -const closePosition = 'close-position'; -const dialogCloseX = 'dialog-close'; -const dialogContent = 'dialog-content'; -const dropDownMenu = 'dropdown-menu'; -const marketActionsContent = 'position-actions-content'; -const positions = 'Positions'; -const tabPositions = 'tab-positions'; -const toastContent = 'toast-content'; -const tooltipContent = 'tooltip-content'; -// #endregion - -describe('positions', { tags: '@smoke', testIsolation: true }, () => { - beforeEach(() => { - cy.mockTradingPage(); - cy.mockSubscription(); - cy.setVegaWallet(); - }); - it('renders positions on trading page', () => { - visitAndClickPositions(); - // 7004-POSI-001 - // 7004-POSI-002 - validatePositionsDisplayed(); - }); - - // TODO: move this to sim, its flakey - it.skip('renders positions on portfolio page', () => { - cy.mockGQL((req) => { - const positions = positionsQuery(); - if (positions.positions?.edges) { - positions.positions.edges.push( - ...positions.positions.edges.map((edge) => ({ - ...edge, - node: { - ...edge.node, - party: { - ...edge.node.party, - id: 'vega-1', - }, - }, - })) - ); - } - aliasGQLQuery(req, 'Positions', positions); - }); - visitAndClickPositions(); - // 7004-POSI-001 - // 7004-POSI-002 - validatePositionsDisplayed(true); - }); - - it('Close my position', () => { - visitAndClickPositions(); - cy.getByTestId(closePosition).first().click(); - // 7004-POSI-010 - cy.getByTestId(toastContent).should( - 'contain.text', - 'Awaiting confirmation' - ); - }); -}); - -describe('positions', { tags: '@regression', testIsolation: true }, () => { - beforeEach(() => { - cy.mockTradingPage(); - cy.mockSubscription(); - cy.setVegaWallet(); - }); - - it('rows should be displayed despite errors', () => { - const errors = [ - { - message: - 'no market data for market: 9c55fb644c6f7de5422d40d691a62bffd5898384c70135bab29ba1e3e2e5280a', - path: ['marketsConnection', 'edges'], - 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', - 'currentLeverage', - 'averageEntryPrice', - ]; - cy.getByTestId(tabPositions) - .first() - .within(() => { - cy.get( - '[row-id="02eceaba4df2bef76ea10caf728d8a099a2aa846cced25737cccaa9812342f65-market-2"]' - ) - .eq(0) - .within(() => { - emptyCells.forEach((cell) => { - cy.get(`[col-id="${cell}"]`).should('contain.text', '-'); - }); - }); - }); - }); - - it('error message should be displayed', () => { - const errors = [ - { - message: - 'no market data for asset: 9c55fb644c6f7de5422d40d691a62bffd5898384c70135bab29ba1e3e2e5280a', - path: ['assets', 'edges'], - extensions: { - code: 13, - type: 'Internal', - }, - }, - ]; - const overrides = { - marketsConnection: { edges: [] }, - }; - cy.mockGQL((req) => { - aliasGQLQuery(req, 'MarketsData', overrides, errors); - }); - cy.visit('/#/markets/market-0'); - cy.getByTestId(tabPositions).contains('no market data'); - }); - - it('sorting by Market', () => { - visitAndClickPositions(); - const marketsSortedDefault = [ - 'AAPL.MF21', - 'BTCUSD.MF21', - 'ETHBTC.QM21', - 'SOLUSD', - ]; - const marketsSortedAsc = [ - 'AAPL.MF21', - 'BTCUSD.MF21', - 'ETHBTC.QM21', - 'SOLUSD', - ]; - const marketsSortedDesc = [ - 'SOLUSD', - 'ETHBTC.QM21', - 'BTCUSD.MF21', - 'AAPL.MF21', - ]; - cy.getByTestId(positions).click(); - // 7004-POSI-003 - checkSorting( - 'marketName', - marketsSortedDefault, - marketsSortedAsc, - marketsSortedDesc, - ' [data-testid="market-code"]' - ); - }); - - // let elementWidth: number; - - it('Resize column', () => { - visitAndClickPositions(); - cy.get('.ag-overlay-loading-wrapper').should('not.be.visible'); - cy.get('.ag-header-container').within(() => { - cy.get(`[col-id="marketName"]`) - .find('.ag-header-cell-resize') - .realMouseDown() - .realMouseMove(250, 0) - .realMouseUp(); - }); - - // 7004-POSI-006 - cy.get(`[col-id="marketName"]`) - .invoke('width') - .should('be.greaterThan', 250); - }); - - // This test depends on the previous one - it('Has persisted column widths', () => { - const width = 400; - - cy.window().then((win) => { - win.localStorage.setItem( - 'vega_positions_store', - JSON.stringify({ - state: { - gridStore: { - columnState: [{ colId: 'marketName', width }], - }, - }, - }) - ); - }); - - visitAndClickPositions(); - - // 7004-POSI-012 - cy.get('.ag-center-cols-container .ag-row') - .first() - .find('[col-id="marketName"]') - .invoke('outerWidth') - .should('equal', width); - }); - - it('Scroll horizontally', () => { - visitAndClickPositions(); - - cy.get('.ag-header-container').within(() => { - cy.get(`[col-id="marketName"]`) - .find('.ag-header-cell-resize') - .realMouseDown() - .realMouseMove(400, 0) - .realMouseUp(); - }); - cy.get('[col-id="marketName"]').should('be.visible'); - cy.get('.ag-body-horizontal-scroll-viewport').realMouseWheel({ - deltaX: 500, - }); - // 7004-POSI-004 - cy.get('[col-id="unrealisedPNL"]').should('be.visible'); - }); - - it('Drag and drop columns', () => { - visitAndClickPositions(); - cy.get('.ag-overlay-loading-wrapper').should('not.be.visible'); - cy.get('[col-id="marketName"]') - .realMouseDown() - .realMouseMove(700, 15) - .realMouseUp(); - - // 7004-POSI-005 - cy.get('[col-id="marketName"]').should(($element) => { - const attributeValue = $element.attr('aria-colindex'); - expect(attributeValue).not.to.equal('1'); - }); - }); - - it('I can see warnings', () => { - visitAndClickPositions(); - - cy.get('[col-id="openVolume"]') - .eq(3) - .within(() => { - cy.get('[aria-label="warning-sign icon"]') - .should('be.visible') - .realHover(); - }); - // 7004-POSI-011 - cy.getByTestId(tooltipContent).should('be.visible'); - }); - - it('Positive and Negative color change', () => { - cy.visit('/#/markets/market-0'); - cy.getByTestId(positions).click(); - // 7004-POSI-007 - cy.get('.ag-center-cols-container').within(() => { - assertPNLColor( - '[col-id="realisedPNL"]', - 'text-market-green-600', - 'text-market-red' - ); - }); - cy.get('.ag-center-cols-container').within(() => { - assertPNLColor( - '[col-id="unrealisedPNL"]', - 'text-market-green-600', - 'text-market-red' - ); - }); - cy.get('.ag-center-cols-container').within(() => { - assertPNLColor( - '[col-id="openVolume"]', - 'text-market-green-600', - 'text-market-red' - ); - }); - }); - - it('View settlement asset', () => { - visitAndClickPositions(); - cy.get('[col-id="asset"]') - .eq(3) - .within(() => { - cy.get('button[type="button"]').click(); - }); - // 7004-POSI-008 - cy.getByTestId(dialogContent).should('be.visible'); - cy.getByTestId(dialogCloseX).click(); - cy.getByTestId(dropDownMenu).first().click(); - cy.getByTestId(marketActionsContent).click(); - // 7004-POSI-009 - cy.getByTestId(dialogContent).should('be.visible'); - }); -}); - -function validatePositionsDisplayed(multiKey = false) { - cy.getByTestId('tab-positions').should('be.visible'); - cy.getByTestId('tab-positions') - .get('.ag-center-cols-container .ag-row') - .eq(multiKey ? 3 : 1) - .within(() => { - cy.get('[col-id="marketName"]') - .should('be.visible') - .invoke('text') - .should('not.be.empty'); - - cy.get('[col-id="openVolume"]').should('not.be.empty'); - - // includes average entry price, mark price, realised PNL & leverage - cy.getByTestId('flash-cell').should('not.be.empty'); - - if (!multiKey) { - cy.get('[col-id="currentLeverage"]').should('contain.text', '2,767.3'); - cy.get('[col-id="marginAccountBalance"]') // margin allocated - .should('contain.text', '0.01'); - } - - cy.get('[col-id="unrealisedPNL"]').should('not.be.empty'); - cy.get('[col-id="notional"]').should('contain.text', '276,761.40348'); // Total tDAI position - cy.get('[col-id="realisedPNL"]').should('contain.text', '2.30'); // Total Realised PNL - cy.get('[col-id="unrealisedPNL"]').should('contain.text', '8.95'); // Total Unrealised PNL - }); - - cy.get('.ag-header-row [col-id="notional"]') - .should('contain.text', 'Notional') - .realHover(); - cy.get('.ag-popup').should('contain.text', 'Mark price x open volume'); - - cy.getByTestId('close-position').should('be.visible').and('have.length', 3); -} - -function assertPNLColor( - pnlSelector: string, - positiveClass: string, - negativeClass: string -) { - cy.get(pnlSelector).each(($el) => { - const value = parseFloat($el.text()); - - if (value > 0) { - cy.wrap($el).invoke('attr', 'class').should('contain', positiveClass); - } else if (value < 0) { - cy.wrap($el).invoke('attr', 'class').should('contain', negativeClass); - } else if (value == 0) { - cy.wrap($el) - .invoke('attr', 'class') - .should('not.contain', negativeClass, positiveClass); - } else { - throw new Error('Unexpected value'); - } - }); -} - -function visitAndClickPositions() { - cy.visit('/#/markets/market-0'); - cy.getByTestId(positions).click(); -} diff --git a/apps/trading/components/market-selector/market-selector-item.tsx b/apps/trading/components/market-selector/market-selector-item.tsx index 8d5479024..b60ef54af 100644 --- a/apps/trading/components/market-selector/market-selector-item.tsx +++ b/apps/trading/components/market-selector/market-selector-item.tsx @@ -89,17 +89,15 @@ const MarketData = ({ ? addDecimalsFormatNumber(vol, market.positionDecimalPlaces) : '0.00'; + const productType = market.tradableInstrument.instrument.product.__typename; + return ( <>

{market.tradableInstrument.instrument.code}{' '} - {allProducts && ( - + {allProducts && productType && ( + )}

{mode && ( diff --git a/libs/datagrid/src/lib/cells/market-name-cell.tsx b/libs/datagrid/src/lib/cells/market-name-cell.tsx index 3ccc37167..d1df34e5d 100644 --- a/libs/datagrid/src/lib/cells/market-name-cell.tsx +++ b/libs/datagrid/src/lib/cells/market-name-cell.tsx @@ -2,28 +2,27 @@ import type { MouseEvent } from 'react'; import { useCallback } from 'react'; import get from 'lodash/get'; import { Pill } from '@vegaprotocol/ui-toolkit'; -import type { Market } from '@vegaprotocol/types'; - -const productTypeMap = { - Future: 'Futr', - FutureProduct: 'Futr', - Spot: 'Spot', - SpotProduct: 'Spot', - Perpetual: 'Perp', - PerpetualProduct: 'Perp', -} as const; -export type ProductType = keyof typeof productTypeMap | undefined; +import { + ProductTypeShortName, + type Market, + type ProductType, + ProductTypeMapping, +} from '@vegaprotocol/types'; export const MarketProductPill = ({ productType, }: { - productType?: ProductType; + productType: ProductType; }) => { - return productType ? ( - - {productTypeMap[productType] || productType} + return ( + + {ProductTypeShortName[productType]} - ) : null; + ); }; interface MarketNameCellProps { @@ -66,7 +65,7 @@ export const MarketNameCell = ({ {value} - + {productType && } ); return onMarketClick && id ? ( diff --git a/libs/positions/src/lib/liquidation-price.tsx b/libs/positions/src/lib/liquidation-price.tsx index ae08e806b..f5afd0ccb 100644 --- a/libs/positions/src/lib/liquidation-price.tsx +++ b/libs/positions/src/lib/liquidation-price.tsx @@ -1,18 +1,18 @@ +import { Tooltip } from '@vegaprotocol/ui-toolkit'; import { useEstimatePositionQuery } from './__generated__/Positions'; -import { formatRange } from '@vegaprotocol/utils'; +import { addDecimalsFormatNumber } from '@vegaprotocol/utils'; +import { t } from '@vegaprotocol/i18n'; export const LiquidationPrice = ({ marketId, openVolume, collateralAvailable, - decimalPlaces, - formatDecimals, + marketDecimalPlaces, }: { marketId: string; openVolume: string; collateralAvailable: string; - decimalPlaces: number; - formatDecimals: number; + marketDecimalPlaces: number; }) => { const { data: currentData, previousData } = useEstimatePositionQuery({ variables: { @@ -23,38 +23,47 @@ export const LiquidationPrice = ({ fetchPolicy: 'no-cache', skip: !openVolume || openVolume === '0', }); - const data = currentData || previousData; - let value = '-'; - if (data) { - const bestCase = - data.estimatePosition?.liquidation?.bestCase.open_volume_only.replace( - /\..*/, - '' - ); - const worstCase = - data.estimatePosition?.liquidation?.worstCase.open_volume_only.replace( - /\..*/, - '' - ); - value = - bestCase && worstCase && BigInt(bestCase) < BigInt(worstCase) - ? formatRange( - bestCase, - worstCase, - decimalPlaces, - undefined, - formatDecimals, - value - ) - : formatRange( - worstCase, - bestCase, - decimalPlaces, - undefined, - formatDecimals, - value - ); + const data = currentData || previousData; + + if (!data?.estimatePosition?.liquidation) { + return -; } - return {value}; + + let bestCase = '-'; + let worstCase = '-'; + + bestCase = + data.estimatePosition?.liquidation?.bestCase.open_volume_only.replace( + /\..*/, + '' + ); + worstCase = + data.estimatePosition?.liquidation?.worstCase.open_volume_only.replace( + /\..*/, + '' + ); + worstCase = addDecimalsFormatNumber(worstCase, marketDecimalPlaces); + bestCase = addDecimalsFormatNumber(bestCase, marketDecimalPlaces); + + return ( + + + + {t('Worst case')} + {worstCase} + + + {t('Best case')} + {bestCase} + + + + } + > + {worstCase} + + ); }; diff --git a/libs/positions/src/lib/positions-data-providers.spec.ts b/libs/positions/src/lib/positions-data-providers.spec.ts index bb7180d11..a8ca0aaa9 100644 --- a/libs/positions/src/lib/positions-data-providers.spec.ts +++ b/libs/positions/src/lib/positions-data-providers.spec.ts @@ -180,12 +180,12 @@ describe('getMetrics && rejoinPositionData', () => { expect(metrics[0].currentLeverage).toBeCloseTo(1.02); expect(metrics[0].marketDecimalPlaces).toEqual(5); expect(metrics[0].positionDecimalPlaces).toEqual(0); - expect(metrics[0].decimals).toEqual(5); + expect(metrics[0].assetDecimals).toEqual(5); expect(metrics[0].markPrice).toEqual('9431775'); expect(metrics[0].marketId).toEqual( '5e6035fe6a6df78c9ec44b333c231e63d357acef0a0620d2c243f5865d1dc0d8' ); - expect(metrics[0].marketName).toEqual('AAVEDAI.MF21'); + expect(metrics[0].marketCode).toEqual('AAVEDAI.MF21'); expect(metrics[0].marketTradingMode).toEqual( 'TRADING_MODE_MONITORING_AUCTION' ); @@ -205,12 +205,12 @@ describe('getMetrics && rejoinPositionData', () => { expect(metrics[1].currentLeverage).toBeCloseTo(0.097); expect(metrics[1].marketDecimalPlaces).toEqual(5); expect(metrics[1].positionDecimalPlaces).toEqual(0); - expect(metrics[1].decimals).toEqual(5); + expect(metrics[1].assetDecimals).toEqual(5); expect(metrics[1].markPrice).toEqual('869762'); expect(metrics[1].marketId).toEqual( '10c4b1114d2f6fda239b73d018bca55888b6018f0ac70029972a17fea0a6a56e' ); - expect(metrics[1].marketName).toEqual('UNIDAI.MF21'); + expect(metrics[1].marketCode).toEqual('UNIDAI.MF21'); expect(metrics[1].marketTradingMode).toEqual('TRADING_MODE_CONTINUOUS'); expect(metrics[1].notional).toEqual('86976200'); expect(metrics[1].openVolume).toEqual('-100'); diff --git a/libs/positions/src/lib/positions-data-providers.ts b/libs/positions/src/lib/positions-data-providers.ts index 572a551fd..8d44c0941 100644 --- a/libs/positions/src/lib/positions-data-providers.ts +++ b/libs/positions/src/lib/positions-data-providers.ts @@ -26,20 +26,20 @@ import { PositionsDocument, PositionsSubscriptionDocument, } from './__generated__/Positions'; -import type { PositionStatus } from '@vegaprotocol/types'; +import type { PositionStatus, ProductType } from '@vegaprotocol/types'; export interface Position { assetId: string; assetSymbol: string; averageEntryPrice: string; currentLeverage: number | undefined; - decimals: number; + assetDecimals: number; quantum: string; lossSocializationAmount: string; marginAccountBalance: string; marketDecimalPlaces: number; marketId: string; - marketName: string; + marketCode: string; marketTradingMode: Schema.MarketTradingMode; markPrice: string | undefined; notional: string | undefined; @@ -51,7 +51,7 @@ export interface Position { totalBalance: string; unrealisedPNL: string; updatedAt: string | null; - productType?: string; + productType: ProductType; } export const getMetrics = ( @@ -71,15 +71,10 @@ export const getMetrics = ( const marginAccount = accounts?.find((account) => { return account.market?.id === market?.id; }); - const { - decimals, - id: assetId, - symbol: assetSymbol, - quantum, - } = market.tradableInstrument.instrument.product.settlementAsset; + const asset = market.tradableInstrument.instrument.product.settlementAsset; const generalAccount = accounts?.find( (account) => - account.asset.id === assetId && + account.asset.id === asset.id && account.type === Schema.AccountType.ACCOUNT_TYPE_GENERAL ); @@ -89,11 +84,11 @@ export const getMetrics = ( const marginAccountBalance = toBigNum( marginAccount?.balance ?? 0, - decimals + asset.decimals ); const generalAccountBalance = toBigNum( generalAccount?.balance ?? 0, - decimals + asset.decimals ); const markPrice = marketData @@ -112,17 +107,17 @@ export const getMetrics = ( : notional.dividedBy(totalBalance) : undefined; metrics.push({ - assetId, - assetSymbol, + assetId: asset.id, + assetSymbol: asset.symbol, averageEntryPrice: position.averageEntryPrice, currentLeverage: currentLeverage ? currentLeverage.toNumber() : undefined, - decimals, - quantum, + assetDecimals: asset.decimals, + quantum: asset.quantum, lossSocializationAmount: position.lossSocializationAmount || '0', marginAccountBalance: marginAccount?.balance ?? '0', marketDecimalPlaces, marketId: market.id, - marketName: market.tradableInstrument.instrument.code, + marketCode: market.tradableInstrument.instrument.code, marketTradingMode: market.tradingMode, markPrice: marketData ? marketData.markPrice : undefined, notional: notional @@ -133,10 +128,11 @@ export const getMetrics = ( positionDecimalPlaces, realisedPNL: position.realisedPNL, status: position.positionStatus, - totalBalance: totalBalance.multipliedBy(10 ** decimals).toFixed(), + totalBalance: totalBalance.multipliedBy(10 ** asset.decimals).toFixed(), unrealisedPNL: position.unrealisedPNL, updatedAt: position.updatedAt || null, - productType: market?.tradableInstrument.instrument.product.__typename, + productType: market?.tradableInstrument.instrument.product + .__typename as ProductType, }); }); return metrics; @@ -288,7 +284,7 @@ export const positionsMetricsProvider = makeDerivedDataProvider< ([positions, accounts, marketsData]) => { const positionsData = rejoinPositionData(positions, marketsData); const metrics = getMetrics(positionsData, accounts as Account[] | null); - return sortBy(metrics, 'marketName'); + return sortBy(metrics, 'marketCode'); }, (data, delta, previousData) => data.filter((row) => { diff --git a/libs/positions/src/lib/positions-manager.tsx b/libs/positions/src/lib/positions-manager.tsx index 45c17f173..f01f71cfa 100644 --- a/libs/positions/src/lib/positions-manager.tsx +++ b/libs/positions/src/lib/positions-manager.tsx @@ -15,7 +15,7 @@ interface PositionsManagerProps { partyIds: string[]; onMarketClick?: (marketId: string) => void; isReadOnly: boolean; - gridProps: ReturnType; + gridProps?: ReturnType; } export const PositionsManager = ({ diff --git a/libs/positions/src/lib/positions-table.spec.tsx b/libs/positions/src/lib/positions-table.spec.tsx index 4665cd002..be4532bfe 100644 --- a/libs/positions/src/lib/positions-table.spec.tsx +++ b/libs/positions/src/lib/positions-table.spec.tsx @@ -1,10 +1,9 @@ -import type { RenderResult } from '@testing-library/react'; import { act, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import PositionsTable, { OpenVolumeCell, PNLCell } from './positions-table'; +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 { PositionStatus } from '@vegaprotocol/types'; import type { ICellRendererParams } from 'ag-grid-community'; import { addDecimalsFormatNumber } from '@vegaprotocol/utils'; @@ -20,13 +19,13 @@ const singleRow: Position = { assetSymbol: 'BTC', averageEntryPrice: '133', currentLeverage: 1.1, - decimals: 2, // this is settlementAsset.decimals + assetDecimals: 2, // this is settlementAsset.decimals quantum: '0.1', lossSocializationAmount: '0', marginAccountBalance: '12345600', marketDecimalPlaces: 1, marketId: 'string', - marketName: 'ETH/BTC (31 july 2022)', + marketCode: 'ETHBTC.QM21', marketTradingMode: Schema.MarketTradingMode.TRADING_MODE_CONTINUOUS, markPrice: '123', notional: '12300', @@ -40,9 +39,13 @@ const singleRow: Position = { productType: 'Future', }; -const singleRowData = [singleRow]; - describe('Positions', () => { + const renderComponent = async (rowData: Position) => { + await act(async () => { + render(); + }); + }; + it('should render successfully', async () => { await act(async () => { const { baseElement } = render( @@ -53,158 +56,132 @@ describe('Positions', () => { }); it('render correct columns', async () => { - await act(async () => { - render(); - }); - - const headers = screen.getAllByRole('columnheader'); - expect(headers).toHaveLength(11); - expect( - headers.map((h) => h.querySelector('[ref="eText"]')?.textContent?.trim()) - ).toEqual([ + const expectedHeaders = [ 'Market', - 'Notional', - 'Open volume', - 'Mark price', - 'Liquidation price', - 'Asset', - 'Entry price', - 'Leverage', + 'Size / Notional', + 'Entry / Mark', 'Margin', + 'Liquidation', 'Realised PNL', 'Unrealised PNL', - ]); + ]; + + await renderComponent(singleRow); + + const headers = screen.getAllByRole('columnheader'); + expect(headers).toHaveLength(expectedHeaders.length); + expect( + headers.map((h) => h.querySelector('[ref="eText"]')?.textContent?.trim()) + ).toEqual(expectedHeaders); }); - it('renders market name', async () => { - await act(async () => { - render(); - }); - expect(screen.getByText('ETH/BTC (31 july 2022)')).toBeTruthy(); + it('renders market code', async () => { + await renderComponent(singleRow); + expect(screen.getByText(singleRow.marketCode)).toBeTruthy(); expect(screen.getByText('Futr')).toBeInTheDocument(); }); it('Does not fail if the market name does not match the split pattern', async () => { const breakingMarketName = 'OP/USD AUG-SEP22 - Incentive'; - const row = [ - Object.assign({}, singleRow, { marketName: breakingMarketName }), - ]; - await act(async () => { - render(); - }); - + await renderComponent({ ...singleRow, marketCode: breakingMarketName }); expect(screen.getByText(breakingMarketName)).toBeTruthy(); }); - it('add color and sign to amount, displays positive notional value', async () => { - let result: RenderResult; - await act(async () => { - result = render( - - ); - }); - let cells = screen.getAllByRole('gridcell'); + it('displays size / notional correctly for long position', async () => { + await renderComponent(singleRow); + const cells = screen.getAllByRole('gridcell'); + const cell = cells[1]; - expect(cells[2].classList.contains('text-market-green-600')).toBeTruthy(); - expect(cells[2].classList.contains('text-market-red')).toBeFalsy(); - expect(cells[2].textContent).toEqual('+100'); - expect(cells[1].textContent).toEqual('1,230.0'); - await act(async () => { - result.rerender( - - ); - }); - cells = screen.getAllByRole('gridcell'); - expect(cells[2].classList.contains('text-market-green-600')).toBeFalsy(); - expect(cells[2].classList.contains('text-market-red')).toBeTruthy(); - expect(cells[2].textContent?.startsWith('-100')).toBeTruthy(); - expect(cells[1].textContent).toEqual('1,230.0'); + expect(cell).toHaveClass('text-market-green-600'); + expect(cell).not.toHaveClass('text-market-red'); + + expect(within(cell).getByTestId('stack-cell-primary')).toHaveTextContent( + '+100' + ); + expect(within(cell).getByTestId('stack-cell-secondary')).toHaveTextContent( + '1,230.0' + ); }); - it('displays mark price', async () => { - let result: RenderResult; - await act(async () => { - result = render( - - ); + it('displays size / notional correctly for short position', async () => { + await renderComponent({ ...singleRow, openVolume: '-100' }); + const cells = screen.getAllByRole('gridcell'); + const cell = cells[1]; + + expect(cell).not.toHaveClass('text-market-green-600'); + expect(cell).toHaveClass('text-market-red'); + + expect(within(cell).getByTestId('stack-cell-primary')).toHaveTextContent( + '-100' + ); + expect(within(cell).getByTestId('stack-cell-secondary')).toHaveTextContent( + '1,230.0' + ); + }); + + it('displays entry / mark price', async () => { + await renderComponent(singleRow); + const cells = screen.getAllByRole('gridcell'); + const cell = within(cells[2]); + expect(cell.getByTestId('stack-cell-primary')).toHaveTextContent('13.3'); + expect(cell.getByTestId('stack-cell-secondary')).toHaveTextContent('12.3'); + }); + + it('doesnt render entry / mark if market is in opening auction', async () => { + await renderComponent({ + ...singleRow, + marketTradingMode: Schema.MarketTradingMode.TRADING_MODE_OPENING_AUCTION, }); - let cells = screen.getAllByRole('gridcell'); - expect(cells[3].textContent).toEqual('12.3'); - - await act(async () => { - result.rerender( - - ); - }); - - cells = screen.getAllByRole('gridcell'); - expect(cells[3].textContent).toEqual('-'); + const cells = screen.getAllByRole('gridcell'); + expect(cells[2].textContent).toEqual('-'); }); it('displays liquidation price', async () => { - await act(async () => { - render(); - }); + await renderComponent(singleRow); const cells = screen.getAllByRole('gridcell'); expect(cells[4].textContent).toEqual('liquidation price'); }); - it('displays leverage', async () => { - await act(async () => { - render(); - }); + it('displays margin and leverage', async () => { + await renderComponent(singleRow); const cells = screen.getAllByRole('gridcell'); - expect(cells[7].textContent).toEqual('1.1'); - }); - it('displays allocated margin', async () => { - await act(async () => { - render(); - }); - const cells = screen.getAllByRole('gridcell'); - const cell = cells[8]; - expect(cell.textContent).toEqual('123,456.00'); + // margin + expect( + within(cells[3]).getByTestId('stack-cell-primary') + ).toHaveTextContent('123,456.00'); + + // leverage + expect( + within(cells[3]).getByTestId('stack-cell-secondary') + ).toHaveTextContent('1.1'); }); it('displays realised and unrealised PNL', async () => { // pnl cells should be rendered with asset dps const expectedRealised = addDecimalsFormatNumber( singleRow.realisedPNL, - singleRow.decimals + singleRow.assetDecimals ); const expectedUnrealised = addDecimalsFormatNumber( singleRow.unrealisedPNL, - singleRow.decimals + singleRow.assetDecimals ); - await act(async () => { - render(); - }); + await renderComponent(singleRow); const cells = screen.getAllByRole('gridcell'); - expect(cells[9].textContent).toEqual(expectedRealised); - expect(cells[10].textContent).toEqual(expectedUnrealised); + expect(cells[5]).toHaveTextContent(expectedRealised); + expect(cells[6]).toHaveTextContent(expectedUnrealised); }); it('displays close button', async () => { await act(async () => { render( { return; }} @@ -212,24 +189,15 @@ describe('Positions', () => { /> ); }); - const cells = screen.getAllByRole('gridcell'); - expect(cells[11].textContent).toEqual(''); + + expect(screen.getByTestId('close-position')).toBeInTheDocument(); }); it('do not display close button if openVolume is zero', async () => { - await act(async () => { - render( - { - return; - }} - isReadOnly={false} - /> - ); - }); - const cells = screen.getAllByRole('gridcell'); - expect(cells[11].textContent).toEqual(''); + await renderComponent({ ...singleRow, openVolume: '0' }); + expect( + screen.queryByRole('button', { name: 'Close' }) + ).not.toBeInTheDocument(); }); describe('PNLCell', () => { @@ -249,40 +217,27 @@ describe('Positions', () => { lossSocialisationAmount: '0', }, valueFormatted: '100', - }; - render(); - expect(screen.getByText(props.valueFormatted)).toBeInTheDocument(); - expect(screen.queryByRole('img')).not.toBeInTheDocument(); + } as ICellRendererParams; + render(); + expect( + screen.getByText(props.valueFormatted as string) + ).toBeInTheDocument(); + expect(screen.queryByTestId(/icon-/)).not.toBeInTheDocument(); }); - it('renders value with warning tooltip if loss socialisation occurred', async () => { + it('renders value with warning icon if loss socialisation occurred', () => { const props = { data: { ...singleRow, lossSocializationAmount: '500', - decimals: 2, + assetDecimals: 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(); - expect( - within(tooltip).getByText( - `You received less BTC in gains that you should have when the market moved in your favour. This occurred because one or more other trader(s) were closed out and did not have enough funds to cover their losses, and the market's insurance pool was empty.` - ) - ).toBeInTheDocument(); + expect(screen.getByTestId(/icon-/)).toBeInTheDocument(); }); }); @@ -290,9 +245,10 @@ describe('Positions', () => { const props = { data: undefined, valueFormatted: '100', - }; + } as ICellRendererParams; + it('renders a dash if no data', () => { - render(); + render(); expect(screen.getByText('-')).toBeInTheDocument(); }); @@ -306,36 +262,21 @@ describe('Positions', () => { }; render(); expect(screen.getByText(props.valueFormatted)).toBeInTheDocument(); - expect(screen.queryByRole('img')).not.toBeInTheDocument(); + expect(screen.queryByTestId(/icon-/)).not.toBeInTheDocument(); }); - it('renders status with warning tooltip if orders were closed', async () => { + it('renders status with warning tooltip if orders were closed', () => { const props = { data: { ...singleRow, status: PositionStatus.POSITION_STATUS_ORDERS_CLOSED, }, valueFormatted: '100', - }; - render(); - const content = screen.getByText(props.valueFormatted); + } as ICellRendererParams; + render(); + const content = screen.getByText(props.valueFormatted as string); 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(); - expect( - // using within as radix renders tooltip content twice - within(tooltip).getByText( - 'The position was distressed, but removing open orders from the book brought the margin level back to a point where the open position could be maintained.' - ) - ).toBeInTheDocument(); + expect(screen.getByTestId(/icon-/)).toBeInTheDocument(); }); it('renders status with warning tooltip if position was closed out', async () => { @@ -345,24 +286,71 @@ describe('Positions', () => { status: PositionStatus.POSITION_STATUS_CLOSED_OUT, }, valueFormatted: '100', - }; - render(); - const content = screen.getByText(props.valueFormatted); + } as ICellRendererParams; + render(); + const content = screen.getByText(props.valueFormatted as string); expect(content).toBeInTheDocument(); - expect(screen.getByRole('img')).toBeInTheDocument(); - await userEvent.hover(content); - const tooltip = await screen.findByRole('tooltip'); - expect(tooltip).toBeInTheDocument(); + expect(screen.getByTestId(/icon-/)).toBeInTheDocument(); + }); + }); + + describe('position status from size column', () => { + it('does not show if position status is normal', async () => { + await renderComponent({ + ...singleRow, + status: PositionStatus.POSITION_STATUS_UNSPECIFIED, + }); + const cells = screen.getAllByRole('gridcell'); + const cell = cells[1]; + await userEvent.hover(cell); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + it.each([ + { + status: PositionStatus.POSITION_STATUS_CLOSED_OUT, + text: 'Your position was closed.', + }, + { + status: PositionStatus.POSITION_STATUS_ORDERS_CLOSED, + text: 'Your open orders were cancelled.', + }, + { + status: PositionStatus.POSITION_STATUS_DISTRESSED, + text: 'Your position is distressed.', + }, + ])('renders content for $status', async (data) => { + await renderComponent({ + ...singleRow, + status: data.status, + }); + const cells = screen.getAllByRole('gridcell'); + const cell = cells[1]; + await userEvent.hover(cell); + const tooltip = within(await screen.findByRole('tooltip')); + expect(tooltip.getByText(data.text)).toBeInTheDocument(); + }); + }); + + describe('loss socialization from realised pnl column', () => { + it('renders', async () => { + await renderComponent({ + ...singleRow, + lossSocializationAmount: '500', + assetDecimals: 2, + }); + const cells = screen.getAllByRole('gridcell'); + const cell = cells[5]; + + await userEvent.hover(cell); + const tooltip = within(await screen.findByRole('tooltip')); + expect(tooltip.getByText('Realised PNL: 1.23')).toBeInTheDocument(); expect( - // using within as radix renders tooltip content twice - within(tooltip).getByText( - `Status: ${PositionStatusMapping[props.data.status]}` - ) + tooltip.getByText('Lifetime loss socialisation deductions: 5.00') ).toBeInTheDocument(); expect( - // using within as radix renders tooltip content twice - within(tooltip).getByText( - 'You did not have enough BTC collateral to meet the maintenance margin requirements for your position, so it was closed by the network.' + tooltip.getByText( + `You received less BTC in gains that you should have when the market moved in your favour. This occurred because one or more other trader(s) were closed out and did not have enough funds to cover their losses, and the market's insurance pool was empty.` ) ).toBeInTheDocument(); }); diff --git a/libs/positions/src/lib/positions-table.tsx b/libs/positions/src/lib/positions-table.tsx index 8aebf7458..66e0ea9bf 100644 --- a/libs/positions/src/lib/positions-table.tsx +++ b/libs/positions/src/lib/positions-table.tsx @@ -1,46 +1,46 @@ -import classNames from 'classnames'; import { useMemo } from 'react'; import type { CSSProperties, ReactNode } from 'react'; -import type { ColDef } from 'ag-grid-community'; +import type { ColDef, ITooltipParams } from 'ag-grid-community'; import type { VegaValueFormatterParams, VegaValueGetterParams, TypedDataAgGrid, VegaICellRendererParams, } from '@vegaprotocol/datagrid'; -import { COL_DEFS } from '@vegaprotocol/datagrid'; -import { ProgressBarCell } from '@vegaprotocol/datagrid'; import { AgGridLazy as AgGrid, + COL_DEFS, PriceFlashCell, - signedNumberCssClass, signedNumberCssClassRules, MarketNameCell, + ProgressBarCell, + MarketProductPill, } from '@vegaprotocol/datagrid'; import { ButtonLink, - Tooltip, TooltipCellComponent, ExternalLink, - Icon, - VegaIconNames, VegaIcon, + VegaIconNames, } from '@vegaprotocol/ui-toolkit'; import { volumePrefix, toBigNum, formatNumber, addDecimalsFormatNumber, + addDecimalsFormatNumberQuantum, } from '@vegaprotocol/utils'; import { t } from '@vegaprotocol/i18n'; import type { Position } from './positions-data-providers'; -import * as Schema from '@vegaprotocol/types'; -import { PositionStatus, PositionStatusMapping } from '@vegaprotocol/types'; +import { + MarketTradingMode, + PositionStatus, + PositionStatusMapping, +} from '@vegaprotocol/types'; import { DocsLinks } from '@vegaprotocol/environment'; import { PositionActionsDropdown } from './position-actions-dropdown'; -import { useAssetDetailsDialogStore } from '@vegaprotocol/assets'; -import type { VegaWalletContextShape } from '@vegaprotocol/wallet'; import { LiquidationPrice } from './liquidation-price'; +import { StackedCell } from './stacked-cell'; interface Props extends TypedDataAgGrid { onClose?: (data: Position) => void; @@ -48,44 +48,25 @@ interface Props extends TypedDataAgGrid { style?: CSSProperties; isReadOnly: boolean; multipleKeys?: boolean; - pubKeys?: VegaWalletContextShape['pubKeys']; - pubKey?: VegaWalletContextShape['pubKey']; + pubKeys?: Array<{ name: string; publicKey: string }> | null; + pubKey?: string | null; } -export interface AmountCellProps { - valueFormatted?: Pick< - Position, - 'openVolume' | 'marketDecimalPlaces' | 'positionDecimalPlaces' | 'notional' - >; -} - -export const AmountCell = ({ valueFormatted }: AmountCellProps) => { - if (!valueFormatted) { - return null; - } - const { openVolume, positionDecimalPlaces, marketDecimalPlaces, notional } = - valueFormatted; - return valueFormatted && notional ? ( -
-
- {volumePrefix( - addDecimalsFormatNumber(openVolume, positionDecimalPlaces) - )} -
-
- {addDecimalsFormatNumber(notional, marketDecimalPlaces)} -
-
- ) : null; -}; - -AmountCell.displayName = 'AmountCell'; - export const getRowId = ({ data }: { data: Position }) => `${data.partyId}-${data.marketId}`; +const realisedPNLValueGetter = ({ data }: { data: Position }) => { + return !data + ? undefined + : toBigNum(data.realisedPNL, data.assetDecimals).toNumber(); +}; + +const unrealisedPNLValueGetter = ({ data }: { data: Position }) => { + return !data + ? undefined + : toBigNum(data.unrealisedPNL, data.assetDecimals).toNumber(); +}; + const defaultColDef = { sortable: true, filter: true, @@ -103,7 +84,6 @@ export const PositionsTable = ({ pubKey, ...props }: Props) => { - const { open: openAssetDetailsDialog } = useAssetDetailsDialogStore(); return ( (() => { const columnDefs: (ColDef | null)[] = [ multipleKeys @@ -132,41 +112,37 @@ export const PositionsTable = ({ : null, { headerName: t('Market'), - field: 'marketName', - cellRenderer: 'MarketNameCell', - cellRendererParams: { idPath: 'marketId', onMarketClick }, - }, - { - headerName: t('Notional'), - headerTooltip: t('Mark price x open volume.'), - field: 'notional', - type: 'rightAligned', - cellClass: 'font-mono text-right', - filter: 'agNumberColumnFilter', - valueGetter: ({ data }: VegaValueGetterParams) => { - return !data?.notional - ? undefined - : toBigNum(data.notional, data.marketDecimalPlaces).toNumber(); + field: 'marketCode', + onCellClicked: ({ data }) => { + if (!onMarketClick) return; + onMarketClick(data.marketId); }, - valueFormatter: ({ + cellRenderer: ({ + value, data, - }: VegaValueFormatterParams) => { - return !data || !data.notional - ? '-' - : addDecimalsFormatNumber( - data.notional, - data.marketDecimalPlaces - ); + }: VegaICellRendererParams) => { + if (!data || !value) return '-'; + return ( + + {data?.assetSymbol} + + + } + /> + ); }, }, { - headerName: t('Open volume'), + headerName: t('Size / Notional'), field: 'openVolume', type: 'rightAligned', cellClass: 'font-mono text-right', cellClassRules: signedNumberCssClassRules, filter: 'agNumberColumnFilter', - valueGetter: ({ data }: VegaValueGetterParams) => { + valueGetter: ({ data }: { data: Position }) => { return data?.openVolume === undefined ? undefined : toBigNum( @@ -174,165 +150,205 @@ export const PositionsTable = ({ data.positionDecimalPlaces ).toNumber(); }, + tooltipValueGetter: ({ data }: ITooltipParams) => { + if ( + !data || + data.status === PositionStatus.POSITION_STATUS_UNSPECIFIED + ) { + return null; + } + return data.status; + }, valueFormatter: ({ data, }: VegaValueFormatterParams): string => { - return data?.openVolume === undefined - ? '' - : volumePrefix( - addDecimalsFormatNumber( - data.openVolume, - data.positionDecimalPlaces - ) + if (!data?.openVolume) return '-'; + + const vol = volumePrefix( + addDecimalsFormatNumber( + data.openVolume, + data.positionDecimalPlaces + ) + ); + + return vol; + }, + tooltipComponent: (args: ITooltipParams) => { + if (!args.data) { + return null; + } + const POSITION_RESOLUTION_LINK = + DocsLinks?.POSITION_RESOLUTION ?? ''; + let primaryTooltip; + switch (args.data.status) { + case PositionStatus.POSITION_STATUS_CLOSED_OUT: + primaryTooltip = t('Your position was closed.'); + break; + case PositionStatus.POSITION_STATUS_ORDERS_CLOSED: + primaryTooltip = t('Your open orders were cancelled.'); + break; + case PositionStatus.POSITION_STATUS_DISTRESSED: + primaryTooltip = t('Your position is distressed.'); + break; + } + + let secondaryTooltip; + switch (args.data.status) { + case PositionStatus.POSITION_STATUS_CLOSED_OUT: + secondaryTooltip = t( + `You did not have enough %s collateral to meet the maintenance margin requirements for your position, so it was closed by the network.`, + args.data.assetSymbol ); + break; + case PositionStatus.POSITION_STATUS_ORDERS_CLOSED: + secondaryTooltip = t( + 'The position was distressed, but removing open orders from the book brought the margin level back to a point where the open position could be maintained.' + ); + break; + case PositionStatus.POSITION_STATUS_DISTRESSED: + secondaryTooltip = t( + 'The position was distressed, but could not be closed out - orders were removed from the book, and the open volume will be closed out once there is sufficient volume on the book.' + ); + break; + default: + secondaryTooltip = t('Maintained by network'); + } + return ( + +

{primaryTooltip}

+

{secondaryTooltip}

+

+ {t( + 'Status: %s', + PositionStatusMapping[args.data.status] + )} +

+ {POSITION_RESOLUTION_LINK && ( + + {t('Read more about position resolution')} + + )} + + } + /> + ); }, cellRenderer: OpenVolumeCell, }, { - headerName: t('Mark price'), + headerName: t('Entry / Mark'), field: 'markPrice', type: 'rightAligned', - cellRenderer: PriceFlashCell, + cellClass: 'font-mono text-right', + cellRenderer: ({ + data, + }: VegaICellRendererParams) => { + if ( + !data?.averageEntryPrice || + !data?.markPrice || + !data?.marketDecimalPlaces + ) { + return <>-; + } + + if ( + data.marketTradingMode === + MarketTradingMode.TRADING_MODE_OPENING_AUCTION + ) { + return <>-; + } + + const entry = addDecimalsFormatNumber( + data.averageEntryPrice, + data.marketDecimalPlaces + ); + const mark = addDecimalsFormatNumber( + data.markPrice, + data.marketDecimalPlaces + ); + return ( + + } + /> + ); + }, filter: 'agNumberColumnFilter', valueGetter: ({ data }: VegaValueGetterParams) => { return !data || !data.markPrice || data.marketTradingMode === - Schema.MarketTradingMode.TRADING_MODE_OPENING_AUCTION + MarketTradingMode.TRADING_MODE_OPENING_AUCTION ? undefined : toBigNum(data.markPrice, data.marketDecimalPlaces).toNumber(); }, - valueFormatter: ({ - data, - }: VegaValueFormatterParams) => { - if (!data) { - return ''; - } + }, + { + headerName: t('Margin'), + colId: 'margin', + type: 'rightAligned', + cellClass: 'font-mono text-right', + filter: 'agNumberColumnFilter', + valueGetter: ({ data }: VegaValueGetterParams) => { + return !data + ? undefined + : toBigNum( + data.marginAccountBalance, + data.assetDecimals + ).toNumber(); + }, + cellRenderer: ({ data }: VegaICellRendererParams) => { if ( - !data.markPrice || - data.marketTradingMode === - Schema.MarketTradingMode.TRADING_MODE_OPENING_AUCTION + !data || + !data.marginAccountBalance || + !data.marketDecimalPlaces ) { - return '-'; + return null; } - return addDecimalsFormatNumber( - data.markPrice, - data.marketDecimalPlaces + const margin = addDecimalsFormatNumberQuantum( + data.marginAccountBalance, + data.assetDecimals, + data.quantum + ); + + const lev = data?.currentLeverage ? data.currentLeverage : 1; + const leverage = formatNumber(Math.max(1, lev), 1); + return ( + ); }, }, { - headerName: t('Liquidation price'), colId: 'liquidationPrice', - type: 'rightAligned', + headerName: 'Liquidation', + headerTooltip: t('Worst case liquidation price'), cellClass: 'font-mono text-right', + type: 'rightAligned', + // Cannot be sortable as data is fetched within the cell + sortable: false, + filter: false, cellRenderer: ({ data }: VegaICellRendererParams) => { - if (!data) return null; + if (!data) { + return '-'; + } return ( ); }, }, - { - headerName: t('Asset'), - field: 'assetSymbol', - colId: 'asset', - cellRenderer: ({ data }: VegaICellRendererParams) => { - if (!data) return null; - return ( - { - openAssetDetailsDialog( - data.assetId, - e.target as HTMLElement - ); - }} - > - {data?.assetSymbol} - - ); - }, - }, - { - headerName: t('Entry price'), - field: 'averageEntryPrice', - type: 'rightAligned', - cellRenderer: PriceFlashCell, - filter: 'agNumberColumnFilter', - valueGetter: ({ data }: VegaValueGetterParams) => { - return data?.markPrice === undefined || !data - ? undefined - : toBigNum( - data.averageEntryPrice, - data.marketDecimalPlaces - ).toNumber(); - }, - valueFormatter: ({ - data, - }: VegaValueFormatterParams< - Position, - 'averageEntryPrice' - >): string => { - if (!data) { - return ''; - } - return addDecimalsFormatNumber( - data.averageEntryPrice, - data.marketDecimalPlaces - ); - }, - }, - multipleKeys - ? null - : { - headerName: t('Leverage'), - field: 'currentLeverage', - type: 'rightAligned', - filter: 'agNumberColumnFilter', - cellRenderer: PriceFlashCell, - valueFormatter: ({ - value, - }: VegaValueFormatterParams) => - value === undefined ? '' : formatNumber(value.toString(), 1), - }, - multipleKeys - ? null - : { - headerName: t('Margin'), - field: 'marginAccountBalance', - type: 'rightAligned', - filter: 'agNumberColumnFilter', - cellRenderer: PriceFlashCell, - valueGetter: ({ data }: VegaValueGetterParams) => { - return !data - ? undefined - : toBigNum( - data.marginAccountBalance, - data.decimals - ).toNumber(); - }, - valueFormatter: ({ - data, - }: VegaValueFormatterParams< - Position, - 'marginAccountBalance' - >): string => { - if (!data) { - return ''; - } - return addDecimalsFormatNumber( - data.marginAccountBalance, - data.decimals - ); - }, - }, { headerName: t('Realised PNL'), field: 'realisedPNL', @@ -340,17 +356,71 @@ export const PositionsTable = ({ cellClassRules: signedNumberCssClassRules, cellClass: 'font-mono text-right', filter: 'agNumberColumnFilter', - valueGetter: ({ data }: VegaValueGetterParams) => { - return !data - ? undefined - : toBigNum(data.realisedPNL, data.decimals).toNumber(); + valueGetter: realisedPNLValueGetter, + // @ts-ignore no type overlap, but the functions are identical + tooltipValueGetter: realisedPNLValueGetter, + tooltipComponent: (args: ITooltipParams) => { + const LOSS_SOCIALIZATION_LINK = + DocsLinks?.LOSS_SOCIALIZATION ?? ''; + + if (!args.data) { + return <>-; + } + + const losses = parseInt( + args.data?.lossSocializationAmount ?? '0' + ); + + if (losses <= 0) { + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>{args.valueFormatted}; + } + + const lossesFormatted = addDecimalsFormatNumber( + args.data.lossSocializationAmount, + args.data.assetDecimals + ); + + return ( + +

+ {t('Realised PNL: %s', args.value)} +

+

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

+

+ {t( + `You received less %s in gains that you should have when the market moved in your favour. This occurred because one or more other trader(s) were closed out and did not have enough funds to cover their losses, and the market's insurance pool was empty.`, + args.data.assetSymbol + )} +

+ {LOSS_SOCIALIZATION_LINK && ( + + {t('Read more about loss socialisation')} + + )} + + } + /> + ); }, valueFormatter: ({ data, }: VegaValueFormatterParams) => { return !data ? '' - : addDecimalsFormatNumber(data.realisedPNL, data.decimals); + : addDecimalsFormatNumberQuantum( + data.realisedPNL, + data.assetDecimals, + data.quantum + ); }, 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.' @@ -364,21 +434,22 @@ export const PositionsTable = ({ cellClassRules: signedNumberCssClassRules, cellClass: 'font-mono text-right', filter: 'agNumberColumnFilter', - valueGetter: ({ data }: VegaValueGetterParams) => { - return !data - ? undefined - : toBigNum(data.unrealisedPNL, data.decimals).toNumber(); - }, + valueGetter: unrealisedPNLValueGetter, + // @ts-ignore no type overlap but function can be identical + tooltipValueGetter: unrealisedPNLValueGetter, valueFormatter: ({ data, }: VegaValueFormatterParams) => !data ? '' - : addDecimalsFormatNumber(data.unrealisedPNL, data.decimals), + : addDecimalsFormatNumberQuantum( + data.unrealisedPNL, + data.assetDecimals, + data.quantum + ), headerTooltip: t( 'Unrealised profit is the current profit on your open position. Margin is still allocated to your position.' ), - cellRenderer: PNLCell, }, onClose && !isReadOnly ? { @@ -410,28 +481,16 @@ export const PositionsTable = ({ return columnDefs.filter( (colDef: ColDef | null): colDef is ColDef => colDef !== null ); - }, [ - isReadOnly, - multipleKeys, - onClose, - onMarketClick, - openAssetDetailsDialog, - pubKey, - pubKeys, - ])} + }, [isReadOnly, multipleKeys, onClose, onMarketClick, pubKey, pubKeys])} {...props} /> ); }; -export default PositionsTable; - export const PNLCell = ({ valueFormatted, data, }: VegaICellRendererParams) => { - const LOSS_SOCIALIZATION_LINK = DocsLinks?.LOSS_SOCIALIZATION ?? ''; - if (!data) { return <>-; } @@ -442,121 +501,62 @@ export const PNLCell = ({ return <>{valueFormatted}; } - const lossesFormatted = addDecimalsFormatNumber( - data.lossSocializationAmount, - data.decimals - ); - - return ( - -

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

-

- {t( - `You received less %s in gains that you should have when the market moved in your favour. This occurred because one or more other trader(s) were closed out and did not have enough funds to cover their losses, and the market's insurance pool was empty.`, - [data.assetSymbol] - )} -

- {LOSS_SOCIALIZATION_LINK && ( - - {t('Read more about loss socialisation')} - - )} - - } - > - {valueFormatted} -
- ); + return {valueFormatted}; }; export const OpenVolumeCell = ({ valueFormatted, data, }: VegaICellRendererParams) => { - if (!data) { + if (!valueFormatted || !data || !data.notional) { return <>-; } - const POSITION_RESOLUTION_LINK = DocsLinks?.POSITION_RESOLUTION ?? ''; + const notional = addDecimalsFormatNumber( + data.notional, + data.marketDecimalPlaces + ); - let primaryTooltip; - switch (data.status) { - case PositionStatus.POSITION_STATUS_CLOSED_OUT: - primaryTooltip = t('Your position was closed.'); - break; - case PositionStatus.POSITION_STATUS_ORDERS_CLOSED: - primaryTooltip = t('Your open orders were cancelled.'); - break; - case PositionStatus.POSITION_STATUS_DISTRESSED: - primaryTooltip = t('Your position is distressed.'); - break; + const cellContent = ( + + ); + + if (data.status === PositionStatus.POSITION_STATUS_UNSPECIFIED) { + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>{cellContent}; } - let secondaryTooltip; - switch (data.status) { - case PositionStatus.POSITION_STATUS_CLOSED_OUT: - secondaryTooltip = t( - `You did not have enough %s collateral to meet the maintenance margin requirements for your position, so it was closed by the network.`, - [data.assetSymbol] - ); - break; - case PositionStatus.POSITION_STATUS_ORDERS_CLOSED: - secondaryTooltip = t( - 'The position was distressed, but removing open orders from the book brought the margin level back to a point where the open position could be maintained.' - ); - break; - case PositionStatus.POSITION_STATUS_DISTRESSED: - secondaryTooltip = t( - 'The position was distressed, but could not be closed out - orders were removed from the book, and the open volume will be closed out once there is sufficient volume on the book.' - ); - break; - default: - secondaryTooltip = t('Maintained by network'); - } return ( -

{primaryTooltip}

-

{secondaryTooltip}

-

- {t('Status: %s', PositionStatusMapping[data.status])} -

- {POSITION_RESOLUTION_LINK && ( - - {t('Read more about position resolution')} - - )} - + showIcon={ + // not sure why but data.status has become a union of all the enum values + // rather than just being the enum itself + (data.status as PositionStatus) !== + PositionStatus.POSITION_STATUS_UNSPECIFIED } > - {valueFormatted} + {cellContent}
); }; const WarningCell = ({ children, - tooltipContent, showIcon = true, }: { children: ReactNode; - tooltipContent: ReactNode; showIcon?: boolean; }) => { return ( - -
- - {showIcon && } +
+ {showIcon && ( + + - {children} -
- + )} + + {children} + +
); }; diff --git a/libs/positions/src/lib/stacked-cell.tsx b/libs/positions/src/lib/stacked-cell.tsx new file mode 100644 index 000000000..0f5ab5c8a --- /dev/null +++ b/libs/positions/src/lib/stacked-cell.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from 'react'; + +export const StackedCell = ({ + primary, + secondary, +}: { + primary: ReactNode; + secondary: ReactNode; +}) => { + return ( +
+
{primary}
+
+ {secondary} +
+
+ ); +}; diff --git a/libs/proposals/src/components/proposals-list/use-column-defs.tsx b/libs/proposals/src/components/proposals-list/use-column-defs.tsx index 6bb0a1d7a..795a0fa8c 100644 --- a/libs/proposals/src/components/proposals-list/use-column-defs.tsx +++ b/libs/proposals/src/components/proposals-list/use-column-defs.tsx @@ -5,7 +5,6 @@ import { CenteredGridCellWrapper, COL_DEFS, DateRangeFilter, - MarketProductPill, SetFilter, } from '@vegaprotocol/datagrid'; import compact from 'lodash/compact'; @@ -20,13 +19,15 @@ import type { VegaICellRendererParams, VegaValueFormatterParams, } from '@vegaprotocol/datagrid'; -import { ExternalLink } from '@vegaprotocol/ui-toolkit'; -import type { InstrumentConfiguration } from '@vegaprotocol/types'; -import { ProposalStateMapping } from '@vegaprotocol/types'; +import { ExternalLink, Pill } from '@vegaprotocol/ui-toolkit'; +import { + ProposalProductTypeMapping, + ProposalProductTypeShortName, + ProposalStateMapping, +} from '@vegaprotocol/types'; import type { ProposalListFieldsFragment } from '../../lib/proposals-data-provider/__generated__/Proposals'; import { VoteProgress } from '../voting-progress'; import { ProposalActionsDropdown } from '../proposal-actions-dropdown'; -import { getMarketProductType } from '../../utils/get-market-product-type'; export const MarketNameProposalCell = ({ value, @@ -38,13 +39,19 @@ export const MarketNameProposalCell = ({ const { VEGA_TOKEN_URL } = useEnvironment(); const { change } = data?.terms || {}; if (change?.__typename === 'NewMarket' && VEGA_TOKEN_URL) { - const productType = getMarketProductType( - change.instrument as InstrumentConfiguration - ); + const type = change.instrument.futureProduct?.__typename; const content = ( <> {value as string} - + {type && ( + + {ProposalProductTypeShortName[type]} + + )} ); if (data?.id) { diff --git a/libs/proposals/src/utils/get-market-product-type.spec.ts b/libs/proposals/src/utils/get-market-product-type.spec.ts deleted file mode 100644 index b7696a1cf..000000000 --- a/libs/proposals/src/utils/get-market-product-type.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { InstrumentConfiguration } from '@vegaprotocol/types'; -import { getMarketProductType } from './get-market-product-type'; - -describe('getMarketProductType', () => { - it('should resolve product type properly', () => { - expect( - getMarketProductType({ - futureProduct: { - quoteName: 'Market 1', - }, - } as InstrumentConfiguration) - ).toEqual('Future'); - expect( - getMarketProductType({ - spotProduct: { - quoteName: 'Market 1', - }, - } as unknown as InstrumentConfiguration) - ).toEqual('Spot'); - expect( - getMarketProductType({ - perpetualProduct: { - quoteName: 'Market 1', - }, - } as unknown as InstrumentConfiguration) - ).toEqual('Perpetual'); - expect( - getMarketProductType({ - product: { - __typename: 'Perpetual', - }, - futureProduct: { - quoteName: 'Market 1', - }, - } as unknown as InstrumentConfiguration) - ).toEqual('Perpetual'); - expect( - getMarketProductType({ - product: { - __typename: 'Spot', - }, - futureProduct: { - quoteName: 'Market 1', - }, - } as unknown as InstrumentConfiguration) - ).toEqual('Spot'); - expect( - getMarketProductType({ - product: { - __typename: 'Future', - }, - perpetualProduct: { - quoteName: 'Market 1', - }, - } as unknown as InstrumentConfiguration) - ).toEqual('Future'); - }); -}); diff --git a/libs/proposals/src/utils/get-market-product-type.ts b/libs/proposals/src/utils/get-market-product-type.ts deleted file mode 100644 index 58be38db8..000000000 --- a/libs/proposals/src/utils/get-market-product-type.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { InstrumentConfiguration, Product } from '@vegaprotocol/types'; - -// it needs to be adjusted after deploy this https://github.com/vegaprotocol/vega/pull/9003 -export const getMarketProductType = ( - instrumentConfiguration: InstrumentConfiguration -) => { - return 'product' in instrumentConfiguration - ? (instrumentConfiguration.product as Product).__typename - : 'futureProduct' in instrumentConfiguration - ? 'Future' - : 'spotProduct' in instrumentConfiguration - ? 'Spot' - : 'perpetualProduct' in instrumentConfiguration - ? 'Perpetual' - : undefined; -}; diff --git a/libs/types/src/global-types-mappings.ts b/libs/types/src/global-types-mappings.ts index 4458530e2..8912ff379 100644 --- a/libs/types/src/global-types-mappings.ts +++ b/libs/types/src/global-types-mappings.ts @@ -25,6 +25,7 @@ import type { DispatchMetric, StopOrderStatus, } from './__generated__/types'; +import type { ProductType, ProposalProductType } from './product'; export const AccountTypeMapping: { [T in AccountType]: string; @@ -513,3 +514,20 @@ export const PeggedReferenceMapping: { [R in PeggedReference]: string } = { PEGGED_REFERENCE_BEST_BID: 'Bid', PEGGED_REFERENCE_MID: 'Mid', }; + +export const ProductTypeMapping: Record = { + Future: 'Future', +}; + +export const ProductTypeShortName: Record = { + Future: 'Futr', +}; + +export const ProposalProductTypeMapping: Record = { + FutureProduct: 'Future', +}; + +export const ProposalProductTypeShortName: Record = + { + FutureProduct: 'Futr', + }; diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 763376bbb..620b07f56 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -1,3 +1,4 @@ export * from './__generated__/types'; export * from './candle'; export * from './global-types-mappings'; +export * from './product'; diff --git a/libs/types/src/product.ts b/libs/types/src/product.ts new file mode 100644 index 000000000..c395539d9 --- /dev/null +++ b/libs/types/src/product.ts @@ -0,0 +1,7 @@ +import type { Product } from './__generated__/types'; + +export type ProductType = NonNullable; + +// TODO: Update to be dynamically created for ProductionConfiguration union when schema +// changes make it to stagnet1 +export type ProposalProductType = 'FutureProduct'; diff --git a/libs/ui-toolkit/src/components/tooltip/tooltip.tsx b/libs/ui-toolkit/src/components/tooltip/tooltip.tsx index ba65ecfd1..9c629d785 100644 --- a/libs/ui-toolkit/src/components/tooltip/tooltip.tsx +++ b/libs/ui-toolkit/src/components/tooltip/tooltip.tsx @@ -60,5 +60,9 @@ export const Tooltip = ({ ); export const TooltipCellComponent = (props: ITooltipParams) => { - return

{props.value}

; + return ( +
+ {props.value} +
+ ); }; diff --git a/libs/utils/src/lib/format/number.spec.ts b/libs/utils/src/lib/format/number.spec.ts index 0dadcaa76..8ff305247 100644 --- a/libs/utils/src/lib/format/number.spec.ts +++ b/libs/utils/src/lib/format/number.spec.ts @@ -44,6 +44,8 @@ describe('number utils', () => { o: '12,345,678.91234568', q: '1', }, + // USDT / USDC + { v: new BigNumber(12345678), d: 6, o: '12.35', q: 1000000 }, ])( 'formats with addDecimalsFormatNumberQuantum given number correctly', ({ v, d, o, q }) => { From 7142fc956ce67546850cbfc4d1087ebf50da41bf Mon Sep 17 00:00:00 2001 From: Matthew Russell Date: Thu, 17 Aug 2023 13:07:14 +0100 Subject: [PATCH 2/2] chore(positions): spec updates for positions table changes (#4568) --- specs/7004-POSI-positions.md | 41 ++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/specs/7004-POSI-positions.md b/specs/7004-POSI-positions.md index d3ee1ca3b..3bcb69493 100644 --- a/specs/7004-POSI-positions.md +++ b/specs/7004-POSI-positions.md @@ -4,30 +4,37 @@ - **Must** be able to view the following columns (7004-POSI-002): - - market - - notional - - open volume - - mark price - - settlement asset - - entry price - - leverage - - margin allocated - - realised PNL - - unrealised PNL - - Updated + - Market + - Code + - Settlement asset symbol + - Product type + - Size / Notional + - Size + - Notional + - Entry / Mark + - Entry + - Mark + - Margin + - Allocated + - Leverage + - Liquidation + - Realised PNL + - Unrealised PNL - **Must** be able to sort each column by asc and dsc (7004-POSI-003) + - If the cell shows two values the primary (top value) is used to sort (7004-POSI-014) + - **Must** be able to scroll horizontally to see all columns if screen resolution isn't wide enough (7004-POSI-004) - **Must** be able to drag and drop column names to re order (7004-POSI-005) +- **Must** be able to remove columns by dragging headers out of the table (7004-POSI-015) + - **Must** be able to resize the width of the columns (7004-POSI-006) - **Must** The columns 'Open volume', 'realised PNL' and 'Unrealised PNL' must change color and have a + or - suffix depending on being positive/negative (7004-POSI-007) -- **Must** be able to select settlement asset to view asset details (7004-POSI-008) - - **Must** be able to select the 3 dots to view asset details (7004-POSI-009) - **Must** be able to select the close button to close my position (7004-POSI-010) @@ -37,3 +44,11 @@ - **Must** retain previous column sizing on refresh (7004-POSI-012) - **Must** notional column has a tooltip on hover (7004-POSI-013) + +- **Must** be able to see worst case liquidation price (7004-POSI-016) + +- **Must** be able to see best and worst case liquidation price when hovering (7004-POSI-017) + +- **Must** be able to see if your realised PnL was affected by loss socialisation (7004-POSI-018) + +- **Must** Must be able to see what type of product the position was opened on (7004-POSI-019)