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(orderPriceField).clear().type('1.123456');
cy.getByTestId(placeOrderBtn).click();
cy.getByTestId('deal-ticket-error-message-price-limit').should(
cy.getByTestId('deal-ticket-error-message-price').should(
'have.text',
'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');
// 7002-SORD-060
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',
'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 () {
cy.getByTestId(orderSizeField).clear().type('0');
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',
'Size cannot be lower than 1'
);

View File

@ -36,6 +36,6 @@ export const StopOrdersContainer = () => {
const useStopOrdersStore = create<DataGridSlice>()(
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%);
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 priceErrorMessage = 'stop-order-error-message-price';
const triggerPriceErrorMessage = 'stop-order-error-message-trigger-price';
const triggerPriceWarningMessage = 'stop-order-warning-message-trigger-price';
const triggerTrailingPercentOffsetErrorMessage =
'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 () => {
const values: Partial<StopOrderFormValues> = {
type: Schema.OrderType.TYPE_LIMIT,
@ -203,8 +196,8 @@ describe('StopOrder', () => {
await userEvent.click(screen.getByTestId(submitButton));
// price error message should not show if size has error
expect(screen.queryByTestId(priceErrorMessage)).toBeNull();
await userEvent.type(screen.getByTestId(sizeInput), '0.1');
// expect(screen.queryByTestId(priceErrorMessage)).toBeNull();
// await userEvent.type(screen.getByTestId(sizeInput), '0.1');
expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument();
await userEvent.type(screen.getByTestId(priceInput), '0.001');
expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument();
@ -249,10 +242,17 @@ describe('StopOrder', () => {
await userEvent.type(screen.getByTestId(triggerPriceInput), '0.001');
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.type(screen.getByTestId(triggerPriceInput), '0.01');
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 () => {

View File

@ -1,22 +1,18 @@
import { useRef, useCallback, useEffect } from 'react';
import { useVegaWallet } from '@vegaprotocol/wallet';
import type { StopOrdersSubmission } from '@vegaprotocol/wallet';
import {
formatNumber,
removeDecimal,
toDecimal,
validateAmount,
} from '@vegaprotocol/utils';
import { removeDecimal, toDecimal, validateAmount } from '@vegaprotocol/utils';
import type { Control, UseFormWatch } from 'react-hook-form';
import { useForm, Controller, useController } from 'react-hook-form';
import * as Schema from '@vegaprotocol/types';
import {
TradingRadio,
TradingRadioGroup,
TradingInput,
TradingCheckbox,
TradingFormGroup,
TradingInputError,
TradingSelect,
TradingRadio as Radio,
TradingRadioGroup as RadioGroup,
TradingInput as Input,
TradingCheckbox as Checkbox,
TradingFormGroup as FormGroup,
TradingInputError as InputError,
TradingSelect as Select,
Tooltip,
} from '@vegaprotocol/ui-toolkit';
import { getDerivedPrice, type Market } from '@vegaprotocol/markets';
@ -49,6 +45,8 @@ export interface StopOrderProps {
submit: (order: StopOrdersSubmission) => void;
}
const trailingPercentOffsetStep = '0.1';
const getDefaultValues = (
type: Schema.OrderType,
storedValues?: Partial<StopOrderFormValues>
@ -62,9 +60,425 @@ const getDefaultValues = (
expire: false,
expiryStrategy: Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT,
size: '0',
ocoType: type,
ocoTimeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_FOK,
ocoTriggerType: 'price',
ocoSize: '0',
...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) => {
const { pubKey, isReadOnly } = useVegaWallet();
const setType = useDealTicketFormValues((state) => state.setType);
@ -107,6 +521,7 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
const timeInForce = watch('timeInForce');
const rawPrice = watch('price');
const rawSize = watch('size');
const oco = watch('oco');
useEffect(() => {
const size = storedFormValues?.[dealTicketType]?.size;
@ -155,12 +570,6 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
const sizeStep = toDecimal(market?.positionDecimalPlaces);
const priceStep = toDecimal(market?.decimalPlaces);
const trailingPercentOffsetStep = '0.1';
const priceFormatted =
isPriceTrigger && triggerPrice
? formatNumber(triggerPrice, market.decimalPlaces)
: undefined;
useController({
name: 'type',
@ -187,9 +596,9 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
}}
/>
{errors.type && (
<TradingInputError testId="stop-order-error-message-type">
<InputError testId="stop-order-error-message-type">
{errors.type.message}
</TradingInputError>
</InputError>
)}
<Controller
@ -199,302 +608,117 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
<SideSelector value={field.value} onValueChange={field.onChange} />
)}
/>
<TradingFormGroup label={t('Trigger')} compact={true} labelFor="">
<Controller
name="triggerDirection"
control={control}
render={({ field }) => {
const { onChange, value } = field;
return (
<TradingRadioGroup
name="triggerDirection"
onChange={onChange}
value={value}
orientation="horizontal"
className="mb-2"
>
<TradingRadio
value={
Schema.StopOrderTriggerDirection
.TRIGGER_DIRECTION_RISES_ABOVE
}
id="triggerDirection-risesAbove"
label={'Rises above'}
/>
<TradingRadio
value={
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}
render={({ field, fieldState }) => {
const { value, ...props } = field;
return (
<div className="mb-2">
<TradingInput
data-testid="triggerPrice"
type="number"
step={priceStep}
appendElement={asset.symbol}
value={value || ''}
hasError={!!fieldState.error}
{...props}
/>
</div>
);
}}
/>
{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>
)}
<Trigger
control={control}
watch={watch}
priceStep={priceStep}
assetSymbol={asset.symbol}
marketPrice={marketPrice}
decimalPlaces={market.decimalPlaces}
/>
<hr className="mb-4 border-vega-clight-500 dark:border-vega-cdark-500" />
<Price
control={control}
watch={watch}
priceStep={priceStep}
quoteName={quoteName}
/>
<Size control={control} sizeStep={sizeStep} />
<TimeInForce control={control} />
<div className="flex gap-2 pb-3 justify-end">
<ReduceOnly />
</div>
<hr className="mb-4 border-vega-clight-500 dark:border-vega-cdark-500" />
<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
name="expire"
control={control}
render={({ field }) => {
const { onChange: onCheckedChange, value } = field;
return (
<TradingCheckbox
<Checkbox
onCheckedChange={onCheckedChange}
checked={value}
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>
{expire && (
<>
<TradingFormGroup
label={t('Strategy')}
labelFor="expiryStrategy"
compact={true}
>
<FormGroup label={t('Strategy')} labelFor="expiryStrategy">
<Controller
name="expiryStrategy"
control={control}
render={({ field }) => {
const { onChange, value } = field;
return (
<TradingRadioGroup orientation="horizontal" {...field}>
<TradingRadio
<RadioGroup
onChange={onChange}
value={value}
orientation="horizontal"
>
<Radio
disabled={oco}
value={
Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT
}
id="expiryStrategy-submit"
label={'Submit'}
/>
<TradingRadio
<Radio
value={
Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_CANCELS
}
id="expiryStrategy-cancel"
label={'Cancel'}
/>
</TradingRadioGroup>
</RadioGroup>
);
}}
/>
</TradingFormGroup>
<div className="mb-2">
</FormGroup>
<div className="mb-4">
<Controller
name="expiresAt"
control={control}

View File

@ -7,7 +7,6 @@ import { DealTicket } from './deal-ticket';
import * as Schema from '@vegaprotocol/types';
import type { MockedResponse } from '@apollo/client/testing';
import { MockedProvider } from '@apollo/client/testing';
import { addDecimal } from '@vegaprotocol/utils';
import type { OrdersQuery } from '@vegaprotocol/orders';
import {
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', () => {
const expectedOrder = {
marketId: market.id,

View File

@ -3,7 +3,6 @@ import * as Schema from '@vegaprotocol/types';
import type { FormEventHandler } from 'react';
import { memo, useCallback, useEffect, useRef, useMemo } from 'react';
import { Controller, useController, useForm } from 'react-hook-form';
import { DealTicketAmount } from './deal-ticket-amount';
import { DealTicketButton } from './deal-ticket-button';
import {
DealTicketFeeDetails,
@ -17,8 +16,10 @@ import type { OrderSubmission } from '@vegaprotocol/wallet';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { mapFormValuesToOrderSubmission } from '../../utils/map-form-values-to-submission';
import {
TradingCheckbox,
TradingInputError,
TradingInput as Input,
TradingCheckbox as Checkbox,
TradingFormGroup as FormGroup,
TradingInputError as InputError,
Intent,
Notification,
Tooltip,
@ -28,7 +29,12 @@ import {
useEstimatePositionQuery,
useOpenVolume,
} from '@vegaprotocol/positions';
import { toBigNum, removeDecimal } from '@vegaprotocol/utils';
import {
toBigNum,
removeDecimal,
validateAmount,
toDecimal,
} from '@vegaprotocol/utils';
import { activeOrdersProvider } from '@vegaprotocol/orders';
import { getDerivedPrice } from '@vegaprotocol/markets';
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 (
<form
onSubmit={
@ -366,15 +376,82 @@ export const DealTicket = ({
<SideSelector value={field.value} onValueChange={field.onChange} />
)}
/>
<DealTicketAmount
type={type}
<Controller
name="size"
control={control}
market={market}
marketData={marketData}
marketPrice={marketPrice || undefined}
sizeError={errors.size?.message}
priceError={errors.price?.message}
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 }) => (
<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
name="timeInForce"
control={control}
@ -417,7 +494,7 @@ export const DealTicket = ({
name="postOnly"
control={control}
render={({ field }) => (
<TradingCheckbox
<Checkbox
name="post-only"
checked={!disablePostOnlyCheckbox && field.value}
disabled={disablePostOnlyCheckbox}
@ -449,7 +526,7 @@ export const DealTicket = ({
name="reduceOnly"
control={control}
render={({ field }) => (
<TradingCheckbox
<Checkbox
name="reduce-only"
checked={!disableReduceOnlyCheckbox && field.value}
disabled={disableReduceOnlyCheckbox}
@ -483,7 +560,7 @@ export const DealTicket = ({
name="iceberg"
control={control}
render={({ field }) => (
<TradingCheckbox
<Checkbox
name="iceberg"
checked={field.value}
onCheckedChange={field.onChange}
@ -572,11 +649,11 @@ export const NoWalletWarning = ({
if (isReadOnly) {
return (
<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'
}
</TradingInputError>
</InputError>
</div>
);
}
@ -613,9 +690,9 @@ const SummaryMessage = memo(
if (error?.message) {
return (
<div className="mb-2">
<TradingInputError testId="deal-ticket-error-message-summary">
<InputError testId="deal-ticket-error-message-summary">
{error?.message}
</TradingInputError>
</InputError>
</div>
);
}

View File

@ -23,25 +23,27 @@ export const ExpirySelector = ({
const dateFormatted = formatForInput(date);
const minDate = formatForInput(date);
return (
<TradingFormGroup
label={t('Expiry time/date')}
labelFor="expiration"
compact={true}
>
<TradingInput
data-testid="date-picker-field"
id="expiration"
type="datetime-local"
value={dateFormatted}
onChange={(e) => onSelect(e.target.value)}
min={minDate}
hasError={!!errorMessage}
/>
{errorMessage && (
<TradingInputError testId="deal-ticket-error-message-expiry">
{errorMessage}
</TradingInputError>
)}
</TradingFormGroup>
<div className="mb-4">
<TradingFormGroup
label={t('Expiry time/date')}
labelFor="expiration"
compact
>
<TradingInput
data-testid="date-picker-field"
id="expiration"
type="datetime-local"
value={dateFormatted}
onChange={(e) => onSelect(e.target.value)}
min={minDate}
hasError={!!errorMessage}
/>
{errorMessage && (
<TradingInputError testId="deal-ticket-error-message-expiry">
{errorMessage}
</TradingInputError>
)}
</TradingFormGroup>
</div>
);
};

View File

@ -1,7 +1,4 @@
export * from './deal-ticket-amount';
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-stop-order';
export * from './deal-ticket-container';

View File

@ -90,32 +90,34 @@ export const TimeInForceSelector = ({
};
return (
<TradingFormGroup
label={t('Time in force')}
labelFor="select-time-in-force"
compact={true}
>
<TradingSelect
id="select-time-in-force"
value={value}
onChange={(e) => {
onSelect(e.target.value as Schema.OrderTimeInForce);
}}
className="w-full"
data-testid="order-tif"
hasError={!!errorMessage}
<div className="mb-4">
<TradingFormGroup
label={t('Time in force')}
labelFor="select-time-in-force"
compact={true}
>
{options.map(([key, value]) => (
<option key={key} value={value}>
{timeInForceLabel(value)}
</option>
))}
</TradingSelect>
{errorMessage && (
<TradingInputError testId="deal-ticket-error-message-tif">
{renderError(errorMessage)}
</TradingInputError>
)}
</TradingFormGroup>
<TradingSelect
id="select-time-in-force"
value={value}
onChange={(e) => {
onSelect(e.target.value as Schema.OrderTimeInForce);
}}
className="w-full"
data-testid="order-tif"
hasError={!!errorMessage}
>
{options.map(([key, value]) => (
<option key={key} value={value}>
{timeInForceLabel(value)}
</option>
))}
</TradingSelect>
{errorMessage && (
<TradingInputError testId="deal-ticket-error-message-tif">
{renderError(errorMessage)}
</TradingInputError>
)}
</TradingFormGroup>
</div>
);
};

View File

@ -76,7 +76,7 @@ export const TypeToggle = ({
<TradingDropdownTrigger
data-testid="order-type-Stop"
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,
}

View File

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

View File

@ -59,6 +59,22 @@ export const mapFormValuesToOrderSubmission = (
: 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 = (
data: StopOrderFormValues,
marketId: string,
@ -81,31 +97,46 @@ export const mapFormValuesToStopOrdersSubmission = (
positionDecimalPlaces
),
};
if (data.triggerType === 'price') {
stopOrderSetup.price = removeDecimal(
data.triggerPrice ?? '',
setTrigger(
stopOrderSetup,
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
);
} else if (data.triggerType === 'trailingPercentOffset') {
stopOrderSetup.trailingPercentOffset = (
Number(data.triggerTrailingPercentOffset) / 100
).toFixed(3);
}
if (data.expire) {
stopOrderSetup.expiresAt = data.expiresAt && toNanoSeconds(data.expiresAt);
if (
data.expiryStrategy ===
Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_CANCELS
) {
stopOrderSetup.expiryStrategy =
Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_CANCELS;
} else if (
data.expiryStrategy ===
Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT
) {
stopOrderSetup.expiryStrategy =
Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT;
const expiresAt = data.expiresAt && toNanoSeconds(data.expiresAt);
stopOrderSetup.expiresAt = expiresAt;
stopOrderSetup.expiryStrategy = data.expiryStrategy;
if (oppositeStopOrderSetup) {
oppositeStopOrderSetup.expiresAt = expiresAt;
oppositeStopOrderSetup.expiryStrategy = data.expiryStrategy;
}
}
@ -114,12 +145,14 @@ export const mapFormValuesToStopOrdersSubmission = (
Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_RISES_ABOVE
) {
submission.risesAbove = stopOrderSetup;
submission.fallsBelow = oppositeStopOrderSetup;
}
if (
data.triggerDirection ===
Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_FALLS_BELOW
) {
submission.fallsBelow = stopOrderSetup;
submission.risesAbove = oppositeStopOrderSetup;
}
return submission;

View File

@ -14,8 +14,8 @@ import {
VegaIconNames,
DropdownMenuItem,
TradingDropdownCopyItem,
Pill,
} from '@vegaprotocol/ui-toolkit';
import type { ForwardedRef } from 'react';
import { memo, useMemo } from 'react';
import {
AgGridLazy as AgGrid,
@ -32,7 +32,6 @@ import type {
VegaValueFormatterParams,
VegaValueGetterParams,
} from '@vegaprotocol/datagrid';
import type { AgGridReact } from 'ag-grid-react';
import type { StopOrder } from '../order-data-provider/stop-orders-data-provider';
import type { ColDef } from 'ag-grid-community';
import type { Order } from '../order-data-provider';
@ -50,236 +49,249 @@ export type StopOrdersTableProps = TypedDataAgGrid<StopOrder> & {
isReadOnly: boolean;
};
export const StopOrdersTable = memo<
StopOrdersTableProps & { ref?: ForwardedRef<AgGridReact> }
>(({ onCancel, onView, onMarketClick, ...props }: StopOrdersTableProps) => {
const showAllActions = !props.isReadOnly;
const columnDefs: ColDef[] = useMemo(
() => [
{
headerName: t('Market'),
field: 'market.tradableInstrument.instrument.code',
cellRenderer: 'MarketNameCell',
cellRendererParams: { idPath: 'market.id', onMarketClick },
},
{
headerName: t('Trigger'),
field: 'trigger',
cellClass: 'font-mono text-right',
type: 'rightAligned',
sortable: false,
valueFormatter: ({
data,
}: VegaValueFormatterParams<StopOrder, 'trigger'>): string =>
data ? formatTrigger(data, data.market.decimalPlaces) : '',
},
{
field: 'expiresAt',
valueFormatter: ({
value,
data,
}: VegaValueFormatterParams<StopOrder, 'expiresAt'>) => {
if (
data &&
value &&
data?.expiryStrategy !==
Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_UNSPECIFIED
) {
const expiresAt = getDateTimeFormat().format(new Date(value));
const expiryStrategy =
data.expiryStrategy ===
Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT
? t('Submit')
: t('Cancels');
return `${expiryStrategy} ${expiresAt}`;
}
return '';
export const StopOrdersTable = memo(
({ onCancel, onMarketClick, onView, ...props }: StopOrdersTableProps) => {
const showAllActions = !props.isReadOnly;
const columnDefs: ColDef[] = useMemo(
() => [
{
headerName: t('Market'),
field: 'market.tradableInstrument.instrument.code',
cellRenderer: 'MarketNameCell',
cellRendererParams: { idPath: 'market.id', onMarketClick },
},
},
{
headerName: t('Size'),
field: 'submission.size',
cellClass: 'font-mono text-right',
type: 'rightAligned',
cellClassRules: {
[positiveClassNames]: ({ data }: { data: StopOrder }) =>
data?.submission.size === Schema.Side.SIDE_BUY,
[negativeClassNames]: ({ data }: { data: StopOrder }) =>
data?.submission.size === Schema.Side.SIDE_SELL,
{
headerName: t('Trigger'),
field: 'trigger',
cellClass: 'font-mono text-right',
type: 'rightAligned',
sortable: false,
valueFormatter: ({
data,
}: VegaValueFormatterParams<StopOrder, 'trigger'>): string =>
data ? formatTrigger(data, data.market.decimalPlaces) : '',
},
valueGetter: ({ data }: VegaValueGetterParams<StopOrder>) => {
return data?.submission.size && data.market
? toBigNum(
data.submission.size,
data.market.positionDecimalPlaces ?? 0
)
.multipliedBy(
data.submission.side === Schema.Side.SIDE_SELL ? -1 : 1
{
field: 'expiresAt',
valueFormatter: ({
value,
data,
}: VegaValueFormatterParams<StopOrder, 'expiresAt'>) => {
if (
data &&
value &&
data?.expiryStrategy !==
Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_UNSPECIFIED
) {
const expiresAt = getDateTimeFormat().format(new Date(value));
const expiryStrategy =
data.expiryStrategy ===
Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT
? t('Submit')
: t('Cancels');
return `${expiryStrategy} ${expiresAt}`;
}
return '';
},
},
{
headerName: t('Size'),
field: 'submission.size',
cellClass: 'font-mono text-right',
type: 'rightAligned',
cellClassRules: {
[positiveClassNames]: ({ data }: { data: StopOrder }) =>
data?.submission.size === Schema.Side.SIDE_BUY,
[negativeClassNames]: ({ data }: { data: StopOrder }) =>
data?.submission.size === Schema.Side.SIDE_SELL,
},
valueGetter: ({ data }: VegaValueGetterParams<StopOrder>) => {
return data?.submission.size && data.market
? toBigNum(
data.submission.size,
data.market.positionDecimalPlaces ?? 0
)
.toNumber()
: undefined;
.multipliedBy(
data.submission.side === Schema.Side.SIDE_SELL ? -1 : 1
)
.toNumber()
: undefined;
},
valueFormatter: ({
data,
}: VegaValueFormatterParams<StopOrder, 'size'>) => {
if (!data) {
return '';
}
if (!data?.market || !isNumeric(data.submission.size)) {
return '-';
}
const prefix = data
? data.submission.side === Schema.Side.SIDE_BUY
? '+'
: '-'
: '';
return (
prefix +
addDecimalsFormatNumber(
data.submission.size,
data.market.positionDecimalPlaces
)
);
},
},
valueFormatter: ({
data,
}: VegaValueFormatterParams<StopOrder, 'size'>) => {
if (!data) {
return '';
}
if (!data?.market || !isNumeric(data.submission.size)) {
return '-';
}
const prefix = data
? data.submission.side === Schema.Side.SIDE_BUY
? '+'
: '-'
: '';
return (
prefix +
addDecimalsFormatNumber(
data.submission.size,
data.market.positionDecimalPlaces
)
);
{
field: 'submission.type',
filter: SetFilter,
filterParams: {
set: Schema.OrderTypeMapping,
},
cellRenderer: ({
value,
}: VegaICellRendererParams<StopOrder, 'submission.type'>) =>
value ? Schema.OrderTypeMapping[value] : '',
},
},
{
field: 'submission.type',
filter: SetFilter,
filterParams: {
set: Schema.OrderTypeMapping,
{
field: 'status',
filter: SetFilter,
filterParams: {
set: Schema.StopOrderStatusMapping,
},
valueFormatter: ({
value,
}: VegaValueFormatterParams<StopOrder, 'status'>) => {
return value ? Schema.StopOrderStatusMapping[value] : '';
},
cellRenderer: ({
valueFormatted,
data,
}: {
valueFormatted: string;
data: StopOrder;
}) => (
<>
<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>
)}
</>
),
},
cellRenderer: ({
value,
}: VegaICellRendererParams<StopOrder, 'submission.type'>) =>
value ? Schema.OrderTypeMapping[value] : '',
},
{
field: 'status',
filter: SetFilter,
filterParams: {
set: Schema.StopOrderStatusMapping,
{
field: 'submission.price',
type: 'rightAligned',
cellClass: 'font-mono text-right',
valueFormatter: ({
value,
data,
}: VegaValueFormatterParams<StopOrder, 'submission.price'>) => {
if (!data) {
return '';
}
if (
!data?.market ||
data.submission.type === Schema.OrderType.TYPE_MARKET ||
!isNumeric(value)
) {
return '-';
}
return addDecimalsFormatNumber(value, data.market.decimalPlaces);
},
},
valueFormatter: ({
value,
}: VegaValueFormatterParams<StopOrder, 'status'>) => {
return value ? Schema.StopOrderStatusMapping[value] : '';
{
field: 'submission.timeInForce',
filter: SetFilter,
filterParams: {
set: Schema.OrderTimeInForceMapping,
},
valueFormatter: ({
value,
}: VegaValueFormatterParams<StopOrder, 'submission.timeInForce'>) => {
return value ? Schema.OrderTimeInForceCode[value] : '';
},
},
cellRenderer: ({
valueFormatted,
data,
}: {
valueFormatted: string;
data: StopOrder;
}) => (
<span data-testid={`order-status-${data?.id}`}>{valueFormatted}</span>
),
},
{
field: 'submission.price',
type: 'rightAligned',
cellClass: 'font-mono text-right',
valueFormatter: ({
value,
data,
}: VegaValueFormatterParams<StopOrder, 'submission.price'>) => {
if (!data) {
return '';
}
if (
!data?.market ||
data.submission.type === Schema.OrderType.TYPE_MARKET ||
!isNumeric(value)
) {
return '-';
}
return addDecimalsFormatNumber(value, data.market.decimalPlaces);
{
field: 'updatedAt',
filter: DateRangeFilter,
valueGetter: ({ data }: VegaValueGetterParams<StopOrder>) =>
data?.updatedAt || data?.createdAt,
cellRenderer: ({
data,
}: VegaICellRendererParams<StopOrder, 'createdAt'>) => {
if (!data) {
return undefined;
}
const value = data.updatedAt || data.createdAt;
return (
<span data-value={value}>
{value ? getDateTimeFormat().format(new Date(value)) : '-'}
</span>
);
},
},
},
{
field: 'submission.timeInForce',
filter: SetFilter,
filterParams: {
set: Schema.OrderTimeInForceMapping,
},
valueFormatter: ({
value,
}: VegaValueFormatterParams<StopOrder, 'submission.timeInForce'>) => {
return value ? Schema.OrderTimeInForceCode[value] : '';
},
},
{
field: 'updatedAt',
filter: DateRangeFilter,
valueGetter: ({ data }: VegaValueGetterParams<StopOrder>) =>
data?.updatedAt || data?.createdAt,
cellRenderer: ({
data,
}: VegaICellRendererParams<StopOrder, 'createdAt'>) => {
if (!data) {
return undefined;
}
const value = data.updatedAt || data.createdAt;
return (
<span data-value={value}>
{value ? getDateTimeFormat().format(new Date(value)) : '-'}
</span>
);
},
},
{
colId: 'actions',
...COL_DEFS.actions,
minWidth: showAllActions ? 120 : COL_DEFS.actions.minWidth,
maxWidth: showAllActions ? 120 : COL_DEFS.actions.minWidth,
cellRenderer: ({ data }: { data?: StopOrder }) => {
if (!data) return null;
{
colId: 'actions',
...COL_DEFS.actions,
minWidth: showAllActions ? 120 : COL_DEFS.actions.minWidth,
maxWidth: showAllActions ? 120 : COL_DEFS.actions.minWidth,
cellRenderer: ({ data }: { data?: StopOrder }) => {
if (!data) return null;
return (
<div className="flex gap-2 items-center justify-end">
{data.status === Schema.StopOrderStatus.STATUS_PENDING &&
!props.isReadOnly && (
<ButtonLink
data-testid="cancel"
onClick={() => onCancel(data)}
>
{t('Cancel')}
</ButtonLink>
)}
{data.status === Schema.StopOrderStatus.STATUS_TRIGGERED &&
data.order && (
<ActionsDropdown data-testid="stop-order-actions-content">
<TradingDropdownCopyItem
value={data.order.id}
text={t('Copy order ID')}
/>
<DropdownMenuItem
key={'view-order'}
data-testid="view-order"
onClick={() =>
data.order &&
onView({ ...data.order, market: data.market })
}
return (
<div className="flex gap-2 items-center justify-end">
{data.status === Schema.StopOrderStatus.STATUS_PENDING &&
!props.isReadOnly && (
<ButtonLink
data-testid="cancel"
onClick={() => onCancel(data)}
>
<VegaIcon name={VegaIconNames.INFO} size={16} />
{t('View order details')}
</DropdownMenuItem>
</ActionsDropdown>
)}
</div>
);
{t('Cancel')}
</ButtonLink>
)}
{data.status === Schema.StopOrderStatus.STATUS_TRIGGERED &&
data.order && (
<ActionsDropdown data-testid="stop-order-actions-content">
<TradingDropdownCopyItem
value={data.order.id}
text={t('Copy order ID')}
/>
<DropdownMenuItem
key={'view-order'}
data-testid="view-order"
onClick={() =>
data.order &&
onView({ ...data.order, market: data.market })
}
>
<VegaIcon name={VegaIconNames.INFO} size={16} />
{t('View order details')}
</DropdownMenuItem>
</ActionsDropdown>
)}
</div>
);
},
},
},
],
[onCancel, onMarketClick, onView, props.isReadOnly, showAllActions]
);
],
[onCancel, onMarketClick, onView, props.isReadOnly, showAllActions]
);
return (
<AgGrid
defaultColDef={defaultColDef}
columnDefs={columnDefs}
getRowId={({ data }) => data.id}
components={{ MarketNameCell }}
{...props}
/>
);
});
return (
<AgGrid
defaultColDef={defaultColDef}
columnDefs={columnDefs}
getRowId={({ data }) => data.id}
components={{ MarketNameCell }}
{...props}
/>
);
}
);

View File

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