This commit is contained in:
2025-11-09 02:08:31 +07:00
parent 1ed58d1a98
commit 7dba29c66f
57 changed files with 8362 additions and 359 deletions

View 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);
}
}
}

View 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;
}
}
}

View File

@@ -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
}
}