feat(#840): update positions tab (#1101)

* feat(#473): add positions metrics data provider

* feat(#473) add positions stats

* feat(#473) add positions stats

* feat(#473): add positions stats

* feat(#473): add positions stats

* feat(#473): position metrics, test and refactoring

* feat(#473): add unit tests to positions table

* feat(#473): fix spelling, order positions by updated at desc

* feat(#473): protect from division by 0

* feat(#473): fix trading positions e2e tests

* feat(#473): fix e2e data mocks

* feat(#473): post code review clean up

* feat(#993): dependencies handling in data provider

* feat(#993): fix e2e tests data mocks

* feat(#993): remove position metrics mocks, add market data market id

* feat(#993): add missing mocks, fix combine function

* feat(#993): set loading initially to true, add unit tests

* feat(#993): cleanup, add comments

* feat(#993): remove undefined from client type

* feat(#993): cosmetic changes

* feat(#840): update positions tab

* feat:(#993): pass informaton about update callback cause

* feat(#840): update positions tab

* feat(#840): update positions tab

* feat(#840): update positions tab

* chore: skip handles 5000 markets e2e test

* feat(#840): update positions tab

* feat(#840): rename assetDecimals to decimals

* feat(#840): close position

* feat(#993): notify about update

* feat(#840): add use close position hook

* feat(#840): do not show 0 volume positions, make liquidation price minimum 0

* feat(#840): post code review fixes and improvments

* feat: fix fill-table spec
This commit is contained in:
Bartłomiej Głownia 2022-09-02 22:53:00 +02:00 committed by GitHub
parent fe716f74a1
commit 6de90c6b1f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 946 additions and 535 deletions

View File

@ -5,19 +5,22 @@ import { Stepper } from '../stepper';
import type { DealTicketQuery_market } from '@vegaprotocol/deal-ticket';
import { InputError } from '@vegaprotocol/ui-toolkit';
import { BigNumber } from 'bignumber.js';
import {
getOrderDialogTitle,
getOrderDialogIntent,
getOrderDialogIcon,
MarketSelector,
} from '@vegaprotocol/deal-ticket';
import { MarketSelector } from '@vegaprotocol/deal-ticket';
import type { Order } from '@vegaprotocol/orders';
import { useVegaWallet, VegaTxStatus } from '@vegaprotocol/wallet';
import { t, addDecimal, toDecimal } from '@vegaprotocol/react-helpers';
import {
t,
addDecimal,
toDecimal,
removeDecimal,
} from '@vegaprotocol/react-helpers';
import {
getDefaultOrder,
useOrderValidation,
useOrderSubmit,
getOrderDialogTitle,
getOrderDialogIntent,
getOrderDialogIcon,
OrderFeedback,
validateSize,
} from '@vegaprotocol/orders';
@ -108,7 +111,7 @@ export const DealTicketSteps = ({
});
const { submit, transaction, finalizedOrder, TransactionDialog } =
useOrderSubmit(market);
useOrderSubmit();
const onSizeChange = (value: number[]) => {
const newVal = new BigNumber(value[0])
@ -151,10 +154,20 @@ export const DealTicketSteps = ({
const onSubmit = React.useCallback(
(order: Order) => {
if (transactionStatus !== 'pending') {
submit(order);
submit({
...order,
price:
order.price && removeDecimal(order.price, market.decimalPlaces),
size: removeDecimal(order.size, market.positionDecimalPlaces),
});
}
},
[transactionStatus, submit]
[
transactionStatus,
submit,
market.decimalPlaces,
market.positionDecimalPlaces,
]
);
const steps = [

View File

@ -25,7 +25,7 @@ export const AccountsManager = ({ partyId }: AccountsManagerProps) => {
({ delta }: { delta: AccountSubscribe_accounts }) => {
const update: Accounts_party_accounts[] = [];
const add: Accounts_party_accounts[] = [];
if (!gridRef.current) {
if (!gridRef.current?.api) {
return false;
}
const rowNode = gridRef.current.api.getRowNode(getId(delta));

View File

@ -0,0 +1,42 @@
import { useMemo } from 'react';
import {
addDecimalsFormatNumber,
useDataProvider,
} from '@vegaprotocol/react-helpers';
import type { AccountSubscribe_accounts } from './__generated__/AccountSubscribe';
import type { Accounts_party_accounts } from './__generated__/Accounts';
import { accountsDataProvider } from './accounts-data-provider';
interface AssetBalanceProps {
partyId: string;
assetSymbol: string;
}
export const AssetBalance = ({ partyId, assetSymbol }: AssetBalanceProps) => {
const variables = useMemo(() => ({ partyId }), [partyId]);
const { data } = useDataProvider<
Accounts_party_accounts[],
AccountSubscribe_accounts
>({
dataProvider: accountsDataProvider,
variables,
});
if (data && data.length) {
const totalBalance = data.reduce((a, c) => {
if (c.asset.symbol === assetSymbol) {
return a + BigInt(c.balance);
}
return a;
}, BigInt(0));
return (
<span>
{addDecimalsFormatNumber(
totalBalance.toString(),
data[0].asset.decimals
)}
</span>
);
}
return null;
};

View File

@ -3,3 +3,4 @@ export * from './accounts-container';
export * from './accounts-data-provider';
export * from './accounts-manager';
export * from './accounts-table';
export * from './asset-balance';

View File

@ -17,7 +17,7 @@ export const DealTicketManager = ({
children,
}: DealTicketManagerProps) => {
const { submit, transaction, finalizedOrder, TransactionDialog } =
useOrderSubmit(market);
useOrderSubmit();
return (
<>

View File

@ -1,6 +1,10 @@
import { useCallback } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { t, addDecimalsFormatNumber } from '@vegaprotocol/react-helpers';
import {
t,
addDecimalsFormatNumber,
removeDecimal,
} from '@vegaprotocol/react-helpers';
import { Button, InputError } from '@vegaprotocol/ui-toolkit';
import { TypeSelector } from './type-selector';
import { SideSelector } from './side-selector';
@ -51,10 +55,15 @@ export const DealTicket = ({
const onSubmit = useCallback(
(order: Order) => {
if (!isDisabled) {
submit(order);
submit({
...order,
price:
order.price && removeDecimal(order.price, market.decimalPlaces),
size: removeDecimal(order.size, market.positionDecimalPlaces),
});
}
},
[isDisabled, submit]
[isDisabled, submit, market.decimalPlaces, market.positionDecimalPlaces]
);
return (
@ -111,7 +120,7 @@ export const DealTicket = ({
{orderType === OrderType.TYPE_LIMIT &&
orderTimeInForce === OrderTimeInForce.TIME_IN_FORCE_GTT && (
<Controller
name="expiration"
name="expiresAt"
control={control}
render={({ field }) => (
<ExpirySelector value={field.value} onSelect={field.onChange} />

View File

@ -1,4 +1,4 @@
import { render, screen, waitFor, act } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import { getDateTimeFormat } from '@vegaprotocol/react-helpers';
import { Side } from '@vegaprotocol/types';
import type { PartialDeep } from 'type-fest';
@ -141,7 +141,6 @@ describe('FillsTable', () => {
});
it('should format cells correctly for seller fill', async () => {
act(async () => {
const partyId = 'party-id';
const buyerFill = generateFill({
...defaultFill,
@ -175,12 +174,10 @@ describe('FillsTable', () => {
});
const amountCell = cells.find((c) => c.getAttribute('col-id') === 'size');
expect(amountCell).toHaveClass('text-vega-red');
});
expect(amountCell).toHaveClass('text-vega-red-dark');
});
it('should render correct maker or taker role', async () => {
act(async () => {
const partyId = 'party-id';
const takerFill = generateFill({
seller: {
@ -219,4 +216,3 @@ describe('FillsTable', () => {
).toHaveTextContent('Maker');
});
});
});

View File

@ -4,6 +4,8 @@ import {
addDecimalsFormatNumber,
formatNumber,
getDateTimeFormat,
positiveClassNames,
negativeClassNames,
t,
} from '@vegaprotocol/react-helpers';
import { Side } from '@vegaprotocol/types';
@ -49,9 +51,8 @@ export const FillsTable = forwardRef<AgGridReact, Props>(
field="size"
cellClass={({ data }: { data: FillFields }) => {
return classNames('text-right', {
'text-vega-green-dark dark:text-vega-green':
data?.buyer.id === partyId,
'text-vega-red-dark dark:text-vega-red': data?.seller.id,
[positiveClassNames]: data?.buyer.id === partyId,
[negativeClassNames]: data?.seller.id,
});
}}
valueFormatter={formatSize(partyId)}

View File

@ -1,6 +1,11 @@
import { useEnvironment } from '@vegaprotocol/environment';
import type { OrderEvent_busEvents_event_Order } from '../../order-hooks/__generated__';
import { addDecimalsFormatNumber, t } from '@vegaprotocol/react-helpers';
import {
addDecimalsFormatNumber,
t,
positiveClassNames,
negativeClassNames,
} from '@vegaprotocol/react-helpers';
import {
OrderRejectionReasonMapping,
OrderStatus,
@ -49,8 +54,8 @@ export const OrderFeedback = ({ transaction, order }: OrderFeedbackProps) => {
<p
className={
order.side === Side.SIDE_BUY
? 'text-vega-green-dark dark:text-vega-green'
: 'text-vega-red-dark dark:text-vega-red'
? positiveClassNames
: negativeClassNames
}
>
{`${

View File

@ -8,7 +8,13 @@ import {
OrderTimeInForceMapping,
OrderRejectionReasonMapping,
} from '@vegaprotocol/types';
import { addDecimal, getDateTimeFormat, t } from '@vegaprotocol/react-helpers';
import {
addDecimal,
getDateTimeFormat,
t,
positiveClassNames,
negativeClassNames,
} from '@vegaprotocol/react-helpers';
import {
AgGridDynamic as AgGrid,
Button,
@ -125,12 +131,12 @@ export const OrderListTable = forwardRef<AgGridReact, OrderListTableProps>(
cellClass="font-mono text-right"
type="rightAligned"
cellClassRules={{
'text-vega-green-dark dark:text-vega-green': ({
[positiveClassNames]: ({
data,
}: {
data: Orders_party_ordersConnection_edges_node;
}) => data?.side === Side.SIDE_BUY,
'text-vega-red-dark dark:text-vega-red': ({
[negativeClassNames]: ({
data,
}: {
data: Orders_party_ordersConnection_edges_node;

View File

@ -60,10 +60,7 @@ const defaultWalletContext = {
connector: null,
};
function setup(
context?: Partial<VegaWalletContextShape>,
market = defaultMarket
) {
function setup(context?: Partial<VegaWalletContextShape>) {
const mocks: MockedResponse<OrderEvent> = {
request: {
query: ORDER_EVENT_SUB,
@ -144,7 +141,7 @@ function setup(
</VegaWalletContext.Provider>
</MockedProvider>
);
return renderHook(() => useOrderSubmit(market), { wrapper });
return renderHook(() => useOrderSubmit(), { wrapper });
}
describe('useOrderSubmit', () => {
@ -164,11 +161,11 @@ describe('useOrderSubmit', () => {
size: '10',
timeInForce: OrderTimeInForce.TIME_IN_FORCE_GTT,
side: Side.SIDE_BUY,
price: '1234567.89',
expiration: new Date('2022-01-01'),
price: '123456789',
expiresAt: new Date('2022-01-01'),
};
await act(async () => {
result.current.submit(order);
result.current.submit({ ...order, marketId: defaultMarket.id });
});
expect(mockSendTx).toHaveBeenCalledWith({
@ -176,14 +173,12 @@ describe('useOrderSubmit', () => {
propagate: true,
orderSubmission: {
type: OrderType.TYPE_LIMIT,
marketId: defaultMarket.id, // Market provided from hook argument
size: '100', // size adjusted based on positionDecimalPlaces
marketId: defaultMarket.id,
size: '10',
side: Side.SIDE_BUY,
timeInForce: OrderTimeInForce.TIME_IN_FORCE_GTT,
price: '123456789', // Decimal removed
expiresAt: order.expiration
? toNanoSeconds(order.expiration)
: undefined,
price: '123456789',
expiresAt: order.expiresAt ? toNanoSeconds(order.expiresAt) : undefined,
},
});
});

View File

@ -1,103 +0,0 @@
import { useCallback, useState } from 'react';
import type { OrderEvent_busEvents_event_Order } from './__generated__';
import { useVegaWallet } from '@vegaprotocol/wallet';
import {
determineId,
removeDecimal,
toNanoSeconds,
} from '@vegaprotocol/react-helpers';
import { useVegaTransaction } from '@vegaprotocol/wallet';
import * as Sentry from '@sentry/react';
import { useOrderEvent } from './use-order-event';
import type { OrderTimeInForce, Side } from '@vegaprotocol/types';
import { OrderType } from '@vegaprotocol/types';
export interface Order {
type: OrderType;
size: string;
side: Side;
timeInForce: OrderTimeInForce;
price?: string;
expiration?: Date;
}
export interface Market {
id: string;
decimalPlaces: number;
positionDecimalPlaces: number;
}
export const useOrderSubmit = (market: Market) => {
const { keypair } = useVegaWallet();
const waitForOrderEvent = useOrderEvent();
const {
send,
transaction,
reset: resetTransaction,
setComplete,
TransactionDialog,
} = useVegaTransaction();
const [finalizedOrder, setFinalizedOrder] =
useState<OrderEvent_busEvents_event_Order | null>(null);
const reset = useCallback(() => {
resetTransaction();
setFinalizedOrder(null);
}, [resetTransaction]);
const submit = useCallback(
async (order: Order) => {
if (!keypair || !order.side) {
return;
}
setFinalizedOrder(null);
try {
const res = await send({
pubKey: keypair.pub,
propagate: true,
orderSubmission: {
marketId: market.id,
price:
order.type === OrderType.TYPE_LIMIT && order.price
? removeDecimal(order.price, market.decimalPlaces)
: undefined,
size: removeDecimal(order.size, market.positionDecimalPlaces),
type: order.type,
side: order.side,
timeInForce: order.timeInForce,
expiresAt: order.expiration
? toNanoSeconds(order.expiration) // Wallet expects timestamp in nanoseconds
: undefined,
},
});
if (res?.signature) {
const resId = determineId(res.signature);
if (resId) {
waitForOrderEvent(resId, keypair.pub, (order) => {
setFinalizedOrder(order);
setComplete();
});
}
}
return res;
} catch (e) {
Sentry.captureException(e);
return;
}
},
[keypair, send, market, setComplete, waitForOrderEvent]
);
return {
transaction,
finalizedOrder,
TransactionDialog,
submit,
reset,
};
};

View File

@ -0,0 +1,165 @@
import { useCallback, useState } from 'react';
import type { ReactNode } from 'react';
import type { OrderEvent_busEvents_event_Order } from './__generated__';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { determineId, toNanoSeconds } from '@vegaprotocol/react-helpers';
import { useVegaTransaction } from '@vegaprotocol/wallet';
import * as Sentry from '@sentry/react';
import { useOrderEvent } from './use-order-event';
import type { OrderTimeInForce, Side } from '@vegaprotocol/types';
import { OrderType, OrderStatus } from '@vegaprotocol/types';
import { Icon, Intent } from '@vegaprotocol/ui-toolkit';
import { t } from '@vegaprotocol/react-helpers';
export interface Order {
marketId: string;
type: OrderType;
size: string;
side: Side;
timeInForce: OrderTimeInForce;
price?: string;
expiresAt?: Date;
}
export const getOrderDialogTitle = (
status?: OrderStatus
): string | undefined => {
if (!status) {
return;
}
switch (status) {
case OrderStatus.STATUS_ACTIVE:
return t('Order submitted');
case OrderStatus.STATUS_FILLED:
return t('Order filled');
case OrderStatus.STATUS_PARTIALLY_FILLED:
return t('Order partially filled');
case OrderStatus.STATUS_PARKED:
return t('Order parked');
case OrderStatus.STATUS_STOPPED:
return t('Order stopped');
case OrderStatus.STATUS_CANCELLED:
return t('Order cancelled');
case OrderStatus.STATUS_EXPIRED:
return t('Order expired');
case OrderStatus.STATUS_REJECTED:
return t('Order rejected');
default:
return t('Submission failed');
}
};
export const getOrderDialogIntent = (
status?: OrderStatus
): Intent | undefined => {
if (!status) {
return;
}
switch (status) {
case OrderStatus.STATUS_PARKED:
case OrderStatus.STATUS_EXPIRED:
case OrderStatus.STATUS_PARTIALLY_FILLED:
return Intent.Warning;
case OrderStatus.STATUS_REJECTED:
case OrderStatus.STATUS_STOPPED:
case OrderStatus.STATUS_CANCELLED:
return Intent.Danger;
case OrderStatus.STATUS_FILLED:
case OrderStatus.STATUS_ACTIVE:
return Intent.Success;
default:
return;
}
};
export const getOrderDialogIcon = (
status?: OrderStatus
): ReactNode | undefined => {
if (!status) {
return;
}
switch (status) {
case OrderStatus.STATUS_PARKED:
case OrderStatus.STATUS_EXPIRED:
return <Icon name="warning-sign" size={16} />;
case OrderStatus.STATUS_REJECTED:
case OrderStatus.STATUS_STOPPED:
case OrderStatus.STATUS_CANCELLED:
return <Icon name="error" size={16} />;
default:
return;
}
};
export const useOrderSubmit = () => {
const { keypair } = useVegaWallet();
const waitForOrderEvent = useOrderEvent();
const {
send,
transaction,
reset: resetTransaction,
setComplete,
TransactionDialog,
} = useVegaTransaction();
const [finalizedOrder, setFinalizedOrder] =
useState<OrderEvent_busEvents_event_Order | null>(null);
const reset = useCallback(() => {
resetTransaction();
setFinalizedOrder(null);
}, [resetTransaction]);
const submit = useCallback(
async (order: Order) => {
if (!keypair || !order.side) {
return;
}
setFinalizedOrder(null);
try {
const res = await send({
pubKey: keypair.pub,
propagate: true,
orderSubmission: {
...order,
price:
order.type === OrderType.TYPE_LIMIT && order.price
? order.price
: undefined,
expiresAt: order.expiresAt
? toNanoSeconds(order.expiresAt) // Wallet expects timestamp in nanoseconds
: undefined,
},
});
if (res?.signature) {
const resId = determineId(res.signature);
if (resId) {
waitForOrderEvent(resId, keypair.pub, (order) => {
setFinalizedOrder(order);
setComplete();
});
}
}
return res;
} catch (e) {
Sentry.captureException(e);
return;
}
},
[keypair, send, setComplete, waitForOrderEvent]
);
return {
transaction,
finalizedOrder,
TransactionDialog,
submit,
reset,
};
};

View File

@ -1,8 +1,12 @@
import { toDecimal } from '@vegaprotocol/react-helpers';
import type { Order, Market } from '../order-hooks';
import type { Order } from '../order-hooks';
import { OrderTimeInForce, OrderType, Side } from '@vegaprotocol/types';
export const getDefaultOrder = (market: Market): Order => ({
export const getDefaultOrder = (market: {
id: string;
positionDecimalPlaces: number;
}): Order => ({
marketId: market.id,
type: OrderType.TYPE_MARKET,
side: Side.SIDE_BUY,
timeInForce: OrderTimeInForce.TIME_IN_FORCE_IOC,

View File

@ -1,4 +1,6 @@
export * from './lib/positions-table';
export * from './lib/__generated__/Positions';
export * from './lib/positions-container';
export * from './lib/positions-data-providers';
export * from './lib/__generated__/Positions';
export * from './lib/positions-table';
export * from './lib/use-close-position';
export * from './lib/use-position-event';

View File

@ -197,7 +197,7 @@ describe('getMetrics', () => {
expect(metrics[0].currentLeverage).toBeCloseTo(1.02);
expect(metrics[0].marketDecimalPlaces).toEqual(5);
expect(metrics[0].positionDecimalPlaces).toEqual(0);
expect(metrics[0].assetDecimals).toEqual(5);
expect(metrics[0].decimals).toEqual(5);
expect(metrics[0].liquidationPrice).toEqual('169990');
expect(metrics[0].lowMarginLevel).toEqual(false);
expect(metrics[0].markPrice).toEqual('9431775');
@ -222,7 +222,7 @@ describe('getMetrics', () => {
expect(metrics[1].currentLeverage).toBeCloseTo(0.097);
expect(metrics[1].marketDecimalPlaces).toEqual(5);
expect(metrics[1].positionDecimalPlaces).toEqual(0);
expect(metrics[1].assetDecimals).toEqual(5);
expect(metrics[1].decimals).toEqual(5);
expect(metrics[1].liquidationPrice).toEqual('9830750');
expect(metrics[1].lowMarginLevel).toEqual(false);
expect(metrics[1].markPrice).toEqual('869762');

View File

@ -24,7 +24,7 @@ export interface Position {
averageEntryPrice: string;
capitalUtilisation: number;
currentLeverage: number;
assetDecimals: number;
decimals: number;
marketDecimalPlaces: number;
positionDecimalPlaces: number;
totalBalance: string;
@ -133,7 +133,12 @@ export const getMetrics = (
const marginAccount = accounts?.find((account) => {
return account.market?.id === market.id;
});
if (!marginAccount || !marginLevel || !marketData) {
if (
!marginAccount ||
!marginLevel ||
!marketData ||
position.node.openVolume === '0'
) {
return;
}
const generalAccount = accounts?.find(
@ -141,7 +146,7 @@ export const getMetrics = (
account.asset.id === marginAccount.asset.id &&
account.type === AccountType.ACCOUNT_TYPE_GENERAL
);
const assetDecimals = marginAccount.asset.decimals;
const decimals = marginAccount.asset.decimals;
const { positionDecimalPlaces, decimalPlaces: marketDecimalPlaces } =
market;
const openVolume = toBigNum(
@ -149,13 +154,10 @@ export const getMetrics = (
positionDecimalPlaces
);
const marginAccountBalance = toBigNum(
marginAccount.balance ?? 0,
assetDecimals
);
const marginAccountBalance = toBigNum(marginAccount.balance ?? 0, decimals);
const generalAccountBalance = toBigNum(
generalAccount?.balance ?? 0,
assetDecimals
decimals
);
const markPrice = toBigNum(marketData.markPrice, marketDecimalPlaces);
@ -180,19 +182,19 @@ export const getMetrics = (
marketDecimalPlaces
);
const searchPrice = openVolume.isEqualTo(0)
? markPrice
: marginSearch
const searchPrice = marginSearch
.minus(marginAccountBalance)
.dividedBy(openVolume)
.plus(markPrice);
const liquidationPrice = openVolume.isEqualTo(0)
? markPrice
: marginMaintenance
const liquidationPrice = BigNumber.maximum(
0,
marginMaintenance
.minus(marginAccountBalance)
.minus(generalAccountBalance)
.dividedBy(openVolume)
.plus(markPrice);
.plus(markPrice)
);
const lowMarginLevel =
marginAccountBalance.isLessThan(
@ -206,9 +208,9 @@ export const getMetrics = (
currentLeverage: currentLeverage.toNumber(),
marketDecimalPlaces,
positionDecimalPlaces,
assetDecimals,
decimals,
assetSymbol: marginLevel.asset.symbol,
totalBalance: totalBalance.multipliedBy(10 ** assetDecimals).toFixed(),
totalBalance: totalBalance.multipliedBy(10 ** decimals).toFixed(),
lowMarginLevel,
liquidationPrice: liquidationPrice
.multipliedBy(10 ** marketDecimalPlaces)

View File

@ -1,26 +1,40 @@
import { useRef, useCallback, useMemo } from 'react';
import { useCallback, useMemo, useRef } from 'react';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { useDataProvider } from '@vegaprotocol/react-helpers';
import type { AgGridReact } from 'ag-grid-react';
import PositionsTable from './positions-table';
import type { GetRowsParams } from './positions-table';
import { positionsMetricsDataProvider as dataProvider } from './positions-data-providers';
import type { Position } from './positions-data-providers';
import { Positions } from './positions';
import { useClosePosition } from '../';
interface PositionsManagerProps {
partyId: string;
}
const getSymbols = (positions: Position[]) =>
Array.from(new Set(positions.map((position) => position.assetSymbol))).sort();
export const PositionsManager = ({ partyId }: PositionsManagerProps) => {
const gridRef = useRef<AgGridReact | null>(null);
const variables = useMemo(() => ({ partyId }), [partyId]);
const dataRef = useRef<Position[] | null>(null);
const assetSymbols = useRef<string[] | undefined>();
const { submit, TransactionDialog } = useClosePosition();
const onClose = useCallback(
(position: Position) => {
submit(position);
},
[submit]
);
const update = useCallback(({ data }: { data: Position[] | null }) => {
if (!gridRef.current?.api) {
if (data?.length) {
const newAssetSymbols = getSymbols(data);
if (
!newAssetSymbols.every(
(symbol) =>
assetSymbols.current && assetSymbols.current.includes(symbol)
)
) {
return false;
}
dataRef.current = data;
gridRef.current.api.refreshInfiniteCache();
}
return true;
}, []);
const { data, error, loading } = useDataProvider<Position[], never>({
@ -28,26 +42,22 @@ export const PositionsManager = ({ partyId }: PositionsManagerProps) => {
update,
variables,
});
dataRef.current = data;
const getRows = async ({
successCallback,
startRow,
endRow,
}: GetRowsParams) => {
const rowsThisBlock = dataRef.current
? dataRef.current.slice(startRow, endRow)
: [];
const lastRow = dataRef.current?.length ?? -1;
successCallback(rowsThisBlock, lastRow);
};
return (
<AsyncRenderer loading={loading} error={error} data={data}>
<PositionsTable
rowModelType={data?.length ? 'infinite' : 'clientSide'}
rowData={data?.length ? undefined : []}
datasource={{ getRows }}
<>
<AsyncRenderer loading={loading} error={error} data={assetSymbols}>
{data &&
getSymbols(data)?.map((assetSymbol) => (
<Positions
partyId={partyId}
assetSymbol={assetSymbol}
key={assetSymbol}
onClose={onClose}
/>
))}
</AsyncRenderer>
<TransactionDialog>
<p>Your position was not closed! This is still not implemented. </p>
</TransactionDialog>
</>
);
};

View File

@ -11,7 +11,7 @@ const singleRow: Position = {
currentLeverage: 1.1,
marketDecimalPlaces: 1,
positionDecimalPlaces: 0,
assetDecimals: 2,
decimals: 2,
totalBalance: '123456',
assetSymbol: 'BTC',
liquidationPrice: '83', // 8.3

View File

@ -16,7 +16,7 @@ const longPosition: Position = {
averageEntryPrice: '1134564',
capitalUtilisation: 10,
currentLeverage: 11,
assetDecimals: 2,
decimals: 2,
marketDecimalPlaces: 2,
positionDecimalPlaces: 2,
// generalAccountBalance: '0',
@ -48,7 +48,7 @@ const shortPosition: Position = {
averageEntryPrice: '23976',
capitalUtilisation: 87,
currentLeverage: 7,
assetDecimals: 2,
decimals: 2,
marketDecimalPlaces: 2,
positionDecimalPlaces: 2,
// generalAccountBalance: '0',

View File

@ -1,6 +1,12 @@
import classNames from 'classnames';
import { forwardRef } from 'react';
import type { ValueFormatterParams } from 'ag-grid-community';
import type { CSSProperties } from 'react';
import type {
ValueFormatterParams,
ValueGetterParams,
ICellRendererParams,
CellRendererSelectorResult,
} from 'ag-grid-community';
import {
PriceFlashCell,
addDecimalsFormatNumber,
@ -8,6 +14,8 @@ import {
t,
formatNumber,
getDateTimeFormat,
signedNumberCssClass,
signedNumberCssClassRules,
} from '@vegaprotocol/react-helpers';
import { AgGridDynamic as AgGrid, ProgressBar } from '@vegaprotocol/ui-toolkit';
import { AgGridColumn } from 'ag-grid-react';
@ -15,7 +23,7 @@ import type { AgGridReact, AgGridReactProps } from 'ag-grid-react';
import type { IDatasource, IGetRowsParams } from 'ag-grid-community';
import type { Position } from './positions-data-providers';
import { MarketTradingMode } from '@vegaprotocol/types';
import { Intent } from '@vegaprotocol/ui-toolkit';
import { Intent, Button } from '@vegaprotocol/ui-toolkit';
export const getRowId = ({ data }: { data: Position }) => data.marketId;
@ -29,6 +37,8 @@ export interface Datasource extends IDatasource {
interface Props extends AgGridReactProps {
rowData?: Position[] | null;
datasource?: Datasource;
onClose?: (data: Position) => void;
style?: CSSProperties;
}
type PositionsTableValueFormatterParams = Omit<
@ -43,12 +53,15 @@ export interface MarketNameCellProps {
}
export const MarketNameCell = ({ valueFormatted }: MarketNameCellProps) => {
return valueFormatted ? (
if (valueFormatted && valueFormatted[1]) {
return (
<div className="leading-tight">
<div>{valueFormatted[0]}</div>
{valueFormatted[1] ? <div>{valueFormatted[1]}</div> : null}
<div>{valueFormatted[1]}</div>
</div>
) : null;
);
}
return (valueFormatted && valueFormatted[0]) || undefined;
};
export interface PriceCellProps {
@ -63,14 +76,14 @@ export interface PriceCellProps {
export const ProgressBarCell = ({ valueFormatted }: PriceCellProps) => {
return valueFormatted ? (
<>
<div className="flex justify-between leading-tight">
<div className="flex justify-between leading-tight font-mono">
<div>{valueFormatted.low}</div>
<div>{valueFormatted.high}</div>
</div>
<ProgressBar
value={valueFormatted.value}
intent={valueFormatted.intent}
className="mt-4"
className="mt-2"
/>
</>
) : null;
@ -91,14 +104,10 @@ export const AmountCell = ({ valueFormatted }: AmountCellProps) => {
}
const { openVolume, positionDecimalPlaces, marketDecimalPlaces, notional } =
valueFormatted;
const isShortPosition = openVolume.startsWith('-');
return valueFormatted ? (
<div className="leading-tight">
<div className="leading-tight font-mono">
<div
className={classNames('text-right', {
'text-vega-green-dark dark:text-vega-green': !isShortPosition,
'text-vega-red-dark dark:text-vega-red': isShortPosition,
})}
className={classNames('text-right', signedNumberCssClass(openVolume))}
>
{volumePrefix(
addDecimalsFormatNumber(openVolume, positionDecimalPlaces)
@ -113,7 +122,24 @@ export const AmountCell = ({ valueFormatted }: AmountCellProps) => {
AmountCell.displayName = 'AmountCell';
export const PositionsTable = forwardRef<AgGridReact, Props>((props, ref) => {
const ButtonCell = ({
onClick,
data,
}: {
onClick: (position: Position) => void;
data: Position;
}) => {
return (
<Button onClick={() => onClick(data)} size="sm">
{t('Close')}
</Button>
);
};
const EmptyCell = () => '';
export const PositionsTable = forwardRef<AgGridReact, Props>(
({ onClose, ...props }, ref) => {
return (
<AgGrid
style={{ width: '100%', height: '100%' }}
@ -140,10 +166,10 @@ export const PositionsTable = forwardRef<AgGridReact, Props>((props, ref) => {
if (!value) {
return undefined;
}
// split market name into two parts, 'Part1 (Part2)'
const matches = value.match(/^(.*)\((.*)\)\s*$/);
// split market name into two parts, 'Part1 (Part2)' or 'Part1 - Part2'
const matches = value.match(/^(.*)(\((.*)\)| - (.*))\s*$/);
if (matches) {
return [matches[1].trim(), matches[2].trim()];
return [matches[1].trim(), matches[3].trim()];
}
return [value];
}}
@ -151,17 +177,30 @@ export const PositionsTable = forwardRef<AgGridReact, Props>((props, ref) => {
<AgGridColumn
headerName={t('Amount')}
field="openVolume"
valueGetter={({ node, data }: ValueGetterParams) => {
return node?.rowPinned ? data?.notional : data?.openVolume;
}}
type="rightAligned"
cellRenderer={AmountCell}
cellRendererSelector={(
params: ICellRendererParams
): CellRendererSelectorResult => {
return {
component: params.node.rowPinned ? PriceFlashCell : AmountCell,
};
}}
valueFormatter={({
value,
data,
node,
}: PositionsTableValueFormatterParams & {
value: Position['openVolume'];
}): AmountCellProps['valueFormatted'] => {
}): AmountCellProps['valueFormatted'] | string => {
if (!value || !data) {
return undefined;
}
if (node?.rowPinned) {
return addDecimalsFormatNumber(value, data.decimals);
}
return data;
}}
/>
@ -169,14 +208,21 @@ export const PositionsTable = forwardRef<AgGridReact, Props>((props, ref) => {
headerName={t('Mark price')}
field="markPrice"
type="rightAligned"
cellRenderer="PriceFlashCell"
cellRendererSelector={(
params: ICellRendererParams
): CellRendererSelectorResult => {
return {
component: params.node.rowPinned ? EmptyCell : PriceFlashCell,
};
}}
valueFormatter={({
value,
data,
node,
}: PositionsTableValueFormatterParams & {
value: Position['markPrice'];
}) => {
if (!data) {
if (!data || !value || node?.rowPinned) {
return undefined;
}
if (
@ -202,13 +248,20 @@ export const PositionsTable = forwardRef<AgGridReact, Props>((props, ref) => {
'</div>',
}}
flex={2}
cellRenderer="ProgressBarCell"
cellRendererSelector={(
params: ICellRendererParams
): CellRendererSelectorResult => {
return {
component: params.node.rowPinned ? EmptyCell : ProgressBarCell,
};
}}
valueFormatter={({
data,
node,
}: PositionsTableValueFormatterParams):
| PriceCellProps['valueFormatted']
| undefined => {
if (!data) {
if (!data || node?.rowPinned) {
return undefined;
}
const min = BigInt(data.averageEntryPrice);
@ -233,9 +286,16 @@ export const PositionsTable = forwardRef<AgGridReact, Props>((props, ref) => {
headerName={t('Leverage')}
field="currentLeverage"
type="rightAligned"
cellRenderer="PriceFlashCell"
cellRendererSelector={(
params: ICellRendererParams
): CellRendererSelectorResult => {
return {
component: params.node.rowPinned ? EmptyCell : PriceFlashCell,
};
}}
valueFormatter={({
value,
node,
}: PositionsTableValueFormatterParams & {
value: Position['currentLeverage'];
}) =>
@ -248,21 +308,26 @@ export const PositionsTable = forwardRef<AgGridReact, Props>((props, ref) => {
type="rightAligned"
flex={2}
cellRenderer="ProgressBarCell"
cellRendererSelector={(
params: ICellRendererParams
): CellRendererSelectorResult => {
return {
component: params.node.rowPinned ? EmptyCell : ProgressBarCell,
};
}}
valueFormatter={({
data,
value,
node,
}: PositionsTableValueFormatterParams & {
value: Position['capitalUtilisation'];
}): PriceCellProps['valueFormatted'] | undefined => {
if (!data) {
if (!data || node?.rowPinned) {
return undefined;
}
return {
low: `${formatNumber(value, 2)}%`,
high: addDecimalsFormatNumber(
data.totalBalance,
data.assetDecimals
),
high: addDecimalsFormatNumber(data.totalBalance, data.decimals),
value: Number(value),
};
}}
@ -271,18 +336,7 @@ export const PositionsTable = forwardRef<AgGridReact, Props>((props, ref) => {
headerName={t('Realised PNL')}
field="realisedPNL"
type="rightAligned"
cellClassRules={{
'text-vega-green-dark dark:text-vega-green': ({
value,
}: {
value: string;
}) => value && BigInt(value) > 0,
'text-vega-red-dark dark:text-vega-red': ({
value,
}: {
value: string;
}) => value && BigInt(value) < 0,
}}
cellClassRules={signedNumberCssClassRules}
valueFormatter={({
value,
data,
@ -291,7 +345,7 @@ export const PositionsTable = forwardRef<AgGridReact, Props>((props, ref) => {
}) =>
value === undefined
? undefined
: addDecimalsFormatNumber(value.toString(), data.assetDecimals)
: addDecimalsFormatNumber(value.toString(), data.decimals)
}
cellRenderer="PriceFlashCell"
headerTooltip={t('P&L excludes any fees paid.')}
@ -300,18 +354,7 @@ export const PositionsTable = forwardRef<AgGridReact, Props>((props, ref) => {
headerName={t('Unrealised PNL')}
field="unrealisedPNL"
type="rightAligned"
cellClassRules={{
'text-vega-green-dark dark:text-vega-green': ({
value,
}: {
value: string;
}) => value && BigInt(value) > 0,
'text-vega-red-dark dark:text-vega-red': ({
value,
}: {
value: string;
}) => value && BigInt(value) < 0,
}}
cellClassRules={signedNumberCssClassRules}
valueFormatter={({
value,
data,
@ -320,7 +363,7 @@ export const PositionsTable = forwardRef<AgGridReact, Props>((props, ref) => {
}) =>
value === undefined
? undefined
: addDecimalsFormatNumber(value.toString(), data.assetDecimals)
: addDecimalsFormatNumber(value.toString(), data.decimals)
}
cellRenderer="PriceFlashCell"
/>
@ -339,8 +382,21 @@ export const PositionsTable = forwardRef<AgGridReact, Props>((props, ref) => {
return getDateTimeFormat().format(new Date(value));
}}
/>
{onClose ? (
<AgGridColumn
cellRendererSelector={(
params: ICellRendererParams
): CellRendererSelectorResult => {
return {
component: params.node.rowPinned ? EmptyCell : ButtonCell,
};
}}
cellRendererParams={{ onClick: onClose }}
/>
) : null}
</AgGrid>
);
});
}
);
export default PositionsTable;

View File

@ -0,0 +1,108 @@
import { useRef, useCallback, useMemo, memo } from 'react';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { BigNumber } from 'bignumber.js';
import { t, toBigNum, useDataProvider } from '@vegaprotocol/react-helpers';
import type { AgGridReact } from 'ag-grid-react';
import filter from 'lodash/filter';
import PositionsTable from './positions-table';
import type { GetRowsParams } from './positions-table';
import { positionsMetricsDataProvider as dataProvider } from './positions-data-providers';
import { AssetBalance } from '@vegaprotocol/accounts';
import type { Position } from './positions-data-providers';
interface PositionsProps {
partyId: string;
assetSymbol: string;
onClose: (position: Position) => void;
}
const getSummaryRow = (positions: Position[]) => {
const summaryRow = {
notional: new BigNumber(0),
realisedPNL: BigInt(0),
unrealisedPNL: BigInt(0),
};
positions.forEach((position) => {
summaryRow.notional = summaryRow.notional.plus(
toBigNum(position.notional, position.marketDecimalPlaces)
);
summaryRow.realisedPNL += BigInt(position.realisedPNL);
summaryRow.unrealisedPNL += BigInt(position.unrealisedPNL);
});
const decimals = positions[0]?.decimals || 0;
return {
marketName: t('Total'),
// we are using asset decimals instead of market decimals because each market can have different decimals
notional: summaryRow.notional
.multipliedBy(10 ** decimals)
.toFixed()
.toString(),
realisedPNL: summaryRow.realisedPNL.toString(),
unrealisedPNL: summaryRow.unrealisedPNL.toString(),
decimals,
};
};
export const Positions = memo(
({ partyId, assetSymbol, onClose }: PositionsProps) => {
const gridRef = useRef<AgGridReact | null>(null);
const variables = useMemo(() => ({ partyId }), [partyId]);
const dataRef = useRef<Position[] | null>(null);
const update = useCallback(
({ data }: { data: Position[] | null }) => {
if (!gridRef.current?.api) {
return false;
}
dataRef.current = filter(data, { assetSymbol });
gridRef.current.api.refreshInfiniteCache();
return true;
},
[assetSymbol]
);
const { data, error, loading } = useDataProvider<Position[], never>({
dataProvider,
update,
variables,
});
dataRef.current = filter(data, { assetSymbol });
const getRows = async ({
successCallback,
startRow,
endRow,
}: GetRowsParams) => {
const rowsThisBlock = dataRef.current
? dataRef.current.slice(startRow, endRow)
: [];
const lastRow = dataRef.current?.length ?? -1;
successCallback(rowsThisBlock, lastRow);
if (gridRef.current?.api) {
gridRef.current.api.setPinnedBottomRowData([
getSummaryRow(rowsThisBlock),
]);
}
};
return (
<AsyncRenderer loading={loading} error={error} data={data}>
<div className="p-2">
<h4 className="text-lg">
{assetSymbol} {t('markets')}
</h4>
<p>
{assetSymbol} {t('balance')}:
<span className="pl-1 font-mono">
<AssetBalance partyId={partyId} assetSymbol={assetSymbol} />
</span>
</p>
</div>
<PositionsTable
domLayout="autoHeight"
style={{ width: '100%' }}
ref={gridRef}
rowModelType={data?.length ? 'infinite' : 'clientSide'}
rowData={data?.length ? undefined : []}
datasource={{ getRows }}
onClose={onClose}
/>
</AsyncRenderer>
);
}
);

View File

@ -0,0 +1,64 @@
import { useCallback } from 'react';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { determineId } from '@vegaprotocol/react-helpers';
import { useVegaTransaction } from '@vegaprotocol/wallet';
import * as Sentry from '@sentry/react';
import { usePositionEvent } from '../';
import type { Position } from '../';
export const useClosePosition = () => {
const { keypair } = useVegaWallet();
const waitForPositionEvent = usePositionEvent();
const {
send,
transaction,
reset: resetTransaction,
setComplete,
TransactionDialog,
} = useVegaTransaction();
const reset = useCallback(() => {
resetTransaction();
}, [resetTransaction]);
const submit = useCallback(
async (position: Position) => {
if (!keypair || position.openVolume === '0') {
return;
}
try {
const res = await send({
pubKey: keypair.pub,
propagate: true,
orderCancellation: {
marketId: position.marketId,
orderId: '',
},
});
if (res?.signature) {
const resId = determineId(res.signature);
if (resId) {
waitForPositionEvent(resId, keypair.pub, () => {
setComplete();
});
}
}
return res;
} catch (e) {
Sentry.captureException(e);
return;
}
},
[keypair, send, setComplete, waitForPositionEvent]
);
return {
transaction,
TransactionDialog,
submit,
reset,
};
};

View File

@ -0,0 +1,14 @@
import { useCallback } from 'react';
// this should be replaced by implementation of busEvents listener when it will be available
export const usePositionEvent = () => {
const waitForOrderEvent = useCallback(
(id: string, partyId: string, callback: () => void) => {
Promise.resolve().then(() => {
callback();
});
},
[]
);
return waitForOrderEvent;
};

View File

@ -488,23 +488,27 @@ function makeDerivedDataProviderInternal<Data>(
let loaded = false;
// notify single callback about current state, delta is passes optionally only if notify was invoked onNext
const notify = (callback: UpdateCallback<Data, never>) => {
const notify = (
callback: UpdateCallback<Data, never>,
updateData?: UpdateData<Data, never>
) => {
callback({
data,
error,
loading,
loaded,
pageInfo: null,
...updateData,
});
};
// notify all callbacks
const notifyAll = () =>
const notifyAll = (updateData?: UpdateData<Data, never>) =>
callbacks.forEach((callback) => {
notify(callback);
notify(callback, updateData);
});
const combine = () => {
const combine = (isUpdate = false) => {
let newError: Error | undefined;
let newLoading = false;
let newLoaded = true;
@ -529,7 +533,7 @@ function makeDerivedDataProviderInternal<Data>(
error = newError;
loaded = newLoaded;
data = newData;
notifyAll();
notifyAll({ isUpdate });
}
};
@ -541,7 +545,7 @@ function makeDerivedDataProviderInternal<Data>(
dependency(
(updateData) => {
parts[i] = updateData;
combine();
combine(updateData.isUpdate);
},
client,
variables

View File

@ -0,0 +1,23 @@
export const positiveClassNames = 'text-vega-green-dark dark:text-vega-green';
export const negativeClassNames = 'text-vega-red-dark dark:text-vega-red';
const isPositive = ({ value }: { value: string | bigint | number }) =>
value && ((typeof value === 'string' && !value.startsWith('-')) || value > 0);
const isNegative = ({ value }: { value: string | bigint | number }) =>
value && ((typeof value === 'string' && value.startsWith('-')) || value < 0);
export const signedNumberCssClass = (value: string | bigint | number) => {
if (isPositive({ value })) {
return positiveClassNames;
}
if (isNegative({ value })) {
return negativeClassNames;
}
return '';
};
export const signedNumberCssClassRules = {
[positiveClassNames]: isPositive,
[negativeClassNames]: isNegative,
};

View File

@ -1,3 +1,4 @@
export * from './cell-class-rules';
export * from './cumulative-vol-cell';
export * from './flash-cell';
export * from './price-cell';

View File

@ -4,7 +4,7 @@ import {
} from '@vegaprotocol/react-helpers';
import BigNumber from 'bignumber.js';
import React from 'react';
import { signedNumberCssClass } from '@vegaprotocol/react-helpers';
import { Arrow } from '../arrows/arrow';
export interface PriceChangeCellProps {
@ -36,20 +36,13 @@ export const priceChange = (candles: string[]) => {
: 0;
};
const priceChangeClassNames = (value: number | bigint) =>
value === 0
? 'text-black dark:text-white'
: value > 0
? `text-vega-green-dark dark:text-vega-green `
: `text-vega-red-dark dark:text-vega-red`;
export const PriceCellChange = React.memo(
({ candles, decimalPlaces }: PriceChangeCellProps) => {
const change = priceChange(candles);
const changePercentage = priceChangePercentage(candles);
return (
<span
className={`${priceChangeClassNames(
className={`${signedNumberCssClass(
change
)} flex items-center gap-2 justify-end`}
>