Skip to content

Commit

Permalink
contrib: live position & oracle price updates v1
Browse files Browse the repository at this point in the history
  • Loading branch information
0xcryptovenom committed Nov 24, 2024
1 parent f3d6a1d commit f0423bb
Show file tree
Hide file tree
Showing 15 changed files with 511 additions and 203 deletions.
18 changes: 11 additions & 7 deletions src/AdrenaClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PositionExtended[]> {
const possiblePositionAddresses = this.getPossiblePositionAddresses(user);

public async loadUserPositions(
user: PublicKey,
positionAddresses?: Array<PublicKey>,
): Promise<PositionExtended[]> {
const actualPositionAddresses =
positionAddresses || this.getPossiblePositionAddresses(user);
const positions =
(await this.readonlyAdrenaProgram.account.position.fetchMultiple(
possiblePositionAddresses,
actualPositionAddresses,
'recent',
)) as (Position | null)[];

Expand All @@ -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[],
);
Expand Down
189 changes: 188 additions & 1 deletion src/actions/thunks.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
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 { selectStreamingTokenPrices } from '@/selectors/streamingTokenPrices';
import { selectTokenPrices } from '@/selectors/tokenPrices';
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';
Expand Down Expand Up @@ -69,3 +74,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);

// Calculate liquidation price
const liquidationPrice = window.adrena.client.calculateLiquidationPrice({
position,
});

if (liquidationPrice) {
return;
}
};

export const fetchUserPositions =
() => async (dispatch: Dispatch, getState: () => RootState) => {
const connection = window.adrena.client.connection;
const walletPublicKey = selectWalletPublicKey(getState());
const possibleUserPositions = selectPossibleUserPositions(getState());

if (
!connection ||
!walletPublicKey ||
!Array.isArray(possibleUserPositions)
) {
return null;
}

try {
const positions = await window.adrena.client.loadUserPositions(
walletPublicKey,
possibleUserPositions,
);

let tokenPrices = selectStreamingTokenPrices(getState());
// Fallback if Pyth price streaming is not active, ie: profile/dashboard page.
if (!tokenPrices) {
tokenPrices = selectTokenPrices(getState());
}
for (const position of positions) {
calculatePnLandLiquidationPrice(position, tokenPrices);
}

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.connection;
const walletPublicKey = selectWalletPublicKey(getState());
const possibleUserPositions = selectPossibleUserPositions(getState());

if (
!connection ||
!walletPublicKey ||
!Array.isArray(possibleUserPositions)
) {
return;
}

// Ensuring we only subscribe once to user position changes.
if (subscribed) {
return;
}

const subscriptions = new Map<number, PublicKey>();
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<Buffer>) => {
const userPosition = possibleUserPosition;

// Position got deleted
if (!accountInfo.data.length) {
removeSubscription(subscriptionId);
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, removing subscription',
err,
);
removeSubscription(subscriptionId);
}
},
);
subscriptions.set(subscriptionId, possibleUserPosition);
}

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) {
let tokenPrices = selectStreamingTokenPrices(getState());
// Fallback if Pyth price streaming is not active, ie: profile/dashboard page.
if (!tokenPrices) {
tokenPrices = selectTokenPrices(getState());
}
// PositionExtended objects are augmented in place.
calculatePnLandLiquidationPrice(position, tokenPrices);
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;
};
Loading

0 comments on commit f0423bb

Please sign in to comment.