From efbb116ed2bebcaf5119e15828efa7eff14793be Mon Sep 17 00:00:00 2001 From: cryptooda Date: Tue, 6 Jan 2026 00:13:21 +0700 Subject: [PATCH] Enhance SpotBot position management and logging - Introduced logic to check if the opening swap was canceled by the broker, marking positions as canceled when necessary. - Adjusted orphaned balance thresholds for ETH and other tokens to improve balance management. - Enhanced logging to provide detailed information on swap status, including warnings for canceled swaps and their implications on position management. - Added a new method to verify swap execution status, improving the robustness of position handling in SpotBot. --- src/Managing.Application/Bots/SpotBot.cs | 172 +++++++++++++++++- .../plugins/get-spot-position-history.test.ts | 115 +++++++++++- 2 files changed, 281 insertions(+), 6 deletions(-) diff --git a/src/Managing.Application/Bots/SpotBot.cs b/src/Managing.Application/Bots/SpotBot.cs index f0708946..6088c0e7 100644 --- a/src/Managing.Application/Bots/SpotBot.cs +++ b/src/Managing.Application/Bots/SpotBot.cs @@ -183,8 +183,11 @@ public class SpotBot : TradingBotBase if (tokenBalance is { Amount: > 0 }) { // Check if this is a meaningful balance or just gas reserves / dust - // Minimum threshold: $10 USD value to be considered an orphaned position - const decimal minOrphanedBalanceValue = 10m; + // For ETH, use a higher threshold since gas reserves are expected to be significant + // For other tokens, use a lower threshold + decimal minOrphanedBalanceValue = Config.Ticker == Ticker.ETH + ? 100m // ETH: $100 threshold (gas reserves can be $20-50+, so this is safe) + : 10m; // Other tokens: $10 threshold if (tokenBalance.Value < minOrphanedBalanceValue) { @@ -194,6 +197,7 @@ public class SpotBot : TradingBotBase $"Token balance: `{tokenBalance.Amount:F8}`\n" + $"USD Value: `${tokenBalance.Value:F2}`\n" + $"Below orphaned threshold of `${minOrphanedBalanceValue:F2}`\n" + + $"{(Config.Ticker == Ticker.ETH ? "(ETH gas reserve - expected behavior)" : "(Dust amount)")}\n" + $"Ignoring - safe to open new position"); return true; // Safe to open new position - this is just dust/gas reserve } @@ -403,6 +407,39 @@ public class SpotBot : TradingBotBase // For spot trading, fetch token balance directly and verify/match with internal position try { + // First, check if the opening swap was canceled by the broker + // This prevents confusing warning messages about token balance mismatches + if (internalPosition.Status == PositionStatus.New) + { + var swapWasCanceled = await CheckIfOpeningSwapWasCanceled(internalPosition); + if (swapWasCanceled) + { + // Mark position as Canceled + var previousStatus = internalPosition.Status; + internalPosition.Status = PositionStatus.Canceled; + internalPosition.Open.SetStatus(TradeStatus.Cancelled); + positionForSignal.Open.SetStatus(TradeStatus.Cancelled); + await SetPositionStatus(internalPosition.SignalIdentifier, PositionStatus.Canceled); + + await UpdatePositionInDatabaseAsync(internalPosition); + + await LogWarningAsync( + $"āŒ Position Opening Failed - Swap Canceled by Broker\n" + + $"Position: `{internalPosition.Identifier}`\n" + + $"Signal: `{internalPosition.SignalIdentifier}`\n" + + $"Ticker: {Config.Ticker}\n" + + $"Expected Quantity: `{internalPosition.Open.Quantity:F5}`\n" + + $"Status Changed: `{previousStatus}` → `Canceled`\n" + + $"The opening swap (USDC → {Config.Ticker}) was canceled by the ExchangeRouter contract\n" + + $"Position will not be tracked or managed"); + + // Notify about the canceled position (using PositionClosed as PositionCanceled doesn't exist) + await NotifyAgentAndPlatformAsync(NotificationEventType.PositionClosed, internalPosition); + + return; // Exit - no further synchronization needed + } + } + var tokenBalance = await ServiceScopeHelpers.WithScopedService( _scopeFactory, async exchangeService => await exchangeService.GetBalance(Account, Config.Ticker)); @@ -632,13 +669,142 @@ public class SpotBot : TradingBotBase } } + private async Task CheckIfOpeningSwapWasCanceled(Position position) + { + try + { + // Only check for canceled swaps if position is in New status + // (positions that haven't been filled yet) + if (position.Status != PositionStatus.New) + { + return false; + } + + await LogDebugAsync( + $"šŸ” Checking for Canceled Opening Swap\n" + + $"Position: `{position.Identifier}`\n" + + $"Ticker: `{Config.Ticker}`"); + + // Get swap history from exchange to check for canceled orders + // We need to check if the opening swap (USDC -> ETH for LONG) was canceled + var positionHistory = await ServiceScopeHelpers.WithScopedService>( + _scopeFactory, + async exchangeService => + { + // Check swaps from 1 hour before position date to now + // This covers the time window when the swap could have been canceled + var fromDate = position.Date.AddHours(-1); + var toDate = DateTime.UtcNow; + return await exchangeService.GetSpotPositionHistory(Account, Config.Ticker, fromDate, toDate); + }); + + if (positionHistory == null || positionHistory.Count == 0) + { + // No history found - swap might still be pending + return false; + } + + // For a LONG position, the opening swap should be LONG (USDC -> Token) + // If we find a LONG swap around the position creation time, check if it was actually executed + var openingSwaps = positionHistory + .Where(p => p.OriginDirection == TradeDirection.Long && + Math.Abs((p.Date - position.Date).TotalMinutes) < 10) // Within 10 minutes of position creation + .OrderBy(p => Math.Abs((p.Date - position.Date).TotalSeconds)) + .ToList(); + + if (openingSwaps.Any()) + { + // We found swap(s) around the position creation time + // If the quantity matches our expected position quantity, this is our opening swap + var matchingSwap = openingSwaps.FirstOrDefault(swap => + Math.Abs(swap.Open.Quantity - position.Open.Quantity) / position.Open.Quantity < 0.02m); // Within 2% tolerance + + if (matchingSwap != null) + { + // Found the matching opening swap - it was executed successfully + await LogDebugAsync( + $"āœ… Opening Swap Found and Executed\n" + + $"Position: `{position.Identifier}`\n" + + $"Swap Quantity: `{matchingSwap.Open.Quantity:F5}`\n" + + $"Expected Quantity: `{position.Open.Quantity:F5}`\n" + + $"Swap was successful"); + return false; // Swap was not canceled + } + } + + // If we reach here, we didn't find a matching executed swap + // This likely means the swap was canceled by the broker + // Double-check by verifying the token balance is significantly lower than expected + var tokenBalance = await ServiceScopeHelpers.WithScopedService( + _scopeFactory, + async exchangeService => await exchangeService.GetBalance(Account, Config.Ticker)); + + if (tokenBalance != null && position.Open.Quantity > 0) + { + var tolerance = position.Open.Quantity * 0.10m; // 10% tolerance + var difference = position.Open.Quantity - tokenBalance.Amount; + + if (difference > tolerance) + { + // Token balance is significantly lower than expected position quantity + // This confirms the swap was likely canceled + await LogWarningAsync( + $"āŒ Opening Swap Appears to be Canceled by Broker\n" + + $"Position: `{position.Identifier}`\n" + + $"Expected Quantity: `{position.Open.Quantity:F5}`\n" + + $"Actual Token Balance: `{tokenBalance.Amount:F5}`\n" + + $"Difference: `{difference:F5}` (exceeds 10% tolerance)\n" + + $"No matching executed swap found in history\n" + + $"Position will be marked as Canceled"); + return true; // Swap was canceled + } + } + + return false; // Swap status unclear - don't assume it was canceled + } + catch (Exception ex) + { + Logger.LogError(ex, "Error checking if opening swap was canceled for position {PositionId}", position.Identifier); + await LogWarningAsync( + $"āš ļø Error Checking for Canceled Swap\n" + + $"Position: `{position.Identifier}`\n" + + $"Error: {ex.Message}"); + return false; // On error, don't assume swap was canceled + } + } + protected override async Task HandleOrderManagementAndPositionStatus(LightSignal signal, Position internalPosition, Position positionForSignal) { // Spot trading doesn't use orders like futures - positions are opened via swaps - // Just check if the swap was successful + // Check if the opening swap was successful or canceled if (internalPosition.Status == PositionStatus.New) { + // First, check if the opening swap was canceled by the broker + var swapWasCanceled = await CheckIfOpeningSwapWasCanceled(internalPosition); + if (swapWasCanceled) + { + // Mark position as Canceled + internalPosition.Status = PositionStatus.Canceled; + internalPosition.Open.SetStatus(TradeStatus.Cancelled); + positionForSignal.Open.SetStatus(TradeStatus.Cancelled); + await SetPositionStatus(signal.Identifier, PositionStatus.Canceled); + + await UpdatePositionInDatabaseAsync(internalPosition); + + await LogWarningAsync( + $"āŒ Position Opening Failed - Swap Canceled by Broker\n" + + $"Position: `{internalPosition.Identifier}`\n" + + $"Signal: `{signal.Identifier}`\n" + + $"The opening swap was canceled by the ExchangeRouter contract\n" + + $"Position status set to Canceled"); + + // Notify about the canceled position (using PositionClosed as PositionCanceled doesn't exist) + await NotifyAgentAndPlatformAsync(NotificationEventType.PositionClosed, internalPosition); + + return; // Exit - position is canceled + } + // Check if swap was successful by verifying position status // For spot, if Open trade is Filled, the position is filled if (positionForSignal.Open?.Status == TradeStatus.Filled) diff --git a/src/Managing.Web3Proxy/test/plugins/get-spot-position-history.test.ts b/src/Managing.Web3Proxy/test/plugins/get-spot-position-history.test.ts index fb6fbc53..f7d00b83 100644 --- a/src/Managing.Web3Proxy/test/plugins/get-spot-position-history.test.ts +++ b/src/Managing.Web3Proxy/test/plugins/get-spot-position-history.test.ts @@ -2,6 +2,8 @@ import {test} from 'node:test' import assert from 'node:assert' import {getClientForAddress, getSpotPositionHistoryImpl} from '../../src/plugins/custom/gmx.js' import {Ticker} from '../../src/generated/ManagingApiTypes.js' +import {TradeActionType} from '../../src/generated/gmxsdk/types/tradeHistory.js' +import {OrderType} from '../../src/generated/gmxsdk/types/orders.js' test('GMX get spot position history - Market swaps', async (t) => { await t.test('should get spot swap executions', async () => { @@ -18,17 +20,124 @@ test('GMX get spot position history - Market swaps', async (t) => { sdk, 0, // pageIndex 100, // pageSize - Ticker.BTC, // ticker + Ticker.ETH, // ticker - changed to ETH to investigate the issue fromDateTime, // fromDateTime (today's start) toDateTime // toDateTime (today's end) ) - console.log('\nšŸ“Š Spot Swap History Summary:') + console.log('\nšŸ“Š Spot Swap History Summary (ETH):') console.log(`Total swaps: ${result.length}`) - console.log(`šŸ“Š Result:`, result); + + // Log detailed information about each swap + if (result.length > 0) { + console.log('\nšŸ“‹ Detailed Swap Information:') + result.forEach((swap, index) => { + console.log(`\n[${index + 1}] Swap:`, JSON.stringify(swap, null, 2)) + }) + } else { + console.log('āš ļø No swaps found for ETH in the specified time range') + } + + console.log(`\nšŸ“Š Full Result:`, result); assert.ok(result, 'Spot position history result should be defined') assert.ok(Array.isArray(result), 'Spot position history should be an array') }) + + await t.test('should get ALL swap events (including canceled)', async () => { + const sdk = await getClientForAddress('0x932167388dD9aad41149b3cA23eBD489E2E2DD78') + + // Get today's date range (start and end of today) + const today = new Date() + const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 0, 0, 0, 0) + const endOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 23, 59, 59, 999) + const fromTimestamp = Math.floor(startOfDay.getTime() / 1000) + const toTimestamp = Math.floor(endOfDay.getTime() / 1000) + + // Fetch markets and tokens data + const marketsInfoResult = await sdk.markets.getMarketsInfo() + const tokensDataResult = await sdk.tokens.getTokensData() + + if (!marketsInfoResult?.marketsInfoData || !tokensDataResult?.tokensData) { + throw new Error("No markets or tokens info data") + } + + const marketsInfoData = marketsInfoResult.marketsInfoData + const tokensData = tokensDataResult.tokensData + + // Fetch ALL swap events (executed, canceled, created, etc.) + const allSwapEvents = await sdk.trades.getTradeHistory({ + pageIndex: 0, + pageSize: 100, + fromTxTimestamp: fromTimestamp, + toTxTimestamp: toTimestamp, + marketsInfoData, + tokensData, + marketsDirectionsFilter: undefined, + orderEventCombinations: [ + { + eventName: TradeActionType.OrderExecuted, + orderType: OrderType.MarketSwap, + }, + { + eventName: TradeActionType.OrderCancelled, + orderType: OrderType.MarketSwap, + }, + { + eventName: TradeActionType.OrderCreated, + orderType: OrderType.MarketSwap, + } + ], + }) + + console.log('\nšŸ” ALL Swap Events (ETH):') + console.log(`Total events: ${allSwapEvents.length}`) + + // Group by event type + const executedEvents = allSwapEvents.filter(e => e.eventName === TradeActionType.OrderExecuted) + const canceledEvents = allSwapEvents.filter(e => e.eventName === TradeActionType.OrderCancelled) + const createdEvents = allSwapEvents.filter(e => e.eventName === TradeActionType.OrderCreated) + + console.log(`\nšŸ“Š Event Breakdown:`) + console.log(` āœ… Executed: ${executedEvents.length}`) + console.log(` āŒ Canceled: ${canceledEvents.length}`) + console.log(` šŸ“ Created: ${createdEvents.length}`) + + // Log details of canceled events (these are likely the culprits) + if (canceledEvents.length > 0) { + console.log('\nāŒ CANCELED SWAP DETAILS (THIS IS LIKELY THE ISSUE):') + canceledEvents.forEach((event, index) => { + const swapEvent = event as any + console.log(`\n[${index + 1}] Canceled Swap:`) + console.log(` Transaction: ${swapEvent.transaction?.hash}`) + console.log(` Order Key: ${swapEvent.orderKey}`) + console.log(` From Token: ${swapEvent.initialCollateralToken?.symbol}`) + console.log(` To Token: ${swapEvent.targetCollateralToken?.symbol}`) + console.log(` Amount In: ${swapEvent.initialCollateralDeltaAmount ? Number(swapEvent.initialCollateralDeltaAmount) / 1e18 : 'N/A'}`) + console.log(` Min Amount Out: ${swapEvent.minOutputAmount ? Number(swapEvent.minOutputAmount) / 1e18 : 'N/A'}`) + console.log(` Reason: ${swapEvent.reason || 'N/A'}`) + console.log(` Timestamp: ${new Date(Number(swapEvent.timestamp) * 1000).toISOString()}`) + }) + } + + // Log details of executed events + if (executedEvents.length > 0) { + console.log('\nāœ… EXECUTED SWAP DETAILS:') + executedEvents.forEach((event, index) => { + const swapEvent = event as any + console.log(`\n[${index + 1}] Executed Swap:`) + console.log(` Transaction: ${swapEvent.transaction?.hash}`) + console.log(` Order Key: ${swapEvent.orderKey}`) + console.log(` From Token: ${swapEvent.initialCollateralToken?.symbol}`) + console.log(` To Token: ${swapEvent.targetCollateralToken?.symbol}`) + console.log(` Amount In: ${swapEvent.initialCollateralDeltaAmount ? Number(swapEvent.initialCollateralDeltaAmount) / 1e18 : 'N/A'}`) + console.log(` Amount Out: ${swapEvent.executionAmountOut ? Number(swapEvent.executionAmountOut) / 1e18 : 'N/A'}`) + console.log(` Timestamp: ${new Date(Number(swapEvent.timestamp) * 1000).toISOString()}`) + }) + } + + assert.ok(allSwapEvents, 'All swap events should be defined') + assert.ok(Array.isArray(allSwapEvents), 'All swap events should be an array') + }) })