Files
managing-apps/src/Managing.Application/Backtests/BacktestJobService.cs
cryptooda 6376e13b07 Add Bollinger Bands Volatility Protection indicator support
- Introduced BollingerBandsVolatilityProtection indicator in GeneticService with configuration settings for period and standard deviation (stdev).
- Updated ScenarioHelpers to handle creation and validation of the new indicator type.
- Enhanced CustomScenario, backtest, and scenario pages to include BollingerBandsVolatilityProtection in indicator lists and parameter mappings.
- Modified API and types to reflect the addition of the new indicator in relevant enums and mappings.
- Updated frontend components to support new parameters and visualization for Bollinger Bands.
2025-11-25 02:12:57 +07:00

317 lines
13 KiB
C#

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 JobService
{
private readonly IJobRepository _jobRepository;
private readonly IBacktestRepository _backtestRepository;
private readonly IKaigenService _kaigenService;
private readonly ILogger<JobService> _logger;
public JobService(
IJobRepository jobRepository,
IBacktestRepository backtestRepository,
IKaigenService kaigenService,
ILogger<JobService> logger)
{
_jobRepository = jobRepository;
_backtestRepository = backtestRepository;
_kaigenService = kaigenService;
_logger = logger;
}
/// <summary>
/// Creates a single backtest job
/// </summary>
public async Task<Job> 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;
}
}
/// <summary>
/// Creates multiple backtest jobs from bundle variants
/// </summary>
public async Task<List<Job>> CreateBundleJobsAsync(
BundleBacktestRequest bundleRequest,
List<RunBacktestRequest> backtestRequests)
{
var jobs = new List<Job>();
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<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} 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;
}
}
/// <summary>
/// Retries a failed or cancelled job by resetting it to Pending status.
/// </summary>
/// <param name="jobId">The job ID to retry</param>
/// <returns>The updated job</returns>
/// <exception cref="InvalidOperationException">Thrown if job cannot be retried</exception>
public async Task<Job> 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;
}
/// <summary>
/// Deletes a job from the database.
/// </summary>
/// <param name="jobId">The job ID to delete</param>
/// <exception cref="InvalidOperationException">Thrown if job cannot be found</exception>
public async Task DeleteJobAsync(Guid jobId)
{
await _jobRepository.DeleteAsync(jobId);
_logger.LogInformation("Deleted job {JobId}", jobId);
}
}