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; /// /// Service for creating and managing backtest jobs in the queue /// public class JobService { private readonly IJobRepository _jobRepository; private readonly IBacktestRepository _backtestRepository; private readonly IKaigenService _kaigenService; private readonly ILogger _logger; public JobService( IJobRepository jobRepository, IBacktestRepository backtestRepository, IKaigenService kaigenService, ILogger logger) { _jobRepository = jobRepository; _backtestRepository = backtestRepository; _kaigenService = kaigenService; _logger = logger; } /// /// Creates a single backtest job /// public async Task 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 Job { UserId = user.Id, Status = JobStatus.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; } } /// /// Creates multiple backtest jobs from bundle variants /// public async Task> CreateBundleJobsAsync( BundleBacktestRequest bundleRequest, List backtestRequests) { var jobs = new List(); 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) { MinimumHistory = ind.MinimumHistory, Period = ind.Period, FastPeriods = ind.FastPeriods, SlowPeriods = ind.SlowPeriods, SignalPeriods = ind.SignalPeriods, Multiplier = ind.Multiplier, StDev = ind.StDev, SmoothPeriods = ind.SmoothPeriods, StochPeriods = ind.StochPeriods, CyclePeriods = ind.CyclePeriods, KFactor = ind.KFactor, DFactor = ind.DFactor, TenkanPeriods = ind.TenkanPeriods, KijunPeriods = ind.KijunPeriods, SenkouBPeriods = ind.SenkouBPeriods, OffsetPeriods = ind.OffsetPeriods, SenkouOffset = ind.SenkouOffset, ChikouOffset = ind.ChikouOffset }).ToList() ?? new List() }; } // 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} v{bundleRequest.Version} #{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 Job { UserId = bundleRequest.User.Id, Status = JobStatus.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; } } /// /// Retries a failed or cancelled job by resetting it to Pending status. /// /// The job ID to retry /// The updated job /// Thrown if job cannot be retried public async Task RetryJobAsync(Guid jobId) { var job = await _jobRepository.GetByIdAsync(jobId); if (job == null) { throw new InvalidOperationException($"Job with ID {jobId} not found."); } // Only allow retrying Failed or Cancelled jobs // Running jobs should be handled by stale job recovery, not manual retry if (job.Status != JobStatus.Failed && job.Status != JobStatus.Cancelled) { throw new InvalidOperationException( $"Cannot retry job with status {job.Status}. Only Failed or Cancelled jobs can be retried."); } // Reset job to pending state job.Status = JobStatus.Pending; job.AssignedWorkerId = null; job.LastHeartbeat = null; job.StartedAt = null; job.CompletedAt = null; job.ProgressPercentage = 0; job.RetryAfter = null; // Keep ErrorMessage for reference, but clear it on next run // Keep RetryCount to track total retries // Reset IsRetryable to true job.IsRetryable = true; await _jobRepository.UpdateAsync(job); _logger.LogInformation("Job {JobId} reset to Pending status for retry", jobId); return job; } /// /// Deletes a job from the database. /// /// The job ID to delete /// Thrown if job cannot be found public async Task DeleteJobAsync(Guid jobId) { await _jobRepository.DeleteAsync(jobId); _logger.LogInformation("Deleted job {JobId}", jobId); } }