diff --git a/src/AdrenaClient.ts b/src/AdrenaClient.ts index 27cb104a..8e3ee399 100644 --- a/src/AdrenaClient.ts +++ b/src/AdrenaClient.ts @@ -4036,12 +4036,15 @@ export class AdrenaClient { // Positions PDA can be found by deriving each mints supported by the pool for 2 sides // DO NOT LOAD PNL OR LIQUIDATION PRICE - public async loadUserPositions(user: PublicKey): Promise { - const possiblePositionAddresses = this.getPossiblePositionAddresses(user); - + public async loadUserPositions( + user: PublicKey, + positionAddresses?: Array, + ): Promise { + const actualPositionAddresses = + positionAddresses || this.getPossiblePositionAddresses(user); const positions = (await this.readonlyAdrenaProgram.account.position.fetchMultiple( - possiblePositionAddresses, + actualPositionAddresses, 'recent', )) as (Position | null)[]; @@ -4054,14 +4057,15 @@ export class AdrenaClient { const positionExtended = this.extendPosition( position, - possiblePositionAddresses[index], + actualPositionAddresses[index], ); - if (!positionExtended) { + if (positionExtended) { + acc.push(positionExtended); return acc; } - return [...acc, positionExtended]; + return acc; }, [] as PositionExtended[], ); diff --git a/src/actions/streamingTokenPrices.ts b/src/actions/streamingTokenPrices.ts index ae5826c9..aab7a323 100644 --- a/src/actions/streamingTokenPrices.ts +++ b/src/actions/streamingTokenPrices.ts @@ -1,6 +1,9 @@ export const SET_STREAMING_TOKEN_PRICE_ACTION_TYPE = 'setStreamingTokenPrice' as const; +export const STOP_STREAMING_TOKEN_PRICES_ACTION_TYPE = + 'stopStreamingTokenPrice' as const; + export const setStreamingTokenPrice = ( tokenSymbol: string, price: number | null, @@ -11,3 +14,7 @@ export const setStreamingTokenPrice = ( price, }, }); + +export const stopStreamingTokenPrices = () => ({ + type: STOP_STREAMING_TOKEN_PRICES_ACTION_TYPE, +}); diff --git a/src/actions/thunks.ts b/src/actions/thunks.ts index 57263524..9aac4a9c 100644 --- a/src/actions/thunks.ts +++ b/src/actions/thunks.ts @@ -1,10 +1,14 @@ import { BN } from '@coral-xyz/anchor'; import { NATIVE_MINT } from '@solana/spl-token'; +import type { AccountInfo, PublicKey } from '@solana/web3.js'; import { SOL_DECIMALS } from '@/constant'; +import type { TokenPricesState } from '@/reducers/streamingTokenPricesReducer'; +import { selectPossibleUserPositions } from '@/selectors/positions'; +import { selectStreamingTokenPricesFallback } from '@/selectors/streamingTokenPrices'; import { selectWalletPublicKey } from '@/selectors/wallet'; import type { Dispatch, RootState } from '@/store/store'; -import type { TokenSymbol } from '@/types'; +import type { PositionExtended, TokenSymbol } from '@/types'; import { findATAAddressSync, nativeToUi } from '@/utils'; import { setWalletTokenBalances } from './walletBalances'; @@ -69,3 +73,185 @@ export const fetchWalletTokenBalances = ), ); }; + +export const calculatePnLandLiquidationPrice = ( + position: PositionExtended, + tokenPrices: TokenPricesState, +) => { + const pnl = window.adrena.client.calculatePositionPnL({ + position, + tokenPrices, + }); + + if (pnl === null) { + return; + } + + const { profitUsd, lossUsd, borrowFeeUsd } = pnl; + + position.profitUsd = profitUsd; + position.lossUsd = lossUsd; + position.borrowFeeUsd = borrowFeeUsd; + position.pnl = profitUsd + -lossUsd; + position.pnlMinusFees = position.pnl + borrowFeeUsd + position.exitFeeUsd; + position.currentLeverage = + position.sizeUsd / (position.collateralUsd + position.pnl); + + const liquidationPrice = window.adrena.client.calculateLiquidationPrice({ + position, + }); + + if (liquidationPrice !== null) { + position.liquidationPrice = liquidationPrice; + } +}; + +export const fetchUserPositions = + () => + async ( + dispatch: Dispatch, + getState: () => RootState, + ): Promise | null> => { + const connection = window.adrena.client.readonlyConnection; + const walletPublicKey = selectWalletPublicKey(getState()); + const possibleUserPositions = selectPossibleUserPositions(getState()); + + if (!connection || !walletPublicKey || !possibleUserPositions) { + return null; + } + + try { + const positions = await window.adrena.client.loadUserPositions( + walletPublicKey, + possibleUserPositions, + ); + + const tokenPrices = selectStreamingTokenPricesFallback(getState()); + for (const position of positions) { + try { + calculatePnLandLiquidationPrice(position, tokenPrices); + } catch (err) { + console.error( + 'Unexpected error calculating PnL / liquidation price', + err, + ); + } + } + + return positions; + } catch (err) { + console.error('Unexpected error fetching user positions', err); + + return null; + } + }; + +let subscribed = false; +export const subscribeToUserPositions = + ({ + onPositionUpdated, + onPositionDeleted, + }: { + onPositionUpdated: (position: PositionExtended) => unknown; + onPositionDeleted: (userPosition: PublicKey) => unknown; + }) => + (dispatch: Dispatch, getState: () => RootState) => { + const connection = window.adrena.client.readonlyConnection; + const walletPublicKey = selectWalletPublicKey(getState()); + const possibleUserPositions = selectPossibleUserPositions(getState()); + + if (!connection || !walletPublicKey || !possibleUserPositions) { + return; + } + + // Ensuring we only subscribe once to user position changes. + if (subscribed) { + return; + } + + const subscriptions = new Map(); + const removeSubscription = (subscriptionId: number) => { + connection.removeAccountChangeListener(subscriptionId); + subscriptions.delete(subscriptionId); + }; + const unsubscribe = () => { + subscribed = false; + for (const [subscriptionId] of subscriptions) { + removeSubscription(subscriptionId); + } + }; + + for (const possibleUserPosition of possibleUserPositions) { + const subscriptionId = connection.onAccountChange( + possibleUserPosition, + (accountInfo: AccountInfo) => { + const userPosition = possibleUserPosition; + + // Position got deleted + if (!accountInfo.data.length) { + onPositionDeleted(userPosition); + return; + } + + try { + const positionData = window.adrena.client + .getReadonlyAdrenaProgram() + .coder.accounts.decode('position', accountInfo.data); + const extendedPosition = window.adrena.client.extendPosition( + positionData, + userPosition, + ); + + if (!extendedPosition) { + throw new Error('Unexpected null extended user position'); + } + + onPositionUpdated(extendedPosition); + } catch (err) { + console.error( + 'Unexpected error decoding / extending user position', + err, + ); + } + }, + ); + subscriptions.set(subscriptionId, possibleUserPosition); + subscribed = true; + } + + return unsubscribe; + }; + +export const fetchAndSubscribeToFullUserPositions = + ( + onPositionUpdated: (position: PositionExtended) => unknown, + onPositionDeleted: (userPosition: PublicKey) => unknown, + ) => + (dispatch: Dispatch, getState: () => RootState) => { + // Fetch the initial user positions + // keep the promise to be returned synchronously, do not await it. + const positionsPromise = dispatch(fetchUserPositions()); + + const unsubscribe = dispatch( + subscribeToUserPositions({ + onPositionUpdated: function augmentPositionWithPnL(position) { + const tokenPrices = selectStreamingTokenPricesFallback(getState()); + try { + // PositionExtended objects are augmented in place. + calculatePnLandLiquidationPrice(position, tokenPrices); + } catch (err) { + console.error( + 'Unexpected error calculating PnL / liquidation price', + err, + ); + } + onPositionUpdated(position); + }, + onPositionDeleted, + }), + ); + + // We must return the unsubscribe function synchronously + // so effect-based react component / hook consumer can clean-up. + return [positionsPromise, unsubscribe] as const; + }; diff --git a/src/components/pages/trading/Positions/PositionBlock.tsx b/src/components/pages/trading/Positions/PositionBlock.tsx index 566a91d6..8d261542 100644 --- a/src/components/pages/trading/Positions/PositionBlock.tsx +++ b/src/components/pages/trading/Positions/PositionBlock.tsx @@ -2,7 +2,7 @@ import Tippy from '@tippyjs/react'; import { AnimatePresence } from 'framer-motion'; import Image from 'next/image'; import Link from 'next/link'; -import { useEffect, useRef, useState } from 'react'; +import { memo, useEffect, useRef, useState } from 'react'; import { twMerge } from 'tailwind-merge'; import Button from '@/components/common/Button/Button'; @@ -12,6 +12,7 @@ import { Congrats } from '@/components/Congrats/Congrats'; import FormatNumber from '@/components/Number/FormatNumber'; import { MINIMUM_POSITION_OPEN_TIME } from '@/constant'; import useBetterMediaQuery from '@/hooks/useBetterMediaQuery'; +import { selectStreamingTokenPriceFallback } from '@/selectors/streamingTokenPrices'; import { useSelector } from '@/store/store'; import { PositionExtended } from '@/types'; import { getTokenImage, getTokenSymbol } from '@/utils'; @@ -21,7 +22,8 @@ import OnchainAccountInfo from '../../monitoring/OnchainAccountInfo'; import NetValueTooltip from '../TradingInputs/NetValueTooltip'; import SharePositionModal from './SharePositionModal'; -export default function PositionBlock({ +// FIXME: Factorize with PositionBlockReadOnly +function PositionBlock({ bodyClassName, borderColor, position, @@ -38,7 +40,10 @@ export default function PositionBlock({ triggerEditPositionCollateral: (p: PositionExtended) => void; showFeesInPnl: boolean; }) { - const tokenPrices = useSelector((s) => s.tokenPrices); + // Only subscribe to the price for the token of this position. + const tradeTokenPrice = useSelector((s) => + selectStreamingTokenPriceFallback(s, getTokenSymbol(position.token.symbol)), + ); const blockRef = useRef(null); const [isOpen, setIsOpen] = useState(false); @@ -56,7 +61,7 @@ export default function PositionBlock({ } const interval = setInterval(() => { - console.log('interval') + console.log('interval'); const openedDuration = Date.now() - openedTime; const diff = MINIMUM_POSITION_OPEN_TIME - openedDuration; @@ -74,22 +79,22 @@ export default function PositionBlock({ }, [position.nativeObject.openTime.toNumber()]); const liquidable = (() => { - const tokenPrice = tokenPrices[getTokenSymbol(position.token.symbol)]; - if ( - tokenPrice === null || - typeof position.liquidationPrice === 'undefined' || + tradeTokenPrice === null || + position.liquidationPrice === undefined || position.liquidationPrice === null - ) + ) { return; + } - if (position.side === 'long') return tokenPrice < position.liquidationPrice; + if (position.side === 'long') { + return tradeTokenPrice < position.liquidationPrice; + } // Short - return tokenPrice > position.liquidationPrice; + return tradeTokenPrice > position.liquidationPrice; })(); - const isSmallSize = useBetterMediaQuery('(max-width: 800px)'); const positionName = (
@@ -168,16 +173,23 @@ export default function PositionBlock({ 0 - ? 'green' - : 'redbright' - }`} + className={`mr-0.5 font-bold text-${ + (showAfterFees ? position.pnl : position.pnl - fees) > 0 + ? 'green' + : 'redbright' + }`} isDecimalDimmed={false} /> - 0 - ? 'text-green' - : 'text-redbright')}>{"("} + 0 + ? 'text-green' + : 'text-redbright', + )} + > + {'('} + 0 - ? 'green' - : 'redbright' - }`} + className={`text-xs text-${ + (showAfterFees ? position.pnl : position.pnl - fees) > 0 + ? 'green' + : 'redbright' + }`} /> - 0 - ? 'text-green' - : 'text-redbright')}>{")"} + 0 + ? 'text-green' + : 'text-redbright', + )} + > + {')'} +