diff --git a/apps/console-lite-e2e/src/integration/market-trade.test.ts b/apps/console-lite-e2e/src/integration/market-trade.test.ts index f4dc1dbb1..6665940bd 100644 --- a/apps/console-lite-e2e/src/integration/market-trade.test.ts +++ b/apps/console-lite-e2e/src/integration/market-trade.test.ts @@ -170,7 +170,50 @@ describe('Market trade', () => { .find('button') .should('have.text', '2'); cy.get('button').contains('Max').click(); - cy.getByTestId('price-slippage-value').should('have.text', '0.02%'); + } + }); + + it('slippage value should be displayed', () => { + if (markets?.length) { + cy.visit(`/trading/${markets[1].id}`); + connectVegaWallet(); + cy.get('#step-1-control [aria-label^="Selected value"]').click(); + cy.get('button[aria-label="Open short position"]').click(); + cy.get('#step-2-control').click(); + cy.get('button').contains('Max').click(); + cy.get('#step-2-panel') + .find('dl') + .eq(2) + .find('dd') + .should('have.text', '0.02%'); + } + }); + + it('allow slippage value to be adjusted', () => { + if (markets?.length) { + cy.visit(`/trading/${markets[1].id}`); + connectVegaWallet(); + cy.get('#step-1-control [aria-label^="Selected value"]').click(); + cy.get('button[aria-label="Open short position"]').click(); + cy.get('#step-2-control').click(); + cy.get('button').contains('Max').click(); + cy.get('#step-2-panel') + .find('dl') + .eq(2) + .find('dd') + .should('have.text', '0.02%'); + cy.get('#step-2-panel').find('dl').eq(2).find('button').click(); + cy.get('#input-order-slippage') + .focus() + .type('{backspace}{backspace}{backspace}1'); + + cy.getByTestId('slippage-dialog').find('button').click(); + + cy.get('#step-2-panel') + .find('dl') + .eq(2) + .find('dd') + .should('have.text', '1%'); } }); @@ -195,9 +238,9 @@ describe('Market trade', () => { cy.get('#step-2-panel').find('dd').eq(0).find('button').click(); cy.get('#step-2-panel') .find('dt') - .eq(3) + .eq(2) .should('have.text', 'Est. Position Size (tDAI)'); - cy.get('#step-2-panel').find('dd').eq(3).should('have.text', '197.86012'); + cy.get('#step-2-panel').find('dd').eq(2).should('have.text', '197.86012'); } }); @@ -210,11 +253,11 @@ describe('Market trade', () => { cy.get('#step-2-control').click(); cy.get('#step-2-panel') .find('dt') - .eq(4) + .eq(3) .should('have.text', 'Est. Fees (tDAI)'); cy.get('#step-2-panel') .find('dd') - .eq(4) + .eq(3) .should('have.text', '3.00000 (3.03%)'); } }); diff --git a/apps/console-lite/src/app/components/deal-ticket/deal-ticket-estimates.tsx b/apps/console-lite/src/app/components/deal-ticket/deal-ticket-estimates.tsx index 97c450d29..21a842ffb 100644 --- a/apps/console-lite/src/app/components/deal-ticket/deal-ticket-estimates.tsx +++ b/apps/console-lite/src/app/components/deal-ticket/deal-ticket-estimates.tsx @@ -4,6 +4,7 @@ import { t } from '@vegaprotocol/react-helpers'; import { Icon, Tooltip } from '@vegaprotocol/ui-toolkit'; import { IconNames } from '@blueprintjs/icons'; import * as constants from './constants'; +import { TrafficLight } from '../traffic-light'; interface DealTicketEstimatesProps { quoteName?: string; @@ -13,6 +14,7 @@ interface DealTicketEstimatesProps { fees?: string; notionalSize?: string; size?: string; + slippage?: string; } interface DataTitleProps { @@ -20,7 +22,7 @@ interface DataTitleProps { quoteName?: string; } -const DataTitle = ({ children, quoteName = '' }: DataTitleProps) => ( +export const DataTitle = ({ children, quoteName = '' }: DataTitleProps) => (
{children} {quoteName && ({quoteName})} @@ -28,14 +30,20 @@ const DataTitle = ({ children, quoteName = '' }: DataTitleProps) => ( ); interface ValueTooltipProps { - value: string; + value?: string; + children?: ReactNode; description: string; id?: string; } -const ValueTooltipRow = ({ value, description, id }: ValueTooltipProps) => ( +export const ValueTooltipRow = ({ + value, + children, + description, + id, +}: ValueTooltipProps) => (
- {value} + {value || children}
(
{size && ( @@ -93,7 +102,7 @@ export const DealTicketEstimates = ({
)} {estMargin && ( -
+
{t('Est. Margin')} )} {estCloseOut && ( -
-
- {t('Est. Close out')} -   - ({quoteName}) -
+
+ {t('Est. Close out')}
)} + {slippage && ( +
+ {t('Est. Price Impact / Slippage')} + + + {slippage}% + + +
+ )} ); diff --git a/apps/console-lite/src/app/components/deal-ticket/deal-ticket-size-input.tsx b/apps/console-lite/src/app/components/deal-ticket/deal-ticket-size-input.tsx new file mode 100644 index 000000000..d3b594f38 --- /dev/null +++ b/apps/console-lite/src/app/components/deal-ticket/deal-ticket-size-input.tsx @@ -0,0 +1,149 @@ +import React, { useCallback, useState } from 'react'; +import { BigNumber } from 'bignumber.js'; +import { t } from '@vegaprotocol/react-helpers'; +import { + SliderRoot, + SliderThumb, + SliderTrack, + SliderRange, + FormGroup, +} from '@vegaprotocol/ui-toolkit'; +import { InputSetter } from '../input-setter'; + +interface DealTicketSizeInputProps { + step: number; + min: number; + max: number; + value: number; + onValueChange: (value: number) => void; + positionDecimalPlaces: number; +} + +const getSizeLabel = (value: number): string => { + const MIN_LABEL = 'Min'; + const MAX_LABEL = 'Max'; + if (value === 0) { + return MIN_LABEL; + } else if (value === 100) { + return MAX_LABEL; + } + + return `${value}%`; +}; + +export const DealTicketSizeInput = ({ + value, + step, + min, + max, + onValueChange, + positionDecimalPlaces, +}: DealTicketSizeInputProps) => { + const sizeRatios = [0, 25, 50, 75, 100]; + const [inputValue, setInputValue] = useState(value); + + const onInputValueChange = useCallback( + (event: React.ChangeEvent) => { + let value = parseFloat(event.target.value); + const isLessThanMin = value < min; + const isMoreThanMax = value > max; + if (isLessThanMin) { + value = min; + } else if (isMoreThanMax) { + value = max; + } + + if (value) { + onValueChange(value); + } + + setInputValue(value); + }, + [min, max, onValueChange, setInputValue] + ); + + const onButtonValueChange = (size: number) => { + const newVal = new BigNumber(size) + .decimalPlaces(positionDecimalPlaces) + .toNumber(); + onValueChange(newVal); + setInputValue(newVal); + }; + + const onSliderValueChange = useCallback( + (value: number[]) => { + const val = value[0]; + setInputValue(val); + onValueChange(val); + }, + [onValueChange] + ); + + return ( +
+
+ {min} + {max} +
+ + + + + + + +
+ {sizeRatios.map((size, index) => { + const proportionalSize = size ? (size / 100) * max : min; + return ( + + ); + })} +
+ +
+
+
{t('Contracts')}
+
+ + + {inputValue} + + +
+
+
+
+ ); +}; diff --git a/apps/console-lite/src/app/components/deal-ticket/deal-ticket-size.tsx b/apps/console-lite/src/app/components/deal-ticket/deal-ticket-size.tsx index 7ad66a31c..b95500df0 100644 --- a/apps/console-lite/src/app/components/deal-ticket/deal-ticket-size.tsx +++ b/apps/console-lite/src/app/components/deal-ticket/deal-ticket-size.tsx @@ -1,27 +1,13 @@ -import React, { useCallback, useState } from 'react'; -import classNames from 'classnames'; -import { IconNames } from '@blueprintjs/icons'; -import { t } from '@vegaprotocol/react-helpers'; -import { - SliderRoot, - SliderThumb, - SliderTrack, - SliderRange, - FormGroup, - Icon, - Tooltip, -} from '@vegaprotocol/ui-toolkit'; -import { BigNumber } from 'bignumber.js'; +import React from 'react'; import { DealTicketEstimates } from './deal-ticket-estimates'; -import { InputSetter } from '../input-setter'; -import * as constants from './constants'; +import { DealTicketSizeInput } from './deal-ticket-size-input'; interface DealTicketSizeProps { step: number; min: number; max: number; - value: number; - onValueChange: (value: number[]) => void; + size: number; + onSizeChange: (value: number) => void; name: string; quoteName: string; price: string; @@ -30,173 +16,33 @@ interface DealTicketSizeProps { fees: string; positionDecimalPlaces: number; notionalSize: string; - slippage: string | null; } -const getSizeLabel = (value: number): string => { - const MIN_LABEL = 'Min'; - const MAX_LABEL = 'Max'; - if (value === 0) { - return MIN_LABEL; - } else if (value === 100) { - return MAX_LABEL; - } - - return `${value}%`; -}; - export const DealTicketSize = ({ - value, step, min, max, price, quoteName, - onValueChange, + size, + onSizeChange, estCloseOut, positionDecimalPlaces, fees, notionalSize, - slippage, }: DealTicketSizeProps) => { - const sizeRatios = [0, 25, 50, 75, 100]; - const [inputValue, setInputValue] = useState(value); - - const onInputValueChange = useCallback( - (event: React.ChangeEvent) => { - const value = parseFloat(event.target.value); - const isLessThanMin = value < min; - const isMoreThanMax = value > max; - if (value) { - if (isLessThanMin) { - onValueChange([min]); - } else if (isMoreThanMax) { - onValueChange([max]); - } else { - onValueChange([value]); - } - } - setInputValue(value); - }, - [min, max, onValueChange, setInputValue] - ); - - const onButtonValueChange = useCallback( - (size: number) => { - const newVal = new BigNumber(size) - .decimalPlaces(positionDecimalPlaces) - .toNumber(); - onValueChange([newVal]); - setInputValue(newVal); - }, - [onValueChange, positionDecimalPlaces] - ); - - const onSliderValueChange = useCallback( - (value: number[]) => { - setInputValue(value[0]); - onValueChange(value); - }, - [onValueChange] - ); - return max === 0 ? (

Not enough balance to trade

) : (
-
- {min} - {max} -
- - - - - - - -
- {sizeRatios.map((size, index) => { - const proportionalSize = size ? (size / 100) * max : min; - return ( - - ); - })} -
- -
-
-
{t('Contracts')}
-
- - - -
-
-
- {slippage && ( -
-
-
{t('Est. Price Impact / Slippage')}
-
- = 1 && parseFloat(slippage) < 5, - 'text-vega-red': parseFloat(slippage) >= 5, - })} - > - {slippage}% - - -
- -
-
-
-
-
- )} + value={size} + onValueChange={onSizeChange} + positionDecimalPlaces={positionDecimalPlaces} + /> { + const [isDialogVisible, setIsDialogVisible] = useState(false); + + const onChange = useCallback( + (event: React.ChangeEvent) => { + const value = event.target.value; + const numericValue = parseFloat(value); + onValueChange(numericValue); + }, + [onValueChange] + ); + + const toggleDialog = useCallback(() => { + setIsDialogVisible(!isDialogVisible); + }, [isDialogVisible]); + + const formLabel = ( + + ); + + return ( + <> + +
+ {formLabel} + + {value}% + +
+
+
+
+ {t('Est. Price Impact / Slippage')} +
+
+ + + {value}% + + +
+ +
+
+
+ + ); +}; diff --git a/apps/console-lite/src/app/components/deal-ticket/deal-ticket-steps.tsx b/apps/console-lite/src/app/components/deal-ticket/deal-ticket-steps.tsx index 18b6f7c97..078a50d76 100644 --- a/apps/console-lite/src/app/components/deal-ticket/deal-ticket-steps.tsx +++ b/apps/console-lite/src/app/components/deal-ticket/deal-ticket-steps.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useForm, Controller } from 'react-hook-form'; import { Stepper } from '../stepper'; @@ -10,7 +10,7 @@ import type { Order } from '@vegaprotocol/orders'; import { useVegaWallet, VegaTxStatus } from '@vegaprotocol/wallet'; import { t, - addDecimal, + addDecimalsFormatNumber, toDecimal, removeDecimal, } from '@vegaprotocol/react-helpers'; @@ -33,6 +33,8 @@ import useOrderCloseOut from '../../hooks/use-order-closeout'; import useOrderMargin from '../../hooks/use-order-margin'; import useMaximumPositionSize from '../../hooks/use-maximum-position-size'; import useCalculateSlippage from '../../hooks/use-calculate-slippage'; +import { Side, OrderType } from '@vegaprotocol/types'; +import { DealTicketSlippage } from './deal-ticket-slippage'; interface DealTicketMarketProps { market: DealTicketQuery_market; @@ -62,28 +64,26 @@ export const DealTicketSteps = ({ defaultValues: getDefaultOrder(market), }); - const [max, setMax] = useState(null); + const emptyString = ' - '; const step = toDecimal(market.positionDecimalPlaces); const orderType = watch('type'); const orderTimeInForce = watch('timeInForce'); const orderSide = watch('side'); const orderSize = watch('size'); const order = watch(); - const estCloseOut = useOrderCloseOut({ order, market, partyData }); + const { message: invalidText, isDisabled } = useOrderValidation({ + market, + orderType, + orderTimeInForce, + fieldErrors: errors, + }); + const { submit, transaction, finalizedOrder, Dialog } = useOrderSubmit(); const { keypair } = useVegaWallet(); const estMargin = useOrderMargin({ order, market, partyId: keypair?.pub || '', }); - const value = new BigNumber(orderSize).toNumber(); - const price = - market.depth.lastTrade && - addDecimal(market.depth.lastTrade.price, market.decimalPlaces); - const emptyString = ' - '; - - const [notionalSize, setNotionalSize] = useState(null); - const [fees, setFees] = useState(null); const maxTrade = useMaximumPositionSize({ partyId: keypair?.pub || '', @@ -94,45 +94,48 @@ export const DealTicketSteps = ({ price: market?.depth?.lastTrade?.price, order, }); + + const estCloseOut = useOrderCloseOut({ order, market, partyData }); const slippage = useCalculateSlippage({ marketId: market.id, order }); - useEffect(() => { - setMax( - new BigNumber(maxTrade) - .decimalPlaces(market.positionDecimalPlaces) - .toNumber() - ); - }, [maxTrade, market.positionDecimalPlaces]); - - const { message: invalidText, isDisabled } = useOrderValidation({ - market, - orderType, - orderTimeInForce, - fieldErrors: errors, - }); - - const { submit, transaction, finalizedOrder, Dialog } = useOrderSubmit(); - - const onSizeChange = (value: number[]) => { - const newVal = new BigNumber(value[0]) - .decimalPlaces(market.positionDecimalPlaces) - .toString(); - const isValid = validateSize(step)(newVal); - if (isValid !== 'step') { - setValue('size', newVal); - } - }; + const [slippageValue, setSlippageValue] = useState( + slippage ? parseFloat(slippage) : 0 + ); + const transactionStatus = + transaction.status === VegaTxStatus.Requested || + transaction.status === VegaTxStatus.Pending + ? 'pending' + : 'default'; useEffect(() => { - if (market?.depth?.lastTrade?.price) { - const size = new BigNumber(market.depth.lastTrade.price) - .multipliedBy(value) + setSlippageValue(slippage ? parseFloat(slippage) : 0); + }, [slippage]); + + const price = useMemo(() => { + if (slippage && market?.depth?.lastTrade?.price) { + const isLong = order.side === Side.SIDE_BUY; + const multiplier = new BigNumber(1)[isLong ? 'plus' : 'minus']( + parseFloat(slippage) / 100 + ); + return new BigNumber(market?.depth?.lastTrade?.price) + .multipliedBy(multiplier) .toNumber(); - - setNotionalSize(addDecimal(size, market.decimalPlaces)); } - }, [market, value]); + return null; + }, [market?.depth?.lastTrade?.price, order.side, slippage]); - useEffect(() => { + const formattedPrice = + price && addDecimalsFormatNumber(price, market.decimalPlaces); + + const notionalSize = useMemo(() => { + if (price) { + const size = new BigNumber(price).multipliedBy(orderSize).toNumber(); + + return addDecimalsFormatNumber(size, market.decimalPlaces); + } + return null; + }, [market.decimalPlaces, orderSize, price]); + + const fees = useMemo(() => { if (estMargin?.fees && notionalSize) { const percentage = new BigNumber(estMargin?.fees) .dividedBy(notionalSize) @@ -140,17 +143,66 @@ export const DealTicketSteps = ({ .decimalPlaces(2) .toString(); - setFees(`${estMargin.fees} (${percentage}%)`); + return `${estMargin.fees} (${percentage}%)`; } - }, [estMargin, notionalSize]); - const transactionStatus = - transaction.status === VegaTxStatus.Requested || - transaction.status === VegaTxStatus.Pending - ? 'pending' - : 'default'; + return null; + }, [estMargin?.fees, notionalSize]); - const onSubmit = React.useCallback( + const max = useMemo(() => { + return new BigNumber(maxTrade) + .decimalPlaces(market.positionDecimalPlaces) + .toNumber(); + }, [market.positionDecimalPlaces, maxTrade]); + + const onSizeChange = useCallback( + (value: number) => { + const newVal = new BigNumber(value) + .decimalPlaces(market.positionDecimalPlaces) + .toString(); + const isValid = validateSize(step)(newVal); + if (isValid !== 'step') { + setValue('size', newVal); + } + }, + [market.positionDecimalPlaces, setValue, step] + ); + + const onSlippageChange = useCallback( + (value: number) => { + if (market?.depth?.lastTrade?.price) { + if (value) { + const isLong = order.side === Side.SIDE_BUY; + const multiplier = new BigNumber(1)[isLong ? 'plus' : 'minus']( + value / 100 + ); + const bestAskPrice = new BigNumber(market?.depth?.lastTrade?.price) + .multipliedBy(multiplier) + .decimalPlaces(market.decimalPlaces) + .toString(); + + setValue('price', bestAskPrice); + + if (orderType === OrderType.TYPE_MARKET) { + setValue('type', OrderType.TYPE_LIMIT); + } + } else { + setValue('type', OrderType.TYPE_MARKET); + setValue('price', market?.depth?.lastTrade?.price); + } + } + setSlippageValue(value); + }, + [ + market.decimalPlaces, + market?.depth?.lastTrade?.price, + order.side, + orderType, + setValue, + ] + ); + + const onSubmit = useCallback( (order: Order) => { if (transactionStatus !== 'pending') { submit({ @@ -198,25 +250,30 @@ export const DealTicketSteps = ({ label: t('Choose Position Size'), component: max !== null ? ( - + <> + + + ) : ( 'loading...' ), @@ -240,13 +297,14 @@ export const DealTicketSteps = ({ order={order} estCloseOut={estCloseOut} estMargin={estMargin?.margin || emptyString} - price={price || emptyString} + price={formattedPrice || emptyString} quoteName={ market.tradableInstrument.instrument.product.settlementAsset .symbol } notionalSize={notionalSize || emptyString} fees={fees || emptyString} + slippage={slippageValue} /> { const { data: tagsData } = useQuery( MARKET_TAGS_QUERY, @@ -105,6 +107,7 @@ export default ({ fees={fees} estCloseOut={estCloseOut} notionalSize={notionalSize} + slippage={slippage.toString()} />
diff --git a/apps/console-lite/src/app/components/input-setter/input-setter.tsx b/apps/console-lite/src/app/components/input-setter/input-setter.tsx index 9e58f3f44..93a229d79 100644 --- a/apps/console-lite/src/app/components/input-setter/input-setter.tsx +++ b/apps/console-lite/src/app/components/input-setter/input-setter.tsx @@ -1,20 +1,19 @@ import React, { useCallback, useState } from 'react'; +import type { ReactNode } from 'react'; import { t } from '@vegaprotocol/react-helpers'; import { Input } from '@vegaprotocol/ui-toolkit'; import type { InputProps } from '@vegaprotocol/ui-toolkit'; interface InputSetterProps { buttonLabel?: string; - value: string | number; isInputVisible?: boolean; - onValueChange?: () => string; + children?: ReactNode; } export const InputSetter = ({ buttonLabel = t('set'), - value = '', isInputVisible = false, - onValueChange, + children, ...props }: InputSetterProps & InputProps) => { const [isInputToggled, setIsInputToggled] = useState(isInputVisible); @@ -35,7 +34,7 @@ export const InputSetter = ({ return isInputToggled ? (
- + ); }; diff --git a/apps/console-lite/src/app/components/stepper/stepper.tsx b/apps/console-lite/src/app/components/stepper/stepper.tsx index 1cdfd35c8..91909d616 100644 --- a/apps/console-lite/src/app/components/stepper/stepper.tsx +++ b/apps/console-lite/src/app/components/stepper/stepper.tsx @@ -88,7 +88,7 @@ export const Stepper = ({ steps }: StepperProps) => { 'md:mt-0 font-alpha uppercase text-black dark:text-white', { 'mt-2 text-md md:text-2xl': isActive, - 'mt-4 text-sm md:text-lg md:ml-8': !isActive, + 'mt-4 text-sm md:text-lg md:ml-2': !isActive, } )} > diff --git a/apps/console-lite/src/app/components/traffic-light/index.ts b/apps/console-lite/src/app/components/traffic-light/index.ts new file mode 100644 index 000000000..f3a7e5c0c --- /dev/null +++ b/apps/console-lite/src/app/components/traffic-light/index.ts @@ -0,0 +1 @@ +export * from './traffic-light'; diff --git a/apps/console-lite/src/app/components/traffic-light/traffic-light.tsx b/apps/console-lite/src/app/components/traffic-light/traffic-light.tsx new file mode 100644 index 000000000..6d2e199bc --- /dev/null +++ b/apps/console-lite/src/app/components/traffic-light/traffic-light.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import type { ReactNode } from 'react'; +import classNames from 'classnames'; + +interface TrafficLightProps { + value: number; + q1: number; + q2: number; + children: ReactNode; +} + +export const TrafficLight = ({ + value, + q1, + q2, + children, +}: TrafficLightProps) => { + const slippageClassName = classNames({ + 'text-darkerGreen dark:text-lightGreen': value < q1, + 'text-amber': value >= q1 && value < q2, + 'text-vega-red': value >= q2, + }); + + return
{children || value}
; +};