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,
IReadOnlyList 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);
var account = user.Accounts.First();
account.User = user; // Ensure Account.User is set for backtest
tradingBot.Account = account;
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
// Use List directly to preserve chronological order and enable incremental updates
const int RollingWindowSize = 600; // TradingBox.GetSignal only needs last 600 candles
var rollingWindowCandles = new List(RollingWindowSize); // Pre-allocate capacity for performance
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;
// 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();
// Maintain rolling window of last 600 candles to prevent exponential memory growth
// Incremental updates: remove oldest if at capacity, then add newest
// This preserves chronological order and avoids expensive HashSet recreation
if (rollingWindowCandles.Count >= RollingWindowSize)
{
rollingWindowCandles.RemoveAt(0); // Remove oldest candle (O(n) but only 600 items max)
}
rollingWindowCandles.Add(candle); // Add newest candle (O(1) amortized)
tradingBot.LastCandle = candle;
// Run with optimized backtest path (minimize async calls)
var signalUpdateStart = Stopwatch.GetTimestamp();
// Pass List directly - no conversion needed, order is preserved
await tradingBot.UpdateSignals(rollingWindowCandles, 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,
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
PositionCount = tradingBot.Positions.Count,
};
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,
PositionCount = backtest.PositionCount
};
}
///
/// 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.TradingType != TradingType.BacktestFutures && config.TradingType != TradingType.BacktestSpot)
{
throw new InvalidOperationException($"BacktestExecutor can only be used for backtesting. TradingType must be BacktestFutures or BacktestSpot, but got {config.TradingType}");
}
// Create the trading bot instance based on TradingType
using var scope = _scopeFactory.CreateScope();
var logger = scope.ServiceProvider.GetRequiredService>();
TradingBotBase tradingBot = config.TradingType switch
{
TradingType.BacktestFutures => new BacktestFuturesBot(logger, _scopeFactory, config),
TradingType.BacktestSpot => new BacktestSpotBot(logger, _scopeFactory, config),
_ => throw new InvalidOperationException($"Unsupported TradingType for backtest: {config.TradingType}")
};
return tradingBot;
}
///
/// Sends notification if backtest meets criteria
///
private async Task SendBacktestNotificationIfCriteriaMet(Backtest backtest)
{
try
{
if (backtest.Score > 65 && backtest.Statistics.SharpeRatio >= 0.01m)
{
await _messengerService.SendBacktestNotification(backtest);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send backtest notification for backtest {Id}", backtest.Id);
}
}
}