diff --git a/src/Managing.Application/Abstractions/Grains/IAgentGrain.cs b/src/Managing.Application/Abstractions/Grains/IAgentGrain.cs index 12c39411..cb5da695 100644 --- a/src/Managing.Application/Abstractions/Grains/IAgentGrain.cs +++ b/src/Managing.Application/Abstractions/Grains/IAgentGrain.cs @@ -1,4 +1,5 @@ using Managing.Application.Abstractions.Models; +using Managing.Application.Bots.Models; using Orleans.Concurrency; namespace Managing.Application.Abstractions.Grains @@ -39,5 +40,14 @@ namespace Managing.Application.Abstractions.Grains /// The update event from the stream] [OneWay] Task OnAgentSummaryUpdateAsync(AgentSummaryUpdateEvent updateEvent); + + /// + /// Coordinates ETH balance checking and swapping for all bots under this agent. + /// Uses cached balance data to reduce external API calls and ensures only one swap operation happens at a time. + /// + /// The bot requesting the ETH balance check + /// The account name to check balances for + /// BalanceCheckResult indicating the status and reason for any failure + Task CheckAndEnsureEthBalanceAsync(Guid requestingBotId, string accountName); } } \ No newline at end of file diff --git a/src/Managing.Application/Bots/Grains/AgentGrain.cs b/src/Managing.Application/Bots/Grains/AgentGrain.cs index 94de2817..5b1b9c71 100644 --- a/src/Managing.Application/Bots/Grains/AgentGrain.cs +++ b/src/Managing.Application/Bots/Grains/AgentGrain.cs @@ -4,6 +4,7 @@ using Managing.Application.Abstractions.Models; using Managing.Application.Abstractions.Services; using Managing.Application.Bots.Models; using Managing.Application.Orleans; +using Managing.Common; using Managing.Domain.Statistics; using Microsoft.Extensions.Logging; using static Managing.Common.Enums; @@ -233,4 +234,242 @@ public class AgentGrain : Grain, IAgentGrain await UpdateSummary(); } } + + public async Task CheckAndEnsureEthBalanceAsync(Guid requestingBotId, string accountName) + { + try + { + // Check if a swap is already in progress + if (_state.State.IsSwapInProgress) + { + _logger.LogInformation("Swap already in progress for agent {UserId}, bot {RequestingBotId} will wait", + this.GetPrimaryKeyLong(), requestingBotId); + return new BalanceCheckResult + { + IsSuccessful = false, + FailureReason = BalanceCheckFailureReason.SwapInProgress, + Message = "Swap operation already in progress", + ShouldStopBot = false + }; + } + + // Check cooldown period (5 minutes between swaps) + if (_state.State.LastSwapTime.HasValue && + DateTime.UtcNow - _state.State.LastSwapTime.Value < TimeSpan.FromMinutes(5)) + { + _logger.LogInformation("Swap cooldown period active for agent {UserId}, bot {RequestingBotId} will wait", + this.GetPrimaryKeyLong(), requestingBotId); + return new BalanceCheckResult + { + IsSuccessful = false, + FailureReason = BalanceCheckFailureReason.SwapCooldownActive, + Message = "Swap cooldown period active", + ShouldStopBot = false + }; + } + + // Get or refresh cached balance data + var balanceData = await GetOrRefreshBalanceDataAsync(accountName); + if (balanceData == null) + { + _logger.LogError("Failed to get balance data for account {AccountName}, user {UserId}", + accountName, this.GetPrimaryKeyLong()); + return new BalanceCheckResult + { + IsSuccessful = false, + FailureReason = BalanceCheckFailureReason.BalanceFetchError, + Message = "Failed to fetch balance data", + ShouldStopBot = false + }; + } + + _logger.LogInformation("Agent {UserId} balance check - ETH: {EthValue:F2} USD, USDC: {UsdcValue:F2} USD (cached: {IsCached})", + this.GetPrimaryKeyLong(), balanceData.EthValueInUsd, balanceData.UsdcValue, + _state.State.CachedBalanceData?.IsValid == true); + + // Check USDC minimum balance first (this will stop the bot if insufficient) + if (balanceData.UsdcValue < Constants.GMX.Config.MinimumPositionAmount) + { + _logger.LogWarning("USDC balance is below minimum required amount - ETH: {EthValue:F2} USD, USDC: {UsdcValue:F2} USD (minimum: {Minimum})", + balanceData.EthValueInUsd, balanceData.UsdcValue, Constants.GMX.Config.MinimumPositionAmount); + return new BalanceCheckResult + { + IsSuccessful = false, + FailureReason = BalanceCheckFailureReason.InsufficientUsdcBelowMinimum, + Message = $"USDC balance below minimum required amount ({Constants.GMX.Config.MinimumPositionAmount} USD)", + ShouldStopBot = true + }; + } + + // If ETH balance is sufficient, return success + if (balanceData.EthValueInUsd >= Constants.GMX.Config.MinimumEthBalance) + { + return new BalanceCheckResult + { + IsSuccessful = true, + FailureReason = BalanceCheckFailureReason.None, + Message = "Balance check successful", + ShouldStopBot = false + }; + } + + // Check if we have enough USDC for swap (need at least 5 USD for swap) + if (balanceData.UsdcValue < (Constants.GMX.Config.MinimumPositionAmount + Constants.GMX.Config.AutoSwapAmount) ) + { + _logger.LogWarning("Insufficient USDC balance for swap - ETH: {EthValue:F2} USD, USDC: {UsdcValue:F2} USD (need {AutoSwapAmount} USD for swap)", + balanceData.EthValueInUsd, balanceData.UsdcValue, Constants.GMX.Config.AutoSwapAmount); + return new BalanceCheckResult + { + IsSuccessful = false, + FailureReason = BalanceCheckFailureReason.InsufficientUsdcForSwap, + Message = $"Insufficient USDC balance for swap (need {Constants.GMX.Config.AutoSwapAmount} USD)", + ShouldStopBot = true + }; + } + + // Mark swap as in progress + _state.State.IsSwapInProgress = true; + await _state.WriteStateAsync(); + + try + { + _logger.LogInformation("Initiating USDC to ETH swap for agent {UserId} - swapping 5 USDC", this.GetPrimaryKeyLong()); + + // Get user for the swap + var userId = (int)this.GetPrimaryKeyLong(); + var user = await _userService.GetUserByIdAsync(userId); + if (user == null) + { + _logger.LogError("User {UserId} not found for swap operation", userId); + return new BalanceCheckResult + { + IsSuccessful = false, + FailureReason = BalanceCheckFailureReason.BalanceFetchError, + Message = "User not found for swap operation", + ShouldStopBot = false + }; + } + + // Perform the swap + var swapInfo = await _accountService.SwapGmxTokensAsync(user, accountName, + Ticker.USDC, Ticker.ETH, 5); + + if (swapInfo.Success) + { + _logger.LogInformation("Successfully swapped 5 USDC to ETH for agent {UserId}, transaction hash: {Hash}", + userId, swapInfo.Hash); + + // Update last swap time and invalidate cache + _state.State.LastSwapTime = DateTime.UtcNow; + _state.State.CachedBalanceData = null; // Invalidate cache after successful swap + return new BalanceCheckResult + { + IsSuccessful = true, + FailureReason = BalanceCheckFailureReason.None, + Message = "Swap completed successfully", + ShouldStopBot = false + }; + } + else + { + _logger.LogError("Failed to swap USDC to ETH for agent {UserId}: {Error}", + userId, swapInfo.Error ?? swapInfo.Message); + return new BalanceCheckResult + { + IsSuccessful = false, + FailureReason = BalanceCheckFailureReason.SwapExecutionError, + Message = swapInfo.Error ?? swapInfo.Message ?? "Swap execution failed", + ShouldStopBot = false + }; + } + } + finally + { + // Always clear the swap in progress flag + _state.State.IsSwapInProgress = false; + await _state.WriteStateAsync(); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking/ensuring ETH balance for agent {UserId}, bot {RequestingBotId}", + this.GetPrimaryKeyLong(), requestingBotId); + + // Clear swap in progress flag on error + _state.State.IsSwapInProgress = false; + await _state.WriteStateAsync(); + + return new BalanceCheckResult + { + IsSuccessful = false, + FailureReason = BalanceCheckFailureReason.BalanceFetchError, + Message = ex.Message, + ShouldStopBot = false + }; + } + } + + /// + /// Gets cached balance data or fetches fresh data if cache is invalid/expired + /// + private async Task GetOrRefreshBalanceDataAsync(string accountName) + { + try + { + // Check if we have valid cached data for the same account + if (_state.State.CachedBalanceData?.IsValid == true && + _state.State.CachedBalanceData.AccountName == accountName) + { + _logger.LogDebug("Using cached balance data for account {AccountName}", accountName); + return _state.State.CachedBalanceData; + } + + // Fetch fresh balance data + _logger.LogInformation("Fetching fresh balance data for account {AccountName}", accountName); + + var userId = (int)this.GetPrimaryKeyLong(); + var user = await _userService.GetUserByIdAsync(userId); + if (user == null) + { + _logger.LogError("User {UserId} not found for balance check", userId); + return null; + } + + var userAccounts = await _accountService.GetAccountsByUserAsync(user, hideSecrets: true, true); + var account = userAccounts.FirstOrDefault(a => a.Name == accountName); + if (account == null) + { + _logger.LogError("Account {AccountName} not found for user {UserId}", accountName, userId); + return null; + } + + // Get current balances + var balances = await _exchangeService.GetBalances(account); + var ethBalance = balances.FirstOrDefault(b => b.TokenName?.ToUpper() == "ETH"); + var usdcBalance = balances.FirstOrDefault(b => b.TokenName?.ToUpper() == "USDC"); + + var ethValueInUsd = ethBalance?.Amount * ethBalance?.Price ?? 0; + var usdcValue = usdcBalance?.Value ?? 0; + + // Cache the balance data + var balanceData = new CachedBalanceData + { + LastFetched = DateTime.UtcNow, + AccountName = accountName, + EthValueInUsd = ethValueInUsd, + UsdcValue = usdcValue + }; + + _state.State.CachedBalanceData = balanceData; + await _state.WriteStateAsync(); + + return balanceData; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching balance data for account {AccountName}, user {UserId}", + accountName, this.GetPrimaryKeyLong()); + return null; + } + } } \ No newline at end of file diff --git a/src/Managing.Application/Bots/Grains/LiveTradingBotGrain.cs b/src/Managing.Application/Bots/Grains/LiveTradingBotGrain.cs index eca166c1..85ad9cec 100644 --- a/src/Managing.Application/Bots/Grains/LiveTradingBotGrain.cs +++ b/src/Managing.Application/Bots/Grains/LiveTradingBotGrain.cs @@ -3,7 +3,6 @@ using Managing.Application.Abstractions.Grains; using Managing.Application.Abstractions.Services; using Managing.Application.Orleans; using Managing.Application.Shared; -using Managing.Common; using Managing.Core; using Managing.Domain.Accounts; using Managing.Domain.Bots; @@ -108,19 +107,21 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable { // Registry says stopped, but check database to see if it should be running var databaseStatus = await GetDatabaseBotStatus(botId); - - _logger.LogInformation("LiveTradingBotGrain {GrainId} registry: {RegistryStatus}, database: {DatabaseStatus}", + + _logger.LogInformation( + "LiveTradingBotGrain {GrainId} registry: {RegistryStatus}, database: {DatabaseStatus}", botId, registryStatus, databaseStatus); if (databaseStatus == BotStatus.Running) { // Database says running but registry says stopped - trust database - _logger.LogWarning("Status mismatch detected for bot {BotId}. Registry: {RegistryStatus}, Database: {DatabaseStatus}. Trusting database and updating registry.", + _logger.LogWarning( + "Status mismatch detected for bot {BotId}. Registry: {RegistryStatus}, Database: {DatabaseStatus}. Trusting database and updating registry.", botId, registryStatus, databaseStatus); - + // Update registry to match database (source of truth) await botRegistry.UpdateBotStatus(botId, databaseStatus); - + // Now proceed with resuming the bot await ResumeBotInternalAsync(databaseStatus); } @@ -345,69 +346,41 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable return; } - // Check broker balance before running - var balances = await ServiceScopeHelpers.WithScopedService>(_scopeFactory, - async exchangeService => { return await exchangeService.GetBalances(_tradingBot.Account, false); }); - - var usdcBalance = balances.FirstOrDefault(b => b.TokenName == Ticker.USDC.ToString()); - var ethBalance = balances.FirstOrDefault(b => b.TokenName == Ticker.ETH.ToString()); - - // Check USDC balance first - if (usdcBalance?.Value < Constants.GMX.Config.MinimumPositionAmount) + // Use coordinated balance checking and swap management through AgentGrain + try { - await _tradingBot.LogWarning( - $"USDC balance is below {Constants.GMX.Config.MinimumPositionAmount} USD (actual: {usdcBalance?.Value:F2}). Stopping bot {_tradingBot.Identifier}."); + var agentGrain = GrainFactory.GetGrain(_state.State.User.Id); + var balanceCheckResult = await agentGrain.CheckAndEnsureEthBalanceAsync(_state.State.Identifier, _tradingBot.Account.Name); - await StopAsync(); - return; - } - - // Check ETH balance and perform automatic swap if needed - var ethValueInUsd = ethBalance?.Value * ethBalance?.Price ?? 0; - if (ethValueInUsd < 2) // ETH balance below 2 USD - { - await _tradingBot.LogWarning( - $"ETH balance is below 2 USD (actual: {ethValueInUsd:F2}). Attempting to swap USDC to ETH."); - - // Check if we have enough USDC for the swap - if (usdcBalance?.Value >= 5) // Need at least 5 USD for swap + if (!balanceCheckResult.IsSuccessful) { - try + // Log the specific reason for the failure + await _tradingBot.LogWarning($"Balance check failed: {balanceCheckResult.Message} (Reason: {balanceCheckResult.FailureReason})"); + + // Check if the bot should stop due to this failure + if (balanceCheckResult.ShouldStopBot) { - var swapInfo = await ServiceScopeHelpers.WithScopedService( - _scopeFactory, - async accountService => - { - return await accountService.SwapGmxTokensAsync(_state.State.User, - _tradingBot.Account.Name, Ticker.USDC, Ticker.ETH, 5); - }); - - if (swapInfo.Success) - { - await NotifyUserAboutSwap(true, 5, swapInfo.Hash); - } - else - { - await NotifyUserAboutSwap(false, 5, null, swapInfo.Error ?? swapInfo.Message); - await StopAsync(); - return; - } + await _tradingBot.LogWarning($"Stopping bot due to balance check failure: {balanceCheckResult.Message}"); + await StopAsync(); + return; } - catch (Exception ex) + else { - await NotifyUserAboutSwap(false, 5, null, ex.Message); + // Skip this execution cycle but continue running + await _tradingBot.LogInformation("Skipping this execution cycle due to balance check failure."); + return; } } else { - // Both USDC and ETH are low - stop the strategy - await _tradingBot.LogWarning( - $"Both USDC ({usdcBalance?.Value:F2}) and ETH ({ethValueInUsd:F2}) balances are low. Stopping bot {_tradingBot.Identifier}."); - - await StopAsync(); - return; + await _tradingBot.LogInformation($"Balance check successful: {balanceCheckResult.Message}"); } } + catch (Exception ex) + { + _logger.LogError(ex, "Error during coordinated balance check for bot {BotId}", _state.State.Identifier); + // Continue execution to avoid stopping the bot due to coordination errors + } // Execute the bot's Run method await _tradingBot.Run(); @@ -849,7 +822,7 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable try { _logger.LogInformation("Ping received for LiveTradingBotGrain {GrainId}", this.GetPrimaryKey()); - + // The grain activation (OnActivateAsync) will automatically call ResumeBotIfRequiredAsync() // which handles checking the registry status and re-registering reminders if needed // So we just need to return true to indicate the ping was received diff --git a/src/Managing.Application/Bots/Models/AgentGrainState.cs b/src/Managing.Application/Bots/Models/AgentGrainState.cs index 91458dde..3f3cded8 100644 --- a/src/Managing.Application/Bots/Models/AgentGrainState.cs +++ b/src/Managing.Application/Bots/Models/AgentGrainState.cs @@ -4,5 +4,91 @@ namespace Managing.Application.Bots.Models { public string AgentName { get; set; } public HashSet BotIds { get; set; } = new HashSet(); + + /// + /// Tracks if a swap operation is currently in progress to prevent multiple simultaneous swaps + /// + public bool IsSwapInProgress { get; set; } = false; + + /// + /// Timestamp of the last swap operation to implement cooldown period + /// + public DateTime? LastSwapTime { get; set; } = null; + + /// + /// Cached balance data to reduce external API calls + /// + public CachedBalanceData? CachedBalanceData { get; set; } = null; } + + /// + /// Cached balance data to avoid repeated external API calls + /// + public class CachedBalanceData + { + /// + /// When the balance was last fetched + /// + public DateTime LastFetched { get; set; } = DateTime.UtcNow; + + /// + /// The account name this balance data is for + /// + public string AccountName { get; set; } = string.Empty; + + /// + /// ETH balance in USD + /// + public decimal EthValueInUsd { get; set; } = 0; + + /// + /// USDC balance value + /// + public decimal UsdcValue { get; set; } = 0; + + /// + /// Whether the cached data is still valid (less than 1 minute old) + /// + public bool IsValid => DateTime.UtcNow - LastFetched < TimeSpan.FromMinutes(1.5); +} + +/// +/// Result of a balance check operation +/// +public class BalanceCheckResult +{ + /// + /// Whether the balance check was successful + /// + public bool IsSuccessful { get; set; } + + /// + /// The reason for failure if not successful + /// + public BalanceCheckFailureReason FailureReason { get; set; } + + /// + /// Additional details about the result + /// + public string Message { get; set; } = string.Empty; + + /// + /// Whether the bot should stop due to this result + /// + public bool ShouldStopBot { get; set; } +} + +/// +/// Reasons why a balance check might fail +/// +public enum BalanceCheckFailureReason +{ + None, + InsufficientUsdcBelowMinimum, + InsufficientUsdcForSwap, + SwapInProgress, + SwapCooldownActive, + BalanceFetchError, + SwapExecutionError +} } \ No newline at end of file diff --git a/src/Managing.Common/Constants.cs b/src/Managing.Common/Constants.cs index 7029ac83..c98f8d62 100644 --- a/src/Managing.Common/Constants.cs +++ b/src/Managing.Common/Constants.cs @@ -107,7 +107,9 @@ namespace Managing.Common public const int USD = 30; } - public const decimal MinimumPositionAmount = 3m; + public const decimal MinimumPositionAmount = 5m; + public const decimal MinimumEthBalance = 2m; + public const decimal AutoSwapAmount = 3m; } public class TokenAddress