feat(deal-ticket): submit oco stop orders (#4539)

This commit is contained in:
Bartłomiej Głownia 2023-08-25 08:37:14 +02:00 committed by GitHub
parent 4684745382
commit 6a9f15f59e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1082 additions and 962 deletions

View File

@ -64,7 +64,7 @@ describe('deal ticker order validation', { tags: '@smoke' }, () => {
cy.getByTestId(orderSizeField).clear().type('1'); cy.getByTestId(orderSizeField).clear().type('1');
cy.getByTestId(orderPriceField).clear().type('1.123456'); cy.getByTestId(orderPriceField).clear().type('1.123456');
cy.getByTestId(placeOrderBtn).click(); cy.getByTestId(placeOrderBtn).click();
cy.getByTestId('deal-ticket-error-message-price-limit').should( cy.getByTestId('deal-ticket-error-message-price').should(
'have.text', 'have.text',
'Price accepts up to 5 decimal places' 'Price accepts up to 5 decimal places'
); );
@ -87,7 +87,7 @@ describe('deal ticker order validation', { tags: '@smoke' }, () => {
cy.getByTestId(orderSizeField).clear().type('1.234'); cy.getByTestId(orderSizeField).clear().type('1.234');
// 7002-SORD-060 // 7002-SORD-060
cy.getByTestId(placeOrderBtn).should('be.enabled'); cy.getByTestId(placeOrderBtn).should('be.enabled');
cy.getByTestId('deal-ticket-error-message-size-market').should( cy.getByTestId('deal-ticket-error-message-size').should(
'have.text', 'have.text',
'Size must be whole numbers for this market' 'Size must be whole numbers for this market'
); );
@ -96,7 +96,7 @@ describe('deal ticker order validation', { tags: '@smoke' }, () => {
it('must warn if order size is set to 0', function () { it('must warn if order size is set to 0', function () {
cy.getByTestId(orderSizeField).clear().type('0'); cy.getByTestId(orderSizeField).clear().type('0');
cy.getByTestId(placeOrderBtn).should('be.enabled'); cy.getByTestId(placeOrderBtn).should('be.enabled');
cy.getByTestId('deal-ticket-error-message-size-market').should( cy.getByTestId('deal-ticket-error-message-size').should(
'have.text', 'have.text',
'Size cannot be lower than 1' 'Size cannot be lower than 1'
); );

View File

@ -36,6 +36,6 @@ export const StopOrdersContainer = () => {
const useStopOrdersStore = create<DataGridSlice>()( const useStopOrdersStore = create<DataGridSlice>()(
persist(createDataGridSlice, { persist(createDataGridSlice, {
name: 'vega_fills_store', name: 'vega_stop_orders_store',
}) })
); );

View File

@ -209,3 +209,15 @@ html [data-theme='dark'] {
box-shadow: inset 0 0 6px rgb(0 0 0 / 30%); box-shadow: inset 0 0 6px rgb(0 0 0 / 30%);
background-color: #999; background-color: #999;
} }
/* Chrome, Safari, Edge, Opera */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type='number'] {
-moz-appearance: textfield;
}

View File

@ -1,39 +0,0 @@
import type { Control } from 'react-hook-form';
import type { Market, StaticMarketData } from '@vegaprotocol/markets';
import { DealTicketMarketAmount } from './deal-ticket-market-amount';
import { DealTicketLimitAmount } from './deal-ticket-limit-amount';
import * as Schema from '@vegaprotocol/types';
import type { OrderFormValues } from '../../hooks/use-form-values';
export interface DealTicketAmountProps {
control: Control<OrderFormValues>;
type: Schema.OrderType;
marketData: StaticMarketData;
marketPrice?: string;
market: Market;
sizeError?: string;
priceError?: string;
}
export const DealTicketAmount = ({
type,
marketData,
marketPrice,
...props
}: DealTicketAmountProps) => {
switch (type) {
case Schema.OrderType.TYPE_MARKET:
return (
<DealTicketMarketAmount
{...props}
marketData={marketData}
marketPrice={marketPrice}
/>
);
case Schema.OrderType.TYPE_LIMIT:
return <DealTicketLimitAmount {...props} />;
default: {
throw new Error('Invalid ticket type ' + type);
}
}
};

View File

@ -1,120 +0,0 @@
import {
TradingFormGroup,
TradingInput,
TradingInputError,
} from '@vegaprotocol/ui-toolkit';
import { toDecimal, validateAmount } from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n';
import type { DealTicketAmountProps } from './deal-ticket-amount';
import { Controller } from 'react-hook-form';
export type DealTicketLimitAmountProps = Omit<
DealTicketAmountProps,
'marketData' | 'type'
>;
export const DealTicketLimitAmount = ({
control,
market,
sizeError,
priceError,
}: DealTicketLimitAmountProps) => {
const priceStep = toDecimal(market?.decimalPlaces);
const sizeStep = toDecimal(market?.positionDecimalPlaces);
const quoteName = market.tradableInstrument.instrument.product.quoteName;
const renderError = () => {
if (sizeError) {
return (
<TradingInputError testId="deal-ticket-error-message-size-limit">
{sizeError}
</TradingInputError>
);
}
if (priceError) {
return (
<TradingInputError testId="deal-ticket-error-message-price-limit">
{priceError}
</TradingInputError>
);
}
return null;
};
return (
<div className="mb-2">
<div className="flex items-start gap-4">
<div className="flex-1">
<TradingFormGroup
label={t('Size')}
labelFor="input-order-size-limit"
className="!mb-0"
>
<Controller
name="size"
control={control}
rules={{
required: t('You need to provide a size'),
min: {
value: sizeStep,
message: t('Size cannot be lower than ' + sizeStep),
},
validate: validateAmount(sizeStep, 'Size'),
}}
render={({ field, fieldState }) => (
<TradingInput
id="input-order-size-limit"
className="w-full"
type="number"
step={sizeStep}
min={sizeStep}
data-testid="order-size"
onWheel={(e) => e.currentTarget.blur()}
hasError={!!fieldState.error}
{...field}
/>
)}
/>
</TradingFormGroup>
</div>
<div className="pt-5 leading-10">@</div>
<div className="flex-1">
<TradingFormGroup
labelFor="input-price-quote"
label={t(`Price (${quoteName})`)}
labelAlign="right"
className="!mb-0"
>
<Controller
name="price"
control={control}
rules={{
required: t('You need provide a price'),
min: {
value: priceStep,
message: t('Price cannot be lower than ' + priceStep),
},
validate: validateAmount(priceStep, 'Price'),
}}
render={({ field, fieldState }) => (
<TradingInput
id="input-price-quote"
className="w-full"
type="number"
step={priceStep}
data-testid="order-price"
onWheel={(e) => e.currentTarget.blur()}
hasError={!!fieldState.error}
{...field}
/>
)}
/>
</TradingFormGroup>
</div>
</div>
{renderError()}
</div>
);
};

View File

@ -1,102 +0,0 @@
import {
addDecimalsFormatNumber,
toDecimal,
validateAmount,
} from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n';
import {
TradingInput,
TradingInputError,
Tooltip,
} from '@vegaprotocol/ui-toolkit';
import { isMarketInAuction } from '@vegaprotocol/markets';
import type { DealTicketAmountProps } from './deal-ticket-amount';
import { Controller } from 'react-hook-form';
import classNames from 'classnames';
export type DealTicketMarketAmountProps = Omit<DealTicketAmountProps, 'type'>;
export const DealTicketMarketAmount = ({
control,
market,
marketData,
marketPrice,
sizeError,
}: DealTicketMarketAmountProps) => {
const quoteName = market.tradableInstrument.instrument.product.quoteName;
const sizeStep = toDecimal(market?.positionDecimalPlaces);
const price = marketPrice;
const priceFormatted = price
? addDecimalsFormatNumber(price, market.decimalPlaces)
: undefined;
const inAuction = isMarketInAuction(marketData.marketTradingMode);
return (
<div className="mb-2">
<div className="flex items-start gap-4">
<div className="flex-1">
<div className="mb-2 text-xs">{t('Size')}</div>
<Controller
name="size"
control={control}
rules={{
required: t('You need to provide a size'),
min: {
value: sizeStep,
message: t('Size cannot be lower than ' + sizeStep),
},
validate: validateAmount(sizeStep, 'Size'),
}}
render={({ field, fieldState }) => (
<TradingInput
id="input-order-size-market"
className="w-full"
type="number"
step={sizeStep}
min={sizeStep}
onWheel={(e) => e.currentTarget.blur()}
data-testid="order-size"
hasError={!!fieldState.error}
{...field}
/>
)}
/>
</div>
<div className="pt-5 leading-10">@</div>
<div className="flex-1 text-sm text-right">
{inAuction && (
<Tooltip
description={t(
'This market is in auction. The uncrossing price is an indication of what the price is expected to be when the auction ends.'
)}
>
<div className="mb-2">{t(`Indicative price`)}</div>
</Tooltip>
)}
<div
data-testid="last-price"
className={classNames('leading-10', { 'pt-5': !inAuction })}
>
{priceFormatted && quoteName ? (
<>
~{priceFormatted} {quoteName}
</>
) : (
'-'
)}
</div>
</div>
</div>
{sizeError && (
<TradingInputError
intent="danger"
testId="deal-ticket-error-message-size-market"
>
{sizeError}
</TradingInputError>
)}
</div>
);
};

View File

@ -72,6 +72,7 @@ const timeInForce = 'order-tif';
const sizeErrorMessage = 'stop-order-error-message-size'; const sizeErrorMessage = 'stop-order-error-message-size';
const priceErrorMessage = 'stop-order-error-message-price'; const priceErrorMessage = 'stop-order-error-message-price';
const triggerPriceErrorMessage = 'stop-order-error-message-trigger-price'; const triggerPriceErrorMessage = 'stop-order-error-message-trigger-price';
const triggerPriceWarningMessage = 'stop-order-warning-message-trigger-price';
const triggerTrailingPercentOffsetErrorMessage = const triggerTrailingPercentOffsetErrorMessage =
'stop-order-error-message-trigger-trailing-percent-offset'; 'stop-order-error-message-trigger-trailing-percent-offset';
@ -114,14 +115,6 @@ describe('StopOrder', () => {
}); });
}); });
it('should display trigger price as price for market type order', async () => {
render(generateJsx());
await userEvent.click(screen.getByTestId(orderTypeTrigger));
await userEvent.click(screen.getByTestId(orderTypeMarket));
await userEvent.type(screen.getByTestId(triggerPriceInput), '10');
expect(screen.getByTestId('price')).toHaveTextContent('10.0');
});
it('should use local storage state for initial values', async () => { it('should use local storage state for initial values', async () => {
const values: Partial<StopOrderFormValues> = { const values: Partial<StopOrderFormValues> = {
type: Schema.OrderType.TYPE_LIMIT, type: Schema.OrderType.TYPE_LIMIT,
@ -203,8 +196,8 @@ describe('StopOrder', () => {
await userEvent.click(screen.getByTestId(submitButton)); await userEvent.click(screen.getByTestId(submitButton));
// price error message should not show if size has error // price error message should not show if size has error
expect(screen.queryByTestId(priceErrorMessage)).toBeNull(); // expect(screen.queryByTestId(priceErrorMessage)).toBeNull();
await userEvent.type(screen.getByTestId(sizeInput), '0.1'); // await userEvent.type(screen.getByTestId(sizeInput), '0.1');
expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument(); expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument();
await userEvent.type(screen.getByTestId(priceInput), '0.001'); await userEvent.type(screen.getByTestId(priceInput), '0.001');
expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument(); expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument();
@ -249,10 +242,17 @@ describe('StopOrder', () => {
await userEvent.type(screen.getByTestId(triggerPriceInput), '0.001'); await userEvent.type(screen.getByTestId(triggerPriceInput), '0.001');
expect(screen.getByTestId(triggerPriceErrorMessage)).toBeInTheDocument(); expect(screen.getByTestId(triggerPriceErrorMessage)).toBeInTheDocument();
// clear and fill using valid value // clear and fill using value causing immediate trigger
await userEvent.clear(screen.getByTestId(triggerPriceInput)); await userEvent.clear(screen.getByTestId(triggerPriceInput));
await userEvent.type(screen.getByTestId(triggerPriceInput), '0.01'); await userEvent.type(screen.getByTestId(triggerPriceInput), '0.01');
expect(screen.queryByTestId(triggerPriceErrorMessage)).toBeNull(); expect(screen.queryByTestId(triggerPriceErrorMessage)).toBeNull();
expect(
screen.queryByTestId(triggerPriceWarningMessage)
).toBeInTheDocument();
// change to correct value
await userEvent.type(screen.getByTestId(triggerPriceInput), '2');
expect(screen.queryByTestId(triggerPriceWarningMessage)).toBeNull();
}); });
it('validates trigger trailing percentage offset field', async () => { it('validates trigger trailing percentage offset field', async () => {

View File

@ -1,22 +1,18 @@
import { useRef, useCallback, useEffect } from 'react'; import { useRef, useCallback, useEffect } from 'react';
import { useVegaWallet } from '@vegaprotocol/wallet'; import { useVegaWallet } from '@vegaprotocol/wallet';
import type { StopOrdersSubmission } from '@vegaprotocol/wallet'; import type { StopOrdersSubmission } from '@vegaprotocol/wallet';
import { import { removeDecimal, toDecimal, validateAmount } from '@vegaprotocol/utils';
formatNumber, import type { Control, UseFormWatch } from 'react-hook-form';
removeDecimal,
toDecimal,
validateAmount,
} from '@vegaprotocol/utils';
import { useForm, Controller, useController } from 'react-hook-form'; import { useForm, Controller, useController } from 'react-hook-form';
import * as Schema from '@vegaprotocol/types'; import * as Schema from '@vegaprotocol/types';
import { import {
TradingRadio, TradingRadio as Radio,
TradingRadioGroup, TradingRadioGroup as RadioGroup,
TradingInput, TradingInput as Input,
TradingCheckbox, TradingCheckbox as Checkbox,
TradingFormGroup, TradingFormGroup as FormGroup,
TradingInputError, TradingInputError as InputError,
TradingSelect, TradingSelect as Select,
Tooltip, Tooltip,
} from '@vegaprotocol/ui-toolkit'; } from '@vegaprotocol/ui-toolkit';
import { getDerivedPrice, type Market } from '@vegaprotocol/markets'; import { getDerivedPrice, type Market } from '@vegaprotocol/markets';
@ -49,6 +45,8 @@ export interface StopOrderProps {
submit: (order: StopOrdersSubmission) => void; submit: (order: StopOrdersSubmission) => void;
} }
const trailingPercentOffsetStep = '0.1';
const getDefaultValues = ( const getDefaultValues = (
type: Schema.OrderType, type: Schema.OrderType,
storedValues?: Partial<StopOrderFormValues> storedValues?: Partial<StopOrderFormValues>
@ -62,9 +60,425 @@ const getDefaultValues = (
expire: false, expire: false,
expiryStrategy: Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT, expiryStrategy: Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT,
size: '0', size: '0',
ocoType: type,
ocoTimeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_FOK,
ocoTriggerType: 'price',
ocoSize: '0',
...storedValues, ...storedValues,
}); });
const Trigger = ({
control,
watch,
priceStep,
assetSymbol,
oco,
marketPrice,
decimalPlaces,
}: {
control: Control<StopOrderFormValues>;
watch: UseFormWatch<StopOrderFormValues>;
priceStep: string;
assetSymbol: string;
oco?: boolean;
marketPrice?: string | null;
decimalPlaces: number;
}) => {
const triggerType = watch(oco ? 'ocoTriggerType' : 'triggerType');
const triggerDirection = watch('triggerDirection');
const isPriceTrigger = triggerType === 'price';
return (
<FormGroup label={t('Trigger')} labelFor="">
<Controller
name="triggerDirection"
control={control}
render={({ field }) => {
const { value, onChange } = field;
return (
<RadioGroup
name="triggerDirection"
onChange={onChange}
value={value}
orientation="horizontal"
className="mb-2"
>
<Radio
value={
oco
? Schema.StopOrderTriggerDirection
.TRIGGER_DIRECTION_FALLS_BELOW
: Schema.StopOrderTriggerDirection
.TRIGGER_DIRECTION_RISES_ABOVE
}
id={`triggerDirection-risesAbove${oco ? '-oco' : ''}`}
label={'Rises above'}
/>
<Radio
value={
!oco
? Schema.StopOrderTriggerDirection
.TRIGGER_DIRECTION_FALLS_BELOW
: Schema.StopOrderTriggerDirection
.TRIGGER_DIRECTION_RISES_ABOVE
}
id={`triggerDirection-fallsBelow${oco ? '-oco' : ''}`}
label={'Falls below'}
/>
</RadioGroup>
);
}}
/>
{isPriceTrigger && (
<div className="mb-2">
<Controller
name={oco ? 'ocoTriggerPrice' : 'triggerPrice'}
rules={{
required: t('You need provide a price'),
min: {
value: priceStep,
message: t('Price cannot be lower than ' + priceStep),
},
validate: validateAmount(priceStep, 'Price'),
}}
control={control}
render={({ field, fieldState }) => {
const { value, ...props } = field;
let triggerWarning = false;
if (marketPrice && value) {
const condition =
(!oco &&
triggerDirection ===
Schema.StopOrderTriggerDirection
.TRIGGER_DIRECTION_RISES_ABOVE) ||
(oco &&
triggerDirection ===
Schema.StopOrderTriggerDirection
.TRIGGER_DIRECTION_FALLS_BELOW)
? '>'
: '<';
const diff =
BigInt(marketPrice) -
BigInt(removeDecimal(value, decimalPlaces));
if (
(condition === '>' && diff > 0) ||
(condition === '<' && diff < 0)
) {
triggerWarning = true;
}
}
return (
<>
<div className="mb-2">
<Input
data-testid={`triggerPrice${oco ? '-oco' : ''}`}
type="number"
step={priceStep}
appendElement={assetSymbol}
value={value || ''}
hasError={!!fieldState.error}
{...props}
/>
</div>
{fieldState.error && (
<InputError
testId={`stop-order-error-message-trigger-price${
oco ? '-oco' : ''
}`}
>
{fieldState.error.message}
</InputError>
)}
{!fieldState.error && triggerWarning && (
<InputError
intent="warning"
testId={`stop-order-warning-message-trigger-price${
oco ? '-oco' : ''
}`}
>
{t('Stop order will be triggered immediately')}
</InputError>
)}
</>
);
}}
/>
</div>
)}
{!isPriceTrigger && (
<div className="mb-2">
<Controller
name={
oco
? 'ocoTriggerTrailingPercentOffset'
: 'triggerTrailingPercentOffset'
}
control={control}
rules={{
required: t('You need provide a trailing percent offset'),
min: {
value: trailingPercentOffsetStep,
message: t(
'Trailing percent offset cannot be lower than ' +
trailingPercentOffsetStep
),
},
max: {
value: '99.9',
message: t(
'Trailing percent offset cannot be higher than 99.9'
),
},
validate: validateAmount(
trailingPercentOffsetStep,
'Trailing percentage offset'
),
}}
render={({ field, fieldState }) => {
const { value, ...props } = field;
return (
<>
<div className="mb-2">
<Input
type="number"
step={trailingPercentOffsetStep}
appendElement="%"
data-testid={`triggerTrailingPercentOffset${
oco ? '-oco' : ''
}`}
value={value || ''}
hasError={!!fieldState.error}
{...props}
/>
</div>
{fieldState.error && (
<InputError
testId={`stop-order-error-message-trigger-trailing-percent-offset${
oco ? '-oco' : ''
}`}
>
{fieldState.error.message}
</InputError>
)}
</>
);
}}
/>
</div>
)}
<Controller
name={oco ? 'ocoTriggerType' : 'triggerType'}
control={control}
rules={{
deps: oco
? ['ocoTriggerTrailingPercentOffset', 'ocoTriggerPrice']
: ['triggerTrailingPercentOffset', 'triggerPrice'],
}}
render={({ field }) => {
const { onChange, value } = field;
return (
<RadioGroup
onChange={onChange}
value={value}
orientation="horizontal"
>
<Radio
value="price"
id={`triggerType-price${oco ? '-oco' : ''}`}
label={'Price'}
/>
<Radio
value="trailingPercentOffset"
id={`triggerType-trailingPercentOffset${oco ? '-oco' : ''}`}
label={'Trailing Percent Offset'}
/>
</RadioGroup>
);
}}
/>
</FormGroup>
);
};
const Size = ({
control,
sizeStep,
oco,
}: {
control: Control<StopOrderFormValues>;
sizeStep: string;
oco?: boolean;
}) => {
return (
<Controller
name={oco ? 'ocoSize' : 'size'}
control={control}
rules={{
required: t('You need to provide a size'),
min: {
value: sizeStep,
message: t('Size cannot be lower than ' + sizeStep),
},
validate: validateAmount(sizeStep, 'Size'),
}}
render={({ field, fieldState }) => {
const { value, ...props } = field;
const id = `order-size${oco ? '-oco' : ''}`;
return (
<div className="mb-4">
<FormGroup labelFor={id} label={t(`Size`)} compact>
<Input
id={id}
className="w-full"
type="number"
step={sizeStep}
min={sizeStep}
onWheel={(e) => e.currentTarget.blur()}
data-testid={id}
value={value || ''}
hasError={!!fieldState.error}
{...props}
/>
</FormGroup>
{fieldState.error && (
<InputError
testId={`stop-order-error-message-size${oco ? '-oco' : ''}`}
>
{fieldState.error.message}
</InputError>
)}
</div>
);
}}
/>
);
};
const Price = ({
control,
watch,
priceStep,
quoteName,
oco,
}: {
control: Control<StopOrderFormValues>;
watch: UseFormWatch<StopOrderFormValues>;
priceStep: string;
quoteName: string;
oco?: boolean;
}) => {
if (watch(oco ? 'ocoType' : 'type') === Schema.OrderType.TYPE_MARKET) {
return null;
}
return (
<Controller
name={oco ? 'ocoPrice' : 'price'}
control={control}
rules={{
deps: 'type',
required: t('You need provide a price'),
min: {
value: priceStep,
message: t('Price cannot be lower than ' + priceStep),
},
validate: validateAmount(priceStep, 'Price'),
}}
render={({ field, fieldState }) => {
const { value, ...props } = field;
const id = `order-price${oco ? '-oco' : ''}`;
return (
<div className="mb-4">
<FormGroup
labelFor={id}
label={t(`Price (${quoteName})`)}
compact={true}
>
<Input
id={id}
className="w-full"
type="number"
step={priceStep}
data-testid={id}
onWheel={(e) => e.currentTarget.blur()}
value={value || ''}
hasError={!!fieldState.error}
{...props}
/>
</FormGroup>
{fieldState.error && (
<InputError
testId={`stop-order-error-message-price${oco ? '-oco' : ''}`}
>
{fieldState.error.message}
</InputError>
)}
</div>
);
}}
/>
);
};
const TimeInForce = ({
control,
oco,
}: {
control: Control<StopOrderFormValues>;
oco?: boolean;
}) => (
<Controller
name="timeInForce"
control={control}
render={({ field, fieldState }) => {
const id = `select-time-in-force${oco ? '-oco' : ''}`;
return (
<div className="mb-2">
<FormGroup label={t('Time in force')} labelFor={id} compact={true}>
<Select
id={id}
className="w-full"
data-testid="order-tif"
hasError={!!fieldState.error}
{...field}
>
<option
key={Schema.OrderTimeInForce.TIME_IN_FORCE_IOC}
value={Schema.OrderTimeInForce.TIME_IN_FORCE_IOC}
>
{timeInForceLabel(Schema.OrderTimeInForce.TIME_IN_FORCE_IOC)}
</option>
<option
key={Schema.OrderTimeInForce.TIME_IN_FORCE_FOK}
value={Schema.OrderTimeInForce.TIME_IN_FORCE_FOK}
>
{timeInForceLabel(Schema.OrderTimeInForce.TIME_IN_FORCE_FOK)}
</option>
</Select>
</FormGroup>
{fieldState.error && (
<InputError testId={`stop-error-message-tif${oco ? '-oco' : ''}`}>
{fieldState.error.message}
</InputError>
)}
</div>
);
}}
/>
);
const ReduceOnly = () => (
<Checkbox
name="reduce-only"
checked={true}
disabled={true}
label={
<Tooltip description={<span>{t(REDUCE_ONLY_TOOLTIP)}</span>}>
<>{t('Reduce only')}</>
</Tooltip>
}
/>
);
export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => { export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
const { pubKey, isReadOnly } = useVegaWallet(); const { pubKey, isReadOnly } = useVegaWallet();
const setType = useDealTicketFormValues((state) => state.setType); const setType = useDealTicketFormValues((state) => state.setType);
@ -107,6 +521,7 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
const timeInForce = watch('timeInForce'); const timeInForce = watch('timeInForce');
const rawPrice = watch('price'); const rawPrice = watch('price');
const rawSize = watch('size'); const rawSize = watch('size');
const oco = watch('oco');
useEffect(() => { useEffect(() => {
const size = storedFormValues?.[dealTicketType]?.size; const size = storedFormValues?.[dealTicketType]?.size;
@ -155,12 +570,6 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
const sizeStep = toDecimal(market?.positionDecimalPlaces); const sizeStep = toDecimal(market?.positionDecimalPlaces);
const priceStep = toDecimal(market?.decimalPlaces); const priceStep = toDecimal(market?.decimalPlaces);
const trailingPercentOffsetStep = '0.1';
const priceFormatted =
isPriceTrigger && triggerPrice
? formatNumber(triggerPrice, market.decimalPlaces)
: undefined;
useController({ useController({
name: 'type', name: 'type',
@ -187,9 +596,9 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
}} }}
/> />
{errors.type && ( {errors.type && (
<TradingInputError testId="stop-order-error-message-type"> <InputError testId="stop-order-error-message-type">
{errors.type.message} {errors.type.message}
</TradingInputError> </InputError>
)} )}
<Controller <Controller
@ -199,302 +608,117 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
<SideSelector value={field.value} onValueChange={field.onChange} /> <SideSelector value={field.value} onValueChange={field.onChange} />
)} )}
/> />
<TradingFormGroup label={t('Trigger')} compact={true} labelFor=""> <Trigger
<Controller
name="triggerDirection"
control={control} control={control}
render={({ field }) => { watch={watch}
const { onChange, value } = field; priceStep={priceStep}
return ( assetSymbol={asset.symbol}
<TradingRadioGroup marketPrice={marketPrice}
name="triggerDirection" decimalPlaces={market.decimalPlaces}
onChange={onChange}
value={value}
orientation="horizontal"
className="mb-2"
>
<TradingRadio
value={
Schema.StopOrderTriggerDirection
.TRIGGER_DIRECTION_RISES_ABOVE
}
id="triggerDirection-risesAbove"
label={'Rises above'}
/> />
<TradingRadio <hr className="mb-4 border-vega-clight-500 dark:border-vega-cdark-500" />
value={ <Price
Schema.StopOrderTriggerDirection
.TRIGGER_DIRECTION_FALLS_BELOW
}
id="triggerDirection-fallsBelow"
label={'Falls below'}
/>
</TradingRadioGroup>
);
}}
/>
{isPriceTrigger && (
<div className="mb-2">
<Controller
name="triggerPrice"
rules={{
required: t('You need provide a price'),
min: {
value: priceStep,
message: t('Price cannot be lower than ' + priceStep),
},
validate: validateAmount(priceStep, 'Price'),
}}
control={control} control={control}
render={({ field, fieldState }) => { watch={watch}
const { value, ...props } = field; priceStep={priceStep}
return ( quoteName={quoteName}
<div className="mb-2">
<TradingInput
data-testid="triggerPrice"
type="number"
step={priceStep}
appendElement={asset.symbol}
value={value || ''}
hasError={!!fieldState.error}
{...props}
/> />
<Size control={control} sizeStep={sizeStep} />
<TimeInForce control={control} />
<div className="flex gap-2 pb-3 justify-end">
<ReduceOnly />
</div> </div>
); <hr className="mb-4 border-vega-clight-500 dark:border-vega-cdark-500" />
}}
/>
{errors.triggerPrice && (
<TradingInputError testId="stop-order-error-message-trigger-price">
{errors.triggerPrice.message}
</TradingInputError>
)}
</div>
)}
{!isPriceTrigger && (
<div className="mb-2">
<Controller
name="triggerTrailingPercentOffset"
control={control}
rules={{
required: t('You need provide a trailing percent offset'),
min: {
value: trailingPercentOffsetStep,
message: t(
'Trailing percent offset cannot be lower than ' +
trailingPercentOffsetStep
),
},
max: {
value: '99.9',
message: t(
'Trailing percent offset cannot be higher than 99.9'
),
},
validate: validateAmount(
trailingPercentOffsetStep,
'Trailing percentage offset'
),
}}
render={({ field, fieldState }) => {
const { value, ...props } = field;
return (
<div className="mb-2">
<TradingInput
type="number"
step={trailingPercentOffsetStep}
appendElement="%"
data-testid="triggerTrailingPercentOffset"
value={value || ''}
hasError={!!fieldState.error}
{...props}
/>
</div>
);
}}
/>
{errors.triggerTrailingPercentOffset && (
<TradingInputError testId="stop-order-error-message-trigger-trailing-percent-offset">
{errors.triggerTrailingPercentOffset.message}
</TradingInputError>
)}
</div>
)}
<Controller
name="triggerType"
control={control}
rules={{ deps: ['triggerTrailingPercentOffset', 'triggerPrice'] }}
render={({ field }) => {
const { onChange, value } = field;
return (
<TradingRadioGroup
onChange={onChange}
value={value}
orientation="horizontal"
>
<TradingRadio
value="price"
id="triggerType-price"
label={'Price'}
/>
<TradingRadio
value="trailingPercentOffset"
id="triggerType-trailingPercentOffset"
label={'Trailing Percent Offset'}
/>
</TradingRadioGroup>
);
}}
/>
</TradingFormGroup>
<div className="mb-2">
<div className="flex items-start gap-4">
<TradingFormGroup
labelFor="input-price-quote"
label={t(`Size`)}
className="!mb-0 flex-1"
>
<Controller
name="size"
control={control}
rules={{
required: t('You need to provide a size'),
min: {
value: sizeStep,
message: t('Size cannot be lower than ' + sizeStep),
},
validate: validateAmount(sizeStep, 'Size'),
}}
render={({ field, fieldState }) => {
const { value, ...props } = field;
return (
<TradingInput
id="order-size"
className="w-full"
type="number"
step={sizeStep}
min={sizeStep}
onWheel={(e) => e.currentTarget.blur()}
data-testid="order-size"
value={value || ''}
hasError={!!fieldState.error}
{...props}
/>
);
}}
/>
</TradingFormGroup>
<div className="pt-5 leading-10">@</div>
<div className="flex-1">
{type === Schema.OrderType.TYPE_LIMIT ? (
<TradingFormGroup
labelFor="input-price-quote"
label={t(`Price (${quoteName})`)}
labelAlign="right"
className="!mb-0"
>
<Controller
name="price"
control={control}
rules={{
deps: 'type',
required: t('You need provide a price'),
min: {
value: priceStep,
message: t('Price cannot be lower than ' + priceStep),
},
validate: validateAmount(priceStep, 'Price'),
}}
render={({ field, fieldState }) => {
const { value, ...props } = field;
return (
<TradingInput
id="input-price-quote"
className="w-full"
type="number"
step={priceStep}
data-testid="order-price"
onWheel={(e) => e.currentTarget.blur()}
value={value || ''}
hasError={!!fieldState.error}
{...props}
/>
);
}}
/>
</TradingFormGroup>
) : (
<div
className="text-sm text-right pt-5 leading-10"
data-testid="price"
>
{priceFormatted && quoteName
? `~${priceFormatted} ${quoteName}`
: '-'}
</div>
)}
</div>
</div>
{errors.size && (
<TradingInputError testId="stop-order-error-message-size">
{errors.size.message}
</TradingInputError>
)}
{!errors.size &&
errors.price &&
type === Schema.OrderType.TYPE_LIMIT && (
<TradingInputError testId="stop-order-error-message-price">
{errors.price.message}
</TradingInputError>
)}
</div>
<div className="mb-2">
<TradingFormGroup
label={t('Time in force')}
labelFor="select-time-in-force"
compact={true}
>
<Controller
name="timeInForce"
control={control}
render={({ field, fieldState }) => (
<TradingSelect
id="select-time-in-force"
className="w-full"
data-testid="order-tif"
hasError={!!fieldState.error}
{...field}
>
<option
key={Schema.OrderTimeInForce.TIME_IN_FORCE_IOC}
value={Schema.OrderTimeInForce.TIME_IN_FORCE_IOC}
>
{timeInForceLabel(Schema.OrderTimeInForce.TIME_IN_FORCE_IOC)}
</option>
<option
key={Schema.OrderTimeInForce.TIME_IN_FORCE_FOK}
value={Schema.OrderTimeInForce.TIME_IN_FORCE_FOK}
>
{timeInForceLabel(Schema.OrderTimeInForce.TIME_IN_FORCE_FOK)}
</option>
</TradingSelect>
)}
/>
</TradingFormGroup>
{errors.timeInForce && (
<TradingInputError testId="stop-error-message-tif">
{errors.timeInForce.message}
</TradingInputError>
)}
</div>
<div className="flex gap-2 pb-2 justify-between"> <div className="flex gap-2 pb-2 justify-between">
<Controller
name="oco"
control={control}
render={({ field }) => {
const { onChange, value } = field;
return (
<Checkbox
onCheckedChange={(state) => {
onChange(state);
setValue(
'expiryStrategy',
Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_CANCELS
);
}}
checked={value}
name="oco"
label={
<Tooltip
description={<span>{t('One cancels another')}</span>}
>
<>{t('OCO')}</>
</Tooltip>
}
/>
);
}}
/>
</div>
{oco && (
<>
<FormGroup label={t('Type')} labelFor="">
<Controller
name={`ocoType`}
control={control}
render={({ field }) => {
const { onChange, value } = field;
return (
<RadioGroup
onChange={onChange}
value={value}
orientation="horizontal"
>
<Radio
value={Schema.OrderType.TYPE_MARKET}
id={`ocoTypeMarket`}
label={'Market'}
/>
<Radio
value={Schema.OrderType.TYPE_LIMIT}
id={`ocoTypeLimit`}
label={'Limit'}
/>
</RadioGroup>
);
}}
/>
</FormGroup>
<Trigger
control={control}
watch={watch}
priceStep={priceStep}
assetSymbol={asset.symbol}
marketPrice={marketPrice}
decimalPlaces={market.decimalPlaces}
oco
/>
<hr className="mb-2 border-vega-clight-500 dark:border-vega-cdark-500" />
<Price
control={control}
watch={watch}
priceStep={priceStep}
quoteName={quoteName}
oco
/>
<Size control={control} sizeStep={sizeStep} oco />
<TimeInForce control={control} oco />
<div className="flex gap-2 mb-2 justify-end">
<ReduceOnly />
</div>
</>
)}
<div className="mb-2">
<Controller <Controller
name="expire" name="expire"
control={control} control={control}
render={({ field }) => { render={({ field }) => {
const { onChange: onCheckedChange, value } = field; const { onChange: onCheckedChange, value } = field;
return ( return (
<TradingCheckbox <Checkbox
onCheckedChange={onCheckedChange} onCheckedChange={onCheckedChange}
checked={value} checked={value}
name="expire" name="expire"
@ -503,50 +727,42 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
); );
}} }}
/> />
<TradingCheckbox
name="reduce-only"
checked={true}
disabled={true}
label={
<Tooltip description={<span>{t(REDUCE_ONLY_TOOLTIP)}</span>}>
<>{t('Reduce only')}</>
</Tooltip>
}
/>
</div> </div>
{expire && ( {expire && (
<> <>
<TradingFormGroup <FormGroup label={t('Strategy')} labelFor="expiryStrategy">
label={t('Strategy')}
labelFor="expiryStrategy"
compact={true}
>
<Controller <Controller
name="expiryStrategy" name="expiryStrategy"
control={control} control={control}
render={({ field }) => { render={({ field }) => {
const { onChange, value } = field;
return ( return (
<TradingRadioGroup orientation="horizontal" {...field}> <RadioGroup
<TradingRadio onChange={onChange}
value={value}
orientation="horizontal"
>
<Radio
disabled={oco}
value={ value={
Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT
} }
id="expiryStrategy-submit" id="expiryStrategy-submit"
label={'Submit'} label={'Submit'}
/> />
<TradingRadio <Radio
value={ value={
Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_CANCELS Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_CANCELS
} }
id="expiryStrategy-cancel" id="expiryStrategy-cancel"
label={'Cancel'} label={'Cancel'}
/> />
</TradingRadioGroup> </RadioGroup>
); );
}} }}
/> />
</TradingFormGroup> </FormGroup>
<div className="mb-2"> <div className="mb-4">
<Controller <Controller
name="expiresAt" name="expiresAt"
control={control} control={control}

View File

@ -7,7 +7,6 @@ import { DealTicket } from './deal-ticket';
import * as Schema from '@vegaprotocol/types'; import * as Schema from '@vegaprotocol/types';
import type { MockedResponse } from '@apollo/client/testing'; import type { MockedResponse } from '@apollo/client/testing';
import { MockedProvider } from '@apollo/client/testing'; import { MockedProvider } from '@apollo/client/testing';
import { addDecimal } from '@vegaprotocol/utils';
import type { OrdersQuery } from '@vegaprotocol/orders'; import type { OrdersQuery } from '@vegaprotocol/orders';
import { import {
DealTicketType, DealTicketType,
@ -135,20 +134,6 @@ describe('DealTicket', () => {
); );
}); });
it('should display last price for market type order', () => {
render(generateJsx());
act(() => {
screen.getByTestId('order-type-Market').click();
});
// Assert last price is shown
expect(screen.getByTestId('last-price')).toHaveTextContent(
// eslint-disable-next-line
`~${addDecimal(marketPrice, market.decimalPlaces)} ${
market.tradableInstrument.instrument.product.quoteName
}`
);
});
it('should use local storage state for initial values', () => { it('should use local storage state for initial values', () => {
const expectedOrder = { const expectedOrder = {
marketId: market.id, marketId: market.id,

View File

@ -3,7 +3,6 @@ import * as Schema from '@vegaprotocol/types';
import type { FormEventHandler } from 'react'; import type { FormEventHandler } from 'react';
import { memo, useCallback, useEffect, useRef, useMemo } from 'react'; import { memo, useCallback, useEffect, useRef, useMemo } from 'react';
import { Controller, useController, useForm } from 'react-hook-form'; import { Controller, useController, useForm } from 'react-hook-form';
import { DealTicketAmount } from './deal-ticket-amount';
import { DealTicketButton } from './deal-ticket-button'; import { DealTicketButton } from './deal-ticket-button';
import { import {
DealTicketFeeDetails, DealTicketFeeDetails,
@ -17,8 +16,10 @@ import type { OrderSubmission } from '@vegaprotocol/wallet';
import { useVegaWallet } from '@vegaprotocol/wallet'; import { useVegaWallet } from '@vegaprotocol/wallet';
import { mapFormValuesToOrderSubmission } from '../../utils/map-form-values-to-submission'; import { mapFormValuesToOrderSubmission } from '../../utils/map-form-values-to-submission';
import { import {
TradingCheckbox, TradingInput as Input,
TradingInputError, TradingCheckbox as Checkbox,
TradingFormGroup as FormGroup,
TradingInputError as InputError,
Intent, Intent,
Notification, Notification,
Tooltip, Tooltip,
@ -28,7 +29,12 @@ import {
useEstimatePositionQuery, useEstimatePositionQuery,
useOpenVolume, useOpenVolume,
} from '@vegaprotocol/positions'; } from '@vegaprotocol/positions';
import { toBigNum, removeDecimal } from '@vegaprotocol/utils'; import {
toBigNum,
removeDecimal,
validateAmount,
toDecimal,
} from '@vegaprotocol/utils';
import { activeOrdersProvider } from '@vegaprotocol/orders'; import { activeOrdersProvider } from '@vegaprotocol/orders';
import { getDerivedPrice } from '@vegaprotocol/markets'; import { getDerivedPrice } from '@vegaprotocol/markets';
import type { OrderInfo } from '@vegaprotocol/types'; import type { OrderInfo } from '@vegaprotocol/types';
@ -332,6 +338,10 @@ export const DealTicket = ({
}, },
}); });
const priceStep = toDecimal(market?.decimalPlaces);
const sizeStep = toDecimal(market?.positionDecimalPlaces);
const quoteName = market.tradableInstrument.instrument.product.quoteName;
return ( return (
<form <form
onSubmit={ onSubmit={
@ -366,15 +376,82 @@ export const DealTicket = ({
<SideSelector value={field.value} onValueChange={field.onChange} /> <SideSelector value={field.value} onValueChange={field.onChange} />
)} )}
/> />
<DealTicketAmount
type={type} <Controller
name="size"
control={control} control={control}
market={market} rules={{
marketData={marketData} required: t('You need to provide a size'),
marketPrice={marketPrice || undefined} min: {
sizeError={errors.size?.message} value: sizeStep,
priceError={errors.price?.message} message: t('Size cannot be lower than ' + sizeStep),
},
validate: validateAmount(sizeStep, 'Size'),
}}
render={({ field, fieldState }) => (
<div className="mb-4">
<FormGroup
label={t('Size')}
labelFor="input-order-size-limit"
compact
>
<Input
id="input-order-size-limit"
className="w-full"
type="number"
step={sizeStep}
min={sizeStep}
data-testid="order-size"
onWheel={(e) => e.currentTarget.blur()}
{...field}
/> />
</FormGroup>
{fieldState.error && (
<InputError testId="deal-ticket-error-message-size">
{fieldState.error.message}
</InputError>
)}
</div>
)}
/>
{type === Schema.OrderType.TYPE_LIMIT && (
<Controller
name="price"
control={control}
rules={{
required: t('You need provide a price'),
min: {
value: priceStep,
message: t('Price cannot be lower than ' + priceStep),
},
validate: validateAmount(priceStep, 'Price'),
}}
render={({ field, fieldState }) => (
<div className="mb-4">
<FormGroup
labelFor="input-price-quote"
label={t(`Price (${quoteName})`)}
compact
>
<Input
id="input-price-quote"
className="w-full"
type="number"
step={priceStep}
data-testid="order-price"
onWheel={(e) => e.currentTarget.blur()}
{...field}
/>
</FormGroup>
{fieldState.error && (
<InputError testId="deal-ticket-error-message-price">
{fieldState.error.message}
</InputError>
)}
</div>
)}
/>
)}
<Controller <Controller
name="timeInForce" name="timeInForce"
control={control} control={control}
@ -417,7 +494,7 @@ export const DealTicket = ({
name="postOnly" name="postOnly"
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<TradingCheckbox <Checkbox
name="post-only" name="post-only"
checked={!disablePostOnlyCheckbox && field.value} checked={!disablePostOnlyCheckbox && field.value}
disabled={disablePostOnlyCheckbox} disabled={disablePostOnlyCheckbox}
@ -449,7 +526,7 @@ export const DealTicket = ({
name="reduceOnly" name="reduceOnly"
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<TradingCheckbox <Checkbox
name="reduce-only" name="reduce-only"
checked={!disableReduceOnlyCheckbox && field.value} checked={!disableReduceOnlyCheckbox && field.value}
disabled={disableReduceOnlyCheckbox} disabled={disableReduceOnlyCheckbox}
@ -483,7 +560,7 @@ export const DealTicket = ({
name="iceberg" name="iceberg"
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<TradingCheckbox <Checkbox
name="iceberg" name="iceberg"
checked={field.value} checked={field.value}
onCheckedChange={field.onChange} onCheckedChange={field.onChange}
@ -572,11 +649,11 @@ export const NoWalletWarning = ({
if (isReadOnly) { if (isReadOnly) {
return ( return (
<div className="mb-2"> <div className="mb-2">
<TradingInputError testId="deal-ticket-error-message-summary"> <InputError testId="deal-ticket-error-message-summary">
{ {
'You need to connect your own wallet to start trading on this market' 'You need to connect your own wallet to start trading on this market'
} }
</TradingInputError> </InputError>
</div> </div>
); );
} }
@ -613,9 +690,9 @@ const SummaryMessage = memo(
if (error?.message) { if (error?.message) {
return ( return (
<div className="mb-2"> <div className="mb-2">
<TradingInputError testId="deal-ticket-error-message-summary"> <InputError testId="deal-ticket-error-message-summary">
{error?.message} {error?.message}
</TradingInputError> </InputError>
</div> </div>
); );
} }

View File

@ -23,10 +23,11 @@ export const ExpirySelector = ({
const dateFormatted = formatForInput(date); const dateFormatted = formatForInput(date);
const minDate = formatForInput(date); const minDate = formatForInput(date);
return ( return (
<div className="mb-4">
<TradingFormGroup <TradingFormGroup
label={t('Expiry time/date')} label={t('Expiry time/date')}
labelFor="expiration" labelFor="expiration"
compact={true} compact
> >
<TradingInput <TradingInput
data-testid="date-picker-field" data-testid="date-picker-field"
@ -43,5 +44,6 @@ export const ExpirySelector = ({
</TradingInputError> </TradingInputError>
)} )}
</TradingFormGroup> </TradingFormGroup>
</div>
); );
}; };

View File

@ -1,7 +1,4 @@
export * from './deal-ticket-amount';
export * from './deal-ticket-container'; export * from './deal-ticket-container';
export * from './deal-ticket-limit-amount';
export * from './deal-ticket-market-amount';
export * from './deal-ticket'; export * from './deal-ticket';
export * from './deal-ticket-stop-order'; export * from './deal-ticket-stop-order';
export * from './deal-ticket-container'; export * from './deal-ticket-container';

View File

@ -90,6 +90,7 @@ export const TimeInForceSelector = ({
}; };
return ( return (
<div className="mb-4">
<TradingFormGroup <TradingFormGroup
label={t('Time in force')} label={t('Time in force')}
labelFor="select-time-in-force" labelFor="select-time-in-force"
@ -117,5 +118,6 @@ export const TimeInForceSelector = ({
</TradingInputError> </TradingInputError>
)} )}
</TradingFormGroup> </TradingFormGroup>
</div>
); );
}; };

View File

@ -76,7 +76,7 @@ export const TypeToggle = ({
<TradingDropdownTrigger <TradingDropdownTrigger
data-testid="order-type-Stop" data-testid="order-type-Stop"
className={classNames( className={classNames(
'rounded px-3 flex flex-nowrap items-center justify-center', 'rounded px-2 flex flex-nowrap items-center justify-center',
{ {
'bg-vega-clight-500 dark:bg-vega-cdark-500': selectedOption, 'bg-vega-clight-500 dark:bg-vega-cdark-500': selectedOption,
} }

View File

@ -28,6 +28,17 @@ export interface StopOrderFormValues {
expire: boolean; expire: boolean;
expiryStrategy?: Schema.StopOrderExpiryStrategy; expiryStrategy?: Schema.StopOrderExpiryStrategy;
expiresAt?: string; expiresAt?: string;
oco?: boolean;
ocoTriggerType: 'price' | 'trailingPercentOffset';
ocoTriggerPrice?: string;
ocoTriggerTrailingPercentOffset?: string;
ocoType: OrderType;
ocoSize: string;
ocoTimeInForce: OrderTimeInForce;
ocoPrice?: string;
} }
export type OrderFormValues = { export type OrderFormValues = {
@ -138,6 +149,7 @@ export const useDealTicketFormValues = create<Store>()(
})), })),
{ {
name: 'vega_deal_ticket_store', name: 'vega_deal_ticket_store',
version: 1,
} }
) )
) )

View File

@ -59,6 +59,22 @@ export const mapFormValuesToOrderSubmission = (
: undefined, : undefined,
}); });
const setTrigger = (
stopOrderSetup: StopOrderSetup,
triggerType: StopOrderFormValues['triggerPrice'],
triggerPrice: StopOrderFormValues['triggerPrice'],
triggerTrailingPercentOffset: StopOrderFormValues['triggerTrailingPercentOffset'],
decimalPlaces: number
) => {
if (triggerType === 'price') {
stopOrderSetup.price = removeDecimal(triggerPrice ?? '', decimalPlaces);
} else if (triggerType === 'trailingPercentOffset') {
stopOrderSetup.trailingPercentOffset = (
Number(triggerTrailingPercentOffset) / 100
).toFixed(3);
}
};
export const mapFormValuesToStopOrdersSubmission = ( export const mapFormValuesToStopOrdersSubmission = (
data: StopOrderFormValues, data: StopOrderFormValues,
marketId: string, marketId: string,
@ -81,31 +97,46 @@ export const mapFormValuesToStopOrdersSubmission = (
positionDecimalPlaces positionDecimalPlaces
), ),
}; };
if (data.triggerType === 'price') { setTrigger(
stopOrderSetup.price = removeDecimal( stopOrderSetup,
data.triggerPrice ?? '', data.triggerType,
data.triggerPrice,
data.triggerTrailingPercentOffset,
decimalPlaces
);
let oppositeStopOrderSetup: StopOrderSetup | undefined = undefined;
if (data.oco) {
oppositeStopOrderSetup = {
orderSubmission: mapFormValuesToOrderSubmission(
{
type: data.ocoType,
side: data.side,
size: data.ocoSize,
timeInForce: data.ocoTimeInForce,
price: data.ocoPrice,
reduceOnly: true,
},
marketId,
decimalPlaces,
positionDecimalPlaces
),
};
setTrigger(
oppositeStopOrderSetup,
data.ocoTriggerType,
data.ocoTriggerPrice,
data.ocoTriggerTrailingPercentOffset,
decimalPlaces decimalPlaces
); );
} else if (data.triggerType === 'trailingPercentOffset') {
stopOrderSetup.trailingPercentOffset = (
Number(data.triggerTrailingPercentOffset) / 100
).toFixed(3);
} }
if (data.expire) { if (data.expire) {
stopOrderSetup.expiresAt = data.expiresAt && toNanoSeconds(data.expiresAt); const expiresAt = data.expiresAt && toNanoSeconds(data.expiresAt);
if ( stopOrderSetup.expiresAt = expiresAt;
data.expiryStrategy === stopOrderSetup.expiryStrategy = data.expiryStrategy;
Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_CANCELS if (oppositeStopOrderSetup) {
) { oppositeStopOrderSetup.expiresAt = expiresAt;
stopOrderSetup.expiryStrategy = oppositeStopOrderSetup.expiryStrategy = data.expiryStrategy;
Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_CANCELS;
} else if (
data.expiryStrategy ===
Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT
) {
stopOrderSetup.expiryStrategy =
Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT;
} }
} }
@ -114,12 +145,14 @@ export const mapFormValuesToStopOrdersSubmission = (
Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_RISES_ABOVE Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_RISES_ABOVE
) { ) {
submission.risesAbove = stopOrderSetup; submission.risesAbove = stopOrderSetup;
submission.fallsBelow = oppositeStopOrderSetup;
} }
if ( if (
data.triggerDirection === data.triggerDirection ===
Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_FALLS_BELOW Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_FALLS_BELOW
) { ) {
submission.fallsBelow = stopOrderSetup; submission.fallsBelow = stopOrderSetup;
submission.risesAbove = oppositeStopOrderSetup;
} }
return submission; return submission;

View File

@ -14,8 +14,8 @@ import {
VegaIconNames, VegaIconNames,
DropdownMenuItem, DropdownMenuItem,
TradingDropdownCopyItem, TradingDropdownCopyItem,
Pill,
} from '@vegaprotocol/ui-toolkit'; } from '@vegaprotocol/ui-toolkit';
import type { ForwardedRef } from 'react';
import { memo, useMemo } from 'react'; import { memo, useMemo } from 'react';
import { import {
AgGridLazy as AgGrid, AgGridLazy as AgGrid,
@ -32,7 +32,6 @@ import type {
VegaValueFormatterParams, VegaValueFormatterParams,
VegaValueGetterParams, VegaValueGetterParams,
} from '@vegaprotocol/datagrid'; } from '@vegaprotocol/datagrid';
import type { AgGridReact } from 'ag-grid-react';
import type { StopOrder } from '../order-data-provider/stop-orders-data-provider'; import type { StopOrder } from '../order-data-provider/stop-orders-data-provider';
import type { ColDef } from 'ag-grid-community'; import type { ColDef } from 'ag-grid-community';
import type { Order } from '../order-data-provider'; import type { Order } from '../order-data-provider';
@ -50,9 +49,8 @@ export type StopOrdersTableProps = TypedDataAgGrid<StopOrder> & {
isReadOnly: boolean; isReadOnly: boolean;
}; };
export const StopOrdersTable = memo< export const StopOrdersTable = memo(
StopOrdersTableProps & { ref?: ForwardedRef<AgGridReact> } ({ onCancel, onMarketClick, onView, ...props }: StopOrdersTableProps) => {
>(({ onCancel, onView, onMarketClick, ...props }: StopOrdersTableProps) => {
const showAllActions = !props.isReadOnly; const showAllActions = !props.isReadOnly;
const columnDefs: ColDef[] = useMemo( const columnDefs: ColDef[] = useMemo(
() => [ () => [
@ -171,7 +169,20 @@ export const StopOrdersTable = memo<
valueFormatted: string; valueFormatted: string;
data: StopOrder; data: StopOrder;
}) => ( }) => (
<span data-testid={`order-status-${data?.id}`}>{valueFormatted}</span> <>
<span data-testid={`order-status-${data?.id}`}>
{valueFormatted}
</span>
{data.ocoLinkId && (
<Pill
size="xxs"
className="uppercase ml-0.5"
title={t('One Cancels the Other')}
>
OCO
</Pill>
)}
</>
), ),
}, },
{ {
@ -282,4 +293,5 @@ export const StopOrdersTable = memo<
{...props} {...props}
/> />
); );
}); }
);

View File

@ -9,6 +9,7 @@ import type {
VegaStoredTxState, VegaStoredTxState,
WithdrawalBusEventFieldsFragment, WithdrawalBusEventFieldsFragment,
StopOrdersSubmission, StopOrdersSubmission,
StopOrderSetup,
} from '@vegaprotocol/wallet'; } from '@vegaprotocol/wallet';
import { import {
isTransferTransaction, isTransferTransaction,
@ -49,6 +50,7 @@ import {
useOrderByIdQuery, useOrderByIdQuery,
useStopOrderByIdQuery, useStopOrderByIdQuery,
} from '@vegaprotocol/orders'; } from '@vegaprotocol/orders';
import type { Market } from '@vegaprotocol/markets';
import { useMarketsMapProvider } from '@vegaprotocol/markets'; import { useMarketsMapProvider } from '@vegaprotocol/markets';
import type { Side } from '@vegaprotocol/types'; import type { Side } from '@vegaprotocol/types';
import { OrderStatusMapping } from '@vegaprotocol/types'; import { OrderStatusMapping } from '@vegaprotocol/types';
@ -174,11 +176,15 @@ const SubmitOrderDetails = ({
); );
}; };
const SubmitStopOrderDetails = ({ data }: { data: StopOrdersSubmission }) => { const SubmitStopOrderSetup = ({
const { data: markets } = useMarketsMapProvider(); stopOrderSetup,
const stopOrderSetup = data.risesAbove || data.fallsBelow; triggerDirection,
if (!stopOrderSetup) return null; market,
const market = markets?.[stopOrderSetup?.orderSubmission.marketId]; }: {
stopOrderSetup: StopOrderSetup;
triggerDirection: Schema.StopOrderTriggerDirection;
market: Market;
}) => {
if (!market || !stopOrderSetup) return null; if (!market || !stopOrderSetup) return null;
const { price, size, side } = stopOrderSetup.orderSubmission; const { price, size, side } = stopOrderSetup.orderSubmission;
@ -191,21 +197,14 @@ const SubmitStopOrderDetails = ({ data }: { data: StopOrdersSubmission }) => {
__typename: 'StopOrderTrailingPercentOffset', __typename: 'StopOrderTrailingPercentOffset',
}; };
} }
const triggerDirection = data.risesAbove
? Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_RISES_ABOVE
: Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_FALLS_BELOW;
return ( return (
<Panel>
<h4>{t('Submit stop order')}</h4>
<p>{market?.tradableInstrument.instrument.code}</p>
<p> <p>
<SizeAtPrice <SizeAtPrice
meta={{ meta={{
positionDecimalPlaces: market.positionDecimalPlaces, positionDecimalPlaces: market.positionDecimalPlaces,
decimalPlaces: market.decimalPlaces, decimalPlaces: market.decimalPlaces,
asset: asset:
market.tradableInstrument.instrument.product.settlementAsset market.tradableInstrument.instrument.product.settlementAsset.symbol,
.symbol,
}} }}
side={side} side={side}
size={size} size={size}
@ -222,6 +221,40 @@ const SubmitStopOrderDetails = ({ data }: { data: StopOrdersSubmission }) => {
'' ''
)} )}
</p> </p>
);
};
const SubmitStopOrderDetails = ({ data }: { data: StopOrdersSubmission }) => {
const { data: markets } = useMarketsMapProvider();
const marketId =
data.fallsBelow?.orderSubmission.marketId ||
data.risesAbove?.orderSubmission.marketId;
const market = marketId && markets?.[marketId];
if (!market) {
return null;
}
return (
<Panel>
<h4>{t('Submit stop order')}</h4>
<p>{market?.tradableInstrument.instrument.code}</p>
{data.fallsBelow && (
<SubmitStopOrderSetup
stopOrderSetup={data.fallsBelow}
triggerDirection={
Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_FALLS_BELOW
}
market={market}
/>
)}
{data.risesAbove && (
<SubmitStopOrderSetup
stopOrderSetup={data.risesAbove}
triggerDirection={
Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_RISES_ABOVE
}
market={market}
/>
)}
</Panel> </Panel>
); );
}; };