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