Fetch closed position to get last pnl realized

This commit is contained in:
2025-10-05 23:31:17 +07:00
parent 1b060fb145
commit dac0a9641f
14 changed files with 749 additions and 23 deletions

View File

@@ -440,6 +440,7 @@ declare module 'fastify' {
getGmxPositions: typeof getGmxPositions;
swapGmxTokens: typeof swapGmxTokens;
estimatePositionGasFee: typeof estimatePositionGasFee;
getPositionHistory: typeof getPositionHistory;
}
}
@@ -499,6 +500,14 @@ const swapTokensSchema = z.object({
path: ["toTicker"]
});
// Schema for get-position-history request
const getPositionHistorySchema = z.object({
account: z.string().nonempty(),
pageIndex: z.number().int().min(0).default(0),
pageSize: z.number().int().min(1).max(100).default(20),
ticker: z.string().optional()
});
/**
* Creates a GMX SDK client with the specified RPC URL
* @param account The wallet address to use
@@ -1152,6 +1161,199 @@ export async function getGmxTrade(
}
}
/**
* Implementation function to get position history on GMX
* This returns closed positions with ACTUAL PnL data from GMX trade history
* The most critical data is the PnL after fees which is used for reconciliation
*
* @param sdk The GMX SDK client
* @param pageIndex The page index for pagination
* @param pageSize The number of items per page
* @param fromTimestamp Optional start timestamp (in seconds)
* @param toTimestamp Optional end timestamp (in seconds)
* @param ticker Optional ticker filter
* @returns Array of historical positions with actual PnL from GMX
*/
export const getPositionHistoryImpl = async (
sdk: GmxSdk,
pageIndex: number = 0,
pageSize: number = 20,
ticker?: string
): Promise<Position[]> => {
return executeWithFallback(
async (sdk, retryCount) => {
// Fetch market info and tokens data for trade history
const {marketsInfoData, tokensData} = await getMarketsInfoWithCache(sdk);
if (!marketsInfoData || !tokensData) {
throw new Error("No markets or tokens info data");
}
// Fetch trade history from SDK - we'll filter for close events after fetching
const tradeActions = await sdk.trades.getTradeHistory({
pageIndex,
pageSize,
fromTxTimestamp: undefined,
toTxTimestamp: undefined,
marketsInfoData,
tokensData,
marketsDirectionsFilter: undefined,
forAllAccounts: false
});
console.log(`📊 Fetched ${tradeActions.length} trade actions from history`);
// Filter for position decrease events which contain the actual PnL data
const closeEvents = tradeActions.filter(action => {
if (!('orderType' in action)) return false;
const decreaseOrderTypes = [
OrderType.MarketDecrease,
OrderType.LimitDecrease,
OrderType.StopLossDecrease,
OrderType.Liquidation
];
return action.eventName === 'OrderExecuted' &&
decreaseOrderTypes.includes(action.orderType);
});
console.log(closeEvents);
console.log(`📉 Found ${closeEvents.length} position close events (filtered from ${tradeActions.length} total actions)`);
// Transform close events into Position objects with actual PnL
let positions: Position[] = [];
for (const action of closeEvents) {
// Only process position actions (not swaps)
if (!('marketInfo' in action) || !action.marketInfo) {
continue;
}
const marketAddress = action.marketAddress;
const isLong = 'isLong' in action ? action.isLong : false;
const ticker = action.marketInfo.indexToken.symbol;
// Get market info for price calculations
const market = marketsInfoData[marketAddress];
if (!market) {
continue;
}
const priceDecimals = calculateDisplayDecimals(
market.indexToken.prices?.minPrice,
undefined,
market.indexToken.visualMultiplier
);
// CRITICAL: Extract ACTUAL PnL from GMX
// pnlUsd contains the realized PnL before fees
// positionFeeAmount, borrowingFeeAmount, fundingFeeAmount are the fees
const pnlUsd = action.pnlUsd ? Number(action.pnlUsd) / 1e30 : 0;
const basePnlUsd = action.basePnlUsd ? Number(action.basePnlUsd) / 1e30 : 0;
// Extract all fees
const positionFee = action.positionFeeAmount ? Number(action.positionFeeAmount) / 1e30 : 0;
const borrowingFee = action.borrowingFeeAmount ? Number(action.borrowingFeeAmount) / 1e30 : 0;
const fundingFee = action.fundingFeeAmount ? Number(action.fundingFeeAmount) / 1e30 : 0;
// Total fees in USD
const totalFeesUsd = positionFee + borrowingFee + Math.abs(fundingFee);
// PnL after fees (this is what matters for reconciliation)
const pnlAfterFees = pnlUsd; // GMX's pnlUsd is already after fees
// Extract execution price
const executionPrice = action.executionPrice || 0n;
const displayPrice = formatUsd(executionPrice, {
displayDecimals: priceDecimals,
visualMultiplier: market.indexToken?.visualMultiplier,
});
const closePrice = Number(displayPrice.replace(/[^0-9.]/g, ''));
// Extract size and collateral
const sizeDeltaUsd = action.sizeDeltaUsd || 0n;
const sizeInTokens = Number(sizeDeltaUsd) / Number(executionPrice);
const quantity = sizeInTokens * 1e-30;
const initialCollateral = action.initialCollateralDeltaAmount || 0n;
const collateral = Number(initialCollateral) / 1e6; // USDC has 6 decimals
// Calculate leverage from size and collateral
let leverage = 2; // Default
if (collateral > 0) {
const size = Number(sizeDeltaUsd) / 1e30;
leverage = Math.round(size / collateral);
}
// Build minimal trade object for the close
const closeTrade: Trade = {
ticker: ticker as any,
direction: isLong ? TradeDirection.Long : TradeDirection.Short,
price: closePrice,
quantity: quantity,
leverage: leverage,
status: TradeStatus.Filled,
tradeType: action.orderType === OrderType.MarketDecrease ? TradeType.Market :
action.orderType === OrderType.StopLossDecrease ? TradeType.StopLoss :
TradeType.Limit,
date: new Date(('timestamp' in action ? action.timestamp : 0) * 1000),
exchangeOrderId: action.orderKey || action.id,
fee: totalFeesUsd,
message: `Closed via ${action.orderType}`
};
// Build position object with ACTUAL GMX PnL data
const position: Position = {
ticker: ticker as any,
direction: isLong ? TradeDirection.Long : TradeDirection.Short,
price: closePrice,
quantity: quantity,
leverage: leverage,
status: PositionStatus.Finished,
tradeType: TradeType.Market,
date: new Date(('timestamp' in action ? action.timestamp : 0) * 1000),
exchangeOrderId: action.transaction.hash || action.id,
pnl: pnlAfterFees, // This is the ACTUAL PnL from GMX after fees
collateral: collateral,
// Store the actual PnL data for reconciliation
ProfitAndLoss: {
realized: pnlAfterFees, // Actual realized PnL from GMX after fees
net: pnlAfterFees, // Net is the same as realized for closed positions
averageOpenPrice: undefined
},
// Store fees separately
UiFees: positionFee,
GasFees: borrowingFee + Math.abs(fundingFee),
// The close trade
Open: closeTrade // Using close trade as Open for simplicity
} as any;
positions.push(position);
console.log(`📈 Position ${action.transaction.hash || action.id}:`, {
ticker,
direction: isLong ? 'LONG' : 'SHORT',
pnlUsd: pnlUsd.toFixed(2),
basePnlUsd: basePnlUsd.toFixed(2),
pnlAfterFees: pnlAfterFees.toFixed(2),
totalFees: totalFeesUsd.toFixed(2),
closePrice: closePrice.toFixed(2)
});
}
console.log(`✅ Returned ${positions.length} closed positions with actual GMX PnL data`);
// Apply ticker filter server-side to reduce payload
if (ticker) {
positions = positions.filter(p => p.ticker === (ticker as any));
}
return positions;
}, sdk, 0
);
};
/**
* Implementation function to get positions on GMX with fallback RPC support
* @param sdk The GMX SDK client
@@ -1378,6 +1580,58 @@ export async function getGmxPositions(
}
}
/**
* Gets position history on GMX
* @param this The FastifyRequest instance
* @param reply The FastifyReply instance
* @param account The wallet address of the user
* @param pageIndex The page index for pagination (default: 0)
* @param pageSize The number of items per page (default: 20)
* @param fromTimestamp Optional start timestamp in seconds
* @param toTimestamp Optional end timestamp in seconds
* @param ticker Optional ticker filter
* @returns The response object with success status and positions array
*/
export async function getPositionHistory(
this: FastifyRequest,
reply: FastifyReply,
account: string,
pageIndex?: number,
pageSize?: number,
ticker?: string
) {
try {
// Validate the request parameters
getPositionHistorySchema.parse({
account,
pageIndex: pageIndex ?? 0,
pageSize: pageSize ?? 20,
ticker
});
// Get client for the address
const sdk = await this.getClientForAddress(account);
// Call the implementation function
const positions = await getPositionHistoryImpl(
sdk,
pageIndex ?? 0,
pageSize ?? 20,
ticker
);
return {
success: true,
positions,
pageIndex: pageIndex ?? 0,
pageSize: pageSize ?? 20,
count: positions.length
};
} catch (error) {
return handleError(this, reply, error, 'gmx/position-history');
}
}
// Helper to pre-populate and refresh the markets cache
async function getMarketsData() {
// Use a dummy zero address for the account
@@ -1410,6 +1664,7 @@ export default fp(async (fastify) => {
fastify.decorateRequest('closeGmxPosition', closeGmxPosition)
fastify.decorateRequest('getGmxTrade', getGmxTrade)
fastify.decorateRequest('getGmxPositions', getGmxPositions)
fastify.decorateRequest('getPositionHistory', getPositionHistory)
fastify.decorateRequest('getGmxRebateStats', getGmxRebateStats)
fastify.decorateRequest('getClaimableFundingFees', getClaimableFundingFees)
fastify.decorateRequest('claimGmxFundingFees', claimGmxFundingFees)

View File

@@ -198,6 +198,38 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
)
})
// Define route to get position history
fastify.get('/position-history', {
schema: {
querystring: Type.Object({
account: Type.String(),
pageIndex: Type.Optional(Type.Integer()),
pageSize: Type.Optional(Type.Integer()),
ticker: Type.Optional(Type.String())
}),
response: {
200: Type.Object({
success: Type.Boolean(),
positions: Type.Optional(Type.Array(Type.Any())),
pageIndex: Type.Optional(Type.Integer()),
pageSize: Type.Optional(Type.Integer()),
count: Type.Optional(Type.Integer()),
error: Type.Optional(Type.String())
})
}
}
}, async (request, reply) => {
const { account, pageIndex, pageSize, ticker } = request.query
return request.getPositionHistory(
reply,
account,
pageIndex,
pageSize,
ticker
)
})
// Define route to get gas fee estimation for opening a position
fastify.get('/gas-fee', {
schema: {

View File

@@ -0,0 +1,105 @@
import {test} from 'node:test'
import assert from 'node:assert'
import {getClientForAddress, getPositionHistoryImpl} from '../../src/plugins/custom/gmx.js'
test('GMX get position history - Closed positions with actual PnL', async (t) => {
await t.test('should get closed positions with actual GMX PnL data', async () => {
const sdk = await getClientForAddress('0x932167388dD9aad41149b3cA23eBD489E2E2DD78')
const result = await getPositionHistoryImpl(
sdk,
0, // pageIndex
50 // pageSize
)
console.log('\n📊 Closed Positions Summary:')
console.log(`Total closed positions: ${result.length}`)
if (result.length > 0) {
console.log('\n💰 PnL Details:')
result.forEach((position: any, index) => {
console.log(`\n--- Position ${index + 1} ---`)
console.log(`Ticker: ${position.ticker}`)
console.log(`Direction: ${position.direction}`)
console.log(`Close Price: $${position.price?.toFixed(2) || 'N/A'}`)
console.log(`Quantity: ${position.quantity?.toFixed(4) || 'N/A'}`)
console.log(`Leverage: ${position.leverage}x`)
console.log(`Status: ${position.status}`)
console.log(`PnL After Fees: $${position.pnl?.toFixed(2) || 'N/A'}`)
console.log(`UI Fees: $${position.UiFees?.toFixed(2) || 'N/A'}`)
console.log(`Gas Fees: $${position.GasFees?.toFixed(2) || 'N/A'}`)
if (position.ProfitAndLoss) {
console.log(`Realized PnL: $${position.ProfitAndLoss.realized?.toFixed(2) || 'N/A'}`)
console.log(`Net PnL: $${position.ProfitAndLoss.net?.toFixed(2) || 'N/A'}`)
}
// Verify critical data for reconciliation
assert.ok(position.ProfitAndLoss, 'Position should have ProfitAndLoss data')
assert.ok(typeof position.ProfitAndLoss.realized === 'number', 'Realized PnL should be a number')
assert.ok(typeof position.pnl === 'number', 'Position pnl should be a number')
})
// Calculate total PnL
const totalPnL = result.reduce((sum: number, pos: any) => sum + (pos.pnl || 0), 0)
console.log(`\n💵 Total PnL from all closed positions: $${totalPnL.toFixed(2)}`)
}
assert.ok(result, 'Position history result should be defined')
assert.ok(Array.isArray(result), 'Position history should be an array')
})
await t.test('should get closed positions with date range', async () => {
const sdk = await getClientForAddress('0x932167388dD9aad41149b3cA23eBD489E2E2DD78')
// Get positions from the last 7 days
const toTimestamp = Math.floor(Date.now() / 1000)
const fromTimestamp = toTimestamp - (7 * 24 * 60 * 60) // 7 days ago
const result = await getPositionHistoryImpl(
sdk,
0,
10,
fromTimestamp,
toTimestamp
)
console.log(`\n📅 Closed positions in last 7 days: ${result.length}`)
// Verify all positions are within date range
result.forEach(position => {
const positionDate = new Date(position.date)
const isInRange = positionDate.getTime() >= fromTimestamp * 1000 &&
positionDate.getTime() <= toTimestamp * 1000
assert.ok(isInRange, `Position date ${positionDate} should be within range`)
})
assert.ok(result, 'Position history result should be defined')
assert.ok(Array.isArray(result), 'Position history should be an array')
})
await t.test('should verify PnL data is suitable for reconciliation', async () => {
const sdk = await getClientForAddress('0x932167388dD9aad41149b3cA23eBD489E2E2DD78')
const result = await getPositionHistoryImpl(sdk, 0, 5)
console.log('\n🔍 Reconciliation Data Verification:')
result.forEach((position: any, index) => {
console.log(`\nPosition ${index + 1}:`)
console.log(` Has ProfitAndLoss: ${!!position.ProfitAndLoss}`)
console.log(` Has realized PnL: ${typeof position.ProfitAndLoss?.realized === 'number'}`)
console.log(` Realized value: ${position.ProfitAndLoss?.realized}`)
console.log(` Has fees: UI=${position.UiFees}, Gas=${position.GasFees}`)
// These are the critical fields needed for HandleClosedPosition reconciliation
assert.ok(position.ProfitAndLoss, 'Must have ProfitAndLoss for reconciliation')
assert.ok(
typeof position.ProfitAndLoss.realized === 'number',
'Must have numeric realized PnL for reconciliation'
)
assert.ok(position.status === 'Finished', 'Position should be finished')
})
})
})