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

@@ -75,4 +75,22 @@ public interface IEvmManager
/// <param name="chain">The blockchain chain</param> /// <param name="chain">The blockchain chain</param>
/// <param name="publicAddress">The public address</param> /// <param name="publicAddress">The public address</param>
void ClearBalancesCache(Chain chain, string publicAddress); void ClearBalancesCache(Chain chain, string publicAddress);
/// <summary>
/// Gets the position history for a specific ticker and account from GMX
/// </summary>
/// <param name="account">The trading account</param>
/// <param name="ticker">The ticker to get history for</param>
/// <param name="fromDate">Optional start date for filtering</param>
/// <param name="toDate">Optional end date for filtering</param>
/// <param name="pageIndex">Page index for pagination (default: 0)</param>
/// <param name="pageSize">Page size for pagination (default: 20)</param>
/// <returns>Position history response with actual GMX PnL data</returns>
Task<List<Position>> GetPositionHistory(
Account account,
Ticker ticker,
DateTime? fromDate = null,
DateTime? toDate = null,
int pageIndex = 0,
int pageSize = 20);
} }

View File

@@ -67,4 +67,10 @@ public interface IExchangeService
Task<Trade> GetTrade(string reference, string orderId, Ticker ticker); Task<Trade> GetTrade(string reference, string orderId, Ticker ticker);
Task<List<FundingRate>> GetFundingRates(); Task<List<FundingRate>> GetFundingRates();
Task<IEnumerable<Position>> GetBrokerPositions(Account account); Task<IEnumerable<Position>> GetBrokerPositions(Account account);
Task<List<Position>> GetPositionHistory(
Account account,
Ticker ticker,
DateTime? fromDate = null,
DateTime? toDate = null);
} }

View File

@@ -1,5 +1,6 @@
using Managing.Domain.Accounts; using Managing.Domain.Accounts;
using Managing.Domain.Evm; using Managing.Domain.Evm;
using Managing.Domain.Trades;
using static Managing.Common.Enums; using static Managing.Common.Enums;
namespace Managing.Application.Abstractions.Services namespace Managing.Application.Abstractions.Services
@@ -22,5 +23,7 @@ namespace Managing.Application.Abstractions.Services
Task<decimal> GetEstimatedGasFeeUsdAsync(); Task<decimal> GetEstimatedGasFeeUsdAsync();
Task<GasFeeData> GetGasFeeDataAsync(); Task<GasFeeData> GetGasFeeDataAsync();
Task<List<Position>> GetGmxPositionHistoryAsync(string account, int pageIndex = 0, int pageSize = 20, string? ticker = null);
} }
} }

View File

@@ -1196,6 +1196,122 @@ public class TradingBotBase : ITradingBot
: await exchangeService.GetCandle(Account, Config.Ticker, DateTime.UtcNow); : await exchangeService.GetCandle(Account, Config.Ticker, DateTime.UtcNow);
}); });
// For live trading on GMX, fetch the actual position history to get real PnL data
if (!Config.IsForBacktest)
{
try
{
Logger.LogInformation(
$"🔍 **Fetching Position History from GMX**\nPosition: `{position.Identifier}`\nTicker: `{Config.Ticker}`");
List<Position> positionHistory = null;
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory,
async exchangeService =>
{
// Get position history from the last 24 hours
var fromDate = DateTime.UtcNow.AddHours(-24);
var toDate = DateTime.UtcNow;
positionHistory =
await exchangeService.GetPositionHistory(Account, Config.Ticker, fromDate, toDate);
});
// Find the matching position in history based on the most recent closed position
if (positionHistory != null && positionHistory.Any())
{
// Get the most recent closed position from GMX
var gmxPosition = positionHistory.OrderByDescending(p => p.Open?.Date ?? DateTime.MinValue)
.FirstOrDefault();
if (gmxPosition != null && gmxPosition.ProfitAndLoss != null)
{
Logger.LogInformation(
$"✅ **GMX Position History Found**\n" +
$"Position: `{position.Identifier}`\n" +
$"GMX Realized PnL (after fees): `${gmxPosition.ProfitAndLoss.Realized:F2}`\n" +
$"Bot's UI Fees: `${position.UiFees:F2}`\n" +
$"Bot's Gas Fees: `${position.GasFees:F2}`");
// Use the actual GMX PnL data (this is already net of fees from GMX)
// We use this for reconciliation with the bot's own calculations
var totalBotFees = position.GasFees + position.UiFees;
var gmxNetPnl = gmxPosition.ProfitAndLoss.Realized; // This is already after GMX fees
position.ProfitAndLoss = new ProfitAndLoss
{
// GMX's realized PnL is already after their fees
Realized = gmxNetPnl,
// For net, we keep it the same since GMX PnL is already net of their fees
Net = gmxNetPnl - totalBotFees
};
// Update the closing trade price if available
if (gmxPosition.Open != null)
{
var closingPrice = gmxPosition.Open.Price;
// Determine which trade was the closing trade based on profitability
bool isProfitable = position.ProfitAndLoss.Realized > 0;
if (isProfitable)
{
if (position.TakeProfit1 != null)
{
position.TakeProfit1.SetPrice(closingPrice, 2);
position.TakeProfit1.SetDate(gmxPosition.Open.Date);
position.TakeProfit1.SetStatus(TradeStatus.Filled);
}
// Cancel SL trade when TP is hit
if (position.StopLoss != null)
{
position.StopLoss.SetStatus(TradeStatus.Cancelled);
}
}
else
{
if (position.StopLoss != null)
{
position.StopLoss.SetPrice(closingPrice, 2);
position.StopLoss.SetDate(gmxPosition.Open.Date);
position.StopLoss.SetStatus(TradeStatus.Filled);
}
// Cancel TP trades when SL is hit
if (position.TakeProfit1 != null)
{
position.TakeProfit1.SetStatus(TradeStatus.Cancelled);
}
if (position.TakeProfit2 != null)
{
position.TakeProfit2.SetStatus(TradeStatus.Cancelled);
}
}
Logger.LogInformation(
$"📊 **Position Reconciliation Complete**\n" +
$"Position: `{position.Identifier}`\n" +
$"Closing Price: `${closingPrice:F2}`\n" +
$"Used: `{(isProfitable ? "Take Profit" : "Stop Loss")}`\n" +
$"PnL from GMX: `${position.ProfitAndLoss.Realized:F2}`");
}
// Skip the candle-based PnL calculation since we have actual GMX data
goto SkipCandleBasedCalculation;
}
}
Logger.LogWarning(
$"⚠️ **No GMX Position History Found**\nPosition: `{position.Identifier}`\nFalling back to candle-based calculation");
}
catch (Exception ex)
{
Logger.LogError(ex,
"Error fetching position history from GMX for position {PositionId}. Falling back to candle-based calculation.",
position.Identifier);
}
}
if (currentCandle != null) if (currentCandle != null)
{ {
List<Candle> recentCandles = null; List<Candle> recentCandles = null;
@@ -1402,6 +1518,7 @@ public class TradingBotBase : ITradingBot
// No need to subtract fees from PnL as they're tracked separately // No need to subtract fees from PnL as they're tracked separately
} }
SkipCandleBasedCalculation:
await SetPositionStatus(position.SignalIdentifier, PositionStatus.Finished); await SetPositionStatus(position.SignalIdentifier, PositionStatus.Finished);
// Update position in database with all trade changes // Update position in database with all trade changes

View File

@@ -41,4 +41,10 @@ public interface IExchangeProcessor
Task<Trade> GetTrade(string reference, string orderId, Ticker ticker); Task<Trade> GetTrade(string reference, string orderId, Ticker ticker);
Task<List<FundingRate>> GetFundingRates(); Task<List<FundingRate>> GetFundingRates();
Task<IEnumerable<Position>> GetPositions(Account account); Task<IEnumerable<Position>> GetPositions(Account account);
Task<List<Position>> GetPositionHistory(
Account account,
Ticker ticker,
DateTime? fromDate = null,
DateTime? toDate = null);
} }

View File

@@ -209,6 +209,16 @@ namespace Managing.Infrastructure.Exchanges
return processor.GetPositions(account); return processor.GetPositions(account);
} }
public Task<List<Position>> GetPositionHistory(
Account account,
Ticker ticker,
DateTime? fromDate = null,
DateTime? toDate = null)
{
var processor = GetProcessor(account);
return processor.GetPositionHistory(account, ticker, fromDate, toDate);
}
public async Task<List<Trade>> GetTrades(Account account, Ticker ticker) public async Task<List<Trade>> GetTrades(Account account, Ticker ticker)
{ {
var processor = GetProcessor(account); var processor = GetProcessor(account);

View File

@@ -42,5 +42,11 @@ namespace Managing.Infrastructure.Exchanges.Exchanges
public abstract Task<Trade> GetTrade(string reference, string orderId, Ticker ticker); public abstract Task<Trade> GetTrade(string reference, string orderId, Ticker ticker);
public abstract Task<List<FundingRate>> GetFundingRates(); public abstract Task<List<FundingRate>> GetFundingRates();
public abstract Task<IEnumerable<Position>> GetPositions(Account account); public abstract Task<IEnumerable<Position>> GetPositions(Account account);
public abstract Task<List<Position>> GetPositionHistory(
Account account,
Ticker ticker,
DateTime? fromDate = null,
DateTime? toDate = null);
} }
} }

View File

@@ -208,6 +208,14 @@ public class EvmProcessor : BaseProcessor
return await _evmManager.GetOrders(account, ticker); return await _evmManager.GetOrders(account, ticker);
} }
public override async Task<List<Position>> GetPositionHistory(
Account account,
Ticker ticker,
DateTime? fromDate = null,
DateTime? toDate = null)
{
return await _evmManager.GetPositionHistory(account, ticker, fromDate, toDate);
}
#region Not implemented #region Not implemented

View File

@@ -961,4 +961,22 @@ public class EvmManager : IEvmManager
{ {
return await GetCandles(ticker, startDate, timeframe, false); return await GetCandles(ticker, startDate, timeframe, false);
} }
public async Task<List<Position>> GetPositionHistory(
Account account,
Ticker ticker,
DateTime? fromDate = null,
DateTime? toDate = null,
int pageIndex = 0,
int pageSize = 20)
{
var result = await _web3ProxyService.GetGmxPositionHistoryAsync(
account.Key,
pageIndex,
pageSize,
ticker.ToString());
// Map the result to the Position domain object
return result;
}
} }

View File

@@ -42,9 +42,21 @@ public class GmxPosition
[JsonProperty("collateral")] public double Collateral { get; set; } [JsonProperty("collateral")] public double Collateral { get; set; }
[JsonProperty("markPrice")] public double MarkPrice { get; set; } [JsonProperty("markPrice")] public double? MarkPrice { get; set; }
[JsonProperty("liquidationPrice")] public double LiquidationPrice { get; set; } [JsonProperty("liquidationPrice")] public double? LiquidationPrice { get; set; }
[JsonProperty("ProfitAndLoss")]
[JsonPropertyName("ProfitAndLoss")]
public GmxPositionProfitAndLoss ProfitAndLoss { get; set; }
[JsonProperty("UiFees")]
[JsonPropertyName("UiFees")]
public double? UiFees { get; set; }
[JsonProperty("GasFees")]
[JsonPropertyName("GasFees")]
public double? GasFees { get; set; }
[JsonProperty("StopLoss")] [JsonProperty("StopLoss")]
[JsonPropertyName("StopLoss")] [JsonPropertyName("StopLoss")]
@@ -64,6 +76,26 @@ public class GetGmxPositionsResponse : Web3ProxyBaseResponse
[JsonProperty("positions")] public List<GmxPosition> Positions { get; set; } [JsonProperty("positions")] public List<GmxPosition> Positions { get; set; }
} }
public class GetGmxPositionHistoryResponse : Web3ProxyResponse
{
[JsonProperty("positions")] public List<GmxPosition> Positions { get; set; }
[JsonProperty("pageIndex")] public int PageIndex { get; set; }
[JsonProperty("pageSize")] public int PageSize { get; set; }
[JsonProperty("count")] public int Count { get; set; }
}
public class GmxPositionProfitAndLoss
{
[JsonProperty("realized")] public double? Realized { get; set; }
[JsonProperty("net")] public double? Net { get; set; }
[JsonProperty("averageOpenPrice")] public double? AverageOpenPrice { get; set; }
}
public class StopLoss public class StopLoss
{ {
[JsonProperty("id")] public string Id { get; set; } [JsonProperty("id")] public string Id { get; set; }

View File

@@ -5,9 +5,11 @@ using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Web; using System.Web;
using Managing.Application.Abstractions.Services; using Managing.Application.Abstractions.Services;
using Managing.Core;
using Managing.Core.Exceptions; using Managing.Core.Exceptions;
using Managing.Domain.Accounts; using Managing.Domain.Accounts;
using Managing.Domain.Evm; using Managing.Domain.Evm;
using Managing.Domain.Trades;
using Managing.Infrastructure.Evm.Models.Proxy; using Managing.Infrastructure.Evm.Models.Proxy;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -164,7 +166,8 @@ namespace Managing.Infrastructure.Evm.Services
} }
} }
private async Task<T> ExecuteWithRetryAsync<T>(Func<Task<HttpResponseMessage>> httpCall, string operationName, string idempotencyKey) private async Task<T> ExecuteWithRetryAsync<T>(Func<Task<HttpResponseMessage>> httpCall, string operationName,
string idempotencyKey)
{ {
try try
{ {
@@ -185,7 +188,9 @@ namespace Managing.Infrastructure.Evm.Services
} }
catch (Exception ex) when (!(ex is Web3ProxyException)) catch (Exception ex) when (!(ex is Web3ProxyException))
{ {
_logger.LogError(ex, "Operation {OperationName} failed after all retry attempts (IdempotencyKey: {IdempotencyKey})", operationName, idempotencyKey); _logger.LogError(ex,
"Operation {OperationName} failed after all retry attempts (IdempotencyKey: {IdempotencyKey})",
operationName, idempotencyKey);
SentrySdk.CaptureException(ex); SentrySdk.CaptureException(ex);
throw new Web3ProxyException($"Failed to execute {operationName}: {ex.Message}"); throw new Web3ProxyException($"Failed to execute {operationName}: {ex.Message}");
} }
@@ -202,7 +207,8 @@ namespace Managing.Infrastructure.Evm.Services
var idempotencyKey = Guid.NewGuid().ToString(); var idempotencyKey = Guid.NewGuid().ToString();
return await ExecuteWithRetryAsync<T>( return await ExecuteWithRetryAsync<T>(
() => { () =>
{
var request = new HttpRequestMessage(HttpMethod.Post, url) var request = new HttpRequestMessage(HttpMethod.Post, url)
{ {
Content = JsonContent.Create(payload, options: _jsonOptions) Content = JsonContent.Create(payload, options: _jsonOptions)
@@ -244,7 +250,8 @@ namespace Managing.Infrastructure.Evm.Services
var idempotencyKey = Guid.NewGuid().ToString(); var idempotencyKey = Guid.NewGuid().ToString();
return await ExecuteWithRetryAsync<T>( return await ExecuteWithRetryAsync<T>(
() => { () =>
{
var request = new HttpRequestMessage(HttpMethod.Post, url) var request = new HttpRequestMessage(HttpMethod.Post, url)
{ {
Content = JsonContent.Create(payload, options: _jsonOptions) Content = JsonContent.Create(payload, options: _jsonOptions)
@@ -477,7 +484,8 @@ namespace Managing.Infrastructure.Evm.Services
if (IsInsufficientFundsError(structuredErrorResponse.ErrorDetails.Message)) if (IsInsufficientFundsError(structuredErrorResponse.ErrorDetails.Message))
{ {
var errorType = GetInsufficientFundsType(structuredErrorResponse.ErrorDetails.Message); var errorType = GetInsufficientFundsType(structuredErrorResponse.ErrorDetails.Message);
throw new InsufficientFundsException(structuredErrorResponse.ErrorDetails.Message, errorType); throw new InsufficientFundsException(structuredErrorResponse.ErrorDetails.Message,
errorType);
} }
throw new Web3ProxyException(structuredErrorResponse.ErrorDetails); throw new Web3ProxyException(structuredErrorResponse.ErrorDetails);
@@ -580,5 +588,107 @@ namespace Managing.Infrastructure.Evm.Services
return queryString.ToString(); return queryString.ToString();
} }
public async Task<List<Position>> GetGmxPositionHistoryAsync(string account, int pageIndex = 0,
int pageSize = 20, string? ticker = null)
{
var payload = new
{
account,
pageIndex,
pageSize,
ticker
};
var response = await GetGmxServiceAsync<GetGmxPositionHistoryResponse>("/position-history", payload);
if (response == null)
{
throw new Web3ProxyException("Position history response is null");
}
if (!response.Success)
{
throw new Web3ProxyException($"Position history request failed: {response.Error}");
}
// Map to domain Positions manually
var positions = new List<Position>();
if (response.Positions != null)
{
foreach (var g in response.Positions)
{
try
{
var tickerEnum = MiscExtensions.ParseEnum<Ticker>(g.Ticker);
var directionEnum = MiscExtensions.ParseEnum<TradeDirection>(g.Direction);
var position = new Position(
Guid.NewGuid(),
0, // AccountId unknown at this layer
directionEnum,
tickerEnum,
null, // MoneyManagement not available here
PositionInitiator.Bot,
g.Date,
null // User not available here
);
position.Status = MiscExtensions.ParseEnum<PositionStatus>(g.Status);
// Open trade represents the closing event from GMX history
if (g.Open != null)
{
position.Open = new Trade(
g.Open.Date,
MiscExtensions.ParseEnum<TradeDirection>(g.Open.Direction),
MiscExtensions.ParseEnum<TradeStatus>(g.Open.Status),
TradeType.Market,
tickerEnum,
(decimal)g.Open.Quantity,
(decimal)g.Open.Price,
(decimal)g.Open.Leverage,
g.Open.ExchangeOrderId,
string.Empty
);
}
else
{
position.Open = new Trade(
g.Date,
directionEnum,
TradeStatus.Filled,
TradeType.Market,
tickerEnum,
(decimal)g.Quantity,
(decimal)g.Price,
(decimal)g.Leverage,
g.ExchangeOrderId,
"GMX Position Close"
);
}
position.ProfitAndLoss = new ProfitAndLoss
{
Realized = g.ProfitAndLoss?.Realized != null
? (decimal)g.ProfitAndLoss.Realized
: (decimal)g.Pnl,
Net = g.ProfitAndLoss?.Net != null ? (decimal)g.ProfitAndLoss.Net : (decimal)g.Pnl
};
position.UiFees = g.UiFees.HasValue ? (decimal)g.UiFees.Value : 0;
position.GasFees = g.GasFees.HasValue ? (decimal)g.GasFees.Value : 0;
positions.Add(position);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to map GMX position history entry");
}
}
}
return positions;
}
} }
} }

View File

@@ -440,6 +440,7 @@ declare module 'fastify' {
getGmxPositions: typeof getGmxPositions; getGmxPositions: typeof getGmxPositions;
swapGmxTokens: typeof swapGmxTokens; swapGmxTokens: typeof swapGmxTokens;
estimatePositionGasFee: typeof estimatePositionGasFee; estimatePositionGasFee: typeof estimatePositionGasFee;
getPositionHistory: typeof getPositionHistory;
} }
} }
@@ -499,6 +500,14 @@ const swapTokensSchema = z.object({
path: ["toTicker"] 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 * Creates a GMX SDK client with the specified RPC URL
* @param account The wallet address to use * @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 * Implementation function to get positions on GMX with fallback RPC support
* @param sdk The GMX SDK client * @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 // Helper to pre-populate and refresh the markets cache
async function getMarketsData() { async function getMarketsData() {
// Use a dummy zero address for the account // Use a dummy zero address for the account
@@ -1410,6 +1664,7 @@ export default fp(async (fastify) => {
fastify.decorateRequest('closeGmxPosition', closeGmxPosition) fastify.decorateRequest('closeGmxPosition', closeGmxPosition)
fastify.decorateRequest('getGmxTrade', getGmxTrade) fastify.decorateRequest('getGmxTrade', getGmxTrade)
fastify.decorateRequest('getGmxPositions', getGmxPositions) fastify.decorateRequest('getGmxPositions', getGmxPositions)
fastify.decorateRequest('getPositionHistory', getPositionHistory)
fastify.decorateRequest('getGmxRebateStats', getGmxRebateStats) fastify.decorateRequest('getGmxRebateStats', getGmxRebateStats)
fastify.decorateRequest('getClaimableFundingFees', getClaimableFundingFees) fastify.decorateRequest('getClaimableFundingFees', getClaimableFundingFees)
fastify.decorateRequest('claimGmxFundingFees', claimGmxFundingFees) 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 // Define route to get gas fee estimation for opening a position
fastify.get('/gas-fee', { fastify.get('/gas-fee', {
schema: { 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')
})
})
})