From 2db6cc9033abf71d76ab7ce392f601a30c85a05e Mon Sep 17 00:00:00 2001 From: cryptooda Date: Wed, 24 Dec 2025 20:52:08 +0700 Subject: [PATCH] Implement orphaned position recovery in SpotBot; enhance logging for recovery attempts and failures, and ensure position synchronization with token balance checks. --- src/Managing.Application/Bots/SpotBot.cs | 145 ++++++++++++++++++++++- 1 file changed, 142 insertions(+), 3 deletions(-) diff --git a/src/Managing.Application/Bots/SpotBot.cs b/src/Managing.Application/Bots/SpotBot.cs index 8cce49e2..c2607d36 100644 --- a/src/Managing.Application/Bots/SpotBot.cs +++ b/src/Managing.Application/Bots/SpotBot.cs @@ -182,14 +182,24 @@ public class SpotBot : TradingBotBase if (tokenBalance is { Value: > 1m }) { - // We have a token balance but no internal position - orphaned position + // We have a token balance but no internal position - attempt to recover orphaned position await LogWarningAsync( $"⚠️ Orphaned Token Balance Detected\n" + $"Ticker: {Config.Ticker}\n" + $"Token balance: `{tokenBalance.Amount:F5}`\n" + $"But no internal position tracked\n" + - $"This may require manual cleanup"); - return false; // Don't allow opening new position until resolved + $"Attempting to recover position from database..."); + + var recovered = await TryRecoverOrphanedPosition(tokenBalance.Amount); + if (!recovered) + { + await LogWarningAsync( + $"❌ Position Recovery Failed\n" + + $"Could not recover orphaned position\n" + + $"Manual cleanup may be required"); + } + + return false; // Don't allow opening new position until next cycle } // No position and no balance - safe to open @@ -202,6 +212,135 @@ public class SpotBot : TradingBotBase } } + private async Task TryRecoverOrphanedPosition(decimal tokenBalance) + { + try + { + // Get all positions for this bot from database + var allPositions = await ServiceScopeHelpers.WithScopedService>( + _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 + var lastPosition = allPositions + .Where(p => p.AccountId == Account.Id && + p.Ticker == Config.Ticker) + .OrderByDescending(p => p.Date) + .FirstOrDefault(); + + if (lastPosition == null) + { + 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"); + return false; + } + + // Verify that the Open trade exists + if (lastPosition.Open == null) + { + await LogWarningAsync( + $"⚠️ Invalid Position Data\n" + + $"Position `{lastPosition.Identifier}` has no Open trade\n" + + $"Cannot recover position"); + return false; + } + + // Verify that the token balance matches the position quantity with tolerance + var positionQuantity = lastPosition.Open.Quantity; + if (positionQuantity == 0) + { + await LogWarningAsync( + $"⚠️ Invalid Position Data\n" + + $"Position `{lastPosition.Identifier}` has zero quantity\n" + + $"Cannot recover position"); + return false; + } + + var tolerance = positionQuantity * 0.006m; // 0.6% tolerance for slippage + var difference = Math.Abs(tokenBalance - positionQuantity); + + if (difference > tolerance) + { + await LogWarningAsync( + $"⚠️ Token Balance Mismatch\n" + + $"Position: `{lastPosition.Identifier}`\n" + + $"Position Quantity: `{positionQuantity:F5}`\n" + + $"Token Balance: `{tokenBalance:F5}`\n" + + $"Difference: `{difference:F5}` exceeds tolerance `{tolerance:F5}`\n" + + $"Cannot recover - amounts don't match"); + return false; + } + + // Recover the position by setting status to Filled + var previousStatus = lastPosition.Status; + lastPosition.Status = PositionStatus.Filled; + lastPosition.Open.SetStatus(TradeStatus.Filled); + + // Update quantity to match actual token balance + lastPosition.Open.Quantity = tokenBalance; + + // Calculate current PnL + var currentPrice = await ServiceScopeHelpers.WithScopedService( + _scopeFactory, + async exchangeService => await exchangeService.GetCurrentPrice(Account, Config.Ticker)); + + if (currentPrice > 0) + { + var openPrice = lastPosition.Open.Price; + var pnlBeforeFees = TradingBox.CalculatePnL(openPrice, currentPrice, tokenBalance, 1, + TradeDirection.Long); + + var totalFees = lastPosition.GasFees + lastPosition.UiFees; + var netPnl = pnlBeforeFees - totalFees; + + lastPosition.ProfitAndLoss = new ProfitAndLoss + { + Realized = pnlBeforeFees, + Net = netPnl + }; + } + + // Update position in database + await UpdatePositionInDatabaseAsync(lastPosition); + + // Add to internal positions collection + Positions[lastPosition.Identifier] = lastPosition; + + // Update signal status if signal already exists + if (Signals.ContainsKey(lastPosition.SignalIdentifier)) + { + SetSignalStatus(lastPosition.SignalIdentifier, SignalStatus.PositionOpen); + } + + await LogWarningAsync( + $"✅ Orphaned Position Recovered\n" + + $"Position: `{lastPosition.Identifier}`\n" + + $"Signal: `{lastPosition.SignalIdentifier}`\n" + + $"Ticker: {Config.Ticker}\n" + + $"Previous Status: `{previousStatus}` → `Filled`\n" + + $"Token Balance: `{tokenBalance:F5}`\n" + + $"Open Price: `${lastPosition.Open.Price:F2}`\n" + + $"Open Date: `{lastPosition.Open.Date}`\n" + + $"Current Price: `${currentPrice:F2}`\n" + + $"Current PnL: `${lastPosition.ProfitAndLoss?.Realized ?? 0:F2}`\n" + + $"Position is now being tracked and will be managed on next cycle"); + + // Send notification + await NotifyAgentAndPlatformAsync(NotificationEventType.PositionOpened, lastPosition); + + return true; + } + catch (Exception ex) + { + Logger.LogError(ex, "Error recovering orphaned position"); + await LogWarningAsync($"❌ Position Recovery Error\n{ex.Message}"); + return false; + } + } + protected override async Task LoadAccountAsync() { // Live trading: load real account from database