using System.Diagnostics; 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.Strategies.Base; using Managing.Domain.Users; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using static Managing.Common.Enums; namespace Managing.Application.Backtests; /// /// Signal caching optimization to reduce redundant calculations /// public class SignalCache { private readonly Dictionary> _cachedSignals = new(); private readonly int _cacheSize = 50; // Cache last N candle signals to balance memory vs performance private int _nextCacheKey = 0; public bool TryGetCachedSignal(int cacheKey, IndicatorType indicatorType, out object signal) { if (_cachedSignals.TryGetValue(cacheKey, out var candleSignals) && candleSignals.TryGetValue(indicatorType, out signal)) { return true; } signal = null; return false; } public int CacheSignal(IndicatorType indicatorType, object signal) { var cacheKey = _nextCacheKey++; if (!_cachedSignals.ContainsKey(cacheKey)) _cachedSignals[cacheKey] = new Dictionary(); _cachedSignals[cacheKey][indicatorType] = signal; // Maintain cache size - remove oldest entries if (_cachedSignals.Count > _cacheSize) { var oldestKey = _cachedSignals.Keys.Min(); _cachedSignals.Remove(oldestKey); } return cacheKey; } public void Clear() { _cachedSignals.Clear(); _nextCacheKey = 0; } } /// /// Comprehensive telemetry data for backtest execution profiling /// public class BacktestTelemetry { public long MemoryUsageAtStart { get; set; } public long MemoryUsageAtEnd { get; set; } public long PeakMemoryUsage { get; set; } public TimeSpan IndicatorPreCalculationTime { get; set; } public TimeSpan CandleProcessingTime { get; set; } public TimeSpan SignalUpdateTime { get; set; } public TimeSpan BacktestStepTime { get; set; } public TimeSpan ProgressCallbackTime { get; set; } public TimeSpan ResultCalculationTime { get; set; } public int TotalCandlesProcessed { get; set; } public int TotalSignalUpdates { get; set; } public int TotalBacktestSteps { get; set; } public int ProgressCallbacksCount { get; set; } public Dictionary OperationBreakdown { get; } = new(); } /// /// Service for executing backtests without Orleans dependencies. /// Extracted from BacktestTradingBotGrain to be reusable in compute workers. /// public class BacktestExecutor { private readonly ILogger _logger; private readonly IServiceScopeFactory _scopeFactory; private readonly IBacktestRepository _backtestRepository; private readonly IScenarioService _scenarioService; private readonly IAccountService _accountService; private readonly IMessengerService _messengerService; private readonly SignalCache _signalCache = new(); public BacktestExecutor( ILogger logger, IServiceScopeFactory scopeFactory, IBacktestRepository backtestRepository, IScenarioService scenarioService, IAccountService accountService, IMessengerService messengerService) { _logger = logger; _scopeFactory = scopeFactory; _backtestRepository = backtestRepository; _scenarioService = scenarioService; _accountService = accountService; _messengerService = messengerService; } /// /// Executes a backtest with the given configuration and candles. /// /// The trading bot configuration /// The candles to use for backtesting /// The user running the backtest /// Whether to save the backtest result /// Whether to include candles in the result /// The request ID to associate with this backtest /// Optional bundle request ID to update with backtest result /// Additional metadata /// Optional callback for progress updates (0-100) /// Cancellation token to stop execution /// The lightweight backtest result public async Task ExecuteAsync( TradingBotConfig config, HashSet candles, User user, bool save = false, bool withCandles = false, string requestId = null, Guid? bundleRequestId = null, object metadata = null, Func progressCallback = null, CancellationToken cancellationToken = default) { if (candles == null || candles.Count == 0) { throw new Exception("No candle to backtest"); } // Comprehensive telemetry setup var backtestStartTime = DateTime.UtcNow; var stopwatch = Stopwatch.StartNew(); var telemetry = new BacktestTelemetry(); // Initial memory snapshot var initialMemory = GC.GetTotalMemory(false); telemetry.MemoryUsageAtStart = initialMemory; _logger.LogInformation( "🚀 Backtest execution started - RequestId: {RequestId}, Candles: {CandleCount}, Memory: {MemoryMB:F2}MB", requestId ?? "N/A", candles.Count, initialMemory / 1024.0 / 1024.0); // 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 = 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); // Pre-calculate indicator values once for all candles to optimize performance // This avoids recalculating indicators for every candle iteration Dictionary preCalculatedIndicatorValues = null; if (config.Scenario != null && false) { var indicatorCalcStart = Stopwatch.GetTimestamp(); try { _logger.LogInformation("⚡ Pre-calculating indicator values for {IndicatorCount} indicators", config.Scenario.Indicators?.Count ?? 0); // Convert LightScenario to Scenario for CalculateIndicatorsValues var scenario = config.Scenario.ToScenario(); // Calculate all indicator values once with all candles preCalculatedIndicatorValues = TradingBox.CalculateIndicatorsValues(scenario, candles); telemetry.IndicatorPreCalculationTime = Stopwatch.GetElapsedTime(indicatorCalcStart); _logger.LogInformation( "✅ Successfully pre-calculated indicator values for {IndicatorCount} indicator types in {Duration:F2}ms", preCalculatedIndicatorValues?.Count ?? 0, telemetry.IndicatorPreCalculationTime.TotalMilliseconds); } catch (Exception ex) { telemetry.IndicatorPreCalculationTime = Stopwatch.GetElapsedTime(indicatorCalcStart); _logger.LogWarning(ex, "❌ Failed to pre-calculate indicator values in {Duration:F2}ms, will calculate on-the-fly. Error: {ErrorMessage}", telemetry.IndicatorPreCalculationTime.TotalMilliseconds, ex.Message); // Continue with normal calculation if pre-calculation fails preCalculatedIndicatorValues = null; } } // Initialize wallet balance with first candle tradingBot.WalletBalances.Clear(); tradingBot.WalletBalances.Add(candles.FirstOrDefault()!.Date, config.BotTradingBalance); var initialBalance = config.BotTradingBalance; // Pre-allocate and populate candle structures for maximum performance var orderedCandles = candles.ToList(); // Skip pre-calculated signals - the approach was flawed and caused performance regression // The signal calculation depends on rolling window state and cannot be pre-calculated effectively // Use optimized rolling window approach - TradingBox.GetSignal only needs last 600 candles var candlesProcessed = 0; // Signal caching optimization - reduce signal update frequency for better performance var signalUpdateSkipCount = 0; var lastProgressUpdate = DateTime.UtcNow; const int progressUpdateIntervalMs = 5000; // Update progress every 5 seconds to reduce database load const int walletCheckInterval = 10; // Check wallet balance every N candles instead of every candle var lastWalletCheck = 0; var lastWalletBalance = config.BotTradingBalance; var fixedCandles = new HashSet(); // Track memory usage during processing var peakMemory = initialMemory; const int memoryCheckInterval = 100; // Check memory every N candles to reduce GC.GetTotalMemory overhead var lastMemoryCheck = 0; // Start timing the candle processing loop var candleProcessingStart = Stopwatch.GetTimestamp(); var signalUpdateTotalTime = TimeSpan.Zero; var backtestStepTotalTime = TimeSpan.Zero; var progressCallbackTotalTime = TimeSpan.Zero; _logger.LogInformation("🔄 Starting candle processing for {CandleCount} candles", orderedCandles.Count); // Process all candles with optimized rolling window approach foreach (var candle in orderedCandles) { // Check for cancellation (timeout or shutdown) cancellationToken.ThrowIfCancellationRequested(); // Add to HashSet for reuse fixedCandles.Add(candle); tradingBot.LastCandle = candle; // Run with optimized backtest path (minimize async calls) var signalUpdateStart = Stopwatch.GetTimestamp(); await tradingBot.UpdateSignals(fixedCandles, preCalculatedIndicatorValues); signalUpdateTotalTime += Stopwatch.GetElapsedTime(signalUpdateStart); var backtestStepStart = Stopwatch.GetTimestamp(); await tradingBot.Run(); backtestStepTotalTime += Stopwatch.GetElapsedTime(backtestStepStart); telemetry.TotalBacktestSteps++; currentCandle++; candlesProcessed++; // Optimized wallet balance check - only check every N candles and cache result if (currentCandle - lastWalletCheck >= walletCheckInterval) { lastWalletBalance = tradingBot.WalletBalances.Values.LastOrDefault(); lastWalletCheck = currentCandle; if (lastWalletBalance < 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, lastWalletBalance, currentCandle, totalCandles, candle.Date.ToString("yyyy-MM-dd HH:mm")); break; } } // Update progress callback if provided (optimized frequency) var currentPercentage = (currentCandle * 100) / totalCandles; var timeSinceLastUpdate = (DateTime.UtcNow - lastProgressUpdate).TotalMilliseconds; if (progressCallback != null && (timeSinceLastUpdate >= progressUpdateIntervalMs || currentPercentage >= lastLoggedPercentage + 10)) { var progressCallbackStart = Stopwatch.GetTimestamp(); try { await progressCallback(currentPercentage); telemetry.ProgressCallbacksCount++; } catch (Exception ex) { _logger.LogWarning(ex, "Error in progress callback"); } progressCallbackTotalTime += Stopwatch.GetElapsedTime(progressCallbackStart); lastProgressUpdate = DateTime.UtcNow; } // Track peak memory usage (reduced frequency to minimize GC overhead) if (currentCandle - lastMemoryCheck >= memoryCheckInterval) { var currentMemory = GC.GetTotalMemory(false); if (currentMemory > peakMemory) { peakMemory = currentMemory; } lastMemoryCheck = currentCandle; } // Log progress every 10% (reduced frequency) if (currentPercentage >= lastLoggedPercentage + 10) { lastLoggedPercentage = currentPercentage; _logger.LogInformation( "Backtest progress: {Percentage}% ({CurrentCandle}/{TotalCandles} candles processed)", currentPercentage, currentCandle, totalCandles); } } // Complete candle processing telemetry telemetry.CandleProcessingTime = Stopwatch.GetElapsedTime(candleProcessingStart); telemetry.SignalUpdateTime = signalUpdateTotalTime; telemetry.BacktestStepTime = backtestStepTotalTime; telemetry.ProgressCallbackTime = progressCallbackTotalTime; telemetry.TotalCandlesProcessed = candlesProcessed; _logger.LogInformation("✅ Backtest processing completed. Calculating final results..."); // Start result calculation timing var resultCalculationStart = Stopwatch.GetTimestamp(); // Calculate final results using static methods from TradingBox var realizedPnl = TradingBox.GetTotalRealizedPnL(tradingBot.Positions); // PnL before fees var netPnl = TradingBox.GetTotalNetPnL(tradingBot.Positions); // PnL after fees var winRate = TradingBox.GetWinRate(tradingBot.Positions); var stats = TradingBox.GetStatistics(tradingBot.WalletBalances); var growthPercentage = TradingBox.GetGrowthFromInitalBalance(tradingBot.WalletBalances.FirstOrDefault().Value, netPnl); var hodlPercentage = TradingBox.GetHodlPercentage(candles.First(), candles.Last()); var fees = TradingBox.GetTotalFees(tradingBot.Positions); var scoringParams = new BacktestScoringParams( sharpeRatio: (double)stats.SharpeRatio, growthPercentage: (double)growthPercentage, hodlPercentage: (double)hodlPercentage, winRate: winRate, totalPnL: (double)netPnl, 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); // Complete result calculation telemetry telemetry.ResultCalculationTime = Stopwatch.GetElapsedTime(resultCalculationStart); // Final memory snapshot var finalMemory = GC.GetTotalMemory(false); telemetry.MemoryUsageAtEnd = finalMemory; telemetry.PeakMemoryUsage = peakMemory; // 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) { FinalPnl = realizedPnl, // Realized PnL before fees 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 = netPnl, // Net PnL after fees }; if (save && user != null) { await _backtestRepository.InsertBacktestForUserAsync(user, result); } // Send notification if backtest meets criteria await SendBacktestNotificationIfCriteriaMet(result); // Stop overall timing stopwatch.Stop(); var totalExecutionTime = stopwatch.Elapsed; // Comprehensive performance telemetry logging _logger.LogInformation("📊 === BACKTEST PERFORMANCE TELEMETRY ==="); _logger.LogInformation("⏱️ Total Execution Time: {TotalTime:F2}s", totalExecutionTime.TotalSeconds); _logger.LogInformation("📈 Candles Processed: {Candles} ({CandlesPerSecond:F1} candles/sec)", telemetry.TotalCandlesProcessed, telemetry.TotalCandlesProcessed / totalExecutionTime.TotalSeconds); _logger.LogInformation("💾 Memory Usage: Start={StartMB:F2}MB, End={EndMB:F2}MB, Peak={PeakMB:F2}MB", telemetry.MemoryUsageAtStart / 1024.0 / 1024.0, telemetry.MemoryUsageAtEnd / 1024.0 / 1024.0, telemetry.PeakMemoryUsage / 1024.0 / 1024.0); _logger.LogInformation("⚡ Operation Breakdown:"); _logger.LogInformation(" • Indicator Pre-calculation: {Time:F2}ms ({Percentage:F1}%)", telemetry.IndicatorPreCalculationTime.TotalMilliseconds, telemetry.IndicatorPreCalculationTime.TotalMilliseconds / totalExecutionTime.TotalMilliseconds * 100); _logger.LogInformation(" • Candle Processing: {Time:F2}ms ({Percentage:F1}%)", telemetry.CandleProcessingTime.TotalMilliseconds, telemetry.CandleProcessingTime.TotalMilliseconds / totalExecutionTime.TotalMilliseconds * 100); _logger.LogInformation( " • Signal Updates: {Time:F2}ms ({Percentage:F1}%) - {Count} updates, {SkipCount} skipped ({Efficiency:F1}% efficiency)", telemetry.SignalUpdateTime.TotalMilliseconds, telemetry.SignalUpdateTime.TotalMilliseconds / totalExecutionTime.TotalMilliseconds * 100, telemetry.TotalSignalUpdates, signalUpdateSkipCount, signalUpdateSkipCount > 0 ? (double)signalUpdateSkipCount / (telemetry.TotalSignalUpdates + signalUpdateSkipCount) * 100 : 0); _logger.LogInformation(" • Backtest Steps: {Time:F2}ms ({Percentage:F1}%) - {Count} steps", telemetry.BacktestStepTime.TotalMilliseconds, telemetry.BacktestStepTime.TotalMilliseconds / totalExecutionTime.TotalMilliseconds * 100, telemetry.TotalBacktestSteps); _logger.LogInformation(" • Progress Callbacks: {Time:F2}ms ({Percentage:F1}%) - {Count} calls", telemetry.ProgressCallbackTime.TotalMilliseconds, telemetry.ProgressCallbackTime.TotalMilliseconds / totalExecutionTime.TotalMilliseconds * 100, telemetry.ProgressCallbacksCount); _logger.LogInformation(" • Result Calculation: {Time:F2}ms ({Percentage:F1}%)", telemetry.ResultCalculationTime.TotalMilliseconds, telemetry.ResultCalculationTime.TotalMilliseconds / totalExecutionTime.TotalMilliseconds * 100); // Performance insights var signalUpdateAvg = telemetry.TotalSignalUpdates > 0 ? telemetry.SignalUpdateTime.TotalMilliseconds / telemetry.TotalSignalUpdates : 0; var backtestStepAvg = telemetry.TotalBacktestSteps > 0 ? telemetry.BacktestStepTime.TotalMilliseconds / telemetry.TotalBacktestSteps : 0; _logger.LogInformation("🔍 Performance Insights:"); _logger.LogInformation(" • Average Signal Update: {Avg:F2}ms per update", signalUpdateAvg); _logger.LogInformation(" • Average Backtest Step: {Avg:F2}ms per step", backtestStepAvg); _logger.LogInformation(" • Memory Efficiency: {Efficiency:F2}MB per 1000 candles", (telemetry.PeakMemoryUsage - telemetry.MemoryUsageAtStart) / 1024.0 / 1024.0 / (telemetry.TotalCandlesProcessed / 1000.0)); // Identify potential bottlenecks var bottlenecks = new List(); if (telemetry.SignalUpdateTime.TotalMilliseconds / totalExecutionTime.TotalMilliseconds > 0.5) bottlenecks.Add("Signal Updates"); if (telemetry.BacktestStepTime.TotalMilliseconds / totalExecutionTime.TotalMilliseconds > 0.3) bottlenecks.Add("Backtest Steps"); if (telemetry.IndicatorPreCalculationTime.TotalMilliseconds / totalExecutionTime.TotalMilliseconds > 0.2) bottlenecks.Add("Indicator Pre-calculation"); if (bottlenecks.Any()) { _logger.LogWarning("⚠️ Performance Bottlenecks Detected: {Bottlenecks}", string.Join(", ", bottlenecks)); } _logger.LogInformation( "🎯 Backtest completed successfully - RequestId: {RequestId} - Score: {Score} - Realized PnL: {RealizedPnl} - Net PnL: {NetPnl} - Fees: {Fees}", finalRequestId, result.Score, result.FinalPnl, result.NetPnl, result.Fees); // Convert Backtest to LightBacktest return ConvertToLightBacktest(result); } /// /// Pre-calculates all signals for the entire backtest period /// This eliminates repeated GetSignal() calls during the backtest loop /// /// /// Converts a Backtest to LightBacktest /// 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 }; } /// /// Creates a TradingBotBase instance for backtesting /// private 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>(); var tradingBot = new TradingBotBase(logger, _scopeFactory, config); return tradingBot; } /// /// Sends notification if backtest meets criteria /// 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); } } }