From dac0a9641f3a2ae4f9cab75200bf450ea776c0f1 Mon Sep 17 00:00:00 2001 From: cryptooda Date: Sun, 5 Oct 2025 23:31:17 +0700 Subject: [PATCH] Fetch closed position to get last pnl realized --- .../Repositories/IEvmManager.cs | 18 ++ .../Services/IExchangeService.cs | 6 + .../Services/IWeb3ProxyService.cs | 3 + .../Bots/TradingBotBase.cs | 117 ++++++++ .../Abstractions/IExchangeProcessor.cs | 6 + .../ExchangeService.cs | 10 + .../Exchanges/BaseProcessor.cs | 6 + .../Exchanges/EvmProcessor.cs | 8 + .../EvmManager.cs | 18 ++ .../Models/Proxy/GetGmxPositionsResponse.cs | 36 ++- .../Services/Web3ProxyService.cs | 152 +++++++++-- .../src/plugins/custom/gmx.ts | 255 ++++++++++++++++++ .../src/routes/api/gmx/index.ts | 32 +++ .../test/plugins/get-position-history.test.ts | 105 ++++++++ 14 files changed, 749 insertions(+), 23 deletions(-) create mode 100644 src/Managing.Web3Proxy/test/plugins/get-position-history.test.ts diff --git a/src/Managing.Application.Abstractions/Repositories/IEvmManager.cs b/src/Managing.Application.Abstractions/Repositories/IEvmManager.cs index 8a627c02..dbbdd2f7 100644 --- a/src/Managing.Application.Abstractions/Repositories/IEvmManager.cs +++ b/src/Managing.Application.Abstractions/Repositories/IEvmManager.cs @@ -75,4 +75,22 @@ public interface IEvmManager /// The blockchain chain /// The public address void ClearBalancesCache(Chain chain, string publicAddress); + + /// + /// Gets the position history for a specific ticker and account from GMX + /// + /// The trading account + /// The ticker to get history for + /// Optional start date for filtering + /// Optional end date for filtering + /// Page index for pagination (default: 0) + /// Page size for pagination (default: 20) + /// Position history response with actual GMX PnL data + Task> GetPositionHistory( + Account account, + Ticker ticker, + DateTime? fromDate = null, + DateTime? toDate = null, + int pageIndex = 0, + int pageSize = 20); } \ No newline at end of file diff --git a/src/Managing.Application.Abstractions/Services/IExchangeService.cs b/src/Managing.Application.Abstractions/Services/IExchangeService.cs index 892a6dd0..5ae71ed1 100644 --- a/src/Managing.Application.Abstractions/Services/IExchangeService.cs +++ b/src/Managing.Application.Abstractions/Services/IExchangeService.cs @@ -67,4 +67,10 @@ public interface IExchangeService Task GetTrade(string reference, string orderId, Ticker ticker); Task> GetFundingRates(); Task> GetBrokerPositions(Account account); + + Task> GetPositionHistory( + Account account, + Ticker ticker, + DateTime? fromDate = null, + DateTime? toDate = null); } \ No newline at end of file diff --git a/src/Managing.Application.Abstractions/Services/IWeb3ProxyService.cs b/src/Managing.Application.Abstractions/Services/IWeb3ProxyService.cs index 2a778e9e..e3a8da29 100644 --- a/src/Managing.Application.Abstractions/Services/IWeb3ProxyService.cs +++ b/src/Managing.Application.Abstractions/Services/IWeb3ProxyService.cs @@ -1,5 +1,6 @@ using Managing.Domain.Accounts; using Managing.Domain.Evm; +using Managing.Domain.Trades; using static Managing.Common.Enums; namespace Managing.Application.Abstractions.Services @@ -22,5 +23,7 @@ namespace Managing.Application.Abstractions.Services Task GetEstimatedGasFeeUsdAsync(); Task GetGasFeeDataAsync(); + + Task> GetGmxPositionHistoryAsync(string account, int pageIndex = 0, int pageSize = 20, string? ticker = null); } } \ No newline at end of file diff --git a/src/Managing.Application/Bots/TradingBotBase.cs b/src/Managing.Application/Bots/TradingBotBase.cs index c4044e58..88f57704 100644 --- a/src/Managing.Application/Bots/TradingBotBase.cs +++ b/src/Managing.Application/Bots/TradingBotBase.cs @@ -1196,6 +1196,122 @@ public class TradingBotBase : ITradingBot : 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 positionHistory = null; + await ServiceScopeHelpers.WithScopedService(_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) { List recentCandles = null; @@ -1402,6 +1518,7 @@ public class TradingBotBase : ITradingBot // No need to subtract fees from PnL as they're tracked separately } + SkipCandleBasedCalculation: await SetPositionStatus(position.SignalIdentifier, PositionStatus.Finished); // Update position in database with all trade changes diff --git a/src/Managing.Infrastructure.Exchanges/Abstractions/IExchangeProcessor.cs b/src/Managing.Infrastructure.Exchanges/Abstractions/IExchangeProcessor.cs index b84a4127..cf30a793 100644 --- a/src/Managing.Infrastructure.Exchanges/Abstractions/IExchangeProcessor.cs +++ b/src/Managing.Infrastructure.Exchanges/Abstractions/IExchangeProcessor.cs @@ -41,4 +41,10 @@ public interface IExchangeProcessor Task GetTrade(string reference, string orderId, Ticker ticker); Task> GetFundingRates(); Task> GetPositions(Account account); + + Task> GetPositionHistory( + Account account, + Ticker ticker, + DateTime? fromDate = null, + DateTime? toDate = null); } diff --git a/src/Managing.Infrastructure.Exchanges/ExchangeService.cs b/src/Managing.Infrastructure.Exchanges/ExchangeService.cs index 0fea82ee..5324d18c 100644 --- a/src/Managing.Infrastructure.Exchanges/ExchangeService.cs +++ b/src/Managing.Infrastructure.Exchanges/ExchangeService.cs @@ -209,6 +209,16 @@ namespace Managing.Infrastructure.Exchanges return processor.GetPositions(account); } + public Task> 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> GetTrades(Account account, Ticker ticker) { var processor = GetProcessor(account); diff --git a/src/Managing.Infrastructure.Exchanges/Exchanges/BaseProcessor.cs b/src/Managing.Infrastructure.Exchanges/Exchanges/BaseProcessor.cs index 83367ff7..2c490b7e 100644 --- a/src/Managing.Infrastructure.Exchanges/Exchanges/BaseProcessor.cs +++ b/src/Managing.Infrastructure.Exchanges/Exchanges/BaseProcessor.cs @@ -42,5 +42,11 @@ namespace Managing.Infrastructure.Exchanges.Exchanges public abstract Task GetTrade(string reference, string orderId, Ticker ticker); public abstract Task> GetFundingRates(); public abstract Task> GetPositions(Account account); + + public abstract Task> GetPositionHistory( + Account account, + Ticker ticker, + DateTime? fromDate = null, + DateTime? toDate = null); } } diff --git a/src/Managing.Infrastructure.Exchanges/Exchanges/EvmProcessor.cs b/src/Managing.Infrastructure.Exchanges/Exchanges/EvmProcessor.cs index 2717ba70..2238ab41 100644 --- a/src/Managing.Infrastructure.Exchanges/Exchanges/EvmProcessor.cs +++ b/src/Managing.Infrastructure.Exchanges/Exchanges/EvmProcessor.cs @@ -208,6 +208,14 @@ public class EvmProcessor : BaseProcessor return await _evmManager.GetOrders(account, ticker); } + public override async Task> GetPositionHistory( + Account account, + Ticker ticker, + DateTime? fromDate = null, + DateTime? toDate = null) + { + return await _evmManager.GetPositionHistory(account, ticker, fromDate, toDate); + } #region Not implemented diff --git a/src/Managing.Infrastructure.Web3/EvmManager.cs b/src/Managing.Infrastructure.Web3/EvmManager.cs index 92075411..57538308 100644 --- a/src/Managing.Infrastructure.Web3/EvmManager.cs +++ b/src/Managing.Infrastructure.Web3/EvmManager.cs @@ -961,4 +961,22 @@ public class EvmManager : IEvmManager { return await GetCandles(ticker, startDate, timeframe, false); } + + public async Task> 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; + } } \ No newline at end of file diff --git a/src/Managing.Infrastructure.Web3/Models/Proxy/GetGmxPositionsResponse.cs b/src/Managing.Infrastructure.Web3/Models/Proxy/GetGmxPositionsResponse.cs index 33d10723..0ab8181d 100644 --- a/src/Managing.Infrastructure.Web3/Models/Proxy/GetGmxPositionsResponse.cs +++ b/src/Managing.Infrastructure.Web3/Models/Proxy/GetGmxPositionsResponse.cs @@ -42,9 +42,21 @@ public class GmxPosition [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")] [JsonPropertyName("StopLoss")] @@ -64,6 +76,26 @@ public class GetGmxPositionsResponse : Web3ProxyBaseResponse [JsonProperty("positions")] public List Positions { get; set; } } +public class GetGmxPositionHistoryResponse : Web3ProxyResponse +{ + [JsonProperty("positions")] public List 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 { [JsonProperty("id")] public string Id { get; set; } diff --git a/src/Managing.Infrastructure.Web3/Services/Web3ProxyService.cs b/src/Managing.Infrastructure.Web3/Services/Web3ProxyService.cs index d7ec03b3..95da35f6 100644 --- a/src/Managing.Infrastructure.Web3/Services/Web3ProxyService.cs +++ b/src/Managing.Infrastructure.Web3/Services/Web3ProxyService.cs @@ -5,9 +5,11 @@ using System.Text; using System.Text.Json; using System.Web; using Managing.Application.Abstractions.Services; +using Managing.Core; using Managing.Core.Exceptions; using Managing.Domain.Accounts; using Managing.Domain.Evm; +using Managing.Domain.Trades; using Managing.Infrastructure.Evm.Models.Proxy; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -36,10 +38,10 @@ namespace Managing.Infrastructure.Evm.Services { _settings = options.Value; _logger = logger; - + _httpClient = new HttpClient(); _httpClient.Timeout = TimeSpan.FromSeconds(_settings.TimeoutSeconds); - + _jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase @@ -62,7 +64,7 @@ namespace Managing.Infrastructure.Evm.Services var exception = outcome.Exception; var response = outcome.Result; var errorMessage = exception?.Message ?? $"HTTP {response?.StatusCode}"; - + _logger.LogWarning( "Web3Proxy request failed (attempt {RetryCount}/{MaxRetries}): {Error}. Retrying in {Delay}ms", retryCount, _settings.MaxRetryAttempts + 1, errorMessage, timespan.TotalMilliseconds); @@ -90,12 +92,12 @@ namespace Managing.Infrastructure.Evm.Services return false; var lowerError = errorMessage.ToLowerInvariant(); - + // Check for common insufficient funds/allowance error patterns return lowerError.Contains("erc20: transfer amount exceeds allowance") || lowerError.Contains("insufficient funds") || lowerError.Contains("insufficient balance") || - lowerError.Contains("execution reverted") && + lowerError.Contains("execution reverted") && (lowerError.Contains("allowance") || lowerError.Contains("balance")) || lowerError.Contains("gas required exceeds allowance") || lowerError.Contains("out of gas") || @@ -115,20 +117,20 @@ namespace Managing.Infrastructure.Evm.Services var lowerError = errorMessage.ToLowerInvariant(); - if (lowerError.Contains("erc20: transfer amount exceeds allowance") || + if (lowerError.Contains("erc20: transfer amount exceeds allowance") || lowerError.Contains("allowance")) { return InsufficientFundsType.InsufficientAllowance; } - if (lowerError.Contains("gas required exceeds allowance") || - lowerError.Contains("out of gas") || + if (lowerError.Contains("gas required exceeds allowance") || + lowerError.Contains("out of gas") || lowerError.Contains("insufficient eth")) { return InsufficientFundsType.InsufficientEth; } - if (lowerError.Contains("insufficient balance") || + if (lowerError.Contains("insufficient balance") || lowerError.Contains("insufficient token")) { return InsufficientFundsType.InsufficientBalance; @@ -142,7 +144,7 @@ namespace Managing.Infrastructure.Evm.Services try { var response = await _retryPolicy.ExecuteAsync(httpCall); - + if (!response.IsSuccessStatusCode) { await HandleErrorResponse(response); @@ -164,12 +166,13 @@ namespace Managing.Infrastructure.Evm.Services } } - private async Task ExecuteWithRetryAsync(Func> httpCall, string operationName, string idempotencyKey) + private async Task ExecuteWithRetryAsync(Func> httpCall, string operationName, + string idempotencyKey) { try { var response = await _retryPolicy.ExecuteAsync(httpCall); - + if (!response.IsSuccessStatusCode) { await HandleErrorResponse(response); @@ -185,7 +188,9 @@ namespace Managing.Infrastructure.Evm.Services } 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); throw new Web3ProxyException($"Failed to execute {operationName}: {ex.Message}"); } @@ -202,7 +207,8 @@ namespace Managing.Infrastructure.Evm.Services var idempotencyKey = Guid.NewGuid().ToString(); return await ExecuteWithRetryAsync( - () => { + () => + { var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(payload, options: _jsonOptions) @@ -244,7 +250,8 @@ namespace Managing.Infrastructure.Evm.Services var idempotencyKey = Guid.NewGuid().ToString(); return await ExecuteWithRetryAsync( - () => { + () => + { var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(payload, options: _jsonOptions) @@ -472,14 +479,15 @@ namespace Managing.Infrastructure.Evm.Services if (structuredErrorResponse?.ErrorDetails != null) { structuredErrorResponse.ErrorDetails.StatusCode = statusCode; - + // Check if this is an insufficient funds error that should not be retried if (IsInsufficientFundsError(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); } } @@ -496,7 +504,7 @@ namespace Managing.Infrastructure.Evm.Services var errorType = GetInsufficientFundsType(content); throw new InsufficientFundsException(content, errorType); } - + // If we couldn't parse as structured error, use the simple error or fallback throw new Web3ProxyException($"HTTP error {statusCode}: {content}"); } @@ -511,14 +519,14 @@ namespace Managing.Infrastructure.Evm.Services SentrySdk.CaptureException(ex); // If we couldn't parse the error as JSON or another issue occurred var content = await response.Content.ReadAsStringAsync(); - + // Check if the raw content contains insufficient funds errors if (IsInsufficientFundsError(content)) { var errorType = GetInsufficientFundsType(content); throw new InsufficientFundsException(content, errorType); } - + throw new Web3ProxyException($"HTTP error {statusCode}: {content}"); } } @@ -580,5 +588,107 @@ namespace Managing.Infrastructure.Evm.Services return queryString.ToString(); } + + public async Task> GetGmxPositionHistoryAsync(string account, int pageIndex = 0, + int pageSize = 20, string? ticker = null) + { + var payload = new + { + account, + pageIndex, + pageSize, + ticker + }; + + var response = await GetGmxServiceAsync("/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(); + if (response.Positions != null) + { + foreach (var g in response.Positions) + { + try + { + var tickerEnum = MiscExtensions.ParseEnum(g.Ticker); + var directionEnum = MiscExtensions.ParseEnum(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(g.Status); + + // Open trade represents the closing event from GMX history + if (g.Open != null) + { + position.Open = new Trade( + g.Open.Date, + MiscExtensions.ParseEnum(g.Open.Direction), + MiscExtensions.ParseEnum(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; + } } } \ No newline at end of file diff --git a/src/Managing.Web3Proxy/src/plugins/custom/gmx.ts b/src/Managing.Web3Proxy/src/plugins/custom/gmx.ts index a5ff11b8..8f839de6 100644 --- a/src/Managing.Web3Proxy/src/plugins/custom/gmx.ts +++ b/src/Managing.Web3Proxy/src/plugins/custom/gmx.ts @@ -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 => { + 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) diff --git a/src/Managing.Web3Proxy/src/routes/api/gmx/index.ts b/src/Managing.Web3Proxy/src/routes/api/gmx/index.ts index 6f7613ab..bd547706 100644 --- a/src/Managing.Web3Proxy/src/routes/api/gmx/index.ts +++ b/src/Managing.Web3Proxy/src/routes/api/gmx/index.ts @@ -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: { diff --git a/src/Managing.Web3Proxy/test/plugins/get-position-history.test.ts b/src/Managing.Web3Proxy/test/plugins/get-position-history.test.ts new file mode 100644 index 00000000..d60a36ce --- /dev/null +++ b/src/Managing.Web3Proxy/test/plugins/get-position-history.test.ts @@ -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') + }) + }) +}) +