feat(trading): add post only and reduce only orders (#3311)

This commit is contained in:
m.ray 2023-03-31 12:00:55 -04:00 committed by GitHub
parent f1524d3fcd
commit ce0ccdfebc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 181 additions and 27 deletions

View File

@ -1,7 +1,6 @@
--- ---
name: Release name: Release
about: about: A template to outline the steps needed to for a successful release of our frontend apps
A template to outline the steps needed to for a successful release of our frontend apps
title: 'Release [add dapp version]-core-[add core version]' title: 'Release [add dapp version]-core-[add core version]'
labels: labels:
assignees: '' assignees: ''

View File

@ -84,6 +84,8 @@ describe('must submit order', { tags: '@smoke' }, () => {
type: Schema.OrderType.TYPE_MARKET, type: Schema.OrderType.TYPE_MARKET,
side: Schema.Side.SIDE_BUY, side: Schema.Side.SIDE_BUY,
timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_FOK, timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_FOK,
postOnly: false,
reduceOnly: false,
size: '100', size: '100',
}; };
createOrder(order); createOrder(order);
@ -98,6 +100,8 @@ describe('must submit order', { tags: '@smoke' }, () => {
side: Schema.Side.SIDE_SELL, side: Schema.Side.SIDE_SELL,
timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_IOC, timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_IOC,
size: '100', size: '100',
postOnly: false,
reduceOnly: false,
}; };
createOrder(order); createOrder(order);
testOrderSubmission(order); testOrderSubmission(order);
@ -112,6 +116,8 @@ describe('must submit order', { tags: '@smoke' }, () => {
side: Schema.Side.SIDE_BUY, side: Schema.Side.SIDE_BUY,
timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_GTC, timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_GTC,
size: '100', size: '100',
postOnly: false,
reduceOnly: false,
price: '200', price: '200',
}; };
createOrder(order); createOrder(order);
@ -126,6 +132,8 @@ describe('must submit order', { tags: '@smoke' }, () => {
side: Schema.Side.SIDE_SELL, side: Schema.Side.SIDE_SELL,
timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_GFN, timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_GFN,
size: '100', size: '100',
postOnly: false,
reduceOnly: false,
price: '50000', price: '50000',
}; };
createOrder(order); createOrder(order);
@ -143,6 +151,8 @@ describe('must submit order', { tags: '@smoke' }, () => {
size: '100', size: '100',
price: '1.00', price: '1.00',
expiresAt: expiresAt.toISOString().substring(0, 16), expiresAt: expiresAt.toISOString().substring(0, 16),
postOnly: false,
reduceOnly: false,
}; };
createOrder(order); createOrder(order);
@ -150,6 +160,8 @@ describe('must submit order', { tags: '@smoke' }, () => {
price: '100000', price: '100000',
expiresAt: expiresAt:
new Date(order.expiresAt as string).getTime().toString() + '000000', new Date(order.expiresAt as string).getTime().toString() + '000000',
postOnly: false,
reduceOnly: false,
}); });
}); });
}); });
@ -182,6 +194,8 @@ describe(
side: Schema.Side.SIDE_BUY, side: Schema.Side.SIDE_BUY,
timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_GTC, timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_GTC,
size: '100', size: '100',
postOnly: false,
reduceOnly: false,
price: '200', price: '200',
}; };
createOrder(order); createOrder(order);
@ -196,6 +210,8 @@ describe(
side: Schema.Side.SIDE_SELL, side: Schema.Side.SIDE_SELL,
timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_GTC, timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_GTC,
size: '100', size: '100',
postOnly: false,
reduceOnly: false,
price: '50000', price: '50000',
}; };
createOrder(order); createOrder(order);
@ -210,12 +226,16 @@ describe(
side: Schema.Side.SIDE_SELL, side: Schema.Side.SIDE_SELL,
timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_GTT, timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_GTT,
size: '100', size: '100',
postOnly: false,
reduceOnly: false,
price: '1.00', price: '1.00',
expiresAt: displayTomorrow(), expiresAt: displayTomorrow(),
}; };
createOrder(order); createOrder(order);
testOrderSubmission(order, { testOrderSubmission(order, {
price: '100000', price: '100000',
postOnly: false,
reduceOnly: false,
expiresAt: expiresAt:
new Date(order.expiresAt as string).getTime().toString() + '000000', new Date(order.expiresAt as string).getTime().toString() + '000000',
}); });
@ -251,6 +271,8 @@ describe(
side: Schema.Side.SIDE_BUY, side: Schema.Side.SIDE_BUY,
timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_GTC, timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_GTC,
size: '100', size: '100',
postOnly: false,
reduceOnly: false,
price: '200', price: '200',
}; };
createOrder(order); createOrder(order);
@ -265,6 +287,8 @@ describe(
side: Schema.Side.SIDE_SELL, side: Schema.Side.SIDE_SELL,
timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_GTC, timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_GTC,
size: '100', size: '100',
postOnly: false,
reduceOnly: false,
price: '50000', price: '50000',
}; };
createOrder(order); createOrder(order);
@ -281,6 +305,8 @@ describe(
size: '100', size: '100',
price: '1.00', price: '1.00',
expiresAt: displayTomorrow(), expiresAt: displayTomorrow(),
postOnly: false,
reduceOnly: false,
}; };
createOrder(order); createOrder(order);
testOrderSubmission(order, { testOrderSubmission(order, {
@ -320,6 +346,8 @@ describe(
side: Schema.Side.SIDE_BUY, side: Schema.Side.SIDE_BUY,
timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_GTC, timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_GTC,
size: '100', size: '100',
postOnly: false,
reduceOnly: false,
price: '200', price: '200',
}; };
createOrder(order); createOrder(order);
@ -335,6 +363,8 @@ describe(
timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_GTC, timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_GTC,
size: '100', size: '100',
price: '50000', price: '50000',
postOnly: false,
reduceOnly: false,
}; };
createOrder(order); createOrder(order);
testOrderSubmission(order, { price: '5000000000' }); testOrderSubmission(order, { price: '5000000000' });
@ -350,6 +380,8 @@ describe(
size: '100', size: '100',
price: '1.00', price: '1.00',
expiresAt: displayTomorrow(), expiresAt: displayTomorrow(),
postOnly: false,
reduceOnly: false,
}; };
createOrder(order); createOrder(order);
testOrderSubmission(order, { testOrderSubmission(order, {

View File

@ -474,15 +474,17 @@ const VegaTxCompleteToastsContent = ({ tx }: VegaTxToastContentProps) => {
} }
if (tx.order && tx.order.rejectionReason) { if (tx.order && tx.order.rejectionReason) {
const rejectionReason = getRejectionReason(tx.order) || ' ';
return ( return (
<> <>
<ToastHeading>{t('Order rejected')}</ToastHeading> <ToastHeading>{t('Order rejected')}</ToastHeading>
{rejectionReason ? (
<p> <p>
{t( {t('Your order has been rejected because: %s', [rejectionReason])}
'Your order has been rejected because: %s',
getRejectionReason(tx.order) || ''
)}
</p> </p>
) : (
<p>{t('Your order has been rejected.')}</p>
)}
{tx.txHash && ( {tx.txHash && (
<p className="break-all"> <p className="break-all">
<ExternalLink <ExternalLink
@ -576,10 +578,9 @@ const VegaTxErrorToastContent = ({ tx }: VegaTxToastContentProps) => {
walletNoConnectionCodes.includes(tx.error.code); walletNoConnectionCodes.includes(tx.error.code);
if (orderRejection) { if (orderRejection) {
label = t('Order rejected'); label = t('Order rejected');
errorMessage = t( errorMessage = t('Your order has been rejected because: %s', [
'Your order has been rejected because: %s', orderRejection,
orderRejection ]);
);
} }
if (walletError) { if (walletError) {
label = t('Wallet disconnected'); label = t('Wallet disconnected');

View File

@ -107,6 +107,50 @@ describe('DealTicket', () => {
); );
}); });
it('should use local storage state for initial values reduceOnly and postOnly', () => {
const expectedOrder = {
marketId: market.id,
type: Schema.OrderType.TYPE_LIMIT,
side: Schema.Side.SIDE_SELL,
size: '0.1',
price: '300.22',
timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_IOC,
persist: true,
reduceOnly: true,
postOnly: false,
};
useOrderStore.setState({
orders: {
[expectedOrder.marketId]: expectedOrder,
},
});
render(generateJsx());
// Assert correct defaults are used from store
expect(
screen
.getByTestId(`order-type-${Schema.OrderType.TYPE_LIMIT}`)
.querySelector('input')
).toBeChecked();
expect(
screen.queryByTestId('order-side-SIDE_SELL')?.querySelector('input')
).toBeChecked();
expect(
screen.queryByTestId('order-side-SIDE_BUY')?.querySelector('input')
).not.toBeChecked();
expect(screen.getByTestId('order-size')).toHaveDisplayValue(
expectedOrder.size
);
expect(screen.getByTestId('order-tif')).toHaveValue(
expectedOrder.timeInForce
);
expect(screen.getByTestId('order-price')).toHaveDisplayValue(
expectedOrder.price
);
});
it('handles TIF select box dependent on order type', async () => { it('handles TIF select box dependent on order type', async () => {
render(generateJsx()); render(generateJsx());

View File

@ -1,6 +1,6 @@
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import * as Schema from '@vegaprotocol/types'; import * as Schema from '@vegaprotocol/types';
import { memo, useCallback, useEffect, useState, useRef } from 'react'; import { memo, useCallback, useEffect, useState, useRef, useMemo } from 'react';
import { Controller } from 'react-hook-form'; import { Controller } from 'react-hook-form';
import { DealTicketAmount } from './deal-ticket-amount'; import { DealTicketAmount } from './deal-ticket-amount';
import { DealTicketButton } from './deal-ticket-button'; import { DealTicketButton } from './deal-ticket-button';
@ -16,10 +16,12 @@ import {
useVegaWalletDialogStore, useVegaWalletDialogStore,
} from '@vegaprotocol/wallet'; } from '@vegaprotocol/wallet';
import { import {
Checkbox,
ExternalLink, ExternalLink,
InputError, InputError,
Intent, Intent,
Notification, Notification,
Tooltip,
TinyScroll, TinyScroll,
} from '@vegaprotocol/ui-toolkit'; } from '@vegaprotocol/ui-toolkit';
@ -146,6 +148,16 @@ export const DealTicket = ({
clearErrors, clearErrors,
]); ]);
const disablePostOnlyCheckbox = useMemo(() => {
const disabled = order
? [
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC,
Schema.OrderTimeInForce.TIME_IN_FORCE_FOK,
].includes(order.timeInForce)
: true;
return disabled;
}, [order]);
const onSubmit = useCallback( const onSubmit = useCallback(
(order: OrderSubmission) => { (order: OrderSubmission) => {
const now = new Date().getTime(); const now = new Date().getTime();
@ -239,7 +251,7 @@ export const DealTicket = ({
value={order.timeInForce} value={order.timeInForce}
orderType={order.type} orderType={order.type}
onSelect={(timeInForce) => { onSelect={(timeInForce) => {
update({ timeInForce }); update({ timeInForce, postOnly: false, reduceOnly: false });
// Set tif value for the given order type, so that when switching // Set tif value for the given order type, so that when switching
// types we know the last used TIF for the given order type // types we know the last used TIF for the given order type
setLastTIF((curr) => ({ setLastTIF((curr) => ({
@ -276,6 +288,65 @@ export const DealTicket = ({
)} )}
/> />
)} )}
<div className="flex gap-2 pb-2 justify-between">
<Controller
name="postOnly"
control={control}
render={() => (
<Checkbox
name="post-only"
checked={order.postOnly}
disabled={disablePostOnlyCheckbox}
onCheckedChange={() => {
update({ postOnly: !order.postOnly, reduceOnly: false });
}}
label={
<Tooltip
description={
<span>
{disablePostOnlyCheckbox
? t(
'"Post only" can not be used on "Fill or Kill" or "Immediate or Cancel" orders.'
)
: t(
'"Post only" will ensure the order is not filled immediately but is placed on the order book as a passive order. When the order is processed it is either stopped (if it would not be filled immediately), or placed in the order book as a passive order until the price taker matches with it.'
)}
</span>
}
>
<span className="text-xs">{t('Post only')}</span>
</Tooltip>
}
/>
)}
/>
<Controller
name="reduceOnly"
control={control}
render={() => (
<Checkbox
name="reduce-only"
checked={order.reduceOnly}
onCheckedChange={() => {
update({ postOnly: false, reduceOnly: !order.reduceOnly });
}}
label={
<Tooltip
description={
<span>
{t(
'"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.'
)}
</span>
}
>
<span className="text-xs">{t('Reduce only')}</span>
</Tooltip>
}
/>
)}
/>
</div>
<SummaryMessage <SummaryMessage
errorMessage={errors.summary?.message} errorMessage={errors.summary?.message}
asset={asset} asset={asset}

View File

@ -96,16 +96,6 @@ export const TimeInForceSelector = ({
id="select-time-in-force" id="select-time-in-force"
value={value} value={value}
onChange={(e) => { onChange={(e) => {
// setPreviousTimeInForce({
// ...previousTimeInForce,
// [orderType]: e.target.value,
// });
// if (previousOrderType !== orderType) {
// setPreviousOrderType(orderType);
// const prev = previousTimeInForce[orderType as OrderType];
// onSelect(prev);
// }
onSelect(e.target.value as Schema.OrderTimeInForce); onSelect(e.target.value as Schema.OrderTimeInForce);
}} }}
className="w-full" className="w-full"

View File

@ -1,2 +1,7 @@
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import 'jest-canvas-mock'; import 'jest-canvas-mock';
import ResizeObserver from 'resize-observer-polyfill';
import { defaultFallbackInView } from 'react-intersection-observer';
defaultFallbackInView(true);
global.ResizeObserver = ResizeObserver;

View File

@ -13,6 +13,8 @@ export type OrderObj = {
price?: string; price?: string;
expiresAt?: string | undefined; expiresAt?: string | undefined;
persist: boolean; // key used to determine if order should be kept in localStorage persist: boolean; // key used to determine if order should be kept in localStorage
postOnly?: boolean;
reduceOnly?: boolean;
}; };
type OrderMap = { [marketId: string]: OrderObj | undefined }; type OrderMap = { [marketId: string]: OrderObj | undefined };
@ -114,4 +116,6 @@ export const getDefaultOrder = (marketId: string): OrderObj => ({
price: '0', price: '0',
expiresAt: undefined, expiresAt: undefined,
persist: false, persist: false,
postOnly: false,
reduceOnly: false,
}); });

View File

@ -54,7 +54,12 @@ export const Checkbox = ({
)} )}
</CheckboxPrimitive.CheckboxIndicator> </CheckboxPrimitive.CheckboxIndicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
<label htmlFor={name} className="text-sm"> <label
htmlFor={name}
className={classNames('text-sm', {
'dark:text-neutral-400 text-neutral-600': disabled,
})}
>
{label} {label}
</label> </label>
</div> </div>

View File

@ -45,6 +45,7 @@ export interface OrderSubmission {
size: string; size: string;
price?: string; price?: string;
expiresAt?: string; expiresAt?: string;
postOnly?: boolean;
reduceOnly?: boolean; reduceOnly?: boolean;
} }

View File

@ -48,6 +48,8 @@ export const normalizeOrderSubmission = (
order.expiresAt && order.timeInForce === OrderTimeInForce.TIME_IN_FORCE_GTT order.expiresAt && order.timeInForce === OrderTimeInForce.TIME_IN_FORCE_GTT
? toNanoSeconds(order.expiresAt) ? toNanoSeconds(order.expiresAt)
: undefined, : undefined,
postOnly: order.postOnly,
reduceOnly: order.reduceOnly,
}); });
export const normalizeOrderAmendment = <T extends Exact<OrderAmendment, T>>( export const normalizeOrderAmendment = <T extends Exact<OrderAmendment, T>>(