Add ETH and USDC balance check before start/restart bot and autoswap

This commit is contained in:
2025-09-23 14:03:46 +07:00
parent d13ac9fd21
commit 40f3c66694
23 changed files with 847 additions and 284 deletions

View File

@@ -5,6 +5,7 @@ using Managing.Application.Abstractions.Services;
using Managing.Application.Bots.Models;
using Managing.Application.Orleans;
using Managing.Common;
using Managing.Core.Exceptions;
using Managing.Domain.Statistics;
using Microsoft.Extensions.Logging;
using static Managing.Common.Enums;
@@ -237,184 +238,193 @@ public class AgentGrain : Grain, IAgentGrain
public async Task<BalanceCheckResult> CheckAndEnsureEthBalanceAsync(Guid requestingBotId, string accountName)
{
try
// Check if a swap is already in progress
if (_state.State.IsSwapInProgress)
{
// 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 + (decimal)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, Constants.GMX.Config.AutoSwapAmount);
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}",
_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
};
}
// Clear swap in progress flag on error
_state.State.IsSwapInProgress = false;
await _state.WriteStateAsync();
// 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 = ex.Message,
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.MinimumTradeEthBalanceUsd)
{
return new BalanceCheckResult
{
IsSuccessful = true,
FailureReason = BalanceCheckFailureReason.None,
Message = "Balance check successful - Enough ETH balance for trading",
ShouldStopBot = false
};
}
if (balanceData.EthValueInUsd < Constants.GMX.Config.MinimumSwapEthBalanceUsd)
{
return new BalanceCheckResult
{
IsSuccessful = false,
FailureReason = BalanceCheckFailureReason.InsufficientEthBelowMinimum,
Message = "ETH balance below minimum required amount",
ShouldStopBot = true
};
}
// Check if we have enough USDC for swap (need at least 5 USD for swap)
if (balanceData.UsdcValue <
(Constants.GMX.Config.MinimumPositionAmount + (decimal)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);
// Perform the swap
var swapInfo = await _accountService.SwapGmxTokensAsync(user, accountName,
Ticker.USDC, Ticker.ETH, Constants.GMX.Config.AutoSwapAmount);
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 = true
};
}
}
catch (InvalidOperationException ex) when (ex.InnerException is InsufficientFundsException insufficientFundsEx)
{
// Handle allowance exception (insufficient ETH for gas fees)
_logger.LogError(insufficientFundsEx,
"Insufficient funds during autoswap for agent {UserId}: {ErrorMessage}",
this.GetPrimaryKeyLong(), insufficientFundsEx.Message);
return new BalanceCheckResult
{
IsSuccessful = false,
FailureReason = BalanceCheckFailureReason.SwapExecutionError,
Message = insufficientFundsEx.UserMessage ??
"Insufficient ETH for gas fees during autoswap. Bot cannot continue trading.",
ShouldStopBot = true
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during autoswap for agent {UserId}, bot {RequestingBotId}",
this.GetPrimaryKeyLong(), requestingBotId);
return new BalanceCheckResult
{
IsSuccessful = false,
FailureReason = BalanceCheckFailureReason.SwapExecutionError,
Message = ex.Message,
ShouldStopBot = true
};
}
finally
{
// Always clear the swap in progress flag
_state.State.IsSwapInProgress = false;
await _state.WriteStateAsync();
}
}
/// <summary>

View File

@@ -1,94 +1,110 @@
#nullable enable
namespace Managing.Application.Bots.Models
{
[GenerateSerializer]
public class AgentGrainState
{
public string AgentName { get; set; }
public HashSet<Guid> BotIds { get; set; } = new HashSet<Guid>();
[Id(0)] public string AgentName { get; set; } = string.Empty;
[Id(1)] public HashSet<Guid> BotIds { get; set; } = new HashSet<Guid>();
/// <summary>
/// Tracks if a swap operation is currently in progress to prevent multiple simultaneous swaps
/// </summary>
[Id(2)]
public bool IsSwapInProgress { get; set; } = false;
/// <summary>
/// Timestamp of the last swap operation to implement cooldown period
/// </summary>
[Id(3)]
public DateTime? LastSwapTime { get; set; } = null;
/// <summary>
/// Cached balance data to reduce external API calls
/// </summary>
[Id(4)]
public CachedBalanceData? CachedBalanceData { get; set; } = null;
}
/// <summary>
/// Cached balance data to avoid repeated external API calls
/// </summary>
[GenerateSerializer]
public class CachedBalanceData
{
/// <summary>
/// When the balance was last fetched
/// </summary>
[Id(0)]
public DateTime LastFetched { get; set; } = DateTime.UtcNow;
/// <summary>
/// The account name this balance data is for
/// </summary>
[Id(1)]
public string AccountName { get; set; } = string.Empty;
/// <summary>
/// ETH balance in USD
/// </summary>
[Id(2)]
public decimal EthValueInUsd { get; set; } = 0;
/// <summary>
/// USDC balance value
/// </summary>
[Id(3)]
public decimal UsdcValue { get; set; } = 0;
/// <summary>
/// Whether the cached data is still valid (less than 1 minute old)
/// </summary>
public bool IsValid => DateTime.UtcNow - LastFetched < TimeSpan.FromMinutes(1.5);
}
/// <summary>
/// Result of a balance check operation
/// </summary>
public class BalanceCheckResult
{
/// <summary>
/// Whether the balance check was successful
/// </summary>
public bool IsSuccessful { get; set; }
/// <summary>
/// The reason for failure if not successful
/// </summary>
public BalanceCheckFailureReason FailureReason { get; set; }
/// <summary>
/// Additional details about the result
/// </summary>
public string Message { get; set; } = string.Empty;
/// <summary>
/// Whether the bot should stop due to this result
/// </summary>
public bool ShouldStopBot { get; set; }
}
/// <summary>
/// Whether the cached data is still valid (less than 1 minute old)
/// </summary>
public bool IsValid => DateTime.UtcNow - LastFetched < TimeSpan.FromMinutes(1.5);
}
/// <summary>
/// Reasons why a balance check might fail
/// </summary>
public enum BalanceCheckFailureReason
{
None,
InsufficientUsdcBelowMinimum,
InsufficientUsdcForSwap,
SwapInProgress,
SwapCooldownActive,
BalanceFetchError,
SwapExecutionError
}
}
/// <summary>
/// Result of a balance check operation
/// </summary>
[GenerateSerializer]
public class BalanceCheckResult
{
/// <summary>
/// Whether the balance check was successful
/// </summary>
[Id(0)]
public bool IsSuccessful { get; set; }
/// <summary>
/// The reason for failure if not successful
/// </summary>
[Id(1)]
public BalanceCheckFailureReason FailureReason { get; set; }
/// <summary>
/// Additional details about the result
/// </summary>
[Id(2)]
public string Message { get; set; } = string.Empty;
/// <summary>
/// Whether the bot should stop due to this result
/// </summary>
[Id(3)]
public bool ShouldStopBot { get; set; }
}
/// <summary>
/// Reasons why a balance check might fail
/// </summary>
public enum BalanceCheckFailureReason
{
None,
InsufficientUsdcBelowMinimum,
InsufficientUsdcForSwap,
SwapInProgress,
SwapCooldownActive,
BalanceFetchError,
SwapExecutionError,
InsufficientEthBelowMinimum
}
}

View File

@@ -6,6 +6,7 @@ using Managing.Application.Trading.Commands;
using Managing.Application.Trading.Handlers;
using Managing.Common;
using Managing.Core;
using Managing.Core.Exceptions;
using Managing.Domain.Accounts;
using Managing.Domain.Bots;
using Managing.Domain.Candles;
@@ -822,6 +823,17 @@ public class TradingBotBase : ITradingBot
return null;
}
catch (InsufficientFundsException ex)
{
// Handle insufficient funds errors with user-friendly messaging
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
await LogWarning(ex.UserMessage);
// Log the technical details for debugging
Logger.LogError(ex, "Insufficient funds error for signal {SignalId}: {ErrorMessage}", signal.Identifier, ex.Message);
return null;
}
catch (Exception ex)
{
SetSignalStatus(signal.Identifier, SignalStatus.Expired);