diff --git a/package.json b/package.json index 60e67f0bad..5007aa53dc 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "@cosmjs/stargate": "^0.32.1", "@cosmjs/tendermint-rpc": "^0.32.1", "@datadog/browser-logs": "^5.23.3", - "@dydxprotocol/v4-client-js": "3.4.0", + "@dydxprotocol/v4-client-js": "3.5.0", "@dydxprotocol/v4-localization": "1.1.390", "@dydxprotocol/v4-proto": "^7.0.0-dev.0", "@emotion/is-prop-valid": "^1.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b91fa6b96c..97c406f13c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,8 +30,8 @@ dependencies: specifier: ^5.23.3 version: 5.35.1 '@dydxprotocol/v4-client-js': - specifier: 3.4.0 - version: 3.4.0 + specifier: 3.5.0 + version: 3.5.0 '@dydxprotocol/v4-localization': specifier: 1.1.390 version: 1.1.390 @@ -1671,8 +1671,8 @@ packages: '@datadog/browser-core': 5.35.1 dev: false - /@dydxprotocol/v4-client-js@3.4.0: - resolution: {integrity: sha512-dMEcqfJAreiDQjrgJyUNGqxUH3sh5J5LG5fKItCRIM7MsygAvf5/kpedQByh6nO6ov+tUw4zgT4OFc9vKN+cMQ==} + /@dydxprotocol/v4-client-js@3.5.0: + resolution: {integrity: sha512-s751Se22tax9cdWG7ZPpNgHMgrA9HHR/rZndSm/WlDHoQKc0FVmjWG5Vo1D8+tnPbA5CNyyQ8TLLN6IktmpqIQ==} dependencies: '@cosmjs/amino': 0.32.4 '@cosmjs/encoding': 0.32.4 diff --git a/src/bonsai/calculators/trades.ts b/src/bonsai/calculators/trades.ts new file mode 100644 index 0000000000..b9e562e438 --- /dev/null +++ b/src/bonsai/calculators/trades.ts @@ -0,0 +1,119 @@ +import { keyBy, maxBy, orderBy } from 'lodash'; +import { weakMapMemoize } from 'reselect'; + +import { EMPTY_ARR } from '@/constants/objects'; +import { + IndexerOrderSide, + IndexerOrderType, + IndexerPositionSide, +} from '@/types/indexer/indexerApiGen'; +import { + IndexerCompositeTradeHistoryObject, + IndexerTradeAction, +} from '@/types/indexer/indexerManual'; + +import { MustBigNumber, MustNumber } from '@/lib/numbers'; + +import { mergeObjects } from '../lib/mergeObjects'; +import { logBonsaiError } from '../logs'; +import { SubaccountTrade, TradeAction } from '../types/summaryTypes'; + +export function calculateTrades( + restTrades: IndexerCompositeTradeHistoryObject[] | undefined, + liveTrades: IndexerCompositeTradeHistoryObject[] | undefined +): SubaccountTrade[] { + const getTradesById = (data: IndexerCompositeTradeHistoryObject[]) => { + const tradesWithIds = data.filter( + (trade): trade is IndexerCompositeTradeHistoryObject & { id: string } => { + if (!trade.id) { + logBonsaiError('calculateTrades', 'Trade missing id, skipping', { trade }); + return false; + } + return true; + } + ); + return keyBy(tradesWithIds, (trade) => trade.id!); + }; + + const merged = mergeObjects( + getTradesById(restTrades ?? EMPTY_ARR), + getTradesById(liveTrades ?? EMPTY_ARR), + (first, second) => maxBy([first, second], (t) => MustBigNumber(t.time).toNumber())! + ); + + return orderBy(Object.values(merged).map(calculateTrade), [(t) => t.createdAt], ['desc']); +} + +const calculateTrade = weakMapMemoize( + (base: IndexerCompositeTradeHistoryObject): SubaccountTrade => ({ + id: base.id ?? '', + marketId: base.marketId ?? '', + orderId: base.orderId, + positionUniqueId: undefined, + side: base.side as IndexerOrderSide | undefined, + positionSide: base.positionSide as IndexerPositionSide | null | undefined, + action: deriveTradeAction(base), + price: Number(base.executionPrice), + entryPrice: base.entryPrice ? Number(base.entryPrice) : undefined, + size: MustNumber(base.additionalSize ?? 0), + prevSize: base.prevSize ? Number(base.prevSize) : undefined, + additionalSize: base.additionalSize ? Number(base.additionalSize) : undefined, + value: MustNumber(base.value ?? 0), + fee: base.netFee, + closedPnl: base.netRealizedPnl != null ? MustNumber(base.netRealizedPnl) : undefined, + closedPnlPercent: derivePerTradePnlPercent(base), + netClosedPnlPercent: + base.netRealizedPnlPercent != null ? MustNumber(base.netRealizedPnlPercent) : undefined, + createdAt: base.time, + marginMode: base.marginMode === 'ISOLATED' ? 'ISOLATED' : 'CROSS', + orderType: base.orderType as IndexerOrderType | undefined, + subaccountNumber: base.subaccountNumber, + }) +); + +function deriveTradeAction(trade: IndexerCompositeTradeHistoryObject): TradeAction { + const isLong = + trade.positionSide === IndexerPositionSide.LONG || + (trade.side === IndexerOrderSide.BUY && trade.action === 'OPEN'); + const isExtend = trade.action === 'EXTEND'; + const isBuy = trade.side === IndexerOrderSide.BUY; + const isLiquidated = + trade.action === 'LIQUIDATION_CLOSE' || trade.action === 'LIQUIDATION_PARTIAL_CLOSE'; + + if (isLiquidated) { + return TradeAction.LIQUIDATION; + } + + if (trade.action === IndexerTradeAction.PARTIAL_CLOSE) { + return isBuy ? TradeAction.PARTIAL_CLOSE_SHORT : TradeAction.PARTIAL_CLOSE_LONG; + } + + if (isExtend) { + return isLong ? TradeAction.ADD_TO_LONG : TradeAction.ADD_TO_SHORT; + } + + if (trade.action === IndexerTradeAction.CLOSE) { + return trade.side === IndexerOrderSide.SELL || isLong + ? TradeAction.CLOSE_LONG + : TradeAction.CLOSE_SHORT; + } + + return trade.action === IndexerTradeAction.OPEN && (trade.side === IndexerOrderSide.BUY || isLong) + ? TradeAction.OPEN_LONG + : TradeAction.OPEN_SHORT; +} + +function derivePerTradePnlPercent(trade: IndexerCompositeTradeHistoryObject): number | undefined { + if (!trade.entryPrice || !trade.executionPrice) { + return undefined; + } + + const entryPrice = MustNumber(trade.entryPrice); + const executionPrice = MustNumber(trade.executionPrice); + const pnl = + trade.positionSide === IndexerPositionSide.LONG + ? executionPrice - entryPrice + : entryPrice - executionPrice; + const pnlPercent = pnl / entryPrice; + return pnlPercent; +} diff --git a/src/bonsai/ontology.ts b/src/bonsai/ontology.ts index cb6dc8abe3..3656d08bd5 100644 --- a/src/bonsai/ontology.ts +++ b/src/bonsai/ontology.ts @@ -48,6 +48,8 @@ import { selectAccountOrders, selectAccountOrdersLoading, selectAccountStakingTier, + selectAccountTradesWithLeverage, + selectAccountTradeHistoryLoading, selectAccountTransfers, selectAccountTransfersLoading, selectChildSubaccountSummaries, @@ -148,6 +150,7 @@ import { SubaccountFill, SubaccountOrder, SubaccountPosition, + SubaccountTrade, SubaccountTransfer, UserStakingTierSummary, UserStats, @@ -188,6 +191,10 @@ interface BonsaiCoreShape { data: BasicSelector; loading: BasicSelector; }; + tradeHistory: { + data: BasicSelector; + loading: BasicSelector; + }; transfers: { data: BasicSelector; loading: BasicSelector; @@ -296,6 +303,10 @@ export const BonsaiCore: BonsaiCoreShape = { data: selectAccountFills, loading: selectAccountFillsLoading, }, + tradeHistory: { + data: selectAccountTradesWithLeverage, + loading: selectAccountTradeHistoryLoading, + }, transfers: { data: selectAccountTransfers, loading: selectAccountTransfersLoading, @@ -410,6 +421,7 @@ interface BonsaiHelpersShape { openOrders: BasicSelector; orderHistory: BasicSelector; fills: BasicSelector; + tradeHistory: BasicSelector; }; orderbook: { selectGroupedData: BasicSelector< @@ -480,6 +492,7 @@ export const BonsaiHelpers: BonsaiHelpersShape = { openOrders: selectCurrentMarketOpenOrders, orderHistory: selectCurrentMarketOrderHistory, fills: getCurrentMarketAccountFills, + tradeHistory: selectAccountTradesWithLeverage, }, }, assets: { diff --git a/src/bonsai/rest/tradeHistory.ts b/src/bonsai/rest/tradeHistory.ts new file mode 100644 index 0000000000..e1f8bc5f6b --- /dev/null +++ b/src/bonsai/rest/tradeHistory.ts @@ -0,0 +1,50 @@ +import { isParentSubaccountTradeHistoryResponse } from '@/types/indexer/indexerChecks'; + +import { type RootStore } from '@/state/_store'; +import { setAccountTradesRaw } from '@/state/raw'; + +import { refreshIndexerQueryOnAccountSocketRefresh } from '../accountRefreshSignal'; +import { loadableIdle } from '../lib/loadable'; +import { mapLoadableData } from '../lib/mapLoadable'; +import { selectParentSubaccountInfo } from '../socketSelectors'; +import { createIndexerQueryStoreEffect } from './lib/indexerQueryStoreEffect'; +import { queryResultToLoadable } from './lib/queryResultToLoadable'; + +export function setUpTradeHistoryQuery(store: RootStore) { + const cleanupListener = refreshIndexerQueryOnAccountSocketRefresh(['account', 'tradeHistory']); + const cleanupEffect = createIndexerQueryStoreEffect(store, { + name: 'tradeHistory', + selector: selectParentSubaccountInfo, + getQueryKey: (data) => ['account', 'tradeHistory', data.wallet, data.subaccount], + getQueryFn: (indexerClient, { wallet, subaccount, isPerpsGeoRestricted }) => { + if (wallet == null || subaccount == null || isPerpsGeoRestricted) { + return null; + } + return () => + indexerClient.account.getParentSubaccountNumberTradeHistory( + wallet, + subaccount, + undefined, + undefined, + undefined, + undefined + ); + }, + onResult: (tradeHistory) => { + store.dispatch( + setAccountTradesRaw( + mapLoadableData( + queryResultToLoadable(tradeHistory), + isParentSubaccountTradeHistoryResponse + ) + ) + ); + }, + onNoQuery: () => store.dispatch(setAccountTradesRaw(loadableIdle())), + }); + return () => { + cleanupListener(); + cleanupEffect(); + store.dispatch(setAccountTradesRaw(loadableIdle())); + }; +} diff --git a/src/bonsai/selectors/account.ts b/src/bonsai/selectors/account.ts index 68b89d39d9..693955c877 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 { isEqual, orderBy, pick } from 'lodash'; +import { isEqual, keyBy, orderBy, pick } from 'lodash'; import { shallowEqual } from 'react-redux'; import { EMPTY_ARR } from '@/constants/objects'; @@ -9,7 +9,7 @@ import { createAppSelector } from '@/state/appTypes'; import { getCurrentMarketIdIfTradeable } from '@/state/currentMarketSelectors'; import { convertBech32Address } from '@/lib/addressUtils'; -import { BIG_NUMBERS } from '@/lib/numbers'; +import { BIG_NUMBERS, MaybeBigNumber } from '@/lib/numbers'; import { calculateBlockRewards } from '../calculators/blockRewards'; import { calculateFills } from '../calculators/fills'; @@ -26,11 +26,12 @@ import { calculateParentSubaccountSummary, calculateUnopenedIsolatedPositions, } from '../calculators/subaccount'; +import { calculateTrades } from '../calculators/trades'; import { calculateTransfers } from '../calculators/transfers'; import { calculateAccountStakingTier } from '../calculators/userStats'; import { mergeLoadableStatus } from '../lib/mapLoadable'; import { selectParentSubaccountInfo } from '../socketSelectors'; -import { SubaccountTransfer } from '../types/summaryTypes'; +import { SubaccountTrade, SubaccountTransfer } from '../types/summaryTypes'; import { selectLatestIndexerHeight, selectLatestValidatorHeight } from './apiStatus'; import { selectRawAccountStakingTierData, @@ -50,6 +51,9 @@ import { selectRawParentSubaccountData, selectRawSelectedMarketLeverages, selectRawSelectedMarketLeveragesData, + selectRawTradeHistoryLiveData, + selectRawTradeHistoryRest, + selectRawTradeHistoryRestData, selectRawTransfersLiveData, selectRawTransfersRest, selectRawTransfersRestData, @@ -306,3 +310,39 @@ export const selectAccountStakingTier = createAppSelector( [selectRawAccountStakingTierData], (stakingTier) => calculateAccountStakingTier(stakingTier) ); + +export const selectAccountTradeHistory = createAppSelector( + [selectRawTradeHistoryRestData, selectRawTradeHistoryLiveData], + (rest, live) => { + return calculateTrades(rest?.tradeHistory, live); + } +); + +export const selectAccountTradeHistoryLoading = createAppSelector( + [selectRawTradeHistoryRest, selectRawParentSubaccount], + mergeLoadableStatus +); + +export const selectAccountTradesWithLeverage = createAppSelector( + [selectAccountTradeHistory, selectAccountOrders, selectParentSubaccountPositions], + (trades, orders, positions): SubaccountTrade[] => { + const orderById = keyBy(orders, (o) => o.id); + const positionByUniqueId = keyBy(positions ?? [], (p) => p.uniqueId); + + return trades.map((trade) => { + const order = trade.orderId ? orderById[trade.orderId] : undefined; + const position = order ? positionByUniqueId[order.positionUniqueId] : undefined; + + if (!position) { + return trade; + } + + return { + ...trade, + positionUniqueId: position.uniqueId, + leverage: MaybeBigNumber(position.effectiveSelectedLeverage)?.toNumber(), + liquidationPrice: MaybeBigNumber(position.liquidationPrice)?.toNumber(), + }; + }); + } +); diff --git a/src/bonsai/selectors/base.ts b/src/bonsai/selectors/base.ts index cce4a2692c..75f133d0b6 100644 --- a/src/bonsai/selectors/base.ts +++ b/src/bonsai/selectors/base.ts @@ -20,6 +20,8 @@ export const selectRawParentSubaccountData = (state: RootState) => export const selectRawFillsRestData = (state: RootState) => state.raw.account.fills.data; export const selectRawOrdersRestData = (state: RootState) => state.raw.account.orders.data; +export const selectRawTradeHistoryRestData = (state: RootState) => + state.raw.account.tradeHistory.data; export const selectRawTransfersRestData = (state: RootState) => state.raw.account.transfers.data; export const selectRawBlockTradingRewardsRestData = (state: RootState) => state.raw.account.blockTradingRewards.data; @@ -28,6 +30,8 @@ export const selectRawFillsLiveData = (state: RootState) => state.raw.account.parentSubaccount.data?.live.fills; export const selectRawOrdersLiveData = (state: RootState) => state.raw.account.parentSubaccount.data?.live.orders; +export const selectRawTradeHistoryLiveData = (state: RootState) => + state.raw.account.parentSubaccount.data?.live.tradeHistory; export const selectRawTransfersLiveData = (state: RootState) => state.raw.account.parentSubaccount.data?.live.transfers; export const selectRawBlockTradingRewardsLiveData = (state: RootState) => @@ -46,6 +50,7 @@ export const selectRawValidatorHeightDataLoadable = (state: RootState) => export const selectRawFillsRest = (state: RootState) => state.raw.account.fills; export const selectRawOrdersRest = (state: RootState) => state.raw.account.orders; +export const selectRawTradeHistoryRest = (state: RootState) => state.raw.account.tradeHistory; export const selectRawTransfersRest = (state: RootState) => state.raw.account.transfers; export const selectRawBlockTradingRewardsRest = (state: RootState) => state.raw.account.blockTradingRewards; diff --git a/src/bonsai/storeLifecycles.ts b/src/bonsai/storeLifecycles.ts index 311fa76e33..d0a9fa7a78 100644 --- a/src/bonsai/storeLifecycles.ts +++ b/src/bonsai/storeLifecycles.ts @@ -26,6 +26,7 @@ import { setUpSpotTokenPriceQuery, setUpTokenMetadataQuery, } from './rest/spot'; +import { setUpTradeHistoryQuery } from './rest/tradeHistory'; import { setUpTransfersQuery } from './rest/transfers'; import { setUpAccountBalancesQuery, @@ -56,6 +57,7 @@ export const storeLifecycles = [ setUpFillsQuery, setUpUserLeverageParamsQuery, setUpOrdersQuery, + setUpTradeHistoryQuery, setUpTransfersQuery, setUpBlockTradingRewardsQuery, setUpOrderbook, diff --git a/src/bonsai/types/rawTypes.ts b/src/bonsai/types/rawTypes.ts index de5b934f63..be64144519 100644 --- a/src/bonsai/types/rawTypes.ts +++ b/src/bonsai/types/rawTypes.ts @@ -7,6 +7,7 @@ import { import { IndexerCompositeFillObject, IndexerCompositeOrderObject, + IndexerCompositeTradeHistoryObject, IndexerTransferCommonResponseObject, IndexerWsBaseMarketObject, } from '@/types/indexer/indexerManual'; @@ -31,6 +32,7 @@ export interface ParentSubaccountData { tradingRewards?: IndexerHistoricalBlockTradingReward[]; fills?: IndexerCompositeFillObject[]; orders?: OrdersData; + tradeHistory?: IndexerCompositeTradeHistoryObject[]; transfers?: IndexerTransferCommonResponseObject[]; }; } diff --git a/src/bonsai/types/summaryTypes.ts b/src/bonsai/types/summaryTypes.ts index 1e0340d896..ab157fb485 100644 --- a/src/bonsai/types/summaryTypes.ts +++ b/src/bonsai/types/summaryTypes.ts @@ -11,6 +11,7 @@ import { IndexerOrderSide, IndexerOrderType, IndexerPerpetualPositionResponseObject, + IndexerPositionSide, IndexerTransferResponseObject, } from '@/types/indexer/indexerApiGen'; import { @@ -200,6 +201,44 @@ export type SubaccountFill = Omit & { closedPnl?: number; }; +export enum TradeAction { + OPEN_LONG = 'OPEN_LONG', + OPEN_SHORT = 'OPEN_SHORT', + CLOSE_LONG = 'CLOSE_LONG', + CLOSE_SHORT = 'CLOSE_SHORT', + PARTIAL_CLOSE_LONG = 'PARTIAL_CLOSE_LONG', + PARTIAL_CLOSE_SHORT = 'PARTIAL_CLOSE_SHORT', + ADD_TO_LONG = 'ADD_TO_LONG', + ADD_TO_SHORT = 'ADD_TO_SHORT', + LIQUIDATION = 'LIQUIDATION', +} + +export type SubaccountTrade = { + id: string; + marketId: string; + orderId: string | undefined; + positionUniqueId: PositionUniqueId | undefined; + side?: IndexerOrderSide | undefined; + positionSide?: IndexerPositionSide | undefined | null; + action: TradeAction; + price: number | undefined; + entryPrice?: number | undefined; + size?: number | undefined; + prevSize?: number | undefined; + additionalSize?: number | undefined; + value: number | undefined; + fee: string | undefined; + closedPnl?: number | undefined; + closedPnlPercent?: number | undefined; + netClosedPnlPercent?: number | undefined; + createdAt: string | undefined; + marginMode: MarginMode; + orderType: IndexerOrderType | undefined; + leverage?: number; + liquidationPrice?: number; + subaccountNumber?: number; +}; + export type LiveTrade = IndexerWsTradeResponseObject; export type PendingIsolatedPosition = { diff --git a/src/constants/dialogs.ts b/src/constants/dialogs.ts index 4da41c2fd0..56a6c238e8 100644 --- a/src/constants/dialogs.ts +++ b/src/constants/dialogs.ts @@ -4,8 +4,6 @@ import { OrderSide } from '@/bonsai/forms/trade/types'; import { PositionUniqueId, SubaccountPosition } from '@/bonsai/types/summaryTypes'; import { TagsOf, UnionOf, ofType, unionize } from 'unionize'; -import { IndexerPositionSide } from '@/types/indexer/indexerApiGen'; - import { BigNumberish } from '@/lib/numbers'; import { Nullable } from '@/lib/typeUtils'; @@ -66,15 +64,25 @@ export type SelectMarginModeDialogProps = {}; export type SetMarketLeverageDialogProps = { marketId: string }; export type SetupPasskeyDialogProps = { onClose: () => void }; export type ShareAffiliateDialogProps = {}; +export type SharePNLShareType = 'open' | 'close' | 'liquidated' | 'partialClose' | 'extended'; export type SharePNLAnalyticsDialogProps = { - marketId: string; assetId: string; - leverage: Nullable; - oraclePrice: Nullable; - entryPrice: Nullable; - unrealizedPnl: Nullable; - side: Nullable; - sideLabel: Nullable; + marketId?: string; + isLong: boolean; + isCross: boolean; + shareType?: SharePNLShareType | undefined; + leverage?: Nullable; + size?: Nullable; + prevSize?: Nullable; + pnl?: Nullable; + unrealizedPnl?: Nullable; + pnlPercentage?: Nullable; + entryPrice?: Nullable; + exitPrice?: Nullable; + liquidationPrice?: Nullable; + oraclePrice?: Nullable; + sideLabel?: Nullable; + closeType?: Nullable; }; export type SimpleUiTradeDialogProps = | { diff --git a/src/hooks/useSharePnlImage.ts b/src/hooks/useSharePnlImage.ts index e3636fbe74..fe21d1508c 100644 --- a/src/hooks/useSharePnlImage.ts +++ b/src/hooks/useSharePnlImage.ts @@ -1,76 +1,43 @@ import { logBonsaiError } from '@/bonsai/logs'; import { useQuery } from '@tanstack/react-query'; +import { SharePNLAnalyticsDialogProps } from '@/constants/dialogs'; import { timeUnits } from '@/constants/time'; -import { IndexerPerpetualPositionStatus, IndexerPositionSide } from '@/types/indexer/indexerApiGen'; import { useAccounts } from '@/hooks/useAccounts'; +import { useEndpointsConfig } from '@/hooks/useEndpointsConfig'; -import { getOpenPositions } from '@/state/accountSelectors'; -import { useAppSelector } from '@/state/appTypes'; - -import { Nullable } from '@/lib/typeUtils'; import { truncateAddress } from '@/lib/wallet'; -import { useEndpointsConfig } from './useEndpointsConfig'; - -export type SharePnlImageParams = { - assetId: string; - marketId: string; - side: Nullable; - leverage: Nullable; - oraclePrice: Nullable; - entryPrice: Nullable; - unrealizedPnl: Nullable; - type?: 'open' | 'close' | 'liquidated' | undefined; -}; - -export const useSharePnlImage = ({ - assetId, - marketId, - side, - leverage, - oraclePrice, - entryPrice, - unrealizedPnl, - type = 'open', -}: SharePnlImageParams) => { +export const useSharePnlImage = (data: SharePNLAnalyticsDialogProps) => { const { pnlImageApi } = useEndpointsConfig(); const { dydxAddress } = useAccounts(); - const openPositions = useAppSelector(getOpenPositions); - - const position = openPositions?.find((p) => p.market === marketId); - - const positionType = - position?.status === IndexerPerpetualPositionStatus.CLOSED - ? 'close' - : position?.status === IndexerPerpetualPositionStatus.LIQUIDATED - ? 'liquidated' - : 'open'; - - const pnl = (position?.realizedPnl.toNumber() ?? 0) + (unrealizedPnl ?? 0); const queryFn = async (): Promise => { - if (!dydxAddress) { + if (!dydxAddress || !data.marketId) { return undefined; } + const totalPnl = (data.pnl ?? 0) + (data.unrealizedPnl ?? 0); + const requestBody = { - ticker: assetId, - type: positionType, - leverage: leverage ?? 0, + ticker: data.assetId, + type: data.shareType, + leverage: data.leverage, username: truncateAddress(dydxAddress), - isLong: side === IndexerPositionSide.LONG, - isCross: position?.marginMode === 'CROSS', - // Optional fields - include if available - size: position?.value.toNumber(), - pnl, - uPnl: unrealizedPnl ?? undefined, - pnlPercentage: position?.updatedUnrealizedPnlPercent?.toNumber(), - entryPx: entryPrice ?? undefined, - exitPx: position?.exitPrice?.toNumber(), - liquidationPx: position?.liquidationPrice?.toNumber(), - markPx: oraclePrice ?? undefined, + isLong: data.isLong, + isCross: data.isCross, + // Optional fields + size: data.size ?? undefined, + prevSize: data.prevSize ?? undefined, + pnl: totalPnl || undefined, + uPnl: data.unrealizedPnl ?? undefined, + pnlPercentage: data.pnlPercentage ?? undefined, + entryPx: data.entryPrice ?? undefined, + exitPx: data.exitPrice ?? undefined, + liquidationPx: data.liquidationPrice ?? undefined, + markPx: data.oraclePrice ?? undefined, + closeType: data.closeType ?? undefined, }; const response = await fetch(pnlImageApi, { @@ -92,25 +59,26 @@ export const useSharePnlImage = ({ return useQuery({ queryKey: [ 'sharePnlImage', - marketId, + data.marketId, dydxAddress, - side, - leverage, - oraclePrice, - entryPrice, - unrealizedPnl, - type, - position?.marginMode, - position?.unsignedSize.toString(), - position?.liquidationPrice?.toString(), + data.isLong, + data.isCross, + data.shareType, + data.leverage, + data.size, + data.pnl, + data.unrealizedPnl, + data.entryPrice, + data.exitPrice, + data.oraclePrice, ], queryFn, enabled: Boolean(dydxAddress), refetchOnWindowFocus: false, refetchOnReconnect: false, - staleTime: 2 * timeUnits.minute, // 2 minutes + staleTime: 2 * timeUnits.minute, retry: 2, - retryDelay: 1 * timeUnits.second, // 1 second + retryDelay: 1 * timeUnits.second, retryOnMount: true, }); }; diff --git a/src/lib/tradeHistoryHelpers.ts b/src/lib/tradeHistoryHelpers.ts new file mode 100644 index 0000000000..27fd02c000 --- /dev/null +++ b/src/lib/tradeHistoryHelpers.ts @@ -0,0 +1,91 @@ +import { TradeAction } from '@/bonsai/types/summaryTypes'; + +import { SharePNLAnalyticsDialogProps } from '@/constants/dialogs'; +import { STRING_KEYS } from '@/constants/localization'; +import { IndexerOrderSide } from '@/types/indexer/indexerApiGen'; + +import type { useStringGetter } from '@/hooks/useStringGetter'; + +import { assertNever } from './assertNever'; + +type ShareType = NonNullable; + +export const TRADE_ACTION_TO_SHARE_TYPE_MAP: Record = { + [TradeAction.OPEN_LONG]: 'open', + [TradeAction.OPEN_SHORT]: 'open', + [TradeAction.CLOSE_LONG]: 'close', + [TradeAction.CLOSE_SHORT]: 'close', + [TradeAction.PARTIAL_CLOSE_LONG]: 'partialClose', + [TradeAction.PARTIAL_CLOSE_SHORT]: 'partialClose', + [TradeAction.ADD_TO_LONG]: 'extended', + [TradeAction.ADD_TO_SHORT]: 'extended', + [TradeAction.LIQUIDATION]: 'liquidated', +}; + +export function getTradeActionDisplayInfo( + action: TradeAction, + stringGetter: ReturnType +) { + switch (action) { + case TradeAction.OPEN_LONG: + return { + label: stringGetter({ key: STRING_KEYS.OPEN_LONG }), + color: 'var(--color-positive)', + }; + case TradeAction.OPEN_SHORT: + return { + label: stringGetter({ key: STRING_KEYS.OPEN_SHORT }), + color: 'var(--color-negative)', + }; + case TradeAction.CLOSE_LONG: + return { + label: stringGetter({ key: STRING_KEYS.CLOSE_LONG }), + color: 'var(--color-negative)', + }; + case TradeAction.CLOSE_SHORT: + return { + label: stringGetter({ key: STRING_KEYS.CLOSE_SHORT }), + color: 'var(--color-negative)', + }; + case TradeAction.PARTIAL_CLOSE_LONG: + return { + label: stringGetter({ key: STRING_KEYS.PARTIAL_CLOSE_LONG }), + color: 'var(--color-negative)', + }; + case TradeAction.PARTIAL_CLOSE_SHORT: + return { + label: stringGetter({ key: STRING_KEYS.PARTIAL_CLOSE_SHORT }), + color: 'var(--color-negative)', + }; + case TradeAction.ADD_TO_LONG: + return { + label: stringGetter({ key: STRING_KEYS.ADD_TO_LONG }), + color: 'var(--color-positive)', + }; + case TradeAction.ADD_TO_SHORT: + return { + label: stringGetter({ key: STRING_KEYS.ADD_TO_SHORT }), + color: 'var(--color-negative)', + }; + case TradeAction.LIQUIDATION: + return { + label: stringGetter({ key: STRING_KEYS.LIQUIDATED }), + color: 'var(--color-negative)', + }; + default: + return assertNever(action); + } +} + +export function getOrderSideColor( + action: TradeAction, + side: IndexerOrderSide | undefined, + stringGetter: ReturnType +) { + const display = getTradeActionDisplayInfo(action, stringGetter); + return { + actionLabel: display.label, + actionColor: display.color, + sideColor: side === IndexerOrderSide.BUY ? 'var(--color-positive)' : 'var(--color-negative)', + }; +} diff --git a/src/pages/portfolio/Portfolio.tsx b/src/pages/portfolio/Portfolio.tsx index 71d372fc94..c5f8f55179 100644 --- a/src/pages/portfolio/Portfolio.tsx +++ b/src/pages/portfolio/Portfolio.tsx @@ -31,7 +31,7 @@ import { TradeHistoryList } from '@/views/Lists/Trade/TradeHistoryList'; import { AccountHistoryList } from '@/views/Lists/Transfers/AccountHistoryList'; import { FundingHistoryList } from '@/views/Lists/Transfers/FundingHistoryList'; import { VaultTransferList } from '@/views/Lists/Transfers/VaultTransferList'; -import { FillsTable, FillsTableColumnKey } from '@/views/tables/FillsTable'; +import { TradeHistoryTable, TradeHistoryTableColumnKey } from '@/views/tables/TradeHistoryTable'; import { TransferHistoryTable } from '@/views/tables/TransferHistoryTable'; import { getOnboardingState, getSubaccountFreeCollateral } from '@/state/accountSelectors'; @@ -113,26 +113,29 @@ const PortfolioPage = () => { ; fills: Loadable; orders: Loadable; + tradeHistory: Loadable; transfers: Loadable; blockTradingRewards: Loadable; }; @@ -148,6 +150,7 @@ const initialState: RawDataState = { stakingTier: loadableIdle(), fills: loadableIdle(), orders: loadableIdle(), + tradeHistory: loadableIdle(), transfers: loadableIdle(), blockTradingRewards: loadableIdle(), }, @@ -225,6 +228,12 @@ export const rawSlice = createSlice({ ) => { state.account.fills = action.payload; }, + setAccountTradesRaw: ( + state, + action: PayloadAction> + ) => { + state.account.tradeHistory = action.payload; + }, setAccountTransfersRaw: ( state, action: PayloadAction> @@ -418,6 +427,7 @@ export const { setAccountBalancesRaw, setAccountStatsRaw, setAccountFillsRaw, + setAccountTradesRaw, setAccountNobleUsdcBalanceRaw, setAccountOrdersRaw, setAccountTransfersRaw, diff --git a/src/types/indexer/indexerChecks.ts b/src/types/indexer/indexerChecks.ts index c7c5d14907..37e273a464 100644 --- a/src/types/indexer/indexerChecks.ts +++ b/src/types/indexer/indexerChecks.ts @@ -24,6 +24,7 @@ import { IndexerWsParentSubaccountUpdateObject, IndexerWsPerpetualMarketResponse, IndexerWsTradesUpdateObject, + IndexerCompositeTradeHistoryResponse, } from './indexerManual'; export const isWsParentSubaccountSubscribed = @@ -57,3 +58,5 @@ export const isPerpetualMarketSparklineResponse = export const isIndexerHistoricalPnlResponse = typia.createAssert(); export const isIndexerHistoricalTradingRewardAggregationResponse = typia.createAssert(); +export const isParentSubaccountTradeHistoryResponse = + typia.createAssert(); diff --git a/src/types/indexer/indexerManual.ts b/src/types/indexer/indexerManual.ts index ebea568056..91bb26bd53 100644 --- a/src/types/indexer/indexerManual.ts +++ b/src/types/indexer/indexerManual.ts @@ -162,6 +162,43 @@ export interface IndexerCompositeFillObject { entryPriceBefore?: string | null; } +export enum IndexerTradeAction { + OPEN = 'OPEN', + CLOSE = 'CLOSE', + PARTIAL_CLOSE = 'PARTIAL_CLOSE', + EXTEND = 'EXTEND', + LIQUIDATION_CLOSE = 'LIQUIDATION_CLOSE', + LIQUIDATION_PARTIAL_CLOSE = 'LIQUIDATION_PARTIAL_CLOSE', +} + +export interface IndexerCompositeTradeHistoryObject { + id?: string; + marketId?: string; + orderId?: string; + side?: string | IndexerOrderSide; + positionSide?: string | IndexerPositionSide | null; + entryPrice?: string; + executionPrice?: string; + value?: string; + prevSize?: string; + additionalSize?: string; + netFee?: string; + time?: string; + action?: string | IndexerTradeAction; + marginMode?: string; + orderType?: string | IndexerOrderType; + netRealizedPnl?: string | null; + netRealizedPnlPercent?: string | null; + subaccountNumber?: number; +} + +export interface IndexerCompositeTradeHistoryResponse { + pageSize?: number; + totalResults?: number; + offset?: number; + tradeHistory?: IndexerCompositeTradeHistoryObject[]; +} + export interface IndexerWsParentSubaccountSubscribedResponse { subaccount: IndexerParentSubaccountResponse; blockHeight: string; diff --git a/src/views/Lists/Trade/TradeHistoryList.tsx b/src/views/Lists/Trade/TradeHistoryList.tsx index 92add283d0..8d9104bd8a 100644 --- a/src/views/Lists/Trade/TradeHistoryList.tsx +++ b/src/views/Lists/Trade/TradeHistoryList.tsx @@ -11,18 +11,21 @@ import { LoadingSpace } from '@/components/Loading/LoadingSpinner'; import { useAppSelector } from '@/state/appTypes'; -import { TradeRow } from './TradeRow'; +import { TradeHistoryRow } from './TradeHistoryRow'; const FILL_HEIGHT = 64; export const TradeHistoryList = () => { - const isLoading = useAppSelector(BonsaiCore.account.fills.loading) === 'pending'; - const fills = useAppSelector(BonsaiCore.account.fills.data); + const loadingStatus = useAppSelector(BonsaiCore.account.tradeHistory.loading); + const trades = useAppSelector(BonsaiCore.account.tradeHistory.data); const parentRef = useRef(null); const stringGetter = useStringGetter(); + const isLoading = loadingStatus === 'pending'; + const isError = loadingStatus === 'error'; + const rowVirtualizer = useVirtualizer({ - count: fills.length, + count: trades.length, estimateSize: (_index: number) => FILL_HEIGHT, getScrollElement: () => parentRef.current, rangeExtractor: (range) => { @@ -30,17 +33,31 @@ export const TradeHistoryList = () => { }, }); + if (isError) { + return ( +
+
+ {stringGetter({ + key: STRING_KEYS.SOMETHING_WENT_WRONG_WITH_MESSAGE, + params: { ERROR_MESSAGE: 'Failed to load trades' }, + })} +
+
+ ); + } + if (isLoading) { return ; } - if (fills.length === 0) { + if (trades.length === 0) { return (
-
{stringGetter({ key: STRING_KEYS.FILLS_EMPTY_STATE })}
+
{stringGetter({ key: STRING_KEYS.TRADES_EMPTY_STATE })}
); } + return (
{ transform: `translateY(${virtualRow.start}px)`, }} > - +
))}
diff --git a/src/views/Lists/Trade/TradeHistoryRow.tsx b/src/views/Lists/Trade/TradeHistoryRow.tsx new file mode 100644 index 0000000000..800584f025 --- /dev/null +++ b/src/views/Lists/Trade/TradeHistoryRow.tsx @@ -0,0 +1,111 @@ +import { useMemo } from 'react'; + +import { BonsaiHelpers } from '@/bonsai/ontology'; +import { SubaccountTrade } from '@/bonsai/types/summaryTypes'; +import styled from 'styled-components'; +import tw from 'twin.macro'; + +import { useAppSelectorWithArgs } from '@/hooks/useParameterizedSelector'; +import { useStringGetter } from '@/hooks/useStringGetter'; + +import { AssetIcon } from '@/components/AssetIcon'; +import { Output, OutputType, ShowSign } from '@/components/Output'; + +import { getAssetFromMarketId } from '@/lib/assetUtils'; +import { mapIfPresent } from '@/lib/do'; +import { getOrderSideColor } from '@/lib/tradeHistoryHelpers'; +import { orEmptyObj } from '@/lib/typeUtils'; + +import { DateContent } from '../DateContent'; + +export const TradeHistoryRow = ({ + className, + trade, +}: { + className?: string; + trade: SubaccountTrade; +}) => { + const stringGetter = useStringGetter(); + const { marketId, side, action, price, size, closedPnl, createdAt } = trade; + + const marketData = useAppSelectorWithArgs( + BonsaiHelpers.markets.selectMarketSummaryById, + marketId + ); + const assetInfo = useAppSelectorWithArgs( + BonsaiHelpers.assets.selectAssetInfo, + mapIfPresent(marketId, getAssetFromMarketId) + ); + + const { logo } = orEmptyObj(assetInfo); + const { displayableAsset, stepSizeDecimals, tickSizeDecimals } = orEmptyObj(marketData); + + const { actionLabel, actionColor, sideColor } = useMemo( + () => getOrderSideColor(action, side, stringGetter), + [action, side, stringGetter] + ); + + const miniIcon = useMemo( + () => ( + + ), + [sideColor] + ); + + return ( + <$TradeHistoryRow className={className}> +
+
+ + {miniIcon} +
+
+ + {actionLabel}{' '} + {' '} + {displayableAsset} + + +
+
+ +
+
+ {closedPnl != null && closedPnl !== 0 ? ( + = 0 ? ShowSign.None : ShowSign.Negative} + withSignedValueColor + /> + ) : ( + -- + )} + + @ } + /> +
+
+ + ); +}; + +const $TradeHistoryRow = styled.div` + ${tw`row w-full justify-between gap-0.5 px-1.25`} + border-bottom: var(--default-border-width) solid var(--color-layer-3); +`; diff --git a/src/views/dialogs/SharePNLAnalyticsDialog.tsx b/src/views/dialogs/SharePNLAnalyticsDialog.tsx index 10d05f0056..86f4a8254a 100644 --- a/src/views/dialogs/SharePNLAnalyticsDialog.tsx +++ b/src/views/dialogs/SharePNLAnalyticsDialog.tsx @@ -42,31 +42,17 @@ const copyBlobToClipboard = async (blob: Blob | null) => { }; export const SharePNLAnalyticsDialog = ({ - marketId, - assetId, - side, - leverage, - oraclePrice, - entryPrice, - unrealizedPnl, setIsOpen, + ...sharePnlData }: DialogProps) => { const stringGetter = useStringGetter(); const dispatch = useAppDispatch(); - const symbol = getDisplayableAssetFromBaseAsset(assetId); + const symbol = getDisplayableAssetFromBaseAsset(sharePnlData.assetId); const [isCopying, setIsCopying] = useState(false); const [isSharing, setIsSharing] = useState(false); const [isCopied, setIsCopied] = useState(false); - const getPnlImage = useSharePnlImage({ - assetId, - marketId, - side, - leverage, - oraclePrice, - entryPrice, - unrealizedPnl, - }); + const getPnlImage = useSharePnlImage(sharePnlData); const pnlImage = useMemo(() => getPnlImage.data ?? undefined, [getPnlImage.data]); @@ -75,7 +61,7 @@ export const SharePNLAnalyticsDialog = ({ setIsCopying(true); try { await copyBlobToClipboard(pnlImage); - track(AnalyticsEvents.SharePnlCopied({ asset: assetId })); + track(AnalyticsEvents.SharePnlCopied({ asset: sharePnlData.assetId })); setIsCopying(false); setIsCopied(true); setTimeout(() => setIsCopied(false), 2000); @@ -100,7 +86,7 @@ export const SharePNLAnalyticsDialog = ({ })}\n\n#dydx #${symbol}\n[${stringGetter({ key: STRING_KEYS.TWEET_PASTE_IMAGE_AND_DELETE_THIS })}]`, related: 'dYdX', }); - track(AnalyticsEvents.SharePnlShared({ asset: assetId })); + track(AnalyticsEvents.SharePnlShared({ asset: sharePnlData.assetId })); setIsSharing(false); } catch (error) { logBonsaiError('SharePNLAnalyticsDialog/sharePnlImage', 'Failed to share PNL image', { diff --git a/src/views/tables/PositionsTable.tsx b/src/views/tables/PositionsTable.tsx index ed238c73b4..c8240b28eb 100644 --- a/src/views/tables/PositionsTable.tsx +++ b/src/views/tables/PositionsTable.tsx @@ -403,6 +403,7 @@ const getPositionsTableColumnDef = ({ allowsSorting: false, hideOnBreakpoint: MediaQueryKeys.isTablet, renderCell: ({ + uniqueId, market, marketSummary, assetId, @@ -412,6 +413,7 @@ const getPositionsTableColumnDef = ({ updatedUnrealizedPnl: unrealizedPnl, }) => ( diff --git a/src/views/tables/PositionsTable/PositionsActionsCell.tsx b/src/views/tables/PositionsTable/PositionsActionsCell.tsx index 6e7835005c..970d64fd8e 100644 --- a/src/views/tables/PositionsTable/PositionsActionsCell.tsx +++ b/src/views/tables/PositionsTable/PositionsActionsCell.tsx @@ -1,13 +1,19 @@ +import { PositionUniqueId } from '@/bonsai/types/summaryTypes'; import BigNumber from 'bignumber.js'; import { useNavigate } from 'react-router-dom'; import styled from 'styled-components'; import { ButtonShape, ButtonStyle } from '@/constants/buttons'; -import { DialogTypes, TradeBoxDialogTypes } from '@/constants/dialogs'; +import { + DialogTypes, + SharePNLAnalyticsDialogProps, + TradeBoxDialogTypes, +} from '@/constants/dialogs'; import { STRING_KEYS } from '@/constants/localization'; import { AppRoute } from '@/constants/routes'; import { IndexerPositionSide } from '@/types/indexer/indexerApiGen'; +import { useAppSelectorWithArgs } from '@/hooks/useParameterizedSelector'; import { useStringGetter } from '@/hooks/useStringGetter'; import { IconName } from '@/components/Icon'; @@ -15,6 +21,7 @@ import { IconButton } from '@/components/IconButton'; import { ActionsTableCell } from '@/components/Table/ActionsTableCell'; import { WithTooltip } from '@/components/WithTooltip'; +import { getOpenPositionFromId } from '@/state/accountSelectors'; import { useAppDispatch, useAppSelector } from '@/state/appTypes'; import { closePositionFormActions } from '@/state/closePositionForm'; import { getCurrentMarketId } from '@/state/currentMarketSelectors'; @@ -24,6 +31,7 @@ import { getActiveTradeBoxDialog } from '@/state/dialogsSelectors'; import { Nullable } from '@/lib/typeUtils'; type ElementProps = { + positionId: PositionUniqueId; marketId: string; assetId: string; leverage: Nullable; @@ -31,12 +39,12 @@ type ElementProps = { entryPrice: Nullable; unrealizedPnl: Nullable; side: Nullable; - sideLabel: Nullable; isDisabled?: boolean; showClosePositionAction: boolean; }; export const PositionsActionsCell = ({ + positionId, marketId, assetId, leverage, @@ -44,7 +52,6 @@ export const PositionsActionsCell = ({ entryPrice, unrealizedPnl, side, - sideLabel, isDisabled, showClosePositionAction, }: ElementProps) => { @@ -55,6 +62,8 @@ export const PositionsActionsCell = ({ const activeTradeBoxDialog = useAppSelector(getActiveTradeBoxDialog); const stringGetter = useStringGetter(); + const position = useAppSelectorWithArgs(getOpenPositionFromId, positionId); + const onCloseButtonToggle = (isPressed: boolean) => { navigate(`${AppRoute.Trade}/${marketId}`); dispatch( @@ -69,20 +78,22 @@ export const PositionsActionsCell = ({ }; const openShareDialog = () => { - dispatch( - openDialog( - DialogTypes.SharePNLAnalytics({ - marketId, - assetId, - leverage: leverage?.toNumber(), - oraclePrice: oraclePrice?.toNumber(), - entryPrice: entryPrice?.toNumber(), - unrealizedPnl: unrealizedPnl?.toNumber(), - side, - sideLabel, - }) - ) - ); + const sharePnlData: SharePNLAnalyticsDialogProps = { + assetId, + marketId, + size: position?.value.toNumber() ?? 0, + isLong: side === IndexerPositionSide.LONG, + isCross: position?.marginMode === 'CROSS', + leverage: leverage?.toNumber(), + oraclePrice: oraclePrice?.toNumber(), + entryPrice: entryPrice?.toNumber(), + unrealizedPnl: unrealizedPnl?.toNumber(), + pnl: position?.realizedPnl.toNumber(), + pnlPercentage: position?.updatedUnrealizedPnlPercent?.toNumber(), + liquidationPrice: position?.liquidationPrice?.toNumber(), + }; + + dispatch(openDialog(DialogTypes.SharePNLAnalytics(sharePnlData))); }; return ( @@ -124,11 +135,9 @@ const $ActionsTableCell = styled(ActionsTableCell)` `; const $TriggersButton = styled(IconButton)` - --button-icon-size: 1.25em; + --button-icon-size: 1em; --button-textColor: var(--color-text-0); --button-hover-textColor: var(--color-text-1); - - --button-icon-size: 1em; `; const $CloseButtonToggle = styled(IconButton)` diff --git a/src/views/tables/TradeHistoryTable.tsx b/src/views/tables/TradeHistoryTable.tsx new file mode 100644 index 0000000000..f51a2184ce --- /dev/null +++ b/src/views/tables/TradeHistoryTable.tsx @@ -0,0 +1,375 @@ +import { forwardRef, useMemo } from 'react'; + +import { BonsaiCore } from '@/bonsai/ontology'; +import { PerpetualMarketSummary, SubaccountTrade } from '@/bonsai/types/summaryTypes'; +import type { ColumnSize } from '@react-types/table'; +import styled, { css } from 'styled-components'; +import tw from 'twin.macro'; + +import { STRING_KEYS, type StringGetterFunction } from '@/constants/localization'; +import { IndexerOrderSide } from '@/types/indexer/indexerApiGen'; + +import { useStringGetter } from '@/hooks/useStringGetter'; + +import { defaultTableMixins } from '@/styles/tableMixins'; + +import { AssetIcon } from '@/components/AssetIcon'; +import { Icon, IconName } from '@/components/Icon'; +import { Output, OutputType, ShowSign } from '@/components/Output'; +import { ColumnDef, Table } from '@/components/Table'; +import { DateAgeOutput } from '@/components/Table/DateAgeToggleHeader'; +import { MarketSummaryTableCell } from '@/components/Table/MarketTableCell'; +import { TableCell } from '@/components/Table/TableCell'; +import { TableColumnHeader } from '@/components/Table/TableColumnHeader'; +import { PageSize } from '@/components/Table/TablePaginationRow'; +import { Tag } from '@/components/Tag'; + +import { useAppSelector } from '@/state/appTypes'; + +import { getMarginModeStringKey } from '@/lib/enumToStringKeyHelpers'; +import { MustBigNumber } from '@/lib/numbers'; +import { getTradeActionDisplayInfo } from '@/lib/tradeHistoryHelpers'; +import { Nullable, orEmptyRecord } from '@/lib/typeUtils'; + +import { TradeHistoryActionsCell } from './TradeHistoryTable/TradeHistoryActionsCell'; + +export enum TradeHistoryTableColumnKey { + Time = 'Time', + Market = 'Market', + Type = 'Type', + Action = 'Action', + Price = 'Price', + Size = 'Size', + Value = 'Value', + Fee = 'Fee', + ClosedPnl = 'ClosedPnl', + Actions = 'Actions', + + // Tablet (stacked) + ActionAmount = 'Action-Amount', + PriceFee = 'Price-Fee', +} + +export type TradeTableRow = { + marketSummary: Nullable; + stepSizeDecimals: number; + tickSizeDecimals: number; +} & SubaccountTrade; + +const getTradeHistoryTableColumnDef = ({ + key, + stringGetter, + width, +}: { + key: TradeHistoryTableColumnKey; + stringGetter: StringGetterFunction; + symbol?: Nullable; + width?: ColumnSize; +}): ColumnDef => ({ + width, + ...( + { + [TradeHistoryTableColumnKey.Time]: { + columnKey: 'time', + getCellValue: (row) => row.createdAt, + label: stringGetter({ key: STRING_KEYS.TIME }), + renderCell: ({ createdAt }) => ( + + ), + }, + + [TradeHistoryTableColumnKey.Market]: { + columnKey: 'market', + getCellValue: (row) => row.marketId, + label: stringGetter({ key: STRING_KEYS.MARKET }), + renderCell: ({ marketSummary }) => ( + + ), + }, + + [TradeHistoryTableColumnKey.Type]: { + columnKey: 'type', + getCellValue: (row) => row.marginMode, + label: stringGetter({ key: STRING_KEYS.TYPE }), + renderCell: ({ marginMode }) => ( + + {stringGetter({ key: getMarginModeStringKey(marginMode) })} + + ), + }, + + [TradeHistoryTableColumnKey.Action]: { + columnKey: 'action', + getCellValue: (row) => row.action, + label: stringGetter({ key: STRING_KEYS.ACTION }), + renderCell: ({ action }) => { + const { label, color } = getTradeActionDisplayInfo(action, stringGetter); + return {label}; + }, + }, + + [TradeHistoryTableColumnKey.Price]: { + columnKey: 'price', + getCellValue: (row) => row.price, + label: stringGetter({ key: STRING_KEYS.PRICE }), + renderCell: ({ price, tickSizeDecimals }) => ( + + ), + }, + + [TradeHistoryTableColumnKey.Size]: { + columnKey: 'size', + getCellValue: (row) => row.additionalSize, + label: stringGetter({ key: STRING_KEYS.AMOUNT }), + renderCell: ({ additionalSize, stepSizeDecimals }) => ( + + ), + }, + + [TradeHistoryTableColumnKey.Value]: { + columnKey: 'total', + getCellValue: (row) => row.value, + label: stringGetter({ key: STRING_KEYS.TOTAL }), + renderCell: ({ value }) => ( + + + + ), + }, + + [TradeHistoryTableColumnKey.Fee]: { + columnKey: 'fee', + getCellValue: (row) => row.fee, + label: stringGetter({ key: STRING_KEYS.FEE }), + renderCell: ({ fee }) => ( + + + + ), + }, + + [TradeHistoryTableColumnKey.ClosedPnl]: { + columnKey: 'closedPnl', + getCellValue: (row) => row.closedPnl, + label: stringGetter({ key: STRING_KEYS.CLOSED_PNL }), + renderCell: ({ closedPnl, netClosedPnlPercent }) => ( + + {closedPnl != null && closedPnl !== 0 ? ( + <> + + <$HighlightOutput + isNegative={MustBigNumber(netClosedPnlPercent).isNegative()} + type={OutputType.Percent} + value={netClosedPnlPercent} + showSign={ShowSign.Both} + withParentheses + withSignedValueColor + /> + + ) : ( + + )} + + ), + }, + + // --- Tablet stacked columns --- + [TradeHistoryTableColumnKey.ActionAmount]: { + columnKey: 'actionAmount', + getCellValue: (row) => row.size, + label: ( + + {stringGetter({ key: STRING_KEYS.ACTION })} + {stringGetter({ key: STRING_KEYS.AMOUNT })} + + ), + renderCell: ({ action, size, stepSizeDecimals, marketSummary }) => { + const { label, color } = getTradeActionDisplayInfo(action, stringGetter); + return ( + + } + > + {label} + + + ); + }, + }, + + [TradeHistoryTableColumnKey.PriceFee]: { + columnKey: 'priceFee', + getCellValue: (row) => row.price, + label: ( + + {stringGetter({ key: STRING_KEYS.PRICE })} + {stringGetter({ key: STRING_KEYS.FEE })} + + ), + renderCell: ({ side, price, fee, tickSizeDecimals }) => ( + + <$InlineRow> + <$Side side={side}>{side === IndexerOrderSide.BUY ? 'Buy' : 'Sell'} + @ + + + + + ), + }, + + [TradeHistoryTableColumnKey.Actions]: { + columnKey: 'actions', + isActionable: true, + allowsSorting: false, + label: stringGetter({ key: STRING_KEYS.SHARE }), + renderCell: (row) => , + }, + } satisfies Record> + )[key], +}); + +type ElementProps = { + columnKeys: TradeHistoryTableColumnKey[]; + columnWidths?: Partial>; + initialPageSize?: PageSize; +}; + +type StyleProps = { + withOuterBorder?: boolean; + withInnerBorders?: boolean; +}; + +export const TradeHistoryTable = forwardRef( + ( + { + columnKeys, + columnWidths, + initialPageSize, + withOuterBorder, + withInnerBorders = true, + }: ElementProps & StyleProps, + _ref + ) => { + const stringGetter = useStringGetter(); + const allTrades = useAppSelector(BonsaiCore.account.tradeHistory.data); + const tradesStatus = useAppSelector(BonsaiCore.account.tradeHistory.loading); + const isError = tradesStatus === 'error'; + const marketSummaries = orEmptyRecord(useAppSelector(BonsaiCore.markets.markets.data)); + + const tradesData = useMemo( + () => + allTrades.map( + (trade: SubaccountTrade): TradeTableRow => ({ + ...trade, + marketSummary: marketSummaries[trade.marketId] ?? null, + stepSizeDecimals: marketSummaries[trade.marketId]?.stepSizeDecimals ?? 2, + tickSizeDecimals: marketSummaries[trade.marketId]?.tickSizeDecimals ?? 2, + }) + ), + [allTrades, marketSummaries] + ); + + return ( + <$Table + key="trade-history" + label="Trades" + tableId="tradeHistory" + data={tradesData} + getRowKey={(row: TradeTableRow) => row.id} + columns={columnKeys.map((key: TradeHistoryTableColumnKey) => + getTradeHistoryTableColumnDef({ + key, + stringGetter, + width: columnWidths?.[key], + }) + )} + slotEmpty={ + isError ? ( + <> + +

+ {stringGetter({ + key: STRING_KEYS.SOMETHING_WENT_WRONG_WITH_MESSAGE, + params: { ERROR_MESSAGE: 'Failed to load trades' }, + })} +

+ + ) : ( + <> + +

{stringGetter({ key: STRING_KEYS.TRADES_EMPTY_STATE })}

+ + ) + } + initialPageSize={initialPageSize} + withOuterBorder={withOuterBorder} + withInnerBorders={withInnerBorders} + withScrollSnapColumns + withScrollSnapRows + withFocusStickyRows + /> + ); + } +); + +const $Table = styled(Table)` + ${defaultTableMixins} +` as typeof Table; +const $InlineRow = tw.div`inlineRow`; +const $Side = styled.span<{ side: Nullable }>` + ${({ side }) => + side && + { + [IndexerOrderSide.BUY]: css` + color: var(--color-positive); + `, + [IndexerOrderSide.SELL]: css` + color: var(--color-negative); + `, + }[side]}; +`; + +const $HighlightOutput = styled(Output)<{ isNegative?: boolean }>` + color: var(--color-text-1); + --secondary-item-color: currentColor; + --output-sign-color: ${({ isNegative }) => + isNegative !== undefined + ? isNegative + ? `var(--color-negative)` + : `var(--color-positive)` + : `currentColor`}; +`; diff --git a/src/views/tables/TradeHistoryTable/TradeHistoryActionsCell.tsx b/src/views/tables/TradeHistoryTable/TradeHistoryActionsCell.tsx new file mode 100644 index 0000000000..5b3af3ce86 --- /dev/null +++ b/src/views/tables/TradeHistoryTable/TradeHistoryActionsCell.tsx @@ -0,0 +1,87 @@ +import { useCallback } from 'react'; + +import { TradeAction } from '@/bonsai/types/summaryTypes'; +import styled from 'styled-components'; + +import { ButtonShape, ButtonStyle } from '@/constants/buttons'; +import { DialogTypes, SharePNLAnalyticsDialogProps } 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 { ActionsTableCell } from '@/components/Table/ActionsTableCell'; +import { WithTooltip } from '@/components/WithTooltip'; + +import { useAppDispatch } from '@/state/appTypes'; +import { openDialog } from '@/state/dialogs'; + +import { MaybeBigNumber, MustNumber } from '@/lib/numbers'; +import { TRADE_ACTION_TO_SHARE_TYPE_MAP } from '@/lib/tradeHistoryHelpers'; + +import { type TradeTableRow } from '../TradeHistoryTable'; + +type ElementProps = { + trade: TradeTableRow; + isDisabled?: boolean; +}; + +export const TradeHistoryActionsCell = ({ trade, isDisabled }: ElementProps) => { + const dispatch = useAppDispatch(); + const stringGetter = useStringGetter(); + + const shareType = TRADE_ACTION_TO_SHARE_TYPE_MAP[trade.action] ?? undefined; + + const prevSizeDollar = + !!trade.prevSize && !!trade.price ? trade.prevSize * trade.price : undefined; + + const openShareDialog = useCallback(() => { + const sharePnlData: SharePNLAnalyticsDialogProps = { + assetId: trade.marketSummary?.assetId ?? '', + marketId: trade.marketId, + size: Number(trade.price) * MustNumber(trade.additionalSize ?? 0) + (prevSizeDollar ?? 0), + prevSize: prevSizeDollar, + isLong: + trade.positionSide === 'LONG' || + trade.action === TradeAction.OPEN_LONG || + trade.action === TradeAction.CLOSE_LONG, + isCross: trade.marginMode === 'CROSS', + shareType, + leverage: trade.leverage, + oraclePrice: MaybeBigNumber(trade.marketSummary?.oraclePrice)?.toNumber(), + entryPrice: trade.entryPrice, + exitPrice: trade.price, + pnl: trade.closedPnl, + pnlPercentage: trade.netClosedPnlPercent ?? 0, + liquidationPrice: trade.liquidationPrice, + }; + + dispatch(openDialog(DialogTypes.SharePNLAnalytics(sharePnlData))); + }, [dispatch, trade, prevSizeDollar, shareType]); + + return ( + <$ActionsTableCell tw="mr-[-0.5rem]"> + + <$TriggersButton + key="share" + onClick={openShareDialog} + iconName={IconName.Share} + shape={ButtonShape.Square} + disabled={isDisabled} + buttonStyle={ButtonStyle.WithoutBackground} + /> + + + ); +}; + +const $ActionsTableCell = styled(ActionsTableCell)` + --toolbar-margin: 0.25rem; +`; + +const $TriggersButton = styled(IconButton)` + --button-icon-size: 1em; + --button-textColor: var(--color-text-0); + --button-hover-textColor: var(--color-text-1); +`;