diff --git a/src/Managing.Application/Bots/TradingBotBase.cs b/src/Managing.Application/Bots/TradingBotBase.cs index ec5a12de..96e77df9 100644 --- a/src/Managing.Application/Bots/TradingBotBase.cs +++ b/src/Managing.Application/Bots/TradingBotBase.cs @@ -400,6 +400,9 @@ public class TradingBotBase : ITradingBot if (!hasOpenPositions && !hasWaitingSignals) return; + // Recovery Logic: Check for recently canceled positions that might need recovery + await RecoverRecentlyCanceledPositions(); + // First, process all existing positions that are not finished // Optimized: Inline the filter to avoid LINQ overhead foreach (var position in Positions.Values) @@ -598,9 +601,20 @@ public class TradingBotBase : ITradingBot $"Checking position history before marking as closed..."); // Verify in exchange history before assuming it's closed - bool existsInHistory = await CheckPositionInExchangeHistory(positionForSignal); + var (existsInHistory, hadWeb3ProxyError) = await CheckPositionInExchangeHistory(positionForSignal); - if (existsInHistory) + if (hadWeb3ProxyError) + { + // Web3Proxy error - don't assume position is closed, wait for next cycle + await LogWarning( + $"⏳ Web3Proxy Error During Position Verification\n" + + $"Position: `{positionForSignal.Identifier}`\n" + + $"Cannot verify if position is closed\n" + + $"Will retry on next execution cycle"); + // Don't change position status, wait for next cycle + return; + } + else if (existsInHistory) { // Position was actually filled and closed by the exchange Logger.LogInformation( @@ -767,9 +781,20 @@ public class TradingBotBase : ITradingBot // Position might be canceled by the broker // Check if position exists in exchange history with PnL before canceling - bool positionFoundInHistory = await CheckPositionInExchangeHistory(positionForSignal); + var (positionFoundInHistory, hadWeb3ProxyError) = await CheckPositionInExchangeHistory(positionForSignal); - if (positionFoundInHistory) + if (hadWeb3ProxyError) + { + // Web3Proxy error occurred - don't mark as cancelled, wait for next cycle + await LogWarning( + $"⏳ Web3Proxy Error - Skipping Position Cancellation\n" + + $"Position: `{positionForSignal.Identifier}`\n" + + $"Status remains: `{positionForSignal.Status}`\n" + + $"Will retry position verification on next execution cycle"); + // Don't change signal status to Expired, let it continue + return; + } + else if (positionFoundInHistory) { // Position was actually filled and closed, process it properly await HandleClosedPosition(positionForSignal); @@ -2824,12 +2849,12 @@ public class TradingBotBase : ITradingBot /// /// The position to check /// True if position found in exchange history with PnL, false otherwise - private async Task CheckPositionInExchangeHistory(Position position) + private async Task<(bool found, bool hadError)> CheckPositionInExchangeHistory(Position position) { if (Config.IsForBacktest) { // For backtests, we don't have exchange history, so return false - return false; + return (false, false); } try @@ -2864,7 +2889,7 @@ public class TradingBotBase : ITradingBot $"Direction: `{position.OriginDirection}` (Matched: ✅)\n" + $"Exchange PnL: `${recentPosition.ProfitAndLoss.Realized:F2}`\n" + $"Position was actually filled and closed"); - return true; + return (true, false); } else { @@ -2880,11 +2905,192 @@ public class TradingBotBase : ITradingBot await LogDebug( $"❌ No Position Found in Exchange History\nPosition: `{position.Identifier}`\nPosition was never filled"); - return false; + return (false, false); } catch (Exception ex) { Logger.LogError(ex, "Error checking position history for position {PositionId}", position.Identifier); + await LogWarning( + $"⚠️ Web3Proxy Error During Position History Check\n" + + $"Position: `{position.Identifier}`\n" + + $"Error: {ex.Message}\n" + + $"Will retry on next execution cycle"); + return (false, true); // found=false, hadError=true + } + } + + private async Task RecoverRecentlyCanceledPositions() + { + if (Config.IsForBacktest) + { + // For backtests, we don't have broker positions, so skip recovery + return; + } + + try + { + // Get the last (most recent) position from all positions + var lastPosition = Positions.Values.LastOrDefault(); + if (lastPosition == null) + { + return; // No positions at all + } + + // Only attempt recovery if the last position is cancelled + if (lastPosition.Status != PositionStatus.Canceled) + { + await LogDebug( + $"❌ Recovery Skipped\nLast position `{lastPosition.Identifier}` is not cancelled (Status: {lastPosition.Status})\nNo recovery needed"); + return; + } + + // Also get count of cancelled positions for logging + var canceledPositionsCount = Positions.Values.Count(p => p.Status == PositionStatus.Canceled); + + await LogDebug( + $"🔄 Position Recovery Check\nFound `{canceledPositionsCount}` canceled positions\nLast position `{lastPosition.Identifier}` is cancelled\nAttempting recovery from broker..."); + + // Get the signal for the last position + if (!Signals.TryGetValue(lastPosition.SignalIdentifier, out var signal)) + { + await LogWarning( + $"⚠️ Signal Not Found for Recovery\nPosition: `{lastPosition.Identifier}`\nSignal: `{lastPosition.SignalIdentifier}`\nCannot recover without signal"); + return; + } + + // Attempt recovery for the last position only + bool recovered = await RecoverOpenPositionFromBroker(signal, lastPosition); + if (recovered) + { + await LogInformation( + $"🎉 Position Recovery Successful\nPosition `{lastPosition.Identifier}` recovered from broker\nStatus restored to Filled\nWill continue normal processing"); + } + else + { + await LogDebug( + $"❌ Recovery Not Needed\nPosition `{lastPosition.Identifier}` confirmed canceled\nNo open position found on broker"); + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Error during recently canceled positions recovery"); + await LogWarning($"Position recovery check failed due to exception: {ex.Message}"); + } + } + + private async Task RecoverOpenPositionFromBroker(LightSignal signal, Position positionForSignal) + { + if (Config.IsForBacktest) + { + // For backtests, we don't have broker positions, so return false + return false; + } + + try + { + await LogWarning( + $"🔄 Attempting Position Recovery\n" + + $"Signal: `{signal.Identifier}`\n" + + $"Position: `{positionForSignal.Identifier}`\n" + + $"Direction: `{positionForSignal.OriginDirection}`\n" + + $"Ticker: `{Config.Ticker}`\n" + + $"Checking broker for open position..."); + + Position brokerPosition = null; + await ServiceScopeHelpers.WithScopedService(_scopeFactory, + async exchangeService => + { + var brokerPositions = await exchangeService.GetBrokerPositions(Account); + brokerPosition = brokerPositions.FirstOrDefault(p => p.Ticker == Config.Ticker); + }); + + if (brokerPosition != null) + { + // Check if the broker position matches our expected direction + if (brokerPosition.OriginDirection == positionForSignal.OriginDirection) + { + await LogInformation( + $"✅ Position Recovered from Broker\n" + + $"Position: `{positionForSignal.Identifier}`\n" + + $"Direction: `{positionForSignal.OriginDirection}` (Matched: ✅)\n" + + $"Broker Position Size: `{brokerPosition.Open?.Quantity ?? 0}`\n" + + $"Broker Position Price: `${brokerPosition.Open?.Price ?? 0:F2}`\n" + + $"Restoring position status to Filled"); + + // Update position status back to Filled (from Canceled) + positionForSignal.Status = PositionStatus.Filled; + await SetPositionStatus(signal.Identifier, PositionStatus.Filled); + + // Update signal status back to PositionOpen since position is recovered + SetSignalStatus(signal.Identifier, SignalStatus.PositionOpen); + + // Update PnL from broker position + var brokerNetPnL = brokerPosition.GetPnLBeforeFees(); + UpdatePositionPnl(positionForSignal.Identifier, brokerNetPnL); + + // Update trade details if available + if (positionForSignal.Open != null && brokerPosition.Open != null) + { + positionForSignal.Open.SetStatus(TradeStatus.Filled); + if (brokerPosition.Open.ExchangeOrderId != null) + { + positionForSignal.Open.SetExchangeOrderId(brokerPosition.Open.ExchangeOrderId); + } + } + + // Update stop loss and take profit trades if available + if (positionForSignal.StopLoss != null && brokerPosition.StopLoss != null) + { + positionForSignal.StopLoss.SetExchangeOrderId(brokerPosition.StopLoss.ExchangeOrderId); + } + + if (positionForSignal.TakeProfit1 != null && brokerPosition.TakeProfit1 != null) + { + positionForSignal.TakeProfit1.SetExchangeOrderId(brokerPosition.TakeProfit1.ExchangeOrderId); + } + + if (positionForSignal.TakeProfit2 != null && brokerPosition.TakeProfit2 != null) + { + positionForSignal.TakeProfit2.SetExchangeOrderId(brokerPosition.TakeProfit2.ExchangeOrderId); + } + + // Update database + await UpdatePositionDatabase(positionForSignal); + + // Notify about position recovery + await NotifyAgentAndPlatformGrainAsync(NotificationEventType.PositionUpdated, positionForSignal); + + await LogInformation( + $"🎉 Position Recovery Complete\n" + + $"Position `{positionForSignal.Identifier}` successfully recovered\n" + + $"Status restored to Filled\n" + + $"Database and internal state updated"); + + return true; + } + else + { + await LogWarning( + $"⚠️ Direction Mismatch During Recovery\n" + + $"Expected: `{positionForSignal.OriginDirection}`\n" + + $"Broker Position: `{brokerPosition.OriginDirection}`\n" + + $"Cannot recover - directions don't match"); + } + } + else + { + await LogDebug( + $"❌ No Open Position Found on Broker\n" + + $"Ticker: `{Config.Ticker}`\n" + + $"Position recovery not possible"); + } + + return false; + } + catch (Exception ex) + { + Logger.LogError(ex, "Error during position recovery for position {PositionId}", positionForSignal.Identifier); + await LogWarning($"Position recovery failed due to exception: {ex.Message}"); return false; } }