feat(deal-ticket): improve stop order rejection handling (#6084)

This commit is contained in:
Bartłomiej Głownia 2024-03-27 17:11:53 +01:00 committed by GitHub
parent 1e15032c06
commit a77bb06b2f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 368 additions and 31 deletions

View File

@ -1,15 +1,8 @@
import { useDataProvider } from '@vegaprotocol/data-provider';
import { activeOrdersProvider } from '@vegaprotocol/orders';
import { useActiveOrders } from '@vegaprotocol/orders';
import { useVegaWallet } from '@vegaprotocol/wallet-react';
export const PartyActiveOrdersHandler = () => {
const { pubKey } = useVegaWallet();
const variables = { partyId: pubKey || '' };
const skip = !pubKey;
useDataProvider({
dataProvider: activeOrdersProvider,
variables,
skip,
});
useActiveOrders(pubKey);
return null;
};

View File

@ -2,7 +2,7 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { generateMarket } from '../../test-helpers';
import { StopOrder } from './deal-ticket-stop-order';
import { NoOpenVolumeWarning, StopOrder } from './deal-ticket-stop-order';
import * as Schema from '@vegaprotocol/types';
import { MockedProvider } from '@apollo/client/testing';
import {
@ -84,6 +84,22 @@ jest.mock('@vegaprotocol/data-provider', () => ({
useDataProvider: jest.fn((...args) => mockDataProvider(...args)),
}));
const mockUseOpenVolume = jest.fn(() => ({
openVolume: '0',
}));
jest.mock('@vegaprotocol/positions', () => ({
...jest.requireActual('@vegaprotocol/positions'),
useOpenVolume: jest.fn(() => mockUseOpenVolume()),
}));
const mockActiveOrders = jest.fn(() => ({}));
jest.mock('@vegaprotocol/orders', () => ({
...jest.requireActual('@vegaprotocol/orders'),
useActiveOrders: jest.fn(() => mockActiveOrders()),
}));
describe('StopOrder', () => {
beforeEach(() => {
localStorage.clear();
@ -576,3 +592,57 @@ describe('StopOrder', () => {
expect(screen.getByTestId(numberOfActiveOrdersLimit)).toBeInTheDocument();
});
});
describe('NoOpenVolumeWarning', () => {
const testId = 'stop-order-warning-position';
it('shows warning if there is no possible position to reduce', () => {
const activeOrders = [
{
side: Schema.Side.SIDE_BUY,
remaining: '8',
},
{
side: Schema.Side.SIDE_BUY,
remaining: '2',
},
{
side: Schema.Side.SIDE_SELL,
remaining: '7',
},
{
side: Schema.Side.SIDE_SELL,
remaining: '3',
},
];
mockActiveOrders.mockReturnValue({ data: activeOrders });
mockUseOpenVolume.mockReturnValue({ openVolume: '10' });
const result = render(
<NoOpenVolumeWarning side={Schema.Side.SIDE_BUY} marketId="" />
);
// side buy, volume + remaining 20
expect(screen.getByTestId(testId)).toBeInTheDocument();
result.rerender(
<NoOpenVolumeWarning side={Schema.Side.SIDE_SELL} marketId="" />
);
// side sell, volume - remaining = 0
expect(screen.queryByTestId(testId)).not.toBeInTheDocument();
result.rerender(
<NoOpenVolumeWarning side={Schema.Side.SIDE_BUY} marketId="" />
);
// side sell, volume - remaining = 0
expect(screen.queryByTestId(testId)).toBeInTheDocument();
mockUseOpenVolume.mockReturnValue({ openVolume: '2' });
render(<NoOpenVolumeWarning side={Schema.Side.SIDE_SELL} marketId="" />);
// side buy, volume + remaining = 12
expect(screen.queryByTestId(testId)).toBeInTheDocument();
render(<NoOpenVolumeWarning side={Schema.Side.SIDE_SELL} marketId="" />);
// side sell, volume - remaining = -8
expect(screen.getByTestId(testId)).toBeInTheDocument();
});
});

View File

@ -56,9 +56,10 @@ import { validateExpiration } from '../../utils';
import { NOTIONAL_SIZE_TOOLTIP_TEXT } from '../../constants';
import { KeyValue } from './key-value';
import { useDataProvider } from '@vegaprotocol/data-provider';
import { stopOrdersProvider } from '@vegaprotocol/orders';
import { useActiveOrders, stopOrdersProvider } from '@vegaprotocol/orders';
import { useT } from '../../use-t';
import { determinePriceStep, determineSizeStep } from '@vegaprotocol/utils';
import { useOpenVolume } from '@vegaprotocol/positions';
export interface StopOrderProps {
market: Market;
@ -453,6 +454,47 @@ const Price = ({
);
};
export const NoOpenVolumeWarning = ({
side,
partyId,
marketId,
}: {
side: Schema.Side;
partyId?: string;
marketId: string;
}) => {
const { data: activeOrders } = useActiveOrders(partyId, marketId);
const t = useT();
const { openVolume } = useOpenVolume(partyId, marketId) || {};
const volume = BigInt(openVolume || 0);
const remaining = activeOrders
? activeOrders.reduce((size, order) => {
if (side !== order.side) {
size += BigInt(order.remaining);
}
return size;
}, BigInt(0))
: BigInt(0);
if (
(side === Schema.Side.SIDE_BUY && volume - remaining < BigInt(0)) ||
(side === Schema.Side.SIDE_SELL && volume + remaining > BigInt(0))
) {
return null;
}
return (
<div className="mb-2">
<Notification
intent={Intent.Warning}
testId={'stop-order-warning-position'}
message={t(
'Stop orders are reduce only and this order would increase your position.'
)}
/>
</div>
);
};
const TimeInForce = ({
control,
oco,
@ -1188,8 +1230,13 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
)}
/>
</div>
) : null}
) : (
<NoOpenVolumeWarning
side={side}
partyId={pubKey}
marketId={market.id}
/>
)}
<SubmitButton
assetUnit={assetUnit}
market={market}

View File

@ -31,6 +31,8 @@ import {
jest.mock('zustand');
jest.mock('./deal-ticket-fee-details', () => ({
DealTicketFeeDetails: () => <div data-testid="deal-ticket-fee-details" />,
}));
jest.mock('./deal-ticket-margin-details', () => ({
DealTicketMarginDetails: () => (
<div data-testid="deal-ticket-margin-details" />
),

View File

@ -36,7 +36,7 @@ import {
formatForInput,
formatValue,
} from '@vegaprotocol/utils';
import { activeOrdersProvider } from '@vegaprotocol/orders';
import { useActiveOrders } from '@vegaprotocol/orders';
import {
getAsset,
getDerivedPrice,
@ -252,11 +252,7 @@ export const DealTicket = ({
market.positionDecimalPlaces
);
const { data: activeOrders } = useDataProvider({
dataProvider: activeOrdersProvider,
variables: { partyId: pubKey || '', marketId: market.id },
skip: !pubKey,
});
const { data: activeOrders } = useActiveOrders(pubKey, market.id);
const { data: margin } = useDataProvider({
dataProvider: marginModeDataProvider,
variables: { partyId: pubKey || '', marketId: market.id },

View File

@ -25,7 +25,7 @@ import {
useMarginAccountBalance,
} from '@vegaprotocol/accounts';
import { useMaxLeverage, useOpenVolume } from '@vegaprotocol/positions';
import { activeOrdersProvider } from '@vegaprotocol/orders';
import { useActiveOrders } from '@vegaprotocol/orders';
import { usePositionEstimate } from '../../hooks/use-position-estimate';
import { addDecimalsFormatNumber } from '@vegaprotocol/utils';
import { getAsset, useMarket } from '@vegaprotocol/markets';
@ -64,10 +64,7 @@ export const MarginChange = ({
openVolume: '0',
averageEntryPrice: '0',
};
const { data: activeOrders } = useDataProvider({
dataProvider: activeOrdersProvider,
variables: { partyId: partyId || '', marketId },
});
const { data: activeOrders } = useActiveOrders(partyId, marketId);
const orders = activeOrders
? activeOrders.map<Schema.OrderInfo>((order) => ({
isMarketOrder: order.type === Schema.OrderType.TYPE_MARKET,

View File

@ -106,6 +106,7 @@
"Stop Limit": "Stop Limit",
"Stop Market": "Stop Market",
"Stop order will be triggered immediately": "Stop order will be triggered immediately",
"Stop orders are reduce only and this order would increase your position.": "Stop orders are reduce only and this order would increase your position.",
"Strategy": "Strategy",
"Submit": "Submit",
"Subtotal": "Subtotal",

View File

@ -4,6 +4,7 @@ import {
makeDataProvider,
makeDerivedDataProvider,
defaultAppend as append,
useDataProvider,
} from '@vegaprotocol/data-provider';
import { type Market } from '@vegaprotocol/markets';
import { marketsMapProvider } from '@vegaprotocol/markets';
@ -208,6 +209,18 @@ export const activeOrdersProvider = makeDerivedDataProvider<
}
);
export const useActiveOrders = (
partyId: string | undefined,
marketId?: string
) =>
useDataProvider({
dataProvider: activeOrdersProvider,
variables: marketId
? { partyId: partyId || '', marketId }
: { partyId: partyId || '' },
skip: !partyId,
});
export const ordersWithMarketProvider = makeDerivedDataProvider<
(Order & Cursor)[],
never,

View File

@ -10,7 +10,10 @@ import {
type TransferStatus,
MarketUpdateType,
} from './__generated__/types';
import type { AccountType } from './__generated__/types';
import type {
AccountType,
StopOrderRejectionReason,
} from './__generated__/types';
import type {
AuctionTrigger,
DataSourceSpecStatus,
@ -285,6 +288,29 @@ export const StopOrderStatusMapping: {
STATUS_UNSPECIFIED: 'Unspecified',
};
/**
* Stop order rejection reason mappings.
*/
export const StopOrderRejectionReasonMapping: {
[T in StopOrderRejectionReason]: string;
} = {
REJECTION_REASON_TRADING_NOT_ALLOWED: 'Trading is not allowed yet',
REJECTION_REASON_EXPIRY_IN_THE_PAST:
'Expiry of the stop order is in the past',
REJECTION_REASON_MUST_BE_REDUCE_ONLY:
'Stop orders submission must be reduce only',
REJECTION_REASON_MAX_STOP_ORDERS_PER_PARTY_REACHED:
'Party has reached the maximum stop orders allowed for this market',
REJECTION_REASON_STOP_ORDER_NOT_ALLOWED_WITHOUT_A_POSITION:
'Stop orders are not allowed without a position',
REJECTION_REASON_STOP_ORDER_NOT_CLOSING_THE_POSITION:
'This stop order does not close the position',
REJECTION_REASON_STOP_ORDER_NOT_ALLOWED_DURING_OPENING_AUCTION:
'Stop orders are not allowed during the opening auction',
REJECTION_REASON_STOP_ORDER_CANNOT_MATCH_OCO_EXPIRY_TIMES:
'Stop order cannot have matching OCO expiry times',
};
/**
* Valid order types, these determine what happens when an order is added to the book
*/

View File

@ -42,6 +42,7 @@ query StopOrderById($stopOrderId: ID!) {
expiryStrategy
triggerDirection
status
rejectionReason
createdAt
updatedAt
partyId

View File

@ -15,7 +15,7 @@ export type StopOrderByIdQueryVariables = Types.Exact<{
}>;
export type StopOrderByIdQuery = { __typename?: 'Query', stopOrder?: { __typename?: 'StopOrder', id: string, ocoLinkId?: string | null, expiresAt?: any | null, expiryStrategy?: Types.StopOrderExpiryStrategy | null, triggerDirection: Types.StopOrderTriggerDirection, status: Types.StopOrderStatus, createdAt: any, updatedAt?: any | null, partyId: string, marketId: string, trigger: { __typename?: 'StopOrderPrice', price: string } | { __typename?: 'StopOrderTrailingPercentOffset', trailingPercentOffset: string }, submission: { __typename?: 'OrderSubmission', marketId: string, price: string, size: string, side: Types.Side, timeInForce: Types.OrderTimeInForce, expiresAt: any, type: Types.OrderType, reference?: string | null, postOnly?: boolean | null, reduceOnly?: boolean | null, peggedOrder?: { __typename?: 'PeggedOrder', reference: Types.PeggedReference, offset: string } | null } } | null };
export type StopOrderByIdQuery = { __typename?: 'Query', stopOrder?: { __typename?: 'StopOrder', id: string, ocoLinkId?: string | null, expiresAt?: any | null, expiryStrategy?: Types.StopOrderExpiryStrategy | null, triggerDirection: Types.StopOrderTriggerDirection, status: Types.StopOrderStatus, rejectionReason?: Types.StopOrderRejectionReason | null, createdAt: any, updatedAt?: any | null, partyId: string, marketId: string, trigger: { __typename?: 'StopOrderPrice', price: string } | { __typename?: 'StopOrderTrailingPercentOffset', trailingPercentOffset: string }, submission: { __typename?: 'OrderSubmission', marketId: string, price: string, size: string, side: Types.Side, timeInForce: Types.OrderTimeInForce, expiresAt: any, type: Types.OrderType, reference?: string | null, postOnly?: boolean | null, reduceOnly?: boolean | null, peggedOrder?: { __typename?: 'PeggedOrder', reference: Types.PeggedReference, offset: string } | null } } | null };
export const OrderByIdDocument = gql`
@ -92,6 +92,7 @@ export const StopOrderByIdDocument = gql`
expiryStrategy
triggerDirection
status
rejectionReason
createdAt
updatedAt
partyId

View File

@ -47,6 +47,7 @@ export interface VegaTransactionStore {
) => void;
dismiss: (index: number) => void;
delete: (index: number) => void;
getTransaction: (txHash: string) => VegaStoredTxState | undefined;
updateWithdrawal: (
withdrawal: NonNullable<VegaStoredTxState['withdrawal']>,
withdrawalApproval: NonNullable<VegaStoredTxState['withdrawalApproval']>
@ -60,6 +61,12 @@ export interface VegaTransactionStore {
export const useVegaTransactionStore = create<VegaTransactionStore>()(
subscribeWithSelector((set, get) => ({
transactions: [] as (VegaStoredTxState | undefined)[],
getTransaction: (txHash: string) => {
return get().transactions.find(
(transaction) =>
transaction?.txHash && transaction.txHash.toLowerCase() === txHash
);
},
create: (body: Transaction, order?: OrderTxUpdateFieldsFragment) => {
const transactions = get().transactions;
const now = new Date();

View File

@ -23,6 +23,9 @@ import {
OrderTimeInForce,
OrderType,
Side,
StopOrderRejectionReason,
StopOrderRejectionReasonMapping,
StopOrderStatus,
WithdrawalStatus,
} from '@vegaprotocol/types';
@ -48,14 +51,22 @@ jest.mock('./wait-for-withdrawal-approval', () => ({
waitForWithdrawalApproval: () => mockWaitForWithdrawalApproval(),
}));
const mockWaitForStopOrder = jest.fn();
jest.mock('./wait-for-stop-order', () => ({
waitForStopOrder: () => mockWaitForStopOrder(),
}));
const updateWithdrawal = jest.fn();
const updateOrder = jest.fn();
const updateTransactionResult = jest.fn();
const getTransaction = jest.fn();
const defaultState: Partial<VegaTransactionStore> = {
updateWithdrawal,
updateOrder,
updateTransactionResult,
getTransaction,
};
const mockTransactionStoreState = jest.fn<Partial<VegaTransactionStore>, []>();
@ -180,6 +191,29 @@ describe('useVegaTransactionManager', () => {
});
});
it('waits for stop order and sets error if rejected', async () => {
getTransaction.mockReturnValueOnce({
signature: 'signature',
body: {
stopOrdersSubmission: {},
},
});
const rejectionReason =
StopOrderRejectionReason.REJECTION_REASON_STOP_ORDER_NOT_CLOSING_THE_POSITION;
mockTransactionStoreState.mockReturnValue(defaultState);
mockWaitForStopOrder.mockResolvedValueOnce({
status: StopOrderStatus.STATUS_REJECTED,
rejectionReason,
});
render([mockedTransactionResultBusEvent]);
await waitFor(() => {
expect(updateTransactionResult).toHaveBeenCalledWith({
...transactionResultBusEvent,
error: StopOrderRejectionReasonMapping[rejectionReason],
});
});
});
it('updates withdrawal on WithdrawalBusEvents', async () => {
mockTransactionStoreState.mockReturnValue(defaultState);
const erc20WithdrawalApproval = {};

View File

@ -7,6 +7,16 @@ import {
} from './__generated__/TransactionResult';
import { useVegaTransactionStore } from './use-vega-transaction-store';
import { waitForWithdrawalApproval } from './wait-for-withdrawal-approval';
import {
determineId,
isStopOrdersSubmissionTransaction,
} from '@vegaprotocol/wallet';
import { waitForStopOrder } from './wait-for-stop-order';
import {
StopOrderRejectionReasonMapping,
StopOrderStatus,
StopOrderStatusMapping,
} from '@vegaprotocol/types';
export const useVegaTransactionUpdater = () => {
const client = useApolloClient();
@ -17,6 +27,9 @@ export const useVegaTransactionUpdater = () => {
const updateTransaction = useVegaTransactionStore(
(state) => state.updateTransactionResult
);
const getTransaction = useVegaTransactionStore(
(state) => state.getTransaction
);
const { pubKey } = useVegaWallet();
const variables = { partyId: pubKey || '' };
@ -51,11 +64,43 @@ export const useVegaTransactionUpdater = () => {
variables,
skip,
fetchPolicy: 'no-cache',
onData: ({ data: result }) =>
result.data?.busEvents?.forEach((event) => {
if (event.event.__typename === 'TransactionResult') {
updateTransaction(event.event);
onData: ({ data: result }) => {
result.data?.busEvents?.forEach(({ event }) => {
if (event.__typename === 'TransactionResult') {
let updateImmediately = true;
if (event.status && !event.error) {
const transaction = getTransaction(event.hash.toLocaleLowerCase());
if (
transaction &&
transaction.signature &&
isStopOrdersSubmissionTransaction(transaction.body)
) {
waitForStopOrder(determineId(transaction.signature), client).then(
(stopOrder) => {
updateTransaction(
stopOrder &&
stopOrder.status === StopOrderStatus.STATUS_REJECTED
? {
...event,
error:
(stopOrder.rejectionReason &&
StopOrderRejectionReasonMapping[
stopOrder.rejectionReason
]) ||
StopOrderStatusMapping[stopOrder.status],
}
}),
: event
);
}
);
updateImmediately = false;
}
}
if (updateImmediately) {
updateTransaction(event);
}
}
});
},
});
};

View File

@ -0,0 +1,72 @@
import { ApolloClient, InMemoryCache } from '@apollo/client';
import { MockLink } from '@apollo/client/testing';
import type { StopOrderByIdQuery } from './__generated__/Orders';
import { StopOrderByIdDocument } from './__generated__/Orders';
import type { MockedResponse } from '@apollo/client/testing';
import { waitForStopOrder } from './wait-for-stop-order';
import {
OrderTimeInForce,
OrderType,
Side,
StopOrderStatus,
StopOrderTriggerDirection,
} from '@vegaprotocol/types';
const stopOrderId =
'ad427c4f5cb599e73ffb6f0ae371d1e0fcba89b6be2401a06e61cab982668d63';
const stopOrder: StopOrderByIdQuery['stopOrder'] = {
__typename: 'StopOrder',
id: stopOrderId,
ocoLinkId: null,
expiresAt: null,
expiryStrategy: null,
triggerDirection: StopOrderTriggerDirection.TRIGGER_DIRECTION_RISES_ABOVE,
status: StopOrderStatus.STATUS_PENDING,
rejectionReason: null,
createdAt: '2024-03-25T10:18:48.946943Z',
updatedAt: null,
partyId: '02eceaba4df2bef76ea10caf728d8a099a2aa846cced25737cccaa9812342f65',
marketId: '00788b763b999ef555ac5da17de155ff4237dd14aa6671a303d1285f27f094f0',
trigger: {
__typename: 'StopOrderPrice',
price: '700000',
},
submission: {
__typename: 'OrderSubmission',
marketId:
'00788b763b999ef555ac5da17de155ff4237dd14aa6671a303d1285f27f094f0',
price: '0',
size: '1',
side: Side.SIDE_BUY,
timeInForce: OrderTimeInForce.TIME_IN_FORCE_FOK,
expiresAt: null,
type: OrderType.TYPE_MARKET,
reference: '',
peggedOrder: null,
postOnly: false,
reduceOnly: true,
},
};
const mockedStopOrderById: MockedResponse<StopOrderByIdQuery> = {
request: {
query: StopOrderByIdDocument,
variables: { stopOrderId },
},
result: {
data: {
stopOrder,
},
},
};
describe('waitForStopOrder', () => {
it('resolves with matching stopOrder', async () => {
const client = new ApolloClient({
cache: new InMemoryCache(),
link: new MockLink([mockedStopOrderById]),
});
expect(await waitForStopOrder(stopOrderId, client)).toEqual(stopOrder);
});
});

View File

@ -0,0 +1,32 @@
import { type ApolloClient } from '@apollo/client';
import {
StopOrderByIdDocument,
type StopOrderByIdQuery,
type StopOrderByIdQueryVariables,
} from './__generated__/Orders';
export const waitForStopOrder = (
stopOrderId: string,
client: ApolloClient<object>
) =>
new Promise<StopOrderByIdQuery['stopOrder']>((resolve) => {
const interval = setInterval(async () => {
try {
const res = await client.query<
StopOrderByIdQuery,
StopOrderByIdQueryVariables
>({
query: StopOrderByIdDocument,
variables: { stopOrderId },
fetchPolicy: 'network-only',
});
if (res.data) {
clearInterval(interval);
resolve(res.data.stopOrder);
}
} catch (err) {
// no op as the query will error until the approval is created
}
}, 1000);
});