feat(positions): table layout changes (#4558)

This commit is contained in:
Matthew Russell 2023-08-17 11:07:48 +01:00 committed by GitHub
parent f3b72b894e
commit 611f8d7491
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 635 additions and 1034 deletions

View File

@ -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();
}

View File

@ -89,17 +89,15 @@ const MarketData = ({
? addDecimalsFormatNumber(vol, market.positionDecimalPlaces)
: '0.00';
const productType = market.tradableInstrument.instrument.product.__typename;
return (
<>
<div className="w-2/5" role="gridcell">
<h3 className="text-ellipsis text-sm lg:text-base whitespace-nowrap overflow-hidden">
{market.tradableInstrument.instrument.code}{' '}
{allProducts && (
<MarketProductPill
productType={
market.tradableInstrument.instrument.product.__typename
}
/>
{allProducts && productType && (
<MarketProductPill productType={productType} />
)}
</h3>
{mode && (

View File

@ -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 ? (
<Pill size="xxs" className="uppercase ml-0.5" title={productType}>
{productTypeMap[productType] || productType}
return (
<Pill
size="xxs"
className="uppercase ml-0.5"
title={ProductTypeMapping[productType]}
>
{ProductTypeShortName[productType]}
</Pill>
) : null;
);
};
interface MarketNameCellProps {
@ -66,7 +65,7 @@ export const MarketNameCell = ({
<span data-testid="market-code" data-market-id={id}>
{value}
</span>
<MarketProductPill productType={productType} />
{productType && <MarketProductPill productType={productType} />}
</>
);
return onMarketClick && id ? (

View File

@ -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 <span>-</span>;
}
return <span data-testid="liquidation-price">{value}</span>;
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 (
<Tooltip
description={
<table>
<tbody>
<tr>
<th>{t('Worst case')}</th>
<td className="text-right font-mono pl-2">{worstCase}</td>
</tr>
<tr>
<th>{t('Best case')}</th>
<td className="text-right font-mono pl-2">{bestCase}</td>
</tr>
</tbody>
</table>
}
>
<span data-testid="liquidation-price">{worstCase}</span>
</Tooltip>
);
};

View File

@ -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');

View File

@ -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) => {

View File

@ -15,7 +15,7 @@ interface PositionsManagerProps {
partyIds: string[];
onMarketClick?: (marketId: string) => void;
isReadOnly: boolean;
gridProps: ReturnType<typeof useDataGridEvents>;
gridProps?: ReturnType<typeof useDataGridEvents>;
}
export const PositionsManager = ({

View File

@ -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(<PositionsTable rowData={[rowData]} isReadOnly={false} />);
});
};
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(<PositionsTable rowData={singleRowData} isReadOnly={true} />);
});
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(<PositionsTable rowData={singleRowData} isReadOnly={false} />);
});
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(<PositionsTable rowData={row} isReadOnly={false} />);
});
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(
<PositionsTable rowData={singleRowData} isReadOnly={false} />
);
});
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(
<PositionsTable
rowData={[{ ...singleRow, openVolume: '-100' }]}
isReadOnly={false}
/>
);
});
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(
<PositionsTable rowData={singleRowData} isReadOnly={false} />
);
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(
<PositionsTable
rowData={[
{
...singleRow,
marketTradingMode:
Schema.MarketTradingMode.TRADING_MODE_OPENING_AUCTION,
},
]}
isReadOnly={false}
/>
);
});
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(<PositionsTable rowData={singleRowData} isReadOnly={false} />);
});
await renderComponent(singleRow);
const cells = screen.getAllByRole('gridcell');
expect(cells[4].textContent).toEqual('liquidation price');
});
it('displays leverage', async () => {
await act(async () => {
render(<PositionsTable rowData={singleRowData} isReadOnly={false} />);
});
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(<PositionsTable rowData={singleRowData} isReadOnly={false} />);
});
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(<PositionsTable rowData={singleRowData} isReadOnly={false} />);
});
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(
<PositionsTable
rowData={singleRowData}
pubKey={singleRowData[0].partyId}
rowData={[singleRow]}
pubKey={singleRow.partyId}
onClose={() => {
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(
<PositionsTable
rowData={[{ ...singleRow, openVolume: '0' }]}
onClose={() => {
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(<PNLCell {...(props as ICellRendererParams)} />);
expect(screen.getByText(props.valueFormatted)).toBeInTheDocument();
expect(screen.queryByRole('img')).not.toBeInTheDocument();
} as ICellRendererParams;
render(<PNLCell {...props} />);
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(<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();
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(<OpenVolumeCell {...(props as ICellRendererParams)} />);
render(<OpenVolumeCell {...props} />);
expect(screen.getByText('-')).toBeInTheDocument();
});
@ -306,36 +262,21 @@ describe('Positions', () => {
};
render(<OpenVolumeCell {...(props as ICellRendererParams)} />);
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(<OpenVolumeCell {...(props as ICellRendererParams)} />);
const content = screen.getByText(props.valueFormatted);
} as ICellRendererParams;
render(<OpenVolumeCell {...props} />);
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(<OpenVolumeCell {...(props as ICellRendererParams)} />);
const content = screen.getByText(props.valueFormatted);
} as ICellRendererParams;
render(<OpenVolumeCell {...props} />);
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();
});

View File

@ -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<Position> {
onClose?: (data: Position) => void;
@ -48,44 +48,25 @@ interface Props extends TypedDataAgGrid<Position> {
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 ? (
<div className="leading-tight font-mono">
<div
className={classNames('text-right', signedNumberCssClass(openVolume))}
>
{volumePrefix(
addDecimalsFormatNumber(openVolume, positionDecimalPlaces)
)}
</div>
<div className="text-right">
{addDecimalsFormatNumber(notional, marketDecimalPlaces)}
</div>
</div>
) : 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 (
<AgGrid
overlayNoRowsTemplate={t('No positions')}
@ -111,11 +91,11 @@ export const PositionsTable = ({
tooltipShowDelay={500}
defaultColDef={defaultColDef}
components={{
AmountCell,
PriceFlashCell,
ProgressBarCell,
MarketNameCell,
}}
rowHeight={45}
columnDefs={useMemo<ColDef[]>(() => {
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<Position>) => {
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<Position, 'notional'>) => {
return !data || !data.notional
? '-'
: addDecimalsFormatNumber(
data.notional,
data.marketDecimalPlaces
);
}: VegaICellRendererParams<Position, 'marketCode'>) => {
if (!data || !value) return '-';
return (
<StackedCell
primary={value}
secondary={
<>
{data?.assetSymbol}
<MarketProductPill productType={data.productType} />
</>
}
/>
);
},
},
{
headerName: t('Open volume'),
headerName: t('Size / Notional'),
field: 'openVolume',
type: 'rightAligned',
cellClass: 'font-mono text-right',
cellClassRules: signedNumberCssClassRules,
filter: 'agNumberColumnFilter',
valueGetter: ({ data }: VegaValueGetterParams<Position>) => {
valueGetter: ({ data }: { data: Position }) => {
return data?.openVolume === undefined
? undefined
: toBigNum(
@ -174,165 +150,205 @@ export const PositionsTable = ({
data.positionDecimalPlaces
).toNumber();
},
tooltipValueGetter: ({ data }: ITooltipParams<Position>) => {
if (
!data ||
data.status === PositionStatus.POSITION_STATUS_UNSPECIFIED
) {
return null;
}
return data.status;
},
valueFormatter: ({
data,
}: VegaValueFormatterParams<Position, 'openVolume'>): 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<Position>) => {
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 (
<TooltipCellComponent
{...args}
value={
<>
<p className="mb-2">{primaryTooltip}</p>
<p className="mb-2">{secondaryTooltip}</p>
<p className="mb-2">
{t(
'Status: %s',
PositionStatusMapping[args.data.status]
)}
</p>
{POSITION_RESOLUTION_LINK && (
<ExternalLink href={POSITION_RESOLUTION_LINK}>
{t('Read more about position resolution')}
</ExternalLink>
)}
</>
}
/>
);
},
cellRenderer: OpenVolumeCell,
},
{
headerName: t('Mark price'),
headerName: t('Entry / Mark'),
field: 'markPrice',
type: 'rightAligned',
cellRenderer: PriceFlashCell,
cellClass: 'font-mono text-right',
cellRenderer: ({
data,
}: VegaICellRendererParams<Position, 'markPrice'>) => {
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 (
<StackedCell
primary={entry}
secondary={
<PriceFlashCell
value={Number(data.markPrice)}
valueFormatted={mark}
/>
}
/>
);
},
filter: 'agNumberColumnFilter',
valueGetter: ({ data }: VegaValueGetterParams<Position>) => {
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<Position, 'markPrice'>) => {
if (!data) {
return '';
}
},
{
headerName: t('Margin'),
colId: 'margin',
type: 'rightAligned',
cellClass: 'font-mono text-right',
filter: 'agNumberColumnFilter',
valueGetter: ({ data }: VegaValueGetterParams<Position>) => {
return !data
? undefined
: toBigNum(
data.marginAccountBalance,
data.assetDecimals
).toNumber();
},
cellRenderer: ({ data }: VegaICellRendererParams<Position>) => {
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 (
<StackedCell primary={margin} secondary={leverage + 'x'} />
);
},
},
{
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<Position>) => {
if (!data) return null;
if (!data) {
return '-';
}
return (
<LiquidationPrice
marketId={data.marketId}
openVolume={data.openVolume}
collateralAvailable={data.totalBalance}
decimalPlaces={data.decimals}
formatDecimals={data.marketDecimalPlaces}
marketDecimalPlaces={data.marketDecimalPlaces}
/>
);
},
},
{
headerName: t('Asset'),
field: 'assetSymbol',
colId: 'asset',
cellRenderer: ({ data }: VegaICellRendererParams<Position>) => {
if (!data) return null;
return (
<ButtonLink
title={t('View settlement asset details')}
onClick={(e) => {
openAssetDetailsDialog(
data.assetId,
e.target as HTMLElement
);
}}
>
{data?.assetSymbol}
</ButtonLink>
);
},
},
{
headerName: t('Entry price'),
field: 'averageEntryPrice',
type: 'rightAligned',
cellRenderer: PriceFlashCell,
filter: 'agNumberColumnFilter',
valueGetter: ({ data }: VegaValueGetterParams<Position>) => {
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<Position, 'currentLeverage'>) =>
value === undefined ? '' : formatNumber(value.toString(), 1),
},
multipleKeys
? null
: {
headerName: t('Margin'),
field: 'marginAccountBalance',
type: 'rightAligned',
filter: 'agNumberColumnFilter',
cellRenderer: PriceFlashCell,
valueGetter: ({ data }: VegaValueGetterParams<Position>) => {
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<Position>) => {
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 (
<TooltipCellComponent
{...args}
value={
<>
<p className="mb-2">
{t('Realised PNL: %s', args.value)}
</p>
<p className="mb-2">
{t(
'Lifetime loss socialisation deductions: %s',
lossesFormatted
)}
</p>
<p className="mb-2">
{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
)}
</p>
{LOSS_SOCIALIZATION_LINK && (
<ExternalLink href={LOSS_SOCIALIZATION_LINK}>
{t('Read more about loss socialisation')}
</ExternalLink>
)}
</>
}
/>
);
},
valueFormatter: ({
data,
}: VegaValueFormatterParams<Position, 'realisedPNL'>) => {
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<Position>) => {
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<Position, 'unrealisedPNL'>) =>
!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: 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<Position, 'realisedPNL'>) => {
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 (
<WarningCell
tooltipContent={
<>
<p className="mb-2">
{t('Lifetime loss socialisation deductions: %s', lossesFormatted)}
</p>
<p className="mb-2">
{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]
)}
</p>
{LOSS_SOCIALIZATION_LINK && (
<ExternalLink href={LOSS_SOCIALIZATION_LINK}>
{t('Read more about loss socialisation')}
</ExternalLink>
)}
</>
}
>
{valueFormatted}
</WarningCell>
);
return <WarningCell>{valueFormatted}</WarningCell>;
};
export const OpenVolumeCell = ({
valueFormatted,
data,
}: VegaICellRendererParams<Position, 'openVolume'>) => {
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 = (
<StackedCell primary={valueFormatted} secondary={notional} />
);
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 (
<WarningCell
showIcon={data.status !== PositionStatus.POSITION_STATUS_UNSPECIFIED}
tooltipContent={
<>
<p className="mb-2">{primaryTooltip}</p>
<p className="mb-2">{secondaryTooltip}</p>
<p className="mb-2">
{t('Status: %s', PositionStatusMapping[data.status])}
</p>
{POSITION_RESOLUTION_LINK && (
<ExternalLink href={POSITION_RESOLUTION_LINK}>
{t('Read more about position resolution')}
</ExternalLink>
)}
</>
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}
</WarningCell>
);
};
const WarningCell = ({
children,
tooltipContent,
showIcon = true,
}: {
children: ReactNode;
tooltipContent: ReactNode;
showIcon?: boolean;
}) => {
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">
{showIcon && <Icon name="warning-sign" size={3} />}
<div className="flex justify-end items-center">
{showIcon && (
<span className="text-black dark:text-white mr-2">
<VegaIcon name={VegaIconNames.EXCLAIMATION_MARK} size={12} />
</span>
<span className="text-ellipsis overflow-hidden">{children}</span>
</div>
</Tooltip>
)}
<span className="overflow-hidden whitespace-nowrap text-ellipsis">
{children}
</span>
</div>
);
};

View File

@ -0,0 +1,18 @@
import type { ReactNode } from 'react';
export const StackedCell = ({
primary,
secondary,
}: {
primary: ReactNode;
secondary: ReactNode;
}) => {
return (
<div className="leading-4 text-ellipsis whitespace-nowrap overflow-hidden">
<div data-testid="stack-cell-primary">{primary}</div>
<div data-testid="stack-cell-secondary" className="text-muted">
{secondary}
</div>
</div>
);
};

View File

@ -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 = (
<>
<span data-testid="market-code">{value as string}</span>
<MarketProductPill productType={productType} />
{type && (
<Pill
size="xxs"
className="uppercase ml-0.5"
title={ProposalProductTypeMapping[type]}
>
{ProposalProductTypeShortName[type]}
</Pill>
)}
</>
);
if (data?.id) {

View File

@ -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');
});
});

View File

@ -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;
};

View File

@ -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<ProductType, string> = {
Future: 'Future',
};
export const ProductTypeShortName: Record<ProductType, string> = {
Future: 'Futr',
};
export const ProposalProductTypeMapping: Record<ProposalProductType, string> = {
FutureProduct: 'Future',
};
export const ProposalProductTypeShortName: Record<ProposalProductType, string> =
{
FutureProduct: 'Futr',
};

View File

@ -1,3 +1,4 @@
export * from './__generated__/types';
export * from './candle';
export * from './global-types-mappings';
export * from './product';

View File

@ -0,0 +1,7 @@
import type { Product } from './__generated__/types';
export type ProductType = NonNullable<Product['__typename']>;
// TODO: Update to be dynamically created for ProductionConfiguration union when schema
// changes make it to stagnet1
export type ProposalProductType = 'FutureProduct';

View File

@ -60,5 +60,9 @@ export const Tooltip = ({
);
export const TooltipCellComponent = (props: ITooltipParams) => {
return <p className={tooltipContentClasses}>{props.value}</p>;
return (
<div className={tooltipContentClasses} role="tooltip">
{props.value}
</div>
);
};

View File

@ -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 }) => {