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,