feat(trading): take profit and stop loss

This commit is contained in:
Madalina Raicu 2024-03-01 20:53:57 +00:00
parent 89e2033556
commit 47ac8d10ef
No known key found for this signature in database
GPG Key ID: 688B7B31149C1DCD
6 changed files with 312 additions and 27 deletions

View File

@ -73,7 +73,7 @@ export const DealTicketContainer = ({
market={market}
marketPrice={marketPrice}
marketData={marketData}
submit={(orderSubmission) => create({ orderSubmission })}
submit={(transaction) => create(transaction)}
/>
)}
</>

View File

@ -0,0 +1,155 @@
import { Controller, type Control } from 'react-hook-form';
import type { Market } from '@vegaprotocol/markets';
import type { OrderFormValues } from '../../hooks/use-form-values';
import { toDecimal, useValidateAmount } from '@vegaprotocol/utils';
import {
TradingFormGroup,
TradingInput,
TradingInputError,
Tooltip,
} from '@vegaprotocol/ui-toolkit';
import { useT } from '../../use-t';
import { type Side } from '@vegaprotocol/types';
export interface DealTicketSizeTakeProfitStopLossProps {
control: Control<OrderFormValues>;
market: Market;
takeProfitError?: string;
stopLossError?: string;
setPrice?: string;
takeProfit?: string;
stopLoss?: string;
side?: Side;
}
export const DealTicketSizeTakeProfitStopLoss = ({
control,
market,
takeProfitError,
stopLossError,
setPrice,
takeProfit,
stopLoss,
side,
}: DealTicketSizeTakeProfitStopLossProps) => {
const t = useT();
const validateAmount = useValidateAmount();
const priceStep = toDecimal(market?.decimalPlaces);
const renderTakeProfitError = () => {
if (takeProfitError) {
return (
<TradingInputError testId="deal-ticket-take-profit-error-message">
{takeProfitError}
</TradingInputError>
);
}
return null;
};
const renderStopLossError = () => {
if (stopLossError) {
return (
<TradingInputError testId="deal-stop-loss-error-message">
{stopLossError}
</TradingInputError>
);
}
return null;
};
return (
<div className="mb-2">
<div className="flex items-center gap-4">
<div className="flex-1">
<TradingFormGroup
label={
<Tooltip
description={
<div>{t('The price at which you can take profit.')}</div>
}
>
<span className="text-xs">{t('Take profit')}</span>
</Tooltip>
}
labelFor="input-order-take-profit"
className="!mb-1"
>
<Controller
name="takeProfit"
control={control}
rules={{
required: t('You need to provide a take profit value'),
min: {
value: priceStep,
message: t('Take profit cannot be lower than {{value}}', {
value: priceStep,
}),
},
validate: validateAmount(priceStep, 'takeProfit'),
}}
render={({ field }) => (
<TradingInput
id="input-order-take-profit"
className="w-full"
type="number"
step={priceStep}
min={priceStep}
data-testid="order-take-profit"
onWheel={(e) => e.currentTarget.blur()}
{...field}
/>
)}
/>
</TradingFormGroup>
</div>
<div className="flex-0 items-center">
<div className="flex"></div>
<div className="flex"></div>
</div>
<div className="flex-1">
<TradingFormGroup
label={
<Tooltip description={<div>{t('The price for stop loss.')}</div>}>
<span className="text-xs">{t('Stop loss')}</span>
</Tooltip>
}
labelFor="input-order-stop-loss"
className="!mb-1"
>
<Controller
name="stopLoss"
control={control}
rules={{
required: t('You need to provide a value for stop loss'),
min: {
value: priceStep,
message: t('Stop loss cannot be lower than {{value}}', {
value: priceStep,
}),
},
validate: validateAmount(priceStep, 'stopLoss'),
}}
render={({ field }) => (
<TradingInput
id="input-order-stop-loss"
className="w-full"
type="number"
step={priceStep}
min={priceStep}
data-testid="order-stop-loss"
onWheel={(e) => e.currentTarget.blur()}
{...field}
/>
)}
/>
</TradingFormGroup>
</div>
</div>
{renderTakeProfitError()}
{renderStopLossError()}
</div>
);
};

View File

@ -8,9 +8,12 @@ import { ExpirySelector } from './expiry-selector';
import { SideSelector } from './side-selector';
import { TimeInForceSelector } from './time-in-force-selector';
import { TypeSelector } from './type-selector';
import { type OrderSubmission } from '@vegaprotocol/wallet';
import { useVegaWallet } from '@vegaprotocol/wallet-react';
import { mapFormValuesToOrderSubmission } from '../../utils/map-form-values-to-submission';
import { type Transaction } from '@vegaprotocol/wallet';
import {
mapFormValuesToOrderSubmission,
mapFormValuesToTakeProfitAndStopLoss,
} from '../../utils/map-form-values-to-submission';
import {
TradingInput as Input,
TradingCheckbox as Checkbox,
@ -77,6 +80,7 @@ import { isNonPersistentOrder } from '../../utils/time-in-force-persistence';
import { KeyValue } from './key-value';
import { DocsLinks } from '@vegaprotocol/environment';
import { useT } from '../../use-t';
import { DealTicketSizeTakeProfitStopLoss } from './deal-ticket-size-tp-sl';
export const REDUCE_ONLY_TOOLTIP =
'"Reduce only" will ensure that this order will not increase the size of an open position. When the order is matched, it will only trade enough volume to bring your open volume towards 0 but never change the direction of your position. If applied to a limit order that is not instantly filled, the order will be stopped.';
@ -86,7 +90,7 @@ export interface DealTicketProps {
marketData: StaticMarketData;
marketPrice?: string | null;
onMarketClick?: (marketId: string, metaKey?: boolean) => void;
submit: (order: OrderSubmission) => void;
submit: (order: Transaction) => void;
onDeposit: (assetId: string) => void;
}
@ -184,9 +188,12 @@ export const DealTicket = ({
const rawSize = watch('size');
const rawPrice = watch('price');
const iceberg = watch('iceberg');
const tpSl = watch('tpSl');
const peakSize = watch('peakSize');
const expiresAt = watch('expiresAt');
const postOnly = watch('postOnly');
const takeProfit = watch('takeProfit');
const stopLoss = watch('stopLoss');
useEffect(() => {
const size = storedFormValues?.[dealTicketType]?.size;
@ -382,17 +389,25 @@ export const DealTicket = ({
if (lastSubmitTime.current && now - lastSubmitTime.current < 1000) {
return;
}
submit(
mapFormValuesToOrderSubmission(
if (formValues.tpSl) {
const batchMarketInstructions = mapFormValuesToTakeProfitAndStopLoss(
formValues,
market.id,
market.decimalPlaces,
market.positionDecimalPlaces
)
market
);
submit({
batchMarketInstructions,
});
}
const orderSubmission = mapFormValuesToOrderSubmission(
formValues,
market.id,
market.decimalPlaces,
market.positionDecimalPlaces
);
submit({ orderSubmission });
lastSubmitTime.current = now;
},
[submit, market.decimalPlaces, market.positionDecimalPlaces, market.id]
[market, submit]
);
useController({
name: 'type',
@ -674,40 +689,63 @@ export const DealTicket = ({
)}
/>
</div>
{isLimitType && (
{
<>
<div className="flex justify-between gap-2 pb-2">
{isLimitType && (
<Controller
name="iceberg"
control={control}
render={({ field }) => (
<Tooltip
description={
<p>
{t(
'ICEBERG_TOOLTIP',
'Trade only a fraction of the order size at once. After the peak size of the order has traded, the size is reset. This is repeated until the order is cancelled, expires, or its full volume trades away. For example, an iceberg order with a size of 1000 and a peak size of 100 will effectively be split into 10 orders with a size of 100 each. Note that the full volume of the order is not hidden and is still reflected in the order book.'
)}{' '}
<ExternalLink href={DocsLinks?.ICEBERG_ORDERS}>
{t('Find out more')}
</ExternalLink>{' '}
</p>
}
>
<div>
<Checkbox
name="iceberg"
checked={field.value}
onCheckedChange={field.onChange}
disabled={disableIcebergCheckbox}
label={t('Iceberg')}
/>
</div>
</Tooltip>
)}
/>
)}
<Controller
name="iceberg"
name="tpSl"
control={control}
render={({ field }) => (
<Tooltip
description={
<p>
{t(
'ICEBERG_TOOLTIP',
'Trade only a fraction of the order size at once. After the peak size of the order has traded, the size is reset. This is repeated until the order is cancelled, expires, or its full volume trades away. For example, an iceberg order with a size of 1000 and a peak size of 100 will effectively be split into 10 orders with a size of 100 each. Note that the full volume of the order is not hidden and is still reflected in the order book.'
)}{' '}
<ExternalLink href={DocsLinks?.ICEBERG_ORDERS}>
{t('Find out more')}
</ExternalLink>{' '}
</p>
<p>{t('TP_SL_TOOLTIP', 'Take profit / Stop loss')}</p>
}
>
<div>
<Checkbox
name="iceberg"
name="tpSl"
checked={field.value}
onCheckedChange={field.onChange}
disabled={disableIcebergCheckbox}
label={t('Iceberg')}
disabled={false}
label={t('TP / SL')}
/>
</div>
</Tooltip>
)}
/>
</div>
{iceberg && (
{isLimitType && iceberg && (
<DealTicketSizeIceberg
market={market}
peakSizeError={errors.peakSize?.message}
@ -717,8 +755,21 @@ export const DealTicket = ({
peakSize={peakSize}
/>
)}
{tpSl && (
<DealTicketSizeTakeProfitStopLoss
market={market}
takeProfitError={errors.takeProfit?.message}
stopLossError={errors.stopLoss?.message}
control={control}
setPrice={price}
takeProfit={takeProfit}
stopLoss={stopLoss}
side={side}
/>
)}
</>
)}
}
<SummaryMessage
error={summaryError}
asset={asset}

View File

@ -53,6 +53,9 @@ export type OrderFormValues = {
iceberg?: boolean;
peakSize?: string;
minimumVisibleSize?: string;
tpSl?: boolean;
takeProfit?: string;
stopLoss?: string;
};
type UpdateOrder = (marketId: string, values: Partial<OrderFormValues>) => void;

View File

@ -10,6 +10,7 @@ import type {
import * as Schema from '@vegaprotocol/types';
import { removeDecimal, toNanoSeconds } from '@vegaprotocol/utils';
import { isPersistentOrder } from './time-in-force-persistence';
import { type MarketFieldsFragment } from '@vegaprotocol/markets';
export const mapFormValuesToOrderSubmission = (
order: OrderFormValues,
@ -159,3 +160,75 @@ export const mapFormValuesToStopOrdersSubmission = (
return submission;
};
export const mapFormValuesToTakeProfitAndStopLoss = (
formValues: OrderFormValues,
market: MarketFieldsFragment
) => {
const ocoType =
formValues.type === Schema.OrderType.TYPE_LIMIT
? Schema.OrderType.TYPE_MARKET
: Schema.OrderType.TYPE_LIMIT;
const orderSubmission = mapFormValuesToOrderSubmission(
formValues,
market.id,
market.decimalPlaces,
market.positionDecimalPlaces
);
const takeProfitStopOrderSubmission =
formValues.takeProfit &&
mapFormValuesToStopOrdersSubmission(
{
...formValues,
price: formValues.takeProfit,
triggerDirection:
formValues.side === Schema.Side.SIDE_SELL
? Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_FALLS_BELOW
: Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_RISES_ABOVE,
triggerType: 'price',
ocoTriggerType: 'price',
expire: false,
ocoType,
ocoSize: formValues.size,
ocoTimeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_FOK,
},
market.id,
market.decimalPlaces,
market.positionDecimalPlaces
);
const stopLossStopOrderSubmission =
formValues.stopLoss &&
mapFormValuesToStopOrdersSubmission(
{
...formValues,
price: formValues.stopLoss,
triggerDirection:
formValues.side === Schema.Side.SIDE_BUY
? Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_FALLS_BELOW
: Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_RISES_ABOVE,
triggerType: 'price',
ocoTriggerType: 'price',
expire: false,
ocoType,
ocoSize: formValues.size,
ocoTimeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_FOK,
},
market.id,
market.decimalPlaces,
market.positionDecimalPlaces
);
const stopOrdersSubmission = [];
if (takeProfitStopOrderSubmission) {
stopOrdersSubmission.push(takeProfitStopOrderSubmission);
}
if (stopLossStopOrderSubmission) {
stopOrdersSubmission.push(stopLossStopOrderSubmission);
}
const batchMarketInstructions = {
submissions: [orderSubmission],
stopOrdersSubmission,
};
return batchMarketInstructions;
};

View File

@ -403,6 +403,9 @@ export interface BatchMarketInstructionSubmissionBody {
// Note: If multiple orders are submitted the first order ID is determined by hashing the signature of the transaction
// (see determineId function). For each subsequent order's ID, a hash of the previous orders ID is used
submissions?: OrderSubmission[];
stopOrdersSubmission?: StopOrdersSubmission[];
stopOrdersCancellation?: StopOrdersCancellation[];
updateMarginMode?: UpdateMarginMode[];
};
}