From fde77ebccb92d13bcd269b88e36222c242e3cd10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20G=C5=82ownia?= Date: Tue, 23 Jan 2024 17:19:49 +0100 Subject: [PATCH] feat(trading): margin mode selector (#5575) Co-authored-by: Dariusz Majcherczyk --- apps/trading/.env | 1 + apps/trading/.env.capsule | 1 + apps/trading/.env.devnet | 1 + apps/trading/.env.mainnet | 1 + apps/trading/.env.mainnet-mirror | 1 + apps/trading/.env.stagnet1 | 1 + apps/trading/.env.testnet | 1 + apps/trading/.env.validators-testnet | 1 + apps/trading/e2e/poetry.lock | 4 +- .../deal_ticket/test_isolated_cross_margin.py | 53 ++++ .../e2e/tests/order/test_order_match.py | 11 +- .../e2e/tests/positions/test_collateral.py | 29 +-- libs/accounts/src/lib/Margins.graphql | 6 + .../accounts/src/lib/__generated__/Margins.ts | 12 +- .../src/lib/accounts-data-provider.ts | 1 + .../accounts/src/lib/breakdown-table.spec.tsx | 30 +-- libs/accounts/src/lib/breakdown-table.tsx | 20 +- libs/accounts/src/lib/margin-data-provider.ts | 19 +- .../src/lib/margin-heath-chart.spec.tsx | 4 + .../deal-ticket/deal-ticket-container.tsx | 41 +-- .../deal-ticket/margin-mode-selector.tsx | 246 ++++++++++++++++++ libs/environment/src/hooks/use-environment.ts | 3 + libs/environment/src/types.ts | 1 + .../src/utils/validate-environment.ts | 1 + libs/i18n/src/locales/en/deal-ticket.json | 14 +- libs/i18n/src/locales/en/positions.json | 6 + libs/i18n/src/locales/en/web3.json | 2 + .../src/lib/positions-data-providers.spec.ts | 4 +- .../src/lib/positions-data-providers.ts | 146 +++++++++-- libs/positions/src/lib/positions-table.tsx | 174 ++++++++++++- libs/positions/src/lib/positions.mock.ts | 13 + libs/types/src/__generated__/types.ts | 72 +++++ libs/types/src/global-types-mappings.ts | 16 ++ .../ui-toolkit/src/components/slider/index.ts | 1 + libs/wallet/src/connectors/vega-connector.ts | 19 ++ .../src/lib/use-vega-transaction-store.tsx | 23 +- .../src/lib/use-vega-transaction-toasts.tsx | 30 +++ 37 files changed, 874 insertions(+), 135 deletions(-) create mode 100644 apps/trading/e2e/tests/deal_ticket/test_isolated_cross_margin.py create mode 100644 libs/deal-ticket/src/components/deal-ticket/margin-mode-selector.tsx diff --git a/apps/trading/.env b/apps/trading/.env index 77d86f56c..565f54412 100644 --- a/apps/trading/.env +++ b/apps/trading/.env @@ -21,6 +21,7 @@ NX_WALLETCONNECT_PROJECT_ID=fe8091dc35738863e509fc4947525c72 # Cosmic elevator flags NX_SUCCESSOR_MARKETS=true NX_STOP_ORDERS=true +NX_ISOLATED_MARGIN=true NX_ICEBERG_ORDERS=true NX_METAMASK_SNAPS=true NX_REFERRALS=true diff --git a/apps/trading/.env.capsule b/apps/trading/.env.capsule index 99187803b..1dae7042b 100644 --- a/apps/trading/.env.capsule +++ b/apps/trading/.env.capsule @@ -21,6 +21,7 @@ NX_ETH_WALLET_MNEMONIC="ozone access unlock valid olympic save include omit supp # Cosmic elevator flags NX_SUCCESSOR_MARKETS=false NX_STOP_ORDERS=false +NX_ISOLATED_MARGIN=true # NX_ICEBERG_ORDERS # NX_PRODUCT_PERPETUALS NX_METAMASK_SNAPS=false diff --git a/apps/trading/.env.devnet b/apps/trading/.env.devnet index 7863d77b5..364481010 100644 --- a/apps/trading/.env.devnet +++ b/apps/trading/.env.devnet @@ -20,6 +20,7 @@ NX_ORACLE_PROOFS_URL=https://raw.githubusercontent.com/vegaprotocol/well-known/m # Cosmic elevator flags NX_SUCCESSOR_MARKETS=true NX_STOP_ORDERS=true +NX_ISOLATED_MARGIN=true # NX_ICEBERG_ORDERS # NX_PRODUCT_PERPETUALS NX_METAMASK_SNAPS=true diff --git a/apps/trading/.env.mainnet b/apps/trading/.env.mainnet index 10f07700d..afb43a660 100644 --- a/apps/trading/.env.mainnet +++ b/apps/trading/.env.mainnet @@ -21,6 +21,7 @@ NX_WALLETCONNECT_PROJECT_ID=fe8091dc35738863e509fc4947525c72 # Cosmic elevator flags NX_SUCCESSOR_MARKETS=true NX_STOP_ORDERS=true +NX_ISOLATED_MARGIN=false NX_ICEBERG_ORDERS=true NX_METAMASK_SNAPS=true NX_REFERRALS=true diff --git a/apps/trading/.env.mainnet-mirror b/apps/trading/.env.mainnet-mirror index 78e1f9f7e..f3e7aad92 100644 --- a/apps/trading/.env.mainnet-mirror +++ b/apps/trading/.env.mainnet-mirror @@ -21,6 +21,7 @@ NX_APP_VERSION=v0.20.19-core-0.71.6 # Cosmic elevator flags NX_SUCCESSOR_MARKETS=true NX_STOP_ORDERS=true +NX_ISOLATED_MARGIN=false NX_ICEBERG_ORDERS=true # NX_PRODUCT_PERPETUALS NX_METAMASK_SNAPS=false diff --git a/apps/trading/.env.stagnet1 b/apps/trading/.env.stagnet1 index bff6c7bb8..c30708ad7 100644 --- a/apps/trading/.env.stagnet1 +++ b/apps/trading/.env.stagnet1 @@ -21,6 +21,7 @@ NX_WALLETCONNECT_PROJECT_ID=fe8091dc35738863e509fc4947525c72 # Cosmic elevator flags NX_SUCCESSOR_MARKETS=true NX_STOP_ORDERS=true +NX_ISOLATED_MARGIN=true NX_ICEBERG_ORDERS=true # NX_PRODUCT_PERPETUALS NX_METAMASK_SNAPS=true diff --git a/apps/trading/.env.testnet b/apps/trading/.env.testnet index 5ddaa9548..ad9347afe 100644 --- a/apps/trading/.env.testnet +++ b/apps/trading/.env.testnet @@ -22,6 +22,7 @@ NX_WALLETCONNECT_PROJECT_ID=fe8091dc35738863e509fc4947525c72 # Cosmic elevator flags NX_SUCCESSOR_MARKETS=true NX_STOP_ORDERS=true +NX_ISOLATED_MARGIN=true NX_ICEBERG_ORDERS=true NX_METAMASK_SNAPS=true NX_REFERRALS=true diff --git a/apps/trading/.env.validators-testnet b/apps/trading/.env.validators-testnet index 5ac9738ac..a24ba7326 100644 --- a/apps/trading/.env.validators-testnet +++ b/apps/trading/.env.validators-testnet @@ -22,6 +22,7 @@ NX_ORACLE_PROOFS_URL=https://raw.githubusercontent.com/vegaprotocol/well-known/m # Cosmic elevator flags NX_SUCCESSOR_MARKETS=true NX_STOP_ORDERS=true +NX_ISOLATED_MARGIN=true NX_ICEBERG_ORDERS=true # NX_PRODUCT_PERPETUALS NX_METAMASK_SNAPS=false diff --git a/apps/trading/e2e/poetry.lock b/apps/trading/e2e/poetry.lock index 176cc7a3f..fca1b8f29 100644 --- a/apps/trading/e2e/poetry.lock +++ b/apps/trading/e2e/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "certifi" @@ -1161,7 +1161,7 @@ profile = ["pytest-profiling", "snakeviz"] type = "git" url = "https://github.com/vegaprotocol/vega-market-sim.git/" reference = "HEAD" -resolved_reference = "2aed8c94b25d8fa2e376d3b63ca1f9193d28cdfd" +resolved_reference = "4440abbb6ce0d3e80beba5cd01f20cd21983cbf8" [[package]] name = "websocket-client" diff --git a/apps/trading/e2e/tests/deal_ticket/test_isolated_cross_margin.py b/apps/trading/e2e/tests/deal_ticket/test_isolated_cross_margin.py new file mode 100644 index 000000000..c592b2712 --- /dev/null +++ b/apps/trading/e2e/tests/deal_ticket/test_isolated_cross_margin.py @@ -0,0 +1,53 @@ +import pytest +from playwright.sync_api import Page, expect +from vega_sim.null_service import VegaServiceNull +from actions.vega import submit_order +from actions.utils import next_epoch, wait_for_toast_confirmation + +tooltip_content = "tooltip-content" +leverage_input = "#leverage-input" +tab_positions = "tab-positions" +margin_row = '[col-id="margin"]' + + +def create_position(vega: VegaServiceNull, market_id): + submit_order(vega, "Key 1", market_id, "SIDE_SELL", 100, 110) + submit_order(vega, "Key 1", market_id, "SIDE_BUY", 100, 110) + vega.wait_fn(1) + vega.wait_for_total_catchup + + +@pytest.mark.usefixtures("auth", "risk_accepted") +def test_switch_cross_isolated_margin( + continuous_market, vega: VegaServiceNull, page: Page): + create_position(vega, continuous_market) + page.goto(f"/#/markets/{continuous_market}") + expect(page.locator(margin_row).nth(1)).to_have_text("874.21992Cross1.0x") + # tbd - tooltip is not visible without this wait + page.wait_for_timeout(1000) + page.get_by_test_id(tab_positions).get_by_text("Cross").hover() + expect(page.get_by_test_id(tooltip_content).nth(0)).to_have_text( + "Liquidation: 582.81328Margin: 874.21992General account: 998,084.95183" + ) + page.get_by_role("button", name="Isolated 10x").click() + page.locator(leverage_input).clear() + page.locator(leverage_input).type("1") + page.get_by_role("button", name="Confirm").click() + wait_for_toast_confirmation(page) + next_epoch(vega=vega) + expect(page.get_by_test_id("toast-content")).to_have_text( + "ConfirmedYour transaction has been confirmedView in block explorerUpdate margin modeBTC:DAI_2023Isolated margin mode, leverage: 1.0x") + expect(page.locator(margin_row).nth(1) + ).to_have_text("11,109.99996Isolated1.0x") + # tbd - tooltip is not visible without this wait + page.wait_for_timeout(1000) + page.get_by_test_id(tab_positions).get_by_text("Isolated").hover() + expect(page.get_by_test_id(tooltip_content).nth(0)).to_have_text( + "Liquidation: 583.62409Margin: 11,109.99996Order: 11,000.00" + ) + page.get_by_role("button", name="Cross").click() + page.get_by_role("button", name="Confirm").click() + wait_for_toast_confirmation(page) + next_epoch(vega=vega) + expect(page.locator(margin_row).nth(1)).to_have_text( + "22,109.99996Cross1.0x") diff --git a/apps/trading/e2e/tests/order/test_order_match.py b/apps/trading/e2e/tests/order/test_order_match.py index dfa71f6dc..215dc4522 100644 --- a/apps/trading/e2e/tests/order/test_order_match.py +++ b/apps/trading/e2e/tests/order/test_order_match.py @@ -29,13 +29,15 @@ def verify_data_grid(page: Page, data_test_id, expected_pattern): logger.info(f"Matched: {expected} == {actual}") else: logger.info(f"Not Matched: {expected} != {actual}") - raise AssertionError(f"Pattern does not match: {expected} != {actual}") + raise AssertionError( + f"Pattern does not match: {expected} != {actual}") else: # it's not a regex, so we escape it if re.search(re.escape(expected), actual): logger.info(f"Matched: {expected} == {actual}") else: logger.info(f"Not Matched: {expected} != {actual}") - raise AssertionError(f"Pattern does not match: {expected} != {actual}") + raise AssertionError( + f"Pattern does not match: {expected} != {actual}") def submit_order(vega: VegaServiceNull, wallet_name, market_id, side, volume, price): @@ -91,7 +93,7 @@ def test_limit_order_trade_open_position(continuous_market, page: Page): "average_entry_price": "107.50", "mark_price": "107.50", "margin": "8.50269", - "leverage": "1.0x", + "leverage": "Cross1.0x", "liquidation": "0.00", "realised_pnl": "0.00", "unrealised_pnl": "0.00", @@ -104,7 +106,8 @@ def test_limit_order_trade_open_position(continuous_market, page: Page): # 7004-POSI-002 size_and_notional = table.locator("[col-id='openVolume']") - expect(size_and_notional.get_by_test_id(primary_id)).to_have_text(position["size"]) + expect(size_and_notional.get_by_test_id( + primary_id)).to_have_text(position["size"]) expect(size_and_notional.get_by_test_id(secondary_id)).to_have_text( position["notional"] ) diff --git a/apps/trading/e2e/tests/positions/test_collateral.py b/apps/trading/e2e/tests/positions/test_collateral.py index af1073c7c..5867b37b3 100644 --- a/apps/trading/e2e/tests/positions/test_collateral.py +++ b/apps/trading/e2e/tests/positions/test_collateral.py @@ -28,8 +28,9 @@ def test_usage_breakdown(continuous_market, page: Page): usage_breakdown = page.get_by_test_id("usage-breakdown") # Verify headers - headers = ["Market", "Account type", "Balance", "Margin health"] - ag_headers = usage_breakdown.locator(".ag-header-cell-text").element_handles() + headers = ["Market", "Account type", "Balance"] + ag_headers = usage_breakdown.locator( + ".ag-header-cell-text").element_handles() for i, header_element in enumerate(ag_headers): header_text = header_element.text_content() assert header_text == headers[i] @@ -38,30 +39,10 @@ def test_usage_breakdown(continuous_market, page: Page): expect(usage_breakdown.locator('[class="mb-2 text-sm"]')).to_have_text( "You have 1,000,000.00 tDAI in total." ) - expect(usage_breakdown.locator(COL_ID_USED).first).to_have_text("8.50269 (0%)") + expect(usage_breakdown.locator( + COL_ID_USED).first).to_have_text("8.50269 (0%)") expect(usage_breakdown.locator(COL_ID_USED).nth(1)).to_have_text( "999,991.49731 (99%)" ) - # Maintenance Level - expect( - usage_breakdown.locator( - ".ag-center-cols-container [col-id='market.id'] .ag-cell-value" - ).first - ).to_have_text("2.85556 above maintenance level") - - # Margin health tooltip - usage_breakdown.get_by_test_id("margin-health-chart-track").hover() - tooltip_data = [ - ("maintenance level", "5.64713"), - ("search level", "6.21184"), - ("initial level", "8.47069"), - ("balance", "8.50269"), - ("release level", "9.60012"), - ] - - for index, (label, value) in enumerate(tooltip_data): - expect(page.get_by_test_id(TOOLTIP_LABEL).nth(index)).to_have_text(label) - expect(page.get_by_test_id(TOOLTIP_VALUE).nth(index)).to_have_text(value) - page.get_by_test_id("dialog-close").click() diff --git a/libs/accounts/src/lib/Margins.graphql b/libs/accounts/src/lib/Margins.graphql index 9580c96af..c0a04e1c8 100644 --- a/libs/accounts/src/lib/Margins.graphql +++ b/libs/accounts/src/lib/Margins.graphql @@ -3,6 +3,9 @@ fragment MarginFields on MarginLevels { searchLevel initialLevel collateralReleaseLevel + marginFactor + marginMode + orderMarginLevel asset { id } @@ -33,6 +36,9 @@ subscription MarginsSubscription($partyId: ID!) { searchLevel initialLevel collateralReleaseLevel + marginFactor + marginMode + orderMarginLevel timestamp } } diff --git a/libs/accounts/src/lib/__generated__/Margins.ts b/libs/accounts/src/lib/__generated__/Margins.ts index daf477bc6..fd7f08b83 100644 --- a/libs/accounts/src/lib/__generated__/Margins.ts +++ b/libs/accounts/src/lib/__generated__/Margins.ts @@ -3,21 +3,21 @@ import * as Types from '@vegaprotocol/types'; import { gql } from '@apollo/client'; import * as Apollo from '@apollo/client'; const defaultOptions = {} as const; -export type MarginFieldsFragment = { __typename?: 'MarginLevels', maintenanceLevel: string, searchLevel: string, initialLevel: string, collateralReleaseLevel: string, asset: { __typename?: 'Asset', id: string }, market: { __typename?: 'Market', id: string } }; +export type MarginFieldsFragment = { __typename?: 'MarginLevels', maintenanceLevel: string, searchLevel: string, initialLevel: string, collateralReleaseLevel: string, marginFactor: string, marginMode: Types.MarginMode, orderMarginLevel: string, asset: { __typename?: 'Asset', id: string }, market: { __typename?: 'Market', id: string } }; export type MarginsQueryVariables = Types.Exact<{ partyId: Types.Scalars['ID']; }>; -export type MarginsQuery = { __typename?: 'Query', party?: { __typename?: 'Party', id: string, marginsConnection?: { __typename?: 'MarginConnection', edges?: Array<{ __typename?: 'MarginEdge', node: { __typename?: 'MarginLevels', maintenanceLevel: string, searchLevel: string, initialLevel: string, collateralReleaseLevel: string, asset: { __typename?: 'Asset', id: string }, market: { __typename?: 'Market', id: string } } }> | null } | null } | null }; +export type MarginsQuery = { __typename?: 'Query', party?: { __typename?: 'Party', id: string, marginsConnection?: { __typename?: 'MarginConnection', edges?: Array<{ __typename?: 'MarginEdge', node: { __typename?: 'MarginLevels', maintenanceLevel: string, searchLevel: string, initialLevel: string, collateralReleaseLevel: string, marginFactor: string, marginMode: Types.MarginMode, orderMarginLevel: string, asset: { __typename?: 'Asset', id: string }, market: { __typename?: 'Market', id: string } } }> | null } | null } | null }; export type MarginsSubscriptionSubscriptionVariables = Types.Exact<{ partyId: Types.Scalars['ID']; }>; -export type MarginsSubscriptionSubscription = { __typename?: 'Subscription', margins: { __typename?: 'MarginLevelsUpdate', marketId: string, asset: string, partyId: string, maintenanceLevel: string, searchLevel: string, initialLevel: string, collateralReleaseLevel: string, timestamp: any } }; +export type MarginsSubscriptionSubscription = { __typename?: 'Subscription', margins: { __typename?: 'MarginLevelsUpdate', marketId: string, asset: string, partyId: string, maintenanceLevel: string, searchLevel: string, initialLevel: string, collateralReleaseLevel: string, marginFactor: string, marginMode: Types.MarginMode, orderMarginLevel: string, timestamp: any } }; export const MarginFieldsFragmentDoc = gql` fragment MarginFields on MarginLevels { @@ -25,6 +25,9 @@ export const MarginFieldsFragmentDoc = gql` searchLevel initialLevel collateralReleaseLevel + marginFactor + marginMode + orderMarginLevel asset { id } @@ -85,6 +88,9 @@ export const MarginsSubscriptionDocument = gql` searchLevel initialLevel collateralReleaseLevel + marginFactor + marginMode + orderMarginLevel timestamp } } diff --git a/libs/accounts/src/lib/accounts-data-provider.ts b/libs/accounts/src/lib/accounts-data-provider.ts index 01b110981..4d57e17f9 100644 --- a/libs/accounts/src/lib/accounts-data-provider.ts +++ b/libs/accounts/src/lib/accounts-data-provider.ts @@ -105,6 +105,7 @@ export interface AccountFields extends Account { // The total balance of these accounts will be used for the 'used' column in the // collateral table const USE_ACCOUNT_TYPES = [ + AccountType.ACCOUNT_TYPE_ORDER_MARGIN, AccountType.ACCOUNT_TYPE_MARGIN, AccountType.ACCOUNT_TYPE_BOND, AccountType.ACCOUNT_TYPE_FEES_INFRASTRUCTURE, diff --git a/libs/accounts/src/lib/breakdown-table.spec.tsx b/libs/accounts/src/lib/breakdown-table.spec.tsx index f7d855ec3..63223259c 100644 --- a/libs/accounts/src/lib/breakdown-table.spec.tsx +++ b/libs/accounts/src/lib/breakdown-table.spec.tsx @@ -4,14 +4,6 @@ import * as Types from '@vegaprotocol/types'; import type { AccountFields } from './accounts-data-provider'; import { getAccountData } from './accounts-data-provider'; -const marginHealthChartTestId = 'margin-health-chart'; - -jest.mock('./margin-health-chart', () => ({ - MarginHealthChart: () => { - return
; - }, -})); - const singleRow = { __typename: 'AccountBalance', type: Types.AccountType.ACCOUNT_TYPE_MARGIN, @@ -49,10 +41,10 @@ describe('BreakdownTable', () => { render(); }); const headers = await screen.findAllByRole('columnheader'); - expect(headers).toHaveLength(4); + expect(headers).toHaveLength(3); expect( headers.map((h) => h.querySelector('[ref="eText"]')?.textContent?.trim()) - ).toEqual(['Market', 'Account type', 'Balance', 'Margin health']); + ).toEqual(['Market', 'Account type', 'Balance']); }); it('should apply correct formatting', async () => { @@ -70,24 +62,6 @@ describe('BreakdownTable', () => { cells.slice(0, -1).forEach((cell, i) => { expect(cell).toHaveTextContent(expectedValues[i]); }); - expect(screen.getByTestId(marginHealthChartTestId)).toBeInTheDocument(); - }); - - it('displays margin health chart only for margin account', async () => { - await act(async () => { - render( - - ); - }); - expect(screen.queryByTestId(marginHealthChartTestId)).toBeNull(); }); it('should get correct account data', () => { diff --git a/libs/accounts/src/lib/breakdown-table.tsx b/libs/accounts/src/lib/breakdown-table.tsx index adadce994..1f77fd896 100644 --- a/libs/accounts/src/lib/breakdown-table.tsx +++ b/libs/accounts/src/lib/breakdown-table.tsx @@ -16,14 +16,13 @@ import { ProgressBarCell } from '@vegaprotocol/datagrid'; import { AgGrid, PriceCell } from '@vegaprotocol/datagrid'; import type { ColDef } from 'ag-grid-community'; import { accountValuesComparator } from './accounts-table'; -import { MarginHealthChart } from './margin-health-chart'; import { MarketNameCell } from '@vegaprotocol/datagrid'; -import { AccountType } from '@vegaprotocol/types'; const defaultColDef = { resizable: true, sortable: true, minWidth: 100, + flex: 1, }; interface BreakdownTableProps extends AgGridReactProps { @@ -111,23 +110,6 @@ const BreakdownTable = forwardRef( }, comparator: accountValuesComparator, }, - { - headerName: t('Margin health'), - field: 'market.id', - maxWidth: 500, - sortable: false, - cellRenderer: ({ - data, - }: VegaICellRendererParams) => - data?.market?.id && - data.type === AccountType['ACCOUNT_TYPE_MARGIN'] && - data?.asset.id ? ( - - ) : null, - }, ]; return defs; }, [t]); diff --git a/libs/accounts/src/lib/margin-data-provider.ts b/libs/accounts/src/lib/margin-data-provider.ts index 54c0c1572..9ea84a417 100644 --- a/libs/accounts/src/lib/margin-data-provider.ts +++ b/libs/accounts/src/lib/margin-data-provider.ts @@ -20,14 +20,20 @@ const update = ( return produce(data || [], (draft) => { const { marketId } = delta; const index = draft.findIndex((node) => node.market.id === marketId); + const deltaData = { + maintenanceLevel: delta.maintenanceLevel, + searchLevel: delta.searchLevel, + initialLevel: delta.initialLevel, + collateralReleaseLevel: delta.collateralReleaseLevel, + marginFactor: delta.marginFactor, + marginMode: delta.marginMode, + orderMarginLevel: delta.orderMarginLevel, + }; if (index !== -1) { const currNode = draft[index]; draft[index] = { ...currNode, - maintenanceLevel: delta.maintenanceLevel, - searchLevel: delta.searchLevel, - initialLevel: delta.initialLevel, - collateralReleaseLevel: delta.collateralReleaseLevel, + ...deltaData, }; } else { draft.unshift({ @@ -36,10 +42,7 @@ const update = ( __typename: 'Market', id: delta.marketId, }, - maintenanceLevel: delta.maintenanceLevel, - searchLevel: delta.searchLevel, - initialLevel: delta.initialLevel, - collateralReleaseLevel: delta.collateralReleaseLevel, + ...deltaData, asset: { __typename: 'Asset', id: delta.asset, diff --git a/libs/accounts/src/lib/margin-heath-chart.spec.tsx b/libs/accounts/src/lib/margin-heath-chart.spec.tsx index 77e437524..18e232933 100644 --- a/libs/accounts/src/lib/margin-heath-chart.spec.tsx +++ b/libs/accounts/src/lib/margin-heath-chart.spec.tsx @@ -5,6 +5,7 @@ import { import { act, render, screen } from '@testing-library/react'; import type { MarginFieldsFragment } from './__generated__/Margins'; import type { AssetFieldsFragment } from '@vegaprotocol/assets'; +import { MarginMode } from '@vegaprotocol/types'; const asset: AssetFieldsFragment = { id: 'assetId', @@ -18,6 +19,9 @@ const margins: MarginFieldsFragment = { initialLevel: '800', searchLevel: '600', maintenanceLevel: '400', + marginFactor: '', + marginMode: MarginMode.MARGIN_MODE_CROSS_MARGIN, + orderMarginLevel: '', market: { id: 'marketId', }, diff --git a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-container.tsx b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-container.tsx index cc9705166..f41306cce 100644 --- a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-container.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-container.tsx @@ -13,6 +13,7 @@ import { AsyncRendererInline } from '@vegaprotocol/ui-toolkit'; import { DealTicket } from './deal-ticket'; import { useFeatureFlags } from '@vegaprotocol/environment'; import { useT } from '../../use-t'; +import { MarginModeSelector } from './margin-mode-selector'; interface DealTicketContainerProps { marketId: string; @@ -51,21 +52,31 @@ export const DealTicketContainer = ({ reload={reload} > {market && marketData ? ( - featureFlags.STOP_ORDERS && showStopOrder ? ( - create({ stopOrdersSubmission })} - /> - ) : ( - create({ orderSubmission })} - /> - ) + <> + {featureFlags.ISOLATED_MARGIN && ( + <> + +
+ + )} + {featureFlags.STOP_ORDERS && showStopOrder ? ( + + create({ stopOrdersSubmission }) + } + /> + ) : ( + create({ orderSubmission })} + /> + )} + ) : (

{t('Could not load market')}

)} diff --git a/libs/deal-ticket/src/components/deal-ticket/margin-mode-selector.tsx b/libs/deal-ticket/src/components/deal-ticket/margin-mode-selector.tsx new file mode 100644 index 000000000..985fd6601 --- /dev/null +++ b/libs/deal-ticket/src/components/deal-ticket/margin-mode-selector.tsx @@ -0,0 +1,246 @@ +import { useDataProvider } from '@vegaprotocol/data-provider'; +import { + TradingButton as Button, + TradingInput as Input, + FormGroup, + LeverageSlider, +} from '@vegaprotocol/ui-toolkit'; +import { MarginMode, useVegaWallet } from '@vegaprotocol/wallet'; +import * as Types from '@vegaprotocol/types'; +import { + type VegaTransactionStore, + useVegaTransactionStore, +} from '@vegaprotocol/web3'; +import { Dialog } from '@vegaprotocol/ui-toolkit'; +import { useEffect, useState } from 'react'; +import { useT } from '../../use-t'; +import classnames from 'classnames'; +import { marketMarginDataProvider } from '@vegaprotocol/accounts'; +import { useMaxLeverage } from '@vegaprotocol/positions'; + +const defaultLeverage = 10; +interface MarginDialogProps { + open: boolean; + onClose: () => void; + marketId: string; + partyId: string; + create: VegaTransactionStore['create']; +} + +const CrossMarginModeDialog = ({ + open, + onClose, + marketId, + create, +}: MarginDialogProps) => { + const t = useT(); + return ( + { + if (!isOpen) { + onClose(); + } + }} + > +
+

+ {t('You are setting this market to cross-margin mode.')} +

+

+ {t( + 'Your max leverage on each position will be determined by the risk model of the market.' + )} +

+

+ {t( + 'All available funds in your general account will be used to finance your margin if the market moves against you.' + )} +

+
+ +
+ ); +}; + +const IsolatedMarginModeDialog = ({ + open, + onClose, + marketId, + partyId, + marginFactor, + create, +}: MarginDialogProps & { marginFactor: string }) => { + const [leverage, setLeverage] = useState( + Number((1 / Number(marginFactor)).toFixed(1)) + ); + const { data: maxLeverage } = useMaxLeverage(marketId, partyId); + const max = Math.floor((maxLeverage || 1) * 10) / 10; + useEffect(() => { + setLeverage(Number((1 / Number(marginFactor)).toFixed(1))); + }, [marginFactor]); + useEffect(() => { + if (maxLeverage && leverage > max) { + setLeverage(max); + } + }, [max, maxLeverage, leverage]); + + const t = useT(); + return ( + { + if (!isOpen) { + onClose(); + } + }} + > +
+

+ {t('You are setting this market to isolated margin mode.')} +

+

+ {t( + 'Set the leverage you want below. The maximum leverage you can take is determined by the risk model of the market.' + )} +

+

+ {t( + 'Only your allocated margin will be used to fund this position, and if the maintenance margin is breached you will be closed out.' + )} +

+
+
{ + create({ + updateMarginMode: { + market_id: marketId, + mode: MarginMode.MARGIN_MODE_ISOLATED_MARGIN, + marginFactor: `${1 / leverage}`, + }, + }); + onClose(); + }} + > + +
+ setLeverage(value)} + /> +
+ setLeverage(Number(e.target.value))} + /> +
+ +
+
+ ); +}; + +export const MarginModeSelector = ({ marketId }: { marketId: string }) => { + const t = useT(); + const [dialog, setDialog] = useState<'cross' | 'isolated' | ''>(); + const { pubKey: partyId, isReadOnly } = useVegaWallet(); + const { data: margin } = useDataProvider({ + dataProvider: marketMarginDataProvider, + variables: { + partyId: partyId || '', + marketId, + }, + skip: !partyId, + }); + useEffect(() => { + if (!partyId) { + setDialog(''); + } + }, [partyId]); + const create = useVegaTransactionStore((state) => state.create); + const marginMode = margin?.marginMode; + const marginFactor = + margin?.marginFactor && margin?.marginFactor !== '0' + ? margin?.marginFactor + : undefined; + const disabled = isReadOnly; + const onClose = () => setDialog(undefined); + const enabledModeClassName = 'bg-vega-clight-500 dark:bg-vega-cdark-500'; + + return ( + <> +
+ + +
+ {partyId && ( + + )} + {partyId && ( + + )} + + ); +}; diff --git a/libs/environment/src/hooks/use-environment.ts b/libs/environment/src/hooks/use-environment.ts index de3afbc75..893ac5f1a 100644 --- a/libs/environment/src/hooks/use-environment.ts +++ b/libs/environment/src/hooks/use-environment.ts @@ -323,6 +323,9 @@ export const compileFeatureFlags = (refresh = false): FeatureFlags => { STOP_ORDERS: TRUTHY.includes( windowOrDefault('NX_STOP_ORDERS', process.env['NX_STOP_ORDERS']) as string ), + ISOLATED_MARGIN: TRUTHY.includes( + windowOrDefault('NX_STOP_ORDERS', process.env['NX_STOP_ORDERS']) as string + ), SUCCESSOR_MARKETS: TRUTHY.includes( windowOrDefault( 'NX_SUCCESSOR_MARKETS', diff --git a/libs/environment/src/types.ts b/libs/environment/src/types.ts index 1e47ebe64..a94d7aa0a 100644 --- a/libs/environment/src/types.ts +++ b/libs/environment/src/types.ts @@ -19,6 +19,7 @@ export type FeatureFlags = z.infer; export type CosmicElevatorFlags = Pick< FeatureFlags, | 'ICEBERG_ORDERS' + | 'ISOLATED_MARGIN' | 'STOP_ORDERS' | 'SUCCESSOR_MARKETS' | 'PRODUCT_PERPETUALS' diff --git a/libs/environment/src/utils/validate-environment.ts b/libs/environment/src/utils/validate-environment.ts index e2ce99700..0fc19e90a 100644 --- a/libs/environment/src/utils/validate-environment.ts +++ b/libs/environment/src/utils/validate-environment.ts @@ -76,6 +76,7 @@ export const envSchema = z const COSMIC_ELEVATOR_FLAGS = { SUCCESSOR_MARKETS: z.optional(z.boolean()), STOP_ORDERS: z.optional(z.boolean()), + ISOLATED_MARGIN: z.optional(z.boolean()), ICEBERG_ORDERS: z.optional(z.boolean()), PRODUCT_PERPETUALS: z.optional(z.boolean()), METAMASK_SNAPS: z.optional(z.boolean()), diff --git a/libs/i18n/src/locales/en/deal-ticket.json b/libs/i18n/src/locales/en/deal-ticket.json index c3bf38ff3..c90019b15 100644 --- a/libs/i18n/src/locales/en/deal-ticket.json +++ b/libs/i18n/src/locales/en/deal-ticket.json @@ -8,13 +8,17 @@ "A release candidate for the staging environment": "A release candidate for the staging environment", "above": "above", "Advanced": "Advanced", + "All available funds in your general account will be used to finance your margin if the market moves against you.": "All available funds in your general account will be used to finance your margin if the market moves against you.", "An estimate of the most you would be expected to pay in fees, in the market's settlement asset {{assetSymbol}}. Fees estimated are \"taker\" fees and will only be payable if the order trades aggressively. Rebate equal to the maker portion will be paid to the trader if the order trades passively.": "An estimate of the most you would be expected to pay in fees, in the market's settlement asset {{assetSymbol}}. Fees estimated are \"taker\" fees and will only be payable if the order trades aggressively. Rebate equal to the maker portion will be paid to the trader if the order trades passively.", "Any orders placed now will not trade until the auction ends": "Any orders placed now will not trade until the auction ends", "below": "below", "Cancel": "Cancel", "Closed": "Closed", "Closing on {{time}}": "Closing on {{time}}", + "Confirm": "Confirm", "Could not load market": "Could not load market", + "Cross": "Cross", + "Cross margin": "Cross margin", "Current margin allocation": "Current margin allocation", "Custom": "Custom", "Deduction from collateral": "Deduction from collateral", @@ -35,6 +39,9 @@ "Iceberg": "Iceberg", "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.", "Infrastructure fee": "Infrastructure fee", + "Isolated {{leverage}}x": "Isolated {{leverage}}x", + "Isolated margin": "Isolated margin", + "Leverage": "Leverage", "Limit": "Limit", "Liquidation": "Liquidation", "LIQUIDATION_PRICE_ESTIMATE_TOOLTIP_TEXT": "This is an approximation for the liquidation price for that particular contract position, assuming nothing else changes, which may affect your margin and collateral balances.", @@ -59,6 +66,7 @@ "OCO": "OCO", "One cancels another": "One cancels another", "Only limit orders are permitted when market is in auction": "Only limit orders are permitted when market is in auction", + "Only your allocated margin will be used to fund this position, and if the maintenance margin is breached you will be closed out.": "Only your allocated margin will be used to fund this position, and if the maintenance margin is breached you will be closed out.", "Peak size": "Peak size", "Peak size cannot be greater than the size ({{size}})": "Peak size cannot be greater than the size ({{size}})", "Peak size cannot be lower than {{stepSize}}": "Peak size cannot be lower than {{stepSize}}", @@ -75,6 +83,7 @@ "Public testnet run by the Vega team, often used for incentives": "Public testnet run by the Vega team, often used for incentives", "Reduce only": "Reduce only", "Referral discount": "Referral discount", + "Set the leverage you want below. The maximum leverage you can take is determined by the risk model of the market.": "Set the leverage you want below. The maximum leverage you can take is determined by the risk model of the market.", "Short": "Short", "Size": "Size", "Size cannot be lower than {{sizeStep}}": "Size cannot be lower than {{sizeStep}}", @@ -128,6 +137,8 @@ "VALIDATOR_TESTNET": "VALIDATOR_TESTNET", "Volume discount": "Volume discount", "When the order trades and its size falls below this threshold, it will be reset to the peak size and moved to the back of the priority order. Must be less than or equal to peak size, and greater than 0.": "When the order trades and its size falls below this threshold, it will be reset to the peak size and moved to the back of the priority order. Must be less than or equal to peak size, and greater than 0.", + "You are setting this market to cross-margin mode.": "You are setting this market to cross-margin mode.", + "You are setting this market to isolated margin mode.": "You are setting this market to isolated margin mode.", "You have only {{amount}}.": "You have only {{amount}}.", "You may not have enough margin available to open this position.": "You may not have enough margin available to open this position.", "You need {{symbol}} in your wallet to trade in this market.": "You need {{symbol}} in your wallet to trade in this market.", @@ -137,5 +148,6 @@ "You need to connect your own wallet to start trading on this market": "You need to connect your own wallet to start trading on this market", "You need to provide a minimum visible size": "You need to provide a minimum visible size", "You need to provide a peak size": "You need to provide a peak size", - "You need to provide a size": "You need to provide a size" + "You need to provide a size": "You need to provide a size", + "Your max leverage on each position will be determined by the risk model of the market.": "Your max leverage on each position will be determined by the risk model of the market." } diff --git a/libs/i18n/src/locales/en/positions.json b/libs/i18n/src/locales/en/positions.json index 0d6df577a..a37df12cd 100644 --- a/libs/i18n/src/locales/en/positions.json +++ b/libs/i18n/src/locales/en/positions.json @@ -1,11 +1,17 @@ { "Best case": "Best case", + "Cross": "Cross", "Close position": "Close position", "Entry / Mark": "Entry / Mark", + "General account: {{balance}}": "General account: {{balance}}", + "Isolated": "Isolated", "Lifetime loss socialisation deductions: {{losses}}": "Lifetime loss socialisation deductions: {{losses}}", + "Liquidation: {{maintenanceLevel}}": "Liquidation: {{maintenanceLevel}}", "Maintained by network": "Maintained by network", "Margin / Leverage": "Margin / Leverage", + "Margin: {{balance}}": "Margin: {{balance}}", "Market": "Market", + "Order: {{balance}}": "Order: {{balance}}", "No positions": "No positions", "Profit or loss is realised whenever your position is reduced to zero and the margin is released back to your collateral balance. P&L excludes any fees paid.": "Profit or loss is realised whenever your position is reduced to zero and the margin is released back to your collateral balance. P&L excludes any fees paid.", "Read more about loss socialisation": "Read more about loss socialisation", diff --git a/libs/i18n/src/locales/en/web3.json b/libs/i18n/src/locales/en/web3.json index 5aec66447..281edbeec 100644 --- a/libs/i18n/src/locales/en/web3.json +++ b/libs/i18n/src/locales/en/web3.json @@ -43,6 +43,7 @@ "Go to your Ethereum wallet and connect to the network {{networkName}}": "Go to your Ethereum wallet and connect to the network {{networkName}}", "If the network is reset or has an outage, records of your withdrawal may be lost. It is recommended that you save these details in a safe place so you can still complete your withdrawal.": "If the network is reset or has an outage, records of your withdrawal may be lost. It is recommended that you save these details in a safe place so you can still complete your withdrawal.", "Invalid asset source: {{source}}": "Invalid asset source: {{source}}", + "Isolated margin mode, leverage: {{leverage}}x": "Isolated margin mode, leverage: {{leverage}}x", "Loading": "Loading", "MetaMask": "MetaMask", "MetaMask, Brave or other injected web wallet": "MetaMask, Brave or other injected web wallet", @@ -79,6 +80,7 @@ "Transfer": "Transfer", "Transfer complete": "Transfer complete", "Unknown": "Unknown", + "Update margin mode": "Update margin mode", "Vega confirmation": "Vega confirmation", "Vega is confirming your transaction...": "Vega is confirming your transaction...", "Verifying withdrawal approval": "Verifying withdrawal approval", diff --git a/libs/positions/src/lib/positions-data-providers.spec.ts b/libs/positions/src/lib/positions-data-providers.spec.ts index 3f584ddda..d1ba1aa0c 100644 --- a/libs/positions/src/lib/positions-data-providers.spec.ts +++ b/libs/positions/src/lib/positions-data-providers.spec.ts @@ -174,13 +174,13 @@ const marketsData = [ describe('getMetrics && rejoinPositionData', () => { it('returns positions metrics', () => { const positionsRejoined = rejoinPositionData(positions, marketsData); - const metrics = getMetrics(positionsRejoined, accounts || null); + const metrics = getMetrics(positionsRejoined, accounts || null, null); expect(metrics.length).toEqual(2); }); it('calculates metrics', () => { const positionsRejoined = rejoinPositionData(positions, marketsData); - const metrics = getMetrics(positionsRejoined, accounts || null); + const metrics = getMetrics(positionsRejoined, accounts || null, null); expect(metrics[0].assetSymbol).toEqual('tDAI'); expect(metrics[0].averageEntryPrice).toEqual('8993727'); diff --git a/libs/positions/src/lib/positions-data-providers.ts b/libs/positions/src/lib/positions-data-providers.ts index 112fb61e0..ff602a562 100644 --- a/libs/positions/src/lib/positions-data-providers.ts +++ b/libs/positions/src/lib/positions-data-providers.ts @@ -1,19 +1,26 @@ import isEqual from 'lodash/isEqual'; import produce from 'immer'; -import BigNumber from 'bignumber.js'; import sortBy from 'lodash/sortBy'; -import { type Account } from '@vegaprotocol/accounts'; +import { + marginsDataProvider, + type Account, + type MarginFieldsFragment, + marketMarginDataProvider, +} from '@vegaprotocol/accounts'; import { accountsDataProvider } from '@vegaprotocol/accounts'; import { toBigNum, removePaginationWrapper } from '@vegaprotocol/utils'; import { makeDataProvider, makeDerivedDataProvider, + useDataProvider, } from '@vegaprotocol/data-provider'; import { type MarketMaybeWithData, type MarketDataQueryVariables, allMarketsWithLiveDataProvider, getAsset, + marketInfoProvider, + type MarketInfo, } from '@vegaprotocol/markets'; import { PositionsDocument, @@ -26,6 +33,7 @@ import { } from './__generated__/Positions'; import { AccountType, + MarginMode, MarketState, type MarketTradingMode, type PositionStatus, @@ -33,6 +41,8 @@ import { } from '@vegaprotocol/types'; export interface Position { + marginMode: MarginFieldsFragment['marginMode']; + maintenanceLevel: MarginFieldsFragment['maintenanceLevel'] | undefined; assetId: string; assetSymbol: string; averageEntryPrice: string; @@ -41,6 +51,8 @@ export interface Position { quantum: string; lossSocializationAmount: string; marginAccountBalance: string; + orderAccountBalance: string; + generalAccountBalance: string; marketDecimalPlaces: number; marketId: string; marketCode: string; @@ -61,7 +73,8 @@ export interface Position { export const getMetrics = ( data: ReturnType | null, - accounts: Account[] | null + accounts: Account[] | null, + margins: MarginFieldsFragment[] | null ): Position[] => { if (!data || !data?.length) { return []; @@ -75,8 +88,20 @@ export const getMetrics = ( } const marketData = market?.data; + const margin = margins?.find((margin) => { + return margin.market?.id === market?.id; + }); const marginAccount = accounts?.find((account) => { - return account.market?.id === market?.id; + return ( + account.market?.id === market?.id && + account.type === AccountType.ACCOUNT_TYPE_MARGIN + ); + }); + const orderAccount = accounts?.find((account) => { + return ( + account.market?.id === market?.id && + account.type === AccountType.ACCOUNT_TYPE_ORDER_MARGIN + ); }); const asset = getAsset(market); const generalAccount = accounts?.find( @@ -93,6 +118,10 @@ export const getMetrics = ( marginAccount?.balance ?? 0, asset.decimals ); + const orderAccountBalance = toBigNum( + orderAccount?.balance ?? 0, + asset.decimals + ); const generalAccountBalance = toBigNum( generalAccount?.balance ?? 0, asset.decimals @@ -107,21 +136,33 @@ export const getMetrics = ( : openVolume.multipliedBy(-1) ).multipliedBy(markPrice) : undefined; - const totalBalance = marginAccountBalance.plus(generalAccountBalance); - const currentLeverage = notional - ? totalBalance.isEqualTo(0) - ? new BigNumber(0) - : notional.dividedBy(totalBalance) - : undefined; + const totalBalance = marginAccountBalance + .plus(generalAccountBalance) + .plus(orderAccountBalance); + const marginMode = + margin?.marginMode || MarginMode.MARGIN_MODE_CROSS_MARGIN; + const marginFactor = margin?.marginFactor; + const currentLeverage = + marginMode === MarginMode.MARGIN_MODE_ISOLATED_MARGIN + ? (marginFactor && 1 / Number(marginFactor)) || undefined + : notional + ? totalBalance.isEqualTo(0) + ? 0 + : notional.dividedBy(totalBalance).toNumber() + : undefined; metrics.push({ + marginMode, + maintenanceLevel: margin?.maintenanceLevel, assetId: asset.id, assetSymbol: asset.symbol, averageEntryPrice: position.averageEntryPrice, - currentLeverage: currentLeverage ? currentLeverage.toNumber() : undefined, + currentLeverage, assetDecimals: asset.decimals, quantum: asset.quantum, lossSocializationAmount: position.lossSocializationAmount || '0', marginAccountBalance: marginAccount?.balance ?? '0', + orderAccountBalance: orderAccount?.balance ?? '0', + generalAccountBalance: generalAccount?.balance ?? '0', marketDecimalPlaces, marketId: market.id, marketCode: market.tradableInstrument.instrument.code, @@ -291,6 +332,9 @@ export const positionsMarketsProvider = makeDerivedDataProvider< ).sort(); }); +const firstOrSelf = (partyIds: string | string[]) => + Array.isArray(partyIds) ? partyIds[0] : partyIds; + export const positionsMetricsProvider = makeDerivedDataProvider< Position[], Position[], @@ -301,18 +345,24 @@ export const positionsMetricsProvider = makeDerivedDataProvider< positionsDataProvider(callback, client, { partyIds: variables.partyIds }), (callback, client, variables) => accountsDataProvider(callback, client, { - partyId: Array.isArray(variables.partyIds) - ? variables.partyIds[0] - : variables.partyIds, + partyId: firstOrSelf(variables.partyIds), }), (callback, client, variables) => allMarketsWithLiveDataProvider(callback, client, { marketIds: variables.marketIds, }), + (callback, client, variables) => + marginsDataProvider(callback, client, { + partyId: firstOrSelf(variables.partyIds), + }), ], - ([positions, accounts, marketsData], variables) => { + ([positions, accounts, marketsData, margins], variables) => { const positionsData = rejoinPositionData(positions, marketsData); - const metrics = getMetrics(positionsData, accounts as Account[] | null); + const metrics = getMetrics( + positionsData, + accounts as Account[] | null, + margins + ); return preparePositions(metrics, variables.showClosed); }, (data, delta, previousData) => @@ -323,3 +373,67 @@ export const positionsMetricsProvider = makeDerivedDataProvider< return !(previousRow && isEqual(previousRow, row)); }) ); + +export const maxLeverageProvider = makeDerivedDataProvider< + number, + never, + { partyId: string; marketId: string } +>( + [ + (callback, client, { marketId }) => + marketInfoProvider(callback, client, { marketId }), + (callback, client, { marketId, partyId }) => + positionDataProvider(callback, client, { partyIds: partyId, marketId }), + marketMarginDataProvider, + ], + (parts) => { + const market: MarketInfo | null = parts[0]; + const position: PositionFieldsFragment | null = parts[1]; + const margin: MarginFieldsFragment | null = parts[2]; + if (!market || !market?.riskFactors) { + return 1; + } + const maxLeverage = + 1 / + (Math.max( + Number(market.riskFactors.long), + Number(market.riskFactors.short) + ) || 1); + + if ( + market && + position?.openVolume && + position?.openVolume !== '0' && + margin + ) { + const asset = getAsset(market); + const { positionDecimalPlaces, decimalPlaces: marketDecimalPlaces } = + market; + const openVolume = toBigNum( + position.openVolume.replace(/^-/, ''), + positionDecimalPlaces + ); + const averageEntryPrice = toBigNum( + position.averageEntryPrice, + marketDecimalPlaces + ); + // https://github.com/vegaprotocol/specs/blob/nebula/protocol/0019-MCAL-margin_calculator.md#isolated-margin-mode + return Math.min( + averageEntryPrice + .multipliedBy(openVolume) + .dividedBy(toBigNum(margin.initialLevel, asset.decimals)) + .toNumber(), + maxLeverage + ); + } + return maxLeverage; + } +); + +export const useMaxLeverage = (marketId: string, partyId?: string) => { + return useDataProvider({ + dataProvider: maxLeverageProvider, + variables: { marketId, partyId: partyId || '' }, + skip: !partyId, + }); +}; diff --git a/libs/positions/src/lib/positions-table.tsx b/libs/positions/src/lib/positions-table.tsx index c325fc21c..14e2f0516 100644 --- a/libs/positions/src/lib/positions-table.tsx +++ b/libs/positions/src/lib/positions-table.tsx @@ -21,6 +21,7 @@ import { VegaIcon, VegaIconNames, Tooltip, + Lozenge, } from '@vegaprotocol/ui-toolkit'; import { volumePrefix, @@ -31,14 +32,17 @@ import { } from '@vegaprotocol/utils'; import { type Position } from './positions-data-providers'; import { + MarginMode, MarketTradingMode, PositionStatus, PositionStatusMapping, } from '@vegaprotocol/types'; -import { DocsLinks } from '@vegaprotocol/environment'; +import { DocsLinks, useFeatureFlags } from '@vegaprotocol/environment'; import { PositionActionsDropdown } from './position-actions-dropdown'; import { LiquidationPrice } from './liquidation-price'; import { useT } from '../use-t'; +import classnames from 'classnames'; +import BigNumber from 'bignumber.js'; interface Props extends TypedDataAgGrid { onClose?: (data: Position) => void; @@ -74,6 +78,126 @@ const defaultColDef = { minWidth: 110, }; +interface MarginChartProps { + width?: number; + label: string; + other?: string; + marker?: number; + markerLabel?: string; + className?: string; +} + +const MarginChart = ({ + width, + label, + other, + marker, + markerLabel, + className, +}: MarginChartProps) => { + return ( +
+ {markerLabel ? ( +
{markerLabel}
+ ) : null} +
+
+ {marker ? ( +
+ ) : null} +
+
+
{label}
+ {other ?
{other}
: null} +
+
+ ); +}; + +const PositionMargin = ({ data }: { data: Position }) => { + const t = useT(); + const max = + data.marginMode === MarginMode.MARGIN_MODE_CROSS_MARGIN + ? ( + BigInt(data.marginAccountBalance) + BigInt(data.generalAccountBalance) + ).toString() + : BigInt(data.marginAccountBalance) > BigInt(data.orderAccountBalance) + ? data.marginAccountBalance + : data.orderAccountBalance; + const getWidth = (balance: string) => + BigNumber(balance).multipliedBy(100).dividedBy(max).toNumber(); + const inCrossMode = data.marginMode === MarginMode.MARGIN_MODE_CROSS_MARGIN; + const hasOrderAccountBalance = + !inCrossMode && data.orderAccountBalance !== '0'; + + return ( + <> + + {hasOrderAccountBalance ? ( + + ) : null} + + ); +}; + export const PositionsTable = ({ onClose, onMarketClick, @@ -83,6 +207,7 @@ export const PositionsTable = ({ pubKey, ...props }: Props) => { + const featureFlags = useFeatureFlags((state) => state.flags); const t = useT(); const colDefs = useMemo(() => { @@ -132,7 +257,8 @@ export const PositionsTable = ({ cellClass: 'font-mono text-right', cellClassRules: signedNumberCssClassRules, filter: 'agNumberColumnFilter', - valueGetter: ({ data }: { data: Position }) => { + sortable: false, + filterValueGetter: ({ data }: { data: Position }) => { return data?.openVolume === undefined ? undefined : toBigNum(data?.openVolume, data.positionDecimalPlaces).toNumber(); @@ -209,7 +335,8 @@ export const PositionsTable = ({ type: 'rightAligned', cellClass: 'font-mono text-right', filter: 'agNumberColumnFilter', - valueGetter: ({ data }: VegaValueGetterParams) => { + sortable: false, + filterValueGetter: ({ data }: VegaValueGetterParams) => { return !data ? undefined : toBigNum( @@ -233,7 +360,35 @@ export const PositionsTable = ({ const lev = data?.currentLeverage ? data.currentLeverage : 1; const leverage = formatNumber(Math.max(1, lev), 1); - return ; + return ( + + ) + } + > +
+ + {featureFlags.ISOLATED_MARGIN && ( + + {data?.marginMode === + MarginMode.MARGIN_MODE_ISOLATED_MARGIN + ? t('Isolated') + : t('Cross')} + + )} + {leverage}x + + } + /> +
+
+ ); }, }, { @@ -406,7 +561,16 @@ export const PositionsTable = ({ return columnDefs.filter( (colDef: ColDef | null): colDef is ColDef => colDef !== null ); - }, [isReadOnly, multipleKeys, onClose, onMarketClick, pubKey, pubKeys, t]); + }, [ + isReadOnly, + multipleKeys, + onClose, + onMarketClick, + pubKey, + pubKeys, + t, + featureFlags.ISOLATED_MARGIN, + ]); return ( ; + /** Unique ID of the market. */ + marketId: Scalars['ID']; + /** Maximum theoretical leverage for the market. Isolated mode only. */ + max_theoretical_leverage?: Maybe; + /** Minimum theoretical margin factor for the market. Isolated mode only. */ + min_theoretical_margin_factor?: Maybe; + /** Unique ID of the party. */ + partyId: Scalars['ID']; +}; + +/** Edge type containing the deposit and cursor information returned by a PartyMarginModeConnection */ +export type PartyMarginModeEdge = { + __typename?: 'PartyMarginModeEdge'; + cursor: Scalars['String']; + node: PartyMarginMode; +}; + +/** Connection type for retrieving cursor-based paginated party margin modes information */ +export type PartyMarginModesConnection = { + __typename?: 'PartyMarginModesConnection'; + /** The party margin modes */ + edges?: Maybe>>; + /** The pagination information */ + pageInfo?: Maybe; +}; + /** * All staking information related to a Party. * Contains the current recognised balance by the network and @@ -4438,6 +4498,12 @@ export type Query = { partiesConnection?: Maybe; /** An entity that is trading on the Vega network */ party?: Maybe; + /** + * List margin modes per party per market + * + * Get a list of all margin modes, or for a specific market ID, or party ID. + */ + partyMarginModes?: Maybe; /** Fetch all positions */ positions?: Maybe; /** A governance proposal located by either its ID or reference. If both are set, ID is used. */ @@ -6213,6 +6279,8 @@ export enum TransferType { TRANSFER_TYPE_INFRASTRUCTURE_FEE_DISTRIBUTE = 'TRANSFER_TYPE_INFRASTRUCTURE_FEE_DISTRIBUTE', /** Infrastructure fee paid from general account */ TRANSFER_TYPE_INFRASTRUCTURE_FEE_PAY = 'TRANSFER_TYPE_INFRASTRUCTURE_FEE_PAY', + /** Funds moved from order margin account to margin account. */ + TRANSFER_TYPE_ISOLATED_MARGIN_LOW = 'TRANSFER_TYPE_ISOLATED_MARGIN_LOW', /** Allocates liquidity fee earnings to each liquidity provider's network controlled liquidity fee account. */ TRANSFER_TYPE_LIQUIDITY_FEE_ALLOCATE = 'TRANSFER_TYPE_LIQUIDITY_FEE_ALLOCATE', /** Liquidity fee received into general account */ @@ -6239,6 +6307,10 @@ export enum TransferType { TRANSFER_TYPE_MTM_LOSS = 'TRANSFER_TYPE_MTM_LOSS', /** Funds added to margin account after mark to market gain */ TRANSFER_TYPE_MTM_WIN = 'TRANSFER_TYPE_MTM_WIN', + /** Funds released from order margin account to general. */ + TRANSFER_TYPE_ORDER_MARGIN_HIGH = 'TRANSFER_TYPE_ORDER_MARGIN_HIGH', + /** Funds moved from general account to order margin account. */ + TRANSFER_TYPE_ORDER_MARGIN_LOW = 'TRANSFER_TYPE_ORDER_MARGIN_LOW', /** Funds deducted from margin account after a perpetuals funding loss. */ TRANSFER_TYPE_PERPETUALS_FUNDING_LOSS = 'TRANSFER_TYPE_PERPETUALS_FUNDING_LOSS', /** Funds added to margin account after a perpetuals funding gain. */ diff --git a/libs/types/src/global-types-mappings.ts b/libs/types/src/global-types-mappings.ts index 77b9ec0a6..7ce958f93 100644 --- a/libs/types/src/global-types-mappings.ts +++ b/libs/types/src/global-types-mappings.ts @@ -38,6 +38,7 @@ import type { ProductType, ProposalProductType } from './product'; export const AccountTypeMapping: { [T in AccountType]: string; } = { + ACCOUNT_TYPE_ORDER_MARGIN: 'Per asset market account', ACCOUNT_TYPE_BOND: 'Bond account', ACCOUNT_TYPE_EXTERNAL: 'External account', ACCOUNT_TYPE_FEES_INFRASTRUCTURE: 'Infrastructure fees account', @@ -211,6 +212,8 @@ export const OrderRejectionReasonMapping: { ORDER_ERROR_INVALID_SIZE: 'Invalid size', ORDER_ERROR_INVALID_TIME_IN_FORCE: 'Invalid time in force', ORDER_ERROR_INVALID_TYPE: 'Invalid type', + ORDER_ERROR_ISOLATED_MARGIN_CHECK_FAILED: + 'Party has insufficient funds to cover for the order margin for the new or amended order', ORDER_ERROR_MARGIN_CHECK_FAILED: 'Margin check failed', ORDER_ERROR_MARKET_CLOSED: 'Market closed', ORDER_ERROR_MISSING_GENERAL_ACCOUNT: 'Missing general account', @@ -221,6 +224,8 @@ export const OrderRejectionReasonMapping: { ORDER_ERROR_NOT_FOUND: 'Not found', ORDER_ERROR_OFFSET_MUST_BE_GREATER_OR_EQUAL_TO_ZERO: 'Offset must be greater or equal to zero', + ORDER_ERROR_PEGGED_ORDERS_NOT_ALLOWED_IN_ISOLATED_MARGIN_MODE: + 'Pegged orders are not allowed for a party in isolated margin mode', ORDER_ERROR_OFFSET_MUST_BE_GREATER_THAN_ZERO: 'Offset must be greater than zero', ORDER_ERROR_OUT_OF_SEQUENCE: 'Out of sequence', @@ -475,6 +480,9 @@ export const TransferTypeMapping: TransferTypeMap = { TRANSFER_TYPE_WIN: 'Final settlement gain', TRANSFER_TYPE_MTM_LOSS: 'Mark to market loss', TRANSFER_TYPE_MTM_WIN: 'Mark to market gain', + TRANSFER_TYPE_ORDER_MARGIN_HIGH: 'From order margin account to general', + TRANSFER_TYPE_ORDER_MARGIN_LOW: + 'From general account to order margin account', TRANSFER_TYPE_MARGIN_LOW: 'Margin topped up', TRANSFER_TYPE_MARGIN_HIGH: 'Margin returned', TRANSFER_TYPE_MARGIN_CONFISCATED: 'Margin confiscated', @@ -482,6 +490,8 @@ export const TransferTypeMapping: TransferTypeMap = { TRANSFER_TYPE_MAKER_FEE_RECEIVE: 'Maker fee received', TRANSFER_TYPE_INFRASTRUCTURE_FEE_PAY: 'Infrastructure fee paid', TRANSFER_TYPE_INFRASTRUCTURE_FEE_DISTRIBUTE: 'Infrastructure fee distributed', + TRANSFER_TYPE_ISOLATED_MARGIN_LOW: + 'From order margin account to margin account', TRANSFER_TYPE_LIQUIDITY_FEE_PAY: 'Liquidity fee paid', TRANSFER_TYPE_LIQUIDITY_FEE_DISTRIBUTE: 'Liquidity fee received', TRANSFER_TYPE_BOND_LOW: 'Bond account funded', @@ -515,6 +525,10 @@ export const DescriptionTransferTypeMapping: TransferTypeMap = { TRANSFER_TYPE_WIN: `Funds added to your general account after final settlement gain`, TRANSFER_TYPE_MTM_LOSS: `Funds deducted from your margin account after mark to market loss`, TRANSFER_TYPE_MTM_WIN: `Funds added to your margin account after mark to market gain`, + TRANSFER_TYPE_ORDER_MARGIN_HIGH: + 'Funds released from order margin account to general', + TRANSFER_TYPE_ORDER_MARGIN_LOW: + 'Funds moved from general account to order margin account', TRANSFER_TYPE_MARGIN_LOW: `Funds deducted from your general account to meet margin requirement`, TRANSFER_TYPE_MARGIN_HIGH: `Excess margin amount returned to your general account`, TRANSFER_TYPE_MARGIN_CONFISCATED: `Margin confiscated from your margin account to fulfil closeout`, @@ -522,6 +536,8 @@ export const DescriptionTransferTypeMapping: TransferTypeMap = { TRANSFER_TYPE_MAKER_FEE_RECEIVE: `Maker fee received into your general account when your passive order was filled`, TRANSFER_TYPE_INFRASTRUCTURE_FEE_PAY: `Infrastructure fee paid from your general account when your order was filled`, TRANSFER_TYPE_INFRASTRUCTURE_FEE_DISTRIBUTE: `Infrastructure fee received: Infrastructure fee, paid by traders, received into your general account`, + TRANSFER_TYPE_ISOLATED_MARGIN_LOW: + 'Funds moved from order margin account to margin account', TRANSFER_TYPE_LIQUIDITY_FEE_PAY: `Liquidity fee paid from your general account to market's liquidity providers`, TRANSFER_TYPE_LIQUIDITY_FEE_DISTRIBUTE: `Liquidity fee received into your general account from traders`, TRANSFER_TYPE_BOND_LOW: `Funds deducted from your general account to meet your required liquidity bond amount`, diff --git a/libs/ui-toolkit/src/components/slider/index.ts b/libs/ui-toolkit/src/components/slider/index.ts index eb0742f80..dff5519db 100644 --- a/libs/ui-toolkit/src/components/slider/index.ts +++ b/libs/ui-toolkit/src/components/slider/index.ts @@ -1 +1,2 @@ export * from './slider'; +export * from './leverage-slider'; diff --git a/libs/wallet/src/connectors/vega-connector.ts b/libs/wallet/src/connectors/vega-connector.ts index 762227b9a..789b33dce 100644 --- a/libs/wallet/src/connectors/vega-connector.ts +++ b/libs/wallet/src/connectors/vega-connector.ts @@ -451,7 +451,22 @@ export type CreateReferralSet = { }; }; +export enum MarginMode { + MARGIN_MODE_CROSS_MARGIN = 1, + MARGIN_MODE_ISOLATED_MARGIN, +} +export interface UpdateMarginMode { + market_id: string; + mode: MarginMode; + marginFactor?: string; +} + +export interface UpdateMarginModeBody { + updateMarginMode: UpdateMarginMode; +} + export type Transaction = + | UpdateMarginModeBody | StopOrdersSubmissionBody | StopOrdersCancellationBody | OrderSubmissionBody @@ -468,6 +483,10 @@ export type Transaction = | ApplyReferralCode | CreateReferralSet; +export const isMarginModeUpdateTransaction = ( + transaction: Transaction +): transaction is UpdateMarginModeBody => 'updateMarginMode' in transaction; + export const isWithdrawTransaction = ( transaction: Transaction ): transaction is WithdrawSubmissionBody => 'withdrawSubmission' in transaction; diff --git a/libs/web3/src/lib/use-vega-transaction-store.tsx b/libs/web3/src/lib/use-vega-transaction-store.tsx index b39ac17ab..0ae4a2a0e 100644 --- a/libs/web3/src/lib/use-vega-transaction-store.tsx +++ b/libs/web3/src/lib/use-vega-transaction-store.tsx @@ -10,6 +10,7 @@ import { isStopOrdersSubmissionTransaction, isStopOrdersCancellationTransaction, determineId, + isMarginModeUpdateTransaction, } from '@vegaprotocol/wallet'; import { create } from 'zustand'; @@ -58,7 +59,7 @@ export interface VegaTransactionStore { export const useVegaTransactionStore = create()( subscribeWithSelector((set, get) => ({ - transactions: [] as VegaStoredTxState[], + transactions: [] as (VegaStoredTxState | undefined)[], create: (body: Transaction, order?: OrderTxUpdateFieldsFragment) => { const transactions = get().transactions; const now = new Date(); @@ -205,16 +206,22 @@ export const useVegaTransactionStore = create()( isStopOrdersCancellationTransaction(transaction.body); const isConfirmedStopOrderSubmission = isStopOrdersSubmissionTransaction(transaction.body); + const isConfirmedMarginModeTransaction = + isMarginModeUpdateTransaction(transaction.body); if ( - (isConfirmedOrderCancellation || - isConfirmedTransfer || - isConfirmedStopOrderCancellation || - isConfirmedStopOrderSubmission) && - !transactionResult.error && - transactionResult.status + isConfirmedOrderCancellation || + isConfirmedTransfer || + isConfirmedStopOrderCancellation || + isConfirmedStopOrderSubmission || + isConfirmedMarginModeTransaction ) { - transaction.status = VegaTxStatus.Complete; + if (transactionResult.error) { + transaction.status = VegaTxStatus.Error; + transaction.error = new Error(transactionResult.error); + } else if (transactionResult.status) { + transaction.status = VegaTxStatus.Complete; + } } transaction.dialogOpen = true; transaction.updatedAt = new Date(); diff --git a/libs/web3/src/lib/use-vega-transaction-toasts.tsx b/libs/web3/src/lib/use-vega-transaction-toasts.tsx index ef73de2e4..e3dd7bfaa 100644 --- a/libs/web3/src/lib/use-vega-transaction-toasts.tsx +++ b/libs/web3/src/lib/use-vega-transaction-toasts.tsx @@ -7,6 +7,7 @@ import type { OrderSubmission, StopOrdersSubmission, StopOrderSetup, + UpdateMarginMode, } from '@vegaprotocol/wallet'; import type { OrderTxUpdateFieldsFragment, @@ -26,6 +27,8 @@ import { isStopOrdersSubmissionTransaction, isStopOrdersCancellationTransaction, isReferralRelatedTransaction, + isMarginModeUpdateTransaction, + MarginMode, } from '@vegaprotocol/wallet'; import { useVegaTransactionStore } from './use-vega-transaction-store'; import { VegaTxStatus } from './types'; @@ -163,6 +166,7 @@ const isClosePositionTransaction = (tx: VegaStoredTxState) => { }; const isTransactionTypeSupported = (tx: VegaStoredTxState) => { + const marginModeUpdate = isMarginModeUpdateTransaction(tx.body); const withdraw = isWithdrawTransaction(tx.body); const submitOrder = isOrderSubmissionTransaction(tx.body); const cancelOrder = isOrderCancellationTransaction(tx.body); @@ -173,6 +177,7 @@ const isTransactionTypeSupported = (tx: VegaStoredTxState) => { const transfer = isTransferTransaction(tx.body); const referral = isReferralRelatedTransaction(tx.body); return ( + marginModeUpdate || withdraw || submitOrder || cancelOrder || @@ -445,6 +450,27 @@ const CancelOrderDetails = ({ ); }; +const MarginModeDetails = ({ data }: { data: UpdateMarginMode }) => { + const t = useT(); + const { data: markets } = useMarketsMapProvider(); + const marketId = data.market_id; + const market = marketId && markets?.[marketId]; + if (!market) { + return null; + } + return ( + +

{t('Update margin mode')}

+

{market?.tradableInstrument.instrument.code}

+ {data.mode === MarginMode.MARGIN_MODE_CROSS_MARGIN + ? t('Cross margin mode') + : t('Isolated margin mode, leverage: {{leverage}}x', { + leverage: (1 / Number(data.marginFactor)).toFixed(1), + })} +
+ ); +}; + const CancelStopOrderDetails = ({ stopOrderId }: { stopOrderId: string }) => { const t = useT(); const formatTrigger = useFormatTrigger(); @@ -598,6 +624,10 @@ export const VegaTransactionDetails = ({ tx }: { tx: VegaStoredTxState }) => { ); } + if (isMarginModeUpdateTransaction(tx.body)) { + return ; + } + if (isClosePositionTransaction(tx)) { const transaction = tx.body as BatchMarketInstructionSubmissionBody; const marketId = first(