From cf34fb2ef99732b46b5ece607b4f105d39e80964 Mon Sep 17 00:00:00 2001 From: Maciek Date: Mon, 4 Sep 2023 09:46:02 +0200 Subject: [PATCH] chore(trading): handle negative decimals (#4659) --- .../deal-ticket/deal-ticket.spec.tsx | 33 +++++++++++++++++-- libs/fills/src/lib/fills-table.spec.tsx | 22 ++++++++++++- .../components/order-list/order-list.spec.tsx | 20 ++++++++++- .../src/lib/positions-table.spec.tsx | 13 ++++++++ libs/utils/src/lib/format/number.spec.ts | 28 ++++++++++++++++ libs/utils/src/lib/format/number.ts | 8 +++-- .../utils/src/lib/validate/validate-amount.ts | 6 ++++ 7 files changed, 122 insertions(+), 8 deletions(-) diff --git a/libs/deal-ticket/src/components/deal-ticket/deal-ticket.spec.tsx b/libs/deal-ticket/src/components/deal-ticket/deal-ticket.spec.tsx index 5008c2b59..c672e93bc 100644 --- a/libs/deal-ticket/src/components/deal-ticket/deal-ticket.spec.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/deal-ticket.spec.tsx @@ -21,6 +21,8 @@ import { import * as positionsTools from '@vegaprotocol/positions'; import { OrdersDocument } from '@vegaprotocol/orders'; import { formatForInput } from '@vegaprotocol/utils'; +import type { PartialDeep } from 'type-fest'; +import type { Market } from '@vegaprotocol/markets'; jest.mock('zustand'); jest.mock('./deal-ticket-fee-details', () => ({ @@ -36,12 +38,19 @@ const market = generateMarket(); const marketData = generateMarketData(); const submit = jest.fn(); -function generateJsx(mocks: MockedResponse[] = []) { +function generateJsx( + mocks: MockedResponse[] = [], + marketOverrides: PartialDeep = {} +) { + const joinedMarket: Market = { + ...market, + ...marketOverrides, + } as Market; return ( { expect(screen.getByTestId('iceberg')).toBeDisabled(); }); - // eslint-disable-next-line jest/no-disabled-tests it('handles TIF select box dependent on order type', async () => { render(generateJsx()); @@ -533,6 +541,25 @@ describe('DealTicket', () => { expect(screen.queryByTestId(priceErrorMessage)).toBeNull(); }); + it('validates size when positionDecimalPlaces is negative', async () => { + render(generateJsx([], { positionDecimalPlaces: -4 })); + const sizeErrorMessage = 'deal-ticket-error-message-size'; + const sizeInput = 'order-size'; + await userEvent.click(screen.getByTestId('place-order')); + // default value should be invalid + expect(screen.getByTestId(sizeErrorMessage)).toBeInTheDocument(); + expect(screen.getByTestId(sizeErrorMessage)).toHaveTextContent( + 'Size cannot be lower than 10000' + ); + await userEvent.type(screen.getByTestId(sizeInput), '10001'); + expect(screen.getByTestId(sizeErrorMessage)).toHaveTextContent( + 'Size must be a multiple of 10000 for this market' + ); + await userEvent.clear(screen.getByTestId(sizeInput)); + await userEvent.type(screen.getByTestId(sizeInput), '10000'); + expect(screen.queryByTestId(sizeErrorMessage)).toBeNull(); + }); + it('validates iceberg field', async () => { const peakSizeErrorMessage = 'deal-ticket-peak-error-message'; const minimumSizeErrorMessage = 'deal-ticket-minimum-error-message'; diff --git a/libs/fills/src/lib/fills-table.spec.tsx b/libs/fills/src/lib/fills-table.spec.tsx index b65fe4dea..f68e124ff 100644 --- a/libs/fills/src/lib/fills-table.spec.tsx +++ b/libs/fills/src/lib/fills-table.spec.tsx @@ -4,7 +4,6 @@ import { getDateTimeFormat } from '@vegaprotocol/utils'; import * as Schema from '@vegaprotocol/types'; import type { PartialDeep } from 'type-fest'; import type { Trade } from './fills-data-provider'; - import { FillsTable, getFeesBreakdown } from './fills-table'; import { generateFill } from './test-helpers'; @@ -215,6 +214,27 @@ describe('FillsTable', () => { ).toBeInTheDocument(); }); + it('negative positionDecimalPoints should be properly rendered in size column', async () => { + const partyId = 'party-id'; + const negativeDecimalPositionFill = generateFill({ + ...defaultFill, + market: { + ...defaultFill.market, + positionDecimalPlaces: -4, + }, + }); + await act(async () => { + render( + + ); + }); + + const sizeCell = screen + .getAllByRole('gridcell') + .find((c) => c.getAttribute('col-id') === 'size'); + expect(sizeCell).toHaveTextContent('3,000,000,000'); + }); + describe('getFeesBreakdown', () => { it('should return correct fees breakdown for a taker', () => { const fees = { diff --git a/libs/orders/src/lib/components/order-list/order-list.spec.tsx b/libs/orders/src/lib/components/order-list/order-list.spec.tsx index 8dde7f8a9..5cfa49863 100644 --- a/libs/orders/src/lib/components/order-list/order-list.spec.tsx +++ b/libs/orders/src/lib/components/order-list/order-list.spec.tsx @@ -6,7 +6,7 @@ import type { PartialDeep } from 'type-fest'; import type { VegaWalletContextShape } from '@vegaprotocol/wallet'; import { VegaWalletContext } from '@vegaprotocol/wallet'; import { MockedProvider } from '@apollo/client/testing'; -import type { OrderFieldsFragment, OrderListTableProps } from '../'; +import type { Order, OrderFieldsFragment, OrderListTableProps } from '../'; import { OrderListTable } from '../'; import { generateOrder, @@ -164,6 +164,24 @@ describe('OrderListTable', () => { ); }); + it('negative positionDecimalPoints should be properly rendered in size column', async () => { + const localMarketOrder = { + ...marketOrder, + size: '3000', + market: { + ...marketOrder.market, + positionDecimalPlaces: -4, + }, + } as Order; + + await act(async () => { + render(generateJsx({ rowData: [localMarketOrder] })); + }); + + const cells = screen.getAllByRole('gridcell'); + expect(cells[2]).toHaveTextContent('+30,000,000'); + }); + describe('amend cell', () => { it('allows cancelling and editing for permitted orders', async () => { const mockEdit = jest.fn(); diff --git a/libs/positions/src/lib/positions-table.spec.tsx b/libs/positions/src/lib/positions-table.spec.tsx index 79fc39b6d..7dc85c5d1 100644 --- a/libs/positions/src/lib/positions-table.spec.tsx +++ b/libs/positions/src/lib/positions-table.spec.tsx @@ -201,6 +201,19 @@ describe('Positions', () => { ).not.toBeInTheDocument(); }); + it('handle negative positionDecimalPlaces', async () => { + await renderComponent({ + ...singleRow, + openVolume: '-2000', + positionDecimalPlaces: -4, + }); + const cells = screen.getAllByRole('gridcell'); + const cell = cells[1]; + expect(within(cell).getByTestId('stack-cell-primary')).toHaveTextContent( + '-20,000,000' + ); + }); + describe('PNLCell', () => { const props = { data: undefined, diff --git a/libs/utils/src/lib/format/number.spec.ts b/libs/utils/src/lib/format/number.spec.ts index d36dfb719..119087fca 100644 --- a/libs/utils/src/lib/format/number.spec.ts +++ b/libs/utils/src/lib/format/number.spec.ts @@ -7,6 +7,8 @@ import { formatNumberPercentage, getUnlimitedThreshold, isNumeric, + removeDecimal, + toBigNum, quantumDecimalPlaces, toDecimal, toNumberParts, @@ -153,10 +155,36 @@ describe('number utils', () => { { v: 7, o: '0.0000001' }, { v: 8, o: '0.00000001' }, { v: 9, o: '0.000000001' }, + { v: -1, o: '10' }, + { v: -2, o: '100' }, + { v: -3, o: '1000' }, ])('formats with toNumber given number correctly', ({ v, o }) => { expect(toDecimal(v)).toStrictEqual(o); }); }); + + describe('positive and negative decimals should be handled correctly', () => { + const baseNum = '2000'; + const methods = [removeDecimal, toBigNum]; + it.each([ + { decimals: 0, result: ['2000', '2000'] }, + { decimals: 1, result: ['20000', '200'] }, + { decimals: -1, result: ['200', '20000'] }, + { decimals: 2, result: ['200000', '20'] }, + { decimals: -2, result: ['20', '200000'] }, + { decimals: 3, result: ['2000000', '2'] }, + { decimals: -3, result: ['2', '2000000'] }, + { decimals: 4, result: ['20000000', '0.2'] }, + { decimals: -4, result: ['0', '20000000'] }, // removeDecimal has toFixed(0) at the end + ])( + 'number methods should handle negative decimals', + ({ decimals, result }) => { + methods.forEach((method, i) => { + expect(method(baseNum, decimals).toString()).toEqual(result[i]); + }); + } + ); + }); }); describe('quantumDecimalPlaces', () => { diff --git a/libs/utils/src/lib/format/number.ts b/libs/utils/src/lib/format/number.ts index e0d035a50..bf5a93ff4 100644 --- a/libs/utils/src/lib/format/number.ts +++ b/libs/utils/src/lib/format/number.ts @@ -21,7 +21,7 @@ const MAX_FRACTION_DIGITS = 20; export function toDecimal(numberOfDecimals: number) { return new BigNumber(1) - .dividedBy(Math.pow(10, numberOfDecimals)) + .dividedBy(new BigNumber(10).exponentiatedBy(numberOfDecimals)) .toString(10); } @@ -29,7 +29,8 @@ export function toBigNum( rawValue: string | number, decimals: number ): BigNumber { - return new BigNumber(rawValue || 0).dividedBy(Math.pow(10, decimals)); + const divides = new BigNumber(10).exponentiatedBy(decimals); + return new BigNumber(rawValue || 0).dividedBy(divides); } export function addDecimal( @@ -48,7 +49,8 @@ export function removeDecimal( value: string | BigNumber, decimals: number ): string { - return new BigNumber(value || 0).times(Math.pow(10, decimals)).toFixed(0); + const times = new BigNumber(10).exponentiatedBy(decimals); + return new BigNumber(value || 0).times(times).toFixed(0); } // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat diff --git a/libs/utils/src/lib/validate/validate-amount.ts b/libs/utils/src/lib/validate/validate-amount.ts index ef45d3bba..95549ab1f 100644 --- a/libs/utils/src/lib/validate/validate-amount.ts +++ b/libs/utils/src/lib/validate/validate-amount.ts @@ -4,6 +4,12 @@ export const validateAmount = (step: number | string, field: string) => { const [, stepDecimals = ''] = String(step).split('.'); return (value?: string) => { + if (Number(step) > 1) { + if (Number(value) % Number(step) > 0) { + return t(`${field} must be a multiple of ${step} for this market`); + } + return true; + } const [, valueDecimals = ''] = (value || '').split('.'); if (stepDecimals.length < valueDecimals.length) { if (stepDecimals === '') {