From ee8db1cdc807b06b6efd6a9537bbd179c004ec2f Mon Sep 17 00:00:00 2001 From: cryptooda Date: Mon, 29 Dec 2025 16:39:02 +0700 Subject: [PATCH] Enhance SpotBot to improve position recovery logic and add token balance verification after closing positions. The changes include filtering for recent unfinished positions and logging detailed information during position closure, ensuring better tracking and error handling for token balances. --- src/Managing.Application/Bots/SpotBot.cs | 67 +++++++++++++++++-- .../CloseSpotPositionCommandHandler.cs | 12 ++++ 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/Managing.Application/Bots/SpotBot.cs b/src/Managing.Application/Bots/SpotBot.cs index c2607d36..2b92ee0b 100644 --- a/src/Managing.Application/Bots/SpotBot.cs +++ b/src/Managing.Application/Bots/SpotBot.cs @@ -221,11 +221,21 @@ public class SpotBot : TradingBotBase _scopeFactory, async tradingService => await tradingService.GetPositionsByInitiatorIdentifierAsync(Identifier)); - // Find the last position for this account and ticker (regardless of status) - // The orphaned token balance indicates this position should actually be open + // Calculate the maximum age for recovery (2 candles ago) + // Only recover positions that are recent enough to be legitimate orphaned positions + var candleIntervalSeconds = Managing.Domain.Candles.CandleHelpers.GetBaseIntervalInSeconds(Config.Timeframe); + var maxAgeSeconds = candleIntervalSeconds * 2; // 2 candles + var minDate = DateTime.UtcNow.AddSeconds(-maxAgeSeconds); + + // Find the last position for this account and ticker that is: + // 1. Not Finished (Finished positions should not be recovered) + // 2. Less than 2 candles old + // 3. In a recoverable state (New, Cancelled, or Filled but not tracked) var lastPosition = allPositions .Where(p => p.AccountId == Account.Id && - p.Ticker == Config.Ticker) + p.Ticker == Config.Ticker && + p.Status != PositionStatus.Finished && // Never recover Finished positions + p.Date >= minDate) // Only check recent positions (less than 2 candles old) .OrderByDescending(p => p.Date) .FirstOrDefault(); @@ -233,8 +243,9 @@ public class SpotBot : TradingBotBase { await LogWarningAsync( $"🔍 No Recoverable Position Found\n" + - $"No unfinished position found in database for this ticker\n" + - $"Token balance: `{tokenBalance:F5}` may be from external source"); + $"No recent unfinished position found in database for this ticker\n" + + $"Token balance: `{tokenBalance:F5}` may be from external source or old position\n" + + $"Only checking positions less than 2 candles old (max age: {maxAgeSeconds}s)"); return false; } @@ -976,6 +987,12 @@ public class SpotBot : TradingBotBase if (closedPosition.Status == PositionStatus.Finished || closedPosition.Status == PositionStatus.Flipped) { + // Verify that token balance is cleared after closing (for live trading only) + if (!Config.IsForWatchingOnly && Config.TradingType != TradingType.BacktestSpot) + { + await VerifyTokenBalanceCleared(closedPosition); + } + if (tradeClosingPosition) { await SetPositionStatus(signal.Identifier, PositionStatus.Finished); @@ -1003,4 +1020,44 @@ public class SpotBot : TradingBotBase } } } + + private async Task VerifyTokenBalanceCleared(Position closedPosition) + { + try + { + // Wait a short moment for the swap to complete on-chain + await Task.Delay(2000); // 2 seconds delay + + var tokenBalance = await ServiceScopeHelpers.WithScopedService( + _scopeFactory, + async exchangeService => await exchangeService.GetBalance(Account, Config.Ticker)); + + // Token balance should be zero or very small (dust) after closing + var maxDustAmount = 0.0001m; // Consider amounts less than this as cleared + if (tokenBalance is { Amount: > 0 } && tokenBalance.Amount > maxDustAmount) + { + await LogWarningAsync( + $"⚠️ Token Balance Not Fully Cleared After Closing\n" + + $"Position: `{closedPosition.Identifier}`\n" + + $"Ticker: {Config.Ticker}\n" + + $"Remaining Token Balance: `{tokenBalance.Amount:F5}`\n" + + $"Expected: `0` or less than `{maxDustAmount:F5}` (dust)\n" + + $"Position may not have been fully closed on exchange"); + } + else + { + await LogDebugAsync( + $"✅ Token Balance Verified Cleared\n" + + $"Position: `{closedPosition.Identifier}`\n" + + $"Ticker: {Config.Ticker}\n" + + $"Token Balance: `{tokenBalance?.Amount ?? 0:F5}`\n" + + $"Position successfully closed on exchange"); + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Error verifying token balance cleared for position {PositionId}", closedPosition.Identifier); + // Don't throw - this is just a verification step + } + } } \ No newline at end of file diff --git a/src/Managing.Application/Trading/Handlers/CloseSpotPositionCommandHandler.cs b/src/Managing.Application/Trading/Handlers/CloseSpotPositionCommandHandler.cs index 984deb38..b2052372 100644 --- a/src/Managing.Application/Trading/Handlers/CloseSpotPositionCommandHandler.cs +++ b/src/Managing.Application/Trading/Handlers/CloseSpotPositionCommandHandler.cs @@ -57,6 +57,11 @@ public class CloseSpotPositionCommandHandler( } amountToSwap = tokenBalance.Amount; + + logger?.LogInformation( + "Closing spot position: PositionId={PositionId}, Ticker={Ticker}, TokenBalance={TokenBalance}, Swapping to USDC", + request.Position.Identifier, request.Position.Ticker, amountToSwap); + swapResult = await tradingService.SwapGmxTokensAsync( request.Position.User, account.Name, @@ -70,10 +75,17 @@ public class CloseSpotPositionCommandHandler( if (!swapResult.Success) { + logger?.LogError( + "Failed to close spot position: PositionId={PositionId}, Ticker={Ticker}, Error={Error}", + request.Position.Identifier, request.Position.Ticker, swapResult.Error ?? swapResult.Message); throw new InvalidOperationException( $"Failed to close spot position: {swapResult.Error ?? swapResult.Message}"); } + logger?.LogInformation( + "Spot position swap completed successfully: PositionId={PositionId}, Ticker={Ticker}, AmountSwapped={AmountSwapped}, Hash={Hash}", + request.Position.Identifier, request.Position.Ticker, amountToSwap, swapResult.Hash); + // Build the closing trade directly for backtest (no exchange call needed) var closedTrade = exchangeService.BuildEmptyTrade( request.Position.Open.Ticker,