diff --git a/package.json b/package.json index b43b297063..2fb29ddcf0 100644 --- a/package.json +++ b/package.json @@ -54,8 +54,8 @@ "@cosmjs/stargate": "^0.32.1", "@cosmjs/tendermint-rpc": "^0.32.1", "@datadog/browser-logs": "^5.23.3", - "@dydxprotocol/v4-client-js": "3.1.1", - "@dydxprotocol/v4-localization": "1.1.358", + "@dydxprotocol/v4-client-js": "3.2.0", + "@dydxprotocol/v4-localization": "1.1.361", "@dydxprotocol/v4-proto": "^7.0.0-dev.0", "@emotion/is-prop-valid": "^1.3.0", "@hugocxl/react-to-image": "^0.0.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2e96db528..8132781cb3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,11 +30,11 @@ dependencies: specifier: ^5.23.3 version: 5.35.1 '@dydxprotocol/v4-client-js': - specifier: 3.1.1 - version: 3.1.1 + specifier: 3.2.0 + version: 3.2.0 '@dydxprotocol/v4-localization': - specifier: 1.1.358 - version: 1.1.358 + specifier: 1.1.361 + version: 1.1.361 '@dydxprotocol/v4-proto': specifier: ^7.0.0-dev.0 version: 7.0.5 @@ -1659,8 +1659,8 @@ packages: '@datadog/browser-core': 5.35.1 dev: false - /@dydxprotocol/v4-client-js@3.1.1: - resolution: {integrity: sha512-ZSAN030x+LBei1lbdJ4TLSFLXNlqI99+LIq1TO2soeircH3TZr/UzJR2TArrj0fmQQBTPto7qqVdQRQacvPCng==} + /@dydxprotocol/v4-client-js@3.2.0: + resolution: {integrity: sha512-dHziRFVcyAzAP5xvi+IZaG9bhoYP7pK91DbfAe8DFuBf/DmUdhSKIOG+92MRe9LFISnl01b6hzP6Y/f3oqFV9A==} dependencies: '@cosmjs/amino': 0.32.4 '@cosmjs/encoding': 0.32.4 @@ -1690,8 +1690,8 @@ packages: - utf-8-validate dev: false - /@dydxprotocol/v4-localization@1.1.358: - resolution: {integrity: sha512-pZ7+D3tywu4wxbplRrQ9A3MLpxwfROuM50Qm9K8eVFUy9XmpT3sWYeFA1pi169rfFdKeka6EATBnwERK3LOdqQ==} + /@dydxprotocol/v4-localization@1.1.361: + resolution: {integrity: sha512-Tq7KBxZ/H6RZgGwM3VQw7euQ7hgVR4FTymPYbU5AYV5pRXdLee5IkYKqkbc/SJBUsDuMaMNQ8WEARdeEaam5HA==} dev: false /@dydxprotocol/v4-proto@7.0.5: diff --git a/src/bonsai/calculators/markets.ts b/src/bonsai/calculators/markets.ts index 634fb21089..7f05a36493 100644 --- a/src/bonsai/calculators/markets.ts +++ b/src/bonsai/calculators/markets.ts @@ -17,7 +17,7 @@ import { getDisplayableTickerFromMarket, } from '@/lib/assetUtils'; import { isTruthy } from '@/lib/isTruthy'; -import { MaybeBigNumber, MustBigNumber, MustNumber } from '@/lib/numbers'; +import { BIG_NUMBERS, MaybeBigNumber, MustBigNumber, MustNumber } from '@/lib/numbers'; import { objectFromEntries } from '@/lib/objectHelpers'; import { MarketsData } from '../types/rawTypes'; @@ -36,6 +36,7 @@ export function calculateAllMarkets(markets: MarketsData | undefined): MarketsIn if (markets == null) { return markets; } + return mapValues(markets, calculateMarket); } @@ -175,6 +176,49 @@ export function createMarketSummary( ); } +/** + * Calculate the effective selected leverage for a market. + * Returns user-selected leverage if set, otherwise calculates max leverage from IMF only (ignoring OIMF). + */ +export function calculateEffectiveSelectedLeverage({ + userSelectedLeverage, + initialMarginFraction, +}: { + userSelectedLeverage: number | undefined; + initialMarginFraction: string | number | BigNumber | null | undefined; +}): number { + return calculateEffectiveSelectedLeverageBigNumber({ + userSelectedLeverage, + initialMarginFraction, + }).toNumber(); +} + +/** + * Calculate the effective selected leverage for a market, returning BigNumber. + * Returns user-selected leverage if set, otherwise calculates max leverage from IMF only (ignoring OIMF). + */ +export function calculateEffectiveSelectedLeverageBigNumber({ + userSelectedLeverage, + initialMarginFraction, +}: { + userSelectedLeverage: number | undefined; + initialMarginFraction: string | number | BigNumber | null | undefined; +}): BigNumber { + // Return user-selected leverage if it exists + if (userSelectedLeverage != null) { + return MaybeBigNumber(userSelectedLeverage) ?? BIG_NUMBERS.ONE; + } + + // Otherwise calculate from IMF only (ignoring OIMF as requested) + const imf = MaybeBigNumber(initialMarginFraction); + if (imf != null && !imf.isZero()) { + return BIG_NUMBERS.ONE.div(imf); + } + + // Fallback + return BIG_NUMBERS.ONE; +} + export function calculateMarketsFeeDiscounts( feeDiscounts: PerpetualMarketFeeDiscount | undefined ): AllPerpetualMarketsFeeDiscounts | undefined { diff --git a/src/bonsai/calculators/subaccount.ts b/src/bonsai/calculators/subaccount.ts index 62bb67827d..db21e924b5 100644 --- a/src/bonsai/calculators/subaccount.ts +++ b/src/bonsai/calculators/subaccount.ts @@ -35,21 +35,32 @@ import { SubaccountSummaryDerived, } from '../types/summaryTypes'; import { getPositionUniqueId } from './helpers'; -import { getMarketEffectiveInitialMarginForMarket } from './markets'; +import { + calculateEffectiveSelectedLeverageBigNumber, + getMarketEffectiveInitialMarginForMarket, +} from './markets'; export function calculateParentSubaccountPositions( parent: ParentSubaccountDataBase, - markets: MarketsData + markets: MarketsData, + rawSelectedMarketLeverages: { [marketId: string]: number } ): SubaccountPosition[] { return Object.values(parent.childSubaccounts) .filter(isPresent) .flatMap((child) => { - const subaccount = calculateSubaccountSummary(child, markets); + const subaccount = calculateSubaccountSummary(child, markets, rawSelectedMarketLeverages); return orderBy( Object.values(child.openPerpetualPositions) .filter(isPresent) .filter((p) => p.status === IndexerPerpetualPositionStatus.OPEN) - .map((perp) => calculateSubaccountPosition(subaccount, perp, markets[perp.market])), + .map((perp) => + calculateSubaccountPosition( + subaccount, + perp, + markets[perp.market], + rawSelectedMarketLeverages + ) + ), [(f) => f.createdAt], ['desc'] ); @@ -58,10 +69,13 @@ export function calculateParentSubaccountPositions( export function calculateParentSubaccountSummary( parent: ParentSubaccountDataBase, - markets: MarketsData + markets: MarketsData, + rawSelectedMarketLeverages: { [marketId: string]: number } ): GroupedSubaccountSummary { const summaries = mapValues(parent.childSubaccounts, (subaccount) => - subaccount != null ? calculateSubaccountSummary(subaccount, markets) : subaccount + subaccount != null + ? calculateSubaccountSummary(subaccount, markets, rawSelectedMarketLeverages) + : subaccount ); const parentSummary = summaries[parent.parentSubaccount]; if (parentSummary == null) { @@ -87,8 +101,16 @@ export function calculateMarketsNeededForSubaccount(parent: ParentSubaccountData } export const calculateSubaccountSummary = weakMapMemoize( - (subaccountData: ChildSubaccountData, markets: MarketsData): SubaccountSummary => { - const core = calculateSubaccountSummaryCore(subaccountData, markets); + ( + subaccountData: ChildSubaccountData, + markets: MarketsData, + rawSelectedMarketLeverages: { [marketId: string]: number } + ): SubaccountSummary => { + const core = calculateSubaccountSummaryCore( + subaccountData, + markets, + rawSelectedMarketLeverages + ); return { ...core, ...calculateSubaccountSummaryDerived(core), @@ -99,7 +121,8 @@ export const calculateSubaccountSummary = weakMapMemoize( function calculateSubaccountSummaryCore( subaccountData: ChildSubaccountData, - markets: MarketsData + markets: MarketsData, + rawSelectedMarketLeverages: { [marketId: string]: number } ): SubaccountSummaryCore { const quoteBalance = calc(() => { const usdcPosition = subaccountData.assetPositions.USDC; @@ -121,9 +144,9 @@ function calculateSubaccountSummaryCore( const { value: positionValue, notional: positionNotional, - initialRisk: positionInitialRisk, + initialRiskFromSelectedLeverage: positionInitialRisk, maintenanceRisk: positionMaintenanceRisk, - } = calculateDerivedPositionCore(getBnPosition(position), market); + } = calculateDerivedPositionCore(getBnPosition(position), market, rawSelectedMarketLeverages); return { valueTotal: acc.valueTotal.plus(positionValue), notionalTotal: acc.notionalTotal.plus(positionNotional), @@ -175,10 +198,11 @@ function calculateSubaccountSummaryDerived(core: SubaccountSummaryCore): Subacco function calculateSubaccountPosition( subaccountSummary: SubaccountSummary, position: IndexerPerpetualPositionResponseObject, - market: IndexerWsBaseMarketObject | undefined + market: IndexerWsBaseMarketObject | undefined, + rawSelectedMarketLeverages?: { [marketId: string]: number } ): SubaccountPosition { const bnPosition = getBnPosition(position); - const core = calculateDerivedPositionCore(bnPosition, market); + const core = calculateDerivedPositionCore(bnPosition, market, rawSelectedMarketLeverages); return { ...bnPosition, ...core, @@ -202,9 +226,31 @@ function getBnPosition(position: IndexerPerpetualPositionResponseObject): Subacc }; } +export function calculateEffectiveMarketImfFromSelectedLeverage({ + rawSelectedLeverage, + initialMarginFraction, + effectiveInitialMarginFraction, +}: { + rawSelectedLeverage: number | undefined; + initialMarginFraction: BigNumber | undefined; + effectiveInitialMarginFraction: BigNumber | undefined; +}) { + const effectiveSelectedLeverage = calculateEffectiveSelectedLeverageBigNumber({ + userSelectedLeverage: rawSelectedLeverage, + initialMarginFraction, + }); + const imfFromSelectedLeverage = BIG_NUMBERS.ONE.div(effectiveSelectedLeverage); + const adjustedImfFromSelectedLeverage = BigNumber.max( + imfFromSelectedLeverage, + effectiveInitialMarginFraction ?? 0 + ); + return { adjustedImfFromSelectedLeverage, effectiveSelectedLeverage }; +} + function calculateDerivedPositionCore( position: SubaccountPositionBase, - market: IndexerWsBaseMarketObject | undefined + market: IndexerWsBaseMarketObject | undefined, + rawSelectedMarketLeverages?: { [marketId: string]: number } ): SubaccountPositionDerivedCore { const marginMode = isParentSubaccount(position.subaccountNumber) ? 'CROSS' : 'ISOLATED'; const effectiveImf = @@ -222,6 +268,14 @@ function calculateDerivedPositionCore( const notional = unsignedSize.times(oracle); const value = signedSize.times(oracle); + const { adjustedImfFromSelectedLeverage, effectiveSelectedLeverage } = + calculateEffectiveMarketImfFromSelectedLeverage({ + rawSelectedLeverage: rawSelectedMarketLeverages?.[position.market], + initialMarginFraction: MaybeBigNumber(market?.initialMarginFraction), + effectiveInitialMarginFraction: effectiveImf, + }); + const initialRiskFromSelectedLeverage = notional.times(adjustedImfFromSelectedLeverage); + return { uniqueId: getPositionUniqueId(position.market, position.subaccountNumber), assetId: getAssetFromMarketId(position.market), @@ -240,6 +294,9 @@ function calculateDerivedPositionCore( } return BIG_NUMBERS.ONE.div(effectiveImf); }), + effectiveSelectedLeverage, + adjustedImfFromSelectedLeverage, + initialRiskFromSelectedLeverage, baseEntryPrice: position.entryPrice, baseNetFunding: position.netFunding, }; @@ -250,13 +307,23 @@ function calculatePositionDerivedExtra( subaccountSummary: SubaccountSummary ): SubaccountPositionDerivedExtra { const { equity, maintenanceRiskTotal } = subaccountSummary; - const { signedSize, notional, value, marginMode, adjustedMmf, maintenanceRisk, initialRisk } = - position; + const { + signedSize, + notional, + value, + marginMode, + adjustedMmf, + maintenanceRisk, + initialRisk, + initialRiskFromSelectedLeverage, + } = position; const leverage = equity.gt(0) ? notional.div(equity) : null; const marginValueMaintenance = marginMode === 'ISOLATED' ? equity : maintenanceRisk; const marginValueInitial = marginMode === 'ISOLATED' ? equity : initialRisk; + const marginValueInitialFromSelectedLeverage = + marginMode === 'ISOLATED' ? equity : initialRiskFromSelectedLeverage; const liquidationPrice = calc(() => { const otherPositionsRisk = maintenanceRiskTotal.minus(maintenanceRisk); @@ -283,7 +350,7 @@ function calculatePositionDerivedExtra( const entryValue = signedSize.multipliedBy(MustBigNumber(position.baseEntryPrice)); const unrealizedPnlInner = value.minus(entryValue); - const baseEquity = getPositionBaseEquity({ ...position, leverage }); + const baseEquity = getPositionBaseEquity(position); const unrealizedPnlPercentInner = baseEquity.isZero() ? null @@ -296,6 +363,7 @@ function calculatePositionDerivedExtra( leverage, marginValueMaintenance, marginValueInitial, + marginValueInitialFromSelectedLeverage, liquidationPrice, updatedUnrealizedPnl, updatedUnrealizedPnlPercent, @@ -304,12 +372,14 @@ function calculatePositionDerivedExtra( export function calculateChildSubaccountSummaries( parent: ParentSubaccountDataBase, - markets: MarketsData + markets: MarketsData, + selectedMarketLeverages: { [marketId: string]: number } ): ChildSubaccountSummaries { return pickBy( mapValues( parent.childSubaccounts, - (subaccount) => subaccount && calculateSubaccountSummary(subaccount, markets) + (subaccount) => + subaccount && calculateSubaccountSummary(subaccount, markets, selectedMarketLeverages) ), isTruthy ); @@ -356,13 +426,11 @@ export function calculateUnopenedIsolatedPositions( } export function getPositionBaseEquity( - position: Pick + position: Pick ) { const entryValue = position.signedSize.times(position.baseEntryPrice); - const scaledLeverage = position.leverage - ? BigNumber.max(position.leverage.abs(), BIG_NUMBERS.ONE) - : BIG_NUMBERS.ONE; + const scaledLeverage = BigNumber.max(position.effectiveSelectedLeverage.abs(), BIG_NUMBERS.ONE); return entryValue.abs().div(scaledLeverage); } diff --git a/src/bonsai/forms/adjustIsolatedMargin.ts b/src/bonsai/forms/adjustIsolatedMargin.ts index 3f0e23f485..d32ca240e6 100644 --- a/src/bonsai/forms/adjustIsolatedMargin.ts +++ b/src/bonsai/forms/adjustIsolatedMargin.ts @@ -80,6 +80,7 @@ const reducer = createVanillaReducer({ interface InputData { rawParentSubaccountData: ParentSubaccountDataBase | undefined; rawRelevantMarkets: MarketsData | undefined; + selectedMarketLeverages: { [marketId: string]: number }; canViewAccount?: boolean; } @@ -109,6 +110,13 @@ export interface SubaccountTransferPayload { destinationSubaccountNumber: number; } +export interface SubaccountUpdateLeveragePayload { + senderAddress: string; + subaccountNumber: number; + clobPairId: number; + leverage: number; +} + interface SummaryData { accountBefore: AccountDetails; accountAfter: AccountDetails; @@ -129,7 +137,12 @@ function calculateSummary( accountData.rawRelevantMarkets, state.childSubaccountNumber, (rawParentSubaccountData, rawRelevantMarkets, childSubaccountNumber) => - getRelevantAccountDetails(rawParentSubaccountData, rawRelevantMarkets, childSubaccountNumber) + getRelevantAccountDetails( + rawParentSubaccountData, + rawRelevantMarkets, + accountData.selectedMarketLeverages, + childSubaccountNumber + ) ); const inputs = calc((): Partial | undefined => { @@ -266,6 +279,7 @@ function calculateSummary( getRelevantAccountDetails( applyOperationsToSubaccount(rawParentSubaccountData, operations), rawRelevantMarkets, + accountData.selectedMarketLeverages, childSubaccountNumber ) ); @@ -433,15 +447,18 @@ function stringToNumberStringOrUndefined(num: string): string | undefined { function getRelevantAccountDetails( rawParentSubaccountData: ParentSubaccountDataBase, rawRelevantMarkets: MarketsData, + selectedMarketLeverages: { [marketId: string]: number }, childSubaccountNumber: number ): AccountDetails { const calculatedAccount = calculateParentSubaccountSummary( rawParentSubaccountData, - rawRelevantMarkets + rawRelevantMarkets, + selectedMarketLeverages ); const calculatedPositions = calculateParentSubaccountPositions( rawParentSubaccountData, - rawRelevantMarkets + rawRelevantMarkets, + selectedMarketLeverages ); const relevantPositions = calculatedPositions.filter( (p) => p.subaccountNumber === childSubaccountNumber diff --git a/src/bonsai/forms/trade/errors.ts b/src/bonsai/forms/trade/errors.ts index d2a11afd6b..e405bb8f9e 100644 --- a/src/bonsai/forms/trade/errors.ts +++ b/src/bonsai/forms/trade/errors.ts @@ -298,20 +298,6 @@ function validateFieldsBasic( ); } - if (options.needsTargetLeverage) { - const targetLeverage = AttemptNumber(state.targetLeverage) ?? 0; - if (targetLeverage <= 0) { - errors.push( - simpleValidationError({ - code: 'REQUIRED_TARGET_LEVERAGE', - type: ErrorType.error, - fields: ['targetLeverage'], - titleKey: STRING_KEYS.MODIFY_TARGET_LEVERAGE, - }) - ); - } - } - return errors; } diff --git a/src/bonsai/forms/trade/fields.ts b/src/bonsai/forms/trade/fields.ts index 3b40f6a565..abd2ad6a25 100644 --- a/src/bonsai/forms/trade/fields.ts +++ b/src/bonsai/forms/trade/fields.ts @@ -1,4 +1,3 @@ -import BigNumber from 'bignumber.js'; import { mapValues } from 'lodash'; import { @@ -9,7 +8,6 @@ import { import { assertNever } from '@/lib/assertNever'; import { calc } from '@/lib/do'; -import { FALLBACK_MARKET_LEVERAGE } from '@/lib/marketsHelpers'; import { ExecutionType, @@ -34,8 +32,6 @@ const DEFAULT_GOOD_TIL_TIME: GoodUntilTime = { unit: TimeUnit.DAY, }; -const DEFAULT_ISOLATED_TARGET_LEVERAGE = 2.0; - export function getTradeFormFieldStates( form: TradeForm, accountData: TradeFormInputData, @@ -63,20 +59,8 @@ export function getTradeFormFieldStates( const existingPosition = baseAccount?.position; - const existingPositionLeverage = existingPosition?.leverage?.toNumber(); - const maxMarketLeverage = existingPosition?.maxLeverage?.toNumber() ?? FALLBACK_MARKET_LEVERAGE; const existingPositionSide = existingPosition?.side; - const defaultTargetLeverage = calc(() => { - if ( - existingPositionOrOpenOrderMarginMode === MarginMode.ISOLATED && - existingPositionLeverage != null - ) { - return BigNumber.min(existingPositionLeverage, maxMarketLeverage); - } - return BigNumber.min(DEFAULT_ISOLATED_TARGET_LEVERAGE, maxMarketLeverage); - }); - const defaults: Required = { type: DEFAULT_TRADE_TYPE, marketId: '', @@ -84,7 +68,6 @@ export function getTradeFormFieldStates( size: OrderSizeInputs.SIZE({ value: '' }), reduceOnly: false, marginMode: MarginMode.CROSS, - targetLeverage: defaultTargetLeverage.toString(10), limitPrice: '', postOnly: false, timeInForce: TimeInForce.GTT, @@ -105,12 +88,6 @@ export function getTradeFormFieldStates( }) ) as TradeFormFieldStates; - function targetLeverageVisibleIfIsolated(result: TradeFormFieldStates): void { - if (result.marginMode.effectiveValue === MarginMode.ISOLATED) { - makeVisible(result, ['targetLeverage']); - } - } - function setMarginMode(result: TradeFormFieldStates): void { if (marketIsIsolatedOnly) { forceValueAndDisable(result.marginMode, MarginMode.ISOLATED); @@ -133,9 +110,8 @@ export function getTradeFormFieldStates( function defaultSizeIfSizeInputIsInvalid(states: TradeFormFieldStates) { if ( - (states.size.effectiveValue?.type === 'AVAILABLE_PERCENT' && - states.reduceOnly.effectiveValue !== true) || - states.size.effectiveValue?.type === 'SIGNED_POSITION_LEVERAGE' + states.size.effectiveValue?.type === 'AVAILABLE_PERCENT' && + states.type.effectiveValue === TradeFormType.TRIGGER_MARKET ) { states.size.effectiveValue = defaults.size; } @@ -177,7 +153,6 @@ export function getTradeFormFieldStates( case TradeFormType.MARKET: makeVisible(result, ['marketId', 'side', 'size', 'marginMode', 'reduceOnly']); setMarginMode(result); - targetLeverageVisibleIfIsolated(result); disableReduceOnlyIfIncreasingMarketOrder(result); return result; @@ -194,7 +169,6 @@ export function getTradeFormFieldStates( ]); defaultSizeIfSizeInputIsInvalid(result); setMarginMode(result); - targetLeverageVisibleIfIsolated(result); // goodTil is only visible and required for GTT if (result.timeInForce.effectiveValue === TimeInForce.GTT) { @@ -219,7 +193,6 @@ export function getTradeFormFieldStates( ]); defaultSizeIfSizeInputIsInvalid(result); setMarginMode(result); - targetLeverageVisibleIfIsolated(result); // reduceOnly is only visible when execution is IOC if (result.execution.effectiveValue !== ExecutionType.IOC) { @@ -239,7 +212,6 @@ export function getTradeFormFieldStates( ]); defaultSizeIfSizeInputIsInvalid(result); setMarginMode(result); - targetLeverageVisibleIfIsolated(result); // Execution is fixed for stop market forceValueAndDisable(result.execution, ExecutionType.IOC); diff --git a/src/bonsai/forms/trade/reducer.ts b/src/bonsai/forms/trade/reducer.ts index ee8724796b..5f8148666d 100644 --- a/src/bonsai/forms/trade/reducer.ts +++ b/src/bonsai/forms/trade/reducer.ts @@ -28,7 +28,6 @@ const getMinimumRequiredFields = ( reduceOnly: undefined, side: undefined, size: undefined, - targetLeverage: undefined, timeInForce: undefined, triggerPrice: undefined, stopLossOrder: undefined, @@ -103,11 +102,6 @@ export const tradeFormReducer = createVanillaReducer({ size: OrderSizeInputs.AVAILABLE_PERCENT({ value }), }), - setSizeLeverageSigned: (state, value: string) => ({ - ...state, - size: OrderSizeInputs.SIGNED_POSITION_LEVERAGE({ value }), - }), - // Price related actions setLimitPrice: (state, limitPrice: string) => ({ ...state, @@ -145,11 +139,6 @@ export const tradeFormReducer = createVanillaReducer({ goodTil, }), - setTargetLeverage: (state, targetLeverage: string) => ({ - ...state, - targetLeverage, - }), - showTriggers: (state) => ({ ...state, takeProfitOrder: {}, diff --git a/src/bonsai/forms/trade/summary.ts b/src/bonsai/forms/trade/summary.ts index 5433758085..972d38c88d 100644 --- a/src/bonsai/forms/trade/summary.ts +++ b/src/bonsai/forms/trade/summary.ts @@ -17,7 +17,7 @@ import { weakMapMemoize } from 'reselect'; import { TransactionMemo } from '@/constants/analytics'; import { timeUnits } from '@/constants/time'; -import { IndexerPerpetualPositionStatus, IndexerPositionSide } from '@/types/indexer/indexerApiGen'; +import { IndexerPerpetualPositionStatus } from '@/types/indexer/indexerApiGen'; import { assertNever } from '@/lib/assertNever'; import { calc, mapIfPresent } from '@/lib/do'; @@ -70,7 +70,12 @@ export function calculateTradeSummary( accountData.rawParentSubaccountData, rawMarkets, (rawParentSubaccountData, markets) => - getRelevantAccountDetails(rawParentSubaccountData, markets, positionIdToUse) + getRelevantAccountDetails( + rawParentSubaccountData, + markets, + accountData.rawSelectedMarketLeverages, + positionIdToUse + ) ); const fieldStates = getTradeFormFieldStates(state, accountData, baseAccount); @@ -128,6 +133,7 @@ export function calculateTradeSummary( getRelevantAccountDetails( applyOperationsToSubaccount(rawParentSubaccountData, operations), markets, + accountData.rawSelectedMarketLeverages, getPositionUniqueId(stateMarketId, tradeInfo.subaccountNumber) ) ); @@ -301,7 +307,6 @@ export function getErrorTradeSummary(marketId?: string | undefined): TradeFormSu size: undefined, reduceOnly: undefined, marginMode: undefined, - targetLeverage: undefined, limitPrice: undefined, postOnly: undefined, timeInForce: undefined, @@ -316,15 +321,13 @@ export function getErrorTradeSummary(marketId?: string | undefined): TradeFormSu executionOptions: [], timeInForceOptions: [], goodTilUnitOptions: [], - showLeverage: false, - showAmountClose: false, + showAllocationSlider: false, showTriggerOrders: false, triggerOrdersChecked: false, needsMarginMode: false, needsSize: false, needsLimitPrice: false, - needsTargetLeverage: false, needsTriggerPrice: false, needsGoodTil: false, needsReduceOnly: false, @@ -337,7 +340,6 @@ export function getErrorTradeSummary(marketId?: string | undefined): TradeFormSu showSize: false, showReduceOnly: false, showMarginMode: false, - showTargetLeverage: false, showLimitPrice: false, showPostOnly: false, showTimeInForce: false, @@ -358,8 +360,6 @@ export function getErrorTradeSummary(marketId?: string | undefined): TradeFormSu subaccountNumber: 0, transferToSubaccountAmount: 0, payloadPrice: undefined, - minimumSignedLeverage: 0, - maximumSignedLeverage: 0, slippage: undefined, fee: undefined, total: undefined, @@ -422,7 +422,7 @@ const memoizedMergeMarkets = weakMapMemoize( function calculateTradeFormOptions( orderType: TradeFormType | undefined, fields: TradeFormFieldStates, - baseAccount: TradeAccountDetails | undefined + _baseAccount: TradeAccountDetails | undefined ): TradeFormOptions { const executionOptions: SelectionOption[] = orderType ? matchOrderType(orderType, { @@ -434,23 +434,12 @@ function calculateTradeFormOptions( }) : emptyExecutionOptions; - const isCross = - fields.marginMode.effectiveValue == null || - fields.marginMode.effectiveValue === MarginMode.CROSS; - - const tradeSide = fields.side.effectiveValue; - const reduceOnly = fields.reduceOnly.effectiveValue; - const isDecreasing = - (baseAccount?.position?.side === IndexerPositionSide.LONG && tradeSide === OrderSide.SELL) || - (baseAccount?.position?.side === IndexerPositionSide.SHORT && tradeSide === OrderSide.BUY); - const options: TradeFormOptions = { orderTypeOptions, executionOptions, timeInForceOptions, goodTilUnitOptions, - needsTargetLeverage: isFieldStateRelevant(fields.targetLeverage), needsMarginMode: isFieldStateRelevant(fields.marginMode), needsSize: isFieldStateRelevant(fields.size), needsLimitPrice: isFieldStateRelevant(fields.limitPrice), @@ -461,17 +450,12 @@ function calculateTradeFormOptions( needsTimeInForce: isFieldStateRelevant(fields.timeInForce), needsExecution: isFieldStateRelevant(fields.execution), - showLeverage: orderType === TradeFormType.MARKET && isCross && (!reduceOnly || !isDecreasing), - showAmountClose: orderType === TradeFormType.MARKET && !!reduceOnly && isDecreasing, + showAllocationSlider: orderType !== TradeFormType.TRIGGER_MARKET, showTriggerOrders: isFieldStateEnabled(fields.takeProfitOrder) && isFieldStateEnabled(fields.stopLossOrder), triggerOrdersChecked: fields.takeProfitOrder.effectiveValue != null || fields.stopLossOrder.effectiveValue != null, - showTargetLeverage: - isFieldStateEnabled(fields.targetLeverage) && - (orderType !== TradeFormType.MARKET || !reduceOnly), - showMarginMode: isFieldStateEnabled(fields.marginMode), showSize: isFieldStateEnabled(fields.size), showLimitPrice: isFieldStateEnabled(fields.limitPrice), @@ -493,15 +477,26 @@ function calculateTradeFormOptions( function getRelevantAccountDetails( rawParentSubaccountData: ParentSubaccountDataBase, rawRelevantMarkets: MarketsData, + rawSelectedMarketLeverages: { [marketId: string]: number }, positionUniqueId?: PositionUniqueId ): TradeAccountDetails { - const account = calculateParentSubaccountSummary(rawParentSubaccountData, rawRelevantMarkets); - const positions = calculateParentSubaccountPositions(rawParentSubaccountData, rawRelevantMarkets); + const account = calculateParentSubaccountSummary( + rawParentSubaccountData, + rawRelevantMarkets, + rawSelectedMarketLeverages + ); + const positions = calculateParentSubaccountPositions( + rawParentSubaccountData, + rawRelevantMarkets, + rawSelectedMarketLeverages + ); const position = positions.find( (p) => positionUniqueId != null && p.uniqueId === positionUniqueId ); const subaccountSummaries = mapValues(rawParentSubaccountData.childSubaccounts, (subaccount) => - subaccount != null ? calculateSubaccountSummary(subaccount, rawRelevantMarkets) : subaccount + subaccount != null + ? calculateSubaccountSummary(subaccount, rawRelevantMarkets, rawSelectedMarketLeverages) + : subaccount ); return { position, account, subaccountSummaries }; } diff --git a/src/bonsai/forms/trade/tradeInfo.ts b/src/bonsai/forms/trade/tradeInfo.ts index 81c0793019..b0cca6cbcb 100644 --- a/src/bonsai/forms/trade/tradeInfo.ts +++ b/src/bonsai/forms/trade/tradeInfo.ts @@ -1,3 +1,5 @@ +import { calculateEffectiveSelectedLeverage } from '@/bonsai/calculators/markets'; +import { calculateEffectiveMarketImfFromSelectedLeverage } from '@/bonsai/calculators/subaccount'; import { CanvasOrderbookLine } from '@/bonsai/types/orderbookTypes'; import { ParentSubaccountDataBase } from '@/bonsai/types/rawTypes'; import { @@ -13,7 +15,7 @@ import { MAX_SUBACCOUNT_NUMBER, NUM_PARENT_SUBACCOUNTS } from '@/constants/accou import { MAJOR_MARKETS } from '@/constants/markets'; import { IndexerPositionSide } from '@/types/indexer/indexerApiGen'; -import { OCT_2025_REWARDS_DETAILS } from '@/hooks/rewards/util'; +import { CURRENT_SURGE_REWARDS_DETAILS } from '@/hooks/rewards/util'; import { assertNever } from '@/lib/assertNever'; import { calc, mapIfPresent } from '@/lib/do'; @@ -22,6 +24,7 @@ import { AttemptNumber, BIG_NUMBERS, clampBn, + MaybeBigNumber, MustBigNumber, MustNumber, toStepSize, @@ -46,7 +49,8 @@ const MARKET_ORDER_MAX_SLIPPAGE = 0.05; const STOP_MARKET_ORDER_SLIPPAGE_BUFFER_MAJOR_MARKET = 0.05; const STOP_MARKET_ORDER_SLIPPAGE_BUFFER = 0.1; const MAX_TARGET_LEVERAGE_BUFFER_PERCENT = 0.98; -const MAX_LEVERAGE_BUFFER_PERCENT = 0.98; +const MAX_ALLOCATION_BUFFER_CROSS = 0.98; +const MAX_ALLOCATION_BUFFER_ISOLATED = 0.95; const DEFAULT_TARGET_LEVERAGE = 2.0; export function calculateTradeInfo( @@ -61,25 +65,32 @@ export function calculateTradeInfo( accountData.allOpenOrders, accountData.rawParentSubaccountData ); - const leverageLimits = getSignedLeverageLimits( - baseAccount?.position, - accountData.currentTradeMarketSummary?.effectiveInitialMarginFraction ?? undefined, - trade.side, - trade.reduceOnly ?? false - ); return calc((): TradeSummary => { switch (trade.type) { case TradeFormType.MARKET: return calc((): TradeSummary => { - const calculated = calculateMarketOrder(trade, baseAccount, accountData, subaccountToUse); - const orderbookBase = accountData.currentTradeMarketOrderbook; - const calculatedMaxLeverage = getMaxCrossMarketOrderSizeSummary( + const calculatedMaxTrade = getMaxCrossMarketOrderSizeSummary( trade, baseAccount, accountData, - subaccountToUse - )?.leverageSigned; + // we force simulate against parent subaccount regardless of actual subaccount we're using + accountData.rawParentSubaccountData?.parentSubaccount ?? 0 + ); + const calculatedMaxUsdc = mapIfPresent( + calculatedMaxTrade?.usdcSize, + calculatedMaxTrade?.totalFees, + (a, b) => a + b + ); + + const calculated = calculateMarketOrder( + trade, + baseAccount, + accountData, + subaccountToUse, + calculatedMaxUsdc + ); + const orderbookBase = accountData.currentTradeMarketOrderbook; return { inputSummary: calculated.summary ?? { @@ -94,11 +105,6 @@ export function calculateTradeInfo( } return price * (1 - MARKET_ORDER_MAX_SLIPPAGE); }), - minimumSignedLeverage: leverageLimits.minLeverage.toNumber(), - maximumSignedLeverage: getSignedLeverageLimitsForMarketOrder( - leverageLimits, - calculatedMaxLeverage - ).toNumber(), slippage: calculateMarketOrderSlippage( calculated.marketOrder?.worstPrice, orderbookBase?.midPrice @@ -138,10 +144,12 @@ export function calculateTradeInfo( trade, calculated.summary?.size?.size ?? 0, calculated.summary?.averageFillPrice ?? 0, + calculated.marketOrder?.totalFees ?? 0, subaccountToUse, accountData.rawParentSubaccountData?.parentSubaccount, baseAccount?.position, - accountData.currentTradeMarketSummary + accountData.currentTradeMarketSummary, + accountData.rawSelectedMarketLeverages ), reward: calculateTakerReward( calculated.marketOrder?.usdcSize, @@ -153,7 +161,13 @@ export function calculateTradeInfo( }); case TradeFormType.TRIGGER_MARKET: return calc((): TradeSummary => { - const calculated = calculateMarketOrder(trade, baseAccount, accountData, subaccountToUse); + const calculated = calculateMarketOrder( + trade, + baseAccount, + accountData, + subaccountToUse, + undefined + ); const orderbookBase = accountData.currentTradeMarketOrderbook; const slippageFromMidPrice = calculateMarketOrderSlippage( @@ -215,14 +229,12 @@ export function calculateTradeInfo( Math.abs(orderSize - positionSize) < stepSize / 2 ) ?? false; - const inputSummary = { + const inputSummary: TradeInputSummary = { size: { size, usdcSize, // not supported - leverageSigned: undefined, - // not supported - balancePercent: undefined, + allocationPercent: undefined, }, averageFillPrice: price, worstFillPrice: price, @@ -232,8 +244,6 @@ export function calculateTradeInfo( return { indexSlippage: 0, - minimumSignedLeverage: leverageLimits.minLeverage.toNumber(), - maximumSignedLeverage: leverageLimits.maxLeverage.toNumber(), subaccountNumber: subaccountToUse, feeRate, filled: calculated.marketOrder?.filled ?? false, @@ -244,12 +254,14 @@ export function calculateTradeInfo( total, transferToSubaccountAmount: calculateIsolatedTransferAmount( trade, - inputSummary.size.size ?? 0, + inputSummary.size?.size ?? 0, price ?? 0, + totalFees ?? 0, subaccountToUse, accountData.rawParentSubaccountData?.parentSubaccount, baseAccount?.position, - accountData.currentTradeMarketSummary + accountData.currentTradeMarketSummary, + accountData.rawSelectedMarketLeverages ), payloadPrice, reward: calculateTakerReward( @@ -275,10 +287,13 @@ export function calculateTradeInfo( const price = AttemptNumber(trade.limitPrice); const inputSummary = calculateLimitOrderInputSummary( trade.size, + trade.side, trade.limitPrice, trade.reduceOnly, AttemptNumber(accountData.currentTradeMarketSummary?.stepSize), - baseAccount + baseAccount, + accountData, + subaccountToUse ); const totalFees = calculateTradeFeeAfterDiscounts( @@ -288,8 +303,6 @@ export function calculateTradeInfo( return { subaccountNumber: subaccountToUse, - minimumSignedLeverage: leverageLimits.minLeverage.toNumber(), - maximumSignedLeverage: leverageLimits.maxLeverage.toNumber(), slippage: 0, indexSlippage: 0, filled: true, @@ -314,10 +327,12 @@ export function calculateTradeInfo( trade, inputSummary.size?.size ?? 0, price ?? 0, + totalFees ?? 0, subaccountToUse, accountData.rawParentSubaccountData?.parentSubaccount, baseAccount?.position, - accountData.currentTradeMarketSummary + accountData.currentTradeMarketSummary, + accountData.rawSelectedMarketLeverages ), reward: isMaker ? calculateMakerReward(totalFees, accountData.rewardParams) @@ -343,7 +358,6 @@ interface TradeInputMarketOrder { size?: number; usdcSize?: number; - leverageSigned?: number; worstPrice?: number; filled: boolean; @@ -358,12 +372,21 @@ function calculateMarketOrder( trade: TradeForm, baseAccount: TradeAccountDetails | undefined, accountData: TradeFormInputData, - subaccountNumber: number + subaccountNumber: number, + maxTradeUsdc: number | undefined ): { marketOrder: TradeInputMarketOrder | undefined; summary: TradeInputSummary | undefined; } { - const marketOrder = createMarketOrder(trade, baseAccount, accountData, subaccountNumber); + const marketOrder = createMarketOrder( + trade, + baseAccount, + accountData, + subaccountNumber, + maxTradeUsdc + ); + const isIsolated = + subaccountNumber !== (accountData.rawParentSubaccountData?.parentSubaccount ?? 0); return { marketOrder, @@ -371,9 +394,36 @@ function calculateMarketOrder( averageFillPrice: marketOrder?.averagePrice, worstFillPrice: marketOrder?.worstPrice, size: { - leverageSigned: marketOrder?.leverageSigned, size: marketOrder?.size, usdcSize: marketOrder?.usdcSize, + allocationPercent: calc(() => { + const isDecreasingOrFlipping = + baseAccount?.position != null && + ((trade.side === OrderSide.BUY && + baseAccount.position.side === IndexerPositionSide.SHORT) || + (trade.side === OrderSide.SELL && + baseAccount.position.side === IndexerPositionSide.LONG)); + const isReduceOnly = !!trade.reduceOnly; + + if (isReduceOnly && isDecreasingOrFlipping) { + // Case 1: reversal of size-based calculation + return mapIfPresent( + marketOrder?.size, + baseAccount.position?.unsignedSize.toNumber(), + (size, positionSize) => size / positionSize + ); + } + // Case 2: reversal of usdc-based calculation + return mapIfPresent( + marketOrder?.usdcSize, + marketOrder?.totalFees, + maxTradeUsdc, + (sizeUsdc, fees, maxTotal) => + (sizeUsdc + fees) / + (maxTotal * + (isIsolated ? MAX_ALLOCATION_BUFFER_ISOLATED : MAX_ALLOCATION_BUFFER_CROSS)) + ); + }), }, }, }; @@ -381,7 +431,7 @@ function calculateMarketOrder( type SizeTarget = { target: BigNumber; - type: 'size' | 'usdc' | 'leverage' | 'maximum'; + type: 'size' | 'usdc' | 'maximum'; }; function getMaxCrossMarketOrderSizeSummary( @@ -390,7 +440,14 @@ function getMaxCrossMarketOrderSizeSummary( accountData: TradeFormInputData, subaccountNumber: number ) { - const result = createMarketOrder(trade, baseAccount, accountData, subaccountNumber, true); + const result = createMarketOrder( + trade, + baseAccount, + accountData, + subaccountNumber, + undefined, + true + ); return result; } @@ -399,6 +456,7 @@ function createMarketOrder( baseAccount: TradeAccountDetails | undefined, accountData: TradeFormInputData, subaccountNumber: number, + maxTradeUsdc: number | undefined, overrideToMaximumSize?: boolean ): TradeInputMarketOrder | undefined { const orderbookBase = accountData.currentTradeMarketOrderbook; @@ -416,7 +474,14 @@ function createMarketOrder( const effectiveSizeTarget = overrideToMaximumSize ? { target: BIG_NUMBERS.ZERO, type: 'maximum' as const } : trade.size != null - ? calculateEffectiveSizeTarget(trade.size, trade, baseAccount, accountData) + ? calculateEffectiveSizeTarget( + trade.size, + trade, + subaccountNumber, + baseAccount, + accountData, + maxTradeUsdc + ) : undefined; if (effectiveSizeTarget == null) { @@ -434,7 +499,18 @@ function createMarketOrder( (summaries) => summaries[subaccountNumber]?.freeCollateral.toNumber() ?? 0 ), AttemptNumber(accountData.currentTradeMarketSummary?.stepSize), - AttemptNumber(accountData.currentTradeMarketSummary?.effectiveInitialMarginFraction), + mapIfPresent( + accountData.currentTradeMarketSummary?.ticker, + AttemptBigNumber(accountData.currentTradeMarketSummary?.effectiveInitialMarginFraction), + AttemptBigNumber(accountData.currentTradeMarketSummary?.initialMarginFraction), + (ticker, effectiveImf, imf) => { + return calculateEffectiveMarketImfFromSelectedLeverage({ + rawSelectedLeverage: accountData.rawSelectedMarketLeverages[ticker], + effectiveInitialMarginFraction: MaybeBigNumber(effectiveImf), + initialMarginFraction: MaybeBigNumber(imf), + }).adjustedImfFromSelectedLeverage.toNumber(); + } + ), trade.side, (oraclePrice, equity, freeCollateral, stepSize, marketEffectiveImf, orderSide) => simulateMarketOrder( @@ -477,7 +553,9 @@ function simulateMarketOrder( let thisPositionValue = existingPosition == null ? 0 : existingPosition.value.toNumber(); let equity = subaccountEquity; const initialRiskWithoutPosition = - subaccountEquity - subaccountFreeCollateral - (existingPosition?.initialRisk.toNumber() ?? 0); + subaccountEquity - + subaccountFreeCollateral - + (existingPosition?.initialRiskFromSelectedLeverage.toNumber() ?? 0); const orderbookRows: OrderbookUsage[] = []; let filled = false; @@ -491,12 +569,6 @@ function simulateMarketOrder( size: 0, usdcSize: 0, totalFees: 0, - leverageSigned: - existingPosition != null - ? (existingPosition.leverage ?? BIG_NUMBERS.ZERO) - .times(existingPosition.value.div(existingPosition.value.abs())) - .toNumber() - : 0, averagePrice: undefined, worstPrice: undefined, }; @@ -523,40 +595,6 @@ function simulateMarketOrder( const maxSizeForRemainingUsdc = (effectiveSizeTarget.target - totalCost) / (rowPrice * (1 + feeRateAfterMarketDiscount)); sizeToTake = maxSizeForRemainingUsdc; - } else if (effectiveSizeTarget.type === 'leverage') { - const targetLeverage = effectiveSizeTarget.target; - - // numerator is the target quantity, which in this case is targetPositionValue - currentPositionValue, - // which is a positionValueDelta - // eslint-disable-next-line @typescript-eslint/no-loop-func - const numerator = calc(() => { - const base = targetLeverage * equity - thisPositionValue; - // if we're heading in the wrong direciton, go nowhere - if (base * operationMultipler < 0) { - return 0; - } - return base; - }); - - // denominator is the impact of each unit size on the target quantity (positionValue) - // for each unit size we add, how is position value affected - // and as above, position value is changing proportional to targetLeverage * equity - thisPositionValue - // eslint-disable-next-line @typescript-eslint/no-loop-func - const denominator = calc(() => { - const base = -(targetLeverage * sizeEquityImpact - operationMultipler * oraclePrice); - - if (base * operationMultipler < 0) { - // somehow adding size is taking position value in the opposite diretion - // which means leverage is going down as size goes up - // so let's force take the whole row I guess, by making numerator/denominator very large - return numerator / Number.MAX_SAFE_INTEGER; - } - - return base; - }); - - const maxSizeAtThisPrice = denominator === 0 ? 0 : numerator / denominator; - sizeToTake = maxSizeAtThisPrice; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition } else if (effectiveSizeTarget.type === 'maximum') { const increasing = @@ -640,18 +678,12 @@ function simulateMarketOrder( usdcSize: totalCostWithoutFees, totalFees: totalCost - totalCostWithoutFees, - leverageSigned: - equity <= 0 - ? undefined - : ((existingPosition?.value.toNumber() ?? 0) + - totalSize * oraclePrice * operationMultipler) / - equity, worstPrice: orderbookRows.at(-1)?.price, filled, }; } -function calculateSubaccountToUseForTrade( +export function calculateSubaccountToUseForTrade( marginMode: MarginMode | undefined, existingPositionSubaccount: number | undefined, openOrderSubaccount: number | undefined, @@ -688,65 +720,17 @@ function calculateSubaccountToUseForTrade( return 0; } -function getSignedLeverageLimits( - currentPosition: SubaccountPosition | undefined, - marketEffectiveImf: number | undefined, - side: OrderSide | undefined, - reduceOnly: boolean -): { minLeverage: BigNumber; maxLeverage: BigNumber } { - const sideToUse = side ?? OrderSide.BUY; - - const effectiveImf = marketEffectiveImf ?? 1; - const marketMaxLeverage = BIG_NUMBERS.ONE.div(effectiveImf === 0 ? 1 : effectiveImf); - - const hasPosition = currentPosition != null && !currentPosition.signedSize.isZero(); - const positionLeverageUnsigned = currentPosition?.leverage ?? BIG_NUMBERS.ZERO; - const isPositionLong = hasPosition && currentPosition.signedSize.gt(BIG_NUMBERS.ZERO); - const positionLeverageSigned = positionLeverageUnsigned.times(isPositionLong ? 1 : -1); - - const isOrderBuy = sideToUse === OrderSide.BUY; - const isOrderIncreasingPosition = - !hasPosition || (isPositionLong && isOrderBuy) || (!isPositionLong && !isOrderBuy); - - return { - minLeverage: positionLeverageSigned, - maxLeverage: calc(() => { - if (reduceOnly) { - if (isOrderIncreasingPosition) { - // Can't increase position with reduceOnly - return positionLeverageSigned; - } - // Can reduce position to zero - return BIG_NUMBERS.ZERO; - } - // Not reduceOnly, use standard market limits - return (isOrderBuy ? marketMaxLeverage : marketMaxLeverage.times(-1)).times( - MAX_LEVERAGE_BUFFER_PERCENT - ); - }), - }; -} - -function getSignedLeverageLimitsForMarketOrder( - limits: { - minLeverage: BigNumber; - maxLeverage: BigNumber; - }, - maxCalculated: number | undefined -) { - return ( - mapIfPresent(maxCalculated, (m) => - AttemptBigNumber(m)?.decimalPlaces(2, BigNumber.ROUND_DOWN).times(MAX_LEVERAGE_BUFFER_PERCENT) - ) ?? limits.maxLeverage - ); -} - function calculateEffectiveSizeTarget( sizeInput: OrderSizeInput, trade: TradeForm, + subaccountNumber: number, baseAccount: TradeAccountDetails | undefined, - accountData: TradeFormInputData + accountData: TradeFormInputData, + maxTradeUsdc: number | undefined ): SizeTarget | undefined { + const isIsolated = + subaccountNumber !== (accountData.rawParentSubaccountData?.parentSubaccount ?? 0); + return OrderSizeInputs.match(sizeInput, { AVAILABLE_PERCENT: ({ value }) => { const percent = AttemptBigNumber(value); @@ -769,17 +753,12 @@ function calculateEffectiveSizeTarget( type: 'size' as const, }; } - // we don't support target leverage for isolated positions, makes no sense since we're transferring collateral with trade - if (trade.marginMode === MarginMode.ISOLATED) { - return undefined; - } - const parentSubaccountFreeCollateral = - baseAccount?.account?.freeCollateral ?? BIG_NUMBERS.ZERO; - const marketEffectiveImf = - accountData.currentTradeMarketSummary?.effectiveInitialMarginFraction ?? 1; - const usdcTarget = parentSubaccountFreeCollateral - .times(percent) - .div(marketEffectiveImf === 0 ? 1 : marketEffectiveImf); + // we do the same for isolated and cross, which is only an approximation due to transfer buffers + // we also aren't accounting for fees properly + const usdcTarget = MustBigNumber(maxTradeUsdc) + .times(isIsolated ? MAX_ALLOCATION_BUFFER_ISOLATED : MAX_ALLOCATION_BUFFER_CROSS) + .times(percent); + return { target: usdcTarget, type: 'usdc' as const, @@ -805,74 +784,65 @@ function calculateEffectiveSizeTarget( target, }; }, - SIGNED_POSITION_LEVERAGE: ({ value }) => { - let target = AttemptBigNumber(value); - if (target == null) { - return undefined; - } - if (trade.side == null) { - return undefined; - } - // we don't support target leverage for isolated positions, makes no sense since we're transferring collateral with trade - if (trade.marginMode === MarginMode.ISOLATED && !trade.reduceOnly) { - return undefined; - } - const signedLimits = getSignedLeverageLimits( - baseAccount?.position, - accountData.currentTradeMarketSummary?.effectiveInitialMarginFraction ?? undefined, - trade.side, - trade.reduceOnly ?? false - ); - if (trade.side === OrderSide.BUY) { - // going positive - if (target.lt(signedLimits.minLeverage)) { - target = signedLimits.minLeverage; - } - if (target.gt(signedLimits.maxLeverage)) { - target = signedLimits.maxLeverage; - } - } else { - // going negative - if (target.gt(signedLimits.minLeverage)) { - target = signedLimits.minLeverage; - } - if (target.lt(signedLimits.maxLeverage)) { - target = signedLimits.maxLeverage; - } - } - return { - target, - type: 'leverage', - }; - }, }); } function calculateLimitOrderInputSummary( size: OrderSizeInput | undefined, + side: OrderSide | undefined, limitPrice: string | undefined, reduceOnly: boolean | undefined, marketStepSize: number | undefined, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - baseAccount: TradeAccountDetails | undefined + baseAccount: TradeAccountDetails | undefined, + accountData: TradeFormInputData, + subaccountToUse: number ): TradeInputSummary { const price = MustNumber(limitPrice); + const targetLeverage = calc(() => { + const effectiveImf = accountData.currentTradeMarketSummary?.effectiveInitialMarginFraction ?? 0; + const marketMaxLeverage = 1 / (effectiveImf === 0 ? 1 : effectiveImf); + const effectiveTargetLeverage = calculateEffectiveSelectedLeverage({ + userSelectedLeverage: + accountData.currentTradeMarketSummary?.ticker != null + ? accountData.rawSelectedMarketLeverages[accountData.currentTradeMarketSummary.ticker] + : undefined, + initialMarginFraction: accountData.currentTradeMarketSummary?.initialMarginFraction, + }); + return Math.min(effectiveTargetLeverage, marketMaxLeverage); + }); + const isDecreasingOrFlipping = + baseAccount?.position != null && + ((side === OrderSide.BUY && baseAccount.position.side === IndexerPositionSide.SHORT) || + (side === OrderSide.SELL && baseAccount.position.side === IndexerPositionSide.LONG)); + + const isIsolatedOrder = + subaccountToUse !== (accountData.rawParentSubaccountData?.parentSubaccount ?? 0); + const transferBufferDivisor = isIsolatedOrder ? 1 + FLAT_TRANSFER_BUFFER : 1; + const effectiveSize = toStepSize( size != null ? OrderSizeInputs.match(size, { - // only reduce only AVAILABLE_PERCENT: ({ value }) => { - if (!reduceOnly) { - return 0.0; - } const percent = AttemptBigNumber(value); if (percent == null) { return 0.0; } - return baseAccount?.position?.unsignedSize.times(percent).toNumber() ?? 0.0; + + if (reduceOnly && isDecreasingOrFlipping) { + return baseAccount.position?.unsignedSize.times(percent).toNumber() ?? 0.0; + } + // we do the same for isolated and cross, which is only an approximation + // we also aren't accounting for fees properly + const crossFree = baseAccount?.account?.freeCollateral.toNumber() ?? 0; + const maxOrderUsdc = (crossFree * targetLeverage) / transferBufferDivisor; + const maxSpendSize = divideIfNonZeroElse(MustNumber(maxOrderUsdc), price, 0); + if (isDecreasingOrFlipping) { + return percent.times( + maxSpendSize + (baseAccount.position?.unsignedSize.toNumber() ?? 0) + ); + } + return percent.times(maxSpendSize); }, - // not supported - SIGNED_POSITION_LEVERAGE: () => 0.0, SIZE: ({ value }) => AttemptNumber(value) ?? 0.0, USDC_SIZE: ({ value }) => divideIfNonZeroElse(MustNumber(value), price, 0), }) @@ -884,10 +854,27 @@ function calculateLimitOrderInputSummary( averageFillPrice: price, worstFillPrice: price, size: { - // not supported - leverageSigned: undefined, size: effectiveSize, usdcSize: effectiveSize * price, + allocationPercent: calc(() => { + if (reduceOnly && isDecreasingOrFlipping) { + return mapIfPresent( + baseAccount.position?.unsignedSize.toNumber(), + (positionSize) => effectiveSize / positionSize + ); + } + + const crossFree = baseAccount?.account?.freeCollateral.toNumber() ?? 0; + const maxOrderUsdc = (crossFree * targetLeverage) / transferBufferDivisor; + const maxSpendSize = divideIfNonZeroElse(MustNumber(maxOrderUsdc), price, 0); + + if (isDecreasingOrFlipping) { + const denominator = maxSpendSize + (baseAccount.position?.unsignedSize.toNumber() ?? 0); + return denominator !== 0 ? effectiveSize / denominator : undefined; + } + + return maxSpendSize !== 0 ? effectiveSize / maxSpendSize : undefined; + }), }, }; } @@ -900,7 +887,7 @@ const RATE_LOST_TO_REV_SHARES = 0.4; // megavault and ops const MAX_POSSIBLE_TAKER_REV_SHARE = 0.5; // affiliates const IS_FEE_REBATE_TIME: boolean = true; -const FEE_REBATE_PERCENT = OCT_2025_REWARDS_DETAILS.rebateFraction; +const FEE_REBATE_PERCENT = CURRENT_SURGE_REWARDS_DETAILS.rebateFraction; function calculateTakerReward( usdcSize: number | undefined, @@ -985,10 +972,12 @@ function calculateIsolatedTransferAmount( trade: TradeForm, tradeSize: number, tradePrice: number, + tradeFees: number, subaccountToUse: number, parentSubaccount: number | undefined, existingPosition: SubaccountPosition | undefined, - tradeMarketSummary: PerpetualMarketSummary | undefined + tradeMarketSummary: PerpetualMarketSummary | undefined, + rawSelectedMarketLeverages: { [marketId: string]: number } ): number { if ( !getShouldTransferCollateral( @@ -1006,8 +995,10 @@ function calculateIsolatedTransferAmount( trade, tradeSize, tradePrice, + tradeFees, existingPosition, - tradeMarketSummary + tradeMarketSummary, + rawSelectedMarketLeverages ) ?? 0 ); } @@ -1022,16 +1013,19 @@ function getShouldTransferCollateral( const isIsolatedOrder = trade.marginMode === MarginMode.ISOLATED && subaccountToUse !== parentSubaccount; const isReduceOnly = trade.reduceOnly ?? false; - const isIncreasingSize = getPositionSizeDifference(trade, tradeSize, existingPosition) > 0; - return isIsolatedOrder && isIncreasingSize && !isReduceOnly; + const isIncreasingOrCrossing = + getIncreasingPositionAmount(trade, tradeSize, existingPosition) > 0; + return isIsolatedOrder && isIncreasingOrCrossing && !isReduceOnly; } function calculateIsolatedMarginTransferAmount( trade: TradeForm, tradeSize: number, tradePrice: number, + tradeFees: number, existingPosition: SubaccountPosition | undefined, - tradeMarketSummary: PerpetualMarketSummary | undefined + tradeMarketSummary: PerpetualMarketSummary | undefined, + rawSelectedMarketLeverages: { [marketId: string]: number } ): number | undefined { const oraclePrice = AttemptNumber(tradeMarketSummary?.oraclePrice); const side = trade.side; @@ -1041,10 +1035,17 @@ function calculateIsolatedMarginTransferAmount( const effectiveImf = tradeMarketSummary?.effectiveInitialMarginFraction ?? 0; const marketMaxLeverage = 1 / (effectiveImf === 0 ? 1 : effectiveImf); + const effectiveTargetLeverage = calculateEffectiveSelectedLeverage({ + userSelectedLeverage: + tradeMarketSummary?.ticker != null + ? rawSelectedMarketLeverages[tradeMarketSummary.ticker] + : undefined, + initialMarginFraction: tradeMarketSummary?.initialMarginFraction, + }); const targetLeverage = - AttemptNumber(trade.targetLeverage) ?? Math.min(DEFAULT_TARGET_LEVERAGE, marketMaxLeverage); + AttemptNumber(effectiveTargetLeverage) ?? Math.min(DEFAULT_TARGET_LEVERAGE, marketMaxLeverage); - const positionSizeDifference = getPositionSizeDifference(trade, tradeSize, existingPosition); + const positionIncreasingAmount = getIncreasingPositionAmount(trade, tradeSize, existingPosition); const estOraclePriceAtExecution = calc(() => { switch (trade.type) { @@ -1066,25 +1067,39 @@ function calculateIsolatedMarginTransferAmount( side, estOraclePriceAtExecution, tradePrice, + tradeFees, marketMaxLeverage, - positionSizeDifference + tradeSize, + positionIncreasingAmount, + trade.type === TradeFormType.LIMIT || trade.type === TradeFormType.TRIGGER_LIMIT ); } -function getPositionSizeDifference( +function getIncreasingPositionAmount( trade: TradeForm, tradeSize: number, existingPosition: SubaccountPosition | undefined -) { - const baseTradeSizeSigned = tradeSize * (trade.side === OrderSide.SELL ? -1 : 1); - const positionSizeBefore = existingPosition?.signedSize.toNumber() ?? 0; - const positionSizeAfterNotAccountingForReduceOnly = positionSizeBefore + baseTradeSizeSigned; - const positionSizeAfter = - trade.reduceOnly && positionSizeBefore * positionSizeAfterNotAccountingForReduceOnly <= 0 - ? 0 - : positionSizeAfterNotAccountingForReduceOnly; - const positionSizeDifference = Math.abs(positionSizeAfter) - Math.abs(positionSizeBefore); - return positionSizeDifference; +): number { + const side = trade.side; + + if (side == null) { + return 0; + } + if (existingPosition == null) { + return tradeSize; + } + + const positionSide = existingPosition.side; + const positionSize = existingPosition.unsignedSize.toNumber(); + + const isSameSide = + (side === OrderSide.BUY && positionSide === IndexerPositionSide.LONG) || + (side === OrderSide.SELL && positionSide === IndexerPositionSide.SHORT); + + if (isSameSide) { + return tradeSize; + } + return Math.max(0, tradeSize - positionSize); } function calculateIsolatedMarginTransferAmountFromValues( @@ -1092,8 +1107,11 @@ function calculateIsolatedMarginTransferAmountFromValues( side: OrderSide, estOraclePriceAtExecution: number, price: number, + fees: number, maxMarketLeverage: number, - positionSizeDifference: number + orderSize: number, + positionIncreasingSize: number, + ignoreSlippageAndOracleDrift: boolean ): number | undefined { const adjustedTargetLeverage = Math.min( targetLeverage, @@ -1106,10 +1124,13 @@ function calculateIsolatedMarginTransferAmountFromValues( const amount = getTransferAmountFromTargetLeverage( price, + fees, estOraclePriceAtExecution, side, - positionSizeDifference, - adjustedTargetLeverage + orderSize, + positionIncreasingSize, + adjustedTargetLeverage, + ignoreSlippageAndOracleDrift ); if (amount <= 0) { return undefined; @@ -1117,27 +1138,46 @@ function calculateIsolatedMarginTransferAmountFromValues( return amount; } +// for MARKET-ish orders +const SLIPPAGE_BUFFER = 0.005; +const ORACLE_BUFFER = 0.005; + +// for LIMIT-ish orders +const FLAT_TRANSFER_BUFFER = 0.01; + function getTransferAmountFromTargetLeverage( price: number, + fees: number, estOraclePriceAtExecution: number, side: OrderSide, - size: number, - targetLeverage: number + orderSize: number, + increasingSize: number, + targetLeverage: number, + ignoreSlippageAndOracleDrift: boolean ): number { - if (targetLeverage === 0) { + if (targetLeverage === 0 || increasingSize <= 0 || orderSize <= 0) { return 0; } - const naiveTransferAmount = (price * size) / targetLeverage; + const slippageBuffer = ignoreSlippageAndOracleDrift ? 0 : SLIPPAGE_BUFFER; + const oracleBuffer = ignoreSlippageAndOracleDrift ? 0 : ORACLE_BUFFER; - // Calculate price difference for immediate PnL impact - const priceDiff = - side === OrderSide.BUY ? price - estOraclePriceAtExecution : estOraclePriceAtExecution - price; + const IMF = 1 / targetLeverage; - // Return the maximum of the naive transfer and the adjusted transfer amount - return Math.max( - (estOraclePriceAtExecution * size) / targetLeverage + priceDiff * size, - naiveTransferAmount + // maximize margin requirement by adding buffer to oracle + const margin = increasingSize * (estOraclePriceAtExecution * (1 + oracleBuffer)) * IMF; + // maximize fees by adding buffer to fill price + const feesAtFillPriceWithBuffer = fees * (1 + slippageBuffer); + + // maximize slippage in each case by adding/removing buffer from actual expectations + const slippageLoss = + side === OrderSide.BUY + ? orderSize * (price * (1 + slippageBuffer) - estOraclePriceAtExecution * (1 - oracleBuffer)) + : orderSize * (estOraclePriceAtExecution * (1 + oracleBuffer) - price * (1 - slippageBuffer)); + + return ( + (margin + feesAtFillPriceWithBuffer + slippageLoss) * + (ignoreSlippageAndOracleDrift ? 1 + FLAT_TRANSFER_BUFFER : 1) ); } diff --git a/src/bonsai/forms/trade/types.ts b/src/bonsai/forms/trade/types.ts index 17a0211f75..fa003bf60c 100644 --- a/src/bonsai/forms/trade/types.ts +++ b/src/bonsai/forms/trade/types.ts @@ -58,14 +58,12 @@ export type GoodUntilTime = { type SizeInput = { value: string }; type UsdcSizeInput = { value: string }; type AvailablePercentInput = { value: string }; -type SignedLeverageInput = { value: string }; export const OrderSizeInputs = unionize( { SIZE: ofType(), USDC_SIZE: ofType(), AVAILABLE_PERCENT: ofType(), - SIGNED_POSITION_LEVERAGE: ofType(), }, { tag: 'type' as const, value: 'value' as const } ); @@ -106,7 +104,6 @@ export type TradeForm = { // isolated marginMode: MarginMode | undefined; - targetLeverage: string | undefined; // Limit order fields limitPrice: string | undefined; @@ -148,9 +145,7 @@ export type TradeFormOptions = { timeInForceOptions: SelectionOption[]; goodTilUnitOptions: SelectionOption[]; - showLeverage: boolean; - showAmountClose: boolean; - + showAllocationSlider: boolean; showTriggerOrders: boolean; triggerOrdersChecked: boolean; @@ -158,7 +153,6 @@ export type TradeFormOptions = { needsSize: boolean; needsReduceOnly: boolean; needsMarginMode: boolean; - needsTargetLeverage: boolean; needsLimitPrice: boolean; needsPostOnly: boolean; needsTimeInForce: boolean; @@ -170,7 +164,6 @@ export type TradeFormOptions = { showSize: boolean; showReduceOnly: boolean; showMarginMode: boolean; - showTargetLeverage: boolean; showLimitPrice: boolean; showPostOnly: boolean; showTimeInForce: boolean; @@ -185,7 +178,7 @@ export type TradeFormOptions = { export type TradeSizeSummary = { size: number | undefined; usdcSize: number | undefined; - leverageSigned: number | undefined; + allocationPercent: number | undefined; }; export type TradeInputSummary = { @@ -206,11 +199,6 @@ export type TradeSummary = { transferToSubaccountAmount: number; payloadPrice: number | undefined; - // minimum is essentially the current position leverage or zero - minimumSignedLeverage: number; - // maximum is how far the current order side can push leverage - maximumSignedLeverage: number; - slippage: number | undefined; fee: number | undefined; total: number | undefined; @@ -245,6 +233,7 @@ export type TradeFormSummary = { export type TradeFormInputData = { rawParentSubaccountData: ParentSubaccountDataBase | undefined; rawRelevantMarkets: MarketsData | undefined; + rawSelectedMarketLeverages: { [marketId: string]: number }; currentTradeMarketOpenOrders: SubaccountOrder[]; // todo remove maybe allOpenOrders: SubaccountOrder[]; diff --git a/src/bonsai/forms/transfers.ts b/src/bonsai/forms/transfers.ts index 93b2451e96..94855afd93 100644 --- a/src/bonsai/forms/transfers.ts +++ b/src/bonsai/forms/transfers.ts @@ -82,6 +82,7 @@ const reducer = createVanillaReducer({ export interface TransferFormInputData { rawParentSubaccountData: ParentSubaccountDataBase | undefined; rawRelevantMarkets: MarketsData | undefined; + selectedMarketLeverages: { [marketId: string]: number }; walletBalances: AccountBalances | undefined; feeResult: TransferFeeData | undefined; // Fee data provided by consumer canViewAccount: boolean | undefined; @@ -146,6 +147,7 @@ function calculateSummary( const accountBefore = getAccountDetails( inputData.rawParentSubaccountData, inputData.rawRelevantMarkets, + inputData.selectedMarketLeverages, inputData.walletBalances ); @@ -268,6 +270,7 @@ function calculateSummary( ...getAccountDetails( modifiedSubaccountData, inputData.rawRelevantMarkets, + inputData.selectedMarketLeverages, inputData.walletBalances ), availableNativeBalance: calc(() => { @@ -292,13 +295,18 @@ function calculateSummary( } function getAccountDetails( - rawParentSubaccountData?: ParentSubaccountDataBase, - rawRelevantMarkets?: MarketsData, + rawParentSubaccountData: ParentSubaccountDataBase | undefined, + rawRelevantMarkets: MarketsData | undefined, + selectedMarketLeverages: { [marketId: string]: number }, rawWalletBalances?: AccountBalances ): AccountDetails { return { ...(mapIfPresent(rawParentSubaccountData, rawRelevantMarkets, (subaccountData, marketsData) => { - const calculatedAccount = calculateParentSubaccountSummary(subaccountData, marketsData); + const calculatedAccount = calculateParentSubaccountSummary( + subaccountData, + marketsData, + selectedMarketLeverages + ); return { equity: calculatedAccount.equity.toNumber(), freeCollateral: calculatedAccount.freeCollateral.toNumber(), diff --git a/src/bonsai/forms/triggers/errors.ts b/src/bonsai/forms/triggers/errors.ts index c96ad9c52d..1495266d59 100644 --- a/src/bonsai/forms/triggers/errors.ts +++ b/src/bonsai/forms/triggers/errors.ts @@ -314,7 +314,7 @@ function validateTriggerOrderPayloadForEquityTiers( inputData.rawParentSubaccountData, inputData.rawRelevantMarkets, (subaccountData, markets) => - calculateChildSubaccountSummaries(subaccountData, markets)[ + calculateChildSubaccountSummaries(subaccountData, markets, inputData.selectedMarketLeverages)[ `${subaccountToUse}` ]?.equity.toNumber() ); diff --git a/src/bonsai/forms/triggers/types.ts b/src/bonsai/forms/triggers/types.ts index 7c1886f4f7..1bd217b170 100644 --- a/src/bonsai/forms/triggers/types.ts +++ b/src/bonsai/forms/triggers/types.ts @@ -55,6 +55,7 @@ export interface TriggerOrderInputData { // these are only for checking equity tier issues rawParentSubaccountData: ParentSubaccountDataBase | undefined; rawRelevantMarkets: MarketsData | undefined; + selectedMarketLeverages: { [marketId: string]: number }; equityTiers: EquityTiersSummary | undefined; allOpenOrders?: SubaccountOrder[]; } diff --git a/src/bonsai/ontology.ts b/src/bonsai/ontology.ts index dd690cb794..e2dca96bbb 100644 --- a/src/bonsai/ontology.ts +++ b/src/bonsai/ontology.ts @@ -77,6 +77,7 @@ import { selectRawIndexerHeightDataLoading, selectRawMarketsData, selectRawParentSubaccountData, + selectRawSelectedMarketLeveragesData, selectRawValidatorHeightDataLoading, } from './selectors/base'; import { selectCompliance, selectComplianceLoading } from './selectors/compliance'; @@ -94,8 +95,10 @@ import { selectCurrentMarketAssetId, selectCurrentMarketAssetLogoUrl, selectCurrentMarketAssetName, + selectCurrentMarketEffectiveSelectedLeverage, selectCurrentMarketInfo, selectCurrentMarketInfoStable, + selectEffectiveSelectedMarketLeverage, selectMarketSummaryById, StablePerpetualMarketSummary, } from './selectors/summary'; @@ -303,6 +306,7 @@ interface BonsaiRawShape { // DANGER: only the CURRENT relevant markets, so you cannot use if your operation might make MORE markets relevant // e.g. any place order parentSubaccountRelevantMarkets: BasicSelector; + selectedMarketLeverages: BasicSelector<{ [marketId: string]: number } | undefined>; currentMarket: BasicSelector | undefined>; // DANGER: updates a lot allMarkets: BasicSelector; @@ -311,6 +315,7 @@ interface BonsaiRawShape { export const BonsaiRaw: BonsaiRawShape = { parentSubaccountBase: selectRawParentSubaccountData, parentSubaccountRelevantMarkets: selectRelevantMarketsData, + selectedMarketLeverages: selectRawSelectedMarketLeveragesData, currentMarket: selectCurrentMarketInfoRaw, allMarkets: selectRawMarketsData, }; @@ -325,6 +330,7 @@ interface BonsaiHelpersShape { assetId: BasicSelector; assetLogo: BasicSelector; assetName: BasicSelector; + effectiveSelectedLeverage: BasicSelector; account: { buyingPower: BasicSelector; @@ -357,6 +363,7 @@ interface BonsaiHelpersShape { PerpetualMarketSummary | undefined, [string | undefined] >; + selectEffectiveSelectedMarketLeverage: BasicSelector; }; forms: { deposit: { @@ -382,6 +389,7 @@ export const BonsaiHelpers: BonsaiHelpersShape = { assetId: selectCurrentMarketAssetId, assetLogo: selectCurrentMarketAssetLogoUrl, assetName: selectCurrentMarketAssetName, + effectiveSelectedLeverage: selectCurrentMarketEffectiveSelectedLeverage, orderbook: { selectGroupedData: selectCurrentMarketOrderbook, loading: selectCurrentMarketOrderbookLoading, @@ -408,6 +416,7 @@ export const BonsaiHelpers: BonsaiHelpersShape = { }, markets: { selectMarketSummaryById, + selectEffectiveSelectedMarketLeverage, }, forms: { deposit: { diff --git a/src/bonsai/public-calculators/vault.ts b/src/bonsai/public-calculators/vault.ts index 11b32258cb..1994270d7e 100644 --- a/src/bonsai/public-calculators/vault.ts +++ b/src/bonsai/public-calculators/vault.ts @@ -203,7 +203,8 @@ function calculateVaultPosition( }, }, }, - market != null ? { [market.ticker]: market } : {} + market != null ? { [market.ticker]: market } : {}, + {} ); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition diff --git a/src/bonsai/rest/leverage.ts b/src/bonsai/rest/leverage.ts new file mode 100644 index 0000000000..8e0e9f72a6 --- /dev/null +++ b/src/bonsai/rest/leverage.ts @@ -0,0 +1,65 @@ +import { DEFAULT_LEVERAGE_PPM } from '@/constants/leverage'; +import { timeUnits } from '@/constants/time'; + +import { type RootStore } from '@/state/_store'; +import { setSelectedMarketLeverages } from '@/state/raw'; + +import { parseToPrimitives } from '@/lib/parseToPrimitives'; + +import { loadableIdle } from '../lib/loadable'; +import { mapLoadableData } from '../lib/mapLoadable'; +import { selectParentSubaccountAndMarkets } from '../selectors/account'; +import { createValidatorQueryStoreEffect } from './lib/indexerQueryStoreEffect'; +import { queryResultToLoadable } from './lib/queryResultToLoadable'; + +export function setUpUserLeverageParamsQuery(store: RootStore) { + const cleanupEffect = createValidatorQueryStoreEffect(store, { + name: 'leverageParams', + selector: selectParentSubaccountAndMarkets, + getQueryKey: (data) => ['leverageParams', data.parentSubaccount.wallet], + getQueryFn: (compositeClient, data) => { + return async () => { + if (!data.parentSubaccount.wallet || !data.markets) { + return {}; + } + + const clobPairToMarket = Object.fromEntries( + Object.values(data.markets).map((m) => [m.clobPairId, m.ticker]) + ); + + const leverages = await compositeClient.validatorClient.get.getPerpetualMarketsLeverage( + data.parentSubaccount.wallet, + data.parentSubaccount.subaccount + ); + return leverages.clobPairLeverage.reduce( + (acc, leverage) => { + const market = clobPairToMarket[leverage.clobPairId]; + if (market === undefined) { + return acc; + } + + return { + ...acc, + [market]: DEFAULT_LEVERAGE_PPM / leverage.customImfPpm, + }; + }, + {} as { [market: string]: number } + ); + }; + }, + onResult: (result) => { + store.dispatch( + setSelectedMarketLeverages( + mapLoadableData(queryResultToLoadable(result), (d) => parseToPrimitives(d)) + ) + ); + }, + onNoQuery: () => store.dispatch(setSelectedMarketLeverages(loadableIdle())), + refetchInterval: timeUnits.hour, + staleTime: timeUnits.hour, + }); + return () => { + cleanupEffect(); + store.dispatch(setSelectedMarketLeverages(loadableIdle())); + }; +} diff --git a/src/bonsai/selectors/account.ts b/src/bonsai/selectors/account.ts index d8c8071ba5..faebfeb99d 100644 --- a/src/bonsai/selectors/account.ts +++ b/src/bonsai/selectors/account.ts @@ -1,5 +1,5 @@ import { NOBLE_BECH32_PREFIX } from '@dydxprotocol/v4-client-js'; -import { orderBy, pick } from 'lodash'; +import { isEqual, orderBy, pick } from 'lodash'; import { shallowEqual } from 'react-redux'; import { EMPTY_ARR } from '@/constants/objects'; @@ -48,11 +48,13 @@ import { selectRawOrdersRestData, selectRawParentSubaccount, selectRawParentSubaccountData, + selectRawSelectedMarketLeveragesData, selectRawTransfersLiveData, selectRawTransfersRest, selectRawTransfersRestData, selectRawValidatorHeightDataLoadable, } from './base'; +import { selectAllMarketsInfoStable } from './summary'; const BACKUP_BLOCK_HEIGHT = { height: 0, time: '1971-01-01T00:00:00Z' }; @@ -86,23 +88,44 @@ export const selectCurrentMarketInfoRaw = createAppSelector( ); export const selectParentSubaccountSummary = createAppSelector( - [selectRawParentSubaccountData, selectRelevantMarketsData], - (parentSubaccount, markets) => { - if (parentSubaccount == null || markets == null) { + [selectRawParentSubaccountData, selectRelevantMarketsData, selectRawSelectedMarketLeveragesData], + (parentSubaccount, markets, selectedMarketLeverages) => { + if (parentSubaccount == null || markets == null || selectedMarketLeverages == null) { return undefined; } - const result = calculateParentSubaccountSummary(parentSubaccount, markets); + const result = calculateParentSubaccountSummary( + parentSubaccount, + markets, + selectedMarketLeverages + ); return result; } ); export const selectParentSubaccountPositions = createAppSelector( - [selectRawParentSubaccountData, selectRelevantMarketsData], - (parentSubaccount, markets) => { - if (parentSubaccount == null || markets == null) { + [selectRawParentSubaccountData, selectRelevantMarketsData, selectRawSelectedMarketLeveragesData], + (parentSubaccount, markets, selectedMarketLeverages) => { + if (parentSubaccount == null || markets == null || selectedMarketLeverages == null) { return undefined; } - return calculateParentSubaccountPositions(parentSubaccount, markets); + return calculateParentSubaccountPositions(parentSubaccount, markets, selectedMarketLeverages); + } +); + +export const selectParentSubaccountAndMarkets = createAppSelector( + [selectParentSubaccountInfo, selectAllMarketsInfoStable], + (parentSubaccount, markets) => { + return { + parentSubaccount, + markets, + }; + }, + { + memoizeOptions: { + resultEqualityCheck: (prev, next) => + prev.parentSubaccount?.wallet === next.parentSubaccount?.wallet && + isEqual(prev.markets, next.markets), + }, } ); @@ -163,13 +186,17 @@ export const selectAccountOrdersLoading = createAppSelector( ); export const selectChildSubaccountSummaries = createAppSelector( - [selectRawParentSubaccountData, selectRelevantMarketsData], - (parentSubaccount, marketsData) => { - if (parentSubaccount == null || marketsData == null) { + [selectRawParentSubaccountData, selectRelevantMarketsData, selectRawSelectedMarketLeveragesData], + (parentSubaccount, marketsData, selectedMarketLeverages) => { + if (parentSubaccount == null || marketsData == null || selectedMarketLeverages == null) { return undefined; } - return calculateChildSubaccountSummaries(parentSubaccount, marketsData); + return calculateChildSubaccountSummaries( + parentSubaccount, + marketsData, + selectedMarketLeverages + ); } ); diff --git a/src/bonsai/selectors/accountActions.ts b/src/bonsai/selectors/accountActions.ts index 9863db3ae7..96a49ecf84 100644 --- a/src/bonsai/selectors/accountActions.ts +++ b/src/bonsai/selectors/accountActions.ts @@ -7,22 +7,27 @@ import { import { calculateParentSubaccountSummary } from '../calculators/subaccount'; import { DepositUsdcProps, SubaccountOperations, WithdrawUsdcProps } from '../types/operationTypes'; import { selectRelevantMarketsData } from './account'; -import { selectRawParentSubaccountData } from './base'; +import { selectRawParentSubaccountData, selectRawSelectedMarketLeveragesData } from './base'; export const selectParentSubaccountSummaryDeposit = createAppSelector( [ selectRawParentSubaccountData, selectRelevantMarketsData, + selectRawSelectedMarketLeveragesData, (_s, input: DepositUsdcProps) => input, ], - (parentSubaccount, markets, depositInputs) => { - if (parentSubaccount == null || markets == null) { + (parentSubaccount, markets, selectedMarketLeverages, depositInputs) => { + if (parentSubaccount == null || markets == null || selectedMarketLeverages == null) { return undefined; } const operations = createBatchedOperations(SubaccountOperations.DepositUsdc(depositInputs)); const modifiedParentSubaccount = applyOperationsToSubaccount(parentSubaccount, operations); - const result = calculateParentSubaccountSummary(modifiedParentSubaccount, markets); + const result = calculateParentSubaccountSummary( + modifiedParentSubaccount, + markets, + selectedMarketLeverages + ); return result; } ); @@ -31,16 +36,21 @@ export const selectParentSubaccountSummaryWithdrawal = createAppSelector( [ selectRawParentSubaccountData, selectRelevantMarketsData, + selectRawSelectedMarketLeveragesData, (_s, input: WithdrawUsdcProps) => input, ], - (parentSubaccount, markets, withdrawalInputs) => { - if (parentSubaccount == null || markets == null) { + (parentSubaccount, markets, selectedMarketLeverages, withdrawalInputs) => { + if (parentSubaccount == null || markets == null || selectedMarketLeverages == null) { return undefined; } const operations = createBatchedOperations(SubaccountOperations.WithdrawUsdc(withdrawalInputs)); const modifiedParentSubaccount = applyOperationsToSubaccount(parentSubaccount, operations); - const result = calculateParentSubaccountSummary(modifiedParentSubaccount, markets); + const result = calculateParentSubaccountSummary( + modifiedParentSubaccount, + markets, + selectedMarketLeverages + ); return result; } ); diff --git a/src/bonsai/selectors/base.ts b/src/bonsai/selectors/base.ts index 779f419e3c..8742b9d047 100644 --- a/src/bonsai/selectors/base.ts +++ b/src/bonsai/selectors/base.ts @@ -75,3 +75,8 @@ export const selectRawGeo = (state: RootState) => state.raw.compliance.geo; export const selectRawRewardParams = (state: RootState) => state.raw.rewards.data.data; export const selectRawRewardPrice = (state: RootState) => state.raw.rewards.price.data; + +export const selectRawSelectedMarketLeverages = (state: RootState) => + state.raw.markets.selectedMarketLeverages; +export const selectRawSelectedMarketLeveragesData = (state: RootState) => + state.raw.markets.selectedMarketLeverages.data; diff --git a/src/bonsai/selectors/summary.ts b/src/bonsai/selectors/summary.ts index f050707818..bce324b443 100644 --- a/src/bonsai/selectors/summary.ts +++ b/src/bonsai/selectors/summary.ts @@ -1,15 +1,22 @@ -import { omit } from 'lodash'; +import { isEqual, omit } from 'lodash'; import { shallowEqual } from 'react-redux'; +import { IndexerWsBaseMarketObject } from '@/types/indexer/indexerManual'; + import { createAppSelector } from '@/state/appTypes'; import { getFavoritedMarkets } from '@/state/appUiConfigsSelectors'; import { getCurrentMarketIdIfTradeable } from '@/state/currentMarketSelectors'; -import { createMarketSummary } from '../calculators/markets'; +import { calculateEffectiveSelectedLeverage, createMarketSummary } from '../calculators/markets'; import { mergeLoadableStatus } from '../lib/mapLoadable'; import { PerpetualMarketSummary } from '../types/summaryTypes'; import { selectAllAssetsInfo } from './assets'; -import { selectRawAssets, selectRawMarkets } from './base'; +import { + selectRawAssets, + selectRawMarkets, + selectRawMarketsData, + selectRawSelectedMarketLeveragesData, +} from './base'; import { selectAllMarketsInfo, selectMarketsFeeDiscounts, selectSparkLinesData } from './markets'; export const selectAllMarketSummariesLoading = createAppSelector( @@ -58,6 +65,28 @@ export const selectCurrentMarketInfoStable = createAppSelector( } ); +export type StableIndexerWsBaseMarketObject = Omit; + +export const selectAllMarketsInfoStable = createAppSelector( + [selectRawMarketsData], + (markets) => { + if (!markets) return markets; + + return Object.entries(markets).reduce>( + (acc, [marketId, market]) => { + acc[marketId] = omit(market, ...unstablePaths); + return acc; + }, + {} + ); + }, + { + memoizeOptions: { + resultEqualityCheck: isEqual, + }, + } +); + export const selectAllMarketsInfoLoading = createAppSelector( [selectRawMarkets], mergeLoadableStatus @@ -103,3 +132,26 @@ export const selectMarketSummaryById = createAppSelector( return allSummaries?.[marketId]; } ); + +/** + * Get the effective selected leverage for a market. + * Returns user-selected leverage if set, otherwise calculates max leverage from IMF only (ignoring OIMF). + */ +export const selectEffectiveSelectedMarketLeverage = createAppSelector( + [ + selectRawSelectedMarketLeveragesData, + selectMarketSummaryById, + (_s, marketId: string | undefined) => marketId, + ], + (leverages, marketSummary, marketId) => { + return calculateEffectiveSelectedLeverage({ + userSelectedLeverage: marketId ? leverages?.[marketId] : undefined, + initialMarginFraction: marketSummary?.initialMarginFraction, + }); + } +); + +export const selectCurrentMarketEffectiveSelectedLeverage = createAppSelector( + [(state) => state, getCurrentMarketIdIfTradeable], + (state, marketId) => selectEffectiveSelectedMarketLeverage(state, marketId) +); diff --git a/src/bonsai/storeLifecycles.ts b/src/bonsai/storeLifecycles.ts index affc87ddc5..d731957cde 100644 --- a/src/bonsai/storeLifecycles.ts +++ b/src/bonsai/storeLifecycles.ts @@ -13,6 +13,7 @@ import { setUpConfigTiersQuery } from './rest/configTiers'; import { setUpFillsQuery } from './rest/fills'; import { setUpGeoQuery } from './rest/geo'; import { setUpIndexerHeightQuery, setUpValidatorHeightQuery } from './rest/height'; +import { setUpUserLeverageParamsQuery } from './rest/leverage'; import { alwaysUseCurrentNetworkClient } from './rest/lib/compositeClientManager'; import { setUpNobleBalanceQuery } from './rest/nobleBalance'; import { setUpOrdersQuery } from './rest/orders'; @@ -37,6 +38,7 @@ export const storeLifecycles = [ setUpAssetsQuery, setUpParentSubaccount, setUpFillsQuery, + setUpUserLeverageParamsQuery, setUpOrdersQuery, setUpTransfersQuery, setUpBlockTradingRewardsQuery, diff --git a/src/bonsai/types/summaryTypes.ts b/src/bonsai/types/summaryTypes.ts index 6aff020aba..a5d8b806e4 100644 --- a/src/bonsai/types/summaryTypes.ts +++ b/src/bonsai/types/summaryTypes.ts @@ -112,6 +112,10 @@ export type SubaccountPositionDerivedCore = { initialRisk: BigNumber; maintenanceRisk: BigNumber; maxLeverage: BigNumber | null; + effectiveSelectedLeverage: BigNumber; + + adjustedImfFromSelectedLeverage: BigNumber; + initialRiskFromSelectedLeverage: BigNumber; // these are just copied from the perpetual position for aesthetic reasons honestly baseEntryPrice: BigNumber; @@ -123,6 +127,7 @@ export type SubaccountPositionDerivedExtra = { leverage: BigNumber | null; marginValueMaintenance: BigNumber; marginValueInitial: BigNumber; + marginValueInitialFromSelectedLeverage: BigNumber; liquidationPrice: BigNumber | null; updatedUnrealizedPnl: BigNumber; diff --git a/src/components/Button.tsx b/src/components/Button.tsx index c919d39971..3c1007c659 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -255,6 +255,16 @@ const getDisabledStateForButtonAction = (action?: ButtonAction, buttonStyle?: Bu --button-cursor: not-allowed; `; } + if (action === ButtonAction.Primary) { + return css` + background-image: none; + --button-backgroundColor: var(--color-accent-faded); + --button-textColor: var(--color-text-0); + --button-cursor: not-allowed; + --button-hover-filter: none; + --button-border: solid var(--border-width) var(--color-accent-faded); + `; + } return css` --button-textColor: var(--color-text-0); --button-backgroundColor: var(--button-disabled-backgroundColor, var(--color-layer-2)); diff --git a/src/components/ComplianceBanner.tsx b/src/components/ComplianceBanner.tsx index c22359942e..cd91d03dcf 100644 --- a/src/components/ComplianceBanner.tsx +++ b/src/components/ComplianceBanner.tsx @@ -14,7 +14,6 @@ import { useComplianceState } from '@/hooks/useComplianceState'; import { useResizeObserver } from '@/hooks/useResizeObserver'; import { useSimpleUiEnabled } from '@/hooks/useSimpleUiEnabled'; import { useStringGetter } from '@/hooks/useStringGetter'; -import { useURLConfigs } from '@/hooks/useURLConfigs'; import breakpoints from '@/styles/breakpoints'; @@ -34,7 +33,6 @@ export const ComplianceBanner = ({ className }: { className?: string }) => { const stringGetter = useStringGetter(); const { complianceMessage, complianceStatus, showComplianceBanner, showRestrictionWarning } = useComplianceState(); - const { help } = useURLConfigs(); const { isTablet } = useBreakpoints(); const isSimpleUi = useSimpleUiEnabled(); @@ -57,19 +55,13 @@ export const ComplianceBanner = ({ className }: { className?: string }) => { {stringGetter({ key: STRING_KEYS.BLOCKED_BANNER_MESSAGE_SHORT, params: { - CONTACT_SUPPORT_LINK: ( - + TERMS_OF_USE_LINK: ( + {stringGetter({ key: STRING_KEYS.CONTACT_SUPPORT })} ), }, - })}{' '} - - {stringGetter({ - key: STRING_KEYS.LEARN_MORE, - })}{' '} - → - + })} ) : ( {complianceMessage} diff --git a/src/constants/analytics.ts b/src/constants/analytics.ts index 36ed5bb84b..d836350688 100644 --- a/src/constants/analytics.ts +++ b/src/constants/analytics.ts @@ -2,7 +2,7 @@ import { OrderSide, TradeFormType } from '@/bonsai/forms/trade/types'; import { PlaceOrderPayload } from '@/bonsai/forms/triggers/types'; import { ApiStatus, SubaccountFill } from '@/bonsai/types/summaryTypes'; import { type SupportedLocale } from '@dydxprotocol/v4-localization'; -import { RouteResponse, UserAddress } from '@skip-go/client'; +import { Route, RouteResponse, UserAddress } from '@skip-go/client'; import { RecordOf, TagsOf, UnionOf, ofType, unionize } from 'unionize'; import { type CustomFlags, type StatsigFlags } from '@/constants/statsig'; @@ -392,6 +392,18 @@ export const AnalyticsEvents = unionize( validatorAddress?: string; }>(), + // Swapping + SwapInitiated: ofType<{ id: string } & Route>(), + SwapError: ofType< + { + id: string; + step: 'withdraw-subaccount' | 'execute-swap'; + error: string; + } & Route + >(), + SwapSubmitted: ofType<{ id: string; txHash: string; chainId: string } & Route>(), + SwapFinalized: ofType<{ id: string; txHash: string; chainId: string } & Route>(), + // Sharing SharePnlShared: ofType<{ asset: string; diff --git a/src/constants/dialogs.ts b/src/constants/dialogs.ts index e8ca538abd..8fbd282570 100644 --- a/src/constants/dialogs.ts +++ b/src/constants/dialogs.ts @@ -64,6 +64,7 @@ export type ReferralDialogProps = { refCode: string }; export type RestrictedGeoDialogProps = { preventClose?: boolean }; export type RestrictedWalletDialogProps = { preventClose?: boolean }; export type SelectMarginModeDialogProps = {}; +export type SetMarketLeverageDialogProps = { marketId: string }; export type SetupPasskeyDialogProps = { onClose: () => void }; export type ShareAffiliateDialogProps = {}; export type SharePNLAnalyticsDialogProps = { @@ -159,6 +160,7 @@ export const DialogTypes = unionize( Referral: ofType(), RestrictedGeo: ofType(), RestrictedWallet: ofType(), + SetMarketLeverage: ofType(), SetupPasskey: ofType(), ShareAffiliate: ofType(), SharePNLAnalytics: ofType(), diff --git a/src/constants/leverage.ts b/src/constants/leverage.ts new file mode 100644 index 0000000000..4e624e3d60 --- /dev/null +++ b/src/constants/leverage.ts @@ -0,0 +1 @@ +export const DEFAULT_LEVERAGE_PPM = 1000000; diff --git a/src/constants/statsig.ts b/src/constants/statsig.ts index 03405562b7..36f90d3143 100644 --- a/src/constants/statsig.ts +++ b/src/constants/statsig.ts @@ -15,6 +15,7 @@ export enum StatsigFlags { ffWithdrawRewrite = 'ff_withdraw_rewrite', ffSeptember2025Rewards = 'ff_rewards_sep_2025', ffTurnkeyWeb = 'ff_turnkey_web', + ffSwapEnabled = 'ff_swap_ui_web', abPopupDeposit = 'ab_popup_deposit', } diff --git a/src/constants/tooltips/trade.ts b/src/constants/tooltips/trade.ts index 4edc2ad524..f91a1c3a3c 100644 --- a/src/constants/tooltips/trade.ts +++ b/src/constants/tooltips/trade.ts @@ -1,6 +1,6 @@ import { TOOLTIP_STRING_KEYS, type TooltipStrings } from '@/constants/localization'; -import { OCT_2025_REWARDS_DETAILS } from '@/hooks/rewards/util'; +import { CURRENT_SURGE_REWARDS_DETAILS } from '@/hooks/rewards/util'; export const tradeTooltips = { 'account-leverage': ({ stringGetter }) => ({ @@ -150,8 +150,17 @@ export const tradeTooltips = { body: stringGetter({ key: TOOLTIP_STRING_KEYS.MAXIMUM_REWARDS_SEPT_2025_BODY, params: { - REWARD_PERCENT: OCT_2025_REWARDS_DETAILS.rebatePercentNumeric, - REWARD_AMOUNT: OCT_2025_REWARDS_DETAILS.rewardAmount, + REWARD_PERCENT: CURRENT_SURGE_REWARDS_DETAILS.rebatePercentNumeric, + REWARD_AMOUNT: CURRENT_SURGE_REWARDS_DETAILS.rewardAmount, + }, + }), + }), + 'max-reward-dec-2025': ({ stringGetter }) => ({ + title: stringGetter({ key: TOOLTIP_STRING_KEYS.MAXIMUM_REWARDS_DEC_2025_TITLE }), + body: stringGetter({ + key: TOOLTIP_STRING_KEYS.MAXIMUM_REWARDS_DEC_2025_BODY, + params: { + REWARD_PERCENT: CURRENT_SURGE_REWARDS_DETAILS.rebatePercentNumeric, }, }), }), diff --git a/src/hooks/TradingForm/useTradeForm.ts b/src/hooks/TradingForm/useTradeForm.ts index c0f535686a..16865ab354 100644 --- a/src/hooks/TradingForm/useTradeForm.ts +++ b/src/hooks/TradingForm/useTradeForm.ts @@ -1,9 +1,12 @@ +import { useMemo } from 'react'; + import { accountTransactionManager } from '@/bonsai/AccountTransactionSupervisor'; import { TradeFormInputData, TradeFormSummary, TradeFormType } from '@/bonsai/forms/trade/types'; import { PlaceOrderPayload } from '@/bonsai/forms/triggers/types'; import { isOperationSuccess } from '@/bonsai/lib/operationResult'; import { ErrorType, ValidationError } from '@/bonsai/lib/validationErrors'; import { logBonsaiInfo } from '@/bonsai/logs'; +import { BonsaiCore } from '@/bonsai/ontology'; import { AnalyticsEvents } from '@/constants/analytics'; import { ComplianceStates } from '@/constants/compliance'; @@ -20,14 +23,17 @@ import { getCurrentTradePageForm } from '@/state/tradeFormSelectors'; import { track } from '@/lib/analytics/analytics'; import { useDisappearingValue } from '@/lib/disappearingValue'; +import { runFn } from '@/lib/do'; import { operationFailureToErrorParams } from '@/lib/errorHelpers'; import { isTruthy } from '@/lib/isTruthy'; import { purgeBigNumbers } from '@/lib/purgeBigNumber'; +import { useAccounts } from '../useAccounts'; import { ConnectionErrorType, useApiState } from '../useApiState'; import { useComplianceState } from '../useComplianceState'; import { useOnOrderIndexed } from '../useOnOrderIndexed'; import { useStringGetter } from '../useStringGetter'; +import { useSubaccount } from '../useSubaccount'; export enum TradeFormSource { ClosePositionForm = 'ClosePositionForm', @@ -62,6 +68,8 @@ export const useTradeForm = ({ const { connectionError } = useApiState(); const { complianceState } = useComplianceState(); + const { updateLeverage } = useSubaccount(); + const { dydxAddress } = useAccounts(); const { setUnIndexedClientId, clientId: unIndexedClientId } = useOnOrderIndexed(onLastOrderIndexed); @@ -76,8 +84,9 @@ export const useTradeForm = ({ const currentMarketId = useAppSelector(getCurrentMarketIdIfTradeable); const subaccountNumber = useAppSelector(getSubaccountId); const canAccountTrade = useAppSelector(calculateCanAccountTrade); + const positions = useAppSelector(BonsaiCore.account.parentSubaccountPositions.data); - const { errors: tradeErrors, summary } = fullFormSummary; + const { errors: tradeErrors, summary, inputData } = fullFormSummary; const tradeFormInputValues = summary.effectiveTrade; const { marketId } = tradeFormInputValues; const isClosePosition = source === TradeFormSource.ClosePositionForm; @@ -100,6 +109,10 @@ export const useTradeForm = ({ const shouldEnableTrade = canAccountTrade && !hasMissingData && !hasValidationErrors && !tradingUnavailable; + const currentPosition = useMemo(() => { + return positions?.find((p) => p.market === marketId); + }, [positions, marketId]); + const placeOrder = async ({ onPlaceOrder, onSuccess, @@ -115,6 +128,35 @@ export const useTradeForm = ({ if (payload == null || tradePayload == null || hasValidationErrors) { return; } + + // We defer saving selected leverage when opening a new position here since the + // subaccount being used for new isolated positions isn't known until the user presses + // placeOrder. For existing positions, updating leverage is done through the dialog. + runFn(() => { + const impliedMarket = summary.accountDetailsAfter?.position?.market; + if (impliedMarket === undefined) return; + + const positionSubaccountNumber = summary.accountDetailsAfter?.position?.subaccountNumber; + const clobPairId = summary.tradePayload?.orderPayload?.clobPairId; + const rawSelectedLeverage = inputData?.rawSelectedMarketLeverages[impliedMarket]; + const hasExistingPosition = currentPosition !== undefined; + if ( + !hasExistingPosition && + positionSubaccountNumber !== undefined && + clobPairId !== undefined && + dydxAddress !== undefined && + rawSelectedLeverage !== undefined + ) { + // Fire and forget - don't await to keep order placement fast + updateLeverage({ + senderAddress: dydxAddress, + subaccountNumber: positionSubaccountNumber, + clobPairId, + leverage: rawSelectedLeverage, + }); + } + }); + onPlaceOrder?.(tradePayload); track( AnalyticsEvents.TradePlaceOrderClick({ diff --git a/src/hooks/rewards/hooks.ts b/src/hooks/rewards/hooks.ts index 447c66d0ed..fb91461f76 100644 --- a/src/hooks/rewards/hooks.ts +++ b/src/hooks/rewards/hooks.ts @@ -10,7 +10,7 @@ import { mapIfPresent } from '@/lib/do'; import { useQueryChaosLabsIncentives } from '../useQueryChaosLabsIncentives'; import { - OCT_2025_REWARDS_DETAILS, + CURRENT_SURGE_REWARDS_DETAILS, pointsToEstimatedDollarRewards, pointsToEstimatedDydxRewards, } from './util'; @@ -76,7 +76,7 @@ export function useChaosLabsPointsDistribution() { item.incentivePoints, pointsInfo?.totalPoints, dydxPrice, - OCT_2025_REWARDS_DETAILS.rewardAmountUsd + CURRENT_SURGE_REWARDS_DETAILS.rewardAmountUsd ), })), }; diff --git a/src/hooks/rewards/util.ts b/src/hooks/rewards/util.ts index a0709c7bd5..1a68e24dfa 100644 --- a/src/hooks/rewards/util.ts +++ b/src/hooks/rewards/util.ts @@ -19,18 +19,18 @@ export function pointsToEstimatedDollarRewards( } // Move to Chaos Labs query once its available -export const OCT_2025_REWARDS_DETAILS = { - season: 8, - rewardAmount: '$1M', - rewardAmountUsd: 1_000_000, +export const CURRENT_SURGE_REWARDS_DETAILS = { + season: 9, + rewardAmount: '', + rewardAmountUsd: 0, rebatePercent: '50%', rebatePercentNumeric: '50', rebateFraction: 0.5, - endTime: '2025-11-30T23:59:59.000Z', // end of month + endTime: '2025-12-31T23:59:59.000Z', // end of month }; -export const NOV_2025_COMPETITION_DETAILS = { - rewardAmount: '$250k', - rewardAmountUsd: 250_000, - endTime: '2025-11-30T23:59:59.000Z', // end of month +export const DEC_2025_COMPETITION_DETAILS = { + rewardAmount: '$1M', + rewardAmountUsd: 1_000_000, + endTime: '2025-12-31T23:59:59.000Z', // end of month }; diff --git a/src/hooks/swap/useSwapQuote.ts b/src/hooks/swap/useSwapQuote.ts index fa121c87d5..22143c5315 100644 --- a/src/hooks/swap/useSwapQuote.ts +++ b/src/hooks/swap/useSwapQuote.ts @@ -61,12 +61,14 @@ export function useSwapQuote( const { skipClient } = useSkipClient(); const tokenConfig = useTokenConfigs(); const selectedDydxChainId = useAppSelector(getSelectedDydxChainId); + const rawAmount = amount && parseFloat(amount); + const isAmountValid = rawAmount && rawAmount > 0; return useQuery({ queryKey: ['swap-quote', input, amount, mode, selectedDydxChainId], queryFn: () => getSkipSwapRoute(skipClient, input, tokenConfig, selectedDydxChainId, amount, mode), - enabled: Boolean(amount), + enabled: Boolean(isAmountValid), staleTime: 15 * timeUnits.second, // Don't auto-retry because some errors are legitimate retry: 0, diff --git a/src/hooks/transferHooks.ts b/src/hooks/transferHooks.ts index d1c5e97384..5c89746f62 100644 --- a/src/hooks/transferHooks.ts +++ b/src/hooks/transferHooks.ts @@ -19,6 +19,7 @@ import { useTokenConfigs } from './useTokenConfigs'; export function useTransferForm(initialToUsdc: boolean) { const rawParentSubaccountData = useAppSelector(BonsaiRaw.parentSubaccountBase); const rawRelevantMarkets = useAppSelector(BonsaiRaw.parentSubaccountRelevantMarkets); + const selectedMarketLeverages = useAppSelector(BonsaiRaw.selectedMarketLeverages); const walletBalances = useAppSelector(BonsaiCore.account.balances.data); const canViewAccount = useAppSelector(calculateCanViewAccount); const { @@ -36,6 +37,7 @@ export function useTransferForm(initialToUsdc: boolean) { (): TransferFormInputData => ({ rawParentSubaccountData, rawRelevantMarkets, + selectedMarketLeverages: selectedMarketLeverages ?? {}, canViewAccount, walletBalances, display: { @@ -57,6 +59,7 @@ export function useTransferForm(initialToUsdc: boolean) { feeResult, rawParentSubaccountData, rawRelevantMarkets, + selectedMarketLeverages, usdcDecimals, usdcDenom, usdcLabel, diff --git a/src/hooks/useAutomatedDepositNotifications.ts b/src/hooks/useAutomatedDepositNotifications.ts index 64f2dd9d8b..620c65a31f 100644 --- a/src/hooks/useAutomatedDepositNotifications.ts +++ b/src/hooks/useAutomatedDepositNotifications.ts @@ -28,13 +28,8 @@ export const useAutomatedDepositNotifications = () => { const isDepositDialogOpen = activeDialog && DialogTypes.is.Deposit2(activeDialog); if (!enabled && dydxAddress && depositAddresses && isDepositDialogOpen) { setEnabled(true); - return; - } - if (!dydxAddress || !depositAddresses || newDeposits.length === 0) { - setEnabled(false); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dydxAddress, depositAddresses, activeDialog]); + }, [dydxAddress, depositAddresses, activeDialog, enabled]); // Stage 1: Watch for deposits from backend and update refs useEffect(() => { diff --git a/src/hooks/useNotificationTypes.tsx b/src/hooks/useNotificationTypes.tsx index 0b0467d2c2..2a785fca2f 100644 --- a/src/hooks/useNotificationTypes.tsx +++ b/src/hooks/useNotificationTypes.tsx @@ -6,7 +6,6 @@ import { useQuery } from '@tanstack/react-query'; import { groupBy, isNumber, max, pick } from 'lodash'; import { shallowEqual } from 'react-redux'; import tw from 'twin.macro'; -import { formatUnits } from 'viem'; import { AMOUNT_RESERVED_FOR_GAS_USDC, AMOUNT_USDC_BEFORE_REBALANCE } from '@/constants/account'; import { CHAIN_INFO } from '@/constants/chains'; @@ -51,6 +50,7 @@ import { TradeNotification } from '@/views/notifications/TradeNotification'; import { getUserWalletAddress } from '@/state/accountInfoSelectors'; import { + getSubaccountFreeCollateral, selectOrphanedTriggerOrders, selectReclaimableChildSubaccountFunds, selectShouldAccountRebalanceUsdc, @@ -1064,18 +1064,18 @@ export const notificationTypes: NotificationTypeConfig[] = [ useTrigger: ({ trigger }) => { const stringGetter = useStringGetter(); const { usdcAmount } = useAppSelector(BonsaiCore.account.balances.data); + const subaccountFreeCollateral = useAppSelector(getSubaccountFreeCollateral); + const { newDeposits, setNewDeposits, prevDepositIdsRef, setEnabled } = useAutomatedDepositNotifications(); const prevWalletBalanceRef = useRef(undefined); + const prevSubaccountFreeCollateralRef = useRef(undefined); const lastNotificationTimeRef = useRef(0); const NOTIFICATION_DEBOUNCE = 1 * timeUnits.second; // 1 second useEffect(() => { - if (newDeposits.length === 0) { - setEnabled(false); - return; - } + if (newDeposits.length === 0) return; const now = Date.now(); newDeposits.forEach((deposit) => { @@ -1124,6 +1124,11 @@ export const notificationTypes: NotificationTypeConfig[] = [ const now = Date.now(); const prevWallet = prevWalletBalanceRef.current; + const prevSubaccountFreeCollateral = prevSubaccountFreeCollateralRef.current; + + if (!prevSubaccountFreeCollateral) { + prevSubaccountFreeCollateralRef.current = subaccountFreeCollateral; + } if (!prevWallet) { prevWalletBalanceRef.current = usdcAmount; @@ -1135,19 +1140,16 @@ export const notificationTypes: NotificationTypeConfig[] = [ if (prevWalletBalanceBN && usdcBalanceBN && usdcBalanceBN.gt(prevWalletBalanceBN)) { const diff = usdcBalanceBN.minus(prevWalletBalanceBN); - const matchingDeposit = newDeposits.find((deposit) => { - const depositAmount = Number(formatUnits(BigInt(deposit?.amount ?? 0), 6)).toFixed( - USD_DECIMALS - ); - return ( - depositAmount && - Math.abs(parseFloat(depositAmount) - parseFloat(diff.toFixed(USD_DECIMALS))) < 0.01 - ); - }); + + const matchingDeposit = newDeposits.find((deposit) => + prevDepositIdsRef.current.has(deposit.id) + ); + + if (!matchingDeposit) return; if (diff.gt(0.01) && now - lastNotificationTimeRef.current > NOTIFICATION_DEBOUNCE) { trigger({ - id: `deposit-confirmed-${matchingDeposit?.id ?? crypto.randomUUID()}`, + id: `deposit-confirmed-${matchingDeposit.id}`, displayData: { toastDuration: DEFAULT_TOAST_AUTO_CLOSE_MS * 2, // 20 seconds groupKey: NotificationType.DepositAddressEvents, @@ -1166,17 +1168,18 @@ export const notificationTypes: NotificationTypeConfig[] = [ lastNotificationTimeRef.current = now; } - if (matchingDeposit) { - const updatedDeposits = newDeposits.filter( - (deposit) => deposit.id !== matchingDeposit.id - ); - setNewDeposits(updatedDeposits); + const updatedDeposits = newDeposits.filter( + (deposit) => deposit.id !== matchingDeposit.id + ); + setNewDeposits(updatedDeposits); + if (updatedDeposits.length === 0) { + setEnabled(false); } } prevWalletBalanceRef.current = usdcAmount; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [usdcAmount, trigger, stringGetter]); + }, [usdcAmount, subaccountFreeCollateral, trigger, stringGetter]); }, useNotificationAction: () => { return () => {}; diff --git a/src/hooks/useQuickUpdatingState.ts b/src/hooks/useQuickUpdatingState.ts index dde181e38c..438d09f25d 100644 --- a/src/hooks/useQuickUpdatingState.ts +++ b/src/hooks/useQuickUpdatingState.ts @@ -7,6 +7,7 @@ import { debounce } from 'lodash'; If global state updates, we check against a small cache to see if it's an update we caused and ignore it if so, otherwise we override local state with the incoming value. */ +const RECEIVE_VAL_TIMEOUT = 1500; export function useQuickUpdatingState({ slowValue, setValueSlow, @@ -21,12 +22,12 @@ export function useQuickUpdatingState({ const [value, setValueState] = useState(slowValue); // Cache of recently sent values to check against incoming values - const recentSlowValuesSentRef = useRef>([]); + const recentSlowValuesSentRef = useRef>([]); // Helper function to update the cache of sent values const updateSentValuesCache = useCallback( (newValue: T) => { - const newCache = [...recentSlowValuesSentRef.current, newValue]; + const newCache = [...recentSlowValuesSentRef.current, { val: newValue, time: Date.now() }]; // Only keep the most recent values up to cacheSize if (newCache.length > cacheSize) { newCache.shift(); @@ -81,7 +82,8 @@ export function useQuickUpdatingState({ useEffect(() => { // If this is a value we sent ourselves (exact reference match), ignore it const wasSentByUs = recentSlowValuesSentRef.current.some( - (sentValue) => sentValue === slowValue + (sentValue) => + sentValue.val === slowValue && Date.now() - sentValue.time < RECEIVE_VAL_TIMEOUT ); if (!wasSentByUs) { diff --git a/src/hooks/useSubaccount.tsx b/src/hooks/useSubaccount.tsx index 7a45d215e9..7ec42d767d 100644 --- a/src/hooks/useSubaccount.tsx +++ b/src/hooks/useSubaccount.tsx @@ -1,7 +1,10 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { accountTransactionManager } from '@/bonsai/AccountTransactionSupervisor'; -import { SubaccountTransferPayload } from '@/bonsai/forms/adjustIsolatedMargin'; +import { + SubaccountTransferPayload, + SubaccountUpdateLeveragePayload, +} from '@/bonsai/forms/adjustIsolatedMargin'; import { TransferPayload, TransferToken } from '@/bonsai/forms/transfers'; import { TriggerOrdersPayload } from '@/bonsai/forms/triggers/types'; import { getLazyTradingKeyUtils } from '@/bonsai/lib/lazyDynamicLibs'; @@ -19,6 +22,7 @@ import { formatUnits, parseUnits } from 'viem'; import { AMOUNT_RESERVED_FOR_GAS_USDC, AMOUNT_USDC_BEFORE_REBALANCE } from '@/constants/account'; import { AnalyticsEvents, DEFAULT_TRANSACTION_MEMO, TransactionMemo } from '@/constants/analytics'; import { DialogTypes } from '@/constants/dialogs'; +import { DEFAULT_LEVERAGE_PPM } from '@/constants/leverage'; import { QUANTUM_MULTIPLIER } from '@/constants/numbers'; import { USDC_DECIMALS } from '@/constants/tokens'; import { DydxAddress, WalletType } from '@/constants/wallets'; @@ -654,6 +658,96 @@ const useSubaccountContext = ({ localDydxWallet }: { localDydxWallet?: LocalWall [compositeClient, localDydxWallet] ); + const updateLeverage = useCallback( + async (params: SubaccountUpdateLeveragePayload) => { + try { + const subaccount = localDydxWallet + ? SubaccountClient.forLocalWallet(localDydxWallet, params.subaccountNumber) + : undefined; + + if (localDydxWallet === undefined || subaccount == null) { + throw new Error('local wallet client not initialized'); + } + + if (!compositeClient) { + throw new Error('Missing compositeClient or localWallet'); + } + + if (params.senderAddress !== subaccount.address) { + throw new Error('Sender address does not match local wallet'); + } + + const tx = await compositeClient.validatorClient.post.updatePerpetualMarketsLeverage( + subaccount, + subaccount.address, + [ + { + clobPairId: params.clobPairId, + customImfPpm: DEFAULT_LEVERAGE_PPM / params.leverage, + }, + ] + ); + + let parsedTx = parseToPrimitives(tx); + logBonsaiInfo( + 'useSubaccount/updateLeverage', + 'Successful update leverage for target subaccount', + { + parsedTx, + } + ); + if (subaccount.subaccountNumber !== subaccountNumber) { + let attempts = 0; + const maxAttempts = 3; + + let crossTx; + let txError; + while (attempts < maxAttempts && crossTx === undefined) { + try { + // Always update the leverage on the cross subaccount so it's easier to consolidate all + // the user's set leverages. No need to await this + const crossSubaccount = SubaccountClient.forLocalWallet( + localDydxWallet, + subaccountNumber + ); + // eslint-disable-next-line no-await-in-loop + crossTx = await compositeClient.validatorClient.post.updatePerpetualMarketsLeverage( + crossSubaccount, + crossSubaccount.address, + [ + { + clobPairId: params.clobPairId, + customImfPpm: DEFAULT_LEVERAGE_PPM / params.leverage, + }, + ] + ); + parsedTx = parseToPrimitives(crossTx); + } catch (error) { + txError = stringifyTransactionError(error); + logBonsaiError('useSubaccount/updateLeverage', 'Failed update cross leverage', { + parsed: txError, + }); + } + attempts += 1; + } + + if (crossTx === undefined && txError !== undefined) { + return wrapOperationFailure(txError); + } + } + + return wrapOperationSuccess(parsedTx); + } catch (error) { + const parsed = stringifyTransactionError(error); + logBonsaiError('useSubaccount/updateLeverage', 'Failed update leverage', { + parsed, + }); + return wrapOperationFailure(parsed); + } + }, + [compositeClient, localDydxWallet, subaccountNumber] + ); + const createTransferMessage = useCallback( (payload: TransferPayload) => { if (subaccountClient == null || !localDydxWallet) { @@ -912,5 +1006,8 @@ const useSubaccountContext = ({ localDydxWallet }: { localDydxWallet?: LocalWall createRandomTradingKeyWallet, authorizeTradingKeyWallet, removeAuthorizedKey, + + updateLeverage, + subaccountNumber, }; }; diff --git a/src/hooks/useUpdateSwaps.tsx b/src/hooks/useUpdateSwaps.tsx index 39d4f459cb..700f889387 100644 --- a/src/hooks/useUpdateSwaps.tsx +++ b/src/hooks/useUpdateSwaps.tsx @@ -5,6 +5,7 @@ import { BonsaiCore } from '@/bonsai/ontology'; import { formatUnits, parseUnits } from 'viem'; import { AMOUNT_RESERVED_FOR_GAS_USDC } from '@/constants/account'; +import { AnalyticsEvents } from '@/constants/analytics'; import { DYDX_CHAIN_DYDX_DENOM, USDC_DECIMALS } from '@/constants/tokens'; import { useSkipClient } from '@/hooks/transfers/skipClient'; @@ -18,6 +19,8 @@ import { useAppDispatch, useAppSelector } from '@/state/appTypes'; import { getPendingSwaps } from '@/state/swapSelectors'; import { Swap, updateSwap } from '@/state/swaps'; +import { track } from '@/lib/analytics/analytics'; + import { useSubaccount } from './useSubaccount'; const SWAP_SLIPPAGE_PERCENT = '0.50'; // 0.50% (50 bps) @@ -53,7 +56,7 @@ export const useUpdateSwaps = () => { if (!parentSubaccountSummary) { throw new Error('Parent subaccount not found'); } - if (subaccountBalanceBigInt < amountRequired) { + if (subaccountBalanceBigInt <= amountRequired) { throw new Error('Insufficeient USDC balance in subaccount'); } const tx = await withdraw(Number(formatUnits(amountRequired, USDC_DECIMALS)), 0); @@ -82,7 +85,10 @@ export const useUpdateSwaps = () => { route, userAddresses, slippageTolerancePercent: SWAP_SLIPPAGE_PERCENT, - onTransactionCompleted: async ({ txHash, status }) => { + onTransactionBroadcast: async ({ txHash, chainId }) => { + track(AnalyticsEvents.SwapSubmitted({ id: swap.id, txHash, ...route, chainId })); + }, + onTransactionCompleted: async ({ chainId, txHash, status }) => { const errorStates = ['STATE_COMPLETED_ERROR', 'STATE_ABANDONED', 'STATE_PENDING_ERROR']; if (status?.state && errorStates.includes(status.state)) { logBonsaiError('useUpdateSwaps', 'Error executing swap', { @@ -97,6 +103,8 @@ export const useUpdateSwaps = () => { txHash, swapId: swap.id, }); + + track(AnalyticsEvents.SwapFinalized({ id: swap.id, txHash, chainId, ...route })); dispatch(updateSwap({ swap: { id: swap.id, txHash, status: 'success' } })); } }, @@ -119,31 +127,51 @@ export const useUpdateSwaps = () => { if (withdrawToCallback.current[swap.id]) continue; withdrawToCallback.current[swap.id] = true; withdrawUsdcFromSubaccount(inputAmountBigInt) + .then(() => { + dispatch(updateSwap({ swap: { ...swap, status: 'pending-transfer' } })); + }) .catch((error) => { logBonsaiError('useUpdateSwaps', 'Error withdrawing from subaccount', { error, }); + + track( + AnalyticsEvents.SwapError({ + id: swap.id, + step: 'withdraw-subaccount', + error: error.message, + ...route, + }) + ); + dispatch(updateSwap({ swap: { ...swap, status: 'error' } })); - }) - .then(() => { - dispatch(updateSwap({ swap: { ...swap, status: 'pending-transfer' } })); }); } else { if (swapToCallback.current[swap.id]) continue; swapToCallback.current[swap.id] = true; executeSwap(swap) + .then(() => { + appQueryClient.invalidateQueries({ + queryKey: ['validator', 'accountBalances'], + exact: false, + }); + }) .catch((error) => { logBonsaiError('useUpdateSwaps', 'Error executing swap', { error, swapId: swap.id, }); + + track( + AnalyticsEvents.SwapError({ + id: swap.id, + step: 'execute-swap', + error: error.message, + ...route, + }) + ); + dispatch(updateSwap({ swap: { ...swap, status: 'error' } })); - }) - .then(() => { - appQueryClient.invalidateQueries({ - queryKey: ['validator', 'accountBalances'], - exact: false, - }); }); } } else if (status === 'pending-transfer' && availableBalanceBigInt > inputAmountBigInt) { diff --git a/src/icons/card-holder.svg b/src/icons/card-holder.svg index ebba258eb3..c3002ed3a7 100644 --- a/src/icons/card-holder.svg +++ b/src/icons/card-holder.svg @@ -1,3 +1,3 @@ - + diff --git a/src/layout/DialogManager.tsx b/src/layout/DialogManager.tsx index 6f50685f00..333b26ef59 100644 --- a/src/layout/DialogManager.tsx +++ b/src/layout/DialogManager.tsx @@ -40,6 +40,7 @@ import { ReclaimChildSubaccountFundsDialog } from '@/views/dialogs/ReclaimChildS import { ReferralDialog } from '@/views/dialogs/ReferralDialog'; import { RestrictedGeoDialog } from '@/views/dialogs/RestrictedGeoDialog'; import { RestrictedWalletDialog } from '@/views/dialogs/RestrictedWalletDialog'; +import { SetMarketLeverageDialog } from '@/views/dialogs/SetMarketLeverageDialog'; import { SetupPasskeyDialog } from '@/views/dialogs/SetupPasskeyDialog'; import { ShareAffiliateDialog } from '@/views/dialogs/ShareAffiliateDialog'; import { SharePNLAnalyticsDialog } from '@/views/dialogs/SharePNLAnalyticsDialog'; @@ -133,6 +134,7 @@ export const DialogManager = React.memo(() => { Referral: (args) => , RestrictedGeo: (args) => , RestrictedWallet: (args) => , + SetMarketLeverage: (args) => , SetupPasskey: (args) => , ShareAffiliate: (args) => , SharePNLAnalytics: (args) => , diff --git a/src/pages/markets/simple-ui/markets-view/MarketList.tsx b/src/pages/markets/simple-ui/markets-view/MarketList.tsx index 96b17cd1a3..77287206d8 100644 --- a/src/pages/markets/simple-ui/markets-view/MarketList.tsx +++ b/src/pages/markets/simple-ui/markets-view/MarketList.tsx @@ -76,9 +76,11 @@ const sortPositions = ( ? marginValueInitial.div(subaccountEquity).toNumber() : undefined; - return orderBy(positions, (position) => getMarginUsage(position.marginValueInitial) ?? 0, [ - 'desc', - ]); + return orderBy( + positions, + (position) => getMarginUsage(position.marginValueInitialFromSelectedLeverage) ?? 0, + ['desc'] + ); } case PositionSortType.Price: return orderBy( diff --git a/src/pages/markets/simple-ui/markets-view/PositionRow.tsx b/src/pages/markets/simple-ui/markets-view/PositionRow.tsx index 790aa3b525..d900a1c448 100644 --- a/src/pages/markets/simple-ui/markets-view/PositionRow.tsx +++ b/src/pages/markets/simple-ui/markets-view/PositionRow.tsx @@ -60,7 +60,7 @@ export const PositionRow = ({ case PositionSortType.Leverage: { const marginUsage = subaccountEquity != null && subaccountEquity !== 0 - ? position.marginValueInitial.div(subaccountEquity).toNumber() + ? position.marginValueInitialFromSelectedLeverage.div(subaccountEquity).toNumber() : undefined; return ( @@ -69,7 +69,7 @@ export const PositionRow = ({ tw="text-color-text-2" withSubscript type={OutputType.Fiat} - value={position.marginValueInitial} + value={position.marginValueInitialFromSelectedLeverage} /> {marginUsage && ( @@ -180,7 +180,11 @@ export const PositionRow = ({ {market.displayableAsset} - + diff --git a/src/pages/token/CompetitionIncentivesPanel.tsx b/src/pages/token/CompetitionIncentivesPanel.tsx index a959f1fdfb..2343b68056 100644 --- a/src/pages/token/CompetitionIncentivesPanel.tsx +++ b/src/pages/token/CompetitionIncentivesPanel.tsx @@ -9,13 +9,12 @@ import { isDev } from '@/constants/networks'; import { StatsigFlags } from '@/constants/statsig'; import { useChaosLabsPnlDistribution } from '@/hooks/rewards/hooks'; -import { NOV_2025_COMPETITION_DETAILS } from '@/hooks/rewards/util'; +import { DEC_2025_COMPETITION_DETAILS } from '@/hooks/rewards/util'; import { useAccounts } from '@/hooks/useAccounts'; import { useNow } from '@/hooks/useNow'; import { useStatsigGateValue } from '@/hooks/useStatsig'; import { useStringGetter } from '@/hooks/useStringGetter'; -import { ChaosLabsIcon } from '@/icons/chaos-labs'; import { layoutMixins } from '@/styles/layoutMixins'; import { Icon, IconName } from '@/components/Icon'; @@ -57,9 +56,9 @@ const September2025RewardsPanel = () => {
{stringGetter({ - key: STRING_KEYS.COMPETITION_HEADLINE_NOV_2025, + key: STRING_KEYS.COMPETITION_HEADLINE_DEC_2025, params: { - REWARD_AMOUNT: NOV_2025_COMPETITION_DETAILS.rewardAmount, + REWARD_AMOUNT: DEC_2025_COMPETITION_DETAILS.rewardAmount, }, })} @@ -71,12 +70,12 @@ const September2025RewardsPanel = () => { {stringGetter({ - key: STRING_KEYS.COMPETITION_BODY_NOV_2025, + key: STRING_KEYS.COMPETITION_BODY_DEC_2025, params: { - REWARD_AMOUNT: NOV_2025_COMPETITION_DETAILS.rewardAmount, + REWARD_AMOUNT: DEC_2025_COMPETITION_DETAILS.rewardAmount, }, })}{' '} - + {stringGetter({ key: STRING_KEYS.LEARN_MORE })} @@ -141,10 +140,6 @@ const Sept2025RewardsPanel = () => {
reward-stars - -
- {stringGetter({ key: STRING_KEYS.POWERED_BY_ALL_CAPS })} -
); }; diff --git a/src/pages/token/CompetitionLeaderboardPanel.tsx b/src/pages/token/CompetitionLeaderboardPanel.tsx index a56405feb8..bdc6384d4e 100644 --- a/src/pages/token/CompetitionLeaderboardPanel.tsx +++ b/src/pages/token/CompetitionLeaderboardPanel.tsx @@ -5,7 +5,7 @@ import styled from 'styled-components'; import { STRING_KEYS, StringGetterFunction } from '@/constants/localization'; import { ChaosLabsCompetitionItem, useChaosLabsPnlDistribution } from '@/hooks/rewards/hooks'; -import { OCT_2025_REWARDS_DETAILS } from '@/hooks/rewards/util'; +import { CURRENT_SURGE_REWARDS_DETAILS } from '@/hooks/rewards/util'; import { useAccounts } from '@/hooks/useAccounts'; import { useStringGetter } from '@/hooks/useStringGetter'; @@ -25,7 +25,6 @@ export enum RewardsLeaderboardTableColumns { Rank = 'Rank', Trader = 'Trader', PNL = 'PNL', - Rewards = 'Rewards', } export const CompetitionLeaderboardPanel = () => { @@ -67,7 +66,7 @@ export const CompetitionLeaderboardPanel = () => { })); exportCSV(csvRows, { - filename: `rewards-leaderboard-season-${OCT_2025_REWARDS_DETAILS.season}`, + filename: `rewards-leaderboard-season-${CURRENT_SURGE_REWARDS_DETAILS.season}`, columnHeaders: [ { key: 'rank', @@ -81,10 +80,6 @@ export const CompetitionLeaderboardPanel = () => { key: 'pnl', displayLabel: stringGetter({ key: STRING_KEYS.PNL }), }, - { - key: 'dollarReward', - displayLabel: stringGetter({ key: STRING_KEYS.REWARDS }), - }, ], }); }; @@ -276,23 +271,6 @@ const getRewardsLeaderboardTableColumnDef = ({ /> ), }, - [RewardsLeaderboardTableColumns.Rewards]: { - columnKey: RewardsLeaderboardTableColumns.Rewards, - getCellValue: (row) => row.dollarReward, - label: ( -
- {stringGetter({ key: STRING_KEYS.PRIZE })} -
- ), - renderCell: ({ dollarReward, account }) => ( - - ), - }, } satisfies Record> )[key], }); diff --git a/src/pages/token/LaunchIncentivesPanel.tsx b/src/pages/token/LaunchIncentivesPanel.tsx index 40fcdf9645..39ab1699b6 100644 --- a/src/pages/token/LaunchIncentivesPanel.tsx +++ b/src/pages/token/LaunchIncentivesPanel.tsx @@ -12,7 +12,7 @@ import { TOKEN_DECIMALS } from '@/constants/numbers'; import { StatsigFlags } from '@/constants/statsig'; import { useChaosLabsUsdRewards } from '@/hooks/rewards/hooks'; -import { OCT_2025_REWARDS_DETAILS } from '@/hooks/rewards/util'; +import { CURRENT_SURGE_REWARDS_DETAILS } from '@/hooks/rewards/util'; import { useAccounts } from '@/hooks/useAccounts'; import { useBreakpoints } from '@/hooks/useBreakpoints'; import { useNow } from '@/hooks/useNow'; @@ -84,10 +84,9 @@ const September2025RewardsPanel = () => {
{' '} {stringGetter({ - key: STRING_KEYS.SURGE_HEADLINE_NOV_2025, + key: STRING_KEYS.SURGE_HEADLING_DEC_2025, params: { - REWARD_AMOUNT: OCT_2025_REWARDS_DETAILS.rewardAmount, - REBATE_PERCENT: OCT_2025_REWARDS_DETAILS.rebatePercent, + REBATE_PERCENT: CURRENT_SURGE_REWARDS_DETAILS.rebatePercent, }, })} @@ -99,10 +98,9 @@ const September2025RewardsPanel = () => { {stringGetter({ - key: STRING_KEYS.SURGE_BODY_NOV_2025, + key: STRING_KEYS.SURGE_BODY_DEC_2025, params: { - REWARD_AMOUNT: OCT_2025_REWARDS_DETAILS.rewardAmount, - REBATE_PERCENT: OCT_2025_REWARDS_DETAILS.rebatePercent, + REBATE_PERCENT: CURRENT_SURGE_REWARDS_DETAILS.rebatePercent, }, })}{' '} @@ -117,11 +115,11 @@ const September2025RewardsPanel = () => {
{stringGetter({ key: STRING_KEYS.SURGE_COUNTDOWN, - params: { SURGE_SEASON: OCT_2025_REWARDS_DETAILS.season }, + params: { SURGE_SEASON: CURRENT_SURGE_REWARDS_DETAILS.season }, })} :
- + @@ -137,7 +135,7 @@ const Sept2025RewardsPanel = () => { const { data: incentiveRewards, isLoading } = useChaosLabsUsdRewards({ dydxAddress, - totalUsdRewards: OCT_2025_REWARDS_DETAILS.rewardAmountUsd, + totalUsdRewards: CURRENT_SURGE_REWARDS_DETAILS.rewardAmountUsd, }); return ( diff --git a/src/pages/token/RewardsLeaderboardPanel.tsx b/src/pages/token/RewardsLeaderboardPanel.tsx index 8ea4dc4d60..b4d9006c17 100644 --- a/src/pages/token/RewardsLeaderboardPanel.tsx +++ b/src/pages/token/RewardsLeaderboardPanel.tsx @@ -5,7 +5,7 @@ import styled from 'styled-components'; import { STRING_KEYS, StringGetterFunction } from '@/constants/localization'; import { ChaosLabsLeaderboardItem, useChaosLabsPointsDistribution } from '@/hooks/rewards/hooks'; -import { OCT_2025_REWARDS_DETAILS } from '@/hooks/rewards/util'; +import { CURRENT_SURGE_REWARDS_DETAILS } from '@/hooks/rewards/util'; import { useAccounts } from '@/hooks/useAccounts'; import { useStringGetter } from '@/hooks/useStringGetter'; @@ -53,7 +53,7 @@ export const RewardsLeaderboardPanel = () => { })); exportCSV(csvRows, { - filename: `rewards-leaderboard-season-${OCT_2025_REWARDS_DETAILS.season}`, + filename: `rewards-leaderboard-season-${CURRENT_SURGE_REWARDS_DETAILS.season}`, columnHeaders: [ { key: 'rank', @@ -256,7 +256,7 @@ const getRewardsLeaderboardTableColumnDef = ({ css={{ color: account === dydxAddress ? 'var(--color-accent)' : 'var(--color-text-1)' }} tw="text-small font-medium" type={OutputType.Number} - value={estimatedDydxRewards} + value={Number.isNaN(Number(estimatedDydxRewards)) ? 0 : estimatedDydxRewards} /> ), }, diff --git a/src/pages/token/Swap.tsx b/src/pages/token/Swap.tsx index 53b52ac07f..7207263a22 100644 --- a/src/pages/token/Swap.tsx +++ b/src/pages/token/Swap.tsx @@ -1,17 +1,22 @@ import { EventHandler, useMemo, useState } from 'react'; import { BonsaiCore } from '@/bonsai/ontology'; -import { ArrowDownIcon } from '@radix-ui/react-icons'; -import { capitalize } from 'lodash'; +import { BigNumber } from 'bignumber.js'; import { SyntheticInputEvent } from 'react-number-format/types/types'; import styled from 'styled-components'; import tw from 'twin.macro'; import { formatUnits, parseUnits } from 'viem'; -import { OnboardingState } from '@/constants/account'; +import { + AMOUNT_RESERVED_FOR_GAS_DYDX, + AMOUNT_RESERVED_FOR_GAS_USDC, + OnboardingState, +} from '@/constants/account'; +import { AlertType } from '@/constants/alerts'; +import { AnalyticsEvents } from '@/constants/analytics'; import { ButtonAction, ButtonShape, ButtonSize, ButtonStyle } from '@/constants/buttons'; import { STRING_KEYS } from '@/constants/localization'; -import { DYDX_CHAIN_DYDX_DENOM, DYDX_DECIMALS, USDC_DECIMALS } from '@/constants/tokens'; +import { DYDX_DECIMALS, USDC_DECIMALS } from '@/constants/tokens'; import { useSwapQuote } from '@/hooks/swap/useSwapQuote'; import { useDebounce } from '@/hooks/useDebounce'; @@ -26,6 +31,7 @@ import UsdcLogo from '@/icons/usdc-inverted.svg'; import WarningFilled from '@/icons/warning-filled.svg'; import { Accordion } from '@/components/Accordion'; +import { AlertMessage } from '@/components/AlertMessage'; import { Button } from '@/components/Button'; import { Icon, IconName } from '@/components/Icon'; import { LoadingDots } from '@/components/Loading/LoadingDots'; @@ -38,8 +44,9 @@ import { useAppDispatch, useAppSelector } from '@/state/appTypes'; import { selectHasPendingSwaps } from '@/state/swapSelectors'; import { addSwap } from '@/state/swaps'; +import { track } from '@/lib/analytics/analytics'; import { escapeRegExp, numericValueRegex } from '@/lib/inputUtils'; -import { BIG_NUMBERS } from '@/lib/numbers'; +import { BIG_NUMBERS, MustBigNumber } from '@/lib/numbers'; type SwapMode = 'exact-in' | 'exact-out'; function otherToken(currToken: 'usdc' | 'dydx') { @@ -47,7 +54,7 @@ function otherToken(currToken: 'usdc' | 'dydx') { } function getTokenLabel(token: 'usdc' | 'dydx') { - return token === 'usdc' ? 'USDC' : 'dYdX'; + return token === 'usdc' ? 'USDC' : 'DYDX'; } const SWAP_SLIPPAGE_PERCENT = '0.50'; // 0.50% (50 bps) @@ -63,31 +70,31 @@ export const Swap = () => { const [inputToken, setInputToken] = useState<'dydx' | 'usdc'>('usdc'); const [mode, setMode] = useState('exact-in'); const [amount, setAmount] = useState(''); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const [isToInputFocused, setIsToInputFocused] = useState(false); const [isFromInputFocused, setIsFromInputFocused] = useState(false); const tokenBalances = useMemo(() => { + const usableDydxBalance = Math.max( + Number(nativeTokenBalance ?? 0) - AMOUNT_RESERVED_FOR_GAS_DYDX, + 0 + ); const dydx = { - rawBalanceBigInt: parseUnits(nativeTokenBalance ?? '0', DYDX_DECIMALS), - formatted: Math.max(0, Number(nativeTokenBalance ?? 0)).toFixed(2), + rawBalanceBigInt: parseUnits(`${usableDydxBalance}`, DYDX_DECIMALS), + formatted: MustBigNumber(usableDydxBalance).toFormat(2, BigNumber.ROUND_DOWN), }; + const usableUsdcBalance = Math.max( + (parentSubaccountUsdcBalance ?? 0) - AMOUNT_RESERVED_FOR_GAS_USDC, + 0 + ); const usdc = { - rawBalanceBigInt: parseUnits(`${parentSubaccountUsdcBalance ?? 0}`, USDC_DECIMALS), - formatted: Math.max(0, parentSubaccountUsdcBalance ?? 0).toFixed(2), + rawBalanceBigInt: parseUnits(`${usableUsdcBalance}`, USDC_DECIMALS), + formatted: MustBigNumber(usableUsdcBalance).toFormat(2, BigNumber.ROUND_DOWN), }; - if (inputToken === 'usdc') { - return { - inputBalance: usdc, - outputBalance: dydx, - }; - } - return { - inputBalance: dydx, - outputBalance: usdc, - dydx, - usdc, + inputBalance: inputToken === 'usdc' ? usdc : dydx, + outputBalance: inputToken === 'usdc' ? dydx : usdc, }; }, [nativeTokenBalance, parentSubaccountUsdcBalance, inputToken]); @@ -116,6 +123,7 @@ export const Swap = () => { const debouncedAmount = useDebounce(amount); + // Swap Quote const { data: quote, isLoading, @@ -123,39 +131,42 @@ export const Swap = () => { error, } = useSwapQuote(inputToken, debouncedAmount, mode); + // Exchange Rate Quote + const { data: priceQuote } = useSwapQuote('dydx', '1', 'exact-in'); + const hasSufficientBalance = useMemo(() => { if (!quote || !amount) return true; - - const inputAmountBigInt = parseUnits( - quote.amountIn, - quote.sourceAssetDenom === DYDX_CHAIN_DYDX_DENOM ? DYDX_DECIMALS : USDC_DECIMALS - ); - const inputBalanceBigInt = - quote.sourceAssetDenom === DYDX_CHAIN_DYDX_DENOM - ? tokenBalances.dydx?.rawBalanceBigInt - : tokenBalances.usdc?.rawBalanceBigInt; - + const inputBalance = + mode === 'exact-in' ? tokenBalances.inputBalance : tokenBalances.outputBalance; + const inputAmountBigInt = BigInt(quote.amountIn); + const inputBalanceBigInt = inputBalance.rawBalanceBigInt; if (!inputBalanceBigInt) return true; - - return inputBalanceBigInt <= inputAmountBigInt; - }, [quote, amount, tokenBalances.dydx?.rawBalanceBigInt, tokenBalances.usdc?.rawBalanceBigInt]); - - const { data: priceQuote } = useSwapQuote('dydx', '1', 'exact-in'); + return inputBalanceBigInt >= inputAmountBigInt; + }, [quote, amount, mode, tokenBalances]); const usdcPerDydx = useMemo(() => { + if (quote) { + const usdcAmount = formatUnits( + BigInt(inputToken === 'usdc' ? quote.amountIn : quote.amountOut), + USDC_DECIMALS + ); + const dydxAmount = formatUnits( + BigInt(inputToken === 'dydx' ? quote.amountIn : quote.amountOut), + DYDX_DECIMALS + ); + return Number(usdcAmount) / Number(dydxAmount); + } if (!priceQuote) return undefined; return Number(formatUnits(BigInt(priceQuote.amountOut), USDC_DECIMALS)); - }, [priceQuote]); + }, [priceQuote, quote, inputToken]); const quotedAmount = useMemo(() => { if (!quote || !amount) return ''; - const quotedToken = mode === 'exact-in' ? otherToken(inputToken) : inputToken; const quotedTokenDecimals = quotedToken === 'dydx' ? DYDX_DECIMALS : USDC_DECIMALS; const quotedTokenAmount = mode === 'exact-in' ? quote.amountOut : quote.amountIn; const formattedQuotedTokenAmount = formatUnits(BigInt(quotedTokenAmount), quotedTokenDecimals); - return Number(formattedQuotedTokenAmount).toFixed(2); }, [quote, inputToken, mode, amount]); @@ -181,6 +192,7 @@ export const Swap = () => { return; } const swapId = `swap-${crypto.randomUUID()}`; + track(AnalyticsEvents.SwapInitiated({ id: swapId, ...quote })); dispatch(addSwap({ swap: { id: swapId, route: quote, status: 'pending' } })); }; @@ -192,11 +204,13 @@ export const Swap = () => { tw="flex flex-col gap-0.25 rounded-0.5 bg-color-layer-4 p-1" >
-
From
+
+ {stringGetter({ key: STRING_KEYS.SWAP_FROM })} +
- + {hasPendingSwap && ( + + {stringGetter({ key: STRING_KEYS.SWAP_IN_PROGRESS_WARNING })} + + )} {onboardingState !== OnboardingState.AccountConnected ? ( ) : error ? ( -
-
- -
{capitalize(error.message)}
-
-
+ +
+ +
+ {stringGetter({ key: STRING_KEYS.SWAP })} +
+
+ + } + /> ) : ( + + + ); +}; + +const $LeverageInputContainer = styled.div` + ${formMixins.inputContainer} + --input-height: 3.5rem; + --input-backgroundColor: none; +`; + +const $InnerInputContainer = styled.div` + ${formMixins.inputContainer} + --input-backgroundColor: var(--color-layer-4); + --input-borderColor: none; + --input-height: 2.25rem; + --input-width: 4rem; + + margin-left: 0.25rem; + + input { + text-align: end; + padding: 0 var(--form-input-paddingX); + } +`; + +const $LeverageSlider = styled(Slider)` + height: 1.375rem; + --slider-track-background: linear-gradient( + 90deg, + var(--color-layer-7) 0%, + var(--color-text-2) 100% + ); +`; diff --git a/src/views/forms/AdjustIsolatedMarginForm.tsx b/src/views/forms/AdjustIsolatedMarginForm.tsx index a2047e52dd..00d30041ef 100644 --- a/src/views/forms/AdjustIsolatedMarginForm.tsx +++ b/src/views/forms/AdjustIsolatedMarginForm.tsx @@ -425,15 +425,17 @@ const $ToggleGroup = styled(ToggleGroup)` function useForm() { const rawParentSubaccountData = useAppSelector(BonsaiRaw.parentSubaccountBase); const rawRelevantMarkets = useAppSelector(BonsaiRaw.parentSubaccountRelevantMarkets); + const selectedMarketLeverages = useAppSelector(BonsaiRaw.selectedMarketLeverages); const canViewAccount = useAppSelector(calculateCanViewAccount); const inputs = useMemo( () => ({ rawParentSubaccountData, rawRelevantMarkets, + selectedMarketLeverages: selectedMarketLeverages ?? {}, canViewAccount, }), - [canViewAccount, rawParentSubaccountData, rawRelevantMarkets] + [canViewAccount, rawParentSubaccountData, rawRelevantMarkets, selectedMarketLeverages] ); return useFormValues(BonsaiForms.AdjustIsolatedMarginFormFns, inputs); diff --git a/src/views/forms/ClosePositionForm.tsx b/src/views/forms/ClosePositionForm.tsx index 19b7b7980f..7a9fff0693 100644 --- a/src/views/forms/ClosePositionForm.tsx +++ b/src/views/forms/ClosePositionForm.tsx @@ -47,7 +47,7 @@ import { orEmptyObj } from '@/lib/typeUtils'; import { CanvasOrderbook } from '../CanvasOrderbook/CanvasOrderbook'; import { TradeFormMessages } from '../TradeFormMessages/TradeFormMessages'; -import { AmountCloseInput } from './TradeForm/AmountCloseInput'; +import { AllocationSlider } from './TradeForm/AllocationSlider'; import { PlaceOrderButtonAndReceipt } from './TradeForm/PlaceOrderButtonAndReceipt'; type ElementProps = { @@ -203,21 +203,19 @@ export const ClosePositionForm = ({ tw="w-full" /> - (positionSize > 0 ? tSize / positionSize : 0) - ) - ) - ) - ?.times(100) - .toFixed(0, BigNumber.ROUND_FLOOR)} - setAmountCloseInput={(value: string | undefined) => { + { dispatch( closePositionFormActions.setSizeAvailablePercent( mapIfPresent(value, (v) => MaybeBigNumber(v)?.div(100).toFixed(2)) ?? '' diff --git a/src/views/forms/NewMarketForm/v7/NewMarketPreviewStep.tsx b/src/views/forms/NewMarketForm/v7/NewMarketPreviewStep.tsx index 3cd5c6da34..543ace67e4 100644 --- a/src/views/forms/NewMarketForm/v7/NewMarketPreviewStep.tsx +++ b/src/views/forms/NewMarketForm/v7/NewMarketPreviewStep.tsx @@ -165,7 +165,7 @@ export const NewMarketPreviewStep = ({ params: { NUM_DAYS: {MARKET_LAUNCH_TOKEN_LOCKUP_DURATION}, PAST_DAYS: MARKET_LAUNCH_TOKEN_LOCKUP_DURATION, - APR_PERCENTAGE: , + APR_PERCENTAGE: , }, })}
diff --git a/src/views/forms/NewMarketForm/v7/NewMarketSelectionStep.tsx b/src/views/forms/NewMarketForm/v7/NewMarketSelectionStep.tsx index c32defce64..a192244313 100644 --- a/src/views/forms/NewMarketForm/v7/NewMarketSelectionStep.tsx +++ b/src/views/forms/NewMarketForm/v7/NewMarketSelectionStep.tsx @@ -89,7 +89,7 @@ export const NewMarketSelectionStep = ({ {stringGetter({ key: STRING_KEYS.MARKET_LAUNCH_DETAILS_4, params: { - APR_PERCENTAGE: , + APR_PERCENTAGE: , DEPOSIT_AMOUNT: ( { useAppSelector(BonsaiHelpers.currentMarket.stableMarketInfo) ); - const { execution, goodTil, postOnly, reduceOnly, timeInForce, stopLossOrder, takeProfitOrder } = - inputTradeData; + const { + execution, + goodTil, + postOnly, + reduceOnly, + timeInForce, + stopLossOrder, + takeProfitOrder, + type, + } = inputTradeData; const { stopLossOrder: stopLossSummary, takeProfitOrder: takeProfitSummary } = orEmptyObj( currentTradeFormSummary.triggersSummary ); @@ -90,6 +99,181 @@ export const AdvancedTradeOptions = () => { return undefined; } + const fullContents = ( +
+ {needsTimeRow && ( + <$AdvancedInputsRow> + {hasTimeInForce && timeInForce != null && ( + <$SelectMenu + value={timeInForce} + onValueChange={(selectedTimeInForceOption: string) => { + if (!selectedTimeInForceOption) { + return; + } + dispatch(tradeFormActions.setTimeInForce(selectedTimeInForceOption as TimeInForce)); + }} + label={stringGetter({ key: STRING_KEYS.TIME_IN_FORCE })} + > + {timeInForceOptions.map(({ value, stringKey }) => ( + <$SelectItem + key={value} + value={value} + label={stringGetter({ key: stringKey as StringKey })} + /> + ))} + + )} + {showGoodTil && ( + <$FormInput + id="trade-good-til-time" + type={InputType.Number} + decimals={INTEGER_DECIMALS} + label={stringGetter({ + key: hasTimeInForce ? STRING_KEYS.TIME : STRING_KEYS.GOOD_TIL_TIME, + })} + onChange={({ value }: NumberFormatValues) => { + dispatch( + tradeFormActions.setGoodTilTime({ duration: value, unit: unit ?? TimeUnit.DAY }) + ); + }} + value={duration ?? ''} + slotRight={ + unit != null && ( + <$InnerSelectMenu + value={unit} + onValueChange={(goodTilTimeTimescale: string) => { + if (!goodTilTimeTimescale) { + return; + } + dispatch( + tradeFormActions.setGoodTilTime({ + duration: duration ?? '', + unit: goodTilTimeTimescale as TimeUnit, + }) + ); + }} + > + {Object.values(TimeUnitShort).map((goodTilTimeTimescale: TimeUnitShort) => ( + <$InnerSelectItem + key={goodTilTimeTimescale} + value={goodTilTimeTimescale} + label={stringGetter({ + key: GOOD_TIL_TIME_TIMESCALE_STRINGS[goodTilTimeTimescale], + })} + /> + ))} + + ) + } + /> + )} + + )} + {needsExecution && ( + <> + {executionOptions.length > 0 && execution != null && ( + <$SelectMenu + value={execution} + label={stringGetter({ key: STRING_KEYS.EXECUTION })} + onValueChange={(selectedExecution: string) => { + if (!selectedExecution) { + return; + } + dispatch(tradeFormActions.setExecution(selectedExecution as ExecutionType)); + }} + > + {executionOptions.map(({ value, stringKey }) => ( + <$SelectItem + key={value} + value={value} + label={stringGetter({ key: stringKey as StringKey })} + /> + ))} + + )} + {shouldShowReduceOnly && ( + dispatch(tradeFormActions.setReduceOnly(checked))} + id="reduce-only" + label={ + + + {stringGetter({ key: STRING_KEYS.REDUCE_ONLY })} + + + } + /> + )} + {shouldShowPostOnly && ( + dispatch(tradeFormActions.setPostOnly(checked))} + id="post-only" + label={ + + {stringGetter({ key: STRING_KEYS.POST_ONLY })} + + } + /> + )} + + )} + {needsTriggers && ( +
+ + dispatch(checked ? tradeFormActions.showTriggers() : tradeFormActions.hideTriggers()) + } + id="show-trigger-orders" + label={stringGetter({ key: STRING_KEYS.TAKE_PROFIT_STOP_LOSS })} + /> + {triggerOrdersChecked && ( +
+ + +
+ )} +
+ )} +
+ ); + + if (type === TradeFormType.MARKET) { + return
{fullContents}
; + } return ( <$Collapsible defaultOpen={!isTablet} @@ -97,177 +281,7 @@ export const AdvancedTradeOptions = () => { triggerIconSide="right" fullWidth > -
- {needsTimeRow && ( - <$AdvancedInputsRow> - {hasTimeInForce && timeInForce != null && ( - <$SelectMenu - value={timeInForce} - onValueChange={(selectedTimeInForceOption: string) => { - if (!selectedTimeInForceOption) { - return; - } - dispatch( - tradeFormActions.setTimeInForce(selectedTimeInForceOption as TimeInForce) - ); - }} - label={stringGetter({ key: STRING_KEYS.TIME_IN_FORCE })} - > - {timeInForceOptions.map(({ value, stringKey }) => ( - <$SelectItem - key={value} - value={value} - label={stringGetter({ key: stringKey as StringKey })} - /> - ))} - - )} - {showGoodTil && ( - <$FormInput - id="trade-good-til-time" - type={InputType.Number} - decimals={INTEGER_DECIMALS} - label={stringGetter({ - key: hasTimeInForce ? STRING_KEYS.TIME : STRING_KEYS.GOOD_TIL_TIME, - })} - onChange={({ value }: NumberFormatValues) => { - dispatch( - tradeFormActions.setGoodTilTime({ duration: value, unit: unit ?? TimeUnit.DAY }) - ); - }} - value={duration ?? ''} - slotRight={ - unit != null && ( - <$InnerSelectMenu - value={unit} - onValueChange={(goodTilTimeTimescale: string) => { - if (!goodTilTimeTimescale) { - return; - } - dispatch( - tradeFormActions.setGoodTilTime({ - duration: duration ?? '', - unit: goodTilTimeTimescale as TimeUnit, - }) - ); - }} - > - {Object.values(TimeUnitShort).map((goodTilTimeTimescale: TimeUnitShort) => ( - <$InnerSelectItem - key={goodTilTimeTimescale} - value={goodTilTimeTimescale} - label={stringGetter({ - key: GOOD_TIL_TIME_TIMESCALE_STRINGS[goodTilTimeTimescale], - })} - /> - ))} - - ) - } - /> - )} - - )} - {needsExecution && ( - <> - {executionOptions.length > 0 && execution != null && ( - <$SelectMenu - value={execution} - label={stringGetter({ key: STRING_KEYS.EXECUTION })} - onValueChange={(selectedExecution: string) => { - if (!selectedExecution) { - return; - } - dispatch(tradeFormActions.setExecution(selectedExecution as ExecutionType)); - }} - > - {executionOptions.map(({ value, stringKey }) => ( - <$SelectItem - key={value} - value={value} - label={stringGetter({ key: stringKey as StringKey })} - /> - ))} - - )} - {shouldShowReduceOnly && ( - dispatch(tradeFormActions.setReduceOnly(checked))} - id="reduce-only" - label={ - - {stringGetter({ key: STRING_KEYS.REDUCE_ONLY })} - - } - /> - )} - {shouldShowPostOnly && ( - dispatch(tradeFormActions.setPostOnly(checked))} - id="post-only" - label={ - - {stringGetter({ key: STRING_KEYS.POST_ONLY })} - - } - /> - )} - - )} - {needsTriggers && ( -
- - dispatch( - checked ? tradeFormActions.showTriggers() : tradeFormActions.hideTriggers() - ) - } - id="show-trigger-orders" - label={stringGetter({ key: STRING_KEYS.TAKE_PROFIT_STOP_LOSS })} - /> - {triggerOrdersChecked && ( -
- - -
- )} -
- )} -
+ {fullContents} ); }; @@ -280,8 +294,6 @@ const $Collapsible = styled(Collapsible)` font: var(--font-small-book); outline: none; - - margin: -0.5rem 0; `; const $SelectMenu = styled(SelectMenu)` ${formMixins.inputSelectMenu} diff --git a/src/views/forms/TradeForm/AmountCloseInput.tsx b/src/views/forms/TradeForm/AllocationSlider.tsx similarity index 61% rename from src/views/forms/TradeForm/AmountCloseInput.tsx rename to src/views/forms/TradeForm/AllocationSlider.tsx index 74f60c5047..b5b071e615 100644 --- a/src/views/forms/TradeForm/AmountCloseInput.tsx +++ b/src/views/forms/TradeForm/AllocationSlider.tsx @@ -1,71 +1,64 @@ import styled from 'styled-components'; -import { STRING_KEYS } from '@/constants/localization'; - import { useQuickUpdatingState } from '@/hooks/useQuickUpdatingState'; -import { useStringGetter } from '@/hooks/useStringGetter'; import breakpoints from '@/styles/breakpoints'; import { formMixins } from '@/styles/formMixins'; import { Input, InputType } from '@/components/Input'; import { Slider } from '@/components/Slider'; -import { WithLabel } from '@/components/WithLabel'; import { mapIfPresent } from '@/lib/do'; import { MustBigNumber } from '@/lib/numbers'; -export const AmountCloseInput = ({ - amountClosePercentInput, - setAmountCloseInput, +export const AllocationSlider = ({ + allocationPercentInput, + setAllocationInput, }: { - amountClosePercentInput: string | undefined; - setAmountCloseInput: (val: string | undefined) => void; + allocationPercentInput: string | undefined; + setAllocationInput: (val: string | undefined) => void; }) => { const { - value: amountClose, - setValue: setAmountClose, - commitValue: commitAmountClose, + value: allocation, + setValue: setAllocation, + commitValue: commitAllocation, } = useQuickUpdatingState({ - setValueSlow: setAmountCloseInput, - slowValue: amountClosePercentInput, + setValueSlow: setAllocationInput, + slowValue: allocationPercentInput, debounceMs: 100, }); const onSliderDrag = ([newValue]: number[]) => { const newValueString = mapIfPresent(newValue, (lev) => MustBigNumber(lev).toFixed(0)); - setAmountClose(newValueString ?? ''); + setAllocation(newValueString ?? ''); }; const commitValue = (newValue: string | undefined) => { - commitAmountClose(newValue); + commitAllocation(newValue); }; const onValueCommit = ([newValue]: number[]) => { commitValue(MustBigNumber(newValue).toFixed(0)); }; - const stringGetter = useStringGetter(); return ( <$InputContainer> - <$WithLabel - label={
{stringGetter({ key: STRING_KEYS.AMOUNT_CLOSE })}
} - > - <$AmountCloseSlider - label={stringGetter({ key: STRING_KEYS.AMOUNT_CLOSE })} +
+ <$AllocationSlider + label="Allocation" min={0} max={100} step={0.1} - value={MustBigNumber(amountClose).toNumber()} + value={MustBigNumber(allocation).toNumber()} onSliderDrag={onSliderDrag} onValueCommit={onValueCommit} /> - +
<$InnerInputContainer> { commitValue(formattedValue); @@ -81,23 +74,17 @@ const $InputContainer = styled.div` --input-height: 3.5rem; --input-backgroundColor: none; - padding: var(--form-input-paddingY) var(--form-input-paddingX); - @media ${breakpoints.tablet} { --input-height: 4rem; } `; -const $WithLabel = styled(WithLabel)` - ${formMixins.inputLabel} -`; - const $InnerInputContainer = styled.div` ${formMixins.inputContainer} --input-backgroundColor: var(--color-layer-4); --input-borderColor: none; --input-height: 2.25rem; - --input-width: 5rem; + --input-width: 4rem; margin-left: 0.25rem; @@ -111,7 +98,7 @@ const $InnerInputContainer = styled.div` } `; -const $AmountCloseSlider = styled(Slider)` +const $AllocationSlider = styled(Slider)` height: 1.375rem; --slider-track-background: linear-gradient( 90deg, diff --git a/src/views/forms/TradeForm/MarginAndLeverageButtons.tsx b/src/views/forms/TradeForm/MarginAndLeverageButtons.tsx index e1e4b9adf9..52b70107cd 100644 --- a/src/views/forms/TradeForm/MarginAndLeverageButtons.tsx +++ b/src/views/forms/TradeForm/MarginAndLeverageButtons.tsx @@ -17,12 +17,13 @@ export const MarginAndLeverageButtons = ({ className }: StyleProps) => { }; const $MarginAndLeverageButtons = styled.div` + height: 2.625rem; display: flex; gap: 0.5rem; - abbr, - button, - span { + > abbr, + > button, + > span { ${layoutMixins.flexExpandToSpace} height: 2.25rem; flex-basis: 100%; diff --git a/src/views/forms/TradeForm/MarginModeSelector.tsx b/src/views/forms/TradeForm/MarginModeSelector.tsx index 65c353e44c..69d6322a4a 100644 --- a/src/views/forms/TradeForm/MarginModeSelector.tsx +++ b/src/views/forms/TradeForm/MarginModeSelector.tsx @@ -2,18 +2,23 @@ import { MarginMode } from '@/bonsai/forms/trade/types'; import { BonsaiHelpers } from '@/bonsai/ontology'; import styled from 'styled-components'; +import { ButtonSize } from '@/constants/buttons'; +import { DialogTypes } from '@/constants/dialogs'; import { STRING_KEYS } from '@/constants/localization'; import { useStringGetter } from '@/hooks/useStringGetter'; import breakpoints from '@/styles/breakpoints'; +import { Button } from '@/components/Button'; import { Icon, IconName } from '@/components/Icon'; +import { Output, OutputType, ShowSign } from '@/components/Output'; import { ToggleGroup } from '@/components/ToggleGroup'; import { WithTooltip } from '@/components/WithTooltip'; import { calculateCanAccountTrade } from '@/state/accountCalculators'; import { useAppDispatch, useAppSelector } from '@/state/appTypes'; +import { openDialog } from '@/state/dialogs'; import { tradeFormActions } from '@/state/tradeForm'; import { getTradeFormSummary, getTradeFormValues } from '@/state/tradeFormSelectors'; @@ -48,9 +53,6 @@ export const MarginModeSelector = ({ className }: { className?: string }) => { const selector = ( <$MarginModeSelector tw="flex flex-1 items-center justify-between"> - {stringGetter({ - key: STRING_KEYS.MARGIN_MODE, - })} <$ToggleGroup disabled={!canAccountTrade || !needsMarginMode} withSeparators @@ -59,17 +61,21 @@ export const MarginModeSelector = ({ className }: { className?: string }) => { { value: MarginMode.CROSS, label: showMarginModeUnToggleableTooltip ? ( - stringGetter({ - key: STRING_KEYS.CROSS, - }) + + {stringGetter({ + key: STRING_KEYS.CROSS, + })} + ) : ( - {stringGetter({ - key: STRING_KEYS.CROSS, - })} + + {stringGetter({ + key: STRING_KEYS.CROSS, + })} + ), disabled: !needsMarginMode && marginMode !== MarginMode.CROSS, @@ -77,17 +83,21 @@ export const MarginModeSelector = ({ className }: { className?: string }) => { { value: MarginMode.ISOLATED, label: showMarginModeUnToggleableTooltip ? ( - stringGetter({ - key: STRING_KEYS.ISOLATED, - }) + + {stringGetter({ + key: STRING_KEYS.ISOLATED, + })} + ) : ( - {stringGetter({ - key: STRING_KEYS.ISOLATED, - })} + + {stringGetter({ + key: STRING_KEYS.ISOLATED, + })} + ), disabled: !needsMarginMode && marginMode !== MarginMode.ISOLATED, @@ -99,13 +109,45 @@ export const MarginModeSelector = ({ className }: { className?: string }) => { ); - return showMarginModeUnToggleableTooltip ? ( + const selectorWithTooltip = showMarginModeUnToggleableTooltip ? ( <$WarningTooltip className={className} slotTooltip={warningTooltip}> {selector} ) : ( selector ); + + const currentMarket = useAppSelector(BonsaiHelpers.currentMarket.stableMarketInfo)?.ticker; + const effectiveSelectedLeverage = useAppSelector( + BonsaiHelpers.currentMarket.effectiveSelectedLeverage + ); + + const leverageButton = ( + + ); + return ( +
+ {selectorWithTooltip} + {leverageButton} +
+ ); }; const $MarginModeSelector = styled.div` diff --git a/src/views/forms/TradeForm/PlaceOrderButtonAndReceipt.tsx b/src/views/forms/TradeForm/PlaceOrderButtonAndReceipt.tsx index 26226b26bd..5756d32838 100644 --- a/src/views/forms/TradeForm/PlaceOrderButtonAndReceipt.tsx +++ b/src/views/forms/TradeForm/PlaceOrderButtonAndReceipt.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; -import { MarginMode, TradeFormSummary } from '@/bonsai/forms/trade/types'; +import { TradeFormSummary } from '@/bonsai/forms/trade/types'; import { BonsaiHelpers } from '@/bonsai/ontology'; import styled from 'styled-components'; @@ -25,7 +25,7 @@ import { Button } from '@/components/Button'; import { DetailsItem } from '@/components/Details'; import { DiffOutput } from '@/components/DiffOutput'; import { Icon, IconName } from '@/components/Icon'; -import { Output, OutputType, ShowSign } from '@/components/Output'; +import { Output, OutputType } from '@/components/Output'; import { WithSeparators } from '@/components/Separator'; import { ToggleButton } from '@/components/ToggleButton'; import { TradeFeeDiscountTag } from '@/components/TradeFeeDiscountTag'; @@ -37,12 +37,10 @@ import { calculateCanAccountTrade } from '@/state/accountCalculators'; import { getSubaccountId } from '@/state/accountInfoSelectors'; import { useAppDispatch, useAppSelector } from '@/state/appTypes'; import { openDialog } from '@/state/dialogs'; -import { getTradeFormValues } from '@/state/tradeFormSelectors'; import { getDisplayableAssetFromBaseAsset } from '@/lib/assetUtils'; import { isTruthy } from '@/lib/isTruthy'; -import { nullIfZero } from '@/lib/numbers'; -import { calculateCrossPositionMargin, getDoubleValuesHasDiff } from '@/lib/tradeData'; +import { getDoubleValuesHasDiff } from '@/lib/tradeData'; import { orEmptyObj } from '@/lib/typeUtils'; type ConfirmButtonConfig = { @@ -92,19 +90,12 @@ export const PlaceOrderButtonAndReceipt = ({ BonsaiHelpers.currentMarket.marketInfo )?.marketFeeDiscountMultiplier; - const { - liquidationPrice, - leverage, - notional: notionalTotal, - adjustedImf, - marginValueMaintenance, - } = orEmptyObj(summary.accountDetailsBefore?.position); + const { liquidationPrice, marginValueInitialFromSelectedLeverage } = orEmptyObj( + summary.accountDetailsBefore?.position + ); const postOrderPositionData = orEmptyObj(summary.accountDetailsAfter?.position); - const tradeValues = useAppSelector(getTradeFormValues); - const { marginMode } = tradeValues; - const [isReceiptOpen, setIsReceiptOpen] = useState(true); const hasMissingData = subaccountNumber === undefined; @@ -117,43 +108,17 @@ export const PlaceOrderButtonAndReceipt = ({ const areInputsFilled = tradePayload != null; const renderMarginValue = () => { - if (marginMode === MarginMode.CROSS) { - const currentCrossMargin = nullIfZero( - calculateCrossPositionMargin({ - notionalTotal: notionalTotal?.toNumber(), - adjustedImf: adjustedImf?.toNumber(), - }) - ); - - const postOrderCrossMargin = nullIfZero( - calculateCrossPositionMargin({ - notionalTotal: postOrderPositionData.notional?.toNumber(), - adjustedImf: postOrderPositionData.adjustedImf?.toNumber(), - }) - ); - - return ( - - ); - } - return ( - {stringGetter({ key: STRING_KEYS.POSITION_LEVERAGE })} - - ), - value: ( - - ), - }, { key: 'fee', label: ( @@ -285,7 +225,7 @@ export const PlaceOrderButtonAndReceipt = ({ tag={reward ? chainTokenLabel : ''} /> ), - tooltip: 'max-reward-sept-2025', + tooltip: 'max-reward-dec-2025', } : { key: 'max-reward', diff --git a/src/views/forms/TradeForm/TargetLeverageInput.tsx b/src/views/forms/TradeForm/TargetLeverageInput.tsx deleted file mode 100644 index 48582f6f7c..0000000000 --- a/src/views/forms/TradeForm/TargetLeverageInput.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { useCallback, useMemo } from 'react'; - -import { BonsaiHelpers } from '@/bonsai/ontology'; -import styled from 'styled-components'; - -import { STRING_KEYS } from '@/constants/localization'; -import { LEVERAGE_DECIMALS } from '@/constants/numbers'; - -import { useQuickUpdatingState } from '@/hooks/useQuickUpdatingState'; -import { useStringGetter } from '@/hooks/useStringGetter'; - -import breakpoints from '@/styles/breakpoints'; -import { formMixins } from '@/styles/formMixins'; - -import { Input, InputType } from '@/components/Input'; -import { Slider } from '@/components/Slider'; -import { WithLabel } from '@/components/WithLabel'; -import { WithTooltip } from '@/components/WithTooltip'; - -import { useAppDispatch, useAppSelector } from '@/state/appTypes'; -import { tradeFormActions } from '@/state/tradeForm'; -import { getTradeFormValues } from '@/state/tradeFormSelectors'; - -import { mapIfPresent } from '@/lib/do'; -import { calculateMarketMaxLeverage } from '@/lib/marketsHelpers'; -import { AttemptBigNumber, MaybeBigNumber, MustBigNumber } from '@/lib/numbers'; -import { orEmptyObj } from '@/lib/typeUtils'; - -export const TargetLeverageInput = () => { - const stringGetter = useStringGetter(); - const dispatch = useAppDispatch(); - - const { targetLeverage } = useAppSelector(getTradeFormValues); - const { initialMarginFraction, effectiveInitialMarginFraction } = orEmptyObj( - useAppSelector(BonsaiHelpers.currentMarket.stableMarketInfo) - ); - - const setLeverageSlow = useCallback( - (newLeverageString: string | undefined) => { - dispatch(tradeFormActions.setTargetLeverage(newLeverageString ?? '')); - }, - [dispatch] - ); - - const { - value: leverage, - setValue: setLeverage, - commitValue: commitLeverageState, - } = useQuickUpdatingState({ - setValueSlow: setLeverageSlow, - slowValue: targetLeverage ?? '', - debounceMs: 100, - }); - - const maxLeverage = useMemo(() => { - return calculateMarketMaxLeverage({ - initialMarginFraction: MaybeBigNumber(initialMarginFraction)?.toNumber(), - effectiveInitialMarginFraction, - }); - }, [initialMarginFraction, effectiveInitialMarginFraction]); - - const onSliderDrag = ([newLeverage]: number[]) => { - const newLeverageString = mapIfPresent(newLeverage, (lev) => - MustBigNumber(lev).toFixed(LEVERAGE_DECIMALS) - ); - setLeverage(newLeverageString); - }; - - const commitLeverage = (newLeverage: string) => { - commitLeverageState(newLeverage); - }; - - const onValueCommit = ([newLeverage]: number[]) => { - const newLeverageString = mapIfPresent(newLeverage, (lev) => - MustBigNumber(lev).toFixed(LEVERAGE_DECIMALS) - ); - commitLeverage(newLeverageString ?? ''); - }; - - return ( - <$InputContainer> - <$WithLabel - label={ -
- - {stringGetter({ key: STRING_KEYS.TARGET_LEVERAGE })} - -
- } - > - <$LeverageSlider - label="TargetLeverage" - min={1} - max={maxLeverage} - value={MustBigNumber(leverage).abs().toNumber()} - onSliderDrag={onSliderDrag} - onValueCommit={onValueCommit} - /> - - <$InnerInputContainer> - { - commitLeverage(formattedValue); - }} - /> - - - ); -}; - -const $InputContainer = styled.div` - ${formMixins.inputContainer} - --input-height: 3.5rem; - --input-backgroundColor: none; - - padding: var(--form-input-paddingY) var(--form-input-paddingX); - - @media ${breakpoints.tablet} { - --input-height: 4rem; - } -`; - -const $WithLabel = styled(WithLabel)` - ${formMixins.inputLabel} -`; - -const $InnerInputContainer = styled.div` - ${formMixins.inputContainer} - --input-backgroundColor: var(--color-layer-4); - --input-borderColor: none; - --input-height: 2.25rem; - --input-width: 5rem; - - margin-left: 0.25rem; - - input { - text-align: end; - padding: 0 var(--form-input-paddingX); - } - - @media ${breakpoints.tablet} { - --input-height: 2.5rem; - } -`; - -const $LeverageSlider = styled(Slider)` - height: 1.375rem; - --slider-track-background: linear-gradient( - 90deg, - var(--color-layer-7) 0%, - var(--color-text-2) 100% - ); -`; diff --git a/src/views/forms/TradeForm/TradeSizeInputs.tsx b/src/views/forms/TradeForm/TradeSizeInputs.tsx index c4e8e72fd8..76a9013e08 100644 --- a/src/views/forms/TradeForm/TradeSizeInputs.tsx +++ b/src/views/forms/TradeForm/TradeSizeInputs.tsx @@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react'; import { OrderSizeInputs } from '@/bonsai/forms/trade/types'; import { BonsaiHelpers } from '@/bonsai/ontology'; +import { BigNumber } from 'bignumber.js'; import { debounce } from 'lodash'; import styled from 'styled-components'; import tw from 'twin.macro'; @@ -37,12 +38,10 @@ import { getTradeFormSummary, getTradeFormValues } from '@/state/tradeFormSelect import { getDisplayableAssetFromBaseAsset } from '@/lib/assetUtils'; import { mapIfPresent } from '@/lib/do'; -import { AttemptBigNumber, MaybeBigNumber, MustBigNumber } from '@/lib/numbers'; +import { AttemptBigNumber, MaybeBigNumber } from '@/lib/numbers'; import { orEmptyObj } from '@/lib/typeUtils'; -import { AmountCloseInput } from './AmountCloseInput'; -import { MarketLeverageInput } from './MarketLeverageInput'; -import { TargetLeverageInput } from './TargetLeverageInput'; +import { AllocationSlider } from './AllocationSlider'; export const TradeSizeInputs = () => { const dispatch = useAppDispatch(); @@ -59,7 +58,7 @@ export const TradeSizeInputs = () => { const effectiveSizes = orEmptyObj(tradeSummary.tradeInfo.inputSummary.size); - const { showLeverage, showTargetLeverage, showAmountClose } = tradeSummary.options; + const { showAllocationSlider } = tradeSummary.options; const decimals = stepSizeDecimals ?? TOKEN_DECIMALS; @@ -187,47 +186,27 @@ export const TradeSizeInputs = () => { } slotRight={inputToggleButton()} type={inputConfig.type} - value={inputConfig.value ?? ''} + value={inputConfig.value} /> ); return ( -
+
{sizeInput} - {showLeverage && ( - { - dispatch(tradeFormActions.setSizeLeverageSigned(value)); - }} - /> - )} - {showTargetLeverage && } - {showAmountClose && ( - (positionSize > 0 ? tSize / positionSize : 0) - ) - ) - ) - ?.times(100) - .toFixed(0)} - setAmountCloseInput={(value: string | undefined) => { + setAllocationInput={(value: string | undefined) => { dispatch( tradeFormActions.setSizeAvailablePercent( mapIfPresent(value, (v) => MaybeBigNumber(v)?.div(100).toFixed(2)) ?? '' diff --git a/src/views/tables/PositionsTable.tsx b/src/views/tables/PositionsTable.tsx index 1896c1f359..ed238c73b4 100644 --- a/src/views/tables/PositionsTable.tsx +++ b/src/views/tables/PositionsTable.tsx @@ -48,6 +48,7 @@ import { } from '../../lib/enumToStringKeyHelpers'; import { CloseAllPositionsButton } from './PositionsTable/CloseAllPositionsButton'; import { PositionsActionsCell } from './PositionsTable/PositionsActionsCell'; +import { PositionsLeverageCell } from './PositionsTable/PositionsLeverageCell'; import { PositionsMarginCell } from './PositionsTable/PositionsMarginCell'; import { PositionsTriggersCell } from './PositionsTable/PositionsTriggersCell'; @@ -107,7 +108,7 @@ const getPositionsTableColumnDef = ({ columnKey: 'details', getCellValue: (row) => row.uniqueId, label: stringGetter({ key: STRING_KEYS.DETAILS }), - renderCell: ({ marketSummary, leverage, signedSize, side }) => ( + renderCell: ({ marketSummary, effectiveSelectedLeverage, signedSize, side }) => ( @ <$HighlightOutput type={OutputType.Multiple} - value={leverage} + value={effectiveSelectedLeverage} showSign={ShowSign.None} />
@@ -226,13 +227,15 @@ const getPositionsTableColumnDef = ({ }, [PositionsTableColumnKey.Leverage]: { columnKey: 'leverage', - getCellValue: (row) => row.leverage?.toNumber(), + getCellValue: (row) => row.effectiveSelectedLeverage.toNumber(), label: stringGetter({ key: STRING_KEYS.LEVERAGE }), hideOnBreakpoint: MediaQueryKeys.isMobile, - renderCell: ({ leverage }) => ( - - - + isActionable: true, + renderCell: ({ effectiveSelectedLeverage, market }) => ( + ), }, [PositionsTableColumnKey.Type]: { @@ -282,7 +285,7 @@ const getPositionsTableColumnDef = ({ }, [PositionsTableColumnKey.Margin]: { columnKey: 'margin', - getCellValue: (row) => row.marginValueInitial.toNumber(), + getCellValue: (row) => row.marginValueInitialFromSelectedLeverage.toNumber(), label: stringGetter({ key: STRING_KEYS.MARGIN }), hideOnBreakpoint: MediaQueryKeys.isMobile, isActionable: true, @@ -403,7 +406,7 @@ const getPositionsTableColumnDef = ({ market, marketSummary, assetId, - leverage, + effectiveSelectedLeverage, side, entryPrice, updatedUnrealizedPnl: unrealizedPnl, @@ -412,7 +415,7 @@ const getPositionsTableColumnDef = ({ marketId={market} assetId={assetId} side={side} - leverage={leverage} + leverage={effectiveSelectedLeverage} oraclePrice={MaybeBigNumber(marketSummary?.oraclePrice)} entryPrice={entryPrice} unrealizedPnl={unrealizedPnl} diff --git a/src/views/tables/PositionsTable/PositionsLeverageCell.tsx b/src/views/tables/PositionsTable/PositionsLeverageCell.tsx new file mode 100644 index 0000000000..44475ddc6e --- /dev/null +++ b/src/views/tables/PositionsTable/PositionsLeverageCell.tsx @@ -0,0 +1,59 @@ +import BigNumber from 'bignumber.js'; +import styled from 'styled-components'; + +import { ButtonShape, ButtonSize } from '@/constants/buttons'; +import { DialogTypes } from '@/constants/dialogs'; +import { STRING_KEYS } from '@/constants/localization'; + +import { useStringGetter } from '@/hooks/useStringGetter'; + +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'; + +type ElementProps = { + marketId: string; + effectiveSelectedLeverage: BigNumber; +}; + +export const PositionsLeverageCell = ({ marketId, effectiveSelectedLeverage }: ElementProps) => { + const stringGetter = useStringGetter(); + const dispatch = useAppDispatch(); + + return ( + + <$EditButton + key="edit-leverage" + tw="mt-0.125" + iconName={IconName.Pencil} + shape={ButtonShape.Square} + size={ButtonSize.XSmall} + onClick={() => dispatch(openDialog(DialogTypes.SetMarketLeverage({ marketId })))} + /> + + } + > + + + ); +}; + +const $EditButton = styled(IconButton)` + --button-textColor: var(--color-text-0); + --button-hover-textColor: var(--color-text-1); + --button-backgroundColor: transparent; + --button-border: none; + --button-width: min-content; +`; diff --git a/src/views/tables/PositionsTable/PositionsMarginCell.tsx b/src/views/tables/PositionsTable/PositionsMarginCell.tsx index aa944cf06a..0a8e0a35ac 100644 --- a/src/views/tables/PositionsTable/PositionsMarginCell.tsx +++ b/src/views/tables/PositionsTable/PositionsMarginCell.tsx @@ -35,7 +35,6 @@ export const PositionsMarginCell = ({ position }: PositionsMarginCellProps) => { shape={ButtonShape.Square} size={ButtonSize.XSmall} onClick={() => - // todo this handoff should be using uniqueid dispatch( openDialog(DialogTypes.AdjustIsolatedMargin({ positionId: position.uniqueId })) ) @@ -45,7 +44,27 @@ export const PositionsMarginCell = ({ position }: PositionsMarginCellProps) => { ) } > - + + {position.marginMode === 'ISOLATED' && + position.effectiveSelectedLeverage + .minus(position.leverage ?? 0) + .abs() + .gt(1) && ( +
+ ( + + ) +
+ )} ); };