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')
+ })
+ })
+})
+