Trading bot grain (#33)
* Trading bot Grain * Fix a bit more of the trading bot * Advance on the tradingbot grain * Fix build * Fix db script * Fix user login * Fix a bit backtest * Fix cooldown and backtest * start fixing bot start * Fix startup * Setup local db * Fix build and update candles and scenario * Add bot registry * Add reminder * Updateing the grains * fix bootstraping * Save stats on tick * Save bot data every tick * Fix serialization * fix save bot stats * Fix get candles * use dict instead of list for position * Switch hashset to dict * Fix a bit * Fix bot launch and bot view * add migrations * Remove the tolist * Add agent grain * Save agent summary * clean * Add save bot * Update get bots * Add get bots * Fix stop/restart * fix Update config * Update scanner table on new backtest saved * Fix backtestRowDetails.tsx * Fix agentIndex * Update agentIndex * Fix more things * Update user cache * Fix * Fix account load/start/restart/run
This commit is contained in:
@@ -1,49 +0,0 @@
|
||||
using Managing.Application.Abstractions;
|
||||
using Managing.Application.Abstractions.Services;
|
||||
using Managing.Application.ManageBot;
|
||||
using Managing.Domain.Bots;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Managing.Application.Bots.Base
|
||||
{
|
||||
public class BotFactory : IBotFactory
|
||||
{
|
||||
private readonly IExchangeService _exchangeService;
|
||||
private readonly IMessengerService _messengerService;
|
||||
private readonly IAccountService _accountService;
|
||||
private readonly ILogger<TradingBotBase> _tradingBotLogger;
|
||||
private readonly ITradingService _tradingService;
|
||||
private readonly IBotService _botService;
|
||||
private readonly IBackupBotService _backupBotService;
|
||||
|
||||
public BotFactory(
|
||||
IExchangeService exchangeService,
|
||||
ILogger<TradingBotBase> tradingBotLogger,
|
||||
IMessengerService messengerService,
|
||||
IAccountService accountService,
|
||||
ITradingService tradingService,
|
||||
IBotService botService,
|
||||
IBackupBotService backupBotService)
|
||||
{
|
||||
_tradingBotLogger = tradingBotLogger;
|
||||
_exchangeService = exchangeService;
|
||||
_messengerService = messengerService;
|
||||
_accountService = accountService;
|
||||
_tradingService = tradingService;
|
||||
_botService = botService;
|
||||
_backupBotService = backupBotService;
|
||||
}
|
||||
|
||||
public async Task<ITradingBot> CreateTradingBot(TradingBotConfig config)
|
||||
{
|
||||
// Delegate to BotService which handles scenario loading properly
|
||||
return await _botService.CreateTradingBot(config);
|
||||
}
|
||||
|
||||
public async Task<ITradingBot> CreateBacktestTradingBot(TradingBotConfig config)
|
||||
{
|
||||
// Delegate to BotService which handles scenario loading properly
|
||||
return await _botService.CreateBacktestTradingBot(config);
|
||||
}
|
||||
}
|
||||
}
|
||||
163
src/Managing.Application/Bots/Grains/AgentGrain.cs
Normal file
163
src/Managing.Application/Bots/Grains/AgentGrain.cs
Normal file
@@ -0,0 +1,163 @@
|
||||
using Managing.Application.Abstractions;
|
||||
using Managing.Application.Abstractions.Grains;
|
||||
using Managing.Application.Abstractions.Services;
|
||||
using Managing.Application.Bots.Models;
|
||||
using Managing.Domain.Statistics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static Managing.Common.Enums;
|
||||
|
||||
namespace Managing.Application.Bots.Grains;
|
||||
|
||||
public class AgentGrain : Grain, IAgentGrain, IRemindable
|
||||
{
|
||||
private readonly IPersistentState<AgentGrainState> _state;
|
||||
private readonly ILogger<AgentGrain> _logger;
|
||||
private readonly IBotService _botService;
|
||||
private readonly IStatisticService _statisticService;
|
||||
private const string _updateSummaryReminderName = "UpdateAgentSummary";
|
||||
|
||||
public AgentGrain(
|
||||
[PersistentState("agent-state", "agent-store")]
|
||||
IPersistentState<AgentGrainState> state,
|
||||
ILogger<AgentGrain> logger,
|
||||
IBotService botService,
|
||||
IStatisticService statisticService)
|
||||
{
|
||||
_state = state;
|
||||
_logger = logger;
|
||||
_botService = botService;
|
||||
_statisticService = statisticService;
|
||||
}
|
||||
|
||||
public override Task OnActivateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("AgentGrain activated for user {UserId}", this.GetPrimaryKeyLong());
|
||||
return base.OnActivateAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync(int userId, string agentName)
|
||||
{
|
||||
_state.State.AgentName = agentName;
|
||||
await _state.WriteStateAsync();
|
||||
_logger.LogInformation("Agent {UserId} initialized with name {AgentName}", userId, agentName);
|
||||
await RegisterReminderAsync();
|
||||
}
|
||||
|
||||
private async Task RegisterReminderAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Register a reminder that fires every 5 minutes
|
||||
await this.RegisterOrUpdateReminder(_updateSummaryReminderName, TimeSpan.FromMinutes(5),
|
||||
TimeSpan.FromMinutes(1));
|
||||
_logger.LogInformation("Reminder registered for agent {UserId} to update summary every 5 minutes",
|
||||
this.GetPrimaryKeyLong());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to register reminder for agent {UserId}", this.GetPrimaryKeyLong());
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ReceiveReminder(string reminderName, TickStatus status)
|
||||
{
|
||||
if (reminderName == _updateSummaryReminderName)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Reminder triggered for agent {UserId} to update summary",
|
||||
this.GetPrimaryKeyLong());
|
||||
await UpdateSummary();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error updating agent summary from reminder for user {UserId}",
|
||||
this.GetPrimaryKeyLong());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateSummary()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get all bots for this agent
|
||||
var bots = await _botService.GetBotsByIdsAsync(_state.State.BotIds);
|
||||
|
||||
// Calculate aggregated statistics from bot data
|
||||
var totalPnL = bots.Sum(b => b.Pnl);
|
||||
var totalWins = bots.Sum(b => b.TradeWins);
|
||||
var totalLosses = bots.Sum(b => b.TradeLosses);
|
||||
|
||||
// Calculate ROI based on total volume traded with proper division by zero handling
|
||||
var totalVolume = bots.Sum(b => b.Volume);
|
||||
decimal totalROI;
|
||||
|
||||
if (totalVolume > 0)
|
||||
{
|
||||
totalROI = (totalPnL / totalVolume) * 100;
|
||||
}
|
||||
else if (totalVolume == 0 && totalPnL == 0)
|
||||
{
|
||||
// No trading activity yet
|
||||
totalROI = 0;
|
||||
}
|
||||
else if (totalVolume == 0 && totalPnL != 0)
|
||||
{
|
||||
// Edge case: PnL exists but no volume (shouldn't happen in normal cases)
|
||||
_logger.LogWarning("Agent {UserId} has PnL {PnL} but zero volume", this.GetPrimaryKeyLong(), totalPnL);
|
||||
totalROI = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback for any other edge cases
|
||||
totalROI = 0;
|
||||
}
|
||||
|
||||
// Calculate Runtime based on the farthest date from bot startup times
|
||||
DateTime? runtime = null;
|
||||
if (bots.Any())
|
||||
{
|
||||
runtime = bots.Max(b => b.StartupTime);
|
||||
}
|
||||
|
||||
var summary = new AgentSummary
|
||||
{
|
||||
UserId = (int)this.GetPrimaryKeyLong(),
|
||||
AgentName = _state.State.AgentName,
|
||||
TotalPnL = totalPnL,
|
||||
Wins = totalWins,
|
||||
Losses = totalLosses,
|
||||
TotalROI = totalROI,
|
||||
Runtime = runtime,
|
||||
ActiveStrategiesCount = bots.Count(b => b.Status == BotStatus.Up),
|
||||
TotalVolume = totalVolume,
|
||||
};
|
||||
|
||||
// Save summary to database
|
||||
await _statisticService.SaveOrUpdateAgentSummary(summary);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error calculating agent summary for user {UserId}", this.GetPrimaryKeyLong());
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RegisterBotAsync(Guid botId)
|
||||
{
|
||||
if (_state.State.BotIds.Add(botId))
|
||||
{
|
||||
await _state.WriteStateAsync();
|
||||
_logger.LogInformation("Bot {BotId} registered to Agent {UserId}", botId, this.GetPrimaryKeyLong());
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UnregisterBotAsync(Guid botId)
|
||||
{
|
||||
if (_state.State.BotIds.Remove(botId))
|
||||
{
|
||||
await _state.WriteStateAsync();
|
||||
_logger.LogInformation("Bot {BotId} unregistered from Agent {UserId}", botId, this.GetPrimaryKeyLong());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
using Managing.Application.Abstractions.Grains;
|
||||
using Managing.Application.Abstractions.Models;
|
||||
using Managing.Application.Abstractions.Repositories;
|
||||
using Managing.Core.FixedSizedQueue;
|
||||
using Managing.Domain.Backtests;
|
||||
using Managing.Domain.Bots;
|
||||
using Managing.Domain.Candles;
|
||||
@@ -9,7 +7,6 @@ using Managing.Domain.Scenarios;
|
||||
using Managing.Domain.Shared.Helpers;
|
||||
using Managing.Domain.Strategies;
|
||||
using Managing.Domain.Strategies.Base;
|
||||
using Managing.Domain.Trades;
|
||||
using Managing.Domain.Users;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -52,7 +49,7 @@ public class BacktestTradingBotGrain : Grain, IBacktestTradingBotGrain
|
||||
/// <returns>The complete backtest result</returns>
|
||||
public async Task<LightBacktest> RunBacktestAsync(
|
||||
TradingBotConfig config,
|
||||
List<Candle> candles,
|
||||
HashSet<Candle> candles,
|
||||
User user = null,
|
||||
bool save = false,
|
||||
bool withCandles = false,
|
||||
@@ -66,7 +63,7 @@ public class BacktestTradingBotGrain : Grain, IBacktestTradingBotGrain
|
||||
|
||||
// Create a fresh TradingBotBase instance for this backtest
|
||||
var tradingBot = await CreateTradingBotInstance(config);
|
||||
tradingBot.Start();
|
||||
tradingBot.Account = user.Accounts.First(a => a.Name == config.AccountName);
|
||||
|
||||
var totalCandles = candles.Count;
|
||||
var currentCandle = 0;
|
||||
@@ -79,11 +76,15 @@ public class BacktestTradingBotGrain : Grain, IBacktestTradingBotGrain
|
||||
tradingBot.WalletBalances.Clear();
|
||||
tradingBot.WalletBalances.Add(candles.FirstOrDefault()!.Date, config.BotTradingBalance);
|
||||
|
||||
var fixedCandles = new HashSet<Candle>();
|
||||
// Process all candles following the exact pattern from GetBacktestingResult
|
||||
foreach (var candle in candles)
|
||||
{
|
||||
tradingBot.OptimizedCandles.Enqueue(candle);
|
||||
tradingBot.Candles.Add(candle);
|
||||
fixedCandles.Add(candle);
|
||||
tradingBot.LastCandle = candle;
|
||||
|
||||
// Update signals manually only for backtesting
|
||||
await tradingBot.UpdateSignals(fixedCandles);
|
||||
await tradingBot.Run();
|
||||
|
||||
currentCandle++;
|
||||
@@ -97,43 +98,16 @@ public class BacktestTradingBotGrain : Grain, IBacktestTradingBotGrain
|
||||
currentWalletBalance, currentCandle, totalCandles, candle.Date.ToString("yyyy-MM-dd HH:mm"));
|
||||
break;
|
||||
}
|
||||
|
||||
// Log progress every 10% or every 1000 candles, whichever comes first
|
||||
var currentPercentage = (int)((double)currentCandle / totalCandles * 100);
|
||||
var shouldLog = currentPercentage >= lastLoggedPercentage + 10 ||
|
||||
currentCandle % 1000 == 0 ||
|
||||
currentCandle == totalCandles;
|
||||
|
||||
if (shouldLog && currentPercentage > lastLoggedPercentage)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Backtest progress: {CurrentCandle}/{TotalCandles} ({Percentage}%) - Processing candle from {CandleDate}",
|
||||
currentCandle, totalCandles, currentPercentage, candle.Date.ToString("yyyy-MM-dd HH:mm"));
|
||||
lastLoggedPercentage = currentPercentage;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Backtest processing completed. Calculating final results...");
|
||||
|
||||
// Set all candles for final calculations
|
||||
tradingBot.Candles = new HashSet<Candle>(candles);
|
||||
|
||||
// Only calculate indicators values if withCandles is true
|
||||
Dictionary<IndicatorType, IndicatorsResultBase> indicatorsValues = null;
|
||||
if (withCandles)
|
||||
{
|
||||
// Convert LightScenario back to full Scenario for indicator calculations
|
||||
var fullScenario = config.Scenario.ToScenario();
|
||||
indicatorsValues = GetIndicatorsValues(fullScenario.Indicators, candles);
|
||||
}
|
||||
|
||||
// Calculate final results following the exact pattern from GetBacktestingResult
|
||||
var finalPnl = tradingBot.GetProfitAndLoss();
|
||||
var winRate = tradingBot.GetWinRate();
|
||||
var stats = TradingHelpers.GetStatistics(tradingBot.WalletBalances);
|
||||
var growthPercentage =
|
||||
TradingHelpers.GetGrowthFromInitalBalance(tradingBot.WalletBalances.FirstOrDefault().Value, finalPnl);
|
||||
var hodlPercentage = TradingHelpers.GetHodlPercentage(candles[0], candles.Last());
|
||||
var hodlPercentage = TradingHelpers.GetHodlPercentage(candles.First(), candles.Last());
|
||||
|
||||
var fees = tradingBot.GetTotalFees();
|
||||
var scoringParams = new BacktestScoringParams(
|
||||
@@ -148,7 +122,7 @@ public class BacktestTradingBotGrain : Grain, IBacktestTradingBotGrain
|
||||
maxDrawdown: stats.MaxDrawdown,
|
||||
initialBalance: tradingBot.WalletBalances.FirstOrDefault().Value,
|
||||
tradingBalance: config.BotTradingBalance,
|
||||
startDate: candles[0].Date,
|
||||
startDate: candles.First().Date,
|
||||
endDate: candles.Last().Date,
|
||||
timeframe: config.Timeframe,
|
||||
moneyManagement: config.MoneyManagement
|
||||
@@ -160,8 +134,8 @@ public class BacktestTradingBotGrain : Grain, IBacktestTradingBotGrain
|
||||
var finalRequestId = requestId ?? Guid.NewGuid().ToString();
|
||||
|
||||
// Create backtest result with conditional candles and indicators values
|
||||
var result = new Backtest(config, tradingBot.Positions, tradingBot.Signals.ToList(),
|
||||
withCandles ? candles : new List<Candle>())
|
||||
var result = new Backtest(config, tradingBot.Positions, tradingBot.Signals,
|
||||
withCandles ? candles : new HashSet<Candle>())
|
||||
{
|
||||
FinalPnl = finalPnl,
|
||||
WinRate = winRate,
|
||||
@@ -170,9 +144,6 @@ public class BacktestTradingBotGrain : Grain, IBacktestTradingBotGrain
|
||||
Fees = fees,
|
||||
WalletBalances = tradingBot.WalletBalances.ToList(),
|
||||
Statistics = stats,
|
||||
IndicatorsValues = withCandles
|
||||
? AggregateValues(indicatorsValues, tradingBot.IndicatorsValues)
|
||||
: new Dictionary<IndicatorType, IndicatorsResultBase>(),
|
||||
Score = scoringResult.Score,
|
||||
ScoreMessage = scoringResult.GenerateSummaryMessage(),
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
@@ -190,9 +161,6 @@ public class BacktestTradingBotGrain : Grain, IBacktestTradingBotGrain
|
||||
// Send notification if backtest meets criteria
|
||||
await SendBacktestNotificationIfCriteriaMet(result);
|
||||
|
||||
// Clean up the trading bot instance
|
||||
tradingBot.Stop();
|
||||
|
||||
// Convert Backtest to LightBacktest for safe Orleans serialization
|
||||
return ConvertToLightBacktest(result);
|
||||
}
|
||||
@@ -241,13 +209,6 @@ public class BacktestTradingBotGrain : Grain, IBacktestTradingBotGrain
|
||||
// Create the trading bot instance
|
||||
var logger = _scopeFactory.CreateScope().ServiceProvider.GetRequiredService<ILogger<TradingBotBase>>();
|
||||
var tradingBot = new TradingBotBase(logger, _scopeFactory, config);
|
||||
|
||||
// Set the user if available
|
||||
if (user != null)
|
||||
{
|
||||
tradingBot.User = user;
|
||||
}
|
||||
|
||||
return tradingBot;
|
||||
}
|
||||
|
||||
@@ -276,8 +237,7 @@ public class BacktestTradingBotGrain : Grain, IBacktestTradingBotGrain
|
||||
/// Aggregates indicator values (following Backtester.cs pattern)
|
||||
/// </summary>
|
||||
private Dictionary<IndicatorType, IndicatorsResultBase> AggregateValues(
|
||||
Dictionary<IndicatorType, IndicatorsResultBase> indicatorsValues,
|
||||
Dictionary<IndicatorType, IndicatorsResultBase> botStrategiesValues)
|
||||
Dictionary<IndicatorType, IndicatorsResultBase> indicatorsValues)
|
||||
{
|
||||
var result = new Dictionary<IndicatorType, IndicatorsResultBase>();
|
||||
foreach (var indicator in indicatorsValues)
|
||||
@@ -291,23 +251,17 @@ public class BacktestTradingBotGrain : Grain, IBacktestTradingBotGrain
|
||||
/// <summary>
|
||||
/// Gets indicators values (following Backtester.cs pattern)
|
||||
/// </summary>
|
||||
private Dictionary<IndicatorType, IndicatorsResultBase> GetIndicatorsValues(List<Indicator> indicators,
|
||||
List<Candle> candles)
|
||||
private Dictionary<IndicatorType, IndicatorsResultBase> GetIndicatorsValues(List<LightIndicator> indicators,
|
||||
HashSet<Candle> candles)
|
||||
{
|
||||
var indicatorsValues = new Dictionary<IndicatorType, IndicatorsResultBase>();
|
||||
var fixedCandles = new FixedSizeQueue<Candle>(10000);
|
||||
foreach (var candle in candles)
|
||||
{
|
||||
fixedCandles.Enqueue(candle);
|
||||
}
|
||||
|
||||
foreach (var indicator in indicators)
|
||||
{
|
||||
try
|
||||
{
|
||||
var s = ScenarioHelpers.BuildIndicator(indicator, 10000);
|
||||
s.Candles = fixedCandles;
|
||||
indicatorsValues[indicator.Type] = s.GetIndicatorValues();
|
||||
var builtIndicator = ScenarioHelpers.BuildIndicator(indicator);
|
||||
indicatorsValues[indicator.Type] = builtIndicator.GetIndicatorValues(candles);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
@@ -325,79 +279,4 @@ public class BacktestTradingBotGrain : Grain, IBacktestTradingBotGrain
|
||||
_isDisposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<BacktestProgress> GetBacktestProgressAsync()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task StartAsync()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task StopAsync()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<BotStatus> GetStatusAsync()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<TradingBotConfig> GetConfigurationAsync()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<Position> OpenPositionManuallyAsync(TradeDirection direction)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task ToggleIsForWatchOnlyAsync()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<TradingBotResponse> GetBotDataAsync()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task LoadBackupAsync(BotBackup backup)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task SaveBackupAsync()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<decimal> GetProfitAndLossAsync()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<int> GetWinRateAsync()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<long> GetExecutionCountAsync()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<DateTime> GetStartupTimeAsync()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<DateTime> GetCreateDateAsync()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
179
src/Managing.Application/Bots/Grains/LiveBotRegistryGrain.cs
Normal file
179
src/Managing.Application/Bots/Grains/LiveBotRegistryGrain.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
using Managing.Application.Abstractions.Grains;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static Managing.Common.Enums;
|
||||
|
||||
namespace Managing.Application.Bots.Grains;
|
||||
|
||||
/// <summary>
|
||||
/// Orleans grain for LiveBotRegistry operations.
|
||||
/// This grain acts as a central, durable directory for all LiveTradingBot grains.
|
||||
/// It maintains a persistent, up-to-date list of all known bot IDs and their status.
|
||||
/// </summary>
|
||||
public class LiveBotRegistryGrain : Grain, ILiveBotRegistryGrain
|
||||
{
|
||||
private readonly IPersistentState<BotRegistryState> _state;
|
||||
private readonly ILogger<LiveBotRegistryGrain> _logger;
|
||||
|
||||
public LiveBotRegistryGrain(
|
||||
[PersistentState("bot-registry", "registry-store")]
|
||||
IPersistentState<BotRegistryState> state,
|
||||
ILogger<LiveBotRegistryGrain> logger)
|
||||
{
|
||||
_state = state;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public override async Task OnActivateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await base.OnActivateAsync(cancellationToken);
|
||||
_logger.LogInformation("LiveBotRegistryGrain activated with {TotalBots} bots registered",
|
||||
_state.State.TotalBotsCount);
|
||||
}
|
||||
|
||||
public override async Task OnDeactivateAsync(DeactivationReason reason, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("LiveBotRegistryGrain deactivating. Reason: {Reason}. Total bots: {TotalBots}",
|
||||
reason.Description, _state.State.TotalBotsCount);
|
||||
await base.OnDeactivateAsync(reason, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task RegisterBot(Guid identifier, int userId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_state.State.Bots.ContainsKey(identifier))
|
||||
{
|
||||
_logger.LogWarning("Bot {Identifier} is already registered in the registry", identifier);
|
||||
return;
|
||||
}
|
||||
|
||||
var entry = new BotRegistryEntry(identifier, userId);
|
||||
_state.State.Bots[identifier] = entry;
|
||||
|
||||
// O(1) FIX: Increment the counters
|
||||
_state.State.TotalBotsCount++;
|
||||
_state.State.ActiveBotsCount++;
|
||||
_state.State.LastUpdated = DateTime.UtcNow;
|
||||
|
||||
await _state.WriteStateAsync();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Bot {Identifier} registered successfully for user {UserId}. Total bots: {TotalBots}, Active bots: {ActiveBots}",
|
||||
identifier, userId, _state.State.TotalBotsCount, _state.State.ActiveBotsCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to register bot {Identifier} for user {UserId}", identifier, userId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UnregisterBot(Guid identifier)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_state.State.Bots.TryGetValue(identifier, out var entryToRemove))
|
||||
{
|
||||
_logger.LogWarning("Bot {Identifier} is not registered in the registry", identifier);
|
||||
return;
|
||||
}
|
||||
|
||||
_state.State.Bots.Remove(identifier);
|
||||
|
||||
// O(1) FIX: Decrement the counters based on the removed entry's status
|
||||
_state.State.TotalBotsCount--;
|
||||
if (entryToRemove.Status == BotStatus.Up)
|
||||
{
|
||||
_state.State.ActiveBotsCount--;
|
||||
}
|
||||
|
||||
_state.State.LastUpdated = DateTime.UtcNow;
|
||||
|
||||
await _state.WriteStateAsync();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Bot {Identifier} unregistered successfully from user {UserId}. Total bots: {TotalBots}",
|
||||
identifier, entryToRemove.UserId, _state.State.TotalBotsCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to unregister bot {Identifier}", identifier);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<List<BotRegistryEntry>> GetAllBots()
|
||||
{
|
||||
var bots = _state.State.Bots.Values.ToList();
|
||||
_logger.LogDebug("Retrieved {Count} bots from registry", bots.Count);
|
||||
return Task.FromResult(bots);
|
||||
}
|
||||
|
||||
public Task<List<BotRegistryEntry>> GetBotsForUser(int userId)
|
||||
{
|
||||
var userBots = _state.State.Bots.Values
|
||||
.Where(b => b.UserId == userId)
|
||||
.ToList();
|
||||
|
||||
_logger.LogDebug("Retrieved {Count} bots for user {UserId}", userBots.Count, userId);
|
||||
return Task.FromResult(userBots);
|
||||
}
|
||||
|
||||
public async Task UpdateBotStatus(Guid identifier, BotStatus newStatus)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_state.State.Bots.TryGetValue(identifier, out var entry))
|
||||
{
|
||||
_logger.LogWarning("Bot {Identifier} is not registered in the registry, cannot update status",
|
||||
identifier);
|
||||
return;
|
||||
}
|
||||
|
||||
var previousStatus = entry.Status;
|
||||
|
||||
if (previousStatus == newStatus)
|
||||
{
|
||||
_logger.LogDebug("Bot {Identifier} status unchanged ({Status}), skipping state write", identifier,
|
||||
newStatus);
|
||||
return;
|
||||
}
|
||||
|
||||
// O(1) FIX: Conditionally adjust the counter
|
||||
if (newStatus == BotStatus.Up && previousStatus != BotStatus.Up)
|
||||
{
|
||||
_state.State.ActiveBotsCount++;
|
||||
}
|
||||
else if (newStatus != BotStatus.Up && previousStatus == BotStatus.Up)
|
||||
{
|
||||
_state.State.ActiveBotsCount--;
|
||||
}
|
||||
|
||||
entry.Status = newStatus;
|
||||
entry.LastStatusUpdate = DateTime.UtcNow;
|
||||
_state.State.LastUpdated = DateTime.UtcNow;
|
||||
|
||||
await _state.WriteStateAsync();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Bot {Identifier} status updated from {PreviousStatus} to {NewStatus}. Active bots: {ActiveBots}",
|
||||
identifier, previousStatus, newStatus, _state.State.ActiveBotsCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update status for bot {Identifier} to {Status}", identifier, newStatus);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<BotStatus> GetBotStatus(Guid identifier)
|
||||
{
|
||||
if (!_state.State.Bots.TryGetValue(identifier, out var entry))
|
||||
{
|
||||
_logger.LogWarning("Bot {Identifier} is not registered in the registry, returning None", identifier);
|
||||
return Task.FromResult(BotStatus.None);
|
||||
}
|
||||
|
||||
return Task.FromResult(entry.Status);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
using Managing.Application.Abstractions;
|
||||
using Managing.Application.Abstractions.Grains;
|
||||
using Managing.Application.Abstractions.Models;
|
||||
using Managing.Core;
|
||||
using Managing.Domain.Accounts;
|
||||
using Managing.Domain.Bots;
|
||||
using Managing.Domain.Shared.Helpers;
|
||||
using Managing.Domain.Trades;
|
||||
using Managing.Domain.Users;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static Managing.Common.Enums;
|
||||
@@ -13,125 +18,200 @@ namespace Managing.Application.Bots.Grains;
|
||||
/// Uses composition with TradingBotBase to maintain separation of concerns.
|
||||
/// This grain handles live trading scenarios with real-time market data and execution.
|
||||
/// </summary>
|
||||
public class LiveTradingBotGrain : Grain<TradingBotGrainState>, ITradingBotGrain
|
||||
public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable
|
||||
{
|
||||
private readonly IPersistentState<TradingBotGrainState> _state;
|
||||
private readonly ILogger<LiveTradingBotGrain> _logger;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private TradingBotBase? _tradingBot;
|
||||
private IDisposable? _timer;
|
||||
private bool _isDisposed = false;
|
||||
private string _reminderName = "RebootReminder";
|
||||
|
||||
public LiveTradingBotGrain(
|
||||
[PersistentState("live-trading-bot", "bot-store")]
|
||||
IPersistentState<TradingBotGrainState> state,
|
||||
ILogger<LiveTradingBotGrain> logger,
|
||||
IServiceScopeFactory scopeFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_scopeFactory = scopeFactory;
|
||||
_state = state;
|
||||
}
|
||||
|
||||
public override async Task OnActivateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await base.OnActivateAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("LiveTradingBotGrain {GrainId} activated", this.GetPrimaryKey());
|
||||
|
||||
// Initialize the grain state if not already done
|
||||
if (!State.IsInitialized)
|
||||
{
|
||||
State.Identifier = this.GetPrimaryKey().ToString();
|
||||
State.CreateDate = DateTime.UtcNow;
|
||||
State.Status = BotStatus.Down;
|
||||
State.IsInitialized = true;
|
||||
await WriteStateAsync();
|
||||
}
|
||||
await base.OnActivateAsync(cancellationToken);
|
||||
await ResumeBotIfRequiredAsync();
|
||||
}
|
||||
|
||||
public override async Task OnDeactivateAsync(DeactivationReason reason, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("LiveTradingBotGrain {GrainId} deactivating. Reason: {Reason}",
|
||||
_logger.LogInformation("LiveTradingBotGrain {GrainId} deactivating. Reason: {Reason}",
|
||||
this.GetPrimaryKey(), reason.Description);
|
||||
|
||||
// Stop the timer and trading bot
|
||||
await StopAsync();
|
||||
|
||||
|
||||
StopAndDisposeTimer();
|
||||
await base.OnDeactivateAsync(reason, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task CreateAsync(TradingBotConfig config, User user)
|
||||
{
|
||||
if (config == null || string.IsNullOrEmpty(config.Name))
|
||||
{
|
||||
throw new InvalidOperationException("Bot configuration is not properly initialized");
|
||||
}
|
||||
|
||||
if (config.IsForBacktest)
|
||||
{
|
||||
throw new InvalidOperationException("LiveTradingBotGrain cannot be used for backtesting");
|
||||
}
|
||||
|
||||
// This is a new bot, so we can assume it's not registered or active.
|
||||
_state.State.Config = config;
|
||||
_state.State.User = user;
|
||||
_state.State.CreateDate = DateTime.UtcNow;
|
||||
_state.State.Identifier = this.GetPrimaryKey();
|
||||
await _state.WriteStateAsync();
|
||||
|
||||
var botRegistry = GrainFactory.GetGrain<ILiveBotRegistryGrain>(0);
|
||||
await botRegistry.RegisterBot(_state.State.Identifier, user.Id);
|
||||
|
||||
// Register the bot with the user's agent
|
||||
var agentGrain = GrainFactory.GetGrain<IAgentGrain>(user.Id);
|
||||
await agentGrain.RegisterBotAsync(_state.State.Identifier);
|
||||
|
||||
await SaveBotAsync(BotStatus.None);
|
||||
|
||||
_logger.LogInformation("LiveTradingBotGrain {GrainId} created successfully", this.GetPrimaryKey());
|
||||
}
|
||||
|
||||
private async Task ResumeBotIfRequiredAsync()
|
||||
{
|
||||
// Make the network call to the registry to get the source of truth
|
||||
var botRegistry = GrainFactory.GetGrain<ILiveBotRegistryGrain>(0);
|
||||
var botId = this.GetPrimaryKey();
|
||||
var botStatus = await botRegistry.GetBotStatus(botId);
|
||||
|
||||
_logger.LogInformation("LiveTradingBotGrain {GrainId} activated. Registry status: {Status}",
|
||||
botId, botStatus);
|
||||
|
||||
if (botStatus == BotStatus.Up && _tradingBot == null)
|
||||
{
|
||||
// Now, we can proceed with resuming the bot.
|
||||
await ResumeBotInternalAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ResumeBotInternalAsync()
|
||||
{
|
||||
// The core of this method remains idempotent thanks to the _tradingBot null check
|
||||
if (_tradingBot != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Load state from persisted grain state
|
||||
_tradingBot = CreateTradingBotInstance(_state.State.Config);
|
||||
LoadStateIntoBase();
|
||||
await _tradingBot.Start();
|
||||
|
||||
// Start the in-memory timer and persistent reminder
|
||||
RegisterAndStartTimer();
|
||||
await RegisterReminder();
|
||||
await SaveBotAsync(BotStatus.Up);
|
||||
_logger.LogInformation("LiveTradingBotGrain {GrainId} resumed successfully", this.GetPrimaryKey());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to resume bot {GrainId}", this.GetPrimaryKey());
|
||||
// If resume fails, update the status to Down via the registry and stop
|
||||
await UpdateBotRegistryStatus(BotStatus.Down);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StartAsync()
|
||||
{
|
||||
var botRegistry = GrainFactory.GetGrain<ILiveBotRegistryGrain>(0);
|
||||
var botId = this.GetPrimaryKey();
|
||||
var status = await botRegistry.GetBotStatus(botId);
|
||||
|
||||
// This is the new idempotency check, using the registry as the source of truth
|
||||
if (status == BotStatus.Up && _tradingBot != null)
|
||||
{
|
||||
await RegisterReminder();
|
||||
_logger.LogInformation("LiveTradingBotGrain {GrainId} is already running", this.GetPrimaryKey());
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (State.Status == BotStatus.Up)
|
||||
{
|
||||
_logger.LogWarning("Bot {GrainId} is already running", this.GetPrimaryKey());
|
||||
return;
|
||||
}
|
||||
|
||||
if (State.Config == null || string.IsNullOrEmpty(State.Config.Name))
|
||||
{
|
||||
throw new InvalidOperationException("Bot configuration is not properly initialized");
|
||||
}
|
||||
|
||||
// Ensure this is not a backtest configuration
|
||||
if (State.Config.IsForBacktest)
|
||||
{
|
||||
throw new InvalidOperationException("LiveTradingBotGrain cannot be used for backtesting");
|
||||
}
|
||||
|
||||
// Create the TradingBotBase instance using composition
|
||||
_tradingBot = await CreateTradingBotInstance();
|
||||
|
||||
// Load backup if available
|
||||
if (State.User != null)
|
||||
{
|
||||
await LoadBackupFromState();
|
||||
}
|
||||
|
||||
// Start the trading bot
|
||||
_tradingBot.Start();
|
||||
|
||||
// Update state
|
||||
State.Status = BotStatus.Up;
|
||||
State.StartupTime = DateTime.UtcNow;
|
||||
await WriteStateAsync();
|
||||
|
||||
// Start Orleans timer for periodic execution
|
||||
StartTimer();
|
||||
// Resume the bot using the internal logic
|
||||
await ResumeBotInternalAsync();
|
||||
|
||||
// Update registry status (if it was previously 'Down')
|
||||
await UpdateBotRegistryStatus(BotStatus.Up);
|
||||
_logger.LogInformation("LiveTradingBotGrain {GrainId} started successfully", this.GetPrimaryKey());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to start LiveTradingBotGrain {GrainId}", this.GetPrimaryKey());
|
||||
State.Status = BotStatus.Down;
|
||||
await WriteStateAsync();
|
||||
// Ensure registry status is correct even on failure
|
||||
await UpdateBotRegistryStatus(BotStatus.Down);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RegisterReminder()
|
||||
{
|
||||
var reminderPeriod = TimeSpan.FromMinutes(2);
|
||||
await this.RegisterOrUpdateReminder(_reminderName, reminderPeriod, reminderPeriod);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the Orleans timer for periodic bot execution
|
||||
/// </summary>
|
||||
private void RegisterAndStartTimer()
|
||||
{
|
||||
if (_tradingBot == null) return;
|
||||
|
||||
if (_timer != null) return;
|
||||
|
||||
_timer = this.RegisterGrainTimer(
|
||||
async _ => await ExecuteBotCycle(),
|
||||
new GrainTimerCreationOptions
|
||||
{
|
||||
Period = TimeSpan.FromMinutes(1),
|
||||
DueTime = TimeSpan.FromMinutes(1),
|
||||
KeepAlive = true
|
||||
});
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
// The check is now against the registry status
|
||||
var botRegistry = GrainFactory.GetGrain<ILiveBotRegistryGrain>(0);
|
||||
var botStatus = await botRegistry.GetBotStatus(this.GetPrimaryKey());
|
||||
if (botStatus == BotStatus.Down)
|
||||
{
|
||||
_logger.LogInformation("Bot {GrainId} is already stopped", this.GetPrimaryKey());
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Stop the timer
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
StopAndDisposeTimer();
|
||||
await UnregisterReminder();
|
||||
|
||||
// Stop the trading bot
|
||||
if (_tradingBot != null)
|
||||
{
|
||||
_tradingBot.Stop();
|
||||
|
||||
// Save backup before stopping
|
||||
await SaveBackupToState();
|
||||
|
||||
_tradingBot = null;
|
||||
}
|
||||
|
||||
// Update state
|
||||
State.Status = BotStatus.Down;
|
||||
await WriteStateAsync();
|
||||
// Sync state from the volatile TradingBotBase before destroying it
|
||||
SyncStateFromBase();
|
||||
await _state.WriteStateAsync();
|
||||
await SaveBotAsync(BotStatus.Down);
|
||||
_tradingBot = null;
|
||||
|
||||
await UpdateBotRegistryStatus(BotStatus.Down);
|
||||
_logger.LogInformation("LiveTradingBotGrain {GrainId} stopped successfully", this.GetPrimaryKey());
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -141,50 +221,88 @@ public class LiveTradingBotGrain : Grain<TradingBotGrainState>, ITradingBotGrain
|
||||
}
|
||||
}
|
||||
|
||||
public Task<BotStatus> GetStatusAsync()
|
||||
private void StopAndDisposeTimer()
|
||||
{
|
||||
return Task.FromResult(State.Status);
|
||||
if (_timer != null)
|
||||
{
|
||||
// Stop the timer
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<TradingBotConfig> GetConfigurationAsync()
|
||||
private async Task UnregisterReminder()
|
||||
{
|
||||
return Task.FromResult(State.Config);
|
||||
var reminder = await this.GetReminder(_reminderName);
|
||||
if (reminder != null)
|
||||
{
|
||||
await this.UnregisterReminder(reminder);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateConfigurationAsync(TradingBotConfig newConfig)
|
||||
/// <summary>
|
||||
/// Creates a TradingBotBase instance using composition
|
||||
/// </summary>
|
||||
private TradingBotBase CreateTradingBotInstance(TradingBotConfig config)
|
||||
{
|
||||
if (string.IsNullOrEmpty(config.AccountName))
|
||||
{
|
||||
throw new InvalidOperationException("Account name is required for live trading");
|
||||
}
|
||||
|
||||
// Create the trading bot instance
|
||||
var logger = _scopeFactory.CreateScope().ServiceProvider.GetRequiredService<ILogger<TradingBotBase>>();
|
||||
var tradingBot = new TradingBotBase(logger, _scopeFactory, config);
|
||||
|
||||
// Restore state from grain state
|
||||
tradingBot.Signals = _state.State.Signals;
|
||||
tradingBot.Positions = _state.State.Positions;
|
||||
tradingBot.WalletBalances = _state.State.WalletBalances;
|
||||
tradingBot.PreloadedCandlesCount = _state.State.PreloadedCandlesCount;
|
||||
tradingBot.ExecutionCount = _state.State.ExecutionCount;
|
||||
tradingBot.Identifier = _state.State.Identifier;
|
||||
tradingBot.LastPositionClosingTime = _state.State.LastPositionClosingTime;
|
||||
|
||||
return tradingBot;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Executes one cycle of the trading bot
|
||||
/// </summary>
|
||||
private async Task ExecuteBotCycle()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_tradingBot == null)
|
||||
{
|
||||
throw new InvalidOperationException("Bot is not running");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure this is not a backtest configuration
|
||||
if (newConfig.IsForBacktest)
|
||||
{
|
||||
throw new InvalidOperationException("LiveTradingBotGrain cannot be used for backtesting");
|
||||
}
|
||||
// Execute the bot's Run method
|
||||
await _tradingBot.Run();
|
||||
SyncStateFromBase();
|
||||
await _state.WriteStateAsync();
|
||||
|
||||
// Update the configuration in the trading bot
|
||||
var success = await _tradingBot.UpdateConfiguration(newConfig);
|
||||
|
||||
if (success)
|
||||
{
|
||||
// Update the state
|
||||
State.Config = newConfig;
|
||||
await WriteStateAsync();
|
||||
}
|
||||
|
||||
return success;
|
||||
// Save bot statistics to database
|
||||
await SaveBotAsync(BotStatus.Up);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Gracefully handle disposed service provider during shutdown
|
||||
_logger.LogInformation("Service provider disposed during shutdown for LiveTradingBotGrain {GrainId}",
|
||||
this.GetPrimaryKey());
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update configuration for LiveTradingBotGrain {GrainId}", this.GetPrimaryKey());
|
||||
return false;
|
||||
// TODO : Turn off the bot if an error occurs
|
||||
_logger.LogError(ex, "Error during bot execution cycle for LiveTradingBotGrain {GrainId}",
|
||||
this.GetPrimaryKey());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task<Position> OpenPositionManuallyAsync(TradeDirection direction)
|
||||
{
|
||||
try
|
||||
@@ -198,12 +316,14 @@ public class LiveTradingBotGrain : Grain<TradingBotGrainState>, ITradingBotGrain
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to open manual position for LiveTradingBotGrain {GrainId}", this.GetPrimaryKey());
|
||||
_logger.LogError(ex, "Failed to open manual position for LiveTradingBotGrain {GrainId}",
|
||||
this.GetPrimaryKey());
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ToggleIsForWatchOnlyAsync()
|
||||
|
||||
public Task<TradingBotResponse> GetBotDataAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -212,39 +332,20 @@ public class LiveTradingBotGrain : Grain<TradingBotGrainState>, ITradingBotGrain
|
||||
throw new InvalidOperationException("Bot is not running");
|
||||
}
|
||||
|
||||
await _tradingBot.ToggleIsForWatchOnly();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to toggle watch-only mode for LiveTradingBotGrain {GrainId}", this.GetPrimaryKey());
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TradingBotResponse> GetBotDataAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_tradingBot == null)
|
||||
return Task.FromResult(new TradingBotResponse
|
||||
{
|
||||
throw new InvalidOperationException("Bot is not running");
|
||||
}
|
||||
|
||||
return new TradingBotResponse
|
||||
{
|
||||
Identifier = State.Identifier,
|
||||
Name = State.Name,
|
||||
Status = State.Status,
|
||||
Config = State.Config,
|
||||
Identifier = _state.State.Identifier,
|
||||
Name = _state.State.Name,
|
||||
Config = _state.State.Config,
|
||||
Positions = _tradingBot.Positions,
|
||||
Signals = _tradingBot.Signals.ToList(),
|
||||
Signals = _tradingBot.Signals,
|
||||
WalletBalances = _tradingBot.WalletBalances,
|
||||
ProfitAndLoss = _tradingBot.GetProfitAndLoss(),
|
||||
WinRate = _tradingBot.GetWinRate(),
|
||||
ExecutionCount = _tradingBot.ExecutionCount,
|
||||
StartupTime = State.StartupTime,
|
||||
CreateDate = State.CreateDate
|
||||
};
|
||||
ExecutionCount = _state.State.ExecutionCount,
|
||||
StartupTime = _state.State.StartupTime,
|
||||
CreateDate = _state.State.CreateDate
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -253,244 +354,236 @@ public class LiveTradingBotGrain : Grain<TradingBotGrainState>, ITradingBotGrain
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LoadBackupAsync(BotBackup backup)
|
||||
private void LoadStateIntoBase()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_tradingBot == null)
|
||||
{
|
||||
throw new InvalidOperationException("Bot is not running");
|
||||
}
|
||||
if (_tradingBot == null)
|
||||
_tradingBot = CreateTradingBotInstance(_state.State.Config);
|
||||
|
||||
_tradingBot.LoadBackup(backup);
|
||||
|
||||
// Update state from backup
|
||||
State.User = backup.User;
|
||||
State.Identifier = backup.Identifier;
|
||||
State.Status = backup.LastStatus;
|
||||
State.CreateDate = backup.Data.CreateDate;
|
||||
State.StartupTime = backup.Data.StartupTime;
|
||||
await WriteStateAsync();
|
||||
if (_tradingBot == null) throw new InvalidOperationException("TradingBotBase instance could not be created");
|
||||
|
||||
_logger.LogInformation("Backup loaded successfully for LiveTradingBotGrain {GrainId}", this.GetPrimaryKey());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load backup for LiveTradingBotGrain {GrainId}", this.GetPrimaryKey());
|
||||
throw;
|
||||
}
|
||||
_tradingBot.Signals = _state.State.Signals;
|
||||
_tradingBot.Positions = _state.State.Positions;
|
||||
_tradingBot.WalletBalances = _state.State.WalletBalances;
|
||||
_tradingBot.PreloadedCandlesCount = _state.State.PreloadedCandlesCount;
|
||||
_tradingBot.ExecutionCount = _state.State.ExecutionCount;
|
||||
_tradingBot.Identifier = _state.State.Identifier;
|
||||
_tradingBot.LastPositionClosingTime = _state.State.LastPositionClosingTime;
|
||||
}
|
||||
|
||||
public async Task SaveBackupAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_tradingBot == null)
|
||||
{
|
||||
throw new InvalidOperationException("Bot is not running");
|
||||
}
|
||||
|
||||
await _tradingBot.SaveBackup();
|
||||
await SaveBackupToState();
|
||||
|
||||
_logger.LogInformation("Backup saved successfully for LiveTradingBotGrain {GrainId}", this.GetPrimaryKey());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save backup for LiveTradingBotGrain {GrainId}", this.GetPrimaryKey());
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<decimal> GetProfitAndLossAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_tradingBot == null)
|
||||
{
|
||||
throw new InvalidOperationException("Bot is not running");
|
||||
}
|
||||
|
||||
return _tradingBot.GetProfitAndLoss();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get P&L for LiveTradingBotGrain {GrainId}", this.GetPrimaryKey());
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> GetWinRateAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_tradingBot == null)
|
||||
{
|
||||
throw new InvalidOperationException("Bot is not running");
|
||||
}
|
||||
|
||||
return _tradingBot.GetWinRate();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get win rate for LiveTradingBotGrain {GrainId}", this.GetPrimaryKey());
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<long> GetExecutionCountAsync()
|
||||
{
|
||||
return Task.FromResult(State.ExecutionCount);
|
||||
}
|
||||
|
||||
public Task<DateTime> GetStartupTimeAsync()
|
||||
{
|
||||
return Task.FromResult(State.StartupTime);
|
||||
}
|
||||
|
||||
public Task<DateTime> GetCreateDateAsync()
|
||||
{
|
||||
return Task.FromResult(State.CreateDate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a TradingBotBase instance using composition
|
||||
/// </summary>
|
||||
private async Task<TradingBotBase> CreateTradingBotInstance()
|
||||
{
|
||||
// Validate configuration for live trading
|
||||
if (State.Config == null)
|
||||
{
|
||||
throw new InvalidOperationException("Bot configuration is not initialized");
|
||||
}
|
||||
|
||||
if (State.Config.IsForBacktest)
|
||||
{
|
||||
throw new InvalidOperationException("LiveTradingBotGrain cannot be used for backtesting");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(State.Config.AccountName))
|
||||
{
|
||||
throw new InvalidOperationException("Account name is required for live trading");
|
||||
}
|
||||
|
||||
// Create the trading bot instance
|
||||
var logger = _scopeFactory.CreateScope().ServiceProvider.GetRequiredService<ILogger<TradingBotBase>>();
|
||||
var tradingBot = new TradingBotBase(logger, _scopeFactory, State.Config);
|
||||
|
||||
// Set the user if available
|
||||
if (State.User != null)
|
||||
{
|
||||
tradingBot.User = State.User;
|
||||
}
|
||||
|
||||
return tradingBot;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the Orleans timer for periodic bot execution
|
||||
/// </summary>
|
||||
private void StartTimer()
|
||||
private void SyncStateFromBase()
|
||||
{
|
||||
if (_tradingBot == null) return;
|
||||
|
||||
var interval = _tradingBot.Interval;
|
||||
_timer = RegisterTimer(
|
||||
async _ => await ExecuteBotCycle(),
|
||||
null,
|
||||
TimeSpan.FromMilliseconds(interval),
|
||||
TimeSpan.FromMilliseconds(interval));
|
||||
_state.State.Signals = _tradingBot.Signals;
|
||||
_state.State.Positions = _tradingBot.Positions;
|
||||
_state.State.WalletBalances = _tradingBot.WalletBalances;
|
||||
_state.State.PreloadedCandlesCount = _tradingBot.PreloadedCandlesCount;
|
||||
_state.State.ExecutionCount = _tradingBot.ExecutionCount;
|
||||
_state.State.Identifier = _tradingBot.Identifier;
|
||||
_state.State.LastPositionClosingTime = _tradingBot.LastPositionClosingTime;
|
||||
_state.State.Config = _tradingBot.Config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes one cycle of the trading bot
|
||||
/// </summary>
|
||||
private async Task ExecuteBotCycle()
|
||||
public async Task<bool> UpdateConfiguration(TradingBotConfig newConfig)
|
||||
{
|
||||
if (_tradingBot == null)
|
||||
LoadStateIntoBase();
|
||||
|
||||
var result = await _tradingBot!.UpdateConfiguration(newConfig);
|
||||
|
||||
if (result)
|
||||
{
|
||||
var botRegistry = GrainFactory.GetGrain<ILiveBotRegistryGrain>(0);
|
||||
var botId = this.GetPrimaryKey();
|
||||
var status = await botRegistry.GetBotStatus(botId);
|
||||
_state.State.Config = newConfig;
|
||||
await _state.WriteStateAsync();
|
||||
await SaveBotAsync(status);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public Task<Account> GetAccount()
|
||||
{
|
||||
return Task.FromResult(_tradingBot.Account);
|
||||
}
|
||||
|
||||
public Task<TradingBotConfig> GetConfiguration()
|
||||
{
|
||||
return Task.FromResult(_state.State.Config);
|
||||
}
|
||||
|
||||
public async Task<Position> ClosePositionAsync(Guid positionId)
|
||||
{
|
||||
if (_tradingBot == null)
|
||||
{
|
||||
throw new InvalidOperationException("Bot is not running");
|
||||
}
|
||||
|
||||
if (!_tradingBot.Positions.TryGetValue(positionId, out var position))
|
||||
{
|
||||
throw new InvalidOperationException($"Position with ID {positionId} not found");
|
||||
}
|
||||
|
||||
var signal = _tradingBot.Signals.TryGetValue(position.SignalIdentifier, out var foundSignal)
|
||||
? foundSignal
|
||||
: null;
|
||||
if (signal == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Signal with ID {position.SignalIdentifier} not found");
|
||||
}
|
||||
|
||||
await _tradingBot.CloseTrade(signal, position, position.Open, _tradingBot.LastCandle.Close, true);
|
||||
|
||||
return position;
|
||||
}
|
||||
|
||||
public async Task RestartAsync()
|
||||
{
|
||||
await StopAsync();
|
||||
await StartAsync();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_tradingBot == null || State.Status != BotStatus.Up || _isDisposed)
|
||||
// Stop the bot first if it's running
|
||||
await StopAsync();
|
||||
|
||||
// Unregister from the bot registry
|
||||
var botRegistry = GrainFactory.GetGrain<ILiveBotRegistryGrain>(0);
|
||||
await botRegistry.UnregisterBot(_state.State.Identifier);
|
||||
|
||||
// Unregister from the user's agent
|
||||
if (_state.State.User != null)
|
||||
{
|
||||
return;
|
||||
var agentGrain = GrainFactory.GetGrain<IAgentGrain>(_state.State.User.Id);
|
||||
await agentGrain.UnregisterBotAsync(_state.State.Identifier);
|
||||
}
|
||||
|
||||
// Execute the bot's Run method
|
||||
await _tradingBot.Run();
|
||||
|
||||
// Update execution count
|
||||
State.ExecutionCount++;
|
||||
|
||||
await SaveBackupToState();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Gracefully handle disposed service provider during shutdown
|
||||
_logger.LogInformation("Service provider disposed during shutdown for LiveTradingBotGrain {GrainId}", this.GetPrimaryKey());
|
||||
return;
|
||||
// Clear the state
|
||||
_tradingBot = null;
|
||||
await _state.ClearStateAsync();
|
||||
|
||||
_logger.LogInformation("LiveTradingBotGrain {GrainId} deleted successfully", this.GetPrimaryKey());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during bot execution cycle for LiveTradingBotGrain {GrainId}", this.GetPrimaryKey());
|
||||
_logger.LogError(ex, "Failed to delete LiveTradingBotGrain {GrainId}", this.GetPrimaryKey());
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves the current bot state to Orleans state storage
|
||||
/// Updates the bot status in the central BotRegistry
|
||||
/// </summary>
|
||||
private async Task SaveBackupToState()
|
||||
private async Task UpdateBotRegistryStatus(BotStatus status)
|
||||
{
|
||||
if (_tradingBot == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
// Sync state from TradingBotBase
|
||||
State.Config = _tradingBot.Config;
|
||||
State.Signals = _tradingBot.Signals;
|
||||
State.Positions = _tradingBot.Positions;
|
||||
State.WalletBalances = _tradingBot.WalletBalances;
|
||||
State.PreloadSince = _tradingBot.PreloadSince;
|
||||
State.PreloadedCandlesCount = _tradingBot.PreloadedCandlesCount;
|
||||
State.Interval = _tradingBot.Interval;
|
||||
State.MaxSignals = _tradingBot._maxSignals;
|
||||
State.LastBackupTime = DateTime.UtcNow;
|
||||
|
||||
await WriteStateAsync();
|
||||
var botRegistry = GrainFactory.GetGrain<ILiveBotRegistryGrain>(0);
|
||||
var botId = this.GetPrimaryKey();
|
||||
await botRegistry.UpdateBotStatus(botId, status);
|
||||
_logger.LogDebug("Bot {BotId} status updated to {Status} in BotRegistry", botId, status);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save state for LiveTradingBotGrain {GrainId}", this.GetPrimaryKey());
|
||||
_logger.LogError(ex, "Failed to update bot {BotId} status to {Status} in BotRegistry", this.GetPrimaryKey(),
|
||||
status);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ReceiveReminder(string reminderName, TickStatus status)
|
||||
{
|
||||
_logger.LogInformation("Reminder '{ReminderName}' received for grain {GrainId}.", reminderName,
|
||||
this.GetPrimaryKey());
|
||||
|
||||
if (reminderName == _reminderName)
|
||||
{
|
||||
// Now a single, clean call to the method that handles all the logic
|
||||
await ResumeBotIfRequiredAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads bot state from Orleans state storage
|
||||
/// Saves the current bot statistics to the database using BotService
|
||||
/// </summary>
|
||||
private async Task LoadBackupFromState()
|
||||
private async Task SaveBotAsync(BotStatus status)
|
||||
{
|
||||
if (_tradingBot == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
// Sync state to TradingBotBase
|
||||
_tradingBot.Signals = State.Signals;
|
||||
_tradingBot.Positions = State.Positions;
|
||||
_tradingBot.WalletBalances = State.WalletBalances;
|
||||
_tradingBot.PreloadSince = State.PreloadSince;
|
||||
_tradingBot.PreloadedCandlesCount = State.PreloadedCandlesCount;
|
||||
_tradingBot.Config = State.Config;
|
||||
Bot bot = null;
|
||||
if (_tradingBot == null || _state.State.User == null)
|
||||
{
|
||||
// Save bot statistics for saved bots
|
||||
bot = new Bot
|
||||
{
|
||||
Identifier = _state.State.Identifier,
|
||||
Name = _state.State.Config.Name,
|
||||
Ticker = _state.State.Config.Ticker,
|
||||
User = _state.State.User,
|
||||
Status = status,
|
||||
CreateDate = _state.State.CreateDate,
|
||||
StartupTime = _state.State.StartupTime,
|
||||
TradeWins = 0,
|
||||
TradeLosses = 0,
|
||||
Pnl = 0,
|
||||
Roi = 0,
|
||||
Volume = 0,
|
||||
Fees = 0
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
// Calculate statistics using TradingBox helpers
|
||||
var (tradeWins, tradeLosses) = TradingBox.GetWinLossCount(_tradingBot.Positions);
|
||||
var pnl = _tradingBot.GetProfitAndLoss();
|
||||
var fees = _tradingBot.GetTotalFees();
|
||||
var volume = TradingBox.GetTotalVolumeTraded(_tradingBot.Positions);
|
||||
|
||||
// Calculate ROI based on total investment
|
||||
var totalInvestment = _tradingBot.Positions.Values
|
||||
.Sum(p => p.Open.Quantity * p.Open.Price);
|
||||
var roi = totalInvestment > 0 ? (pnl / totalInvestment) * 100 : 0;
|
||||
|
||||
// Create complete Bot object with all statistics
|
||||
bot = new Bot
|
||||
{
|
||||
Identifier = _state.State.Identifier,
|
||||
Name = _state.State.Config.Name,
|
||||
Ticker = _state.State.Config.Ticker,
|
||||
User = _state.State.User,
|
||||
Status = status,
|
||||
StartupTime = _state.State.StartupTime,
|
||||
CreateDate = _state.State.CreateDate,
|
||||
TradeWins = tradeWins,
|
||||
TradeLosses = tradeLosses,
|
||||
Pnl = pnl,
|
||||
Roi = roi,
|
||||
Volume = volume,
|
||||
Fees = fees
|
||||
};
|
||||
}
|
||||
|
||||
// Pass the complete Bot object to BotService for saving
|
||||
var success = await ServiceScopeHelpers.WithScopedService<IBotService, bool>(_scopeFactory,
|
||||
async (botService) => { return await botService.SaveBotStatisticsAsync(bot); });
|
||||
|
||||
if (success)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Successfully saved bot statistics for bot {BotId}: Wins={Wins}, Losses={Losses}, PnL={PnL}, ROI={ROI}%, Volume={Volume}, Fees={Fees}",
|
||||
_state.State.Identifier, bot.TradeWins, bot.TradeLosses, bot.Pnl, bot.Roi, bot.Volume, bot.Fees);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed to save bot statistics for bot {BotId}", _state.State.Identifier);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load state for LiveTradingBotGrain {GrainId}", this.GetPrimaryKey());
|
||||
_logger.LogError(ex, "Failed to save bot statistics for bot {BotId}", _state.State.Identifier);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
_timer?.Dispose();
|
||||
_isDisposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/Managing.Application/Bots/Models/AgentGrainState.cs
Normal file
8
src/Managing.Application/Bots/Models/AgentGrainState.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Managing.Application.Bots.Models
|
||||
{
|
||||
public class AgentGrainState
|
||||
{
|
||||
public string AgentName { get; set; }
|
||||
public HashSet<Guid> BotIds { get; set; } = new HashSet<Guid>();
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
using Managing.Application.Abstractions;
|
||||
using Managing.Application.ManageBot;
|
||||
using Managing.Domain.Bots;
|
||||
using Managing.Domain.Workflows;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Managing.Application.Bots
|
||||
{
|
||||
public class SimpleBot : Bot
|
||||
{
|
||||
public readonly ILogger<TradingBotBase> Logger;
|
||||
private readonly IBotService _botService;
|
||||
private readonly IBackupBotService _backupBotService;
|
||||
private Workflow _workflow;
|
||||
|
||||
public SimpleBot(string name, ILogger<TradingBotBase> logger, Workflow workflow, IBotService botService,
|
||||
IBackupBotService backupBotService) :
|
||||
base(name)
|
||||
{
|
||||
Logger = logger;
|
||||
_botService = botService;
|
||||
_backupBotService = backupBotService;
|
||||
_workflow = workflow;
|
||||
Interval = 100;
|
||||
}
|
||||
|
||||
public override void Start()
|
||||
{
|
||||
Task.Run(() => InitWorker(Run));
|
||||
base.Start();
|
||||
}
|
||||
|
||||
public async Task Run()
|
||||
{
|
||||
await Task.Run(
|
||||
async () =>
|
||||
{
|
||||
Logger.LogInformation(Identifier);
|
||||
Logger.LogInformation(DateTime.Now.ToString());
|
||||
await _workflow.Execute();
|
||||
await SaveBackup();
|
||||
Logger.LogInformation("__________________________________________________");
|
||||
});
|
||||
}
|
||||
|
||||
public override async Task SaveBackup()
|
||||
{
|
||||
var data = JsonConvert.SerializeObject(_workflow);
|
||||
await _backupBotService.SaveOrUpdateBotBackup(User, Identifier, Status, new TradingBotBackup());
|
||||
}
|
||||
|
||||
public override void LoadBackup(BotBackup backup)
|
||||
{
|
||||
_workflow = new Workflow();
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
using Managing.Domain.Bots;
|
||||
using Managing.Domain.Indicators;
|
||||
using Managing.Domain.Trades;
|
||||
using Managing.Domain.Users;
|
||||
using static Managing.Common.Enums;
|
||||
|
||||
namespace Managing.Application.Bots;
|
||||
|
||||
@@ -23,13 +23,13 @@ public class TradingBotGrainState
|
||||
/// Collection of trading signals generated by the bot
|
||||
/// </summary>
|
||||
[Id(1)]
|
||||
public HashSet<LightSignal> Signals { get; set; } = new();
|
||||
public Dictionary<string, LightSignal> Signals { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// List of trading positions opened by the bot
|
||||
/// Dictionary of trading positions opened by the bot, keyed by position identifier
|
||||
/// </summary>
|
||||
[Id(2)]
|
||||
public List<Position> Positions { get; set; } = new();
|
||||
public Dictionary<Guid, Position> Positions { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Historical wallet balances tracked over time
|
||||
@@ -37,12 +37,6 @@ public class TradingBotGrainState
|
||||
[Id(3)]
|
||||
public Dictionary<DateTime, decimal> WalletBalances { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Current status of the bot (Running, Stopped, etc.)
|
||||
/// </summary>
|
||||
[Id(4)]
|
||||
public BotStatus Status { get; set; } = BotStatus.Down;
|
||||
|
||||
/// <summary>
|
||||
/// When the bot was started
|
||||
/// </summary>
|
||||
@@ -71,7 +65,7 @@ public class TradingBotGrainState
|
||||
/// Bot identifier/name
|
||||
/// </summary>
|
||||
[Id(9)]
|
||||
public string Identifier { get; set; } = string.Empty;
|
||||
public Guid Identifier { get; set; } = Guid.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Bot display name
|
||||
@@ -114,4 +108,10 @@ public class TradingBotGrainState
|
||||
/// </summary>
|
||||
[Id(16)]
|
||||
public DateTime LastBackupTime { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Last time a position was closed (for cooldown period tracking)
|
||||
/// </summary>
|
||||
[Id(17)]
|
||||
public DateTime? LastPositionClosingTime { get; set; }
|
||||
}
|
||||
Reference in New Issue
Block a user