547 lines
24 KiB
C#
547 lines
24 KiB
C#
using Managing.Application.Abstractions;
|
|
using Managing.Application.Abstractions.Grains;
|
|
using Managing.Application.Abstractions.Repositories;
|
|
using Managing.Application.Abstractions.Services;
|
|
using Managing.Application.Bots;
|
|
using Managing.Application.Bots.Models;
|
|
using Managing.Common;
|
|
using Managing.Core;
|
|
using Managing.Domain.Accounts;
|
|
using Managing.Domain.Bots;
|
|
using Managing.Domain.Scenarios;
|
|
using Managing.Domain.Shared.Helpers;
|
|
using Managing.Domain.Trades;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using static Managing.Common.Enums;
|
|
|
|
namespace Managing.Application.ManageBot
|
|
{
|
|
public class BotService : IBotService
|
|
{
|
|
private readonly IBotRepository _botRepository;
|
|
private readonly IMessengerService _messengerService;
|
|
private readonly ILogger<TradingBotBase> _tradingBotLogger;
|
|
private readonly ITradingService _tradingService;
|
|
private readonly IGrainFactory _grainFactory;
|
|
private readonly IServiceScopeFactory _scopeFactory;
|
|
|
|
|
|
public BotService(IBotRepository botRepository,
|
|
IMessengerService messengerService, ILogger<TradingBotBase> tradingBotLogger,
|
|
ITradingService tradingService, IGrainFactory grainFactory, IServiceScopeFactory scopeFactory)
|
|
{
|
|
_botRepository = botRepository;
|
|
_messengerService = messengerService;
|
|
_tradingBotLogger = tradingBotLogger;
|
|
_tradingService = tradingService;
|
|
_grainFactory = grainFactory;
|
|
_scopeFactory = scopeFactory;
|
|
}
|
|
|
|
public async Task<IEnumerable<Bot>> GetBotsAsync()
|
|
{
|
|
return await _botRepository.GetBotsAsync();
|
|
}
|
|
|
|
public async Task<IEnumerable<Bot>> GetBotsByStatusAsync(BotStatus status)
|
|
{
|
|
return await _botRepository.GetBotsByStatusAsync(status);
|
|
}
|
|
|
|
public async Task<BotStatus> StopBot(Guid identifier)
|
|
{
|
|
try
|
|
{
|
|
var grain = _grainFactory.GetGrain<ILiveTradingBotGrain>(identifier);
|
|
await grain.StopAsync();
|
|
return BotStatus.Stopped;
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
_tradingBotLogger.LogError(e, "Error stopping bot {Identifier}", identifier);
|
|
return BotStatus.Stopped;
|
|
}
|
|
}
|
|
|
|
public async Task<bool> DeleteBot(Guid identifier)
|
|
{
|
|
var grain = _grainFactory.GetGrain<ILiveTradingBotGrain>(identifier);
|
|
|
|
try
|
|
{
|
|
var config = await grain.GetConfiguration();
|
|
var account = await grain.GetAccount();
|
|
await grain.StopAsync();
|
|
await _botRepository.DeleteBot(identifier);
|
|
await grain.DeleteAsync();
|
|
|
|
var deleteMessage = $"🗑️ Bot Deleted\n\n" +
|
|
$"🎯 Agent: {account.User.AgentName}\n" +
|
|
$"🤖 Bot Name: {config.Name}\n" +
|
|
$"⏰ Deleted At: {DateTime.UtcNow:MMM dd, yyyy • HH:mm:ss} UTC\n\n" +
|
|
$"⚠️ Bot has been permanently deleted and all data removed";
|
|
|
|
await _messengerService.SendTradeMessage(deleteMessage, false, account.User);
|
|
return true;
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
_tradingBotLogger.LogError(e, "Error deleting bot {Identifier}", identifier);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public async Task<BotStatus> RestartBot(Guid identifier)
|
|
{
|
|
try
|
|
{
|
|
var registryGrain = _grainFactory.GetGrain<ILiveBotRegistryGrain>(0);
|
|
var previousStatus = await registryGrain.GetBotStatus(identifier);
|
|
|
|
// If bot is already up, return the status directly
|
|
if (previousStatus == BotStatus.Running)
|
|
{
|
|
return BotStatus.Running;
|
|
}
|
|
|
|
var botGrain = _grainFactory.GetGrain<ILiveTradingBotGrain>(identifier);
|
|
|
|
// Check balances for EVM/GMX V2 bots before starting/restarting
|
|
var botConfig = await botGrain.GetConfiguration();
|
|
var account = await ServiceScopeHelpers.WithScopedService<IAccountService, Account>(
|
|
_scopeFactory,
|
|
async accountService => await accountService.GetAccount(botConfig.AccountName, true, true));
|
|
|
|
if (account.Exchange == TradingExchanges.Evm || account.Exchange == TradingExchanges.GmxV2)
|
|
{
|
|
// Allocation guard: ensure this bot's configured balance fits in remaining allocation
|
|
var availableAllocation = await GetAvailableAllocationUsdAsync(account, identifier);
|
|
if (botConfig.BotTradingBalance > availableAllocation)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Insufficient available allocation. Requested: {botConfig.BotTradingBalance:F2} USDC, Available: {availableAllocation:F2} USDC.");
|
|
}
|
|
|
|
var balanceCheckResult = await CheckAccountBalancesAsync(account);
|
|
if (!balanceCheckResult.IsSuccessful)
|
|
{
|
|
_tradingBotLogger.LogWarning(
|
|
"Bot {Identifier} restart blocked due to insufficient balances: {Message}",
|
|
identifier, balanceCheckResult.Message);
|
|
throw new InvalidOperationException(balanceCheckResult.Message);
|
|
}
|
|
}
|
|
|
|
var grainState = await botGrain.GetBotDataAsync();
|
|
|
|
if (previousStatus == BotStatus.Saved)
|
|
{
|
|
// First time startup
|
|
await botGrain.StartAsync();
|
|
var startupMessage = $"🚀 Bot Started\n\n" +
|
|
$"🎯 Agent: {account.User.AgentName}\n" +
|
|
$"🤖 Bot Name: {grainState.Config.Name}\n" +
|
|
$"⏰ Started At: {DateTime.UtcNow:MMM dd, yyyy • HH:mm:ss} UTC\n" +
|
|
$"🕐 Startup Time: {grainState.StartupTime:MMM dd, yyyy • HH:mm:ss} UTC\n\n" +
|
|
$"✅ Bot has been successfully started and is now active";
|
|
|
|
await _messengerService.SendTradeMessage(startupMessage, false, account.User);
|
|
}
|
|
else
|
|
{
|
|
// Restart (bot was previously down)
|
|
await botGrain.RestartAsync();
|
|
var restartMessage = $"🔄 Bot Restarted\n\n" +
|
|
$"🎯 Agent: {account.User.AgentName}\n" +
|
|
$"🤖 Bot Name: {grainState.Config.Name}\n" +
|
|
$"⏰ Restarted At: {DateTime.UtcNow:MMM dd, yyyy • HH:mm:ss} UTC\n" +
|
|
$"🕐 New Startup Time: {grainState.StartupTime:MMM dd, yyyy • HH:mm:ss} UTC\n\n" +
|
|
$"🚀 Bot has been successfully restarted and is now active";
|
|
|
|
await _messengerService.SendTradeMessage(restartMessage, false, account.User);
|
|
}
|
|
|
|
return BotStatus.Running;
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
SentrySdk.CaptureException(e);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private async Task<Bot> GetBot(Guid identifier)
|
|
{
|
|
var bot = await _botRepository.GetBotByIdentifierAsync(identifier);
|
|
return bot;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the configuration of an existing bot without stopping and restarting it.
|
|
/// </summary>
|
|
/// <param name="identifier">The bot identifier</param>
|
|
/// <param name="newConfig">The new configuration to apply</param>
|
|
/// <returns>True if the configuration was successfully updated, false otherwise</returns>
|
|
public async Task<bool> UpdateBotConfiguration(Guid identifier, TradingBotConfig newConfig)
|
|
{
|
|
var grain = _grainFactory.GetGrain<ILiveTradingBotGrain>(identifier);
|
|
// Ensure the scenario is properly loaded from database if needed
|
|
if (newConfig.Scenario == null && !string.IsNullOrEmpty(newConfig.ScenarioName))
|
|
{
|
|
var scenario = await _tradingService.GetScenarioByNameAsync(newConfig.ScenarioName);
|
|
if (scenario != null)
|
|
{
|
|
newConfig.Scenario = LightScenario.FromScenario(scenario);
|
|
}
|
|
else
|
|
{
|
|
throw new ArgumentException(
|
|
$"Scenario '{newConfig.ScenarioName}' not found in database when updating configuration");
|
|
}
|
|
}
|
|
|
|
if (newConfig.Scenario == null)
|
|
{
|
|
throw new ArgumentException(
|
|
"Scenario object must be provided or ScenarioName must be valid when updating configuration");
|
|
}
|
|
|
|
return await grain.UpdateConfiguration(newConfig);
|
|
}
|
|
|
|
public async Task<TradingBotConfig> GetBotConfig(Guid identifier)
|
|
{
|
|
var grain = _grainFactory.GetGrain<ILiveTradingBotGrain>(identifier);
|
|
return await grain.GetConfiguration();
|
|
}
|
|
|
|
public async Task<IEnumerable<TradingBotConfig>> GetBotConfigsByIdsAsync(IEnumerable<Guid> botIds)
|
|
{
|
|
var configs = new List<TradingBotConfig>();
|
|
|
|
foreach (var botId in botIds)
|
|
{
|
|
try
|
|
{
|
|
var grain = _grainFactory.GetGrain<ILiveTradingBotGrain>(botId);
|
|
var config = await grain.GetConfiguration();
|
|
configs.Add(config);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_tradingBotLogger.LogWarning(ex, "Failed to get configuration for bot {BotId}", botId);
|
|
// Continue with other bots even if one fails
|
|
}
|
|
}
|
|
|
|
return configs;
|
|
}
|
|
|
|
public async Task<IEnumerable<string>> GetActiveBotsNamesAsync()
|
|
{
|
|
var bots = await _botRepository.GetBotsByStatusAsync(BotStatus.Running);
|
|
return bots.Select(b => b.Name);
|
|
}
|
|
|
|
public async Task<IEnumerable<Bot>> GetBotsByUser(int id)
|
|
{
|
|
return await _botRepository.GetBotsByUserIdAsync(id);
|
|
}
|
|
|
|
public async Task<IEnumerable<Bot>> GetBotsByIdsAsync(IEnumerable<Guid> botIds)
|
|
{
|
|
return await _botRepository.GetBotsByIdsAsync(botIds);
|
|
}
|
|
|
|
public async Task<Bot> GetBotByName(string name)
|
|
{
|
|
return await _botRepository.GetBotByNameAsync(name);
|
|
}
|
|
|
|
public async Task<Bot> GetBotByIdentifier(Guid identifier)
|
|
{
|
|
return await _botRepository.GetBotByIdentifierAsync(identifier);
|
|
}
|
|
|
|
public async Task<Position> OpenPositionManuallyAsync(Guid identifier, TradeDirection direction)
|
|
{
|
|
var grain = _grainFactory.GetGrain<ILiveTradingBotGrain>(identifier);
|
|
return await grain.OpenPositionManuallyAsync(direction);
|
|
}
|
|
|
|
public async Task<Position> ClosePositionAsync(Guid identifier, Guid positionId)
|
|
{
|
|
var grain = _grainFactory.GetGrain<ILiveTradingBotGrain>(identifier);
|
|
return await grain.ClosePositionAsync(positionId);
|
|
}
|
|
|
|
public async Task<bool> UpdateBotStatisticsAsync(Guid identifier)
|
|
{
|
|
try
|
|
{
|
|
var grain = _grainFactory.GetGrain<ILiveTradingBotGrain>(identifier);
|
|
var botData = await grain.GetBotDataAsync();
|
|
|
|
// Get the current bot from database
|
|
var existingBot = await _botRepository.GetBotByIdentifierAsync(identifier);
|
|
if (existingBot == null)
|
|
{
|
|
_tradingBotLogger.LogWarning("Bot {Identifier} not found in database for statistics update",
|
|
identifier);
|
|
return false;
|
|
}
|
|
|
|
// Calculate statistics using TradingBox helpers
|
|
var (tradeWins, tradeLosses) = TradingBox.GetWinLossCount(botData.Positions);
|
|
var pnl = botData.ProfitAndLoss;
|
|
var fees = botData.Positions.Values.Sum(p => p.CalculateTotalFees());
|
|
var volume = TradingBox.GetTotalVolumeTraded(botData.Positions);
|
|
|
|
// Calculate ROI based on total investment
|
|
var totalInvestment = botData.Positions.Values
|
|
.Where(p => p.IsFinished())
|
|
.Sum(p => p.Open.Quantity * p.Open.Price);
|
|
var netPnl = pnl - fees;
|
|
var roi = totalInvestment > 0 ? (netPnl / totalInvestment) * 100 : 0;
|
|
|
|
// Update bot statistics
|
|
existingBot.TradeWins = tradeWins;
|
|
existingBot.TradeLosses = tradeLosses;
|
|
existingBot.Pnl = pnl;
|
|
existingBot.Roi = roi;
|
|
existingBot.Volume = volume;
|
|
existingBot.Fees = fees;
|
|
|
|
// Use the new SaveBotStatisticsAsync method
|
|
return await SaveBotStatisticsAsync(existingBot);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
_tradingBotLogger.LogError(e, "Error updating bot statistics for {Identifier}", identifier);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public async Task<bool> SaveBotStatisticsAsync(Bot bot)
|
|
{
|
|
try
|
|
{
|
|
if (bot == null)
|
|
{
|
|
_tradingBotLogger.LogWarning("Cannot save bot statistics: bot object is null");
|
|
return false;
|
|
}
|
|
|
|
var existingBot = await _botRepository.GetBotByIdentifierAsync(bot.Identifier);
|
|
|
|
|
|
// Check if bot already exists in database
|
|
await ServiceScopeHelpers.WithScopedService<IBotRepository>(
|
|
_scopeFactory,
|
|
async repo =>
|
|
{
|
|
if (existingBot == null)
|
|
{
|
|
_tradingBotLogger.LogInformation("Updating existing bot statistics for bot {BotId}",
|
|
bot.Identifier);
|
|
// Insert new bot
|
|
await repo.InsertBotAsync(bot);
|
|
_tradingBotLogger.LogInformation(
|
|
"Created new bot statistics for bot {BotId}: Wins={Wins}, Losses={Losses}, PnL={PnL}, ROI={ROI}%, Volume={Volume}, Fees={Fees}",
|
|
bot.Identifier, bot.TradeWins, bot.TradeLosses, bot.Pnl, bot.Roi, bot.Volume, bot.Fees);
|
|
}
|
|
else if (existingBot.Status != bot.Status
|
|
|| existingBot.Pnl != Math.Round(bot.Pnl, 8)
|
|
|| existingBot.Roi != Math.Round(bot.Roi, 8)
|
|
|| existingBot.Volume != Math.Round(bot.Volume, 8)
|
|
|| existingBot.Fees != Math.Round(bot.Fees, 8)
|
|
|| existingBot.LongPositionCount != bot.LongPositionCount
|
|
|| existingBot.ShortPositionCount != bot.ShortPositionCount
|
|
|| !string.Equals(existingBot.Name, bot.Name, StringComparison.Ordinal)
|
|
|| existingBot.AccumulatedRunTimeSeconds != bot.AccumulatedRunTimeSeconds
|
|
|| existingBot.LastStartTime != bot.LastStartTime
|
|
|| existingBot.LastStopTime != bot.LastStopTime
|
|
|| existingBot.Ticker != bot.Ticker)
|
|
{
|
|
_tradingBotLogger.LogInformation("Update bot statistics for bot {BotId}",
|
|
bot.Identifier);
|
|
// Update existing bot
|
|
await repo.UpdateBot(bot);
|
|
_tradingBotLogger.LogDebug(
|
|
"Updated bot statistics for bot {BotId}: Wins={Wins}, Losses={Losses}, PnL={PnL}, ROI={ROI}%, Volume={Volume}, Fees={Fees}",
|
|
bot.Identifier, bot.TradeWins, bot.TradeLosses, bot.Pnl, bot.Roi, bot.Volume, bot.Fees);
|
|
}
|
|
});
|
|
|
|
return true;
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
_tradingBotLogger.LogError(e, "Error saving bot statistics for bot {BotId}", bot?.Identifier);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public async Task<decimal> GetAvailableAllocationUsdAsync(Account account,
|
|
Guid excludeIdentifier = default)
|
|
{
|
|
try
|
|
{
|
|
return await ServiceScopeHelpers.WithScopedService<IBotRepository, decimal>(
|
|
_scopeFactory,
|
|
async repo =>
|
|
{
|
|
// Get all bots for the account's user
|
|
var botsForUser = (await repo.GetBotsByUserIdAsync(account.User.Id)).ToList();
|
|
|
|
// Sum allocations for bots using this account name, excluding the requested identifier
|
|
var totalAllocatedForAccount = 0m;
|
|
var usdcBalance = account.Balances.FirstOrDefault(b => b.TokenName == Ticker.USDC.ToString());
|
|
|
|
Console.WriteLine($"Bots for user: {botsForUser.Count}");
|
|
Console.WriteLine($"Exclude identifier: {excludeIdentifier}");
|
|
|
|
foreach (var bot in botsForUser)
|
|
{
|
|
if (excludeIdentifier != default && bot.Identifier == excludeIdentifier)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (bot.Status == BotStatus.Stopped || bot.Status == BotStatus.Saved)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var grain = _grainFactory.GetGrain<ILiveTradingBotGrain>(bot.Identifier);
|
|
TradingBotConfig config;
|
|
try
|
|
{
|
|
config = await grain.GetConfiguration();
|
|
Console.WriteLine($"Bot Balance: {config.BotTradingBalance}");
|
|
}
|
|
catch
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (string.Equals(config.AccountName, account.Name, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
totalAllocatedForAccount += config.BotTradingBalance;
|
|
}
|
|
}
|
|
|
|
var usdcValue = usdcBalance?.Amount ?? 0m;
|
|
var available = usdcValue - totalAllocatedForAccount;
|
|
return available < 0m ? 0m : available;
|
|
});
|
|
}
|
|
catch
|
|
{
|
|
// On failure, be safe and return 0 to block over-allocation
|
|
return 0m;
|
|
}
|
|
}
|
|
|
|
public async Task<(IEnumerable<Bot> Bots, int TotalCount)> GetBotsPaginatedAsync(
|
|
int pageNumber,
|
|
int pageSize,
|
|
BotStatus? status = null,
|
|
string? name = null,
|
|
string? ticker = null,
|
|
string? agentName = null,
|
|
string sortBy = "CreateDate",
|
|
string sortDirection = "Desc")
|
|
{
|
|
return await ServiceScopeHelpers.WithScopedService<IBotRepository, (IEnumerable<Bot> Bots, int TotalCount)>(
|
|
_scopeFactory,
|
|
async repo =>
|
|
{
|
|
return await repo.GetBotsPaginatedAsync(
|
|
pageNumber,
|
|
pageSize,
|
|
status,
|
|
name,
|
|
ticker,
|
|
agentName,
|
|
sortBy,
|
|
sortDirection);
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks USDC and ETH balances for EVM/GMX V2 accounts
|
|
/// </summary>
|
|
/// <param name="account">The account to check balances for</param>
|
|
/// <returns>Balance check result</returns>
|
|
public async Task<BalanceCheckResult> CheckAccountBalancesAsync(Account account)
|
|
{
|
|
try
|
|
{
|
|
return await ServiceScopeHelpers
|
|
.WithScopedServices<IExchangeService, IAccountService, BalanceCheckResult>(
|
|
_scopeFactory,
|
|
async (exchangeService, accountService) =>
|
|
{
|
|
// 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;
|
|
|
|
_tradingBotLogger.LogInformation(
|
|
"Balance check for bot restart - Account: {AccountName}, ETH: {EthValue:F2} USD, USDC: {UsdcValue:F2} USD",
|
|
account.Name, ethValueInUsd, usdcValue);
|
|
|
|
// Check USDC minimum balance
|
|
if (usdcValue < Constants.GMX.Config.MinimumPositionAmount)
|
|
{
|
|
return new BalanceCheckResult
|
|
{
|
|
IsSuccessful = false,
|
|
FailureReason = BalanceCheckFailureReason.InsufficientUsdcBelowMinimum,
|
|
Message =
|
|
$"USDC balance ({usdcValue:F2} USD) is below minimum required amount ({Constants.GMX.Config.MinimumPositionAmount} USD). Please add more USDC to restart the bot.",
|
|
ShouldStopBot = true
|
|
};
|
|
}
|
|
|
|
// Check ETH minimum balance for trading
|
|
if (ethValueInUsd < Constants.GMX.Config.MinimumTradeEthBalanceUsd)
|
|
{
|
|
return new BalanceCheckResult
|
|
{
|
|
IsSuccessful = false,
|
|
FailureReason = BalanceCheckFailureReason.InsufficientEthBelowMinimum,
|
|
Message =
|
|
$"ETH balance ({ethValueInUsd:F2} USD) is below minimum required amount ({Constants.GMX.Config.MinimumTradeEthBalanceUsd} USD) for trading. Please add more ETH to restart the bot.",
|
|
ShouldStopBot = true
|
|
};
|
|
}
|
|
|
|
return new BalanceCheckResult
|
|
{
|
|
IsSuccessful = true,
|
|
FailureReason = BalanceCheckFailureReason.None,
|
|
Message = "Balance check successful - Sufficient USDC and ETH balances",
|
|
ShouldStopBot = false
|
|
};
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_tradingBotLogger.LogError(ex, "Error checking balances for account {AccountName}", account.Name);
|
|
return new BalanceCheckResult
|
|
{
|
|
IsSuccessful = false,
|
|
FailureReason = BalanceCheckFailureReason.BalanceFetchError,
|
|
Message = $"Failed to check balances: {ex.Message}",
|
|
ShouldStopBot = false
|
|
};
|
|
}
|
|
}
|
|
}
|
|
} |