Fetch closed position to get last pnl realized
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
105
src/Managing.Web3Proxy/test/plugins/get-position-history.test.ts
Normal file
105
src/Managing.Web3Proxy/test/plugins/get-position-history.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user