#nullable enable using Managing.Application.Abstractions.Grains; using Managing.Application.Abstractions.Services; using Managing.Application.Trading.Commands; using Managing.Application.Trading.Handlers; using Managing.Core; using Managing.Domain.Accounts; using Managing.Domain.Bots; using Managing.Domain.Candles; using Managing.Domain.Indicators; using Managing.Domain.Shared.Helpers; using Managing.Domain.Strategies.Base; using Managing.Domain.Trades; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Orleans.Streams; using static Managing.Common.Enums; namespace Managing.Application.Bots; public class SpotBot : TradingBotBase { public SpotBot( ILogger logger, IServiceScopeFactory scopeFactory, TradingBotConfig config, IStreamProvider? streamProvider = null ) : base(logger, scopeFactory, config, streamProvider) { // Spot trading mode - ensure it's not backtest Config.TradingType = TradingType.Spot; } // SpotBot uses the base implementation for Start() which includes // account loading, balance verification, and live trading startup messages public override async Task Run() { // Live trading signal update logic if (!Config.IsForCopyTrading) { await UpdateSignals(); } await LoadLastCandle(); if (!Config.IsForWatchingOnly) { await ManagePositions(); } UpdateWalletBalances(); // Live trading execution logging ExecutionCount++; Logger.LogInformation( "[Spot][{CopyTrading}][{AgentName}] Bot Status {Name} - ServerDate: {ServerDate}, LastCandleDate: {LastCandleDate}, Signals: {SignalCount}, Executions: {ExecutionCount}, Positions: {PositionCount}", Config.IsForCopyTrading ? "CopyTrading" : "LiveTrading", Account.User.AgentName, Config.Name, DateTime.UtcNow, LastCandle?.Date, Signals.Count, ExecutionCount, Positions.Count); Logger.LogInformation("[{AgentName}] Internal Positions : {Position}", Account.User.AgentName, string.Join(", ", Positions.Values.Select(p => $"{p.SignalIdentifier} - Status: {p.Status}"))); } protected override async Task GetInternalPositionForUpdate(Position position) { // For live trading, get position from database via trading service return await ServiceScopeHelpers.WithScopedService( _scopeFactory, async tradingService => await tradingService.GetPositionByIdentifierAsync(position.Identifier)); } protected override async Task UpdatePositionWithBrokerData(Position position, List brokerPositions) { // For spot trading, fetch token balance directly and update PnL based on current price try { var tokenBalance = await ServiceScopeHelpers.WithScopedService( _scopeFactory, async exchangeService => await exchangeService.GetBalance(Account, Config.Ticker)); if (tokenBalance is not { Amount: > 0 }) { // No token balance found - position might be closed return; } // Get current price from LastCandle var currentPrice = LastCandle?.Close ?? 0; if (currentPrice == 0) { // Try to get current price from exchange currentPrice = await ServiceScopeHelpers.WithScopedService( _scopeFactory, async exchangeService => await exchangeService.GetCurrentPrice(Account, Config.Ticker)); } if (currentPrice == 0) { Logger.LogWarning("Cannot update PnL: current price is 0"); return; } // Calculate PnL based on current token balance and current price // For LONG spot position: PnL = (currentPrice - openPrice) * tokenBalance var openPrice = position.Open.Price; var pnlBeforeFees = TradingBox.CalculatePnL(openPrice, currentPrice, tokenBalance.Amount, 1, TradeDirection.Long); // Update position PnL UpdatePositionPnl(position.Identifier, pnlBeforeFees); var totalFees = position.GasFees + position.UiFees; var netPnl = pnlBeforeFees - totalFees; if (position.ProfitAndLoss == null) { position.ProfitAndLoss = new ProfitAndLoss { Realized = pnlBeforeFees, Net = netPnl }; } else { position.ProfitAndLoss.Realized = pnlBeforeFees; position.ProfitAndLoss.Net = netPnl; } await LogDebugAsync( $"πŸ“Š Spot Position PnL Updated\n" + $"Position: `{position.Identifier}`\n" + $"Token Balance: `{tokenBalance.Amount:F5}`\n" + $"Open Price: `${openPrice:F2}`\n" + $"Current Price: `${currentPrice:F2}`\n" + $"PnL (before fees): `${pnlBeforeFees:F2}`\n" + $"Net PnL: `${netPnl:F2}`"); } catch (Exception ex) { Logger.LogError(ex, "Error updating position PnL from token balance"); } } protected override async Task GetCurrentCandleForPositionClose(Account account, string ticker) { // For live trading, get real-time candle from exchange // ticker parameter is a string representation of the position's ticker var tickerEnum = Enum.Parse(ticker); return await ServiceScopeHelpers.WithScopedService(_scopeFactory, async exchangeService => await exchangeService.GetCandle(Account, tickerEnum, DateTime.UtcNow)); } protected override async Task CheckBrokerPositions() { // For spot trading, check token balances to verify position status try { var tokenBalance = await ServiceScopeHelpers.WithScopedService( _scopeFactory, async exchangeService => await exchangeService.GetBalance(Account, Config.Ticker)); var hasOpenPosition = Positions.Values.Any(p => p.IsOpen()); if (hasOpenPosition) { // We have an internal position - verify it matches broker balance if (tokenBalance is { Amount: > 0 }) { await LogDebugAsync( $"βœ… Spot Position Verified\n" + $"Ticker: {Config.Ticker}\n" + $"Internal position: Open\n" + $"Token balance: `{tokenBalance.Amount:F5}`\n" + $"Position matches broker balance"); return false; // Position already open, cannot open new one } await LogWarningAsync( $"⚠️ Position Mismatch\n" + $"Ticker: {Config.Ticker}\n" + $"Internal position exists but no token balance found\n" + $"Position may need synchronization"); return false; // Don't allow opening new position until resolved } if (tokenBalance is { Amount: > 0 }) { // Check if this is a meaningful balance or just gas reserves / dust // 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) { await LogDebugAsync( $"ℹ️ Small Token Balance Detected (Likely Gas Reserve or Dust)\n" + $"Ticker: {Config.Ticker}\n" + $"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 } // We have a significant 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}` (Value: ${tokenBalance.Value:F2})\n" + $"Above orphaned threshold of `${minOrphanedBalanceValue:F2}`\n" + $"But no internal position tracked\n" + $"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 return true; } catch (Exception ex) { await LogWarningAsync($"❌ Broker Position Check Failed\nError checking token balances\n{ex.Message}"); return false; } } 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)); // 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.Status != PositionStatus.Finished && // Never recover Finished positions p.Date >= minDate) // Only check recent positions (less than 2 candles old) .OrderByDescending(p => p.Date) .FirstOrDefault(); if (lastPosition == null) { await LogWarningAsync( $"πŸ” No Recoverable Position Found\n" + $"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; } // 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.007m; // 0.7% tolerance for slippage and gas reserve 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); // Only update quantity if actual balance is less than position quantity (slippage loss) // Don't update if balance is higher (likely includes gas reserve for ETH) if (tokenBalance < positionQuantity) { await LogInformationAsync( $"πŸ“‰ Adjusting Position Quantity Due to Slippage\n" + $"Original Quantity: `{positionQuantity:F5}`\n" + $"Actual Balance: `{tokenBalance:F5}`\n" + $"Difference: `{difference:F5}`"); lastPosition.Open.Quantity = tokenBalance; } else { await LogInformationAsync( $"ℹ️ Keeping Original Position Quantity\n" + $"Position Quantity: `{positionQuantity:F5}`\n" + $"Actual Balance: `{tokenBalance:F5}`\n" + $"Not updating (balance includes gas reserve or is within tolerance)"); } // 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 if (Config.TradingType == TradingType.BacktestSpot) return; await ServiceScopeHelpers.WithScopedService(_scopeFactory, async accountService => { var account = await accountService.GetAccountByAccountName(Config.AccountName, false); Account = account; }); } protected override async Task SynchronizeWithBrokerPositions(Position internalPosition, Position positionForSignal) { // 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)); if (tokenBalance is { Amount: > 0 }) { // Verify that the token balance matches the position amount var positionQuantity = internalPosition.Open.Quantity; var tokenBalanceAmount = tokenBalance.Amount; if (positionQuantity > 0) { // Only check tolerance if token balance is LESS than position quantity // If balance is greater, it could be orphaned tokens from previous positions if (tokenBalanceAmount < positionQuantity) { var tolerance = positionQuantity * 0.007m; // 0.7% tolerance to account for slippage and gas reserve var difference = positionQuantity - tokenBalanceAmount; if (difference > tolerance) { await LogWarningAsync( $"⚠️ Token Balance Below Position Quantity\n" + $"Position: `{internalPosition.Identifier}`\n" + $"Position Quantity: `{positionQuantity:F5}`\n" + $"Token Balance: `{tokenBalanceAmount:F5}`\n" + $"Difference: `{difference:F5}`\n" + $"Tolerance (0.7%): `{tolerance:F5}`\n" + $"Token balance is significantly lower than expected\n" + $"Skipping position synchronization"); return; // Skip processing if balance is too low } } else if (tokenBalanceAmount > positionQuantity) { // Token balance is higher than position - likely orphaned tokens // Log but continue with synchronization var excess = tokenBalanceAmount - positionQuantity; await LogDebugAsync( $"ℹ️ Token Balance Exceeds Position Quantity\n" + $"Position: `{internalPosition.Identifier}`\n" + $"Position Quantity: `{positionQuantity:F5}`\n" + $"Token Balance: `{tokenBalanceAmount:F5}`\n" + $"Excess: `{excess:F5}`\n" + $"Proceeding with position synchronization"); } } // Token balance exists - verify the opening swap actually succeeded in history before marking as Filled var previousPositionStatus = internalPosition.Status; // Only check history if position is not already Filled if (internalPosition.Status != PositionStatus.Filled) { // Verify the opening swap actually executed successfully bool swapConfirmedInHistory = await VerifyOpeningSwapInHistory(internalPosition); if (!swapConfirmedInHistory) { await LogDebugAsync( $"⏳ Opening Swap Not Yet Confirmed in History\n" + $"Position: `{internalPosition.Identifier}`\n" + $"Token Balance: `{tokenBalanceAmount:F5}`\n" + $"Status: `{internalPosition.Status}`\n" + $"Waiting for swap to appear in exchange history...\n" + $"Will retry on next cycle"); return; // Don't mark as Filled yet, wait for history confirmation } } // Position confirmed on broker (token balance exists AND swap confirmed in history) // Update position status internalPosition.Status = PositionStatus.Filled; await SetPositionStatus(internalPosition.SignalIdentifier, PositionStatus.Filled); // Update Open trade status internalPosition.Open.SetStatus(TradeStatus.Filled); positionForSignal.Open.SetStatus(TradeStatus.Filled); // Update quantity to match actual token balance only if difference is within acceptable tolerance // For ETH, we need to be careful not to include gas reserve in the position quantity var actualTokenBalance = tokenBalance.Amount; var quantityTolerance = internalPosition.Open.Quantity * 0.007m; // 0.7% tolerance for slippage and gas reserve var quantityDifference = Math.Abs(internalPosition.Open.Quantity - actualTokenBalance); // Only update if the actual balance is LESS than expected (slippage loss) // Don't update if balance is higher (likely includes gas reserve or orphaned tokens) if (actualTokenBalance < internalPosition.Open.Quantity && quantityDifference > quantityTolerance) { await LogDebugAsync( $"πŸ”„ Token Balance Lower Than Expected (Slippage)\n" + $"Internal Quantity: `{internalPosition.Open.Quantity:F5}`\n" + $"Broker Balance: `{actualTokenBalance:F5}`\n" + $"Difference: `{quantityDifference:F5}`\n" + $"Tolerance (0.7%): `{quantityTolerance:F5}`\n" + $"Updating to match actual balance"); internalPosition.Open.Quantity = actualTokenBalance; positionForSignal.Open.Quantity = actualTokenBalance; } else if (actualTokenBalance > internalPosition.Open.Quantity) { await LogDebugAsync( $"ℹ️ Token Balance Higher Than Position Quantity\n" + $"Internal Quantity: `{internalPosition.Open.Quantity:F5}`\n" + $"Broker Balance: `{actualTokenBalance:F5}`\n" + $"Difference: `{quantityDifference:F5}`\n" + $"Keeping original position quantity (extra balance likely gas reserve or orphaned tokens)"); } // Calculate and update PnL based on current price var currentPrice = await ServiceScopeHelpers.WithScopedService( _scopeFactory, async exchangeService => await exchangeService.GetCurrentPrice(Account, Config.Ticker)); if (currentPrice > 0) { var openPrice = internalPosition.Open.Price; var pnlBeforeFees = TradingBox.CalculatePnL(openPrice, currentPrice, actualTokenBalance, 1, TradeDirection.Long); UpdatePositionPnl(positionForSignal.Identifier, pnlBeforeFees); var totalFees = internalPosition.GasFees + internalPosition.UiFees; var netPnl = pnlBeforeFees - totalFees; internalPosition.ProfitAndLoss = new ProfitAndLoss { Realized = pnlBeforeFees, Net = netPnl }; } await UpdatePositionInDatabaseAsync(internalPosition); if (previousPositionStatus != PositionStatus.Filled && internalPosition.Status == PositionStatus.Filled) { await NotifyAgentAndPlatformAsync(NotificationEventType.PositionOpened, internalPosition); } else { await NotifyAgentAndPlatformAsync(NotificationEventType.PositionUpdated, internalPosition); } } else { // No token balance found - check if position was closed if (internalPosition.Status == PositionStatus.Filled) { var (positionFoundInHistory, hadWeb3ProxyError) = await CheckSpotPositionInExchangeHistory(internalPosition); if (hadWeb3ProxyError) { await LogWarningAsync( $"⏳ Web3Proxy Error During Spot Position Verification\n" + $"Position: `{internalPosition.Identifier}`\n" + $"Cannot verify if position is closed\n" + $"Will retry on next execution cycle"); return; } if (positionFoundInHistory) { internalPosition.Status = PositionStatus.Finished; await HandleClosedPosition(internalPosition); return; } // Position is Filled but no token balance and not found in history // This could be a zombie position - check if token balance exists via direct exchange call await LogWarningAsync( $"⚠️ Potential Zombie Position Detected\n" + $"Position: `{internalPosition.Identifier}`\n" + $"Status: Filled\n" + $"Token balance: 0\n" + $"Not found in exchange history\n" + $"This position may have been closed externally or data is delayed\n" + $"Will retry verification on next cycle"); } } } catch (Exception ex) { Logger.LogError(ex, "Error synchronizing position with token balance"); } } private async Task<(bool found, bool hadError)> CheckSpotPositionInExchangeHistory(Position position) { try { await LogDebugAsync( $"πŸ” Checking Spot Position History for Position: `{position.Identifier}`\nTicker: `{Config.Ticker}`"); var positionHistory = await ServiceScopeHelpers.WithScopedService>( _scopeFactory, async exchangeService => { var fromDate = DateTime.UtcNow.AddHours(-24); var toDate = DateTime.UtcNow; return await exchangeService.GetSpotPositionHistory(Account, Config.Ticker, fromDate, toDate); }); if (positionHistory != null && positionHistory.Count != 0) { // Find a SELL/SHORT position that occurred AFTER our open position was created // This indicates our LONG position was closed var closingPosition = positionHistory .Where(p => p.OriginDirection == TradeDirection.Short && p.Date >= position.Date) .OrderByDescending(p => p.Date) .FirstOrDefault(); if (closingPosition != null) { await LogDebugAsync( $"βœ… Closing Position Found in History\n" + $"Position: `{position.Identifier}`\n" + $"Ticker: `{closingPosition.Ticker}`\n" + $"Direction: `{closingPosition.OriginDirection}` (SHORT = Sold/Closed)\n" + $"Date: `{closingPosition.Date}`\n" + $"Our Position Open Date: `{position.Date}`"); return (true, false); } await LogDebugAsync( $"ℹ️ No Closing Position Found in History\n" + $"Position: `{position.Identifier}`\n" + $"Found {positionHistory.Count} history entries but none indicate position was closed\n" + $"Position is likely still open"); } else { await LogDebugAsync( $"ℹ️ No Position History Available\n" + $"Position: `{position.Identifier}`\n" + $"Position is likely still open"); } return (false, false); } catch (Exception ex) { Logger.LogError(ex, "Error checking spot position history for position {PositionId}", position.Identifier); await LogWarningAsync( $"⚠️ Web3Proxy Error During Spot Position History Check\n" + $"Position: `{position.Identifier}`\n" + $"Error: {ex.Message}\n" + $"Will retry on next execution cycle"); return (false, true); } } private async Task VerifyOpeningSwapInHistory(Position position) { try { await LogDebugAsync( $"πŸ” Verifying Opening Swap in History\n" + $"Position: `{position.Identifier}`\n" + $"Ticker: `{Config.Ticker}`\n" + $"Expected Quantity: `{position.Open.Quantity:F5}`"); // Get swap history from exchange var positionHistory = await ServiceScopeHelpers.WithScopedService>( _scopeFactory, async exchangeService => { // Check swaps from 1 hour before position date to now 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) { await LogDebugAsync( $"ℹ️ No History Found Yet\n" + $"Position: `{position.Identifier}`\n" + $"Swap may still be pending or history not yet indexed"); return false; } // For a LONG position, the opening swap should be LONG (USDC β†’ Token) // Find a matching swap in the history 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()) { await LogDebugAsync( $"ℹ️ No Matching Opening Swap Found in History\n" + $"Position: `{position.Identifier}`\n" + $"Position Date: `{position.Date}`\n" + $"Searched {positionHistory.Count} history entries\n" + $"No LONG swaps found within 10 minutes of position creation"); return false; } // Check if any of the swaps match our position quantity (with tolerance) var matchingSwap = openingSwaps.FirstOrDefault(swap => { if (position.Open.Quantity == 0) return false; var quantityDifference = Math.Abs(swap.Open.Quantity - position.Open.Quantity) / position.Open.Quantity; return quantityDifference < 0.02m; // Within 2% tolerance }); if (matchingSwap != null) { await LogDebugAsync( $"βœ… Opening Swap Confirmed in History\n" + $"Position: `{position.Identifier}`\n" + $"Swap Date: `{matchingSwap.Date}`\n" + $"Swap Quantity: `{matchingSwap.Open.Quantity:F5}`\n" + $"Expected Quantity: `{position.Open.Quantity:F5}`\n" + $"Swap successfully executed"); return true; } // Found swaps around the time, but none match the quantity await LogDebugAsync( $"⚠️ Found Swaps But None Match Position Quantity\n" + $"Position: `{position.Identifier}`\n" + $"Expected Quantity: `{position.Open.Quantity:F5}`\n" + $"Found {openingSwaps.Count} LONG swaps around position creation time\n" + $"But none match the quantity (within 2% tolerance)"); return false; } catch (Exception ex) { Logger.LogError(ex, "Error verifying opening swap in history for position {PositionId}", position.Identifier); await LogWarningAsync( $"⚠️ Error Verifying Opening Swap\n" + $"Position: `{position.Identifier}`\n" + $"Error: {ex.Message}\n" + $"Assuming swap not yet confirmed, will retry on next cycle"); return false; // On error, don't assume swap succeeded } } 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 // 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) { internalPosition.Status = PositionStatus.Filled; await SetPositionStatus(signal.Identifier, PositionStatus.Filled); await UpdatePositionInDatabaseAsync(internalPosition); await NotifyAgentAndPlatformAsync(NotificationEventType.PositionOpened, internalPosition); } } } protected override Task MonitorSynthRisk(LightSignal signal, Position position) { // Spot trading doesn't use Synth risk monitoring (futures-specific feature) return Task.CompletedTask; } protected override Task RecoverOpenPositionFromBroker(LightSignal signal, Position positionForSignal) { // Spot trading doesn't have broker positions to recover // Positions are token balances, not tracked positions return Task.FromResult(false); } protected override async Task ReconcileWithBrokerHistory(Position position, Candle currentCandle) { // Spot-specific: reconcile with spot position history try { await LogDebugAsync( $"πŸ” Fetching Spot Position History\nPosition: `{position.Identifier}`\nTicker: `{Config.Ticker}`"); var positionHistory = await ServiceScopeHelpers.WithScopedService>( _scopeFactory, async exchangeService => { // Get position history from the last 24 hours for better coverage var fromDate = DateTime.UtcNow.AddHours(-24); var toDate = DateTime.UtcNow; return await exchangeService.GetSpotPositionHistory(Account, Config.Ticker, fromDate, toDate); }); // Find the most recent position in history if (positionHistory != null && positionHistory.Any()) { // Get the most recent position from spot history (ordered by date) var brokerPosition = positionHistory .OrderByDescending(p => p.Open?.Date ?? p.Date) .FirstOrDefault(); // For spot trading, SHORT direction means the spot was sold/closed // We need to verify the last position is SHORT to confirm the position was correctly closed if (brokerPosition != null && brokerPosition.OriginDirection == TradeDirection.Short) { if (brokerPosition.ProfitAndLoss != null) { await LogDebugAsync( $"βœ… Spot Position History Found\n" + $"Position: `{position.Identifier}`\n" + $"Last Position Direction: `{brokerPosition.OriginDirection}` (SHORT = Sold/Closed) βœ…\n" + $"Realized PnL (after fees): `${brokerPosition.ProfitAndLoss.Realized:F2}`\n" + $"Bot's UI Fees: `${position.UiFees:F2}`\n" + $"Bot's Gas Fees: `${position.GasFees:F2}`"); // Use the actual spot PnL data from broker history // For spot, fees are simpler (no leverage, no closing UI fees) var totalBotFees = position.GasFees + position.UiFees; var brokerRealizedPnl = brokerPosition.ProfitAndLoss.Realized; // If broker PNL is 0 or invalid, calculate it ourselves using actual prices if (brokerRealizedPnl == 0 && brokerPosition.Open != null) { var entryPrice = position.Open.Price; var exitPrice = brokerPosition.Open.Price; var quantity = position.Open.Quantity; // Calculate PNL: (exitPrice - entryPrice) * quantity for LONG brokerRealizedPnl = TradingBox.CalculatePnL(entryPrice, exitPrice, quantity, 1, position.OriginDirection); await LogDebugAsync( $"⚠️ Broker PNL was 0, calculated from prices\n" + $"Entry Price: `${entryPrice:F2}` | Exit Price: `${exitPrice:F2}`\n" + $"Quantity: `{quantity:F8}`\n" + $"Calculated PNL: `${brokerRealizedPnl:F2}`"); } position.ProfitAndLoss = new ProfitAndLoss { Realized = brokerRealizedPnl, Net = brokerRealizedPnl - totalBotFees }; // Update the closing trade price if available if (brokerPosition.Open != null) { var brokerClosingPrice = brokerPosition.Open.Price; // If brokerClosingPrice is 0 or invalid, calculate it from PNL // This handles cases where very small prices (like PEPE) lose precision if (brokerClosingPrice <= 0 && brokerPosition.ProfitAndLoss != null && brokerPosition.ProfitAndLoss.Realized != 0 && position.Open != null) { // Calculate closing price from PNL formula // For LONG: PNL = (exitPrice - entryPrice) * quantity * leverage // exitPrice = (PNL / (quantity * leverage)) + entryPrice // For SHORT: PNL = (entryPrice - exitPrice) * quantity * leverage // exitPrice = entryPrice - (PNL / (quantity * leverage)) var realizedPnl = brokerPosition.ProfitAndLoss.Realized; var entryPrice = position.Open.Price; var quantity = position.Open.Quantity; var leverage = position.Open.Leverage; if (quantity > 0 && leverage > 0) { var pnlPerUnit = realizedPnl / (quantity * leverage); if (position.OriginDirection == TradeDirection.Long) { brokerClosingPrice = entryPrice + pnlPerUnit; } else // Short { brokerClosingPrice = entryPrice - pnlPerUnit; } await LogDebugAsync( $"⚠️ Broker closing price was 0, calculated from PNL\n" + $"Position: `{position.Identifier}`\n" + $"Entry Price: `${entryPrice:F8}`\n" + $"PNL: `${realizedPnl:F8}`\n" + $"Quantity: `{quantity:F8}`\n" + $"Leverage: `{leverage}`\n" + $"Calculated Closing Price: `${brokerClosingPrice:F8}`"); } } // Only update TP/SL prices if we have a valid closing price if (brokerClosingPrice > 0) { var isProfitable = position.OriginDirection == TradeDirection.Long ? position.Open.Price < brokerClosingPrice : position.Open.Price > brokerClosingPrice; if (isProfitable) { if (position.TakeProfit1 != null) { position.TakeProfit1.Price = brokerClosingPrice; position.TakeProfit1.SetDate(brokerPosition.Open.Date); position.TakeProfit1.SetStatus(TradeStatus.Filled); } // Cancel SL trade when TP is hit if (position.StopLoss != null) { position.StopLoss.SetStatus(TradeStatus.Cancelled); } } else { if (position.StopLoss != null) { position.StopLoss.Price = brokerClosingPrice; position.StopLoss.SetDate(brokerPosition.Open.Date); position.StopLoss.SetStatus(TradeStatus.Filled); } // Cancel TP trades when SL is hit if (position.TakeProfit1 != null) { position.TakeProfit1.SetStatus(TradeStatus.Cancelled); } if (position.TakeProfit2 != null) { position.TakeProfit2.SetStatus(TradeStatus.Cancelled); } } await LogDebugAsync( $"πŸ“Š Spot Position Reconciliation Complete\n" + $"Position: `{position.Identifier}`\n" + $"Closing Price: `${brokerClosingPrice:F8}`\n" + $"Used: `{(isProfitable ? "Take Profit" : "Stop Loss")}`\n" + $"PnL from broker: `${position.ProfitAndLoss.Realized:F8}`"); } else { await LogWarningAsync( $"⚠️ Cannot update closing trade price - broker price is invalid\n" + $"Position: `{position.Identifier}`\n" + $"Broker PNL: `{brokerPosition.ProfitAndLoss?.Realized:F8}`\n" + $"Entry Price: `${position.Open?.Price:F8}`\n" + $"Broker Closing Price: `{brokerPosition.Open.Price}`\n" + $"Will skip updating TP/SL prices to avoid zero-price fills"); } } return true; // Successfully reconciled, skip candle-based calculation } } else if (brokerPosition != null) { await LogDebugAsync( $"⚠️ Last Position Not SHORT\n" + $"Position: `{position.Identifier}`\n" + $"Last Position Direction: `{brokerPosition.OriginDirection}`\n" + $"Expected SHORT to confirm spot was sold/closed\n" + $"Will continue with candle-based calculation"); } } return false; // No matching position found or not reconciled, continue with candle-based calculation } catch (Exception ex) { Logger.LogError(ex, "Error reconciling spot position with broker history for position {PositionId}", position.Identifier); await LogWarningAsync( $"⚠️ Error During Spot Position History Reconciliation\n" + $"Position: `{position.Identifier}`\n" + $"Error: {ex.Message}\n" + $"Will continue with candle-based calculation"); return false; // On error, continue with candle-based calculation } } protected override Task<(decimal closingPrice, bool pnlCalculated)> CalculatePositionClosingFromCandles( Position position, Candle? currentCandle, bool forceMarketClose, decimal? forcedClosingPrice) { decimal closingPrice = 0; bool pnlCalculated = false; if (forceMarketClose && forcedClosingPrice.HasValue) { closingPrice = forcedClosingPrice.Value; bool isManualCloseProfitable = position.OriginDirection == TradeDirection.Long ? closingPrice > position.Open.Price : closingPrice < position.Open.Price; if (isManualCloseProfitable) { if (position.TakeProfit1 != null) { position.TakeProfit1.Price = closingPrice; position.TakeProfit1.SetDate(currentCandle?.Date ?? DateTime.UtcNow); position.TakeProfit1.SetStatus(TradeStatus.Filled); } if (position.StopLoss != null) { position.StopLoss.SetStatus(TradeStatus.Cancelled); } } else { if (position.StopLoss != null) { position.StopLoss.Price = closingPrice; position.StopLoss.SetDate(currentCandle?.Date ?? DateTime.UtcNow); position.StopLoss.SetStatus(TradeStatus.Filled); } if (position.TakeProfit1 != null) { position.TakeProfit1.SetStatus(TradeStatus.Cancelled); } if (position.TakeProfit2 != null) { position.TakeProfit2.SetStatus(TradeStatus.Cancelled); } } pnlCalculated = true; } else if (currentCandle != null) { // For spot trading, check if SL/TP was hit using candle data if (position.OriginDirection == TradeDirection.Long) { if (position.StopLoss.Price >= currentCandle.Low) { closingPrice = position.StopLoss.Price; position.StopLoss.SetDate(currentCandle.Date); position.StopLoss.SetStatus(TradeStatus.Filled); if (position.TakeProfit1 != null) { position.TakeProfit1.SetStatus(TradeStatus.Cancelled); } if (position.TakeProfit2 != null) { position.TakeProfit2.SetStatus(TradeStatus.Cancelled); } } else if (position.TakeProfit1.Price <= currentCandle.High && position.TakeProfit1.Status != TradeStatus.Filled) { closingPrice = position.TakeProfit1.Price; position.TakeProfit1.SetDate(currentCandle.Date); position.TakeProfit1.SetStatus(TradeStatus.Filled); if (position.StopLoss != null) { position.StopLoss.SetStatus(TradeStatus.Cancelled); } } } if (closingPrice == 0) { // Manual/exchange close - use current candle close closingPrice = currentCandle.Close; bool isManualCloseProfitable = position.OriginDirection == TradeDirection.Long ? closingPrice > position.Open.Price : closingPrice < position.Open.Price; if (isManualCloseProfitable && position.TakeProfit1 != null) { position.TakeProfit1.SetPrice(closingPrice, 2); position.TakeProfit1.SetDate(currentCandle.Date); position.TakeProfit1.SetStatus(TradeStatus.Filled); if (position.StopLoss != null) { position.StopLoss.SetStatus(TradeStatus.Cancelled); } } else { position.StopLoss?.SetPrice(closingPrice, 2); position.StopLoss?.SetDate(currentCandle.Date); position.StopLoss?.SetStatus(TradeStatus.Filled); if (position.TakeProfit1 != null) { position.TakeProfit1.SetStatus(TradeStatus.Cancelled); } if (position.TakeProfit2 != null) { position.TakeProfit2.SetStatus(TradeStatus.Cancelled); } } } pnlCalculated = true; } return Task.FromResult((closingPrice, pnlCalculated)); } protected override async Task UpdateSignalsCore(IReadOnlyList candles, Dictionary? preCalculatedIndicatorValues = null) { // For spot trading, always fetch signals regardless of open positions // Check if we're in cooldown period if (await IsInCooldownPeriodAsync()) { // Still in cooldown period, skip signal generation return; } // Ensure account is loaded before accessing Account.Exchange if (Account == null) { Logger.LogWarning("Cannot update signals: Account is null. Loading account..."); await LoadAccountAsync(); if (Account == null) { Logger.LogError("Cannot update signals: Account failed to load"); return; } } // Live trading: use ScenarioRunnerGrain to get signals await ServiceScopeHelpers.WithScopedService(_scopeFactory, async grainFactory => { var scenarioRunnerGrain = grainFactory.GetGrain(Guid.NewGuid()); // Create indicator combo config from user settings var indicatorComboConfig = TradingBox.CreateConfigFromUserSettings(Account.User); var signal = await scenarioRunnerGrain.GetSignals(Config, Signals, Account.Exchange, LastCandle, indicatorComboConfig); if (signal == null) return; await AddSignal(signal); }); } protected override async Task CanOpenPosition(LightSignal signal) { // For spot trading, only LONG signals can open positions if (signal.Direction != TradeDirection.Long) { await LogInformationAsync( $"🚫 Short Signal Ignored\nShort signals cannot open positions in spot trading\nSignal: `{signal.Identifier}` will be ignored"); return false; } // Early return if bot hasn't executed first cycle yet if (ExecutionCount == 0) { await LogInformationAsync("⏳ Bot Not Ready\nCannot open position\nBot hasn't executed first cycle yet"); return false; } // Check broker positions for live trading var canOpenPosition = await CanOpenPositionWithBrokerChecks(signal); if (!canOpenPosition) { return false; } // Check cooldown period and loss streak return !await IsInCooldownPeriodAsync() && await CheckLossStreak(signal); } protected override async Task HandleFlipPosition(LightSignal signal, Position openedPosition, LightSignal previousSignal, decimal lastPrice) { // For spot trading, SHORT signals should close the open LONG position // LONG signals should not flip (they would be same direction) if (signal.Direction == TradeDirection.Short && openedPosition.OriginDirection == TradeDirection.Long) { // SHORT signal closes the open LONG position await LogInformationAsync( $"πŸ”» Short Signal - Closing Long Position\nClosing position `{openedPosition.Identifier}` due to SHORT signal\nSignal: `{signal.Identifier}`"); try { await CloseTrade(previousSignal, openedPosition, openedPosition.Open, lastPrice, true); // Only mark as Finished if close was successful await SetPositionStatus(previousSignal.Identifier, PositionStatus.Finished); } catch (Exception ex) { await LogWarningAsync( $"❌ Failed to Close Position on SHORT Signal\n" + $"Position: `{openedPosition.Identifier}`\n" + $"Signal: `{signal.Identifier}`\n" + $"Error: {ex.Message}\n" + $"Position status NOT changed - will retry on next cycle"); // Don't change position status if close failed // The position will be retried on the next bot cycle } SetSignalStatus(signal.Identifier, SignalStatus.Expired); return null; // No new position opened for SHORT signals } else if (signal.Direction == TradeDirection.Long && openedPosition.OriginDirection == TradeDirection.Long) { // Same direction LONG signal - ignore it await LogInformationAsync( $"πŸ“ Same Direction Signal\nLONG signal `{signal.Identifier}` ignored\nPosition `{openedPosition.Identifier}` already open for LONG"); SetSignalStatus(signal.Identifier, SignalStatus.Expired); return null; } else { // This shouldn't happen in spot trading, but handle it gracefully await LogInformationAsync( $"⚠️ Unexpected Signal Direction\nSignal: `{signal.Identifier}` Direction: `{signal.Direction}`\nPosition: `{openedPosition.Identifier}` Direction: `{openedPosition.OriginDirection}`\nSignal ignored"); SetSignalStatus(signal.Identifier, SignalStatus.Expired); return null; } } protected override async Task ExecuteOpenPosition(LightSignal signal, decimal lastPrice) { // Spot-specific position opening: includes balance verification and live exchange calls if (signal.Direction != TradeDirection.Long) { throw new InvalidOperationException( $"Only LONG signals can open positions in spot trading. Received: {signal.Direction}"); } if (Account == null || Account.User == null) { throw new InvalidOperationException("Account and Account.User must be set before opening a position"); } // Verify actual balance before opening position await VerifyAndUpdateBalanceAsync(); var command = new OpenSpotPositionRequest( Config.AccountName, Config.MoneyManagement, signal.Direction, Config.Ticker, PositionInitiator.Bot, signal.Date, Account.User, Config.BotTradingBalance, isForPaperTrading: false, // Spot is live trading lastPrice, signalIdentifier: signal.Identifier, initiatorIdentifier: Identifier, tradingType: Config.TradingType); var position = await ServiceScopeHelpers .WithScopedServices( _scopeFactory, async (exchangeService, accountService, tradingService) => await new OpenSpotPositionCommandHandler(exchangeService, accountService, tradingService) .Handle(command)); return position; } public override async Task CloseTrade(LightSignal signal, Position position, Trade tradeToClose, decimal lastPrice, bool tradeClosingPosition = false, bool forceMarketClose = false) { await LogInformationAsync( $"πŸ”§ Closing {position.OriginDirection} Spot Trade\nTicker: `{Config.Ticker}`\nPrice: `${lastPrice}`\nπŸ“‹ Type: `{tradeToClose.TradeType}`\nπŸ“Š Quantity: `{tradeToClose.Quantity:F5}`"); // Live spot trading: close position via swap var command = new CloseSpotPositionCommand(position, position.AccountId, lastPrice); try { Position closedPosition = await ServiceScopeHelpers .WithScopedServices( _scopeFactory, async (exchangeService, accountService, tradingService) => await new CloseSpotPositionCommandHandler(exchangeService, accountService, tradingService) .Handle(command)); 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); } // Wait for swap to settle and refresh USDC balance // This prevents the bot from stopping due to "low USDC" before the ETHβ†’USDC swap completes if (!Config.IsForWatchingOnly && Config.TradingType != TradingType.BacktestSpot) { await LogDebugAsync( $"⏳ Waiting for swap to settle and refreshing USDC balance...\n" + $"Position: `{closedPosition.Identifier}`"); // Wait 3 seconds for the swap to settle on-chain await Task.Delay(3000); // Refresh USDC balance to reflect the swapped tokens await VerifyAndUpdateBalanceAsync(); // Invalidate AgentGrain balance cache to force fresh fetch on next balance check // This ensures the bot won't stop due to "low USDC" using stale cached data await ServiceScopeHelpers.WithScopedService(_scopeFactory, async grainFactory => { var agentGrain = grainFactory.GetGrain(Account.User.Id); await agentGrain.ForceUpdateSummaryImmediate(); }); await LogDebugAsync($"βœ… Balance refreshed and cache invalidated after position close"); } if (tradeClosingPosition) { await SetPositionStatus(signal.Identifier, PositionStatus.Finished); } await HandleClosedPosition(closedPosition, forceMarketClose ? lastPrice : null, forceMarketClose); } else { throw new Exception($"Wrong position status : {closedPosition.Status}"); } } catch (Exception ex) { await LogWarningAsync($"Position {signal.Identifier} not closed : {ex.Message}"); if (position.Status == PositionStatus.Canceled || position.Status == PositionStatus.Rejected) { // Trade close on exchange => Should close trade manually await SetPositionStatus(signal.Identifier, PositionStatus.Finished); // Ensure trade dates are properly updated even for canceled/rejected positions await HandleClosedPosition(position, forceMarketClose ? lastPrice : null, forceMarketClose); } else { // Re-throw exception for other cases so caller knows the operation failed throw; } } } 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)); // For ETH, remaining balance is expected (gas reserve) - use higher threshold // For other tokens, very small dust amounts are acceptable var maxDustAmount = Config.Ticker == Ticker.ETH ? 0.01m // ETH: up to 0.01 ETH is acceptable (gas reserve) : 0.0001m; // Other tokens: only dust amounts acceptable if (tokenBalance is { Amount: > 0 } && tokenBalance.Amount > maxDustAmount) { // Check if remaining balance is small enough (< $2 USD) and verified in history if (tokenBalance.Value < 2m) { // Check if the closing swap exists in history var (sellFoundInHistory, _) = await CheckSpotPositionInExchangeHistory(closedPosition); if (sellFoundInHistory) { await LogDebugAsync( $"βœ… Small Leftover Accepted - Position Verified Closed\n" + $"Position: `{closedPosition.Identifier}`\n" + $"Ticker: {Config.Ticker}\n" + $"Remaining Token Balance: `{tokenBalance.Amount:F5}`\n" + $"USD Value: `${tokenBalance.Value:F2}` (below $2 threshold)\n" + $"Sell transaction confirmed in exchange history\n" + $"Accepting as successfully closed - leftover is likely slippage/rounding"); return; // Position is verified closed, no force close needed } } 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" + $"USD Value: `${tokenBalance.Value:F2}`\n" + $"Expected: `0` or less than `{maxDustAmount:F5}` (dust)\n" + $"Attempting to force close remaining balance..."); // Attempt to force close the remaining balance await ForceCloseRemainingBalance(closedPosition, tokenBalance.Amount); } else { await LogDebugAsync( $"βœ… Token Balance Verified Cleared\n" + $"Position: `{closedPosition.Identifier}`\n" + $"Ticker: {Config.Ticker}\n" + $"Token Balance: `{tokenBalance?.Amount ?? 0:F5}`\n" + $"{(Config.Ticker == Ticker.ETH && tokenBalance?.Amount > 0 ? "(Gas reserve - expected)" : "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 } } private async Task ForceCloseRemainingBalance(Position position, decimal remainingBalance) { try { // Prevent infinite retry loop - only attempt force close once // The next bot cycle will handle verification await LogInformationAsync( $"πŸ”„ Force Closing Remaining Balance (One-Time Attempt)\n" + $"Position: `{position.Identifier}`\n" + $"Ticker: {Config.Ticker}\n" + $"Remaining Balance: `{remainingBalance:F5}`"); // Get current price for closing var currentPrice = await ServiceScopeHelpers.WithScopedService( _scopeFactory, async exchangeService => await exchangeService.GetCurrentPrice(Account, Config.Ticker)); if (currentPrice <= 0) { await LogWarningAsync( $"❌ Cannot Force Close\n" + $"Current price is invalid: `{currentPrice}`\n" + $"Will retry on next cycle"); return; } // Create a new command to close the remaining balance var retryCommand = new CloseSpotPositionCommand(position, position.AccountId, currentPrice); Position retryClosedPosition = await ServiceScopeHelpers .WithScopedServices( _scopeFactory, async (exchangeService, accountService, tradingService) => await new CloseSpotPositionCommandHandler(exchangeService, accountService, tradingService) .Handle(retryCommand)); if (retryClosedPosition != null && (retryClosedPosition.Status == PositionStatus.Finished || retryClosedPosition.Status == PositionStatus.Flipped)) { await LogInformationAsync( $"βœ… Remaining Balance Force Closed Successfully\n" + $"Position: `{position.Identifier}`\n" + $"Cleared Balance: `{remainingBalance:F5}`\n" + $"Close Price: `${currentPrice:F2}`"); // Verify one more time that balance is now cleared (without triggering another force close) await Task.Delay(2000); // Wait for swap to complete var finalBalance = await ServiceScopeHelpers.WithScopedService( _scopeFactory, async exchangeService => await exchangeService.GetBalance(Account, Config.Ticker)); if (finalBalance is { Amount: > 0.0001m }) { // Check if remaining balance is small enough (< $2 USD) and verified in history if (finalBalance.Value < 2m) { var (sellFoundInHistory, _) = await CheckSpotPositionInExchangeHistory(position); if (sellFoundInHistory) { await LogInformationAsync( $"βœ… Small Leftover Accepted After Force Close\n" + $"Position: `{position.Identifier}`\n" + $"Remaining: `{finalBalance.Amount:F5}`\n" + $"USD Value: `${finalBalance.Value:F2}` (below $2 threshold)\n" + $"Sell transaction confirmed in exchange history\n" + $"Accepting as successfully closed - leftover is likely slippage/rounding"); return; } } await LogWarningAsync( $"⚠️ Balance Still Remaining After Force Close Attempt\n" + $"Position: `{position.Identifier}`\n" + $"Remaining: `{finalBalance.Amount:F5}`\n" + $"USD Value: `${finalBalance.Value:F2}`\n" + $"This will be handled on the next bot cycle\n" + $"Manual intervention may be required if issue persists"); } else { await LogInformationAsync( $"βœ… Balance Fully Cleared After Force Close\n" + $"Position: `{position.Identifier}`\n" + $"Final Balance: `{finalBalance?.Amount ?? 0:F5}`"); } } } catch (Exception ex) { Logger.LogError(ex, "Error force closing remaining balance for position {PositionId}", position.Identifier); await LogWarningAsync( $"❌ Force Close Failed\n" + $"Position: `{position.Identifier}`\n" + $"Error: {ex.Message}\n" + $"Manual intervention may be required"); } } }