diff --git a/package.json b/package.json index de95c3931..8d6fe33ee 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "@cosmjs/proto-signing": "^0.32.1", "@cosmjs/stargate": "^0.32.1", "@cosmjs/tendermint-rpc": "^0.32.1", - "@dydxprotocol/v4-abacus": "1.7.69", + "@dydxprotocol/v4-abacus": "^1.7.72", "@dydxprotocol/v4-client-js": "^1.1.15", "@dydxprotocol/v4-localization": "^1.1.118", "@ethersproject/providers": "^5.7.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63ea3dba0..0f93eca18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,8 +30,8 @@ dependencies: specifier: ^0.32.1 version: 0.32.2 '@dydxprotocol/v4-abacus': - specifier: 1.7.69 - version: 1.7.69 + specifier: ^1.7.72 + version: 1.7.72 '@dydxprotocol/v4-client-js': specifier: ^1.1.15 version: 1.1.15 @@ -1376,8 +1376,8 @@ packages: '@jridgewell/trace-mapping': 0.3.9 dev: true - /@dydxprotocol/v4-abacus@1.7.69: - resolution: {integrity: sha512-W9FceQ0wuQhf68LRrINON7+kfguZwjAzJDiBIyhH7qxF7JxKGXGsji9pWqdrE5tgX6556pUyiqbAqfXmjOc6lQ==} + /@dydxprotocol/v4-abacus@1.7.72: + resolution: {integrity: sha512-ie6ynsIwBNuAIKZgrOtMFy74tudEm26ruIW2y1816u/c37VzIG1R7ZsKDXFdZEDGlIjzXK0D16NIVR8jEEleqw==} dependencies: '@js-joda/core': 3.2.0 format-util: 1.0.5 diff --git a/public/configs/v1/env.json b/public/configs/v1/env.json index 3b1aedf25..33f8f0081 100644 --- a/public/configs/v1/env.json +++ b/public/configs/v1/env.json @@ -73,6 +73,7 @@ "accountExportLearnMore": "https://help.dydx.exchange/en/articles/8565867-secret-phrase-on-dydx-chain", "walletLearnMore": "https://www.dydx.academy/video/defi-wallet", "withdrawalGateLearnMore": "https://help.dydx.exchange/en/articles/8981384-withdrawals-on-dydx-chain#h_23e97bc665", + "adjustTargetLeverageLearnMore": "", "launchIncentive": "https://cloud.chaoslabs.co", "tradingRewardsLearnMore": "https://docs.dydx.exchange/concepts-trading/rewards_fees_and_parameters", "exchangeStats": "https://app.mode.com/dydx_eng/reports/58822121650d?secret_key=391d9214fe6aefec35b7d35c", @@ -101,6 +102,7 @@ "keplrDashboard": "https://testnet.keplr.app/", "strideZoneApp": "https://testnet.stride.zone", "accountExportLearnMore": "https://help.dydx.exchange/en/articles/8565867-secret-phrase-on-dydx-chain", + "adjustTargetLeverageLearnMore": "", "walletLearnMore": "https://www.dydx.academy/video/defi-wallet", "withdrawalGateLearnMore": "https://help.dydx.exchange/en/articles/8981384-withdrawals-on-dydx-chain#h_23e97bc665", "launchIncentive": "https://cloud.chaoslabs.co", @@ -131,6 +133,7 @@ "keplrDashboard": "[HTTP link to keplr dashboard, can be null]", "strideZoneApp": "[HTTP link to stride zone app, can be null]", "accountExportLearnMore": "[HTTP link to account export learn more, can be null]", + "adjustTargetLeverageLearnMore": "[HTTP link to adjust target leverage learn more, can be null]", "walletLearnMore": "[HTTP link to wallet learn more, can be null]", "withdrawalGateLearnMore": "[HTTP link to withdrawal gate learn more, can be null]", "launchIncentive": "[HTTP link to launch incentive host, can be null]", diff --git a/src/components/Link.tsx b/src/components/Link.tsx index 5531379f8..2d026a96e 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -61,6 +61,7 @@ const $A = styled.a` ${layoutMixins.spacedRow} gap: 0.25em; + cursor: pointer; &:hover { text-decoration: underline; diff --git a/src/components/PotentialPositionCard.tsx b/src/components/PotentialPositionCard.tsx index c477f23cc..f51206d5b 100644 --- a/src/components/PotentialPositionCard.tsx +++ b/src/components/PotentialPositionCard.tsx @@ -1,36 +1,63 @@ +import { useCallback } from 'react'; + import styled from 'styled-components'; +import { SubaccountPendingPosition } from '@/constants/abacus'; +import { DialogTypes } from '@/constants/dialogs'; import { STRING_KEYS } from '@/constants/localization'; import { useStringGetter } from '@/hooks/useStringGetter'; import { layoutMixins } from '@/styles/layoutMixins'; +import { useAppDispatch } from '@/state/appTypes'; +import { openDialog } from '@/state/dialogs'; + import { AssetIcon } from './AssetIcon'; import { Icon, IconName } from './Icon'; import { Link } from './Link'; import { Output, OutputType } from './Output'; type PotentialPositionCardProps = { + marketName: string; onViewOrders: (marketId: string) => void; + pendingPosition: SubaccountPendingPosition; }; -export const PotentialPositionCard = ({ onViewOrders }: PotentialPositionCardProps) => { +export const PotentialPositionCard = ({ + marketName, + onViewOrders, + pendingPosition, +}: PotentialPositionCardProps) => { + const dispatch = useAppDispatch(); + const onCancelOrders = useCallback( + (marketId: string) => { + dispatch(openDialog({ type: DialogTypes.CancelPendingOrders, dialogProps: { marketId } })); + }, + [dispatch] + ); + const stringGetter = useStringGetter(); + const { assetId, freeCollateral, marketId, orderCount } = pendingPosition; return ( <$PotentialPositionCard> <$MarketRow> - Market Name + + {marketName} <$MarginRow> <$MarginLabel>{stringGetter({ key: STRING_KEYS.MARGIN })}{' '} - <$Output type={OutputType.Fiat} value={1_000} /> + <$Output type={OutputType.Fiat} value={freeCollateral?.current} /> <$ActionRow> - <$Link onClick={() => onViewOrders('UNI-USD')}> - {stringGetter({ key: STRING_KEYS.VIEW_ORDERS })} + <$Link onClick={() => onViewOrders(marketId)}> + {stringGetter({ key: orderCount > 1 ? STRING_KEYS.VIEW_ORDERS : STRING_KEYS.VIEW })}{' '} + + <$CancelLink onClick={() => onCancelOrders(marketId)}> + {stringGetter({ key: orderCount > 1 ? STRING_KEYS.CANCEL_ORDERS : STRING_KEYS.CANCEL })}{' '} + ); @@ -46,6 +73,7 @@ const $PotentialPositionCard = styled.div` padding: 0.75rem 0; border-radius: 0.625rem; `; + const $MarketRow = styled.div` ${layoutMixins.row} gap: 0.5rem; @@ -56,18 +84,22 @@ const $MarketRow = styled.div` font-size: 1.25rem; // 20px x 20px } `; + const $MarginRow = styled.div` ${layoutMixins.spacedRow} padding: 0 0.625rem; margin-top: 0.625rem; `; + const $MarginLabel = styled.span` color: var(--color-text-0); font: var(--font-mini-book); `; + const $Output = styled(Output)` font: var(--font-small-book); `; + const $ActionRow = styled.div` ${layoutMixins.spacedRow} border-top: var(--border); @@ -75,7 +107,13 @@ const $ActionRow = styled.div` padding: 0 0.625rem; padding-top: 0.5rem; `; + const $Link = styled(Link)` --link-color: var(--color-accent); font: var(--font-small-book); `; + +const $CancelLink = styled(Link)` + --link-color: var(--color-risk-high); + font: var(--font-small-book); +`; diff --git a/src/components/RadioButtonCards.tsx b/src/components/RadioButtonCards.tsx index 3c3ef08db..ca5eb7ae3 100644 --- a/src/components/RadioButtonCards.tsx +++ b/src/components/RadioButtonCards.tsx @@ -54,6 +54,8 @@ const $Root = styled(Root)` --radio-button-cards-item-checked-backgroundColor: ; --radio-button-cards-item-disabled-backgroundColor: ; --radio-button-cards-item-backgroundColor: ; + --radio-button-cards-item-header-font: ; + --radio-button-cards-item-body-font: ; `; const $RadioButtonCard = styled(Item)` @@ -63,7 +65,7 @@ const $RadioButtonCard = styled(Item)` background-color: var(--radio-button-cards-item-backgroundColor, transparent); border: 1px solid var(--color-layer-6); padding: var(--radio-button-cards-item-padding, 1rem); - font: var(--font-mini-book); + font: var(--radio-buttons-cards-item-body-font, var(--font-mini-book)); text-align: left; gap: var(--radio-button-cards-item-gap, 0.5rem); @@ -83,7 +85,7 @@ const $CardHeader = styled.div` align-self: stretch; align-items: center; color: var(--color-text-2); - font: var(--font-base-medium); + font: var(--radio-button-cards-item-header-font, var(--font-base-medium)); justify-content: space-between; gap: 1rem; `; diff --git a/src/components/Table.tsx b/src/components/Table.tsx index 2e64646b7..ca99b39da 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -156,7 +156,7 @@ export const Table = ({ const currentBreakpoints = useBreakpoints(); const shownColumns = columns.filter( - ({ hideOnBreakpoint }) => !hideOnBreakpoint || !currentBreakpoints[hideOnBreakpoint as string] + ({ hideOnBreakpoint }) => !hideOnBreakpoint || !currentBreakpoints[hideOnBreakpoint] ); const collator = useCollator(); diff --git a/src/components/Tabs.tsx b/src/components/Tabs.tsx index ea9deb63d..e7d850056 100644 --- a/src/components/Tabs.tsx +++ b/src/components/Tabs.tsx @@ -12,6 +12,8 @@ import { DropdownSelectMenu } from '@/components/DropdownSelectMenu'; import { Tag } from '@/components/Tag'; import { Toolbar } from '@/components/Toolbar'; +import { getSimpleStyledOutputType } from '@/lib/genericFunctionalComponentUtils'; + export type TabItem = { value: TabItemsValue; label: React.ReactNode; @@ -31,6 +33,7 @@ type ElementProps = { items: TabItem[]; slotToolbar?: ReactNode; sharedContent?: ReactNode; + disabled?: boolean; onValueChange?: (value: TabItemsValue) => void; onWheel?: (event: React.WheelEvent) => void; }; @@ -57,6 +60,7 @@ export const Tabs = ({ side = 'top', withBorders = true, withTransitions = true, + disabled = false, className, }: ElementProps & StyleProps) => { const currentItem = items.find((item) => item.value === value); @@ -67,7 +71,12 @@ export const Tabs = ({ {items.map((item) => !item.subitems ? ( item.customTrigger ?? ( - <$Trigger key={item.value} value={item.value} $withBorders={withBorders}> + <$Trigger + key={item.value} + value={item.value} + $withBorders={withBorders} + disabled={disabled} + > {item.label} {item.tag && {item.tag}} {item.slotRight} @@ -82,6 +91,7 @@ export const Tabs = ({ align="end" $isActive={item.subitems.some((subitem) => subitem.value === value)} slotTrigger={<$DropdownTabTrigger value={value ?? ''} />} + disabled={disabled} > {item.label} @@ -139,10 +149,13 @@ const tabTriggerStyle = css` font: var(--trigger-font, var(--font-base-book)); color: var(--trigger-textColor); background-color: var(--trigger-backgroundColor); + box-shadow: inset 0 calc(var(--trigger-underline-size) * -1) 0 var(--trigger-active-textColor); &[data-state='active'] { color: var(--trigger-active-textColor); background-color: var(--trigger-active-backgroundColor); + box-shadow: inset 0 calc(var(--trigger-active-underline-size) * -1) 0 + var(--trigger-active-textColor); } `; @@ -154,6 +167,9 @@ const $Root = styled(Root)<{ $side: 'top' | 'bottom'; $withInnerBorder?: boolean --trigger-active-backgroundColor: var(--color-layer-1); --trigger-active-textColor: var(--color-text-2); + --trigger-active-underline-size: 0px; + --trigger-underline-size: 0px; + /* Variants */ --tabs-currentHeight: var(--tabs-height); @@ -300,6 +316,10 @@ const $DropdownTabTrigger = styled(Trigger)` width: 100%; `; +const dropdownSelectMenuType = getSimpleStyledOutputType( + DropdownSelectMenu, + {} as { $isActive?: boolean } +); const $DropdownSelectMenu = styled(DropdownSelectMenu)<{ $isActive?: boolean }>` --trigger-radius: 0; --dropdownSelectMenu-item-font-size: var(--fontSize-base); @@ -309,10 +329,9 @@ const $DropdownSelectMenu = styled(DropdownSelectMenu)<{ $isActive?: boolean }>` css` --trigger-textColor: var(--trigger-active-textColor); --trigger-backgroundColor: var(--trigger-active-backgroundColor); + --trigger-underline-size: var(--trigger-active-underline-size); `} -` as ( - props: { $isActive?: boolean } & React.ComponentProps> -) => ReactNode; +` as typeof dropdownSelectMenuType; export const MobileTabs = styled(Tabs)` --trigger-backgroundColor: transparent; diff --git a/src/constants/abacus.ts b/src/constants/abacus.ts index 48b3ec09c..f2d2795d2 100644 --- a/src/constants/abacus.ts +++ b/src/constants/abacus.ts @@ -83,6 +83,7 @@ export type MarketTrade = Abacus.exchange.dydx.abacus.output.MarketTrade; export type OrderbookLine = Abacus.exchange.dydx.abacus.output.OrderbookLine; export type PerpetualMarket = Abacus.exchange.dydx.abacus.output.PerpetualMarket; export type MarketHistoricalFunding = Abacus.exchange.dydx.abacus.output.MarketHistoricalFunding; +export const PerpetualMarketType = Abacus.exchange.dydx.abacus.output.PerpetualMarketType; // ------ Configs ------ // export type Configs = Abacus.exchange.dydx.abacus.output.Configs; @@ -102,6 +103,8 @@ export type ClosePositionInputs = Abacus.exchange.dydx.abacus.output.input.Close export type TradeInputSummary = Abacus.exchange.dydx.abacus.output.input.TradeInputSummary; export type TransferInputs = Abacus.exchange.dydx.abacus.output.input.TransferInput; export type TriggerOrdersInputs = Abacus.exchange.dydx.abacus.output.input.TriggerOrdersInput; +export type AdjustIsolatedMarginInputs = + Abacus.exchange.dydx.abacus.output.input.AdjustIsolatedMarginInput; export type InputError = Abacus.exchange.dydx.abacus.output.input.ValidationError; export type TransferInputTokenResource = Abacus.exchange.dydx.abacus.output.input.TransferInputTokenResource; @@ -122,6 +125,8 @@ export type HistoricalTradingRewardsPeriods = (typeof historicalTradingRewardsPe export type Subaccount = Abacus.exchange.dydx.abacus.output.Subaccount; export type SubaccountPosition = Abacus.exchange.dydx.abacus.output.SubaccountPosition; +export type SubaccountPendingPosition = + Abacus.exchange.dydx.abacus.output.SubaccountPendingPosition; export type SubaccountOrder = Abacus.exchange.dydx.abacus.output.SubaccountOrder; export type OrderStatus = Abacus.exchange.dydx.abacus.output.input.OrderStatus; export const AbacusOrderStatus = Abacus.exchange.dydx.abacus.output.input.OrderStatus; @@ -148,6 +153,13 @@ export type TransferInputFields = (typeof transferInputFields)[number]; export const TransferType = Abacus.exchange.dydx.abacus.output.input.TransferType; +export const AdjustIsolatedMarginInputField = + Abacus.exchange.dydx.abacus.state.model.AdjustIsolatedMarginInputField; +const adjustIsolatedMarginInputFields = [...AdjustIsolatedMarginInputField.values()] as const; +export type AdjustIsolatedMarginInputFields = (typeof adjustIsolatedMarginInputFields)[number]; +export const IsolatedMarginAdjustmentType = + Abacus.exchange.dydx.abacus.output.input.IsolatedMarginAdjustmentType; + // ------ Trade Items ------ // export const TradeInputField = Abacus.exchange.dydx.abacus.state.model.TradeInputField; const tradeInputFields = [...TradeInputField.values()] as const; @@ -194,6 +206,8 @@ export type HumanReadableWithdrawPayload = Abacus.exchange.dydx.abacus.state.manager.HumanReadableWithdrawPayload; export type HumanReadableTransferPayload = Abacus.exchange.dydx.abacus.state.manager.HumanReadableTransferPayload; +export type HumanReadableSubaccountTransferPayload = + Abacus.exchange.dydx.abacus.state.manager.HumanReadableSubaccountTransferPayload; // ------ Helpers ------ // export const AbacusHelper = Abacus.exchange.dydx.abacus.utils.AbacusHelper; diff --git a/src/constants/account.ts b/src/constants/account.ts index 3ec81060f..e9438f54a 100644 --- a/src/constants/account.ts +++ b/src/constants/account.ts @@ -51,3 +51,8 @@ export type Hdkey = { }; export const AMOUNT_RESERVED_FOR_GAS_USDC = 0.1; + +/** + * @description The number of parentSubaccounts: 0 - 127, 128 is the first childSubaccount + */ +export const NUM_PARENT_SUBACCOUNTS = 128; diff --git a/src/constants/dialogs.ts b/src/constants/dialogs.ts index a34a9fdc8..026c9588d 100644 --- a/src/constants/dialogs.ts +++ b/src/constants/dialogs.ts @@ -2,6 +2,7 @@ export enum DialogTypes { AdjustIsolatedMargin = 'AdjustIsolatedMargin', AdjustTargetLeverage = 'AdjustTargetLeverage', ClosePosition = 'ClosePosition', + CancelPendingOrders = 'CancelPendingOrders', ComplianceConfig = 'ComplianceConfig', Deposit = 'Deposit', DisconnectWallet = 'DisconnectWallet', diff --git a/src/constants/tooltips/trade.ts b/src/constants/tooltips/trade.ts index 1ddfa562e..baebf5909 100644 --- a/src/constants/tooltips/trade.ts +++ b/src/constants/tooltips/trade.ts @@ -21,6 +21,14 @@ export const tradeTooltips: TooltipStrings = { title: stringGetter({ key: TOOLTIP_STRING_KEYS.BUYING_POWER_TITLE }), body: stringGetter({ key: TOOLTIP_STRING_KEYS.BUYING_POWER_BODY, params: stringParams }), }), + 'cross-margin-usage': ({ stringGetter }) => ({ + title: stringGetter({ key: TOOLTIP_STRING_KEYS.CROSS_MARGIN_USAGE_TITLE }), + body: stringGetter({ key: TOOLTIP_STRING_KEYS.CROSS_MARGIN_USAGE_BODY }), + }), + 'cross-free-collateral': ({ stringGetter }) => ({ + title: stringGetter({ key: TOOLTIP_STRING_KEYS.CROSS_FREE_COLLATERAL_TITLE }), + body: stringGetter({ key: TOOLTIP_STRING_KEYS.CROSS_FREE_COLLATERAL_BODY }), + }), 'default-execution': ({ stringGetter }) => ({ title: stringGetter({ key: TOOLTIP_STRING_KEYS.DEFAULT_EXECUTION_TITLE }), body: stringGetter({ key: TOOLTIP_STRING_KEYS.DEFAULT_EXECUTION_BODY }), @@ -195,6 +203,10 @@ export const tradeTooltips: TooltipStrings = { title: stringGetter({ key: TOOLTIP_STRING_KEYS.TAKER_FEE_TITLE }), body: stringGetter({ key: TOOLTIP_STRING_KEYS.TAKER_FEE_BODY }), }), + 'target-leverage': ({ stringGetter, stringParams }) => ({ + title: stringGetter({ key: TOOLTIP_STRING_KEYS.TARGET_LEVERAGE_TITLE }), + body: stringGetter({ key: TOOLTIP_STRING_KEYS.TARGET_LEVERAGE_BODY, params: stringParams }), + }), 'tick-size': ({ stringGetter }) => ({ title: stringGetter({ key: TOOLTIP_STRING_KEYS.TICK_SIZE_TITLE }), body: stringGetter({ key: TOOLTIP_STRING_KEYS.TICK_SIZE_BODY }), diff --git a/src/hooks/useBreakpoints.ts b/src/hooks/useBreakpoints.ts index a627687ad..0906f803f 100644 --- a/src/hooks/useBreakpoints.ts +++ b/src/hooks/useBreakpoints.ts @@ -65,5 +65,5 @@ export const useBreakpoints = () => { objectEntries(state).map(([key, [matches]]) => [key, matches]) ); - return breakpointMatches; + return breakpointMatches as { [key in MediaQueryKeys]: boolean }; }; diff --git a/src/hooks/useCurrentMarketId.ts b/src/hooks/useCurrentMarketId.ts index bad65f6ee..1fcacbc12 100644 --- a/src/hooks/useCurrentMarketId.ts +++ b/src/hooks/useCurrentMarketId.ts @@ -82,8 +82,9 @@ export const useCurrentMarketId = () => { useEffect(() => { // Check for marketIds otherwise Abacus will silently fail its isMarketValid check - if (marketIds) { + if (hasMarketIds) { abacusStateManager.setMarket(marketId ?? DEFAULT_MARKETID); + abacusStateManager.setTradeValue({ value: null, field: null }); } }, [selectedNetwork, hasMarketIds, marketId]); }; diff --git a/src/hooks/useSubaccount.tsx b/src/hooks/useSubaccount.tsx index 254b23657..e83445d38 100644 --- a/src/hooks/useSubaccount.tsx +++ b/src/hooks/useSubaccount.tsx @@ -18,6 +18,7 @@ import type { AccountBalance, HumanReadableCancelOrderPayload, HumanReadablePlaceOrderPayload, + HumanReadableSubaccountTransferPayload, HumanReadableTriggerOrdersPayload, ParsingError, } from '@/constants/abacus'; @@ -348,6 +349,34 @@ const useSubaccountContext = ({ localDydxWallet }: { localDydxWallet?: LocalWall [subaccountClient, sendSquidWithdrawFromSubaccount] ); + const adjustIsolatedMarginOfPosition = useCallback( + ({ + onError, + onSuccess, + }: { + onError?: (onErrorParams?: { errorStringKey?: Nullable }) => void; + onSuccess?: ( + subaccountTransferPayload?: Nullable + ) => void; + }) => { + const callback = ( + success: boolean, + parsingError?: Nullable, + data?: Nullable + ) => { + if (success) { + onSuccess?.(data); + } else { + onError?.({ errorStringKey: parsingError?.stringKey }); + } + }; + + const subaccountTransferPayload = abacusStateManager.adjustIsolatedMarginOfPosition(callback); + return subaccountTransferPayload; + }, + [subaccountClient] + ); + // ------ Faucet Methods ------ // const requestFaucetFunds = useCallback(async () => { try { @@ -684,6 +713,7 @@ const useSubaccountContext = ({ localDydxWallet }: { localDydxWallet?: LocalWall // Transfer Methods transfer, sendSquidWithdraw, + adjustIsolatedMarginOfPosition, // Trading Methods placeOrder, diff --git a/src/hooks/useURLConfigs.ts b/src/hooks/useURLConfigs.ts index 75dfa65ea..aab92fc57 100644 --- a/src/hooks/useURLConfigs.ts +++ b/src/hooks/useURLConfigs.ts @@ -21,6 +21,7 @@ export interface LinksConfigs { mintscan: string; mintscanBase: string; newMarketProposalLearnMore: string; + adjustTargetLeverageLearnMore: string; privacy: string; reduceOnlyLearnMore?: string; statusPage: string; @@ -67,6 +68,7 @@ export const useURLConfigs = (): LinksConfigs => { withdrawalGateLearnMore: linksConfigs.withdrawalGateLearnMore ?? FALLBACK_URL, exchangeStats: linksConfigs.exchangeStats ?? FALLBACK_URL, complianceSupportEmail: linksConfigs.complianceSupportEmail ?? FALLBACK_URL, + adjustTargetLeverageLearnMore: linksConfigs.adjustTargetLeverageLearnMore ?? FALLBACK_URL, fetAgixMarketWindDownProposal: linksConfigs.fetAgixMarketWindDownProposal, contractLossMechanismLearnMore: linksConfigs.contractLossMechanismLearnMore, }; diff --git a/src/layout/DialogManager.tsx b/src/layout/DialogManager.tsx index a682ddd0e..03b9ab4f2 100644 --- a/src/layout/DialogManager.tsx +++ b/src/layout/DialogManager.tsx @@ -2,6 +2,7 @@ import { DialogTypes } from '@/constants/dialogs'; import { AdjustIsolatedMarginDialog } from '@/views/dialogs/AdjustIsolatedMarginDialog'; import { AdjustTargetLeverageDialog } from '@/views/dialogs/AdjustTargetLeverageDialog'; +import { CancelAllOrdersDialog } from '@/views/dialogs/CancelAllOrdersDialog'; import { ClosePositionDialog } from '@/views/dialogs/ClosePositionDialog'; import { ComplianceConfigDialog } from '@/views/dialogs/ComplianceConfigDialog'; import { DepositDialog } from '@/views/dialogs/DepositDialog'; @@ -62,6 +63,7 @@ export const DialogManager = () => { [DialogTypes.AdjustIsolatedMargin]: , [DialogTypes.AdjustTargetLeverage]: , [DialogTypes.ClosePosition]: , + [DialogTypes.CancelPendingOrders]: , [DialogTypes.ComplianceConfig]: , [DialogTypes.Deposit]: , [DialogTypes.DisplaySettings]: , diff --git a/src/lib/__test__/tradeData.spec.ts b/src/lib/__test__/tradeData.spec.ts index c76e4b245..7be32e2ce 100644 --- a/src/lib/__test__/tradeData.spec.ts +++ b/src/lib/__test__/tradeData.spec.ts @@ -2,8 +2,7 @@ import { describe, expect, it } from 'vitest'; import { PositionSide } from '@/constants/trade'; -import { BIG_NUMBERS, MustBigNumber } from '../numbers'; -import { calculatePositionMargin, hasPositionSideChanged } from '../tradeData'; +import { calculateCrossPositionMargin, hasPositionSideChanged } from '../tradeData'; describe('hasPositionSideChanged', () => { describe('Should return false when the position side has not changed', () => { @@ -79,38 +78,32 @@ describe('hasPositionSideChanged', () => { }); }); -describe('calculatePositionMargin', () => { +describe('calculateCrossPositionMargin', () => { it('should calculate the position margin', () => { - expect(calculatePositionMargin({ notionalTotal: 100, adjustedMmf: 0.1 })).toEqual( - MustBigNumber(10) - ); + expect(calculateCrossPositionMargin({ notionalTotal: 100, adjustedMmf: 0.1 })).toEqual('10.00'); }); it('should calculate the position margin with a notionalTotal of 0', () => { - expect(calculatePositionMargin({ notionalTotal: 0, adjustedMmf: 0.1 })).toEqual( - BIG_NUMBERS.ZERO - ); + expect(calculateCrossPositionMargin({ notionalTotal: 0, adjustedMmf: 0.1 })).toEqual('0.00'); }); it('should calculate the position margin with a adjustedMmf of 0', () => { - expect(calculatePositionMargin({ notionalTotal: 100, adjustedMmf: 0 })).toEqual( - BIG_NUMBERS.ZERO - ); + expect(calculateCrossPositionMargin({ notionalTotal: 100, adjustedMmf: 0 })).toEqual('0.00'); }); it('should calculate the position margin with a notionalTotal of 0 and a adjustedMmf of 0', () => { - expect(calculatePositionMargin({ notionalTotal: 0, adjustedMmf: 0 })).toEqual(BIG_NUMBERS.ZERO); + expect(calculateCrossPositionMargin({ notionalTotal: 0, adjustedMmf: 0 })).toEqual('0.00'); }); it('should calculate the position margin with a negative notionalTotal', () => { - expect(calculatePositionMargin({ notionalTotal: -100, adjustedMmf: 0.1 })).toEqual( - MustBigNumber(-10) + expect(calculateCrossPositionMargin({ notionalTotal: -100, adjustedMmf: 0.1 })).toEqual( + '-10.00' ); }); it('should handle undefined notionalTotal', () => { - expect(calculatePositionMargin({ notionalTotal: undefined, adjustedMmf: 0.1 })).toEqual( - BIG_NUMBERS.ZERO + expect(calculateCrossPositionMargin({ notionalTotal: undefined, adjustedMmf: 0.1 })).toEqual( + '0.00' ); }); }); diff --git a/src/lib/abacus/dydxChainTransactions.ts b/src/lib/abacus/dydxChainTransactions.ts index 3f00b1eb2..f936fbd89 100644 --- a/src/lib/abacus/dydxChainTransactions.ts +++ b/src/lib/abacus/dydxChainTransactions.ts @@ -232,9 +232,11 @@ class DydxChainTransactions implements AbacusDYDXChainTransactionsProtocol { this.store?.dispatch(placeOrderTimeout(clientId)); }, UNCOMMITTED_ORDER_TIMEOUT_MS); + const subaccountClient = new SubaccountClient(this.localWallet, subaccountNumber); + // Place order const tx = await this.compositeClient?.placeOrder( - new SubaccountClient(this.localWallet, subaccountNumber), + subaccountClient, marketId, type as OrderType, side as OrderSide, @@ -549,6 +551,36 @@ class DydxChainTransactions implements AbacusDYDXChainTransactionsProtocol { } } + async subaccountTransfer(params: { + subaccountNumber: number; + amount: string; + destinationAddress: string; + destinationSubaccountNumber: number; + }): Promise { + if (!this.compositeClient || !this.localWallet) { + throw new Error('Missing compositeClient or localWallet'); + } + + try { + const tx = await this.compositeClient.transferToSubaccount( + new SubaccountClient(this.localWallet, params.subaccountNumber), + params.destinationAddress, + params.destinationSubaccountNumber, + parseFloat(params.amount).toFixed(6) + ); + + const parsedTx = this.parseToPrimitives(tx); + + return JSON.stringify(parsedTx); + } catch (error) { + log('DydxChainTransactions/subaccountTransfer', error); + + return JSON.stringify({ + error, + }); + } + } + async transaction( type: TransactionTypes, paramsInJson: Abacus.Nullable, @@ -563,6 +595,11 @@ class DydxChainTransactions implements AbacusDYDXChainTransactionsProtocol { callback(result); break; } + case TransactionType.SubaccountTransfer: { + const result = await this.subaccountTransfer(params); + callback(result); + break; + } case TransactionType.CancelOrder: { const result = await this.cancelOrderTransaction(params); callback(result); diff --git a/src/lib/abacus/index.ts b/src/lib/abacus/index.ts index 52e61dd5d..1623950e0 100644 --- a/src/lib/abacus/index.ts +++ b/src/lib/abacus/index.ts @@ -1,12 +1,14 @@ import type { LocalWallet, SelectedGasDenom } from '@dydxprotocol/v4-client-js'; import type { + AdjustIsolatedMarginInputFields, ClosePositionInputFields, HistoricalPnlPeriods, HistoricalTradingRewardsPeriod, HistoricalTradingRewardsPeriods, HumanReadableCancelOrderPayload, HumanReadablePlaceOrderPayload, + HumanReadableSubaccountTransferPayload, HumanReadableTriggerOrdersPayload, Nullable, ParsingError, @@ -17,6 +19,7 @@ import type { import { AbacusAppConfig, AbacusHelper, + AdjustIsolatedMarginInputField, ApiData, AsyncAbacusStateManager, ClosePositionInputField, @@ -97,7 +100,7 @@ class AbacusStateManager { this.abacusFormatter ); - const appConfigs = AbacusAppConfig.Companion.forWeb; + const appConfigs = AbacusAppConfig.Companion.forWebAppWithIsolatedMargins; appConfigs.onboardingConfigs.squidVersion = OnboardingConfig.SquidVersion.V2; this.stateManager = new AsyncAbacusStateManager( @@ -115,9 +118,9 @@ class AbacusStateManager { if (network) { this.stateManager.environmentId = network; } - this.stateManager.trade(null, null); this.stateManager.readyToConnect = true; this.setMarket(this.currentMarket ?? DEFAULT_MARKETID); + this.stateManager.trade(null, null); }; // ------ Breakdown ------ // @@ -210,6 +213,17 @@ class AbacusStateManager { this.setTriggerOrdersValue({ value: null, field: TriggerOrdersInputField.takeProfitOrderType }); }; + clearAdjustIsolatedMarginInputValues = () => { + this.setAdjustIsolatedMarginValue({ + value: null, + field: AdjustIsolatedMarginInputField.Amount, + }); + this.setAdjustIsolatedMarginValue({ + value: null, + field: AdjustIsolatedMarginInputField.AmountPercent, + }); + }; + resetInputState = () => { this.clearTransferInputValues(); this.setTransferValue({ @@ -217,6 +231,7 @@ class AbacusStateManager { value: null, }); this.clearTriggerOrdersInputValues(); + this.clearAdjustIsolatedMarginInputValues(); this.clearTradeInputValues({ shouldResetSize: true }); }; @@ -258,10 +273,20 @@ class AbacusStateManager { this.chainTransactions.setSelectedGasDenom(denom); }; - setTradeValue = ({ value, field }: { value: any; field: TradeInputFields }) => { + setTradeValue = ({ value, field }: { value: any; field: Nullable }) => { this.stateManager.trade(value, field); }; + setAdjustIsolatedMarginValue = ({ + value, + field, + }: { + value: any; + field: AdjustIsolatedMarginInputFields; + }) => { + this.stateManager.adjustIsolatedMargin(value, field); + }; + setTransferValue = ({ value, field }: { value: any; field: TransferInputFields }) => { this.stateManager.transfer(value, field); }; @@ -328,6 +353,15 @@ class AbacusStateManager { ) => void ) => this.stateManager.cancelOrder(orderId, callback); + adjustIsolatedMarginOfPosition = ( + callback: ( + success: boolean, + parsingError: Nullable, + data: Nullable + ) => void + ): Nullable => + this.stateManager.commitAdjustIsolatedMargin(callback); + triggerOrders = ( callback: ( success: boolean, diff --git a/src/lib/abacus/stateNotification.ts b/src/lib/abacus/stateNotification.ts index 0aa850517..0d1fe9bc5 100644 --- a/src/lib/abacus/stateNotification.ts +++ b/src/lib/abacus/stateNotification.ts @@ -14,10 +14,12 @@ import type { SubaccountOrder, } from '@/constants/abacus'; import { Changes } from '@/constants/abacus'; +import { NUM_PARENT_SUBACCOUNTS } from '@/constants/account'; import { type RootStore } from '@/state/_store'; import { setBalances, + setChildSubaccount, setCompliance, setFills, setFundingPayments, @@ -154,33 +156,70 @@ class AbacusStateNotifier implements AbacusStateNotificationProtocol { } subaccountNumbers?.forEach((subaccountId: number) => { - if (subaccountId !== null) { - if (changes.has(Changes.subaccount)) { - dispatch(setSubaccount(updatedState.subaccount(subaccountId))); + const isChildSubaccount = subaccountId >= NUM_PARENT_SUBACCOUNTS; + const childSubaccountUpdate: Parameters[0] = {}; + + if (changes.has(Changes.subaccount)) { + const subaccountData = updatedState.subaccount(subaccountId); + if (isChildSubaccount) { + childSubaccountUpdate[subaccountId] = subaccountData; + } else { + dispatch(setSubaccount(subaccountData)); } + } - if (changes.has(Changes.fills)) { - const fills = updatedState.subaccountFills(subaccountId)?.toArray() ?? []; + if (changes.has(Changes.fills)) { + const fills = updatedState.subaccountFills(subaccountId)?.toArray() ?? []; + if (isChildSubaccount) { + childSubaccountUpdate[subaccountId] = { ...childSubaccountUpdate[subaccountId], fills }; + } else { dispatch(setFills(fills)); } + } + + if (changes.has(Changes.fundingPayments)) { + const fundingPayments = + updatedState.subaccountFundingPayments(subaccountId)?.toArray() ?? []; - if (changes.has(Changes.fundingPayments)) { - const fundingPayments = - updatedState.subaccountFundingPayments(subaccountId)?.toArray() ?? []; + if (isChildSubaccount) { + childSubaccountUpdate[subaccountId] = { + ...childSubaccountUpdate[subaccountId], + fundingPayments, + }; + } else { dispatch(setFundingPayments(fundingPayments)); } + } + + if (changes.has(Changes.transfers)) { + const transfers = updatedState.subaccountTransfers(subaccountId)?.toArray() ?? []; - if (changes.has(Changes.transfers)) { - const transfers = updatedState.subaccountTransfers(subaccountId)?.toArray() ?? []; + if (isChildSubaccount) { + childSubaccountUpdate[subaccountId] = { + ...childSubaccountUpdate[subaccountId], + transfers, + }; + } else { dispatch(setTransfers(transfers)); } + } + + if (changes.has(Changes.historicalPnl)) { + const historicalPnl = updatedState.subaccountHistoricalPnl(subaccountId)?.toArray() ?? []; - if (changes.has(Changes.historicalPnl)) { - const historicalPnl = - updatedState.subaccountHistoricalPnl(subaccountId)?.toArray() ?? []; + if (isChildSubaccount) { + childSubaccountUpdate[subaccountId] = { + ...childSubaccountUpdate[subaccountId], + historicalPnl, + }; + } else { dispatch(setHistoricalPnl(historicalPnl)); } } + + if (isChildSubaccount) { + dispatch(setChildSubaccount(childSubaccountUpdate)); + } }); marketIds?.forEach((market: string) => { diff --git a/src/lib/numbers.ts b/src/lib/numbers.ts index 8d3d119dc..2351ff831 100644 --- a/src/lib/numbers.ts +++ b/src/lib/numbers.ts @@ -81,3 +81,5 @@ export const getNumberSign = (n: any): NumberSign => : MustBigNumber(n).lt(0) ? NumberSign.Negative : NumberSign.Neutral; + +export const nullIfZero = (n?: number | string | null) => (MustBigNumber(n).eq(0) ? null : n); diff --git a/src/lib/testFlags.ts b/src/lib/testFlags.ts index 369ed3584..964241210 100644 --- a/src/lib/testFlags.ts +++ b/src/lib/testFlags.ts @@ -32,10 +32,6 @@ class TestFlags { return this.queryParams.address; } - get isolatedMargin() { - return !!this.queryParams.isolatedmargin; - } - get withNewMarketType() { return !!this.queryParams.withnewmarkettype; } diff --git a/src/lib/tradeData.ts b/src/lib/tradeData.ts index daa3b1e33..c0af4602b 100644 --- a/src/lib/tradeData.ts +++ b/src/lib/tradeData.ts @@ -1,13 +1,17 @@ import { OrderSide } from '@dydxprotocol/v4-client-js'; import { + AbacusMarginMode, AbacusOrderSide, AbacusOrderTypes, ErrorType, ValidationError, type AbacusOrderSides, type Nullable, + type SubaccountPosition, + type TradeState, } from '@/constants/abacus'; +import { NUM_PARENT_SUBACCOUNTS } from '@/constants/account'; import { AlertType } from '@/constants/alerts'; import type { StringGetterFunction } from '@/constants/localization'; import { PERCENT_DECIMALS, USD_DECIMALS } from '@/constants/numbers'; @@ -125,7 +129,7 @@ export const getTradeInputAlert = ({ return inputAlerts?.[0]; }; -export const calculatePositionMargin = ({ +export const calculateCrossPositionMargin = ({ notionalTotal, adjustedMmf, }: { @@ -134,5 +138,37 @@ export const calculatePositionMargin = ({ }) => { const notionalTotalBN = MustBigNumber(notionalTotal); const adjustedMmfBN = MustBigNumber(adjustedMmf); - return notionalTotalBN.times(adjustedMmfBN); + return notionalTotalBN.times(adjustedMmfBN).toFixed(USD_DECIMALS); +}; + +/** + * @param subaccountNumber + * @returns marginMode from subaccountNumber, defaulting to cross margin if subaccountNumber is undefined or null. + * @note v4-web is assuming that subaccountNumber >= 128 is used as childSubaccounts. API Traders may utilize these subaccounts differently. + */ +export const getMarginModeFromSubaccountNumber = (subaccountNumber: Nullable) => { + if (!subaccountNumber) return AbacusMarginMode.cross; + + return subaccountNumber >= NUM_PARENT_SUBACCOUNTS + ? AbacusMarginMode.isolated + : AbacusMarginMode.cross; +}; + +export const getPositionMargin = ({ position }: { position: SubaccountPosition }) => { + const { childSubaccountNumber, equity, notionalTotal, adjustedMmf } = position; + const marginMode = getMarginModeFromSubaccountNumber(childSubaccountNumber); + + const margin = + marginMode === AbacusMarginMode.cross + ? calculateCrossPositionMargin({ + notionalTotal: notionalTotal?.current, + adjustedMmf: adjustedMmf.current, + }) + : equity?.current; + + return margin; +}; + +export const getTradeStateWithDoubleValuesHasDiff = (tradeState: Nullable>) => { + return !!tradeState && tradeState.current !== tradeState.postOrder; }; diff --git a/src/pages/portfolio/Orders.tsx b/src/pages/portfolio/Orders.tsx index 8fe3f84ac..d02bf155f 100644 --- a/src/pages/portfolio/Orders.tsx +++ b/src/pages/portfolio/Orders.tsx @@ -32,6 +32,7 @@ export const Orders = () => { OrdersTableColumnKey.AmountFill, OrdersTableColumnKey.Price, OrdersTableColumnKey.Trigger, + OrdersTableColumnKey.MarginType, OrdersTableColumnKey.GoodTil, !isAccountViewOnly && OrdersTableColumnKey.Actions, ].filter(isTruthy) diff --git a/src/pages/portfolio/Overview.tsx b/src/pages/portfolio/Overview.tsx index eee08c8f3..b616caf3e 100644 --- a/src/pages/portfolio/Overview.tsx +++ b/src/pages/portfolio/Overview.tsx @@ -1,3 +1,5 @@ +import { useCallback } from 'react'; + import { useNavigate } from 'react-router-dom'; import styled from 'styled-components'; @@ -19,8 +21,8 @@ import { import { useAppSelector } from '@/state/appTypes'; import { isTruthy } from '@/lib/isTruthy'; -import { testFlags } from '@/lib/testFlags'; +import { MaybeUnopenedIsolatedPositionsPanel } from '../trade/UnopenedIsolatedPositions'; import { AccountDetailsAndHistory } from './AccountDetailsAndHistory'; export const Overview = () => { @@ -28,12 +30,15 @@ export const Overview = () => { const { isTablet } = useBreakpoints(); const navigate = useNavigate(); - const showClosePositionAction = false; + const handleViewUnopenedIsolatedOrders = useCallback(() => { + navigate(`${AppRoute.Portfolio}/${PortfolioRoute.Orders}`, { + state: { from: AppRoute.Portfolio }, + }); + }, [navigate]); const shouldRenderTriggers = useAppSelector(calculateShouldRenderTriggersInPositionsTable); const shouldRenderActions = useParameterizedSelector( - calculateShouldRenderActionsInPositionsTable, - showClosePositionAction + calculateShouldRenderActionsInPositionsTable ); return ( @@ -56,8 +61,9 @@ export const Overview = () => { : [ PositionsTableColumnKey.Market, PositionsTableColumnKey.Size, + PositionsTableColumnKey.Margin, PositionsTableColumnKey.UnrealizedPnl, - !testFlags.isolatedMargin && PositionsTableColumnKey.RealizedPnl, + PositionsTableColumnKey.RealizedPnl, PositionsTableColumnKey.AverageOpenAndClose, PositionsTableColumnKey.LiquidationAndOraclePrice, shouldRenderTriggers && PositionsTableColumnKey.Triggers, @@ -70,13 +76,31 @@ export const Overview = () => { state: { from: AppRoute.Portfolio }, }) } - showClosePositionAction={showClosePositionAction} + showClosePositionAction={shouldRenderActions} withOuterBorder /> + <$MaybeUnopenedIsolatedPositionsPanel + header={ + + } + onViewOrders={handleViewUnopenedIsolatedOrders} + /> ); }; + const $AttachedExpandingSection = styled(AttachedExpandingSection)` margin-top: 1rem; `; + +const $MaybeUnopenedIsolatedPositionsPanel = styled(MaybeUnopenedIsolatedPositionsPanel)` + margin-top: 1rem; + margin-bottom: 1rem; + + > div { + padding-left: 1rem; + } +`; diff --git a/src/pages/portfolio/Positions.tsx b/src/pages/portfolio/Positions.tsx index da1314497..033926bc7 100644 --- a/src/pages/portfolio/Positions.tsx +++ b/src/pages/portfolio/Positions.tsx @@ -1,4 +1,7 @@ +import { useCallback } from 'react'; + import { useNavigate } from 'react-router-dom'; +import styled from 'styled-components'; import { STRING_KEYS } from '@/constants/localization'; import { AppRoute, PortfolioRoute } from '@/constants/routes'; @@ -18,23 +21,27 @@ import { import { useAppSelector } from '@/state/appTypes'; import { isTruthy } from '@/lib/isTruthy'; -import { testFlags } from '@/lib/testFlags'; + +import { MaybeUnopenedIsolatedPositionsPanel } from '../trade/UnopenedIsolatedPositions'; export const Positions = () => { const stringGetter = useStringGetter(); const { isTablet, isNotTablet } = useBreakpoints(); const navigate = useNavigate(); - const showClosePositionAction = false; - const shouldRenderTriggers = useAppSelector(calculateShouldRenderTriggersInPositionsTable); const shouldRenderActions = useParameterizedSelector( - calculateShouldRenderActionsInPositionsTable, - showClosePositionAction + calculateShouldRenderActionsInPositionsTable ); + const handleViewUnopenedIsolatedOrders = useCallback(() => { + navigate(`${AppRoute.Portfolio}/${PortfolioRoute.Orders}`, { + state: { from: AppRoute.Portfolio }, + }); + }, [navigate]); + return ( - + <$AttachedExpandingSection> {isNotTablet && } { : [ PositionsTableColumnKey.Market, PositionsTableColumnKey.Size, - testFlags.isolatedMargin && PositionsTableColumnKey.Margin, + PositionsTableColumnKey.Margin, PositionsTableColumnKey.UnrealizedPnl, - !testFlags.isolatedMargin && PositionsTableColumnKey.RealizedPnl, + PositionsTableColumnKey.RealizedPnl, PositionsTableColumnKey.NetFunding, PositionsTableColumnKey.AverageOpenAndClose, PositionsTableColumnKey.LiquidationAndOraclePrice, @@ -60,13 +67,35 @@ export const Positions = () => { } currentRoute={`${AppRoute.Portfolio}/${PortfolioRoute.Positions}`} withOuterBorder={isNotTablet} - showClosePositionAction={showClosePositionAction} + showClosePositionAction={shouldRenderActions} navigateToOrders={() => navigate(`${AppRoute.Portfolio}/${PortfolioRoute.Orders}`, { state: { from: AppRoute.Portfolio }, }) } /> - + + <$MaybeUnopenedIsolatedPositionsPanel + header={ + + } + onViewOrders={handleViewUnopenedIsolatedOrders} + /> + ); }; + +const $AttachedExpandingSection = styled(AttachedExpandingSection)` + margin-bottom: 1rem; +`; + +const $MaybeUnopenedIsolatedPositionsPanel = styled(MaybeUnopenedIsolatedPositionsPanel)` + margin-top: 1rem; + margin-bottom: 1rem; + + > div { + padding-left: 1rem; + } +`; diff --git a/src/pages/trade/HorizontalPanel.tsx b/src/pages/trade/HorizontalPanel.tsx index 1bf9aa82a..729a297da 100644 --- a/src/pages/trade/HorizontalPanel.tsx +++ b/src/pages/trade/HorizontalPanel.tsx @@ -11,9 +11,13 @@ import { useBreakpoints } from '@/hooks/useBreakpoints'; import { useParameterizedSelector } from '@/hooks/useParameterizedSelector'; import { useStringGetter } from '@/hooks/useStringGetter'; +import { formMixins } from '@/styles/formMixins'; + import { AssetIcon } from '@/components/AssetIcon'; import { CollapsibleTabs } from '@/components/CollapsibleTabs'; import { LoadingSpinner } from '@/components/Loading/LoadingSpinner'; +import { SelectItem, SelectMenu } from '@/components/SelectMenu'; +import { VerticalSeparator } from '@/components/Separator'; import { MobileTabs } from '@/components/Tabs'; import { Tag, TagType } from '@/components/Tag'; import { ToggleGroup } from '@/components/ToggleGroup'; @@ -41,9 +45,9 @@ import { getCurrentMarketAssetId, getCurrentMarketId } from '@/state/perpetualsS import { getSimpleStyledOutputType } from '@/lib/genericFunctionalComponentUtils'; import { isTruthy } from '@/lib/isTruthy'; import { shortenNumberForDisplay } from '@/lib/numbers'; -import { testFlags } from '@/lib/testFlags'; -import { UnopenedIsolatedPositions } from './UnopenedIsolatedPositions'; +import { MaybeUnopenedIsolatedPositionsDrawer } from './UnopenedIsolatedPositions'; +import { MarketTypeFilter } from './types'; enum InfoSection { Position = 'Position', @@ -65,12 +69,13 @@ type ElementProps = { export const HorizontalPanel = ({ isOpen = true, setIsOpen }: ElementProps) => { const stringGetter = useStringGetter(); const navigate = useNavigate(); - const { isTablet } = useBreakpoints(); + const { isTablet, isDesktopSmall } = useBreakpoints(); const allMarkets = useAppSelector(getDefaultToAllMarketsInPositionsOrdersFills); const [view, setView] = useState( allMarkets ? PanelView.AllMarkets : PanelView.CurrentMarket ); + const [viewIsolated, setViewIsolated] = useState(MarketTypeFilter.AllMarkets); const [tab, setTab] = useState(InfoSection.Position); const currentMarketId = useAppSelector(getCurrentMarketId); @@ -82,15 +87,12 @@ export const HorizontalPanel = ({ isOpen = true, setIsOpen }: ElementProps) => { const { numOpenOrders, numUnseenFills } = useAppSelector(getCurrentMarketTradeInfoNumbers, shallowEqual) ?? {}; - const showClosePositionAction = true; - const hasUnseenOrderUpdates = useAppSelector(getHasUnseenOrderUpdates); const hasUnseenFillUpdates = useAppSelector(getHasUnseenFillUpdates); const isAccountViewOnly = useAppSelector(calculateIsAccountViewOnly); const shouldRenderTriggers = useAppSelector(calculateShouldRenderTriggersInPositionsTable); const shouldRenderActions = useParameterizedSelector( - calculateShouldRenderActionsInPositionsTable, - showClosePositionAction + calculateShouldRenderActionsInPositionsTable ); const isWaitingForOrderToIndex = useAppSelector(calculateHasUncommittedOrders); const showCurrentMarket = isTablet || view === PanelView.CurrentMarket; @@ -132,6 +134,7 @@ export const HorizontalPanel = ({ isOpen = true, setIsOpen }: ElementProps) => { ) : ( { : [ PositionsTableColumnKey.Market, PositionsTableColumnKey.Size, - testFlags.isolatedMargin && PositionsTableColumnKey.Margin, + PositionsTableColumnKey.Margin, PositionsTableColumnKey.UnrealizedPnl, - !testFlags.isolatedMargin && PositionsTableColumnKey.RealizedPnl, + !isDesktopSmall && PositionsTableColumnKey.RealizedPnl, PositionsTableColumnKey.NetFunding, PositionsTableColumnKey.AverageOpenAndClose, PositionsTableColumnKey.LiquidationAndOraclePrice, @@ -152,9 +155,8 @@ export const HorizontalPanel = ({ isOpen = true, setIsOpen }: ElementProps) => { shouldRenderActions && PositionsTableColumnKey.Actions, ].filter(isTruthy) } - showClosePositionAction={showClosePositionAction} + showClosePositionAction={shouldRenderActions} initialPageSize={initialPageSize} - onNavigate={() => setView(PanelView.CurrentMarket)} navigateToOrders={onViewOrders} /> ), @@ -177,6 +179,7 @@ export const HorizontalPanel = ({ isOpen = true, setIsOpen }: ElementProps) => { content: ( { OrdersTableColumnKey.AmountFill, OrdersTableColumnKey.Price, OrdersTableColumnKey.Trigger, + OrdersTableColumnKey.MarginType, OrdersTableColumnKey.GoodTil, !isAccountViewOnly && OrdersTableColumnKey.Actions, ].filter(isTruthy) @@ -252,8 +256,10 @@ export const HorizontalPanel = ({ isOpen = true, setIsOpen }: ElementProps) => { [ stringGetter, currentMarketId, + viewIsolated, showCurrentMarket, isTablet, + isDesktopSmall, isWaitingForOrderToIndex, isAccountViewOnly, ordersTagNumber, @@ -264,9 +270,7 @@ export const HorizontalPanel = ({ isOpen = true, setIsOpen }: ElementProps) => { ); const slotBottom = { - [InfoSection.Position]: testFlags.isolatedMargin && ( - <$UnopenedIsolatedPositions onViewOrders={onViewOrders} /> - ), + [InfoSection.Position]: <$UnopenedIsolatedPositions onViewOrders={onViewOrders} />, [InfoSection.Orders]: null, [InfoSection.Fills]: null, [InfoSection.Payments]: null, @@ -283,28 +287,56 @@ export const HorizontalPanel = ({ isOpen = true, setIsOpen }: ElementProps) => { defaultOpen={isOpen} onOpenChange={setIsOpen} slotToolbar={ - , - label: currentMarketAssetId, - } - : { label: stringGetter({ key: STRING_KEYS.MARKET }) }), - }, - ]} - value={view} - onValueChange={setView} - onInteraction={() => { - setIsOpen?.(true); - }} - /> + <> + , + label: currentMarketAssetId, + } + : { label: stringGetter({ key: STRING_KEYS.MARKET }) }), + }, + ]} + value={view} + onValueChange={setView} + onInteraction={() => { + setIsOpen?.(true); + }} + /> + <$VerticalSeparator /> + <$SelectMenu + value={viewIsolated} + onValueChange={(newViewIsolated: string) => { + setViewIsolated(newViewIsolated as MarketTypeFilter); + }} + > + <$SelectItem + key={MarketTypeFilter.AllMarkets} + value={MarketTypeFilter.AllMarkets} + label={`${stringGetter({ key: STRING_KEYS.CROSS })} / ${stringGetter({ + key: STRING_KEYS.ISOLATED, + })}`} + /> + <$SelectItem + key={MarketTypeFilter.Isolated} + value={MarketTypeFilter.Isolated} + label={stringGetter({ key: STRING_KEYS.ISOLATED })} + /> + <$SelectItem + key={MarketTypeFilter.Cross} + value={MarketTypeFilter.Cross} + label={stringGetter({ key: STRING_KEYS.CROSS })} + /> + + <$VerticalSeparator /> + } tabItems={tabItems} /> @@ -313,6 +345,12 @@ export const HorizontalPanel = ({ isOpen = true, setIsOpen }: ElementProps) => { ); }; +const $VerticalSeparator = styled(VerticalSeparator)` + && { + height: 1em; + } +`; + const $AssetIcon = styled(AssetIcon)` font-size: 1.5em; `; @@ -329,6 +367,17 @@ const $CollapsibleTabs = styled(CollapsibleTabs)` const $LoadingSpinner = styled(LoadingSpinner)` --spinner-width: 1rem; `; -const $UnopenedIsolatedPositions = styled(UnopenedIsolatedPositions)` +const $UnopenedIsolatedPositions = styled(MaybeUnopenedIsolatedPositionsDrawer)` margin-top: auto; `; + +const $SelectMenu = styled(SelectMenu)` + ${formMixins.inputInnerSelectMenu} + --trigger-height: 1.75rem; + --trigger-radius: 2rem; + --trigger-backgroundColor: var(--color-layer-1); +`; + +const $SelectItem = styled(SelectItem)` + ${formMixins.inputInnerSelectMenuItem} +` as typeof SelectItem; diff --git a/src/pages/trade/Trade.tsx b/src/pages/trade/Trade.tsx index 6c25b4df6..75c67b639 100644 --- a/src/pages/trade/Trade.tsx +++ b/src/pages/trade/Trade.tsx @@ -111,7 +111,7 @@ const $TradeLayout = styled.article<{ --layout-default-desktopMedium: 'Top Top Top' auto 'Side Vertical Inner' minmax(0, 1fr) - 'Side Vertical Horizontal' minmax(var(--tabs-height), var(--horizontalPanel-height)) + 'Side Horizontal Horizontal' minmax(var(--tabs-height), var(--horizontalPanel-height)) / var(--sidebar-width) minmax(0, var(--orderbook-trades-width)) 1fr; /* prettier-ignore */ @@ -132,7 +132,7 @@ const $TradeLayout = styled.article<{ --layout-alternative-desktopMedium: 'Top Top Top' auto 'Vertical Inner Side' minmax(0, 1fr) - 'Vertical Horizontal Side' minmax(var(--tabs-height), var(--horizontalPanel-height)) + 'Horizontal Horizontal Side' minmax(var(--tabs-height), var(--horizontalPanel-height)) / minmax(0, var(--orderbook-trades-width)) 1fr var(--sidebar-width); /* prettier-ignore */ diff --git a/src/pages/trade/UnopenedIsolatedPositions.tsx b/src/pages/trade/UnopenedIsolatedPositions.tsx index 4f3de4809..bbdbd8398 100644 --- a/src/pages/trade/UnopenedIsolatedPositions.tsx +++ b/src/pages/trade/UnopenedIsolatedPositions.tsx @@ -1,48 +1,106 @@ -import { useState } from 'react'; +import { ReactNode, useState } from 'react'; +import { shallowEqual, useSelector } from 'react-redux'; import styled from 'styled-components'; +import { SubaccountPendingPosition } from '@/constants/abacus'; +import { STRING_KEYS } from '@/constants/localization'; + +import { useStringGetter } from '@/hooks/useStringGetter'; + import { layoutMixins } from '@/styles/layoutMixins'; import { Button } from '@/components/Button'; import { Icon, IconName } from '@/components/Icon'; import { PotentialPositionCard } from '@/components/PotentialPositionCard'; +import { getNonZeroPendingPositions } from '@/state/accountSelectors'; +import { getAssets } from '@/state/assetsSelectors'; + type UnopenedIsolatedPositionsProps = { className?: string; onViewOrders: (marketId: string) => void; }; -export const UnopenedIsolatedPositions = ({ +export const MaybeUnopenedIsolatedPositionsDrawer = ({ className, onViewOrders, }: UnopenedIsolatedPositionsProps) => { const [isOpen, setIsOpen] = useState(false); + + const pendingPositions = useSelector(getNonZeroPendingPositions, shallowEqual); + const stringGetter = useStringGetter(); + + if (!pendingPositions?.length) return null; + return ( - <$UnopenedIsolatedPositions className={className} isOpen={isOpen}> + <$UnopenedIsolatedPositionsDrawerContainer className={className} isOpen={isOpen}> <$Button isOpen={isOpen} onClick={() => setIsOpen(!isOpen)}> - Unopened Isolated Positions + {stringGetter({ key: STRING_KEYS.UNOPENED_ISOLATED_POSITIONS })} {isOpen && ( - <$Cards> - - - - - - - - - - + <$CardsContainer> + + )} - + ); }; -const $UnopenedIsolatedPositions = styled.div<{ isOpen?: boolean }>` +type UnopenedIsolatedPositionsPanelProps = { + onViewOrders: (marketId: string) => void; + className?: string; + header: ReactNode; +}; +export const MaybeUnopenedIsolatedPositionsPanel = ({ + onViewOrders, + header, + className, +}: UnopenedIsolatedPositionsPanelProps) => { + const pendingPositions = useSelector(getNonZeroPendingPositions, shallowEqual); + if (!pendingPositions?.length) return null; + + return ( +
+ {header} + +
+ ); +}; + +type UnopenedIsolatedPositionsCardsProps = { + onViewOrders: (marketId: string) => void; + pendingPositions: SubaccountPendingPosition[]; +}; + +const UnopenedIsolatedPositionsCards = ({ + onViewOrders, + pendingPositions, +}: UnopenedIsolatedPositionsCardsProps) => { + const assetsData = useSelector(getAssets, shallowEqual); + return ( + <$Cards> + {pendingPositions.map((pendingPosition) => ( + + ))} + + ); +}; + +const $UnopenedIsolatedPositionsDrawerContainer = styled.div<{ isOpen?: boolean }>` overflow: auto; border-top: var(--border); ${({ isOpen }) => isOpen && 'height: 100%;'} @@ -68,5 +126,7 @@ const $Cards = styled.div` ${layoutMixins.flexWrap} gap: 1rem; scroll-snap-align: none; +`; +const $CardsContainer = styled.div` padding: 0 1rem 1rem; `; diff --git a/src/pages/trade/types.ts b/src/pages/trade/types.ts new file mode 100644 index 000000000..5cca48e46 --- /dev/null +++ b/src/pages/trade/types.ts @@ -0,0 +1,14 @@ +export enum MarketTypeFilter { + AllMarkets = 'AllMarkets', + Cross = 'Cross', + Isolated = 'Isolated', +} + +export function marketTypeMatchesFilter(type: 'isolated' | 'cross', filter?: MarketTypeFilter) { + return ( + filter == null || + filter === MarketTypeFilter.AllMarkets || + (type === 'cross' && filter === MarketTypeFilter.Cross) || + (type === 'isolated' && filter === MarketTypeFilter.Isolated) + ); +} diff --git a/src/state/account.ts b/src/state/account.ts index 8e5810599..b809f6fc9 100644 --- a/src/state/account.ts +++ b/src/state/account.ts @@ -7,11 +7,11 @@ import type { Nullable, StakingDelegation, StakingRewards, - SubAccountHistoricalPNLs, Subaccount, SubaccountFill, SubaccountFills, SubaccountFundingPayments, + SubAccountHistoricalPNLs, SubaccountOrder, SubaccountTransfers, TradingRewards, @@ -49,6 +49,19 @@ export type AccountState = { transfers?: SubaccountTransfers; historicalPnl?: SubAccountHistoricalPNLs; + childSubaccounts: { + [subaccountNumber: number]: Nullable< + Partial< + Subaccount & { + fills: SubaccountFills; + fundingPayments: SubaccountFundingPayments; + transfers: SubaccountTransfers; + historicalPnl: SubAccountHistoricalPNLs; + } + > + >; + }; + onboardingGuards: Record; onboardingState: OnboardingState; @@ -75,6 +88,7 @@ const initialState: AccountState = { // Subaccount subaccount: undefined, + childSubaccounts: {}, fills: undefined, fundingPayments: undefined, transfers: undefined, @@ -224,6 +238,21 @@ export const accountSlice = createSlice({ state.subaccount = action.payload; }, + setChildSubaccount: ( + state, + action: PayloadAction> + ) => { + const childSubaccountsCopy = { ...state.childSubaccounts }; + + Object.keys(action.payload).forEach((subaccountNumber) => { + childSubaccountsCopy[Number(subaccountNumber)] = { + ...childSubaccountsCopy[Number(subaccountNumber)], + ...action.payload[Number(subaccountNumber)], + }; + }); + + state.childSubaccounts = childSubaccountsCopy; + }, setWallet: (state, action: PayloadAction>) => ({ ...state, wallet: action.payload, @@ -330,6 +359,7 @@ export const { setRestrictionType, setCompliance, setSubaccount, + setChildSubaccount, setWallet, viewedFills, viewedOrders, diff --git a/src/state/accountCalculators.ts b/src/state/accountCalculators.ts index 5bee11935..a181855a3 100644 --- a/src/state/accountCalculators.ts +++ b/src/state/accountCalculators.ts @@ -9,8 +9,6 @@ import { } from '@/state/accountSelectors'; import { getSelectedNetwork } from '@/state/appSelectors'; -import { testFlags } from '@/lib/testFlags'; - import { createAppSelector } from './appTypes'; export const calculateOnboardingStep = createAppSelector( @@ -103,15 +101,9 @@ export const calculateShouldRenderTriggersInPositionsTable = createAppSelector( */ export const calculateShouldRenderActionsInPositionsTable = () => createAppSelector( - [ - calculateIsAccountViewOnly, - calculateShouldRenderTriggersInPositionsTable, - (s, isCloseActionShown: boolean) => isCloseActionShown, - ], - (isAccountViewOnly: boolean, areTriggersRendered: boolean, isCloseActionShown) => { - const hasActionsInColumn = testFlags.isolatedMargin - ? isCloseActionShown - : areTriggersRendered || isCloseActionShown; + [calculateIsAccountViewOnly, (s, isCloseActionShown: boolean = true) => isCloseActionShown], + (isAccountViewOnly: boolean, isCloseActionShown) => { + const hasActionsInColumn = isCloseActionShown; return !isAccountViewOnly && hasActionsInColumn; } ); diff --git a/src/state/accountSelectors.ts b/src/state/accountSelectors.ts index 7d3b3b956..04da5db2f 100644 --- a/src/state/accountSelectors.ts +++ b/src/state/accountSelectors.ts @@ -1,7 +1,8 @@ import { OrderSide } from '@dydxprotocol/v4-client-js'; -import { sum } from 'lodash'; +import { groupBy, sum } from 'lodash'; import { + AbacusMarginMode, AbacusOrderSide, AbacusOrderStatus, AbacusPositionSide, @@ -12,7 +13,8 @@ import { type SubaccountFundingPayment, type SubaccountOrder, } from '@/constants/abacus'; -import { OnboardingState } from '@/constants/account'; +import { NUM_PARENT_SUBACCOUNTS, OnboardingState } from '@/constants/account'; +import { LEVERAGE_DECIMALS } from '@/constants/numbers'; import { getHydratedTradingData, isStopLossOrder, isTakeProfitOrder } from '@/lib/orders'; import { getHydratedPositionData } from '@/lib/positions'; @@ -70,6 +72,15 @@ export const getExistingOpenPositions = createAppSelector([getOpenPositions], (a allOpenPositions?.filter((position) => position.side.current !== AbacusPositionSide.NONE) ); +/** + * + * @returns All SubaccountOrders that have a margin mode of Isolated and no existing position for the market. + */ +export const getNonZeroPendingPositions = createAppSelector( + [(state: RootState) => state.account.subaccount?.pendingPositions], + (pending) => pending?.toArray().filter((p) => (p.equity?.current ?? 0) > 0) +); + /** * @param marketId * @returns user's position details with the given marketId @@ -103,6 +114,24 @@ export const getCurrentMarketPositionData = (state: RootState) => { )[currentMarketId!]; }; +/** + * @returns the current leverage of the isolated position. Selector will return null if position is not isolated or does not exist. + */ +export const getCurrentMarketIsolatedPositionLeverage = createAppSelector( + [getCurrentMarketPositionData], + (position) => { + if ( + position?.childSubaccountNumber && + position.childSubaccountNumber >= NUM_PARENT_SUBACCOUNTS && + position.leverage?.current + ) { + return Math.abs(Number(position.leverage.current.toFixed(LEVERAGE_DECIMALS))); + } + + return 0; + } +); + /** * @param state * @returns list of orders for the currently connected subaccount @@ -172,6 +201,28 @@ export const getSubaccountOpenOrders = createAppSelector([getSubaccountOrders], ) ); +export const getPendingIsolatedOrders = createAppSelector( + [getSubaccountOrders, getExistingOpenPositions, getPerpetualMarkets], + (allOrders, allOpenPositions, allMarkets) => { + const allValidOrders = (allOrders ?? []) + .filter( + (o) => + (o.status === AbacusOrderStatus.open || + o.status === AbacusOrderStatus.pending || + o.status === AbacusOrderStatus.partiallyFilled || + o.status === AbacusOrderStatus.untriggered) && + o.marginMode === AbacusMarginMode.isolated + ) + // eslint-disable-next-line prefer-object-spread + .map((o) => Object.assign({}, o, { assetId: allMarkets?.[o.marketId]?.assetId })); + const allOpenPositionAssetIds = new Set(allOpenPositions?.map((p) => p.assetId) ?? []); + return groupBy( + allValidOrders.filter((o) => !allOpenPositionAssetIds.has(o.assetId ?? '')), + (o) => o.marketId + ); + } +); + /** * @param state * @returns order with the specified id diff --git a/src/state/inputs.ts b/src/state/inputs.ts index 369391635..6ab61ce94 100644 --- a/src/state/inputs.ts +++ b/src/state/inputs.ts @@ -2,6 +2,7 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; import assign from 'lodash/assign'; import type { + AdjustIsolatedMarginInputs, ClosePositionInputs, InputError, Inputs, @@ -21,6 +22,7 @@ export interface InputsState { inputErrors?: Nullable; tradeFormInputs: TradeFormInputs; tradeInputs?: Nullable; + adjustIsolatedMarginInputs?: Nullable; closePositionInputs?: Nullable; triggerOrdersInputs?: Nullable; transferInputs?: Nullable; @@ -42,8 +44,15 @@ export const inputsSlice = createSlice({ initialState, reducers: { setInputs: (state, action: PayloadAction>) => { - const { current, errors, trade, closePosition, transfer, triggerOrders } = - action.payload ?? {}; + const { + current, + errors, + trade, + closePosition, + transfer, + triggerOrders, + adjustIsolatedMargin, + } = action.payload ?? {}; return { ...state, @@ -51,6 +60,7 @@ export const inputsSlice = createSlice({ inputErrors: errors?.toArray(), tradeInputs: trade, closePositionInputs: closePosition, + adjustIsolatedMarginInputs: adjustIsolatedMargin, transferInputs: safeAssign({}, transfer, { isCctp: !!transfer?.isCctp, }), diff --git a/src/state/inputsCalculator.ts b/src/state/inputsCalculator.ts new file mode 100644 index 000000000..2414a3752 --- /dev/null +++ b/src/state/inputsCalculator.ts @@ -0,0 +1,22 @@ +import { AbacusMarginMode } from '@/constants/abacus'; + +import { MustBigNumber } from '@/lib/numbers'; +import { orEmptyObj } from '@/lib/typeUtils'; + +import { getCurrentMarketPositionData } from './accountSelectors'; +import { createAppSelector } from './appTypes'; +import { getCurrentInput, getInputClosePositionData } from './inputsSelectors'; + +export const getIsClosingIsolatedMarginPosition = createAppSelector( + [getCurrentInput, getInputClosePositionData, getCurrentMarketPositionData], + (currentInput, closePositionInput, position) => { + const { size } = orEmptyObj(closePositionInput); + const { marginMode } = orEmptyObj(position); + + return ( + currentInput === 'closePosition' && + MustBigNumber(size?.size).abs().gt(0) && + marginMode === AbacusMarginMode.isolated + ); + } +); diff --git a/src/state/inputsSelectors.ts b/src/state/inputsSelectors.ts index 0a5ff745f..dacd3e654 100644 --- a/src/state/inputsSelectors.ts +++ b/src/state/inputsSelectors.ts @@ -44,7 +44,7 @@ export const getInputTradeTargetLeverage = (state: RootState) => /** * @param state - * @returns ValidationErrors of the current Input type (Trade or Transfer) + * @returns ValidationErrors of the current Input type */ export const getInputErrors = (state: RootState) => state.inputs.inputErrors; @@ -99,6 +99,12 @@ export const getTriggerOrdersInputErrors = (state: RootState) => { */ export const getTriggerOrdersInputs = (state: RootState) => state.inputs.triggerOrdersInputs; +/** + * @returns AdjustIsolatedMarginInputs + */ +export const getAdjustIsolatedMarginInputs = (state: RootState) => + state.inputs.adjustIsolatedMarginInputs; + /** * @returns Data needed for the TradeForm (price, size, summary, input render options, and errors/input validation) */ @@ -112,14 +118,18 @@ export const useTradeFormData = () => { const { needsLimitPrice, + needsMarginMode, + needsTargetLeverage, needsTrailingPercent, needsTriggerPrice, - executionOptions, needsGoodUntil, needsPostOnly, needsReduceOnly, postOnlyTooltip, reduceOnlyTooltip, + + executionOptions, + marginModeOptions, timeInForceOptions, } = tradeOptions ?? {}; @@ -129,14 +139,18 @@ export const useTradeFormData = () => { summary, needsLimitPrice, + needsMarginMode, + needsTargetLeverage, needsTrailingPercent, needsTriggerPrice, - executionOptions, needsGoodUntil, needsPostOnly, needsReduceOnly, postOnlyTooltip, reduceOnlyTooltip, + + executionOptions, + marginModeOptions, timeInForceOptions, tradeErrors, diff --git a/src/state/perpetualsSelectors.ts b/src/state/perpetualsSelectors.ts index 5d06e0df9..8aa458386 100644 --- a/src/state/perpetualsSelectors.ts +++ b/src/state/perpetualsSelectors.ts @@ -1,7 +1,9 @@ import { Candle, TradingViewBar } from '@/constants/candles'; import { EMPTY_ARR, EMPTY_OBJ } from '@/constants/objects'; +import { BIG_NUMBERS } from '@/lib/numbers'; import { mapCandle } from '@/lib/tradingView/utils'; +import { orEmptyObj } from '@/lib/typeUtils'; import { type RootState } from './_store'; import { createAppSelector } from './appTypes'; @@ -169,3 +171,23 @@ export const getCurrentMarketNextFundingRate = createAppSelector( [getCurrentMarketData], (marketData) => marketData?.perpetual?.nextFundingRate ); + +/** + * @returns Specified market's max leverage + */ +export const getMarketMaxLeverage = createAppSelector( + [(state: RootState, marketId: string) => getMarketConfig(state, marketId)], + (marketConfig) => { + const { effectiveInitialMarginFraction, initialMarginFraction } = orEmptyObj(marketConfig); + + if (effectiveInitialMarginFraction) { + return BIG_NUMBERS.ONE.div(effectiveInitialMarginFraction).toNumber(); + } + + if (initialMarginFraction) { + return BIG_NUMBERS.ONE.div(initialMarginFraction).toNumber(); + } + + return 10; // safe default + } +); diff --git a/src/styles/constants.css b/src/styles/constants.css index 213f44260..5c0ab7e6d 100644 --- a/src/styles/constants.css +++ b/src/styles/constants.css @@ -27,7 +27,7 @@ --tableHeader-height: 2.5rem; --tableHeader-height-mobile: 3.25rem; - --account-info-section-height: 11.25rem; + --account-info-section-height: 7rem; --position-details-width: 23rem; /* Auto grid constants */ diff --git a/src/styles/layoutMixins.ts b/src/styles/layoutMixins.ts index a8aafd7cd..dbf107597 100644 --- a/src/styles/layoutMixins.ts +++ b/src/styles/layoutMixins.ts @@ -76,7 +76,7 @@ const contentSectionDetached = css` ${() => contentSection} ${() => stickyLeft} - max-width: min(var(--content-container-width), var(--content-max-width)); + max-width: min(var(--content-container-width), var(--content-max-width)); transition: max-width 0.3s var(--ease-out-expo); `; diff --git a/src/views/AccountInfo.tsx b/src/views/AccountInfo.tsx index 56f4ec2c3..91ccfd803 100644 --- a/src/views/AccountInfo.tsx +++ b/src/views/AccountInfo.tsx @@ -50,7 +50,7 @@ const $DisconnectedAccountInfoContainer = styled.div` ${layoutMixins.column} justify-items: center; text-align: center; - gap: 1em; + gap: 0.5em; p { font: var(--font-small-book); diff --git a/src/views/AccountInfo/AccountInfoConnectedState.tsx b/src/views/AccountInfo/AccountInfoConnectedState.tsx index 662b74cf2..f181d2d5b 100644 --- a/src/views/AccountInfo/AccountInfoConnectedState.tsx +++ b/src/views/AccountInfo/AccountInfoConnectedState.tsx @@ -21,26 +21,24 @@ import { Details } from '@/components/Details'; import { Icon, IconName } from '@/components/Icon'; import { IconButton } from '@/components/IconButton'; import { MarginUsageRing } from '@/components/MarginUsageRing'; -import { Output, OutputType } from '@/components/Output'; -import { UsageBars } from '@/components/UsageBars'; +import { OutputType } from '@/components/Output'; import { WithTooltip } from '@/components/WithTooltip'; import { calculateIsAccountLoading } from '@/state/accountCalculators'; import { getSubaccount } from '@/state/accountSelectors'; import { useAppDispatch, useAppSelector } from '@/state/appTypes'; import { openDialog } from '@/state/dialogs'; +import { getIsClosingIsolatedMarginPosition } from '@/state/inputsCalculator'; import { getInputErrors } from '@/state/inputsSelectors'; -import { getCurrentMarketId } from '@/state/perpetualsSelectors'; import { isNumber, MustBigNumber } from '@/lib/numbers'; +import { getTradeStateWithDoubleValuesHasDiff } from '@/lib/tradeData'; import { AccountInfoDiffOutput } from './AccountInfoDiffOutput'; enum AccountInfoItem { BuyingPower = 'buying-power', - Equity = 'equity', MarginUsage = 'margin-usage', - Leverage = 'leverage', } const getUsageValue = (value: Nullable>) => { @@ -60,19 +58,18 @@ export const AccountInfoConnectedState = () => { const { dydxAccounts } = useAccounts(); const inputErrors = useAppSelector(getInputErrors, shallowEqual); - const currentMarketId = useAppSelector(getCurrentMarketId); const subAccount = useAppSelector(getSubaccount, shallowEqual); const isLoading = useAppSelector(calculateIsAccountLoading); + const isClosingIsolatedPosition = useAppSelector(getIsClosingIsolatedMarginPosition); const listOfErrors = inputErrors?.map(({ code }: { code: string }) => code); - const { buyingPower, equity, marginUsage, leverage } = subAccount ?? {}; + const { freeCollateral, marginUsage } = subAccount ?? {}; const hasDiff = - (marginUsage?.postOrder !== null && - !MustBigNumber(marginUsage?.postOrder).eq(MustBigNumber(marginUsage?.current))) || - (buyingPower?.postOrder !== null && - !MustBigNumber(buyingPower?.postOrder).eq(MustBigNumber(buyingPower?.current))); + !isClosingIsolatedPosition && + ((!!marginUsage?.postOrder && getTradeStateWithDoubleValuesHasDiff(marginUsage)) || + (!!freeCollateral?.postOrder && getTradeStateWithDoubleValuesHasDiff(freeCollateral))); const showHeader = !hasDiff && !isTablet; @@ -132,35 +129,14 @@ export const AccountInfoConnectedState = () => { )} <$Details items={[ - { - key: AccountInfoItem.Leverage, - // hasError: - // listOfErrors?.includes('INVALID_LARGE_POSITION_LEVERAGE') || - // listOfErrors?.includes('INVALID_NEW_POSITION_LEVERAGE'), - tooltip: 'leverage', - isPositive: !MustBigNumber(leverage?.postOrder).gt(MustBigNumber(leverage?.current)), - label: stringGetter({ key: STRING_KEYS.LEVERAGE }), - type: OutputType.Multiple, - value: leverage, - slotRight: <$UsageBars value={getUsageValue(leverage)} />, - }, - { - key: AccountInfoItem.Equity, - // hasError: isNumber(equity?.postOrder) && MustBigNumber(equity?.postOrder).lt(0), - tooltip: 'equity', - isPositive: MustBigNumber(equity?.postOrder).gt(MustBigNumber(equity?.current)), - label: stringGetter({ key: STRING_KEYS.EQUITY }), - type: OutputType.Fiat, - value: equity, - }, { key: AccountInfoItem.MarginUsage, hasError: listOfErrors?.includes('INVALID_NEW_ACCOUNT_MARGIN_USAGE'), - tooltip: 'margin-usage', + tooltip: 'cross-margin-usage', isPositive: !MustBigNumber(marginUsage?.postOrder).gt( MustBigNumber(marginUsage?.current) ), - label: stringGetter({ key: STRING_KEYS.MARGIN_USAGE }), + label: stringGetter({ key: STRING_KEYS.CROSS_MARGIN_USAGE }), type: OutputType.Percent, value: marginUsage, slotRight: , @@ -168,25 +144,24 @@ export const AccountInfoConnectedState = () => { { key: AccountInfoItem.BuyingPower, hasError: - isNumber(buyingPower?.postOrder) && MustBigNumber(buyingPower?.postOrder).lt(0), - tooltip: 'buying-power', - stringParams: { MARKET: currentMarketId }, - isPositive: MustBigNumber(buyingPower?.postOrder).gt( - MustBigNumber(buyingPower?.current) + isNumber(freeCollateral?.postOrder) && + MustBigNumber(freeCollateral?.postOrder).lt(0), + tooltip: 'cross-free-collateral', + isPositive: MustBigNumber(freeCollateral?.postOrder).gt( + MustBigNumber(freeCollateral?.current) ), - label: stringGetter({ key: STRING_KEYS.BUYING_POWER }), + label: stringGetter({ key: STRING_KEYS.CROSS_FREE_COLLATERAL }), type: OutputType.Fiat, value: - MustBigNumber(buyingPower?.current).lt(0) && buyingPower?.postOrder === null + MustBigNumber(freeCollateral?.current).lt(0) && freeCollateral?.postOrder === null ? undefined - : buyingPower, + : freeCollateral, }, ].map( ({ key, hasError, tooltip = undefined, - stringParams, isPositive, label, type, @@ -195,21 +170,20 @@ export const AccountInfoConnectedState = () => { }) => ({ key, label: ( - + <$WithUsage> {label} {hasError ? <$CautionIcon iconName={IconName.CautionCircle} /> : slotRight} ), - value: [AccountInfoItem.Leverage, AccountInfoItem.Equity].includes(key) ? ( - <$Output type={type} value={value?.current} /> - ) : ( + value: ( ), }) @@ -277,8 +251,8 @@ const $Details = styled(Details)<{ showHeader?: boolean }>` > * { height: ${({ showHeader }) => !showHeader - ? `calc(var(--account-info-section-height) / 2)` - : `calc((var(--account-info-section-height) - var(--tabs-height)) / 2)`}; + ? `calc(var(--account-info-section-height))` + : `calc((var(--account-info-section-height) - var(--tabs-height)))`}; padding: 0.625rem 1rem; } @@ -292,15 +266,6 @@ const $Details = styled(Details)<{ showHeader?: boolean }>` } `; -const $UsageBars = styled(UsageBars)` - margin-top: -0.125rem; -`; - -const $Output = styled(Output)<{ isNegative?: boolean }>` - color: var(--color-text-1); - font: var(--font-base-book); -`; - const $Header = styled.header` ${layoutMixins.spacedRow} font: var(--font-small-book); @@ -317,6 +282,9 @@ const $ConnectedAccountInfoContainer = styled.div<{ $showHeader?: boolean }>` @media ${breakpoints.notTablet} { ${layoutMixins.withOuterAndInnerBorders} + > *:last-child { + box-shadow: none; + } } ${({ $showHeader }) => diff --git a/src/views/AccountInfo/AccountInfoDiffOutput.tsx b/src/views/AccountInfo/AccountInfoDiffOutput.tsx index 7e00b6de4..0502772e7 100644 --- a/src/views/AccountInfo/AccountInfoDiffOutput.tsx +++ b/src/views/AccountInfo/AccountInfoDiffOutput.tsx @@ -10,15 +10,22 @@ import { isNumber } from '@/lib/numbers'; type ElementProps = { hasError?: boolean | null; + hideDiff?: boolean; isPositive: boolean; type: OutputType; value: Nullable>; }; -export const AccountInfoDiffOutput = ({ hasError, isPositive, type, value }: ElementProps) => { +export const AccountInfoDiffOutput = ({ + hasError, + hideDiff, + isPositive, + type, + value, +}: ElementProps) => { const currentValue = value?.current; const postOrderValue = value?.postOrder; - const hasDiffPostOrder = isNumber(postOrderValue) && currentValue !== postOrderValue; + const hasDiffPostOrder = isNumber(postOrderValue) && currentValue !== postOrderValue && !hideDiff; return ( <$DiffOutput diff --git a/src/views/MarketDetails.tsx b/src/views/MarketDetails.tsx index ad42f7330..00f01231b 100644 --- a/src/views/MarketDetails.tsx +++ b/src/views/MarketDetails.tsx @@ -2,6 +2,7 @@ import BigNumber from 'bignumber.js'; import { shallowEqual } from 'react-redux'; import styled from 'styled-components'; +import { PerpetualMarketType } from '@/constants/abacus'; import { ButtonShape, ButtonSize, ButtonType } from '@/constants/buttons'; import { STRING_KEYS } from '@/constants/localization'; @@ -41,6 +42,7 @@ export const MarketDetails: React.FC = () => { effectiveInitialMarginFraction, maintenanceMarginFraction, minOrderSize, + perpetualMarketType, stepSizeDecimals, tickSizeDecimals, } = configs; @@ -63,6 +65,14 @@ export const MarketDetails: React.FC = () => { label: stringGetter({ key: STRING_KEYS.MARKET_NAME }), value: market, }, + { + key: 'market-type', + label: stringGetter({ key: STRING_KEYS.TYPE }), + value: + perpetualMarketType === PerpetualMarketType.CROSS + ? stringGetter({ key: STRING_KEYS.CROSS }) + : stringGetter({ key: STRING_KEYS.ISOLATED }), + }, { key: 'tick-size', label: stringGetter({ key: STRING_KEYS.TICK_SIZE }), diff --git a/src/views/TradeBoxOrderView.tsx b/src/views/TradeBoxOrderView.tsx index feb9d0ba0..de1292f51 100644 --- a/src/views/TradeBoxOrderView.tsx +++ b/src/views/TradeBoxOrderView.tsx @@ -1,71 +1,29 @@ import { useCallback } from 'react'; -import { shallowEqual } from 'react-redux'; import styled from 'styled-components'; import { TradeInputField } from '@/constants/abacus'; -import { STRING_KEYS, StringKey } from '@/constants/localization'; +import { OnboardingState } from '@/constants/account'; import { TradeTypes } from '@/constants/trade'; -import { useStringGetter } from '@/hooks/useStringGetter'; - +import breakpoints from '@/styles/breakpoints'; import { layoutMixins } from '@/styles/layoutMixins'; -import { TabItem, Tabs } from '@/components/Tabs'; +import { Tabs } from '@/components/Tabs'; +import { getOnboardingState } from '@/state/accountSelectors'; import { useAppSelector } from '@/state/appTypes'; -import { getInputTradeData, getInputTradeOptions } from '@/state/inputsSelectors'; import abacusStateManager from '@/lib/abacus'; -import { isTruthy } from '@/lib/isTruthy'; import { TradeForm } from './forms/TradeForm'; - -const useTradeTypeOptions = (): { - tradeTypeItems: TabItem[]; - selectedTradeType: TradeTypes; -} => { - const stringGetter = useStringGetter(); - - const currentTradeData = useAppSelector(getInputTradeData, shallowEqual); - const selectedTradeType = (currentTradeData?.type?.rawValue as TradeTypes) ?? TradeTypes.LIMIT; - - const { typeOptions } = useAppSelector(getInputTradeOptions, shallowEqual) ?? {}; - const allTradeTypeItems = typeOptions?.toArray()?.map(({ type, stringKey }) => ({ - value: type, - label: stringGetter({ - key: - type === TradeTypes.TAKE_PROFIT ? STRING_KEYS.TAKE_PROFIT_LIMIT : (stringKey as StringKey), - }), - })); - - return { - selectedTradeType, - tradeTypeItems: allTradeTypeItems - ? [ - allTradeTypeItems?.shift(), // Limit order is always first - allTradeTypeItems?.shift(), // Market order is always second - // All conditional orders labeled under "Stop Order" - allTradeTypeItems?.length && { - label: stringGetter({ key: STRING_KEYS.STOP_ORDER_SHORT }), - value: '', - subitems: allTradeTypeItems - ?.map( - ({ value, label }) => - value != null && { - value, - label, - } - ) - .filter(isTruthy), - }, - ].filter(isTruthy) - : [], - }; -}; +import { MarginModeSelector } from './forms/TradeForm/MarginModeSelector'; +import { TargetLeverageButton } from './forms/TradeForm/TargetLeverageButton'; +import { TradeSideToggle } from './forms/TradeForm/TradeSideToggle'; +import { useTradeTypeOptions } from './forms/TradeForm/useTradeTypeOptions'; export const TradeBoxOrderView = () => { - const onTradeTypeChange = useCallback((tradeType?: string) => { + const onTradeTypeChange = useCallback((tradeType?: TradeTypes) => { if (tradeType) { abacusStateManager.clearTradeInputValues(); abacusStateManager.setTradeValue({ value: tradeType, field: TradeInputField.type }); @@ -74,26 +32,80 @@ export const TradeBoxOrderView = () => { const { selectedTradeType, tradeTypeItems } = useTradeTypeOptions(); + const onboardingState = useAppSelector(getOnboardingState); + const allowChangingOrderType = onboardingState === OnboardingState.AccountConnected; + return ( - <$Tabs - key={selectedTradeType} - value={selectedTradeType} - items={tradeTypeItems} - onValueChange={onTradeTypeChange} - sharedContent={ - <$Container> - - - } - fullWidthTabs - /> + <$TradeBoxOrderViewContainer> + <$TopActionsRow> + <$MarginAndLeverageButtons> + + + + + + <$Tabs + key={selectedTradeType} + value={selectedTradeType} + items={tradeTypeItems} + onValueChange={onTradeTypeChange} + withBorders={false} + disabled={!allowChangingOrderType} + sharedContent={ + <$Container> + + + } + /> + ); }; +const $TradeBoxOrderViewContainer = styled.div` + display: flex; + flex-direction: column; + gap: 0.25rem; + padding-top: 0.875rem; + min-height: 100%; +`; + const $Container = styled.div` ${layoutMixins.scrollArea} + border-top: var(--border-width) solid var(--border-color); `; const $Tabs = styled(Tabs)` overflow: hidden; + --tabs-height: 2.125rem; + --trigger-active-backgroundColor: --trigger-backgroundColor; + --trigger-active-underline-size: 2px; + + > header { + justify-content: space-around; + } ` as typeof Tabs; + +const $MarginAndLeverageButtons = styled.div` + ${layoutMixins.inlineRow} + gap: 0.5rem; + margin-right: 0.5rem; + + abbr, + button { + width: 100%; + height: 2.5rem; + } +`; + +const $TopActionsRow = styled.div` + display: grid; + grid-auto-flow: column; + + padding-left: 1rem; + padding-right: 1rem; + + @media ${breakpoints.tablet} { + grid-auto-columns: var(--orderbox-column-width) 1fr; + gap: var(--form-input-gap); + } +`; diff --git a/src/views/dialogs/AdjustIsolatedMarginDialog.tsx b/src/views/dialogs/AdjustIsolatedMarginDialog.tsx index a14404085..2236ac587 100644 --- a/src/views/dialogs/AdjustIsolatedMarginDialog.tsx +++ b/src/views/dialogs/AdjustIsolatedMarginDialog.tsx @@ -1,3 +1,5 @@ +import { useCallback } from 'react'; + import { shallowEqual } from 'react-redux'; import styled from 'styled-components'; @@ -25,19 +27,28 @@ export const AdjustIsolatedMarginDialog = ({ positionId, setIsOpen }: ElementPro const stringGetter = useStringGetter(); const subaccountPosition = useAppSelector(getOpenPositionFromId(positionId), shallowEqual); + const onIsolatedMarginAdjustment = useCallback(() => setIsOpen?.(false), [setIsOpen]); + return ( - } title={stringGetter({ key: STRING_KEYS.ADJUST_ISOLATED_MARGIN })} > <$Content> - + - + ); }; + +const $Dialog = styled(Dialog)` + --dialog-width: 25rem; +`; const $Content = styled.div` ${layoutMixins.column} gap: 1rem; diff --git a/src/views/dialogs/CancelAllOrdersDialog.tsx b/src/views/dialogs/CancelAllOrdersDialog.tsx new file mode 100644 index 000000000..797e61c27 --- /dev/null +++ b/src/views/dialogs/CancelAllOrdersDialog.tsx @@ -0,0 +1,46 @@ +import { useCallback, useMemo } from 'react'; + +import { shallowEqual, useSelector } from 'react-redux'; + +import { STRING_KEYS } from '@/constants/localization'; + +import { useStringGetter } from '@/hooks/useStringGetter'; + +import { AssetIcon } from '@/components/AssetIcon'; +import { Dialog } from '@/components/Dialog'; + +import { getNonZeroPendingPositions } from '@/state/accountSelectors'; + +import { CancelAllOrdersInMarketForm } from '../forms/CancelAllOrdersInMarketForm'; + +type CancelAllOrdersDialogProps = { + setIsOpen?: (open: boolean) => void; + marketId: string; +}; + +export const CancelAllOrdersDialog = ({ setIsOpen, marketId }: CancelAllOrdersDialogProps) => { + const stringGetter = useStringGetter(); + const allPending = useSelector(getNonZeroPendingPositions, shallowEqual); + const pendingPosition = useMemo( + () => allPending?.find((p) => p.marketId === marketId), + [allPending, marketId] + ); + + const onSuccessfulCancel = useCallback(() => setIsOpen?.(false), [setIsOpen]); + + return ( + } + title={stringGetter({ + key: + (pendingPosition?.orderCount ?? 0) !== 1 + ? STRING_KEYS.CANCEL_ORDERS + : STRING_KEYS.CANCEL_ORDER, + })} + > + + + ); +}; diff --git a/src/views/dialogs/DetailsDialog/OrderDetailsDialog.tsx b/src/views/dialogs/DetailsDialog/OrderDetailsDialog.tsx index 5c3d362b2..1cd05bf78 100644 --- a/src/views/dialogs/DetailsDialog/OrderDetailsDialog.tsx +++ b/src/views/dialogs/DetailsDialog/OrderDetailsDialog.tsx @@ -2,9 +2,15 @@ import { OrderFlags, OrderSide } from '@dydxprotocol/v4-client-js'; import { shallowEqual } from 'react-redux'; import styled from 'styled-components'; -import { AbacusOrderStatus, AbacusOrderTypes, type Nullable } from '@/constants/abacus'; +import { + AbacusMarginMode, + AbacusOrderStatus, + AbacusOrderTypes, + type Nullable, +} from '@/constants/abacus'; import { ButtonAction } from '@/constants/buttons'; import { STRING_KEYS, type StringKey } from '@/constants/localization'; +import { isMainnet } from '@/constants/networks'; import { CancelOrderStatuses } from '@/constants/trade'; import { useParameterizedSelector } from '@/hooks/useParameterizedSelector'; @@ -29,6 +35,7 @@ import { getSelectedLocale } from '@/state/localizationSelectors'; import { MustBigNumber } from '@/lib/numbers'; import { isMarketOrderType, isOrderStatusClearable, relativeTimeString } from '@/lib/orders'; +import { getMarginModeFromSubaccountNumber } from '@/lib/tradeData'; type ElementProps = { orderId: string; @@ -63,6 +70,7 @@ export const OrderDetailsDialog = ({ orderId, setIsOpen }: ElementProps) => { size, status, stepSizeDecimals, + subaccountNumber, tickSizeDecimals, trailingPercent, triggerPrice, @@ -70,6 +78,13 @@ export const OrderDetailsDialog = ({ orderId, setIsOpen }: ElementProps) => { orderFlags, } = useParameterizedSelector(getOrderDetails, orderId)! ?? {}; + const marginMode = getMarginModeFromSubaccountNumber(subaccountNumber); + + const marginModeLabel = + marginMode === AbacusMarginMode.cross + ? stringGetter({ key: STRING_KEYS.CROSS }) + : stringGetter({ key: STRING_KEYS.ISOLATED }); + const renderOrderPrice = ({ type: innerType, price: innerPrice, @@ -95,6 +110,11 @@ export const OrderDetailsDialog = ({ orderId, setIsOpen }: ElementProps) => { label: stringGetter({ key: STRING_KEYS.MARKET }), value: marketId, }, + { + key: 'margin-mode', + label: stringGetter({ key: STRING_KEYS.MARGIN_MODE }), + value: marginModeLabel, + }, { key: 'side', label: stringGetter({ key: STRING_KEYS.SIDE }), @@ -176,6 +196,11 @@ export const OrderDetailsDialog = ({ orderId, setIsOpen }: ElementProps) => { label: stringGetter({ key: STRING_KEYS.CREATED_AT }), value: renderOrderTime({ timeInMs: createdAtMilliseconds }), }, + { + key: 'subaccount', + label: 'Subaccount # (Debug Only)', + value: !isMainnet ? `${subaccountNumber}` : undefined, + }, ] satisfies DetailsItem[] ).filter((item) => Boolean(item.value)); diff --git a/src/views/dialogs/TradeDialog.tsx b/src/views/dialogs/TradeDialog.tsx index 10b5755dc..ce67c7bf2 100644 --- a/src/views/dialogs/TradeDialog.tsx +++ b/src/views/dialogs/TradeDialog.tsx @@ -2,7 +2,6 @@ import { useState } from 'react'; import styled, { css } from 'styled-components'; -import { DialogTypes } from '@/constants/dialogs'; import { STRING_KEYS } from '@/constants/localization'; import { MobilePlaceOrderSteps } from '@/constants/trade'; @@ -11,18 +10,14 @@ import { useStringGetter } from '@/hooks/useStringGetter'; import { layoutMixins } from '@/styles/layoutMixins'; -import { Button } from '@/components/Button'; import { Dialog, DialogPlacement } from '@/components/Dialog'; import { GreenCheckCircle } from '@/components/GreenCheckCircle'; import { Icon, IconName } from '@/components/Icon'; import { Ring } from '@/components/Ring'; import { TradeForm } from '@/views/forms/TradeForm'; -import { useAppDispatch } from '@/state/appTypes'; -import { openDialog } from '@/state/dialogs'; - -import { testFlags } from '@/lib/testFlags'; - +import { MarginModeSelector } from '../forms/TradeForm/MarginModeSelector'; +import { TargetLeverageButton } from '../forms/TradeForm/TargetLeverageButton'; import { TradeSideToggle } from '../forms/TradeForm/TradeSideToggle'; type ElementProps = { @@ -33,9 +28,7 @@ type ElementProps = { export const TradeDialog = ({ isOpen, setIsOpen, slotTrigger }: ElementProps) => { const { isMobile } = useBreakpoints(); - const dispatch = useAppDispatch(); const stringGetter = useStringGetter(); - const [currentStep, setCurrentStep] = useState( MobilePlaceOrderSteps.EditOrder ); @@ -55,32 +48,15 @@ export const TradeDialog = ({ isOpen, setIsOpen, slotTrigger }: ElementProps) => hasHeaderBorder {...{ [MobilePlaceOrderSteps.EditOrder]: { - title: testFlags.isolatedMargin ? ( + title: ( <$EditTradeHeader> - - - + <$MarginControls> + <$MarginModeSelector openInTradeBox={false} /> + <$TargetLeverageButton /> + - ) : ( - ), }, [MobilePlaceOrderSteps.PreviewOrder]: { @@ -114,6 +90,7 @@ export const TradeDialog = ({ isOpen, setIsOpen, slotTrigger }: ElementProps) => ); }; + const $Dialog = styled(Dialog)<{ currentStep: MobilePlaceOrderSteps }>` --dialog-backgroundColor: var(--color-layer-2); --dialog-header-height: 1rem; @@ -133,10 +110,27 @@ const $Dialog = styled(Dialog)<{ currentStep: MobilePlaceOrderSteps }>` const $EditTradeHeader = styled.div` display: grid; - grid-template-columns: auto auto 1fr; + grid-template-columns: 1fr 2fr; gap: 0.5rem; `; +const $MarginControls = styled.div` + display: flex; + gap: 0.5rem; +`; + +const $MarginModeSelector = styled(MarginModeSelector)` + flex: 1; +`; + +const $TargetLeverageButton = styled(TargetLeverageButton)` + flex: 1; + + button { + width: 100%; + } +`; + const $TradeForm = styled(TradeForm)` --tradeBox-content-paddingTop: 1rem; --tradeBox-content-paddingRight: 1.5rem; diff --git a/src/views/forms/AdjustIsolatedMarginForm.tsx b/src/views/forms/AdjustIsolatedMarginForm.tsx index 38a7c3038..3dcc9a779 100644 --- a/src/views/forms/AdjustIsolatedMarginForm.tsx +++ b/src/views/forms/AdjustIsolatedMarginForm.tsx @@ -1,43 +1,55 @@ -import { FormEvent, useMemo, useState } from 'react'; +import { FormEvent, useEffect, useMemo, useState } from 'react'; +import { NumberFormatValues } from 'react-number-format'; import { shallowEqual } from 'react-redux'; import styled from 'styled-components'; -import type { SubaccountPosition } from '@/constants/abacus'; -import { ButtonAction, ButtonShape } from '@/constants/buttons'; +import { + AdjustIsolatedMarginInputField, + IsolatedMarginAdjustmentType, + type SubaccountPosition, +} from '@/constants/abacus'; +import { AlertType } from '@/constants/alerts'; +import { + ButtonAction, + ButtonShape, + ButtonSize, + ButtonState, + ButtonType, +} from '@/constants/buttons'; import { STRING_KEYS } from '@/constants/localization'; -import { NumberSign, USD_DECIMALS } from '@/constants/numbers'; +import { NumberSign, PERCENT_DECIMALS } from '@/constants/numbers'; import { useStringGetter } from '@/hooks/useStringGetter'; +import { useSubaccount } from '@/hooks/useSubaccount'; import { formMixins } from '@/styles/formMixins'; import { layoutMixins } from '@/styles/layoutMixins'; +import { AlertMessage } from '@/components/AlertMessage'; import { Button } from '@/components/Button'; import { DiffOutput } from '@/components/DiffOutput'; import { FormInput } from '@/components/FormInput'; import { GradientCard } from '@/components/GradientCard'; import { InputType } from '@/components/Input'; -import { OutputType } from '@/components/Output'; +import { OutputType, ShowSign } from '@/components/Output'; import { ToggleGroup } from '@/components/ToggleGroup'; import { WithDetailsReceipt } from '@/components/WithDetailsReceipt'; -import { getOpenPositionFromId, getSubaccount } from '@/state/accountSelectors'; +import { getOpenPositionFromId } from '@/state/accountSelectors'; import { useAppSelector } from '@/state/appTypes'; -import { getMarketConfig } from '@/state/perpetualsSelectors'; +import { getAdjustIsolatedMarginInputs } from '@/state/inputsSelectors'; +import { getMarketConfig, getMarketMaxLeverage } from '@/state/perpetualsSelectors'; +import abacusStateManager from '@/lib/abacus'; +import { MustBigNumber } from '@/lib/numbers'; import { objectEntries } from '@/lib/objectHelpers'; -import { calculatePositionMargin } from '@/lib/tradeData'; type ElementProps = { marketId: SubaccountPosition['id']; + onIsolatedMarginAdjustment?(): void; }; -enum MarginAction { - ADD = 'ADD', - REMOVE = 'REMOVE', -} - const SIZE_PERCENT_OPTIONS = { '5%': '0.05', '10%': '0.1', @@ -46,46 +58,157 @@ const SIZE_PERCENT_OPTIONS = { '75%': '0.75', }; -export const AdjustIsolatedMarginForm = ({ marketId }: ElementProps) => { +export const AdjustIsolatedMarginForm = ({ + marketId, + onIsolatedMarginAdjustment, +}: ElementProps) => { const stringGetter = useStringGetter(); - const [marginAction, setMarginAction] = useState(MarginAction.ADD); const subaccountPosition = useAppSelector(getOpenPositionFromId(marketId)); - const { adjustedMmf, leverage, liquidationPrice, notionalTotal } = subaccountPosition ?? {}; + const { childSubaccountNumber, marginUsage } = subaccountPosition ?? {}; const marketConfig = useAppSelector((s) => getMarketConfig(s, marketId)); + const adjustIsolatedMarginInputs = useAppSelector(getAdjustIsolatedMarginInputs, shallowEqual); + + const { + type: isolatedMarginAdjustmentType, + amount, + amountPercent, + summary, + } = adjustIsolatedMarginInputs ?? {}; + const { tickSizeDecimals } = marketConfig ?? {}; - /** - * @todo: Replace with Abacus functionality - */ - const [percent, setPercent] = useState(''); - const [amount, setAmount] = useState(''); - const onSubmit = () => {}; + useEffect(() => { + abacusStateManager.setAdjustIsolatedMarginValue({ + value: childSubaccountNumber, + field: AdjustIsolatedMarginInputField.ChildSubaccountNumber, + }); - const positionMargin = useMemo( - () => ({ - current: calculatePositionMargin({ - adjustedMmf: adjustedMmf?.current, - notionalTotal: notionalTotal?.current, - }).toFixed(tickSizeDecimals ?? USD_DECIMALS), - postOrder: calculatePositionMargin({ - adjustedMmf: adjustedMmf?.postOrder, - notionalTotal: notionalTotal?.postOrder, - }).toFixed(tickSizeDecimals ?? USD_DECIMALS), - }), - [adjustedMmf, notionalTotal, tickSizeDecimals] - ); + return () => { + abacusStateManager.setAdjustIsolatedMarginValue({ + value: null, + field: AdjustIsolatedMarginInputField.ChildSubaccountNumber, + }); + abacusStateManager.clearAdjustIsolatedMarginInputValues(); + abacusStateManager.clearTradeInputValues({ shouldResetSize: true }); + }; + }, [childSubaccountNumber]); + + const setAmount = ({ floatValue }: NumberFormatValues) => { + abacusStateManager.setAdjustIsolatedMarginValue({ + value: floatValue, + field: AdjustIsolatedMarginInputField.Amount, + }); + }; - const { freeCollateral, marginUsage } = useAppSelector(getSubaccount, shallowEqual) ?? {}; + const setPercent = (value: string) => { + abacusStateManager.setAdjustIsolatedMarginValue({ + value, + field: AdjustIsolatedMarginInputField.AmountPercent, + }); + }; + + const setMarginAction = (marginAction: string) => { + abacusStateManager.setAdjustIsolatedMarginValue({ + value: marginAction, + field: AdjustIsolatedMarginInputField.Type, + }); + }; + + const { adjustIsolatedMarginOfPosition } = useSubaccount(); + + const [errorMessage, setErrorMessage] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const onSubmit = () => { + setErrorMessage(null); + setIsSubmitting(true); + + adjustIsolatedMarginOfPosition({ + onError: (errorParams) => { + setIsSubmitting(false); + if (errorParams?.errorStringKey) { + setErrorMessage(stringGetter({ key: errorParams.errorStringKey })); + } + }, + onSuccess: () => { + setIsSubmitting(false); + abacusStateManager.clearAdjustIsolatedMarginInputValues(); + onIsolatedMarginAdjustment?.(); + }, + }); + }; const renderDiffOutput = ({ type, value, newValue, + showSign, withDiff, - }: Pick[0], 'type' | 'value' | 'newValue' | 'withDiff'>) => ( - + }: Pick< + Parameters[0], + 'type' | 'value' | 'newValue' | 'withDiff' | 'showSign' + >) => ( + ); + const { + crossFreeCollateral, + crossFreeCollateralUpdated, + crossMarginUsage, + crossMarginUsageUpdated, + positionMargin, + positionMarginUpdated, + positionLeverage, + positionLeverageUpdated, + liquidationPrice, + liquidationPriceUpdated, + } = summary ?? {}; + + /** + * TODO: Handle by adding AdjustIsolatedMarginValidator within Abacus + */ + const marketMaxLeverage = useAppSelector((s) => getMarketMaxLeverage(s, marketId)); + + const alertMessage = useMemo(() => { + if (isolatedMarginAdjustmentType === IsolatedMarginAdjustmentType.Add) { + if (crossMarginUsageUpdated && MustBigNumber(crossMarginUsageUpdated).gte(1)) { + return { + message: stringGetter({ key: STRING_KEYS.INVALID_NEW_ACCOUNT_MARGIN_USAGE }), + type: AlertType.Error, + }; + } + } else if (isolatedMarginAdjustmentType === IsolatedMarginAdjustmentType.Remove) { + if (marginUsage?.postOrder && MustBigNumber(marginUsage?.postOrder).gte(1)) { + return { + message: stringGetter({ key: STRING_KEYS.INVALID_NEW_ACCOUNT_MARGIN_USAGE }), + type: AlertType.Error, + }; + } + + if (positionLeverageUpdated && MustBigNumber(positionLeverageUpdated).gt(marketMaxLeverage)) { + return { + message: stringGetter({ key: STRING_KEYS.INVALID_NEW_POSITION_LEVERAGE }), + type: AlertType.Error, + }; + } + } + + return null; + }, [ + crossMarginUsageUpdated, + isolatedMarginAdjustmentType, + marginUsage, + marketMaxLeverage, + positionLeverageUpdated, + stringGetter, + ]); + const { freeCollateralDiffOutput, marginUsageDiffOutput, @@ -95,37 +218,47 @@ export const AdjustIsolatedMarginForm = ({ marketId }: ElementProps) => { () => ({ freeCollateralDiffOutput: renderDiffOutput({ withDiff: - !!freeCollateral?.postOrder && freeCollateral?.current !== freeCollateral?.postOrder, - value: freeCollateral?.current, - newValue: freeCollateral?.postOrder, - type: OutputType.Number, + !!crossFreeCollateralUpdated && crossFreeCollateral !== crossFreeCollateralUpdated, + value: crossFreeCollateral, + newValue: crossFreeCollateralUpdated, + type: OutputType.Fiat, }), marginUsageDiffOutput: renderDiffOutput({ - withDiff: !!marginUsage?.postOrder && marginUsage?.current !== marginUsage?.postOrder, - value: marginUsage?.current, - newValue: marginUsage?.postOrder, + withDiff: !!crossMarginUsageUpdated && crossMarginUsage !== crossMarginUsageUpdated, + value: crossMarginUsage, + newValue: crossMarginUsageUpdated, type: OutputType.Percent, }), positionMarginDiffOutput: renderDiffOutput({ - withDiff: !!positionMargin.postOrder && positionMargin.current !== positionMargin.postOrder, - value: positionMargin.current, - newValue: positionMargin.postOrder, + withDiff: !!positionMarginUpdated && positionMargin !== positionMarginUpdated, + value: positionMargin, + newValue: positionMarginUpdated, type: OutputType.Fiat, }), leverageDiffOutput: renderDiffOutput({ - withDiff: !!leverage?.postOrder && leverage?.current !== leverage?.postOrder, - value: leverage?.current, - newValue: leverage?.postOrder, + withDiff: !!positionLeverageUpdated && positionLeverage !== positionLeverageUpdated, + value: positionLeverage, + newValue: positionLeverageUpdated, type: OutputType.Multiple, + showSign: ShowSign.None, }), }), - [freeCollateral, marginUsage, positionMargin, leverage] + [ + crossFreeCollateral, + crossFreeCollateralUpdated, + crossMarginUsage, + crossMarginUsageUpdated, + positionMargin, + positionMarginUpdated, + positionLeverage, + positionLeverageUpdated, + ] ); const formConfig = - marginAction === MarginAction.ADD + isolatedMarginAdjustmentType === IsolatedMarginAdjustmentType.Add ? { - formLabel: stringGetter({ key: STRING_KEYS.ADDING }), + formLabel: stringGetter({ key: STRING_KEYS.AMOUNT_TO_ADD }), buttonLabel: stringGetter({ key: STRING_KEYS.ADD_MARGIN }), inputReceiptItems: [ { @@ -153,7 +286,7 @@ export const AdjustIsolatedMarginForm = ({ marketId }: ElementProps) => { ], } : { - formLabel: stringGetter({ key: STRING_KEYS.REMOVING }), + formLabel: stringGetter({ key: STRING_KEYS.AMOUNT_TO_REMOVE }), buttonLabel: stringGetter({ key: STRING_KEYS.REMOVE_MARGIN }), inputReceiptItems: [ { @@ -181,8 +314,28 @@ export const AdjustIsolatedMarginForm = ({ marketId }: ElementProps) => { ], }; - const CenterElement = ( - <$GradientCard fromColor="neutral" toColor="negative"> + const gradientToColor = useMemo(() => { + if (MustBigNumber(amount).isZero()) { + return 'neutral'; + } + + if (isolatedMarginAdjustmentType === IsolatedMarginAdjustmentType.Add) { + return 'positive'; + } + + if (isolatedMarginAdjustmentType === IsolatedMarginAdjustmentType.Remove) { + return 'negative'; + } + + return 'neutral'; + }, [amount, isolatedMarginAdjustmentType]); + + const CenterElement = alertMessage ? ( + {alertMessage.message} + ) : errorMessage ? ( + {errorMessage} + ) : ( + <$GradientCard fromColor="neutral" toColor={gradientToColor}> <$Column> <$TertiarySpan>{stringGetter({ key: STRING_KEYS.ESTIMATED })} {stringGetter({ key: STRING_KEYS.LIQUIDATION_PRICE })} @@ -190,13 +343,14 @@ export const AdjustIsolatedMarginForm = ({ marketId }: ElementProps) => {
@@ -212,37 +366,53 @@ export const AdjustIsolatedMarginForm = ({ marketId }: ElementProps) => { }} > - <$ToggleGroup - items={objectEntries(SIZE_PERCENT_OPTIONS).map(([key, value]) => ({ - label: key, - value: value.toString(), - }))} - value={percent} - onValueChange={setPercent} - shape={ButtonShape.Rectangle} - /> - - - + <$ToggleGroup + items={objectEntries(SIZE_PERCENT_OPTIONS).map(([key, value]) => ({ + label: key, + value: value.toString(), + }))} + value={MustBigNumber(amountPercent).toFixed(PERCENT_DECIMALS)} + onValueChange={setPercent} + shape={ButtonShape.Rectangle} /> - + + + + + {CenterElement} - + ); @@ -251,6 +421,11 @@ export const AdjustIsolatedMarginForm = ({ marketId }: ElementProps) => { const $Form = styled.form` ${formMixins.transfersForm} `; + +const $RelatedInputsGroup = styled.div` + ${layoutMixins.flexColumn} + gap: 0.56rem; +`; const $ToggleGroup = styled(ToggleGroup)` ${formMixins.inputToggleGroup} `; diff --git a/src/views/forms/AdjustTargetLeverageForm.tsx b/src/views/forms/AdjustTargetLeverageForm.tsx index a38b8c183..cb53009b1 100644 --- a/src/views/forms/AdjustTargetLeverageForm.tsx +++ b/src/views/forms/AdjustTargetLeverageForm.tsx @@ -1,4 +1,4 @@ -import { FormEvent, useState } from 'react'; +import { FormEvent, useMemo, useState } from 'react'; import { NumberFormatValues } from 'react-number-format'; import { shallowEqual } from 'react-redux'; @@ -7,9 +7,10 @@ import styled from 'styled-components'; import { TradeInputField } from '@/constants/abacus'; import { ButtonAction, ButtonShape, ButtonType } from '@/constants/buttons'; import { STRING_KEYS } from '@/constants/localization'; -import { LEVERAGE_DECIMALS, USD_DECIMALS } from '@/constants/numbers'; +import { LEVERAGE_DECIMALS } from '@/constants/numbers'; import { useStringGetter } from '@/hooks/useStringGetter'; +import { useURLConfigs } from '@/hooks/useURLConfigs'; import breakpoints from '@/styles/breakpoints'; import { formMixins } from '@/styles/formMixins'; @@ -17,18 +18,20 @@ import { formMixins } from '@/styles/formMixins'; import { Button } from '@/components/Button'; import { DiffOutput } from '@/components/DiffOutput'; import { Input, InputType } from '@/components/Input'; +import { Link } from '@/components/Link'; import { OutputType } from '@/components/Output'; import { Slider } from '@/components/Slider'; import { ToggleGroup } from '@/components/ToggleGroup'; import { WithDetailsReceipt } from '@/components/WithDetailsReceipt'; import { WithLabel } from '@/components/WithLabel'; -import { getSubaccount } from '@/state/accountSelectors'; import { useAppSelector } from '@/state/appTypes'; import { getInputTradeTargetLeverage } from '@/state/inputsSelectors'; +import { getCurrentMarketConfig } from '@/state/perpetualsSelectors'; import abacusStateManager from '@/lib/abacus'; -import { MustBigNumber } from '@/lib/numbers'; +import { BIG_NUMBERS, MustBigNumber } from '@/lib/numbers'; +import { orEmptyObj } from '@/lib/typeUtils'; export const AdjustTargetLeverageForm = ({ onSetTargetLeverage, @@ -36,15 +39,28 @@ export const AdjustTargetLeverageForm = ({ onSetTargetLeverage: (value: string) => void; }) => { const stringGetter = useStringGetter(); - const { buyingPower } = useAppSelector(getSubaccount, shallowEqual) ?? {}; + const { adjustTargetLeverageLearnMore } = useURLConfigs(); + + const { initialMarginFraction, effectiveInitialMarginFraction } = orEmptyObj( + useAppSelector(getCurrentMarketConfig, shallowEqual) + ); - /** - * @todo: Replace with Abacus functionality - */ const targetLeverage = useAppSelector(getInputTradeTargetLeverage); const [leverage, setLeverage] = useState(targetLeverage?.toString() ?? ''); const leverageBN = MustBigNumber(leverage); + const maxLeverage = useMemo(() => { + if (effectiveInitialMarginFraction) { + return BIG_NUMBERS.ONE.div(effectiveInitialMarginFraction).toNumber(); + } + + if (initialMarginFraction) { + return BIG_NUMBERS.ONE.div(initialMarginFraction).toNumber(); + } + + return 10; // default + }, [initialMarginFraction, effectiveInitialMarginFraction]); + return ( <$Form onSubmit={(e: FormEvent) => { @@ -58,11 +74,18 @@ export const AdjustTargetLeverageForm = ({ onSetTargetLeverage?.(leverage); }} > + <$Description> + {stringGetter({ key: STRING_KEYS.ADJUST_TARGET_LEVERAGE_DESCRIPTION })} + + {stringGetter({ key: STRING_KEYS.LEARN_MORE })} + + + <$InputContainer> <$WithLabel label={stringGetter({ key: STRING_KEYS.TARGET_LEVERAGE })}> <$LeverageSlider min={1} - max={10} + max={maxLeverage} value={MustBigNumber(leverage).abs().toNumber()} onSliderDrag={([value]: number[]) => setLeverage(value.toString())} onValueCommit={([value]: number[]) => setLeverage(value.toString())} @@ -73,6 +96,7 @@ export const AdjustTargetLeverageForm = ({ placeholder={`${MustBigNumber(leverage).abs().toFixed(LEVERAGE_DECIMALS)}×`} type={InputType.Leverage} value={leverage} + max={maxLeverage} onChange={({ floatValue }: NumberFormatValues) => setLeverage(floatValue?.toString() ?? '') } @@ -84,6 +108,7 @@ export const AdjustTargetLeverageForm = ({ items={[1, 2, 3, 5, 10].map((leverageAmount: number) => ({ label: `${leverageAmount}×`, value: MustBigNumber(leverageAmount).toFixed(LEVERAGE_DECIMALS), + disabled: leverageAmount > maxLeverage, }))} value={leverageBN.abs().toFixed(LEVERAGE_DECIMALS)} // sign agnostic onValueChange={(value: string) => setLeverage(value)} @@ -105,21 +130,6 @@ export const AdjustTargetLeverageForm = ({ /> ), }, - { - key: 'buying-power', - label: stringGetter({ key: STRING_KEYS.BUYING_POWER }), - value: ( - - ), - }, ]} > + + )} + + + ); return ( <$TradeForm onSubmit={onSubmit} className={className}> @@ -328,141 +307,11 @@ export const TradeForm = ({ ) : ( <> - <$TopActionsRow> - {isTablet && ( - <> - <$OrderbookButtons> - <$OrderbookButton - slotRight={} - onPressedChange={setShowOrderbook} - isPressed={showOrderbook} - > - {!showOrderbook && stringGetter({ key: STRING_KEYS.ORDERBOOK })} - - {/* TODO[TRCL-1411]: add orderbook scale functionality */} - - - <$ToggleGroup - items={allTradeTypeItems} - value={selectedTradeType} - onValueChange={onTradeTypeChange} - /> - - )} - - {!isTablet && ( - <> - {testFlags.isolatedMargin && ( - <$MarginAndLeverageButtons> - - - - - )} - - - )} - - - <$OrderbookAndInputs showOrderbook={showOrderbook}> - {isTablet && showOrderbook && <$Orderbook maxRowsPerSide={5} />} - - <$InputsColumn> - {tradeFormInputs.map( - ({ key, inputType, label, onChange, validationConfig, value, decimals }) => ( - - ) - )} - - - - {needsAdvancedOptions && } - - {complianceStatus === ComplianceStatus.CLOSE_ONLY && ( - - <$Message>{complianceMessage} - - )} - - {alertContent && ( - - <$Message> - {alertContent} - {shouldPromptUserToPlaceLimitOrder && ( - <$IconButton - iconName={IconName.Arrow} - shape={ButtonShape.Circle} - action={ButtonAction.Navigation} - size={ButtonSize.XSmall} - onClick={() => onTradeTypeChange(TradeTypes.LIMIT)} - /> - )} - - - )} - - + {tabletActionsRow} + {orderbookAndInputs} )} - - <$Footer> - {isInputFilled && (!currentStep || currentStep === MobilePlaceOrderSteps.EditOrder) && ( - <$ButtonRow> - - - )} - - + {tradeFooter} ); }; @@ -509,15 +358,7 @@ const $TradeForm = styled.form` } } `; -const $MarginAndLeverageButtons = styled.div` - ${layoutMixins.inlineRow} - gap: 0.5rem; - margin-right: 0.5rem; - button { - width: 100%; - } -`; const $TopActionsRow = styled.div` display: grid; grid-auto-flow: column; @@ -607,13 +448,12 @@ const $ToggleGroup = styled(ToggleGroup)` } } ` as typeof ToggleGroup; -const $InputsColumn = styled.div` - ${formMixins.inputsColumn} -`; + const $Message = styled.div` ${layoutMixins.row} gap: 0.75rem; `; + const $IconButton = styled(IconButton)` --button-backgroundColor: var(--color-white-faded); flex-shrink: 0; @@ -623,11 +463,17 @@ const $IconButton = styled(IconButton)` height: 1.25em; } `; + +const $InputsColumn = styled.div` + ${formMixins.inputsColumn} +`; + const $ButtonRow = styled.div` ${layoutMixins.row} justify-self: end; padding: 0.5rem 0 0.5rem 0; `; + const $Footer = styled.footer` ${formMixins.footer} --stickyFooterBackdrop-outsetY: var(--tradeBox-content-paddingBottom); diff --git a/src/views/forms/TradeForm/AdvancedTradeOptions.tsx b/src/views/forms/TradeForm/AdvancedTradeOptions.tsx index 023a8f9f1..68281d5a1 100644 --- a/src/views/forms/TradeForm/AdvancedTradeOptions.tsx +++ b/src/views/forms/TradeForm/AdvancedTradeOptions.tsx @@ -56,7 +56,8 @@ export const AdvancedTradeOptions = () => { const showReduceOnly = !!needsReduceOnly || !!reduceOnlyTooltip; const needsExecution = !!executionOptions || !!showPostOnly || !!showReduceOnly; - const hasTimeInForce = timeInForceOptions?.toArray()?.length; + const hasTimeInForce = !!timeInForceOptions?.toArray()?.length; + const needsTimeRow = !!needsGoodUntil || hasTimeInForce; useEffect(() => { if (complianceState === ComplianceStates.CLOSE_ONLY) { @@ -67,6 +68,11 @@ export const AdvancedTradeOptions = () => { } }, [complianceState]); + const necessary = needsTimeRow || needsExecution; + if (!necessary) { + return undefined; + } + return ( <$Collapsible defaultOpen={!isTablet} @@ -75,7 +81,7 @@ export const AdvancedTradeOptions = () => { fullWidth > <$AdvancedInputsContainer> - {(!!hasTimeInForce || !!needsGoodUntil) && ( + {needsTimeRow && ( <$AdvancedInputsRow> {hasTimeInForce && timeInForce != null && ( <$SelectMenu diff --git a/src/views/forms/TradeForm/MarginModeSelector.tsx b/src/views/forms/TradeForm/MarginModeSelector.tsx new file mode 100644 index 000000000..df993b0ca --- /dev/null +++ b/src/views/forms/TradeForm/MarginModeSelector.tsx @@ -0,0 +1,99 @@ +import { useCallback } from 'react'; + +import { shallowEqual } from 'react-redux'; +import styled from 'styled-components'; + +import { MARGIN_MODE_STRINGS } from '@/constants/abacus'; +import { DialogTypes, TradeBoxDialogTypes } from '@/constants/dialogs'; +import { STRING_KEYS } from '@/constants/localization'; + +import { useStringGetter } from '@/hooks/useStringGetter'; + +import { Button } from '@/components/Button'; +import { Icon, IconName } from '@/components/Icon'; +import { WithTooltip } from '@/components/WithTooltip'; + +import { useAppDispatch, useAppSelector } from '@/state/appTypes'; +import { openDialog, openDialogInTradeBox } from '@/state/dialogs'; +import { getInputTradeData, useTradeFormData } from '@/state/inputsSelectors'; +import { getCurrentMarketAssetId } from '@/state/perpetualsSelectors'; + +import { orEmptyObj } from '@/lib/typeUtils'; + +export const MarginModeSelector = ({ + className, + openInTradeBox, +}: { + className?: string; + openInTradeBox: boolean; +}) => { + const stringGetter = useStringGetter(); + const currentAssetId = useAppSelector(getCurrentMarketAssetId); + const { marginMode } = orEmptyObj(useAppSelector(getInputTradeData, shallowEqual)); + const { needsMarginMode } = useTradeFormData(); + + const dispatch = useAppDispatch(); + const handleClick = useCallback(() => { + dispatch( + openInTradeBox + ? openDialogInTradeBox({ type: TradeBoxDialogTypes.SelectMarginMode }) + : openDialog({ + type: DialogTypes.SelectMarginMode, + }) + ); + }, [dispatch, openInTradeBox]); + + return needsMarginMode ? ( + + ) : ( + <$WarningTooltip + className={className} + slotTooltip={ + <$WarningTooltipContent> + <$CautionIcon iconName={IconName.Warning} /> + {stringGetter({ + key: STRING_KEYS.UNABLE_TO_CHANGE_MARGIN_MODE, + params: { + MARKET: currentAssetId, + }, + })} + + } + > + + + ); +}; + +const $TriangleIcon = styled(Icon)` + font-size: 0.4375rem; + transform: rotate(0.75turn); + margin-left: 0.5ch; +`; + +const $WarningTooltip = styled(WithTooltip)` + --tooltip-backgroundColor: var(--color-gradient-warning); + border: 1px solid ${({ theme }) => theme.warning}30; +`; + +const $WarningTooltipContent = styled.div` + display: flex; + flex-direction: row; + align-items: start; +`; + +const $CautionIcon = styled(Icon)` + font-size: 1.5rem; + color: var(--color-warning); +`; diff --git a/src/views/forms/TradeForm/PlaceOrderButtonAndReceipt.tsx b/src/views/forms/TradeForm/PlaceOrderButtonAndReceipt.tsx index ba47e0477..6f902385d 100644 --- a/src/views/forms/TradeForm/PlaceOrderButtonAndReceipt.tsx +++ b/src/views/forms/TradeForm/PlaceOrderButtonAndReceipt.tsx @@ -1,6 +1,7 @@ +import { shallowEqual } from 'react-redux'; import styled from 'styled-components'; -import type { TradeInputSummary } from '@/constants/abacus'; +import { AbacusMarginMode, type TradeInputSummary } from '@/constants/abacus'; import { ButtonAction, ButtonSize, ButtonType } from '@/constants/buttons'; import { ComplianceStates } from '@/constants/compliance'; import { DialogTypes } from '@/constants/dialogs'; @@ -14,6 +15,7 @@ import { useTokenConfigs } from '@/hooks/useTokenConfigs'; import { AssetIcon } from '@/components/AssetIcon'; import { Button } from '@/components/Button'; +import { DiffOutput } from '@/components/DiffOutput'; import { Icon, IconName } from '@/components/Icon'; import { Output, OutputType, ShowSign } from '@/components/Output'; import { WithDetailsReceipt } from '@/components/WithDetailsReceipt'; @@ -21,10 +23,19 @@ import { WithTooltip } from '@/components/WithTooltip'; import { OnboardingTriggerButton } from '@/views/dialogs/OnboardingTriggerButton'; import { calculateCanAccountTrade } from '@/state/accountCalculators'; -import { getSubaccountId } from '@/state/accountSelectors'; +import { getCurrentMarketPositionData, getSubaccountId } from '@/state/accountSelectors'; import { useAppDispatch, useAppSelector } from '@/state/appTypes'; import { openDialog } from '@/state/dialogs'; -import { getCurrentInput } from '@/state/inputsSelectors'; +import { getCurrentInput, getInputTradeMarginMode } from '@/state/inputsSelectors'; +import { getCurrentMarketConfig } from '@/state/perpetualsSelectors'; + +import { isTruthy } from '@/lib/isTruthy'; +import { nullIfZero } from '@/lib/numbers'; +import { + calculateCrossPositionMargin, + getTradeStateWithDoubleValuesHasDiff, +} from '@/lib/tradeData'; +import { orEmptyObj } from '@/lib/typeUtils'; type ConfirmButtonConfig = { stringKey: string; @@ -60,6 +71,12 @@ export const PlaceOrderButtonAndReceipt = ({ const canAccountTrade = useAppSelector(calculateCanAccountTrade); const subaccountNumber = useAppSelector(getSubaccountId); const currentInput = useAppSelector(getCurrentInput); + const { tickSizeDecimals } = orEmptyObj(useAppSelector(getCurrentMarketConfig, shallowEqual)); + const { liquidationPrice, equity, leverage, notionalTotal, adjustedMmf } = orEmptyObj( + useAppSelector(getCurrentMarketPositionData, shallowEqual) + ); + + const marginMode = useAppSelector(getInputTradeMarginMode, shallowEqual); const hasMissingData = subaccountNumber === undefined; @@ -74,7 +91,48 @@ export const PlaceOrderButtonAndReceipt = ({ currentInput !== 'transfer' && !tradingUnavailable; - const { fee, price: expectedPrice, total, reward } = summary ?? {}; + const { fee, price: expectedPrice, reward } = summary ?? {}; + + // check if required fields are filled and summary has been calculated + const areInputsFilled = fee != null || reward != null; + + const renderMarginValue = () => { + if (marginMode === AbacusMarginMode.cross) { + const currentCrossMargin = nullIfZero( + calculateCrossPositionMargin({ + notionalTotal: notionalTotal?.current, + adjustedMmf: adjustedMmf?.current, + }) + ); + + const postOrderCrossMargin = nullIfZero( + calculateCrossPositionMargin({ + notionalTotal: notionalTotal?.postOrder, + adjustedMmf: adjustedMmf?.postOrder, + }) + ); + + return ( + + ); + } + + return ( + + ); + }; const items = [ { @@ -84,7 +142,46 @@ export const PlaceOrderButtonAndReceipt = ({ {stringGetter({ key: STRING_KEYS.EXPECTED_PRICE })} ), - value: , + value: ( + + ), + }, + { + key: 'liquidation-price', + label: stringGetter({ key: STRING_KEYS.LIQUIDATION_PRICE }), + value: ( + + ), + }, + { + key: 'position-margin', + label: stringGetter({ key: STRING_KEYS.POSITION_MARGIN }), + value: renderMarginValue(), + }, + { + key: 'position-leverage', + label: stringGetter({ key: STRING_KEYS.POSITION_LEVERAGE }), + value: ( + + ), }, { key: 'fee', @@ -113,12 +210,7 @@ export const PlaceOrderButtonAndReceipt = ({ ), tooltip: 'max-reward', }, - { - key: 'total', - label: stringGetter({ key: STRING_KEYS.TOTAL }), - value: , - }, - ]; + ].filter(isTruthy); const returnToMarketState = () => ({ buttonTextStringKey: STRING_KEYS.RETURN_TO_MARKET, diff --git a/src/views/forms/TradeForm/TargetLeverageButton.tsx b/src/views/forms/TradeForm/TargetLeverageButton.tsx new file mode 100644 index 000000000..2d447e8c9 --- /dev/null +++ b/src/views/forms/TradeForm/TargetLeverageButton.tsx @@ -0,0 +1,41 @@ +import { useCallback } from 'react'; + +import { shallowEqual } from 'react-redux'; + +import { DialogTypes } from '@/constants/dialogs'; +import { LEVERAGE_DECIMALS } from '@/constants/numbers'; + +import { Button } from '@/components/Button'; +import { Output, OutputType } from '@/components/Output'; +import { WithTooltip } from '@/components/WithTooltip'; + +import { useAppDispatch, useAppSelector } from '@/state/appTypes'; +import { openDialog } from '@/state/dialogs'; +import { getInputTradeData, useTradeFormData } from '@/state/inputsSelectors'; + +export const TargetLeverageButton = ({ className }: { className?: string }) => { + const { needsTargetLeverage } = useTradeFormData(); + + const currentTradeData = useAppSelector(getInputTradeData, shallowEqual); + + const { targetLeverage } = currentTradeData ?? {}; + + const dispatch = useAppDispatch(); + const handleClick = useCallback(() => { + dispatch(openDialog({ type: DialogTypes.AdjustTargetLeverage })); + }, [dispatch]); + + return ( + needsTargetLeverage && ( + + + + ) + ); +}; diff --git a/src/views/forms/TradeForm/TradeFormInputs.tsx b/src/views/forms/TradeForm/TradeFormInputs.tsx new file mode 100644 index 000000000..49ed4b793 --- /dev/null +++ b/src/views/forms/TradeForm/TradeFormInputs.tsx @@ -0,0 +1,113 @@ +import { Ref } from 'react'; + +import { NumberFormatValues, SourceInfo } from 'react-number-format'; +import { shallowEqual } from 'react-redux'; + +import { STRING_KEYS } from '@/constants/localization'; +import { USD_DECIMALS } from '@/constants/numbers'; +import { InputErrorData, TradeBoxKeys } from '@/constants/trade'; + +import { useStringGetter } from '@/hooks/useStringGetter'; + +import { FormInput } from '@/components/FormInput'; +import { InputType } from '@/components/Input'; +import { Tag } from '@/components/Tag'; +import { WithTooltip } from '@/components/WithTooltip'; + +import { useAppDispatch, useAppSelector } from '@/state/appTypes'; +import { setTradeFormInputs } from '@/state/inputs'; +import { getTradeFormInputs, useTradeFormData } from '@/state/inputsSelectors'; +import { getCurrentMarketConfig } from '@/state/perpetualsSelectors'; + +type TradeBoxInputConfig = { + key: TradeBoxKeys; + inputType: InputType; + label: React.ReactNode; + onChange: (values: NumberFormatValues, e: SourceInfo) => void; + ref?: Ref; + validationConfig?: InputErrorData; + value: string | number; + decimals?: number; +}; + +export const TradeFormInputs = () => { + const dispatch = useAppDispatch(); + const stringGetter = useStringGetter(); + + const { needsLimitPrice, needsTrailingPercent, needsTriggerPrice } = useTradeFormData(); + const tradeFormInputValues = useAppSelector(getTradeFormInputs, shallowEqual); + const { limitPriceInput, triggerPriceInput, trailingPercentInput } = tradeFormInputValues; + const { tickSizeDecimals } = useAppSelector(getCurrentMarketConfig, shallowEqual) ?? {}; + + const tradeFormInputs: TradeBoxInputConfig[] = []; + if (needsTriggerPrice) { + tradeFormInputs.push({ + key: TradeBoxKeys.TriggerPrice, + inputType: InputType.Currency, + label: ( + <> + + {stringGetter({ key: STRING_KEYS.TRIGGER_PRICE })} + + USD + + ), + onChange: ({ value }: NumberFormatValues) => { + dispatch(setTradeFormInputs({ triggerPriceInput: value })); + }, + value: triggerPriceInput ?? '', + decimals: tickSizeDecimals ?? USD_DECIMALS, + }); + } + + if (needsLimitPrice) { + tradeFormInputs.push({ + key: TradeBoxKeys.LimitPrice, + inputType: InputType.Currency, + label: ( + <> + + {stringGetter({ key: STRING_KEYS.LIMIT_PRICE })} + + USD + + ), + onChange: ({ value }: NumberFormatValues) => { + dispatch(setTradeFormInputs({ limitPriceInput: value })); + }, + value: limitPriceInput, + decimals: tickSizeDecimals ?? USD_DECIMALS, + }); + } + + if (needsTrailingPercent) { + tradeFormInputs.push({ + key: TradeBoxKeys.TrailingPercent, + inputType: InputType.Percent, + label: ( + + {stringGetter({ key: STRING_KEYS.TRAILING_PERCENT })} + + ), + onChange: ({ value }: NumberFormatValues) => { + dispatch(setTradeFormInputs({ trailingPercentInput: value })); + }, + value: trailingPercentInput ?? '', + }); + } + + return tradeFormInputs.map( + ({ key, inputType, label, onChange, validationConfig, value, decimals }) => ( + + ) + ); +}; diff --git a/src/views/forms/TradeForm/TradeSideToggle.tsx b/src/views/forms/TradeForm/TradeSideToggle.tsx index 0cdc68db9..21116fc0b 100644 --- a/src/views/forms/TradeForm/TradeSideToggle.tsx +++ b/src/views/forms/TradeForm/TradeSideToggle.tsx @@ -19,13 +19,14 @@ import abacusStateManager from '@/lib/abacus'; import { getSimpleStyledOutputType } from '@/lib/genericFunctionalComponentUtils'; import { getSelectedOrderSide } from '@/lib/tradeData'; -export const TradeSideToggle = memo(() => { +export const TradeSideToggle = memo(({ className }: { className?: string }) => { const stringGetter = useStringGetter(); const side = useAppSelector(getTradeSide, shallowEqual); const selectedOrderSide = getSelectedOrderSide(side); return ( <$ToggleContainer + className={className} items={[ { value: OrderSide.BUY, label: stringGetter({ key: STRING_KEYS.BUY }) }, { value: OrderSide.SELL, label: stringGetter({ key: STRING_KEYS.SELL }) }, diff --git a/src/views/forms/TradeForm/useTradeTypeOptions.tsx b/src/views/forms/TradeForm/useTradeTypeOptions.tsx new file mode 100644 index 000000000..5c3936a50 --- /dev/null +++ b/src/views/forms/TradeForm/useTradeTypeOptions.tsx @@ -0,0 +1,69 @@ +import { useMemo } from 'react'; + +import { shallowEqual } from 'react-redux'; + +import { STRING_KEYS, StringKey } from '@/constants/localization'; +import { MenuItem } from '@/constants/menus'; +import { EMPTY_ARR } from '@/constants/objects'; +import { TradeTypes } from '@/constants/trade'; + +import { useStringGetter } from '@/hooks/useStringGetter'; + +import { AssetIcon } from '@/components/AssetIcon'; + +import { useAppSelector } from '@/state/appTypes'; +import { getInputTradeData, getInputTradeOptions } from '@/state/inputsSelectors'; +import { getCurrentMarketAssetId } from '@/state/perpetualsSelectors'; + +import { isTruthy } from '@/lib/isTruthy'; +import { getSelectedTradeType } from '@/lib/tradeData'; + +export const useTradeTypeOptions = (opts?: { showAssetIcon?: boolean; showAll?: boolean }) => { + const { showAll, showAssetIcon } = opts ?? {}; + const stringGetter = useStringGetter(); + + const currentTradeData = useAppSelector(getInputTradeData, shallowEqual); + const currentAssetId = useAppSelector(getCurrentMarketAssetId); + const { type: tradeType } = currentTradeData ?? {}; + + const selectedTradeType = getSelectedTradeType(tradeType); + + const { typeOptions } = useAppSelector(getInputTradeOptions, shallowEqual) ?? {}; + + const allTradeTypeItems = useMemo((): Array> | undefined => { + const allItems = typeOptions?.toArray()?.map(({ type, stringKey }) => ({ + value: type as TradeTypes, + label: stringGetter({ + key: + type === TradeTypes.TAKE_PROFIT + ? STRING_KEYS.TAKE_PROFIT_LIMIT + : ((stringKey ?? '') as StringKey), + }), + slotBefore: showAssetIcon ? : undefined, + })); + return allItems; + }, [currentAssetId, showAssetIcon, stringGetter, typeOptions]); + + const asSubItems = useMemo((): Array> => { + if (allTradeTypeItems == null || allTradeTypeItems.length === 0) { + return EMPTY_ARR; + } + return [ + allTradeTypeItems[0], // Limit order is always first + allTradeTypeItems[1], // Market order is always second + // All conditional orders labeled under "Stop Order" + allTradeTypeItems.length > 2 + ? { + label: stringGetter({ key: STRING_KEYS.STOP_ORDER_SHORT }), + value: '' as TradeTypes, + subitems: allTradeTypeItems.slice(2), + } + : undefined, + ].filter(isTruthy); + }, [allTradeTypeItems, stringGetter]); + + return { + selectedTradeType, + tradeTypeItems: showAll ? allTradeTypeItems ?? EMPTY_ARR : asSubItems, + }; +}; diff --git a/src/views/tables/OrdersTable.tsx b/src/views/tables/OrdersTable.tsx index 9983cbf65..632e467f1 100644 --- a/src/views/tables/OrdersTable.tsx +++ b/src/views/tables/OrdersTable.tsx @@ -1,4 +1,4 @@ -import { Key, useEffect, useMemo } from 'react'; +import { Key, ReactNode, useEffect, useMemo } from 'react'; import { OrderSide } from '@dydxprotocol/v4-client-js'; import { ColumnSize } from '@react-types/table'; @@ -7,7 +7,7 @@ import { DateTime } from 'luxon'; import { shallowEqual } from 'react-redux'; import styled, { css } from 'styled-components'; -import { Asset, Nullable, SubaccountOrder } from '@/constants/abacus'; +import { AbacusMarginMode, Asset, Nullable, SubaccountOrder } from '@/constants/abacus'; import { DialogTypes } from '@/constants/dialogs'; import { STRING_KEYS, type StringGetterFunction } from '@/constants/localization'; import { TOKEN_DECIMALS } from '@/constants/numbers'; @@ -31,6 +31,7 @@ import { TableColumnHeader } from '@/components/Table/TableColumnHeader'; import { PageSize } from '@/components/Table/TablePaginationRow'; import { TagSize } from '@/components/Tag'; import { WithTooltip } from '@/components/WithTooltip'; +import { MarketTypeFilter, marketTypeMatchesFilter } from '@/pages/trade/types'; import { viewedOrders } from '@/state/account'; import { calculateIsAccountViewOnly } from '@/state/accountCalculators'; @@ -52,6 +53,7 @@ import { isOrderStatusClearable, } from '@/lib/orders'; import { getStringsForDateTimeDiff } from '@/lib/timeUtils'; +import { getMarginModeFromSubaccountNumber } from '@/lib/tradeData'; import { orEmptyObj } from '@/lib/typeUtils'; import { OrderStatusIcon } from '../OrderStatusIcon'; @@ -66,6 +68,7 @@ export enum OrdersTableColumnKey { Trigger = 'Trigger', GoodTil = 'Good-Til', Actions = 'Actions', + MarginType = 'Margin-Type', // Tablet Only StatusFill = 'Status-Fill', @@ -306,6 +309,20 @@ const getOrdersTableColumnDef = ({ ), }, + [OrdersTableColumnKey.MarginType]: { + columnKey: 'marginType', + label: stringGetter({ key: STRING_KEYS.MARGIN_MODE }), + getCellValue: (row) => getMarginModeFromSubaccountNumber(row.subaccountNumber).name, + renderCell(row: OrderTableRow): ReactNode { + const marginMode = getMarginModeFromSubaccountNumber(row.subaccountNumber); + + const marginModeLabel = + marginMode === AbacusMarginMode.cross + ? stringGetter({ key: STRING_KEYS.CROSS }) + : stringGetter({ key: STRING_KEYS.ISOLATED }); + return ; + }, + }, } satisfies Record> )[key], }); @@ -314,6 +331,7 @@ type ElementProps = { columnKeys: OrdersTableColumnKey[]; columnWidths?: Partial>; currentMarket?: string; + marketTypeFilter?: MarketTypeFilter; initialPageSize?: PageSize; }; @@ -325,6 +343,7 @@ export const OrdersTable = ({ columnKeys = [], columnWidths, currentMarket, + marketTypeFilter, initialPageSize, withOuterBorder, }: ElementProps & StyleProps) => { @@ -335,7 +354,15 @@ export const OrdersTable = ({ const isAccountViewOnly = useAppSelector(calculateIsAccountViewOnly); const marketOrders = useAppSelector(getCurrentMarketOrders, shallowEqual) ?? EMPTY_ARR; const allOrders = useAppSelector(getSubaccountUnclearedOrders, shallowEqual) ?? EMPTY_ARR; - const orders = currentMarket ? marketOrders : allOrders; + + const orders = useMemo( + () => + (currentMarket ? marketOrders : allOrders).filter((order) => { + const orderType = getMarginModeFromSubaccountNumber(order.subaccountNumber).name; + return marketTypeMatchesFilter(orderType, marketTypeFilter); + }), + [allOrders, currentMarket, marketOrders, marketTypeFilter] + ); const allPerpetualMarkets = orEmptyObj(useAppSelector(getPerpetualMarkets, shallowEqual)); const allAssets = orEmptyObj(useAppSelector(getAssets, shallowEqual)); diff --git a/src/views/tables/PositionsTable.tsx b/src/views/tables/PositionsTable.tsx index 18b6cd067..1c52e67a0 100644 --- a/src/views/tables/PositionsTable.tsx +++ b/src/views/tables/PositionsTable.tsx @@ -32,11 +32,9 @@ import { MarketTableCell } from '@/components/Table/MarketTableCell'; import { TableCell } from '@/components/Table/TableCell'; import { TableColumnHeader } from '@/components/Table/TableColumnHeader'; import { PageSize } from '@/components/Table/TablePaginationRow'; +import { MarketTypeFilter, marketTypeMatchesFilter } from '@/pages/trade/types'; -import { - calculateIsAccountViewOnly, - calculateShouldRenderTriggersInPositionsTable, -} from '@/state/accountCalculators'; +import { calculateIsAccountViewOnly } from '@/state/accountCalculators'; import { getExistingOpenPositions, getSubaccountConditionalOrders } from '@/state/accountSelectors'; import { useAppSelector } from '@/state/appTypes'; import { getAssets } from '@/state/assetsSelectors'; @@ -44,7 +42,7 @@ import { getPerpetualMarkets } from '@/state/perpetualsSelectors'; import { MustBigNumber, getNumberSign } from '@/lib/numbers'; import { safeAssign } from '@/lib/objectHelpers'; -import { testFlags } from '@/lib/testFlags'; +import { getMarginModeFromSubaccountNumber, getPositionMargin } from '@/lib/tradeData'; import { orEmptyObj } from '@/lib/typeUtils'; import { PositionsActionsCell } from './PositionsTable/PositionsActionsCell'; @@ -83,7 +81,6 @@ const getPositionsTableColumnDef = ({ width, isAccountViewOnly, showClosePositionAction, - shouldRenderTriggers, navigateToOrders, }: { key: PositionsTableColumnKey; @@ -91,7 +88,6 @@ const getPositionsTableColumnDef = ({ width?: ColumnSize; isAccountViewOnly: boolean; showClosePositionAction: boolean; - shouldRenderTriggers: boolean; navigateToOrders: (market: string) => void; }) => ({ width, @@ -210,13 +206,11 @@ const getPositionsTableColumnDef = ({ }, [PositionsTableColumnKey.Margin]: { columnKey: 'margin', - getCellValue: (row) => row.leverage?.current, + getCellValue: (row) => getPositionMargin({ position: row }), label: stringGetter({ key: STRING_KEYS.MARGIN }), hideOnBreakpoint: MediaQueryKeys.isMobile, isActionable: true, - renderCell: ({ id, adjustedMmf, notionalTotal }) => ( - - ), + renderCell: (row) => , }, [PositionsTableColumnKey.NetFunding]: { columnKey: 'netFunding', @@ -346,25 +340,16 @@ const getPositionsTableColumnDef = ({ [PositionsTableColumnKey.Actions]: { columnKey: 'actions', label: stringGetter({ - key: - shouldRenderTriggers && showClosePositionAction && !testFlags.isolatedMargin - ? STRING_KEYS.ACTIONS - : showClosePositionAction - ? STRING_KEYS.CLOSE - : STRING_KEYS.ACTION, + key: showClosePositionAction ? STRING_KEYS.CLOSE : STRING_KEYS.ACTION, }), isActionable: true, allowsSorting: false, hideOnBreakpoint: MediaQueryKeys.isTablet, - renderCell: ({ id, assetId, stopLossOrders, takeProfitOrders }) => ( + renderCell: ({ id }) => ( ), }, @@ -377,6 +362,7 @@ type ElementProps = { columnWidths?: Partial>; currentRoute?: string; currentMarket?: string; + marketTypeFilter?: MarketTypeFilter; showClosePositionAction: boolean; initialPageSize?: PageSize; onNavigate?: () => void; @@ -393,6 +379,7 @@ export const PositionsTable = ({ columnWidths, currentRoute, currentMarket, + marketTypeFilter, showClosePositionAction, initialPageSize, onNavigate, @@ -407,13 +394,17 @@ export const PositionsTable = ({ const isAccountViewOnly = useAppSelector(calculateIsAccountViewOnly); const perpetualMarkets = orEmptyObj(useAppSelector(getPerpetualMarkets, shallowEqual)); const assets = orEmptyObj(useAppSelector(getAssets, shallowEqual)); - const shouldRenderTriggers = useAppSelector(calculateShouldRenderTriggersInPositionsTable); const openPositions = useAppSelector(getExistingOpenPositions, shallowEqual) ?? EMPTY_ARR; const positions = useMemo(() => { - const marketPosition = openPositions.find((position) => position.id === currentMarket); - return currentMarket ? (marketPosition ? [marketPosition] : []) : openPositions; - }, [currentMarket, openPositions]); + return openPositions.filter((position) => { + const matchesMarket = currentMarket == null || position.id === currentMarket; + const subaccountNumber = position.childSubaccountNumber; + const marginType = getMarginModeFromSubaccountNumber(subaccountNumber).name; + const matchesType = marketTypeMatchesFilter(marginType, marketTypeFilter); + return matchesMarket && matchesType; + }); + }, [currentMarket, marketTypeFilter, openPositions]); const conditionalOrderSelector = useMemo(getSubaccountConditionalOrders, []); const { stopLossOrders: allStopLossOrders, takeProfitOrders: allTakeProfitOrders } = @@ -466,7 +457,6 @@ export const PositionsTable = ({ width: columnWidths?.[key], isAccountViewOnly, showClosePositionAction, - shouldRenderTriggers, navigateToOrders, }) )} diff --git a/src/views/tables/PositionsTable/PositionsActionsCell.tsx b/src/views/tables/PositionsTable/PositionsActionsCell.tsx index d365a0970..8f64031c2 100644 --- a/src/views/tables/PositionsTable/PositionsActionsCell.tsx +++ b/src/views/tables/PositionsTable/PositionsActionsCell.tsx @@ -1,53 +1,42 @@ import { useNavigate } from 'react-router-dom'; import styled from 'styled-components'; -import { type SubaccountOrder } from '@/constants/abacus'; import { ButtonShape } from '@/constants/buttons'; -import { ComplianceStates } from '@/constants/compliance'; -import { DialogTypes, TradeBoxDialogTypes } from '@/constants/dialogs'; +import { TradeBoxDialogTypes } from '@/constants/dialogs'; +import { STRING_KEYS } from '@/constants/localization'; import { AppRoute } from '@/constants/routes'; -import { useComplianceState } from '@/hooks/useComplianceState'; -import { useEnvFeatures } from '@/hooks/useEnvFeatures'; +import { useStringGetter } from '@/hooks/useStringGetter'; import { IconName } from '@/components/Icon'; import { IconButton } from '@/components/IconButton'; import { ActionsTableCell } from '@/components/Table/ActionsTableCell'; +import { WithTooltip } from '@/components/WithTooltip'; import { useAppDispatch, useAppSelector } from '@/state/appTypes'; -import { closeDialogInTradeBox, openDialog, openDialogInTradeBox } from '@/state/dialogs'; +import { closeDialogInTradeBox, openDialogInTradeBox } from '@/state/dialogs'; import { getActiveTradeBoxDialog } from '@/state/dialogsSelectors'; import { getCurrentMarketId } from '@/state/perpetualsSelectors'; import abacusStateManager from '@/lib/abacus'; -import { testFlags } from '@/lib/testFlags'; type ElementProps = { marketId: string; - assetId: string; - stopLossOrders: SubaccountOrder[]; - takeProfitOrders: SubaccountOrder[]; isDisabled?: boolean; showClosePositionAction: boolean; - navigateToMarketOrders: (market: string) => void; }; export const PositionsActionsCell = ({ marketId, - assetId, - stopLossOrders, - takeProfitOrders, isDisabled, showClosePositionAction, - navigateToMarketOrders, }: ElementProps) => { const dispatch = useAppDispatch(); const navigate = useNavigate(); - const { complianceState } = useComplianceState(); - const { isSlTpEnabled } = useEnvFeatures(); const currentMarketId = useAppSelector(getCurrentMarketId); const activeTradeBoxDialog = useAppSelector(getActiveTradeBoxDialog); + const stringGetter = useStringGetter(); const { type: tradeBoxDialogType } = activeTradeBoxDialog ?? {}; const onCloseButtonToggle = (isPressed: boolean) => { @@ -67,51 +56,25 @@ export const PositionsActionsCell = ({ return ( - {isSlTpEnabled && - !testFlags.isolatedMargin && - complianceState === ComplianceStates.FULL_ACCESS && ( - <$TriggersButton - key="edittriggers" - onClick={() => - dispatch( - openDialog({ - type: DialogTypes.Triggers, - dialogProps: { - marketId, - assetId, - stopLossOrders, - takeProfitOrders, - navigateToMarketOrders, - }, - }) - ) + {showClosePositionAction && ( + + <$CloseButtonToggle + key="closepositions" + isToggle + isPressed={ + tradeBoxDialogType === TradeBoxDialogTypes.ClosePosition && + currentMarketId === marketId } - iconName={IconName.Pencil} + onPressedChange={onCloseButtonToggle} + iconName={IconName.Close} shape={ButtonShape.Square} disabled={isDisabled} /> - )} - {showClosePositionAction && ( - <$CloseButtonToggle - key="closepositions" - isToggle - isPressed={ - tradeBoxDialogType === TradeBoxDialogTypes.ClosePosition && currentMarketId === marketId - } - onPressedChange={onCloseButtonToggle} - iconName={IconName.Close} - shape={ButtonShape.Square} - disabled={isDisabled} - /> + )} ); }; -const $TriggersButton = styled(IconButton)` - --button-icon-size: 1.33em; - --button-textColor: var(--color-text-0); - --button-hover-textColor: var(--color-text-1); -`; const $CloseButtonToggle = styled(IconButton)` --button-icon-size: 1em; diff --git a/src/views/tables/PositionsTable/PositionsMarginCell.tsx b/src/views/tables/PositionsTable/PositionsMarginCell.tsx index d6d16005a..54c808ca5 100644 --- a/src/views/tables/PositionsTable/PositionsMarginCell.tsx +++ b/src/views/tables/PositionsTable/PositionsMarginCell.tsx @@ -1,6 +1,8 @@ +import { useMemo } from 'react'; + import styled from 'styled-components'; -import { type SubaccountPosition } from '@/constants/abacus'; +import { AbacusMarginMode, type SubaccountPosition } from '@/constants/abacus'; import { ButtonShape } from '@/constants/buttons'; import { DialogTypes } from '@/constants/dialogs'; import { STRING_KEYS } from '@/constants/localization'; @@ -11,54 +13,56 @@ import { IconName } from '@/components/Icon'; import { IconButton } from '@/components/IconButton'; import { Output, OutputType, ShowSign } from '@/components/Output'; import { TableCell } from '@/components/Table/TableCell'; +import { WithTooltip } from '@/components/WithTooltip'; import { useAppDispatch } from '@/state/appTypes'; import { openDialog } from '@/state/dialogs'; -import { calculatePositionMargin } from '@/lib/tradeData'; +import { getMarginModeFromSubaccountNumber, getPositionMargin } from '@/lib/tradeData'; type PositionsMarginCellProps = { - id: SubaccountPosition['id']; - notionalTotal: SubaccountPosition['notionalTotal']; - adjustedMmf: SubaccountPosition['adjustedMmf']; + position: SubaccountPosition; }; -export const PositionsMarginCell = ({ - id, - adjustedMmf, - notionalTotal, -}: PositionsMarginCellProps) => { +export const PositionsMarginCell = ({ position }: PositionsMarginCellProps) => { const stringGetter = useStringGetter(); const dispatch = useAppDispatch(); - const margin = calculatePositionMargin({ - notionalTotal: notionalTotal?.current, - adjustedMmf: adjustedMmf?.current, - }); - const perpetualMarketType = 'CROSS'; // Todo: Replace with perpetualMarketType when available - const marginModeLabel = - perpetualMarketType === 'CROSS' - ? stringGetter({ key: STRING_KEYS.CROSS }) - : stringGetter({ key: STRING_KEYS.ISOLATED }); + const { marginMode, marginModeLabel, margin } = useMemo(() => { + const { childSubaccountNumber } = position; + const derivedMarginMode = getMarginModeFromSubaccountNumber(childSubaccountNumber); + + return { + marginMode: derivedMarginMode, + marginModeLabel: + derivedMarginMode === AbacusMarginMode.cross + ? stringGetter({ key: STRING_KEYS.CROSS }) + : stringGetter({ key: STRING_KEYS.ISOLATED }), + margin: getPositionMargin({ position }), + }; + }, [position, stringGetter]); return ( - dispatch( - openDialog({ - type: DialogTypes.AdjustIsolatedMargin, - dialogProps: { positionId: id }, - }) - ) - } - /> + marginMode === AbacusMarginMode.isolated && ( + + <$EditButton + key="edit-margin" + iconName={IconName.Pencil} + shape={ButtonShape.Square} + onClick={() => + dispatch( + openDialog({ + type: DialogTypes.AdjustIsolatedMargin, + dialogProps: { positionId: position.id }, + }) + ) + } + /> + + ) } > diff --git a/src/views/tables/PositionsTable/PositionsTriggersCell.tsx b/src/views/tables/PositionsTable/PositionsTriggersCell.tsx index 7769facfb..7bed907de 100644 --- a/src/views/tables/PositionsTable/PositionsTriggersCell.tsx +++ b/src/views/tables/PositionsTable/PositionsTriggersCell.tsx @@ -23,12 +23,12 @@ import { IconButton } from '@/components/IconButton'; import { Output, OutputType } from '@/components/Output'; import { TableCell } from '@/components/Table/TableCell'; import { WithHovercard } from '@/components/WithHovercard'; +import { WithTooltip } from '@/components/WithTooltip'; import { useAppDispatch } from '@/state/appTypes'; import { openDialog } from '@/state/dialogs'; import { isStopLossOrder } from '@/lib/orders'; -import { testFlags } from '@/lib/testFlags'; enum TriggerButtonState { Warning = 'Warning', @@ -81,6 +81,9 @@ export const PositionsTriggersCell = ({ }; const openTriggersDialog = () => { + if (isDisabled) { + return; + } dispatch( openDialog({ type: DialogTypes.Triggers, @@ -100,6 +103,7 @@ export const PositionsTriggersCell = ({ action={ButtonAction.Navigation} size={ButtonSize.XSmall} onClick={onViewOrders ?? undefined} + disabled={isDisabled} > {stringGetter({ key: STRING_KEYS.VIEW_ORDERS })} <$ArrowIcon iconName={IconName.Arrow} /> @@ -125,6 +129,7 @@ export const PositionsTriggersCell = ({ action={ButtonAction.Primary} onClick={openTriggersDialog} triggerButtonState={triggerButtonState} + disabled={isDisabled} > {label} @@ -142,6 +147,7 @@ export const PositionsTriggersCell = ({