Fetch closed position to get last pnl realized
This commit is contained in:
@@ -75,4 +75,22 @@ public interface IEvmManager
|
||||
/// <param name="chain">The blockchain chain</param>
|
||||
/// <param name="publicAddress">The public address</param>
|
||||
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);
|
||||
}
|
||||
@@ -67,4 +67,10 @@ public interface IExchangeService
|
||||
Task<Trade> GetTrade(string reference, string orderId, Ticker ticker);
|
||||
Task<List<FundingRate>> GetFundingRates();
|
||||
Task<IEnumerable<Position>> GetBrokerPositions(Account account);
|
||||
|
||||
Task<List<Position>> GetPositionHistory(
|
||||
Account account,
|
||||
Ticker ticker,
|
||||
DateTime? fromDate = null,
|
||||
DateTime? toDate = null);
|
||||
}
|
||||
@@ -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<decimal> GetEstimatedGasFeeUsdAsync();
|
||||
|
||||
Task<GasFeeData> GetGasFeeDataAsync();
|
||||
|
||||
Task<List<Position>> GetGmxPositionHistoryAsync(string account, int pageIndex = 0, int pageSize = 20, string? ticker = null);
|
||||
}
|
||||
}
|
||||
@@ -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<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)
|
||||
{
|
||||
List<Candle> 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
|
||||
|
||||
@@ -41,4 +41,10 @@ public interface IExchangeProcessor
|
||||
Task<Trade> GetTrade(string reference, string orderId, Ticker ticker);
|
||||
Task<List<FundingRate>> GetFundingRates();
|
||||
Task<IEnumerable<Position>> GetPositions(Account account);
|
||||
|
||||
Task<List<Position>> GetPositionHistory(
|
||||
Account account,
|
||||
Ticker ticker,
|
||||
DateTime? fromDate = null,
|
||||
DateTime? toDate = null);
|
||||
}
|
||||
|
||||
@@ -209,6 +209,16 @@ namespace Managing.Infrastructure.Exchanges
|
||||
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)
|
||||
{
|
||||
var processor = GetProcessor(account);
|
||||
|
||||
@@ -42,5 +42,11 @@ namespace Managing.Infrastructure.Exchanges.Exchanges
|
||||
public abstract Task<Trade> GetTrade(string reference, string orderId, Ticker ticker);
|
||||
public abstract Task<List<FundingRate>> GetFundingRates();
|
||||
public abstract Task<IEnumerable<Position>> GetPositions(Account account);
|
||||
|
||||
public abstract Task<List<Position>> GetPositionHistory(
|
||||
Account account,
|
||||
Ticker ticker,
|
||||
DateTime? fromDate = null,
|
||||
DateTime? toDate = null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +208,14 @@ public class EvmProcessor : BaseProcessor
|
||||
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
|
||||
|
||||
|
||||
@@ -961,4 +961,22 @@ public class EvmManager : IEvmManager
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<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
|
||||
{
|
||||
[JsonProperty("id")] public string Id { get; set; }
|
||||
|
||||
@@ -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<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
|
||||
{
|
||||
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<T>(
|
||||
() => {
|
||||
() =>
|
||||
{
|
||||
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<T>(
|
||||
() => {
|
||||
() =>
|
||||
{
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -440,6 +440,7 @@ declare module 'fastify' {
|
||||
getGmxPositions: typeof getGmxPositions;
|
||||
swapGmxTokens: typeof swapGmxTokens;
|
||||
estimatePositionGasFee: typeof estimatePositionGasFee;
|
||||
getPositionHistory: typeof getPositionHistory;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,6 +500,14 @@ const swapTokensSchema = z.object({
|
||||
path: ["toTicker"]
|
||||
});
|
||||
|
||||
// Schema for get-position-history request
|
||||
const getPositionHistorySchema = z.object({
|
||||
account: z.string().nonempty(),
|
||||
pageIndex: z.number().int().min(0).default(0),
|
||||
pageSize: z.number().int().min(1).max(100).default(20),
|
||||
ticker: z.string().optional()
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a GMX SDK client with the specified RPC URL
|
||||
* @param account The wallet address to use
|
||||
@@ -1152,6 +1161,199 @@ export async function getGmxTrade(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation function to get position history on GMX
|
||||
* This returns closed positions with ACTUAL PnL data from GMX trade history
|
||||
* The most critical data is the PnL after fees which is used for reconciliation
|
||||
*
|
||||
* @param sdk The GMX SDK client
|
||||
* @param pageIndex The page index for pagination
|
||||
* @param pageSize The number of items per page
|
||||
* @param fromTimestamp Optional start timestamp (in seconds)
|
||||
* @param toTimestamp Optional end timestamp (in seconds)
|
||||
* @param ticker Optional ticker filter
|
||||
* @returns Array of historical positions with actual PnL from GMX
|
||||
*/
|
||||
export const getPositionHistoryImpl = async (
|
||||
sdk: GmxSdk,
|
||||
pageIndex: number = 0,
|
||||
pageSize: number = 20,
|
||||
ticker?: string
|
||||
): Promise<Position[]> => {
|
||||
return executeWithFallback(
|
||||
async (sdk, retryCount) => {
|
||||
// Fetch market info and tokens data for trade history
|
||||
const {marketsInfoData, tokensData} = await getMarketsInfoWithCache(sdk);
|
||||
|
||||
if (!marketsInfoData || !tokensData) {
|
||||
throw new Error("No markets or tokens info data");
|
||||
}
|
||||
|
||||
// Fetch trade history from SDK - we'll filter for close events after fetching
|
||||
const tradeActions = await sdk.trades.getTradeHistory({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
fromTxTimestamp: undefined,
|
||||
toTxTimestamp: undefined,
|
||||
marketsInfoData,
|
||||
tokensData,
|
||||
marketsDirectionsFilter: undefined,
|
||||
forAllAccounts: false
|
||||
});
|
||||
|
||||
console.log(`📊 Fetched ${tradeActions.length} trade actions from history`);
|
||||
|
||||
// Filter for position decrease events which contain the actual PnL data
|
||||
const closeEvents = tradeActions.filter(action => {
|
||||
if (!('orderType' in action)) return false;
|
||||
|
||||
const decreaseOrderTypes = [
|
||||
OrderType.MarketDecrease,
|
||||
OrderType.LimitDecrease,
|
||||
OrderType.StopLossDecrease,
|
||||
OrderType.Liquidation
|
||||
];
|
||||
|
||||
return action.eventName === 'OrderExecuted' &&
|
||||
decreaseOrderTypes.includes(action.orderType);
|
||||
});
|
||||
|
||||
console.log(closeEvents);
|
||||
|
||||
console.log(`📉 Found ${closeEvents.length} position close events (filtered from ${tradeActions.length} total actions)`);
|
||||
|
||||
// Transform close events into Position objects with actual PnL
|
||||
let positions: Position[] = [];
|
||||
|
||||
for (const action of closeEvents) {
|
||||
// Only process position actions (not swaps)
|
||||
if (!('marketInfo' in action) || !action.marketInfo) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const marketAddress = action.marketAddress;
|
||||
const isLong = 'isLong' in action ? action.isLong : false;
|
||||
const ticker = action.marketInfo.indexToken.symbol;
|
||||
|
||||
// Get market info for price calculations
|
||||
const market = marketsInfoData[marketAddress];
|
||||
if (!market) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const priceDecimals = calculateDisplayDecimals(
|
||||
market.indexToken.prices?.minPrice,
|
||||
undefined,
|
||||
market.indexToken.visualMultiplier
|
||||
);
|
||||
|
||||
// CRITICAL: Extract ACTUAL PnL from GMX
|
||||
// pnlUsd contains the realized PnL before fees
|
||||
// positionFeeAmount, borrowingFeeAmount, fundingFeeAmount are the fees
|
||||
const pnlUsd = action.pnlUsd ? Number(action.pnlUsd) / 1e30 : 0;
|
||||
const basePnlUsd = action.basePnlUsd ? Number(action.basePnlUsd) / 1e30 : 0;
|
||||
|
||||
// Extract all fees
|
||||
const positionFee = action.positionFeeAmount ? Number(action.positionFeeAmount) / 1e30 : 0;
|
||||
const borrowingFee = action.borrowingFeeAmount ? Number(action.borrowingFeeAmount) / 1e30 : 0;
|
||||
const fundingFee = action.fundingFeeAmount ? Number(action.fundingFeeAmount) / 1e30 : 0;
|
||||
|
||||
// Total fees in USD
|
||||
const totalFeesUsd = positionFee + borrowingFee + Math.abs(fundingFee);
|
||||
|
||||
// PnL after fees (this is what matters for reconciliation)
|
||||
const pnlAfterFees = pnlUsd; // GMX's pnlUsd is already after fees
|
||||
|
||||
// Extract execution price
|
||||
const executionPrice = action.executionPrice || 0n;
|
||||
const displayPrice = formatUsd(executionPrice, {
|
||||
displayDecimals: priceDecimals,
|
||||
visualMultiplier: market.indexToken?.visualMultiplier,
|
||||
});
|
||||
const closePrice = Number(displayPrice.replace(/[^0-9.]/g, ''));
|
||||
|
||||
// Extract size and collateral
|
||||
const sizeDeltaUsd = action.sizeDeltaUsd || 0n;
|
||||
const sizeInTokens = Number(sizeDeltaUsd) / Number(executionPrice);
|
||||
const quantity = sizeInTokens * 1e-30;
|
||||
|
||||
const initialCollateral = action.initialCollateralDeltaAmount || 0n;
|
||||
const collateral = Number(initialCollateral) / 1e6; // USDC has 6 decimals
|
||||
|
||||
// Calculate leverage from size and collateral
|
||||
let leverage = 2; // Default
|
||||
if (collateral > 0) {
|
||||
const size = Number(sizeDeltaUsd) / 1e30;
|
||||
leverage = Math.round(size / collateral);
|
||||
}
|
||||
|
||||
// Build minimal trade object for the close
|
||||
const closeTrade: Trade = {
|
||||
ticker: ticker as any,
|
||||
direction: isLong ? TradeDirection.Long : TradeDirection.Short,
|
||||
price: closePrice,
|
||||
quantity: quantity,
|
||||
leverage: leverage,
|
||||
status: TradeStatus.Filled,
|
||||
tradeType: action.orderType === OrderType.MarketDecrease ? TradeType.Market :
|
||||
action.orderType === OrderType.StopLossDecrease ? TradeType.StopLoss :
|
||||
TradeType.Limit,
|
||||
date: new Date(('timestamp' in action ? action.timestamp : 0) * 1000),
|
||||
exchangeOrderId: action.orderKey || action.id,
|
||||
fee: totalFeesUsd,
|
||||
message: `Closed via ${action.orderType}`
|
||||
};
|
||||
|
||||
// Build position object with ACTUAL GMX PnL data
|
||||
const position: Position = {
|
||||
ticker: ticker as any,
|
||||
direction: isLong ? TradeDirection.Long : TradeDirection.Short,
|
||||
price: closePrice,
|
||||
quantity: quantity,
|
||||
leverage: leverage,
|
||||
status: PositionStatus.Finished,
|
||||
tradeType: TradeType.Market,
|
||||
date: new Date(('timestamp' in action ? action.timestamp : 0) * 1000),
|
||||
exchangeOrderId: action.transaction.hash || action.id,
|
||||
pnl: pnlAfterFees, // This is the ACTUAL PnL from GMX after fees
|
||||
collateral: collateral,
|
||||
// Store the actual PnL data for reconciliation
|
||||
ProfitAndLoss: {
|
||||
realized: pnlAfterFees, // Actual realized PnL from GMX after fees
|
||||
net: pnlAfterFees, // Net is the same as realized for closed positions
|
||||
averageOpenPrice: undefined
|
||||
},
|
||||
// Store fees separately
|
||||
UiFees: positionFee,
|
||||
GasFees: borrowingFee + Math.abs(fundingFee),
|
||||
// The close trade
|
||||
Open: closeTrade // Using close trade as Open for simplicity
|
||||
} as any;
|
||||
|
||||
positions.push(position);
|
||||
|
||||
console.log(`📈 Position ${action.transaction.hash || action.id}:`, {
|
||||
ticker,
|
||||
direction: isLong ? 'LONG' : 'SHORT',
|
||||
pnlUsd: pnlUsd.toFixed(2),
|
||||
basePnlUsd: basePnlUsd.toFixed(2),
|
||||
pnlAfterFees: pnlAfterFees.toFixed(2),
|
||||
totalFees: totalFeesUsd.toFixed(2),
|
||||
closePrice: closePrice.toFixed(2)
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`✅ Returned ${positions.length} closed positions with actual GMX PnL data`);
|
||||
// Apply ticker filter server-side to reduce payload
|
||||
if (ticker) {
|
||||
positions = positions.filter(p => p.ticker === (ticker as any));
|
||||
}
|
||||
|
||||
return positions;
|
||||
}, sdk, 0
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Implementation function to get positions on GMX with fallback RPC support
|
||||
* @param sdk The GMX SDK client
|
||||
@@ -1378,6 +1580,58 @@ export async function getGmxPositions(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets position history on GMX
|
||||
* @param this The FastifyRequest instance
|
||||
* @param reply The FastifyReply instance
|
||||
* @param account The wallet address of the user
|
||||
* @param pageIndex The page index for pagination (default: 0)
|
||||
* @param pageSize The number of items per page (default: 20)
|
||||
* @param fromTimestamp Optional start timestamp in seconds
|
||||
* @param toTimestamp Optional end timestamp in seconds
|
||||
* @param ticker Optional ticker filter
|
||||
* @returns The response object with success status and positions array
|
||||
*/
|
||||
export async function getPositionHistory(
|
||||
this: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
account: string,
|
||||
pageIndex?: number,
|
||||
pageSize?: number,
|
||||
ticker?: string
|
||||
) {
|
||||
try {
|
||||
// Validate the request parameters
|
||||
getPositionHistorySchema.parse({
|
||||
account,
|
||||
pageIndex: pageIndex ?? 0,
|
||||
pageSize: pageSize ?? 20,
|
||||
ticker
|
||||
});
|
||||
|
||||
// Get client for the address
|
||||
const sdk = await this.getClientForAddress(account);
|
||||
|
||||
// Call the implementation function
|
||||
const positions = await getPositionHistoryImpl(
|
||||
sdk,
|
||||
pageIndex ?? 0,
|
||||
pageSize ?? 20,
|
||||
ticker
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
positions,
|
||||
pageIndex: pageIndex ?? 0,
|
||||
pageSize: pageSize ?? 20,
|
||||
count: positions.length
|
||||
};
|
||||
} catch (error) {
|
||||
return handleError(this, reply, error, 'gmx/position-history');
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to pre-populate and refresh the markets cache
|
||||
async function getMarketsData() {
|
||||
// Use a dummy zero address for the account
|
||||
@@ -1410,6 +1664,7 @@ export default fp(async (fastify) => {
|
||||
fastify.decorateRequest('closeGmxPosition', closeGmxPosition)
|
||||
fastify.decorateRequest('getGmxTrade', getGmxTrade)
|
||||
fastify.decorateRequest('getGmxPositions', getGmxPositions)
|
||||
fastify.decorateRequest('getPositionHistory', getPositionHistory)
|
||||
fastify.decorateRequest('getGmxRebateStats', getGmxRebateStats)
|
||||
fastify.decorateRequest('getClaimableFundingFees', getClaimableFundingFees)
|
||||
fastify.decorateRequest('claimGmxFundingFees', claimGmxFundingFees)
|
||||
|
||||
@@ -198,6 +198,38 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
|
||||
)
|
||||
})
|
||||
|
||||
// Define route to get position history
|
||||
fastify.get('/position-history', {
|
||||
schema: {
|
||||
querystring: Type.Object({
|
||||
account: Type.String(),
|
||||
pageIndex: Type.Optional(Type.Integer()),
|
||||
pageSize: Type.Optional(Type.Integer()),
|
||||
ticker: Type.Optional(Type.String())
|
||||
}),
|
||||
response: {
|
||||
200: Type.Object({
|
||||
success: Type.Boolean(),
|
||||
positions: Type.Optional(Type.Array(Type.Any())),
|
||||
pageIndex: Type.Optional(Type.Integer()),
|
||||
pageSize: Type.Optional(Type.Integer()),
|
||||
count: Type.Optional(Type.Integer()),
|
||||
error: Type.Optional(Type.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
const { account, pageIndex, pageSize, ticker } = request.query
|
||||
|
||||
return request.getPositionHistory(
|
||||
reply,
|
||||
account,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
ticker
|
||||
)
|
||||
})
|
||||
|
||||
// Define route to get gas fee estimation for opening a position
|
||||
fastify.get('/gas-fee', {
|
||||
schema: {
|
||||
|
||||
105
src/Managing.Web3Proxy/test/plugins/get-position-history.test.ts
Normal file
105
src/Managing.Web3Proxy/test/plugins/get-position-history.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import {test} from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import {getClientForAddress, getPositionHistoryImpl} from '../../src/plugins/custom/gmx.js'
|
||||
|
||||
test('GMX get position history - Closed positions with actual PnL', async (t) => {
|
||||
await t.test('should get closed positions with actual GMX PnL data', async () => {
|
||||
const sdk = await getClientForAddress('0x932167388dD9aad41149b3cA23eBD489E2E2DD78')
|
||||
|
||||
const result = await getPositionHistoryImpl(
|
||||
sdk,
|
||||
0, // pageIndex
|
||||
50 // pageSize
|
||||
)
|
||||
|
||||
console.log('\n📊 Closed Positions Summary:')
|
||||
console.log(`Total closed positions: ${result.length}`)
|
||||
|
||||
if (result.length > 0) {
|
||||
console.log('\n💰 PnL Details:')
|
||||
result.forEach((position: any, index) => {
|
||||
console.log(`\n--- Position ${index + 1} ---`)
|
||||
console.log(`Ticker: ${position.ticker}`)
|
||||
console.log(`Direction: ${position.direction}`)
|
||||
console.log(`Close Price: $${position.price?.toFixed(2) || 'N/A'}`)
|
||||
console.log(`Quantity: ${position.quantity?.toFixed(4) || 'N/A'}`)
|
||||
console.log(`Leverage: ${position.leverage}x`)
|
||||
console.log(`Status: ${position.status}`)
|
||||
console.log(`PnL After Fees: $${position.pnl?.toFixed(2) || 'N/A'}`)
|
||||
console.log(`UI Fees: $${position.UiFees?.toFixed(2) || 'N/A'}`)
|
||||
console.log(`Gas Fees: $${position.GasFees?.toFixed(2) || 'N/A'}`)
|
||||
|
||||
if (position.ProfitAndLoss) {
|
||||
console.log(`Realized PnL: $${position.ProfitAndLoss.realized?.toFixed(2) || 'N/A'}`)
|
||||
console.log(`Net PnL: $${position.ProfitAndLoss.net?.toFixed(2) || 'N/A'}`)
|
||||
}
|
||||
|
||||
// Verify critical data for reconciliation
|
||||
assert.ok(position.ProfitAndLoss, 'Position should have ProfitAndLoss data')
|
||||
assert.ok(typeof position.ProfitAndLoss.realized === 'number', 'Realized PnL should be a number')
|
||||
assert.ok(typeof position.pnl === 'number', 'Position pnl should be a number')
|
||||
})
|
||||
|
||||
// Calculate total PnL
|
||||
const totalPnL = result.reduce((sum: number, pos: any) => sum + (pos.pnl || 0), 0)
|
||||
console.log(`\n💵 Total PnL from all closed positions: $${totalPnL.toFixed(2)}`)
|
||||
}
|
||||
|
||||
assert.ok(result, 'Position history result should be defined')
|
||||
assert.ok(Array.isArray(result), 'Position history should be an array')
|
||||
})
|
||||
|
||||
await t.test('should get closed positions with date range', async () => {
|
||||
const sdk = await getClientForAddress('0x932167388dD9aad41149b3cA23eBD489E2E2DD78')
|
||||
|
||||
// Get positions from the last 7 days
|
||||
const toTimestamp = Math.floor(Date.now() / 1000)
|
||||
const fromTimestamp = toTimestamp - (7 * 24 * 60 * 60) // 7 days ago
|
||||
|
||||
const result = await getPositionHistoryImpl(
|
||||
sdk,
|
||||
0,
|
||||
10,
|
||||
fromTimestamp,
|
||||
toTimestamp
|
||||
)
|
||||
|
||||
console.log(`\n📅 Closed positions in last 7 days: ${result.length}`)
|
||||
|
||||
// Verify all positions are within date range
|
||||
result.forEach(position => {
|
||||
const positionDate = new Date(position.date)
|
||||
const isInRange = positionDate.getTime() >= fromTimestamp * 1000 &&
|
||||
positionDate.getTime() <= toTimestamp * 1000
|
||||
assert.ok(isInRange, `Position date ${positionDate} should be within range`)
|
||||
})
|
||||
|
||||
assert.ok(result, 'Position history result should be defined')
|
||||
assert.ok(Array.isArray(result), 'Position history should be an array')
|
||||
})
|
||||
|
||||
await t.test('should verify PnL data is suitable for reconciliation', async () => {
|
||||
const sdk = await getClientForAddress('0x932167388dD9aad41149b3cA23eBD489E2E2DD78')
|
||||
|
||||
const result = await getPositionHistoryImpl(sdk, 0, 5)
|
||||
|
||||
console.log('\n🔍 Reconciliation Data Verification:')
|
||||
|
||||
result.forEach((position: any, index) => {
|
||||
console.log(`\nPosition ${index + 1}:`)
|
||||
console.log(` Has ProfitAndLoss: ${!!position.ProfitAndLoss}`)
|
||||
console.log(` Has realized PnL: ${typeof position.ProfitAndLoss?.realized === 'number'}`)
|
||||
console.log(` Realized value: ${position.ProfitAndLoss?.realized}`)
|
||||
console.log(` Has fees: UI=${position.UiFees}, Gas=${position.GasFees}`)
|
||||
|
||||
// These are the critical fields needed for HandleClosedPosition reconciliation
|
||||
assert.ok(position.ProfitAndLoss, 'Must have ProfitAndLoss for reconciliation')
|
||||
assert.ok(
|
||||
typeof position.ProfitAndLoss.realized === 'number',
|
||||
'Must have numeric realized PnL for reconciliation'
|
||||
)
|
||||
assert.ok(position.status === 'Finished', 'Position should be finished')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user