Add jobs
This commit is contained in:
280
src/Managing.Application/Backtests/BacktestExecutor.cs
Normal file
280
src/Managing.Application/Backtests/BacktestExecutor.cs
Normal file
@@ -0,0 +1,280 @@
|
||||
using Managing.Application.Abstractions;
|
||||
using Managing.Application.Abstractions.Repositories;
|
||||
using Managing.Application.Abstractions.Services;
|
||||
using Managing.Application.Bots;
|
||||
using Managing.Common;
|
||||
using Managing.Domain.Backtests;
|
||||
using Managing.Domain.Bots;
|
||||
using Managing.Domain.Candles;
|
||||
using Managing.Domain.Shared.Helpers;
|
||||
using Managing.Domain.Users;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Managing.Application.Backtests;
|
||||
|
||||
/// <summary>
|
||||
/// Service for executing backtests without Orleans dependencies.
|
||||
/// Extracted from BacktestTradingBotGrain to be reusable in compute workers.
|
||||
/// </summary>
|
||||
public class BacktestExecutor
|
||||
{
|
||||
private readonly ILogger<BacktestExecutor> _logger;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IBacktestRepository _backtestRepository;
|
||||
private readonly IScenarioService _scenarioService;
|
||||
private readonly IAccountService _accountService;
|
||||
private readonly IMessengerService _messengerService;
|
||||
|
||||
public BacktestExecutor(
|
||||
ILogger<BacktestExecutor> logger,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IBacktestRepository backtestRepository,
|
||||
IScenarioService scenarioService,
|
||||
IAccountService accountService,
|
||||
IMessengerService messengerService)
|
||||
{
|
||||
_logger = logger;
|
||||
_scopeFactory = scopeFactory;
|
||||
_backtestRepository = backtestRepository;
|
||||
_scenarioService = scenarioService;
|
||||
_accountService = accountService;
|
||||
_messengerService = messengerService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a backtest with the given configuration and candles.
|
||||
/// </summary>
|
||||
/// <param name="config">The trading bot configuration</param>
|
||||
/// <param name="candles">The candles to use for backtesting</param>
|
||||
/// <param name="user">The user running the backtest</param>
|
||||
/// <param name="save">Whether to save the backtest result</param>
|
||||
/// <param name="withCandles">Whether to include candles in the result</param>
|
||||
/// <param name="requestId">The request ID to associate with this backtest</param>
|
||||
/// <param name="metadata">Additional metadata</param>
|
||||
/// <param name="progressCallback">Optional callback for progress updates (0-100)</param>
|
||||
/// <returns>The lightweight backtest result</returns>
|
||||
public async Task<LightBacktest> ExecuteAsync(
|
||||
TradingBotConfig config,
|
||||
HashSet<Candle> candles,
|
||||
User user,
|
||||
bool save = false,
|
||||
bool withCandles = false,
|
||||
string requestId = null,
|
||||
object metadata = null,
|
||||
Func<int, Task> progressCallback = null)
|
||||
{
|
||||
if (candles == null || candles.Count == 0)
|
||||
{
|
||||
throw new Exception("No candle to backtest");
|
||||
}
|
||||
|
||||
// Ensure user has accounts loaded
|
||||
if (user.Accounts == null || !user.Accounts.Any())
|
||||
{
|
||||
user.Accounts = (await _accountService.GetAccountsByUserAsync(user, hideSecrets: true, getBalance: false)).ToList();
|
||||
}
|
||||
|
||||
// Create a fresh TradingBotBase instance for this backtest
|
||||
var tradingBot = await CreateTradingBotInstance(config);
|
||||
tradingBot.Account = user.Accounts.First();
|
||||
|
||||
var totalCandles = candles.Count;
|
||||
var currentCandle = 0;
|
||||
var lastLoggedPercentage = 0;
|
||||
|
||||
_logger.LogInformation("Backtest requested by {UserId} with {TotalCandles} candles for {Ticker} on {Timeframe}",
|
||||
user.Id, totalCandles, config.Ticker, config.Timeframe);
|
||||
|
||||
// Initialize wallet balance with first candle
|
||||
tradingBot.WalletBalances.Clear();
|
||||
tradingBot.WalletBalances.Add(candles.FirstOrDefault()!.Date, config.BotTradingBalance);
|
||||
var initialBalance = config.BotTradingBalance;
|
||||
|
||||
var fixedCandles = new HashSet<Candle>();
|
||||
var lastProgressUpdate = DateTime.UtcNow;
|
||||
const int progressUpdateIntervalMs = 1000; // Update progress every second
|
||||
|
||||
// Process all candles
|
||||
foreach (var candle in candles)
|
||||
{
|
||||
fixedCandles.Add(candle);
|
||||
tradingBot.LastCandle = candle;
|
||||
|
||||
// Update signals manually only for backtesting
|
||||
await tradingBot.UpdateSignals(fixedCandles);
|
||||
await tradingBot.Run();
|
||||
|
||||
currentCandle++;
|
||||
|
||||
// Update progress callback if provided
|
||||
var currentPercentage = (currentCandle * 100) / totalCandles;
|
||||
var timeSinceLastUpdate = (DateTime.UtcNow - lastProgressUpdate).TotalMilliseconds;
|
||||
if (progressCallback != null && (timeSinceLastUpdate >= progressUpdateIntervalMs || currentPercentage >= lastLoggedPercentage + 10))
|
||||
{
|
||||
try
|
||||
{
|
||||
await progressCallback(currentPercentage);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error in progress callback");
|
||||
}
|
||||
lastProgressUpdate = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
// Log progress every 10%
|
||||
if (currentPercentage >= lastLoggedPercentage + 10)
|
||||
{
|
||||
lastLoggedPercentage = currentPercentage;
|
||||
_logger.LogInformation(
|
||||
"Backtest progress: {Percentage}% ({CurrentCandle}/{TotalCandles} candles processed)",
|
||||
currentPercentage, currentCandle, totalCandles);
|
||||
}
|
||||
|
||||
// Check if wallet balance fell below 10 USDC and break if so
|
||||
var currentWalletBalance = tradingBot.WalletBalances.Values.LastOrDefault();
|
||||
if (currentWalletBalance < Constants.GMX.Config.MinimumPositionAmount)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Backtest stopped early: Wallet balance fell below {MinimumPositionAmount} USDC (Current: {CurrentBalance:F2} USDC) at candle {CurrentCandle}/{TotalCandles} from {CandleDate}",
|
||||
Constants.GMX.Config.MinimumPositionAmount, currentWalletBalance, currentCandle, totalCandles,
|
||||
candle.Date.ToString("yyyy-MM-dd HH:mm"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Backtest processing completed. Calculating final results...");
|
||||
|
||||
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.First(), candles.Last());
|
||||
|
||||
var fees = tradingBot.GetTotalFees();
|
||||
var scoringParams = new BacktestScoringParams(
|
||||
sharpeRatio: (double)stats.SharpeRatio,
|
||||
growthPercentage: (double)growthPercentage,
|
||||
hodlPercentage: (double)hodlPercentage,
|
||||
winRate: winRate,
|
||||
totalPnL: (double)finalPnl,
|
||||
fees: (double)fees,
|
||||
tradeCount: tradingBot.Positions.Count,
|
||||
maxDrawdownRecoveryTime: stats.MaxDrawdownRecoveryTime,
|
||||
maxDrawdown: stats.MaxDrawdown,
|
||||
initialBalance: tradingBot.WalletBalances.FirstOrDefault().Value,
|
||||
tradingBalance: config.BotTradingBalance,
|
||||
startDate: candles.First().Date,
|
||||
endDate: candles.Last().Date,
|
||||
timeframe: config.Timeframe,
|
||||
moneyManagement: config.MoneyManagement
|
||||
);
|
||||
|
||||
var scoringResult = BacktestScorer.CalculateDetailedScore(scoringParams);
|
||||
|
||||
// Generate requestId if not provided
|
||||
var finalRequestId = requestId != null ? Guid.Parse(requestId) : Guid.NewGuid();
|
||||
|
||||
// Create backtest result with conditional candles and indicators values
|
||||
var result = new Backtest(config, tradingBot.Positions, tradingBot.Signals,
|
||||
withCandles ? candles : new HashSet<Candle>())
|
||||
{
|
||||
FinalPnl = finalPnl,
|
||||
WinRate = winRate,
|
||||
GrowthPercentage = growthPercentage,
|
||||
HodlPercentage = hodlPercentage,
|
||||
Fees = fees,
|
||||
WalletBalances = tradingBot.WalletBalances.ToList(),
|
||||
Statistics = stats,
|
||||
Score = scoringResult.Score,
|
||||
ScoreMessage = scoringResult.GenerateSummaryMessage(),
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
RequestId = finalRequestId,
|
||||
Metadata = metadata,
|
||||
StartDate = candles.FirstOrDefault()!.OpenTime,
|
||||
EndDate = candles.LastOrDefault()!.OpenTime,
|
||||
InitialBalance = initialBalance,
|
||||
NetPnl = finalPnl - fees,
|
||||
};
|
||||
|
||||
if (save && user != null)
|
||||
{
|
||||
await _backtestRepository.InsertBacktestForUserAsync(user, result);
|
||||
}
|
||||
|
||||
// Send notification if backtest meets criteria
|
||||
await SendBacktestNotificationIfCriteriaMet(result);
|
||||
|
||||
// Convert Backtest to LightBacktest
|
||||
return ConvertToLightBacktest(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a Backtest to LightBacktest
|
||||
/// </summary>
|
||||
private LightBacktest ConvertToLightBacktest(Backtest backtest)
|
||||
{
|
||||
return new LightBacktest
|
||||
{
|
||||
Id = backtest.Id,
|
||||
Config = backtest.Config,
|
||||
FinalPnl = backtest.FinalPnl,
|
||||
WinRate = backtest.WinRate,
|
||||
GrowthPercentage = backtest.GrowthPercentage,
|
||||
HodlPercentage = backtest.HodlPercentage,
|
||||
StartDate = backtest.StartDate,
|
||||
EndDate = backtest.EndDate,
|
||||
MaxDrawdown = backtest.Statistics?.MaxDrawdown,
|
||||
Fees = backtest.Fees,
|
||||
SharpeRatio = (double?)backtest.Statistics?.SharpeRatio,
|
||||
Score = backtest.Score,
|
||||
ScoreMessage = backtest.ScoreMessage,
|
||||
InitialBalance = backtest.InitialBalance,
|
||||
NetPnl = backtest.NetPnl
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a TradingBotBase instance for backtesting
|
||||
/// </summary>
|
||||
private async Task<TradingBotBase> CreateTradingBotInstance(TradingBotConfig config)
|
||||
{
|
||||
// Validate configuration for backtesting
|
||||
if (config == null)
|
||||
{
|
||||
throw new InvalidOperationException("Bot configuration is not initialized");
|
||||
}
|
||||
|
||||
if (!config.IsForBacktest)
|
||||
{
|
||||
throw new InvalidOperationException("BacktestExecutor can only be used for backtesting");
|
||||
}
|
||||
|
||||
// Create the trading bot instance
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<TradingBotBase>>();
|
||||
var tradingBot = new TradingBotBase(logger, _scopeFactory, config);
|
||||
return tradingBot;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends notification if backtest meets criteria
|
||||
/// </summary>
|
||||
private async Task SendBacktestNotificationIfCriteriaMet(Backtest backtest)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (backtest.Score > 60)
|
||||
{
|
||||
await _messengerService.SendBacktestNotification(backtest);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send backtest notification for backtest {Id}", backtest.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
254
src/Managing.Application/Backtests/BacktestJobService.cs
Normal file
254
src/Managing.Application/Backtests/BacktestJobService.cs
Normal file
@@ -0,0 +1,254 @@
|
||||
using System.Text.Json;
|
||||
using Managing.Application.Abstractions.Repositories;
|
||||
using Managing.Application.Abstractions.Services;
|
||||
using Managing.Domain.Backtests;
|
||||
using Managing.Domain.Bots;
|
||||
using Managing.Domain.MoneyManagements;
|
||||
using Managing.Domain.Scenarios;
|
||||
using Managing.Domain.Strategies;
|
||||
using Managing.Domain.Users;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static Managing.Common.Enums;
|
||||
|
||||
namespace Managing.Application.Backtests;
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating and managing backtest jobs in the queue
|
||||
/// </summary>
|
||||
public class BacktestJobService
|
||||
{
|
||||
private readonly IBacktestJobRepository _jobRepository;
|
||||
private readonly IBacktestRepository _backtestRepository;
|
||||
private readonly IKaigenService _kaigenService;
|
||||
private readonly ILogger<BacktestJobService> _logger;
|
||||
|
||||
public BacktestJobService(
|
||||
IBacktestJobRepository jobRepository,
|
||||
IBacktestRepository backtestRepository,
|
||||
IKaigenService kaigenService,
|
||||
ILogger<BacktestJobService> logger)
|
||||
{
|
||||
_jobRepository = jobRepository;
|
||||
_backtestRepository = backtestRepository;
|
||||
_kaigenService = kaigenService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a single backtest job
|
||||
/// </summary>
|
||||
public async Task<BacktestJob> CreateJobAsync(
|
||||
TradingBotConfig config,
|
||||
DateTime startDate,
|
||||
DateTime endDate,
|
||||
User user,
|
||||
int priority = 0,
|
||||
string requestId = null)
|
||||
{
|
||||
// Debit user credits before creating job
|
||||
string creditRequestId = null;
|
||||
try
|
||||
{
|
||||
creditRequestId = await _kaigenService.DebitUserCreditsAsync(user, 1);
|
||||
_logger.LogInformation(
|
||||
"Successfully debited credits for user {UserName} with request ID {RequestId}",
|
||||
user.Name, creditRequestId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to debit credits for user {UserName}. Job will not be created.",
|
||||
user.Name);
|
||||
throw new Exception($"Failed to debit credits: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var job = new BacktestJob
|
||||
{
|
||||
UserId = user.Id,
|
||||
Status = BacktestJobStatus.Pending,
|
||||
JobType = JobType.Backtest,
|
||||
Priority = priority,
|
||||
ConfigJson = JsonSerializer.Serialize(config),
|
||||
StartDate = startDate,
|
||||
EndDate = endDate,
|
||||
BundleRequestId = null, // Single jobs are not part of a bundle
|
||||
RequestId = requestId
|
||||
};
|
||||
|
||||
var createdJob = await _jobRepository.CreateAsync(job);
|
||||
_logger.LogInformation("Created backtest job {JobId} for user {UserId}", createdJob.Id, user.Id);
|
||||
|
||||
return createdJob;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// If job creation fails, attempt to refund credits
|
||||
if (!string.IsNullOrEmpty(creditRequestId))
|
||||
{
|
||||
try
|
||||
{
|
||||
var refundSuccess = await _kaigenService.RefundUserCreditsAsync(creditRequestId, user);
|
||||
if (refundSuccess)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Successfully refunded credits for user {UserName} after job creation failure",
|
||||
user.Name);
|
||||
}
|
||||
}
|
||||
catch (Exception refundEx)
|
||||
{
|
||||
_logger.LogError(refundEx, "Error during refund attempt for user {UserName}", user.Name);
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates multiple backtest jobs from bundle variants
|
||||
/// </summary>
|
||||
public async Task<List<BacktestJob>> CreateBundleJobsAsync(
|
||||
BundleBacktestRequest bundleRequest,
|
||||
List<RunBacktestRequest> backtestRequests)
|
||||
{
|
||||
var jobs = new List<BacktestJob>();
|
||||
var creditRequestId = (string?)null;
|
||||
|
||||
try
|
||||
{
|
||||
// Debit credits for all jobs upfront
|
||||
var totalJobs = backtestRequests.Count;
|
||||
creditRequestId = await _kaigenService.DebitUserCreditsAsync(bundleRequest.User, totalJobs);
|
||||
_logger.LogInformation(
|
||||
"Successfully debited {TotalJobs} credits for user {UserName} with request ID {RequestId}",
|
||||
totalJobs, bundleRequest.User.Name, creditRequestId);
|
||||
|
||||
// Create jobs for each variant
|
||||
for (int i = 0; i < backtestRequests.Count; i++)
|
||||
{
|
||||
var backtestRequest = backtestRequests[i];
|
||||
|
||||
// Map MoneyManagement
|
||||
var moneyManagement = backtestRequest.MoneyManagement;
|
||||
if (moneyManagement == null && backtestRequest.Config.MoneyManagement != null)
|
||||
{
|
||||
var mmReq = backtestRequest.Config.MoneyManagement;
|
||||
moneyManagement = new MoneyManagement
|
||||
{
|
||||
Name = mmReq.Name,
|
||||
Timeframe = mmReq.Timeframe,
|
||||
StopLoss = mmReq.StopLoss,
|
||||
TakeProfit = mmReq.TakeProfit,
|
||||
Leverage = mmReq.Leverage
|
||||
};
|
||||
moneyManagement.FormatPercentage();
|
||||
}
|
||||
|
||||
// Map Scenario
|
||||
LightScenario scenario = null;
|
||||
if (backtestRequest.Config.Scenario != null)
|
||||
{
|
||||
var sReq = backtestRequest.Config.Scenario;
|
||||
scenario = new LightScenario(sReq.Name, sReq.LoopbackPeriod)
|
||||
{
|
||||
Indicators = sReq.Indicators?.Select(ind => new LightIndicator(ind.Name, ind.Type)
|
||||
{
|
||||
SignalType = ind.SignalType,
|
||||
MinimumHistory = ind.MinimumHistory,
|
||||
Period = ind.Period,
|
||||
FastPeriods = ind.FastPeriods,
|
||||
SlowPeriods = ind.SlowPeriods,
|
||||
SignalPeriods = ind.SignalPeriods,
|
||||
Multiplier = ind.Multiplier,
|
||||
SmoothPeriods = ind.SmoothPeriods,
|
||||
StochPeriods = ind.StochPeriods,
|
||||
CyclePeriods = ind.CyclePeriods
|
||||
}).ToList() ?? new List<LightIndicator>()
|
||||
};
|
||||
}
|
||||
|
||||
// Create TradingBotConfig
|
||||
var backtestConfig = new TradingBotConfig
|
||||
{
|
||||
AccountName = backtestRequest.Config.AccountName,
|
||||
MoneyManagement = moneyManagement != null
|
||||
? new LightMoneyManagement
|
||||
{
|
||||
Name = moneyManagement.Name,
|
||||
Timeframe = moneyManagement.Timeframe,
|
||||
StopLoss = moneyManagement.StopLoss,
|
||||
TakeProfit = moneyManagement.TakeProfit,
|
||||
Leverage = moneyManagement.Leverage
|
||||
}
|
||||
: null,
|
||||
Ticker = backtestRequest.Config.Ticker,
|
||||
ScenarioName = backtestRequest.Config.ScenarioName,
|
||||
Scenario = scenario,
|
||||
Timeframe = backtestRequest.Config.Timeframe,
|
||||
IsForWatchingOnly = backtestRequest.Config.IsForWatchingOnly,
|
||||
BotTradingBalance = backtestRequest.Config.BotTradingBalance,
|
||||
IsForBacktest = true,
|
||||
CooldownPeriod = backtestRequest.Config.CooldownPeriod ?? 1,
|
||||
MaxLossStreak = backtestRequest.Config.MaxLossStreak,
|
||||
MaxPositionTimeHours = backtestRequest.Config.MaxPositionTimeHours,
|
||||
FlipOnlyWhenInProfit = backtestRequest.Config.FlipOnlyWhenInProfit,
|
||||
FlipPosition = backtestRequest.Config.FlipPosition,
|
||||
Name = $"{bundleRequest.Name} #{i + 1}",
|
||||
CloseEarlyWhenProfitable = backtestRequest.Config.CloseEarlyWhenProfitable,
|
||||
UseSynthApi = backtestRequest.Config.UseSynthApi,
|
||||
UseForPositionSizing = backtestRequest.Config.UseForPositionSizing,
|
||||
UseForSignalFiltering = backtestRequest.Config.UseForSignalFiltering,
|
||||
UseForDynamicStopLoss = backtestRequest.Config.UseForDynamicStopLoss
|
||||
};
|
||||
|
||||
var job = new BacktestJob
|
||||
{
|
||||
UserId = bundleRequest.User.Id,
|
||||
Status = BacktestJobStatus.Pending,
|
||||
JobType = JobType.Backtest,
|
||||
Priority = 0, // All bundle jobs have same priority
|
||||
ConfigJson = JsonSerializer.Serialize(backtestConfig),
|
||||
StartDate = backtestRequest.StartDate,
|
||||
EndDate = backtestRequest.EndDate,
|
||||
BundleRequestId = bundleRequest.RequestId,
|
||||
RequestId = bundleRequest.RequestId.ToString()
|
||||
};
|
||||
|
||||
var createdJob = await _jobRepository.CreateAsync(job);
|
||||
jobs.Add(createdJob);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created {JobCount} backtest jobs for bundle request {BundleRequestId}",
|
||||
jobs.Count, bundleRequest.RequestId);
|
||||
|
||||
return jobs;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// If job creation fails, attempt to refund credits
|
||||
if (!string.IsNullOrEmpty(creditRequestId))
|
||||
{
|
||||
try
|
||||
{
|
||||
var refundSuccess = await _kaigenService.RefundUserCreditsAsync(creditRequestId, bundleRequest.User);
|
||||
if (refundSuccess)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Successfully refunded credits for user {UserName} after bundle job creation failure",
|
||||
bundleRequest.User.Name);
|
||||
}
|
||||
}
|
||||
catch (Exception refundEx)
|
||||
{
|
||||
_logger.LogError(refundEx, "Error during refund attempt for user {UserName}", bundleRequest.User.Name);
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
using Managing.Application.Abstractions;
|
||||
using Managing.Application.Abstractions.Grains;
|
||||
using System.Text.Json;
|
||||
using Managing.Application.Abstractions;
|
||||
using Managing.Application.Abstractions.Repositories;
|
||||
using Managing.Application.Abstractions.Services;
|
||||
using Managing.Application.Abstractions.Shared;
|
||||
using Managing.Application.Hubs;
|
||||
using Managing.Core;
|
||||
using Managing.Domain.Accounts;
|
||||
using Managing.Domain.Backtests;
|
||||
using Managing.Domain.Bots;
|
||||
using Managing.Domain.Candles;
|
||||
using Managing.Domain.Scenarios;
|
||||
using Managing.Domain.MoneyManagements;
|
||||
using Managing.Domain.Users;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -30,7 +29,7 @@ namespace Managing.Application.Backtests
|
||||
private readonly IMessengerService _messengerService;
|
||||
private readonly IKaigenService _kaigenService;
|
||||
private readonly IHubContext<BacktestHub> _hubContext;
|
||||
private readonly IGrainFactory _grainFactory;
|
||||
private readonly BacktestJobService _jobService;
|
||||
|
||||
public Backtester(
|
||||
IExchangeService exchangeService,
|
||||
@@ -41,8 +40,8 @@ namespace Managing.Application.Backtests
|
||||
IMessengerService messengerService,
|
||||
IKaigenService kaigenService,
|
||||
IHubContext<BacktestHub> hubContext,
|
||||
IGrainFactory grainFactory,
|
||||
IServiceScopeFactory serviceScopeFactory)
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
BacktestJobService jobService)
|
||||
{
|
||||
_exchangeService = exchangeService;
|
||||
_backtestRepository = backtestRepository;
|
||||
@@ -52,23 +51,23 @@ namespace Managing.Application.Backtests
|
||||
_messengerService = messengerService;
|
||||
_kaigenService = kaigenService;
|
||||
_hubContext = hubContext;
|
||||
_grainFactory = grainFactory;
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_jobService = jobService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a trading bot backtest with the specified configuration and date range.
|
||||
/// Automatically handles different bot types based on config.BotType.
|
||||
/// Creates a backtest job and returns immediately (fire-and-forget pattern).
|
||||
/// The job will be processed by compute workers.
|
||||
/// </summary>
|
||||
/// <param name="config">The trading bot configuration (must include Scenario object or ScenarioName)</param>
|
||||
/// <param name="startDate">The start date for the backtest</param>
|
||||
/// <param name="endDate">The end date for the backtest</param>
|
||||
/// <param name="user">The user running the backtest (optional)</param>
|
||||
/// <param name="user">The user running the backtest (required)</param>
|
||||
/// <param name="save">Whether to save the backtest results</param>
|
||||
/// <param name="withCandles">Whether to include candles and indicators values in the response</param>
|
||||
/// <param name="withCandles">Whether to include candles and indicators values in the response (ignored, always false for jobs)</param>
|
||||
/// <param name="requestId">The request ID to associate with this backtest (optional)</param>
|
||||
/// <param name="metadata">Additional metadata to associate with this backtest (optional)</param>
|
||||
/// <returns>The lightweight backtest results</returns>
|
||||
/// <returns>A lightweight backtest response with job ID (result will be available later via GetJobStatus)</returns>
|
||||
public async Task<LightBacktestResponse> RunTradingBotBacktest(
|
||||
TradingBotConfig config,
|
||||
DateTime startDate,
|
||||
@@ -79,59 +78,33 @@ namespace Managing.Application.Backtests
|
||||
string requestId = null,
|
||||
object metadata = null)
|
||||
{
|
||||
string creditRequestId = null;
|
||||
|
||||
// Debit user credits before starting the backtest
|
||||
if (user != null)
|
||||
if (user == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
creditRequestId = await _kaigenService.DebitUserCreditsAsync(user, 1);
|
||||
_logger.LogInformation(
|
||||
"Successfully debited credits for user {UserName} with request ID {RequestId}",
|
||||
user.Name, creditRequestId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to debit credits for user {UserName}. Backtest will not proceed.",
|
||||
user.Name);
|
||||
throw new Exception($"Failed to debit credits: {ex.Message}");
|
||||
}
|
||||
throw new ArgumentNullException(nameof(user), "User is required for job-based backtests");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var candles = await GetCandles(config.Ticker, config.Timeframe, startDate, endDate);
|
||||
return await RunBacktestWithCandles(config, candles, user, save, withCandles, requestId, metadata);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// If backtest fails and we debited credits, attempt to refund
|
||||
if (user != null && !string.IsNullOrEmpty(creditRequestId))
|
||||
{
|
||||
try
|
||||
{
|
||||
var refundSuccess = await _kaigenService.RefundUserCreditsAsync(creditRequestId, user);
|
||||
if (refundSuccess)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Successfully refunded credits for user {UserName} after backtest failure: {message}",
|
||||
user.Name, ex.Message);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("Failed to refund credits for user {UserName} after backtest failure",
|
||||
user.Name);
|
||||
}
|
||||
}
|
||||
catch (Exception refundEx)
|
||||
{
|
||||
_logger.LogError(refundEx, "Error during refund attempt for user {UserName}", user.Name);
|
||||
}
|
||||
}
|
||||
// Create a job instead of running synchronously
|
||||
var job = await _jobService.CreateJobAsync(
|
||||
config,
|
||||
startDate,
|
||||
endDate,
|
||||
user,
|
||||
priority: 0,
|
||||
requestId: requestId);
|
||||
|
||||
throw;
|
||||
}
|
||||
_logger.LogInformation(
|
||||
"Created backtest job {JobId} for user {UserId}. Job will be processed by compute workers.",
|
||||
job.Id, user.Id);
|
||||
|
||||
// Return a placeholder response with job ID
|
||||
// The actual result will be available via GetJobStatus endpoint
|
||||
return new LightBacktestResponse
|
||||
{
|
||||
Id = job.Id.ToString(),
|
||||
Config = config,
|
||||
Score = 0, // Placeholder, actual score will be available when job completes
|
||||
ScoreMessage = $"Job {job.Id} is queued for processing"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -153,67 +126,21 @@ namespace Managing.Application.Backtests
|
||||
string requestId = null,
|
||||
object metadata = null)
|
||||
{
|
||||
return await RunBacktestWithCandles(config, candles, user, false, withCandles, requestId, metadata);
|
||||
// This overload is deprecated - use the date range overload which creates a job
|
||||
// For backward compatibility, create a job with the provided candles date range
|
||||
if (user == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(user), "User is required");
|
||||
}
|
||||
|
||||
var startDate = candles.Min(c => c.Date);
|
||||
var endDate = candles.Max(c => c.Date);
|
||||
|
||||
return await RunTradingBotBacktest(config, startDate, endDate, user, false, withCandles, requestId, metadata);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core backtesting logic - handles the actual backtest execution with pre-loaded candles
|
||||
/// </summary>
|
||||
private async Task<LightBacktestResponse> RunBacktestWithCandles(
|
||||
TradingBotConfig config,
|
||||
HashSet<Candle> candles,
|
||||
User user = null,
|
||||
bool save = false,
|
||||
bool withCandles = false,
|
||||
string requestId = null,
|
||||
object metadata = null)
|
||||
{
|
||||
// Ensure this is a backtest configuration
|
||||
if (!config.IsForBacktest)
|
||||
{
|
||||
throw new InvalidOperationException("Backtest configuration must have IsForBacktest set to true");
|
||||
}
|
||||
|
||||
// Validate that scenario and indicators are properly loaded
|
||||
if (config.Scenario == null && string.IsNullOrEmpty(config.ScenarioName))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Backtest configuration must include either Scenario object or ScenarioName");
|
||||
}
|
||||
|
||||
if (config.Scenario == null && !string.IsNullOrEmpty(config.ScenarioName))
|
||||
{
|
||||
var fullScenario = await _scenarioService.GetScenarioByNameAndUserAsync(config.ScenarioName, user);
|
||||
config.Scenario = LightScenario.FromScenario(fullScenario);
|
||||
}
|
||||
|
||||
// Create a clean copy of the config to avoid Orleans serialization issues
|
||||
var cleanConfig = CreateCleanConfigForOrleans(config);
|
||||
|
||||
// Create Orleans grain for backtesting
|
||||
var backtestGrain = _grainFactory.GetGrain<IBacktestTradingBotGrain>(Guid.NewGuid());
|
||||
|
||||
// Run the backtest using the Orleans grain
|
||||
var result = await backtestGrain.RunBacktestAsync(cleanConfig, candles, user, save, withCandles, requestId,
|
||||
metadata);
|
||||
|
||||
// Increment backtest count for the user if user is provided
|
||||
if (user != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ServiceScopeHelpers.WithScopedService<IAgentService>(_serviceScopeFactory,
|
||||
async (agentService) => await agentService.IncrementBacktestCountAsync(user.Id));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to increment backtest count for user {UserId}", user.Id);
|
||||
// Don't throw here as the backtest was successful, just log the error
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
// Removed RunBacktestWithCandles - backtests now run via compute workers
|
||||
// This method is kept for backward compatibility but should not be called directly
|
||||
|
||||
private async Task<HashSet<Candle>> GetCandles(Ticker ticker, Timeframe timeframe,
|
||||
DateTime startDate, DateTime endDate)
|
||||
@@ -229,16 +156,7 @@ namespace Managing.Application.Backtests
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Creates a clean copy of the trading bot config for Orleans serialization
|
||||
/// Uses LightScenario and LightIndicator to avoid FixedSizeQueue serialization issues
|
||||
/// </summary>
|
||||
private TradingBotConfig CreateCleanConfigForOrleans(TradingBotConfig originalConfig)
|
||||
{
|
||||
// Since we're now using LightScenario in TradingBotConfig, we can just return the original config
|
||||
// The conversion to LightScenario is already done when loading the scenario
|
||||
return originalConfig;
|
||||
}
|
||||
// Removed CreateCleanConfigForOrleans - no longer needed with job queue approach
|
||||
|
||||
private async Task SendBacktestNotificationIfCriteriaMet(Backtest backtest)
|
||||
{
|
||||
@@ -464,8 +382,121 @@ namespace Managing.Application.Backtests
|
||||
|
||||
if (!saveAsTemplate)
|
||||
{
|
||||
// Trigger the BundleBacktestGrain to process this request
|
||||
await TriggerBundleBacktestGrainAsync(bundleRequest.RequestId);
|
||||
// Generate backtest requests from variants (same logic as BundleBacktestGrain)
|
||||
var backtestRequests = await GenerateBacktestRequestsFromVariants(bundleRequest);
|
||||
|
||||
if (backtestRequests != null && backtestRequests.Any())
|
||||
{
|
||||
// Create jobs for all variants
|
||||
await _jobService.CreateBundleJobsAsync(bundleRequest, backtestRequests);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created {JobCount} backtest jobs for bundle request {BundleRequestId}",
|
||||
backtestRequests.Count, bundleRequest.RequestId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates individual backtest requests from variant configuration
|
||||
/// </summary>
|
||||
private async Task<List<RunBacktestRequest>> GenerateBacktestRequestsFromVariants(
|
||||
BundleBacktestRequest bundleRequest)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Deserialize the variant configurations
|
||||
var universalConfig =
|
||||
JsonSerializer.Deserialize<BundleBacktestUniversalConfig>(bundleRequest.UniversalConfigJson);
|
||||
var dateTimeRanges = JsonSerializer.Deserialize<List<DateTimeRange>>(bundleRequest.DateTimeRangesJson);
|
||||
var moneyManagementVariants =
|
||||
JsonSerializer.Deserialize<List<MoneyManagementVariant>>(bundleRequest.MoneyManagementVariantsJson);
|
||||
var tickerVariants = JsonSerializer.Deserialize<List<Ticker>>(bundleRequest.TickerVariantsJson);
|
||||
|
||||
if (universalConfig == null || dateTimeRanges == null || moneyManagementVariants == null ||
|
||||
tickerVariants == null)
|
||||
{
|
||||
_logger.LogError("Failed to deserialize variant configurations for bundle request {RequestId}",
|
||||
bundleRequest.RequestId);
|
||||
return new List<RunBacktestRequest>();
|
||||
}
|
||||
|
||||
// Get the first account for the user
|
||||
var accounts = await _accountService.GetAccountsByUserAsync(bundleRequest.User, hideSecrets: true, getBalance: false);
|
||||
var firstAccount = accounts.FirstOrDefault();
|
||||
|
||||
if (firstAccount == null)
|
||||
{
|
||||
_logger.LogError("No accounts found for user {UserId} in bundle request {RequestId}",
|
||||
bundleRequest.User.Id, bundleRequest.RequestId);
|
||||
return new List<RunBacktestRequest>();
|
||||
}
|
||||
|
||||
var backtestRequests = new List<RunBacktestRequest>();
|
||||
|
||||
foreach (var dateRange in dateTimeRanges)
|
||||
{
|
||||
foreach (var mmVariant in moneyManagementVariants)
|
||||
{
|
||||
foreach (var ticker in tickerVariants)
|
||||
{
|
||||
var config = new TradingBotConfigRequest
|
||||
{
|
||||
AccountName = firstAccount.Name,
|
||||
Ticker = ticker,
|
||||
Timeframe = universalConfig.Timeframe,
|
||||
IsForWatchingOnly = universalConfig.IsForWatchingOnly,
|
||||
BotTradingBalance = universalConfig.BotTradingBalance,
|
||||
Name =
|
||||
$"{universalConfig.BotName}_{ticker}_{dateRange.StartDate:yyyyMMdd}_{dateRange.EndDate:yyyyMMdd}",
|
||||
FlipPosition = universalConfig.FlipPosition,
|
||||
CooldownPeriod = universalConfig.CooldownPeriod,
|
||||
MaxLossStreak = universalConfig.MaxLossStreak,
|
||||
Scenario = universalConfig.Scenario,
|
||||
ScenarioName = universalConfig.ScenarioName,
|
||||
MoneyManagement = mmVariant.MoneyManagement,
|
||||
MaxPositionTimeHours = universalConfig.MaxPositionTimeHours,
|
||||
CloseEarlyWhenProfitable = universalConfig.CloseEarlyWhenProfitable,
|
||||
FlipOnlyWhenInProfit = universalConfig.FlipOnlyWhenInProfit,
|
||||
UseSynthApi = universalConfig.UseSynthApi,
|
||||
UseForPositionSizing = universalConfig.UseForPositionSizing,
|
||||
UseForSignalFiltering = universalConfig.UseForSignalFiltering,
|
||||
UseForDynamicStopLoss = universalConfig.UseForDynamicStopLoss
|
||||
};
|
||||
|
||||
var backtestRequest = new RunBacktestRequest
|
||||
{
|
||||
Config = config,
|
||||
StartDate = dateRange.StartDate,
|
||||
EndDate = dateRange.EndDate,
|
||||
Balance = universalConfig.BotTradingBalance,
|
||||
WatchOnly = universalConfig.WatchOnly,
|
||||
Save = universalConfig.Save,
|
||||
WithCandles = false,
|
||||
MoneyManagement = mmVariant.MoneyManagement != null
|
||||
? new MoneyManagement
|
||||
{
|
||||
Name = mmVariant.MoneyManagement.Name,
|
||||
Timeframe = mmVariant.MoneyManagement.Timeframe,
|
||||
StopLoss = mmVariant.MoneyManagement.StopLoss,
|
||||
TakeProfit = mmVariant.MoneyManagement.TakeProfit,
|
||||
Leverage = mmVariant.MoneyManagement.Leverage
|
||||
}
|
||||
: null
|
||||
};
|
||||
|
||||
backtestRequests.Add(backtestRequest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return backtestRequests;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error generating backtest requests from variants for bundle request {RequestId}",
|
||||
bundleRequest.RequestId);
|
||||
return new List<RunBacktestRequest>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,64 +561,6 @@ namespace Managing.Application.Backtests
|
||||
await _hubContext.Clients.Group($"bundle-{requestId}").SendAsync("BundleBacktestUpdate", response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Triggers the BundleBacktestGrain to process a bundle request synchronously (fire and forget)
|
||||
/// </summary>
|
||||
private void TriggerBundleBacktestGrain(Guid bundleRequestId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bundleBacktestGrain = _grainFactory.GetGrain<IBundleBacktestGrain>(bundleRequestId);
|
||||
|
||||
// Fire and forget - don't await
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await bundleBacktestGrain.ProcessBundleRequestAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error triggering BundleBacktestGrain for request {RequestId}",
|
||||
bundleRequestId);
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in TriggerBundleBacktestGrain for request {RequestId}", bundleRequestId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Triggers the BundleBacktestGrain to process a bundle request asynchronously
|
||||
/// </summary>
|
||||
private Task TriggerBundleBacktestGrainAsync(Guid bundleRequestId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bundleBacktestGrain = _grainFactory.GetGrain<IBundleBacktestGrain>(bundleRequestId);
|
||||
|
||||
// Fire and forget - don't await the actual processing
|
||||
return Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await bundleBacktestGrain.ProcessBundleRequestAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error triggering BundleBacktestGrain for request {RequestId}",
|
||||
bundleRequestId);
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in TriggerBundleBacktestGrainAsync for request {RequestId}",
|
||||
bundleRequestId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
// Removed TriggerBundleBacktestGrain methods - bundle backtests now use job queue
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ public class UserService : IUserService
|
||||
private readonly IAccountService _accountService;
|
||||
private readonly ILogger<UserService> _logger;
|
||||
private readonly ICacheService _cacheService;
|
||||
private readonly IGrainFactory _grainFactory;
|
||||
private readonly IGrainFactory? _grainFactory;
|
||||
private readonly IWhitelistService _whitelistService;
|
||||
private readonly string[] _authorizedAddresses;
|
||||
|
||||
@@ -27,7 +27,7 @@ public class UserService : IUserService
|
||||
IAccountService accountService,
|
||||
ILogger<UserService> logger,
|
||||
ICacheService cacheService,
|
||||
IGrainFactory grainFactory,
|
||||
IGrainFactory? grainFactory,
|
||||
IWhitelistService whitelistService,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
@@ -134,17 +134,21 @@ public class UserService : IUserService
|
||||
};
|
||||
|
||||
// Initialize AgentGrain for new user (with empty agent name initially)
|
||||
try
|
||||
// Only if Orleans is available (not available in compute workers)
|
||||
if (_grainFactory != null)
|
||||
{
|
||||
var agentGrain = _grainFactory.GetGrain<IAgentGrain>(user.Id);
|
||||
await agentGrain.InitializeAsync(user.Id, string.Empty);
|
||||
_logger.LogInformation("AgentGrain initialized for new user {UserId}", user.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to initialize AgentGrain for new user {UserId}", user.Id);
|
||||
SentrySdk.CaptureException(ex);
|
||||
// Don't throw here to avoid breaking the user creation process
|
||||
try
|
||||
{
|
||||
var agentGrain = _grainFactory.GetGrain<IAgentGrain>(user.Id);
|
||||
await agentGrain.InitializeAsync(user.Id, string.Empty);
|
||||
_logger.LogInformation("AgentGrain initialized for new user {UserId}", user.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to initialize AgentGrain for new user {UserId}", user.Id);
|
||||
SentrySdk.CaptureException(ex);
|
||||
// Don't throw here to avoid breaking the user creation process
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,18 +203,22 @@ public class UserService : IUserService
|
||||
await _userRepository.SaveOrUpdateUserAsync(user);
|
||||
|
||||
// Update the AgentGrain with the new agent name (lightweight operation)
|
||||
try
|
||||
// Only if Orleans is available (not available in compute workers)
|
||||
if (_grainFactory != null)
|
||||
{
|
||||
var agentGrain = _grainFactory.GetGrain<IAgentGrain>(user.Id);
|
||||
await agentGrain.UpdateAgentNameAsync(agentName);
|
||||
_logger.LogInformation("AgentGrain updated for user {UserId} with agent name {AgentName}", user.Id,
|
||||
agentName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update AgentGrain for user {UserId} with agent name {AgentName}",
|
||||
user.Id, agentName);
|
||||
// Don't throw here to avoid breaking the user update process
|
||||
try
|
||||
{
|
||||
var agentGrain = _grainFactory.GetGrain<IAgentGrain>(user.Id);
|
||||
await agentGrain.UpdateAgentNameAsync(agentName);
|
||||
_logger.LogInformation("AgentGrain updated for user {UserId} with agent name {AgentName}", user.Id,
|
||||
agentName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update AgentGrain for user {UserId} with agent name {AgentName}",
|
||||
user.Id, agentName);
|
||||
// Don't throw here to avoid breaking the user update process
|
||||
}
|
||||
}
|
||||
|
||||
return user;
|
||||
|
||||
422
src/Managing.Application/Workers/BacktestComputeWorker.cs
Normal file
422
src/Managing.Application/Workers/BacktestComputeWorker.cs
Normal file
@@ -0,0 +1,422 @@
|
||||
using System.Text.Json;
|
||||
using Managing.Application.Abstractions.Repositories;
|
||||
using Managing.Application.Abstractions.Services;
|
||||
using Managing.Application.Backtests;
|
||||
using Managing.Domain.Backtests;
|
||||
using Managing.Domain.Bots;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using static Managing.Common.Enums;
|
||||
|
||||
namespace Managing.Application.Workers;
|
||||
|
||||
/// <summary>
|
||||
/// Background worker that processes backtest jobs from the queue.
|
||||
/// Polls for pending jobs, claims them using advisory locks, and processes them.
|
||||
/// </summary>
|
||||
public class BacktestComputeWorker : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<BacktestComputeWorker> _logger;
|
||||
private readonly BacktestComputeWorkerOptions _options;
|
||||
private readonly SemaphoreSlim _semaphore;
|
||||
|
||||
public BacktestComputeWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<BacktestComputeWorker> logger,
|
||||
IOptions<BacktestComputeWorkerOptions> options)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
_options = options.Value;
|
||||
_semaphore = new SemaphoreSlim(_options.MaxConcurrentBacktests, _options.MaxConcurrentBacktests);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"BacktestComputeWorker starting. WorkerId: {WorkerId}, MaxConcurrent: {MaxConcurrent}, PollInterval: {PollInterval}s",
|
||||
_options.WorkerId, _options.MaxConcurrentBacktests, _options.JobPollIntervalSeconds);
|
||||
|
||||
// Background task for stale job recovery
|
||||
var staleJobRecoveryTask = Task.Run(() => StaleJobRecoveryLoop(stoppingToken), stoppingToken);
|
||||
|
||||
// Background task for heartbeat updates
|
||||
var heartbeatTask = Task.Run(() => HeartbeatLoop(stoppingToken), stoppingToken);
|
||||
|
||||
// Main job processing loop
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessJobsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in BacktestComputeWorker main loop");
|
||||
SentrySdk.CaptureException(ex);
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(_options.JobPollIntervalSeconds), stoppingToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("BacktestComputeWorker stopping");
|
||||
}
|
||||
|
||||
private async Task ProcessJobsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Check if we have capacity
|
||||
if (!await _semaphore.WaitAsync(0, cancellationToken))
|
||||
{
|
||||
// At capacity, skip this iteration
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var jobRepository = scope.ServiceProvider.GetRequiredService<IBacktestJobRepository>();
|
||||
|
||||
// Try to claim a job
|
||||
var job = await jobRepository.ClaimNextJobAsync(_options.WorkerId);
|
||||
|
||||
if (job == null)
|
||||
{
|
||||
// No jobs available, release semaphore
|
||||
_semaphore.Release();
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Claimed backtest job {JobId} for worker {WorkerId}", job.Id, _options.WorkerId);
|
||||
|
||||
// Process the job asynchronously (don't await, let it run in background)
|
||||
// Create a new scope for the job processing to ensure proper lifetime management
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessJobAsync(job, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_semaphore.Release();
|
||||
}
|
||||
}, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error claiming or processing job");
|
||||
_semaphore.Release();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessJobAsync(
|
||||
BacktestJob job,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var jobRepository = scope.ServiceProvider.GetRequiredService<IBacktestJobRepository>();
|
||||
var executor = scope.ServiceProvider.GetRequiredService<BacktestExecutor>();
|
||||
var userService = scope.ServiceProvider.GetRequiredService<IUserService>();
|
||||
var exchangeService = scope.ServiceProvider.GetRequiredService<IExchangeService>();
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Processing backtest job {JobId} (BundleRequestId: {BundleRequestId}, UserId: {UserId})",
|
||||
job.Id, job.BundleRequestId, job.UserId);
|
||||
|
||||
// Deserialize config
|
||||
var config = JsonSerializer.Deserialize<TradingBotConfig>(job.ConfigJson);
|
||||
if (config == null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to deserialize TradingBotConfig from job");
|
||||
}
|
||||
|
||||
// Load user
|
||||
var user = await userService.GetUserByIdAsync(job.UserId);
|
||||
if (user == null)
|
||||
{
|
||||
throw new InvalidOperationException($"User {job.UserId} not found");
|
||||
}
|
||||
|
||||
// Load candles
|
||||
var candles = await exchangeService.GetCandlesInflux(
|
||||
TradingExchanges.Evm,
|
||||
config.Ticker,
|
||||
job.StartDate,
|
||||
config.Timeframe,
|
||||
job.EndDate);
|
||||
|
||||
if (candles == null || candles.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"No candles found for {config.Ticker} on {config.Timeframe} from {job.StartDate} to {job.EndDate}");
|
||||
}
|
||||
|
||||
// Progress callback to update job progress
|
||||
Func<int, Task> progressCallback = async (percentage) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
job.ProgressPercentage = percentage;
|
||||
job.LastHeartbeat = DateTime.UtcNow;
|
||||
await jobRepository.UpdateAsync(job);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error updating job progress for job {JobId}", job.Id);
|
||||
}
|
||||
};
|
||||
|
||||
// Execute the backtest
|
||||
var result = await executor.ExecuteAsync(
|
||||
config,
|
||||
candles,
|
||||
user,
|
||||
save: true,
|
||||
withCandles: false,
|
||||
requestId: job.RequestId,
|
||||
metadata: null,
|
||||
progressCallback: progressCallback);
|
||||
|
||||
// Update job with result
|
||||
job.Status = BacktestJobStatus.Completed;
|
||||
job.ProgressPercentage = 100;
|
||||
job.ResultJson = JsonSerializer.Serialize(result);
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
job.LastHeartbeat = DateTime.UtcNow;
|
||||
|
||||
await jobRepository.UpdateAsync(job);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Completed backtest job {JobId}. Score: {Score}, PnL: {PnL}",
|
||||
job.Id, result.Score, result.FinalPnl);
|
||||
|
||||
// Update bundle request if this is part of a bundle
|
||||
if (job.BundleRequestId.HasValue)
|
||||
{
|
||||
await UpdateBundleRequestProgress(job.BundleRequestId.Value, scope.ServiceProvider);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing backtest job {JobId}", job.Id);
|
||||
SentrySdk.CaptureException(ex);
|
||||
|
||||
// Update job status to failed
|
||||
try
|
||||
{
|
||||
job.Status = BacktestJobStatus.Failed;
|
||||
job.ErrorMessage = ex.Message;
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
await jobRepository.UpdateAsync(job);
|
||||
|
||||
// Update bundle request if this is part of a bundle
|
||||
if (job.BundleRequestId.HasValue)
|
||||
{
|
||||
await UpdateBundleRequestProgress(job.BundleRequestId.Value, scope.ServiceProvider);
|
||||
}
|
||||
}
|
||||
catch (Exception updateEx)
|
||||
{
|
||||
_logger.LogError(updateEx, "Error updating job {JobId} status to failed", job.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateBundleRequestProgress(Guid bundleRequestId, IServiceProvider serviceProvider)
|
||||
{
|
||||
try
|
||||
{
|
||||
var backtestRepository = serviceProvider.GetRequiredService<IBacktestRepository>();
|
||||
var jobRepository = serviceProvider.GetRequiredService<IBacktestJobRepository>();
|
||||
var userService = serviceProvider.GetRequiredService<IUserService>();
|
||||
|
||||
// Get all jobs for this bundle
|
||||
var jobs = await jobRepository.GetByBundleRequestIdAsync(bundleRequestId);
|
||||
var completedJobs = jobs.Count(j => j.Status == BacktestJobStatus.Completed);
|
||||
var failedJobs = jobs.Count(j => j.Status == BacktestJobStatus.Failed);
|
||||
var runningJobs = jobs.Count(j => j.Status == BacktestJobStatus.Running);
|
||||
var totalJobs = jobs.Count();
|
||||
|
||||
if (totalJobs == 0)
|
||||
{
|
||||
return; // No jobs yet
|
||||
}
|
||||
|
||||
// Get user from first job
|
||||
var firstJob = jobs.First();
|
||||
var user = await userService.GetUserByIdAsync(firstJob.UserId);
|
||||
if (user == null)
|
||||
{
|
||||
_logger.LogWarning("User {UserId} not found for bundle request {BundleRequestId}", firstJob.UserId, bundleRequestId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get bundle request
|
||||
var bundleRequest = backtestRepository.GetBundleBacktestRequestByIdForUser(user, bundleRequestId);
|
||||
if (bundleRequest == null)
|
||||
{
|
||||
_logger.LogWarning("Bundle request {BundleRequestId} not found for user {UserId}", bundleRequestId, user.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update bundle request progress
|
||||
bundleRequest.CompletedBacktests = completedJobs;
|
||||
bundleRequest.FailedBacktests = failedJobs;
|
||||
|
||||
// Update status based on job states
|
||||
if (completedJobs + failedJobs == totalJobs)
|
||||
{
|
||||
// All jobs completed or failed
|
||||
if (failedJobs == 0)
|
||||
{
|
||||
bundleRequest.Status = BundleBacktestRequestStatus.Completed;
|
||||
}
|
||||
else if (completedJobs == 0)
|
||||
{
|
||||
bundleRequest.Status = BundleBacktestRequestStatus.Failed;
|
||||
bundleRequest.ErrorMessage = "All backtests failed";
|
||||
}
|
||||
else
|
||||
{
|
||||
bundleRequest.Status = BundleBacktestRequestStatus.Completed;
|
||||
bundleRequest.ErrorMessage = $"{failedJobs} backtests failed";
|
||||
}
|
||||
bundleRequest.CompletedAt = DateTime.UtcNow;
|
||||
bundleRequest.CurrentBacktest = null;
|
||||
}
|
||||
else if (runningJobs > 0)
|
||||
{
|
||||
// Some jobs still running
|
||||
bundleRequest.Status = BundleBacktestRequestStatus.Running;
|
||||
}
|
||||
|
||||
// Update results list from completed jobs
|
||||
var completedJobResults = jobs
|
||||
.Where(j => j.Status == BacktestJobStatus.Completed && !string.IsNullOrEmpty(j.ResultJson))
|
||||
.Select(j =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = JsonSerializer.Deserialize<LightBacktest>(j.ResultJson);
|
||||
return result?.Id;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.Where(id => !string.IsNullOrEmpty(id))
|
||||
.ToList();
|
||||
|
||||
bundleRequest.Results = completedJobResults!;
|
||||
|
||||
await backtestRepository.UpdateBundleBacktestRequestAsync(bundleRequest);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Updated bundle request {BundleRequestId} progress: {Completed}/{Total} completed, {Failed} failed, {Running} running",
|
||||
bundleRequestId, completedJobs, totalJobs, failedJobs, runningJobs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error updating bundle request {BundleRequestId} progress", bundleRequestId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StaleJobRecoveryLoop(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), cancellationToken); // Check every minute
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var jobRepository = scope.ServiceProvider.GetRequiredService<IBacktestJobRepository>();
|
||||
|
||||
var resetCount = await jobRepository.ResetStaleJobsAsync(_options.StaleJobTimeoutMinutes);
|
||||
|
||||
if (resetCount > 0)
|
||||
{
|
||||
_logger.LogInformation("Reset {Count} stale backtest jobs back to Pending status", resetCount);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in stale job recovery loop");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HeartbeatLoop(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(_options.HeartbeatIntervalSeconds), cancellationToken);
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var jobRepository = scope.ServiceProvider.GetRequiredService<IBacktestJobRepository>();
|
||||
|
||||
// Update heartbeat for all jobs assigned to this worker
|
||||
var runningJobs = await jobRepository.GetRunningJobsByWorkerIdAsync(_options.WorkerId);
|
||||
|
||||
foreach (var job in runningJobs)
|
||||
{
|
||||
job.LastHeartbeat = DateTime.UtcNow;
|
||||
await jobRepository.UpdateAsync(job);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in heartbeat loop");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
_semaphore?.Dispose();
|
||||
base.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for BacktestComputeWorker
|
||||
/// </summary>
|
||||
public class BacktestComputeWorkerOptions
|
||||
{
|
||||
public const string SectionName = "BacktestComputeWorker";
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier for this worker instance
|
||||
/// </summary>
|
||||
public string WorkerId { get; set; } = Environment.MachineName;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of concurrent backtests to process
|
||||
/// </summary>
|
||||
public int MaxConcurrentBacktests { get; set; } = 6;
|
||||
|
||||
/// <summary>
|
||||
/// Interval in seconds between job polling attempts
|
||||
/// </summary>
|
||||
public int JobPollIntervalSeconds { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Interval in seconds between heartbeat updates
|
||||
/// </summary>
|
||||
public int HeartbeatIntervalSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Timeout in minutes for considering a job stale
|
||||
/// </summary>
|
||||
public int StaleJobTimeoutMinutes { get; set; } = 5;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user