diff --git a/src/constants/dialogs.ts b/src/constants/dialogs.ts index 4da41c2fd0..994f46f992 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'; @@ -67,14 +65,22 @@ export type SetMarketLeverageDialogProps = { marketId: string }; export type SetupPasskeyDialogProps = { onClose: () => void }; export type ShareAffiliateDialogProps = {}; 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?: 'open' | 'close' | 'liquidated' | 'partialClose' | undefined; + leverage?: Nullable; + size?: Nullable; + prevSize?: Nullable; + pnl?: Nullable; + unrealizedPnl?: Nullable; + pnlPercentage?: Nullable; + entryPrice?: Nullable; + exitPrice?: Nullable; + liquidationPrice?: Nullable; + oraclePrice?: Nullable; + sideLabel?: Nullable; }; export type SimpleUiTradeDialogProps = | { diff --git a/src/hooks/useSharePnlImage.ts b/src/hooks/useSharePnlImage.ts index e3636fbe74..1606f97ac7 100644 --- a/src/hooks/useSharePnlImage.ts +++ b/src/hooks/useSharePnlImage.ts @@ -1,76 +1,42 @@ 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 ?? 'open', + leverage: data.leverage ?? 0, 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, }; const response = await fetch(pnlImageApi, { @@ -92,25 +58,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/pages/portfolio/Portfolio.tsx b/src/pages/portfolio/Portfolio.tsx index 71d372fc94..3b4e75f0e4 100644 --- a/src/pages/portfolio/Portfolio.tsx +++ b/src/pages/portfolio/Portfolio.tsx @@ -133,6 +133,7 @@ const PortfolioPage = () => { FillsTableColumnKey.Fee, FillsTableColumnKey.ClosedPnl, FillsTableColumnKey.Liquidity, + FillsTableColumnKey.Actions, ] } withOuterBorder={isNotTablet} 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/FillsTable.tsx b/src/views/tables/FillsTable.tsx index 6c1792c8b9..23a7266e76 100644 --- a/src/views/tables/FillsTable.tsx +++ b/src/views/tables/FillsTable.tsx @@ -26,11 +26,12 @@ import { TableColumnHeader } from '@/components/Table/TableColumnHeader'; import { PageSize } from '@/components/Table/TablePaginationRow'; import { TagSize } from '@/components/Tag'; +import { calculateIsAccountViewOnly } from '@/state/accountCalculators'; import { useAppDispatch, useAppSelector } from '@/state/appTypes'; import { openDialog } from '@/state/dialogs'; import { mapIfPresent } from '@/lib/do'; -import { MustBigNumber } from '@/lib/numbers'; +import { MaybeBigNumber, MustBigNumber } from '@/lib/numbers'; import { getHydratedFill } from '@/lib/orders'; import { Nullable, orEmptyRecord } from '@/lib/typeUtils'; @@ -39,6 +40,7 @@ import { getIndexerLiquidityStringKey, getIndexerOrderSideStringKey, } from '../../lib/enumToStringKeyHelpers'; +import { FillActionsCell } from './FillsTable/FillActionsCell'; export enum FillsTableColumnKey { Time = 'Time', @@ -53,6 +55,7 @@ export enum FillsTableColumnKey { Total = 'Total', Fee = 'Fee', ClosedPnl = 'ClosedPnl', + Actions = 'Actions', // Tablet Only TypeAmount = 'Type-Amount', @@ -70,11 +73,13 @@ const getFillsTableColumnDef = ({ stringGetter, symbol = '', width, + isAccountViewOnly, }: { key: FillsTableColumnKey; stringGetter: StringGetterFunction; symbol?: Nullable; width?: ColumnSize; + isAccountViewOnly?: boolean; }): ColumnDef => ({ width, ...( @@ -258,7 +263,6 @@ const getFillsTableColumnDef = ({ label: stringGetter({ key: STRING_KEYS.SIDE }), renderCell: ({ side }) => side && , }, - [FillsTableColumnKey.AmountPrice]: { columnKey: 'sizePrice', getCellValue: (row) => row.size, @@ -280,6 +284,20 @@ const getFillsTableColumnDef = ({ ), }, + [FillsTableColumnKey.Actions]: { + columnKey: 'actions', + label: '', + isActionable: true, + allowsSorting: false, + renderCell: ({ marketSummary, ...fill }: FillTableRow) => ( + + ), + }, } satisfies Record> )[key], }); @@ -316,6 +334,7 @@ export const FillsTable = forwardRef( const fills = currentMarket ? marketFills : allFills; const marketSummaries = orEmptyRecord(useAppSelector(BonsaiCore.markets.markets.data)); + const isAccountViewOnly = useAppSelector(calculateIsAccountViewOnly); useViewPanel(currentMarket, 'fills'); @@ -352,6 +371,7 @@ export const FillsTable = forwardRef( stringGetter, symbol, width: columnWidths?.[key], + isAccountViewOnly, }) )} slotEmpty={ diff --git a/src/views/tables/FillsTable/FillActionsCell.tsx b/src/views/tables/FillsTable/FillActionsCell.tsx new file mode 100644 index 0000000000..d8f032d2be --- /dev/null +++ b/src/views/tables/FillsTable/FillActionsCell.tsx @@ -0,0 +1,188 @@ +import { + PositionUniqueId, + SubaccountFill, + SubaccountFillType, + SubaccountPosition, +} 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 { IndexerOrderSide, IndexerPositionSide } from '@/types/indexer/indexerApiGen'; + +import { useAppSelectorWithArgs } from '@/hooks/useParameterizedSelector'; +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 { + getFillsForOrderId, + getOrderById, + getSubaccountPositionByUniqueId, +} from '@/state/accountSelectors'; +import { useAppDispatch } from '@/state/appTypes'; +import { openDialog } from '@/state/dialogs'; + +import { getIndexerOrderSideStringKey } from '@/lib/enumToStringKeyHelpers'; +import { MustNumber } from '@/lib/numbers'; +import { Nullable } from '@/lib/typeUtils'; + +import { FillTableRow } from '../FillsTable'; + +type ElementProps = { + fill: SubaccountFill; + assetId: string; + oraclePrice: Nullable; + isDisabled?: boolean; +}; + +export type FullFillTableRow = FillTableRow & { quoteAmount: string | undefined }; + +// Transform fill data into SharePnlData +const transformFillToShareData = ( + fill: FullFillTableRow, + relatedFills: FullFillTableRow[], + positionData: SubaccountPosition, + assetId: string, + oraclePrice: Nullable +): SharePNLAnalyticsDialogProps => { + const isLong = fill.positionSideBefore === IndexerPositionSide.LONG; + const isCross = fill.marginMode === 'CROSS'; + + let type: 'open' | 'close' | 'liquidated' | 'partialClose' = 'close'; + + // Determine if this was an opening or closing trade + const wasClosingTrade = + fill.positionSideBefore != null && + ((fill.positionSideBefore === IndexerPositionSide.LONG && + fill.side === IndexerOrderSide.SELL) || + (fill.positionSideBefore === IndexerPositionSide.SHORT && + fill.side === IndexerOrderSide.BUY)); + + if (fill.type === SubaccountFillType.LIQUIDATED) { + type = 'liquidated'; + } + + if (positionData.status === 'OPEN' && wasClosingTrade) { + type = 'partialClose'; + } else if (positionData.status === 'OPEN' && !wasClosingTrade) { + type = 'open'; + } + + const aggregatedPnl = relatedFills.reduce((acc, rFill) => acc + (rFill.closedPnl ?? 0), 0); + const calcTotalValue = relatedFills.reduce( + (acc, rFill) => acc + MustNumber(rFill.quoteAmount ?? '0'), + 0 + ); + + const size = positionData.value.toNumber(); + const prevSize = wasClosingTrade ? size + calcTotalValue : size - calcTotalValue; + + const entryPrice = fill.entryPriceBefore + ? parseFloat(fill.entryPriceBefore) + : positionData.entryPrice.toNumber(); + + const exitPrice = MustNumber(fill.price); + + const pnlPercentage = isLong + ? (exitPrice - entryPrice) / entryPrice + : (entryPrice - exitPrice) / entryPrice; + + // eslint-disable-next-line no-console + console.log('calcTotalValue: ', calcTotalValue); + // eslint-disable-next-line no-console + console.log('prevSize: ', prevSize); + // eslint-disable-next-line no-console + console.log('size: ', size); + + return { + assetId, + marketId: fill.market ?? '', + isLong, + isCross, + shareType: type, + size, + prevSize, + entryPrice: fill.entryPriceBefore ? parseFloat(fill.entryPriceBefore) : undefined, + exitPrice: MustNumber(fill.price), + oraclePrice: oraclePrice ?? undefined, + pnlPercentage, + pnl: aggregatedPnl, + }; +}; + +export const FillActionsCell = ({ fill, assetId, oraclePrice, isDisabled }: ElementProps) => { + const dispatch = useAppDispatch(); + const stringGetter = useStringGetter(); + const orderData = useAppSelectorWithArgs(getOrderById, fill.orderId ?? ''); + const relatedFills = useAppSelectorWithArgs(getFillsForOrderId, fill.orderId ?? ''); + + const positionData = useAppSelectorWithArgs( + getSubaccountPositionByUniqueId, + orderData?.positionUniqueId as PositionUniqueId + ); + + const sideLabel = fill.side + ? stringGetter({ key: getIndexerOrderSideStringKey(fill.side) }) + : undefined; + + const openShareDialog = () => { + if (!positionData) return; + + const sharePnlData = transformFillToShareData( + fill as FullFillTableRow, + relatedFills as FullFillTableRow[], + positionData as SubaccountPosition, + assetId, + oraclePrice + ); + + // eslint-disable-next-line no-console + console.log('position data: ', positionData); + // eslint-disable-next-line no-console + console.log('fill: ', fill); + // eslint-disable-next-line no-console + console.log('order data: ', orderData); + + dispatch( + openDialog( + DialogTypes.SharePNLAnalytics({ + ...sharePnlData, + leverage: positionData.leverage?.toNumber(), + liquidationPrice: positionData.liquidationPrice?.toNumber(), + sideLabel, + }) + ) + ); + }; + + return ( + <$ActionsTableCell> + + <$ShareButton + key="share" + onClick={openShareDialog} + iconName={IconName.Share} + shape={ButtonShape.Square} + disabled={isDisabled} + buttonStyle={ButtonStyle.WithoutBackground} + /> + + + ); +}; + +const $ActionsTableCell = styled(ActionsTableCell)` + --toolbar-margin: 0.25rem; +`; + +const $ShareButton = styled(IconButton)` + --button-icon-size: 1.25em; + --button-textColor: var(--color-text-0); + --button-hover-textColor: var(--color-text-1); + --button-icon-size: 1em; +`; diff --git a/src/views/tables/PositionsTable.tsx b/src/views/tables/PositionsTable.tsx index ed238c73b4..dee01289ff 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, }) => ( ; @@ -37,6 +45,7 @@ type ElementProps = { }; export const PositionsActionsCell = ({ + positionId, marketId, assetId, leverage, @@ -55,6 +64,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,16 +80,26 @@ export const PositionsActionsCell = ({ }; const openShareDialog = () => { + const sharePnlData: SharePNLAnalyticsDialogProps = { + assetId, + marketId, + size: position?.value.toNumber() ?? 0, + isLong: side === IndexerPositionSide.LONG, + isCross: position?.marginMode === 'CROSS', + shareType: position?.status === 'OPEN' ? 'open' : 'close', + leverage: leverage?.toNumber(), + oraclePrice: oraclePrice?.toNumber(), + entryPrice: entryPrice?.toNumber(), + unrealizedPnl: unrealizedPnl?.toNumber(), + pnl: position?.realizedPnl.toNumber(), + pnlPercentage: position?.updatedUnrealizedPnlPercent?.toNumber() ?? 0, + liquidationPrice: position?.liquidationPrice?.toNumber(), + }; + dispatch( openDialog( DialogTypes.SharePNLAnalytics({ - marketId, - assetId, - leverage: leverage?.toNumber(), - oraclePrice: oraclePrice?.toNumber(), - entryPrice: entryPrice?.toNumber(), - unrealizedPnl: unrealizedPnl?.toNumber(), - side, + ...sharePnlData, sideLabel, }) )