diff --git a/src/Managing.Api/Controllers/BotController.cs b/src/Managing.Api/Controllers/BotController.cs index aabacfac..7beb4ab3 100644 --- a/src/Managing.Api/Controllers/BotController.cs +++ b/src/Managing.Api/Controllers/BotController.cs @@ -539,7 +539,7 @@ public class BotController : BaseController WinRate = (item.TradeWins + item.TradeLosses) != 0 ? item.TradeWins / (item.TradeWins + item.TradeLosses) : 0, - ProfitAndLoss = item.Pnl, + ProfitAndLoss = item.NetPnL, Roi = item.Roi, Identifier = item.Identifier.ToString(), AgentName = item.User.AgentName, diff --git a/src/Managing.Api/Controllers/DataController.cs b/src/Managing.Api/Controllers/DataController.cs index 8e4b590f..2f6edce9 100644 --- a/src/Managing.Api/Controllers/DataController.cs +++ b/src/Managing.Api/Controllers/DataController.cs @@ -918,6 +918,7 @@ public class DataController : ControllerBase public async Task>> GetStrategiesPaginated( int pageNumber = 1, int pageSize = 10, + BotStatus? status = null, string? name = null, string? ticker = null, string? agentName = null, @@ -943,11 +944,14 @@ public class DataController : ControllerBase // Check environment variable for filtering profitable strategies only var showOnlyProfitable = _configuration.GetValue("showOnlyProfitable", false); - // Get paginated bots excluding Saved status + // Default to Running status if not provided + var statusFilter = status ?? BotStatus.Running; + + // Get paginated bots with status filter var (bots, totalCount) = await _botService.GetBotsPaginatedAsync( pageNumber, pageSize, - null, // No specific status filter - we'll exclude Saved in the service call + statusFilter, name, ticker, agentName, @@ -957,9 +961,9 @@ public class DataController : ControllerBase sortDirection, showOnlyProfitable); - // Filter out Saved status bots - var filteredBots = bots.Where(bot => bot.Status != BotStatus.Saved).ToList(); - var filteredCount = totalCount - bots.Count(bot => bot.Status == BotStatus.Saved); + // No additional filtering needed since we're using the status filter directly + var filteredBots = bots.ToList(); + var filteredCount = totalCount; // Map to response objects var tradingBotResponses = MapBotsToTradingBotResponse(filteredBots); @@ -1028,7 +1032,7 @@ public class DataController : ControllerBase WinRate = (item.TradeWins + item.TradeLosses) != 0 ? item.TradeWins / (item.TradeWins + item.TradeLosses) : 0, - ProfitAndLoss = item.Pnl, + ProfitAndLoss = item.NetPnL, Roi = item.Roi, Identifier = item.Identifier.ToString(), AgentName = item.User.AgentName, diff --git a/src/Managing.Application/Bots/Grains/LiveTradingBotGrain.cs b/src/Managing.Application/Bots/Grains/LiveTradingBotGrain.cs index 5d4821bc..f38615cf 100644 --- a/src/Managing.Application/Bots/Grains/LiveTradingBotGrain.cs +++ b/src/Managing.Application/Bots/Grains/LiveTradingBotGrain.cs @@ -593,6 +593,18 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable // Load state into the trading bot instance LoadStateIntoTradingBot(tradingBot); + // Set up callback for immediate balance sync and save when balance is updated + tradingBot.OnBalanceUpdatedCallback = async () => + { + SyncStateFromBase(); + await _state.WriteStateAsync(); + + // Save to database immediately so GetStrategiesPaginated shows correct balance + var botRegistry = GrainFactory.GetGrain(0); + var status = await botRegistry.GetBotStatus(this.GetPrimaryKey()); + await SaveBotAsync(status); + }; + return tradingBot; } diff --git a/src/Managing.Application/Bots/SpotBot.cs b/src/Managing.Application/Bots/SpotBot.cs index d68b7ad5..38ab72dd 100644 --- a/src/Managing.Application/Bots/SpotBot.cs +++ b/src/Managing.Application/Bots/SpotBot.cs @@ -454,6 +454,75 @@ public class SpotBot : TradingBotBase if (positionQuantity > 0) { + // Check if token balance is very low (dust) compared to position quantity + // This likely means the position was closed but status wasn't updated + var dustThreshold = Config.Ticker == Ticker.ETH + ? 0.01m // ETH: 0.01 ETH is likely gas reserve or dust + : 0.0001m; // Other tokens: very small amount is dust + + var isDustAmount = tokenBalanceAmount <= dustThreshold; + var balanceIsVeryLow = tokenBalanceAmount < positionQuantity * 0.1m; // Less than 10% of expected + + // If balance is dust or very low, check if opening swap failed or position was closed + if (isDustAmount || balanceIsVeryLow) + { + if (internalPosition.Status == PositionStatus.Filled) + { + // First, check if the opening swap actually succeeded (exists in history) + // If not, the swap likely failed and position should be marked as Canceled + var openingSwapConfirmed = await VerifyOpeningSwapInHistory(internalPosition); + + if (!openingSwapConfirmed) + { + // Opening swap not found in history - likely failed + // Mark position as Canceled since it never actually opened + 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 Not Found in History\n" + + $"Position: `{internalPosition.Identifier}`\n" + + $"Signal: `{internalPosition.SignalIdentifier}`\n" + + $"Ticker: {Config.Ticker}\n" + + $"Expected Quantity: `{positionQuantity:F5}`\n" + + $"Token Balance: `{tokenBalanceAmount:F5}` (very low)\n" + + $"Status Changed: `{previousStatus}` → `Canceled`\n" + + $"The opening swap (USDC → {Config.Ticker}) was not found in exchange history\n" + + $"This indicates the swap failed and position never opened\n" + + $"Position will not be tracked or managed"); + + await NotifyAgentAndPlatformAsync(NotificationEventType.PositionClosed, internalPosition); + return; // Exit - position failed to open + } + + // Opening swap exists, so position did open - check if it was closed + 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) + { + // Position was closed - mark as Finished + internalPosition.Status = PositionStatus.Finished; + await HandleClosedPosition(internalPosition); + return; + } + } + } + // 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) @@ -463,15 +532,19 @@ public class SpotBot : TradingBotBase 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"); + // Only log warning if this is not a dust amount (already handled above) + if (!isDustAmount && !balanceIsVeryLow) + { + 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 } } @@ -490,25 +563,68 @@ public class SpotBot : TradingBotBase } } - // Token balance exists - verify the opening swap actually succeeded in history before marking as Filled + // Token balance exists - ALWAYS verify the opening swap actually succeeded in history + // This is critical because Web3Proxy may return success when transaction is sent, + // but the swap might fail on-chain. We must verify it actually executed. var previousPositionStatus = internalPosition.Status; - // Only check history if position is not already Filled - if (internalPosition.Status != PositionStatus.Filled) + // Always verify the opening swap exists in history, even if position is already marked as Filled + // This catches cases where the position was prematurely marked as Filled by the command handler + bool swapConfirmedInHistory = await VerifyOpeningSwapInHistory(internalPosition); + + if (!swapConfirmedInHistory) { - // Verify the opening swap actually executed successfully - bool swapConfirmedInHistory = await VerifyOpeningSwapInHistory(internalPosition); + // Swap not found in history - check if this is a failed swap or just delayed + // If balance is very low, the swap likely failed + var dustThreshold = Config.Ticker == Ticker.ETH + ? 0.01m // ETH: 0.01 ETH is likely gas reserve or dust + : 0.0001m; // Other tokens: very small amount is dust - if (!swapConfirmedInHistory) + var isDustAmount = tokenBalanceAmount <= dustThreshold; + var balanceIsVeryLow = tokenBalanceAmount < positionQuantity * 0.1m; // Less than 10% of expected + + if (isDustAmount || balanceIsVeryLow) { - await LogDebugAsync( - $"⏳ Opening Swap Not Yet Confirmed in History\n" + + // Balance is very low and swap not in history - swap likely failed + // Mark position as Canceled since swap failed on-chain + 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 Not Found 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 + $"Signal: `{internalPosition.SignalIdentifier}`\n" + + $"Ticker: {Config.Ticker}\n" + + $"Expected Quantity: `{positionQuantity:F5}`\n" + + $"Token Balance: `{tokenBalanceAmount:F5}` (very low)\n" + + $"Status Changed: `{previousStatus}` → `Canceled`\n" + + $"The opening swap (USDC → {Config.Ticker}) was not found in exchange history\n" + + $"This indicates the swap transaction failed on-chain even though it was sent\n" + + $"Position will not be tracked or managed"); + + await NotifyAgentAndPlatformAsync(NotificationEventType.PositionClosed, internalPosition); + return; // Exit - swap failed + } + else + { + // Balance exists but swap not yet in history - might be delayed + // Only wait if position is not already Filled (to avoid repeated checks) + if (internalPosition.Status != PositionStatus.Filled) + { + 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 + } + // If already Filled, continue - might be a timing issue with history indexing } } diff --git a/src/Managing.Application/Bots/TradingBotBase.cs b/src/Managing.Application/Bots/TradingBotBase.cs index 5c976051..136fb614 100644 --- a/src/Managing.Application/Bots/TradingBotBase.cs +++ b/src/Managing.Application/Bots/TradingBotBase.cs @@ -40,7 +40,6 @@ public abstract class TradingBotBase : ITradingBot public Dictionary Signals { get; set; } public Dictionary Positions { get; set; } public Dictionary WalletBalances { get; set; } - private decimal _currentBalance; public DateTime PreloadSince { get; set; } public int PreloadedCandlesCount { get; set; } public long ExecutionCount { get; set; } = 0; @@ -51,6 +50,12 @@ public abstract class TradingBotBase : ITradingBot // OPTIMIZATION 2: Cache open position state to avoid expensive Positions.Any() calls private bool _hasOpenPosition = false; + /// + /// Callback to notify the grain when balance is updated (for immediate sync and save to database). + /// Set by the grain after creating the bot instance. + /// + public Func? OnBalanceUpdatedCallback { get; set; } + public TradingBotBase( ILogger logger, IServiceScopeFactory scopeFactory, @@ -65,7 +70,6 @@ public abstract class TradingBotBase : ITradingBot Signals = new Dictionary(); Positions = new Dictionary(); WalletBalances = new Dictionary(); - _currentBalance = config.BotTradingBalance; PreloadSince = CandleHelpers.GetBotPreloadSinceFromTimeframe(config.Timeframe); } @@ -433,13 +437,13 @@ public abstract class TradingBotBase : ITradingBot if (WalletBalances.Count == 0) { - WalletBalances[date] = _currentBalance; + WalletBalances[date] = Config.BotTradingBalance; return; } if (!WalletBalances.ContainsKey(date)) { - WalletBalances[date] = _currentBalance; + WalletBalances[date] = Config.BotTradingBalance; } } @@ -1270,13 +1274,15 @@ public abstract class TradingBotBase : ITradingBot if (position.ProfitAndLoss != null) { - // Update the current balance when position closes - _currentBalance += position.ProfitAndLoss.Net; + // Update the balance when position closes Config.BotTradingBalance += position.ProfitAndLoss.Net; await LogDebugAsync( string.Format("💰 Balance Updated\nNew bot trading balance: `${0:F2}`", Config.BotTradingBalance)); + + // For live trading, immediately sync and save to database + await OnBalanceUpdatedAsync(); } } else @@ -2060,6 +2066,18 @@ public abstract class TradingBotBase : ITradingBot await CancelAllOrders(); } + /// + /// Called when the bot trading balance is updated (e.g., after a position closes). + /// Calls the OnBalanceUpdatedCallback if set (by the grain for live trading). + /// + protected virtual async Task OnBalanceUpdatedAsync() + { + if (OnBalanceUpdatedCallback != null) + { + await OnBalanceUpdatedCallback(); + } + } + // Interface implementation public async Task LogInformation(string message) { diff --git a/src/Managing.Infrastructure.Database/PostgreSql/PostgreSqlBotRepository.cs b/src/Managing.Infrastructure.Database/PostgreSql/PostgreSqlBotRepository.cs index 5d854dc5..b40fcc15 100644 --- a/src/Managing.Infrastructure.Database/PostgreSql/PostgreSqlBotRepository.cs +++ b/src/Managing.Infrastructure.Database/PostgreSql/PostgreSqlBotRepository.cs @@ -83,6 +83,7 @@ public class PostgreSqlBotRepository : IBotRepository existingEntity.AccumulatedRunTimeSeconds = bot.AccumulatedRunTimeSeconds; existingEntity.MasterBotUserId = bot.MasterBotUserId ?? existingEntity.MasterBotUserId; + existingEntity.BotTradingBalance = bot.BotTradingBalance; await _context.SaveChangesAsync().ConfigureAwait(false); }