using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Managing.Application.Abstractions; using Managing.Application.Abstractions.Repositories; using Managing.Application.Abstractions.Services; using Managing.Application.Backtests; using Managing.Application.Bots; using Managing.Application.Tests; 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.Strategies; using Managing.Domain.Users; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Moq; using Newtonsoft.Json; using Xunit; using static Managing.Common.Enums; namespace Managing.Workers.Tests; public class BacktestExecutorTests : BaseTests, IDisposable { private readonly BacktestExecutor _backtestExecutor; private readonly Mock _scopeFactory; private readonly Mock _backtestRepository; private readonly Mock _scenarioService; private readonly Mock _messengerService; private readonly User _testUser; private readonly ILoggerFactory _loggerFactory; public BacktestExecutorTests() : base() { // Setup mock dependencies _backtestRepository = new Mock(); _scenarioService = new Mock(); _messengerService = new Mock(); // Setup service scope factory _scopeFactory = new Mock(); var mockScope = new Mock(); var mockServiceProvider = new Mock(); // Setup TradingBotBase logger var tradingBotLogger = TradingBaseTests.CreateTradingBotLogger(); mockServiceProvider.Setup(x => x.GetService(typeof(ILogger))) .Returns(tradingBotLogger); // Setup all services that TradingBotBase might need mockServiceProvider.Setup(x => x.GetService(typeof(IExchangeService))) .Returns(_exchangeService); mockServiceProvider.Setup(x => x.GetService(typeof(IAccountService))) .Returns(_accountService.Object); mockServiceProvider.Setup(x => x.GetService(typeof(ITradingService))) .Returns(_tradingService.Object); mockServiceProvider.Setup(x => x.GetService(typeof(IMoneyManagementService))) .Returns(_moneyManagementService.Object); mockServiceProvider.Setup(x => x.GetService(typeof(IBotService))) .Returns(new Mock().Object); mockServiceProvider.Setup(x => x.GetService(typeof(IMessengerService))) .Returns(_messengerService.Object); mockScope.Setup(x => x.ServiceProvider).Returns(mockServiceProvider.Object); _scopeFactory.Setup(x => x.CreateScope()).Returns(mockScope.Object); // Create test user with account _testUser = new User { Id = 1, Name = "Test User", Accounts = new List { _account } }; // Create logger factory for telemetry visibility _loggerFactory = LoggerFactory.Create(builder => { builder.AddConsole(); builder.SetMinimumLevel(LogLevel.Debug); // Enable debug for troubleshooting }); // Create BacktestExecutor instance with console logger var logger = _loggerFactory.CreateLogger(); _backtestExecutor = new BacktestExecutor( logger, _scopeFactory.Object, _backtestRepository.Object, _scenarioService.Object, _accountService.Object, _messengerService.Object); } [Fact] public async Task ExecuteBacktest_With_ETH_FifteenMinutes_Data_Should_Return_LightBacktest() { Console.WriteLine("TEST START: ExecuteBacktest_With_ETH_FifteenMinutes_Data_Should_Return_LightBacktest"); // Arrange var candles = FileHelpers.ReadJson>("../../../Data/ETH-FifteenMinutes-candles.json"); Assert.NotNull(candles); Assert.NotEmpty(candles); var scenario = new Scenario("ETH_BacktestScenario"); var rsiDivIndicator = ScenarioHelpers.BuildIndicator(IndicatorType.RsiDivergence, "RsiDiv", period: 14); scenario.Indicators = new List { (IndicatorBase)rsiDivIndicator }; scenario.LoopbackPeriod = 15; var config = new TradingBotConfig { AccountName = _account.Name, MoneyManagement = MoneyManagement, Ticker = Ticker.ETH, Scenario = LightScenario.FromScenario(scenario), Timeframe = Timeframe.FifteenMinutes, IsForWatchingOnly = false, BotTradingBalance = 1000, IsForBacktest = true, CooldownPeriod = 1, MaxLossStreak = 0, FlipPosition = false, Name = "ETH_FifteenMinutes_Test", FlipOnlyWhenInProfit = true, MaxPositionTimeHours = null, CloseEarlyWhenProfitable = false }; // Act var result = await _backtestExecutor.ExecuteAsync( config, candles.ToHashSet(), _testUser, save: false, withCandles: false, requestId: null, bundleRequestId: null, metadata: null, progressCallback: null); // Output the result to console for review var json = JsonConvert.SerializeObject(new { result.FinalPnl, result.WinRate, result.GrowthPercentage, result.HodlPercentage, result.Fees, result.NetPnl, result.MaxDrawdown, result.SharpeRatio, result.Score, result.InitialBalance, StartDate = result.StartDate.ToString("yyyy-MM-dd HH:mm:ss"), EndDate = result.EndDate.ToString("yyyy-MM-dd HH:mm:ss") }, Formatting.Indented); Console.WriteLine("BacktestExecutor Results:"); Console.WriteLine(json); // Debug: Verify telemetry is working Console.WriteLine($"DEBUG: Test completed successfully with {result.WinRate}% win rate"); // Assert - Validate specific backtest results Assert.NotNull(result); Assert.IsType(result); // Validate key metrics - Updated after bug fix in executor Assert.Equal(1000.0m, result.InitialBalance); Assert.Equal(44.343999999999999999999999991m, result.FinalPnl); Assert.Equal(31, result.WinRate); Assert.Equal(4.43440000000000000000000m, result.GrowthPercentage); Assert.Equal(-0.67091284426766023865867781m, result.HodlPercentage); Assert.Equal(86.64864600000000000000000000m, result.Fees); Assert.Equal(-42.304646000000000000000000009m, result.NetPnl); Assert.Equal(119.8400000000000000000000000m, result.MaxDrawdown); Assert.True(Math.Abs((double)(result.SharpeRatio ?? 0) - 0.01080949889674031) < 0.01, $"SharpeRatio mismatch: expected ~0.01080949889674031, got {result.SharpeRatio}"); Assert.True(Math.Abs(result.Score - 12.402462484050353) < 0.001, $"Score {result.Score} should be within 0.001 of expected value 12.402462484050353"); // Validate dates Assert.Equal(new DateTime(2025, 10, 14, 12, 0, 0), result.StartDate); Assert.Equal(new DateTime(2025, 10, 24, 11, 45, 0), result.EndDate); Assert.True(result.StartDate < result.EndDate); } [Fact] public async Task LongBacktest_ETH_RSI() { // Arrange var candles = FileHelpers.ReadJson>("../../../Data/ETH-FifteenMinutes-candles-20:44:15 +00:00-.json"); Assert.NotNull(candles); Assert.NotEmpty(candles); // Use more candles for performance testing (first 5000 candles) candles = candles.Take(5000).ToList(); Console.WriteLine($"DEBUG: Loaded {candles.Count} candles for backtest"); var scenario = new Scenario("ETH_BacktestScenario"); var rsiDivIndicator = ScenarioHelpers.BuildIndicator(IndicatorType.RsiDivergence, "RsiDiv", period: 14); scenario.Indicators = new List { (IndicatorBase)rsiDivIndicator }; scenario.LoopbackPeriod = 15; var config = new TradingBotConfig { AccountName = _account.Name, MoneyManagement = MoneyManagement, Ticker = Ticker.ETH, Scenario = LightScenario.FromScenario(scenario), Timeframe = Timeframe.FifteenMinutes, IsForWatchingOnly = false, BotTradingBalance = 100000, // Increased balance for testing more candles IsForBacktest = true, CooldownPeriod = 1, MaxLossStreak = 0, FlipPosition = false, Name = "ETH_FifteenMinutes_Test_Second", FlipOnlyWhenInProfit = true, MaxPositionTimeHours = null, CloseEarlyWhenProfitable = false }; // Act var result = await _backtestExecutor.ExecuteAsync( config, candles.ToHashSet(), _testUser, save: false, withCandles: false, requestId: null, bundleRequestId: null, metadata: null, progressCallback: null); // Output the result to console for review var json = JsonConvert.SerializeObject(new { result.FinalPnl, result.WinRate, result.GrowthPercentage, result.HodlPercentage, result.Fees, result.NetPnl, result.MaxDrawdown, result.SharpeRatio, result.Score, result.InitialBalance, StartDate = result.StartDate.ToString("yyyy-MM-dd HH:mm:ss"), EndDate = result.EndDate.ToString("yyyy-MM-dd HH:mm:ss") }, Formatting.Indented); Console.WriteLine("BacktestExecutor Results (Second File):"); Console.WriteLine(json); // Assert - Validate specific backtest results Assert.NotNull(result); Assert.IsType(result); // Validate key metrics - Updated after bug fix in executor Assert.Equal(100000.0m, result.InitialBalance); Assert.Equal(-31899.032000000000000000000000m, result.FinalPnl); Assert.Equal(21, result.WinRate); Assert.Equal(-31.8990320000000000000000m, result.GrowthPercentage); Assert.Equal(-12.86812721679866545042180006m, result.HodlPercentage); Assert.Equal(25875.444102000000000000000000m, result.Fees); Assert.Equal(-57774.476102000000000000000000m, result.NetPnl); Assert.Equal(37030.256000000000000000000000m, result.MaxDrawdown); Assert.True(Math.Abs((double)(result.SharpeRatio ?? 0) - (-0.024119163190349627)) < 0.01, $"SharpeRatio mismatch: expected ~-0.024119163190349627, got {result.SharpeRatio}"); // Use tolerance for floating point precision Assert.Equal((double)0.0m, result.Score); // Validate dates - Updated to match actual results from ETH-FifteenMinutes-candles-20:44:15 +00:00-.json Assert.Equal(new DateTime(2025, 9, 11, 20, 45, 0), result.StartDate); Assert.Equal(new DateTime(2025, 11, 2, 22, 30, 0), result.EndDate); Assert.True(result.StartDate < result.EndDate); } [Fact] public async Task Telemetry_ETH_RSI() { // Arrange - Use the large dataset for performance testing var candles = FileHelpers.ReadJson>("../../../Data/ETH-FifteenMinutes-candles-20:44:15 +00:00-.json"); Assert.NotNull(candles); Assert.NotEmpty(candles); Console.WriteLine($"DEBUG: Loaded {candles.Count} candles for performance telemetry test"); var scenario = new Scenario("ETH_BacktestScenario"); var rsiDivIndicator = ScenarioHelpers.BuildIndicator(IndicatorType.RsiDivergence, "RsiDiv", period: 14); scenario.Indicators = new List { (IndicatorBase)rsiDivIndicator }; scenario.LoopbackPeriod = 15; var config = new TradingBotConfig { AccountName = _account.Name, MoneyManagement = MoneyManagement, Ticker = Ticker.ETH, Scenario = LightScenario.FromScenario(scenario), Timeframe = Timeframe.FifteenMinutes, IsForWatchingOnly = false, BotTradingBalance = 100000, IsForBacktest = true, CooldownPeriod = 1, MaxLossStreak = 0, FlipPosition = false, Name = "ETH_FifteenMinutes_Performance_Test", FlipOnlyWhenInProfit = true, MaxPositionTimeHours = null, CloseEarlyWhenProfitable = false }; // Track execution time var startTime = DateTime.UtcNow; // Act var result = await _backtestExecutor.ExecuteAsync( config, candles.ToHashSet(), _testUser, save: false, withCandles: false, requestId: null, bundleRequestId: null, metadata: null, progressCallback: null); var endTime = DateTime.UtcNow; var totalExecutionTime = (endTime - startTime).TotalSeconds; // Output performance metrics Console.WriteLine("═══════════════════════════════════════════════════════════"); Console.WriteLine("📊 PERFORMANCE TELEMETRY TEST RESULTS"); Console.WriteLine("═══════════════════════════════════════════════════════════"); Console.WriteLine($"📈 Total Candles Processed: {candles.Count:N0}"); Console.WriteLine($"⏱️ Total Execution Time: {totalExecutionTime:F2}s"); Console.WriteLine($"🚀 Processing Rate: {candles.Count / totalExecutionTime:F1} candles/sec"); Console.WriteLine($"💾 Memory per 1000 candles: ~{(16.80 - 12.06) / (candles.Count / 1000.0):F2}MB"); Console.WriteLine(); Console.WriteLine("📋 Backtest Results Summary:"); Console.WriteLine($" • Final PnL: {result.FinalPnl:F2}"); Console.WriteLine($" • Win Rate: {result.WinRate}%"); Console.WriteLine($" • Growth: {result.GrowthPercentage:F2}%"); Console.WriteLine($" • Fees: {result.Fees:F2}"); Console.WriteLine($" • Net PnL: {result.NetPnl:F2}"); Console.WriteLine($" • Max Drawdown: {result.MaxDrawdown:F2}"); Console.WriteLine($" • Sharpe Ratio: {result.SharpeRatio:F4}"); Console.WriteLine($" • Score: {result.Score:F2}"); Console.WriteLine($" • Start Date: {result.StartDate:yyyy-MM-dd HH:mm:ss}"); Console.WriteLine($" • End Date: {result.EndDate:yyyy-MM-dd HH:mm:ss}"); Console.WriteLine("═══════════════════════════════════════════════════════════"); // Assert - Validate basic results Assert.NotNull(result); Assert.IsType(result); Assert.True(result.StartDate < result.EndDate); Assert.True(totalExecutionTime > 0); // Performance assertions - ensure we're processing at a reasonable rate var candlesPerSecond = candles.Count / totalExecutionTime; Assert.True(candlesPerSecond > 500, $"Expected >500 candles/sec, got {candlesPerSecond:F1} candles/sec"); Console.WriteLine($"✅ Performance test passed: {candlesPerSecond:F1} candles/sec"); } [Fact] public async Task Telemetry_ETH_RSI_EMACROSS() { // Arrange - Test with 2 indicators to verify pre-calculated signals optimization works with multiple scenarios var candles = FileHelpers.ReadJson>("../../../Data/ETH-FifteenMinutes-candles-20:44:15 +00:00-.json"); Assert.NotNull(candles); Assert.NotEmpty(candles); Console.WriteLine($"DEBUG: Loaded {candles.Count} candles for two-scenarios performance telemetry test"); var scenario = new Scenario("ETH_TwoScenarios_Backtest"); var rsiDivIndicator = ScenarioHelpers.BuildIndicator(IndicatorType.RsiDivergence, "RsiDiv", period: 14); var emaCrossIndicator = ScenarioHelpers.BuildIndicator(IndicatorType.EmaCross, "EmaCross", period: 21); scenario.Indicators = new List { (IndicatorBase)rsiDivIndicator, (IndicatorBase)emaCrossIndicator }; scenario.LoopbackPeriod = 15; // 15 minutes loopback period as requested var config = new TradingBotConfig { AccountName = _account.Name, MoneyManagement = MoneyManagement, Ticker = Ticker.ETH, Scenario = LightScenario.FromScenario(scenario), Timeframe = Timeframe.FifteenMinutes, IsForWatchingOnly = false, BotTradingBalance = 100000, IsForBacktest = true, CooldownPeriod = 1, MaxLossStreak = 0, FlipPosition = false, Name = "ETH_TwoScenarios_Performance_Test", FlipOnlyWhenInProfit = true, MaxPositionTimeHours = null, CloseEarlyWhenProfitable = false }; // Track execution time var startTime = DateTime.UtcNow; // Act var result = await _backtestExecutor.ExecuteAsync( config, candles.ToHashSet(), _testUser, save: false, withCandles: false, requestId: null, bundleRequestId: null, metadata: null, progressCallback: null); var executionTime = DateTime.UtcNow - startTime; // Assert - Verify the result is valid Assert.NotNull(result); Assert.Equal(Ticker.ETH, result.Config.Ticker); Assert.Equal(100000, result.InitialBalance); Assert.True(result.Score >= 0); // Score can be 0 or positive // Output the result to console for review var json = JsonConvert.SerializeObject(new { result.FinalPnl, result.WinRate, result.GrowthPercentage, result.HodlPercentage, result.Fees, result.NetPnl, result.MaxDrawdown, result.SharpeRatio, result.Score, result.InitialBalance, StartDate = result.StartDate.ToString("yyyy-MM-dd HH:mm:ss"), EndDate = result.EndDate.ToString("yyyy-MM-dd HH:mm:ss") }, Formatting.Indented); Console.WriteLine("Two-Scenarios Backtest Results:"); Console.WriteLine(json); // Business Logic Baseline Assertions - Updated after bug fix in executor // These values establish the expected baseline for the two-scenarios test const decimal expectedFinalPnl = -34137.424000000000000000000000m; const double expectedScore = 0.0; const int expectedWinRatePercent = 20; // 20% win rate const decimal expectedGrowthPercentage = -34.1374240000000000000000m; // Allow small tolerance for floating-point precision variations const decimal pnlTolerance = 0.01m; const double scoreTolerance = 0.01; const decimal growthTolerance = 0.01m; Assert.True(Math.Abs(result.FinalPnl - expectedFinalPnl) <= pnlTolerance, $"Final PnL {result.FinalPnl:F2} differs from expected baseline {expectedFinalPnl:F2} (tolerance: ±{pnlTolerance:F2})"); Assert.True(Math.Abs(result.Score - expectedScore) <= scoreTolerance, $"Score {result.Score:F2} differs from expected baseline {expectedScore:F2} (tolerance: ±{scoreTolerance:F2})"); Assert.True(Math.Abs(result.WinRate - expectedWinRatePercent) <= 5, $"Win Rate {result.WinRate}% differs from expected baseline {expectedWinRatePercent}% (tolerance: ±5%)"); Assert.True(Math.Abs(result.GrowthPercentage - expectedGrowthPercentage) <= growthTolerance, $"Growth {result.GrowthPercentage:F2}% differs from expected baseline {expectedGrowthPercentage:F2}% (tolerance: ±{growthTolerance:F2}%)"); // Performance metrics var totalCandles = candles.Count; var candlesPerSecond = totalCandles / executionTime.TotalSeconds; // Log comprehensive performance metrics Console.WriteLine($"📊 === TWO-SCENARIOS PERFORMANCE TELEMETRY ==="); Console.WriteLine($"⏱️ Total Execution Time: {executionTime.TotalSeconds:F2}s"); Console.WriteLine($"📈 Candles Processed: {totalCandles} ({candlesPerSecond:F1} candles/sec)"); Console.WriteLine($"🎯 Final PnL: {result.FinalPnl:F2} (Expected: {expectedFinalPnl:F2})"); Console.WriteLine($"📊 Score: {result.Score:F2} (Expected: {expectedScore:F2})"); Console.WriteLine($"📈 Win Rate: {result.WinRate}% (Expected: {expectedWinRatePercent}%)"); Console.WriteLine($"📈 Growth: {result.GrowthPercentage:F2}% (Expected: {expectedGrowthPercentage:F2}%)"); Console.WriteLine($"🎭 Scenario: {scenario.Name} ({scenario.Indicators.Count} indicators, LoopbackPeriod: {scenario.LoopbackPeriod})"); // Performance assertion - should be reasonably fast even with 2 indicators Assert.True(candlesPerSecond > 200, $"Expected >200 candles/sec with 2 indicators, got {candlesPerSecond:F1} candles/sec"); Console.WriteLine($"✅ Two-scenarios performance test passed: {candlesPerSecond:F1} candles/sec with {scenario.Indicators.Count} indicators"); } public void Dispose() { _loggerFactory?.Dispose(); } }