- 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.
317 lines
13 KiB
C#
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);
|
|
}
|
|
} |