using System.Text.Json; using Managing.Api.Models.Requests; using Managing.Api.Models.Responses; using Managing.Application.Abstractions.Repositories; using Managing.Application.Abstractions.Services; using Managing.Application.Abstractions.Shared; using Managing.Application.Hubs; using Managing.Domain.Backtests; using Managing.Domain.Bots; using Managing.Domain.MoneyManagements; using Managing.Domain.Scenarios; using Managing.Domain.Strategies; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; using static Managing.Common.Enums; using MoneyManagementRequest = Managing.Domain.Backtests.MoneyManagementRequest; namespace Managing.Api.Controllers; /// /// Controller for managing backtest operations. /// Provides endpoints for creating, retrieving, and deleting backtests. /// Returns complete backtest configurations for easy bot deployment. /// Requires authorization for access. /// [ApiController] [Authorize] [Route("[controller]")] [Produces("application/json")] public class BacktestController : BaseController { private readonly IHubContext _hubContext; private readonly IBacktester _backtester; private readonly IAccountService _accountService; private readonly IMoneyManagementService _moneyManagementService; private readonly IGeneticService _geneticService; private readonly IFlagsmithService _flagsmithService; private readonly IServiceScopeFactory _serviceScopeFactory; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The SignalR hub context for real-time communication. /// The service for backtesting strategies. /// The service for managing scenarios. /// The service for account management. /// The service for money management strategies. /// The service for genetic algorithm operations. /// The repository for backtest operations. public BacktestController( IHubContext hubContext, IBacktester backtester, IAccountService accountService, IMoneyManagementService moneyManagementService, IGeneticService geneticService, IFlagsmithService flagsmithService, IUserService userService, IServiceScopeFactory serviceScopeFactory, ILogger logger) : base(userService) { _hubContext = hubContext; _backtester = backtester; _accountService = accountService; _moneyManagementService = moneyManagementService; _geneticService = geneticService; _flagsmithService = flagsmithService; _serviceScopeFactory = serviceScopeFactory; _logger = logger; } /// /// Retrieves all backtests for the authenticated user. /// Each backtest includes the complete TradingBotConfig for easy bot deployment. /// /// A list of backtests with complete configurations. [HttpGet] public async Task>> Backtests() { var user = await GetUser(); var backtests = await _backtester.GetBacktestsByUserAsync(user); return Ok(backtests); } /// /// Retrieves a specific backtest by ID for the authenticated user. /// This endpoint will also populate the candles for visualization and includes /// the complete TradingBotConfig that can be used to start a new bot. /// /// The ID of the backtest to retrieve. /// The requested backtest with populated candle data and complete configuration. [HttpGet("{id}")] public async Task> Backtest(string id) { var user = await GetUser(); var backtest = await _backtester.GetBacktestByIdForUserAsync(user, id); if (backtest == null) { return NotFound($"Backtest with ID {id} not found or doesn't belong to the current user."); } return Ok(backtest); } /// /// Retrieves only the statistical information for a specific backtest by ID. /// This endpoint returns only the performance metrics without positions, signals, or candles. /// Useful for displaying backtest stats when starting a bot from a backtest. /// /// The ID of the backtest to retrieve stats for. /// The backtest statistics without detailed position/signal data. [HttpGet("{id}/stats")] public async Task> GetBacktestStats(int id) { var user = await GetUser(); var backtest = await _backtester.GetBacktestByIdForUserAsync(user, id.ToString()); if (backtest == null) { return NotFound($"Backtest with ID {id} not found or doesn't belong to the current user."); } // Return only the statistical information var stats = new { id = backtest.Id, name = backtest.Config.Name, ticker = backtest.Config.Ticker, timeframe = backtest.Config.Timeframe, tradingType = backtest.Config.TradingType, startDate = backtest.StartDate, endDate = backtest.EndDate, initialBalance = backtest.InitialBalance, finalPnl = backtest.FinalPnl, netPnl = backtest.NetPnl, growthPercentage = backtest.GrowthPercentage, hodlPercentage = backtest.HodlPercentage, winRate = backtest.WinRate, sharpeRatio = backtest.Statistics?.SharpeRatio ?? 0, maxDrawdown = backtest.Statistics?.MaxDrawdown ?? 0, maxDrawdownRecoveryTime = backtest.Statistics?.MaxDrawdownRecoveryTime ?? TimeSpan.Zero, fees = backtest.Fees, score = backtest.Score, scoreMessage = backtest.ScoreMessage, positionCount = backtest.PositionCount }; return Ok(stats); } /// /// Deletes a specific backtest by ID for the authenticated user. /// /// The ID of the backtest to delete. /// An ActionResult indicating the outcome of the operation. [HttpDelete] public async Task DeleteBacktest(string id) { var user = await GetUser(); var result = await _backtester.DeleteBacktestByUserAsync(user, id); return Ok(result); } /// /// Deletes multiple backtests by their IDs for the authenticated user. /// /// The request containing the array of backtest IDs to delete. /// An ActionResult indicating the outcome of the operation. [HttpDelete("multiple")] public async Task DeleteBacktests([FromBody] DeleteBacktestsRequest request) { var user = await GetUser(); return Ok(await _backtester.DeleteBacktestsByIdsForUserAsync(user, request.BacktestIds)); } /// /// Deletes backtests based on filter criteria for the authenticated user. /// Uses the same filter parameters as GetBacktestsPaginated. /// /// Minimum score filter (0-100) /// Maximum score filter (0-100) /// Minimum winrate filter (0-100) /// Maximum winrate filter (0-100) /// Maximum drawdown filter /// Comma-separated list of tickers to filter by /// Comma-separated list of indicators to filter by /// Minimum duration in days /// Maximum duration in days /// Name contains filter /// An ActionResult indicating the number of backtests deleted. [HttpDelete("ByFilters")] public async Task DeleteBacktestsByFilters( [FromQuery] double? scoreMin = null, [FromQuery] double? scoreMax = null, [FromQuery] int? winrateMin = null, [FromQuery] int? winrateMax = null, [FromQuery] decimal? maxDrawdownMax = null, [FromQuery] string? tickers = null, [FromQuery] string? indicators = null, [FromQuery] double? durationMinDays = null, [FromQuery] double? durationMaxDays = null, [FromQuery] string? name = null, [FromQuery] TradingType? tradingType = null) { var user = await GetUser(); // Validate score and winrate ranges [0,100] if (scoreMin.HasValue && (scoreMin < 0 || scoreMin > 100)) { return BadRequest("scoreMin must be between 0 and 100"); } if (scoreMax.HasValue && (scoreMax < 0 || scoreMax > 100)) { return BadRequest("scoreMax must be between 0 and 100"); } if (winrateMin.HasValue && (winrateMin < 0 || winrateMin > 100)) { return BadRequest("winrateMin must be between 0 and 100"); } if (winrateMax.HasValue && (winrateMax < 0 || winrateMax > 100)) { return BadRequest("winrateMax must be between 0 and 100"); } if (scoreMin.HasValue && scoreMax.HasValue && scoreMin > scoreMax) { return BadRequest("scoreMin must be less than or equal to scoreMax"); } if (winrateMin.HasValue && winrateMax.HasValue && winrateMin > winrateMax) { return BadRequest("winrateMin must be less than or equal to winrateMax"); } if (maxDrawdownMax.HasValue && maxDrawdownMax < 0) { return BadRequest("maxDrawdownMax must be greater than or equal to 0"); } // Parse multi-selects if provided (comma-separated) var tickerList = string.IsNullOrWhiteSpace(tickers) ? Array.Empty() : tickers.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var indicatorList = string.IsNullOrWhiteSpace(indicators) ? Array.Empty() : indicators.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var filter = new BacktestsFilter { NameContains = string.IsNullOrWhiteSpace(name) ? null : name.Trim(), ScoreMin = scoreMin, ScoreMax = scoreMax, WinrateMin = winrateMin, WinrateMax = winrateMax, MaxDrawdownMax = maxDrawdownMax, Tickers = tickerList, Indicators = indicatorList, DurationMin = durationMinDays.HasValue ? TimeSpan.FromDays(durationMinDays.Value) : (TimeSpan?)null, DurationMax = durationMaxDays.HasValue ? TimeSpan.FromDays(durationMaxDays.Value) : (TimeSpan?)null, TradingType = tradingType }; try { var deletedCount = await _backtester.DeleteBacktestsByFiltersAsync(user, filter); return Ok(new { DeletedCount = deletedCount }); } catch (Exception ex) { return StatusCode(500, $"Error deleting backtests: {ex.Message}"); } } /// /// Retrieves all backtests for a specific genetic request ID. /// This endpoint is used to view the results of a genetic algorithm optimization. /// /// The request ID to filter backtests by. /// A list of backtests associated with the specified request ID. [HttpGet] [Route("ByRequestId/{requestId}")] public async Task>> GetBacktestsByRequestId(string requestId) { if (string.IsNullOrEmpty(requestId)) { return BadRequest("Request ID is required"); } if (!Guid.TryParse(requestId, out var requestGuid)) { return BadRequest("Invalid request ID format. Must be a valid GUID."); } var backtests = await _backtester.GetBacktestsByRequestIdAsync(requestGuid); return Ok(backtests.Select(b => LightBacktestResponseMapper.MapFromDomain(b))); } /// /// Retrieves paginated backtests for a specific genetic request ID. /// This endpoint is used to view the results of a genetic algorithm optimization with pagination support. /// /// The request ID to filter backtests by. /// Page number (defaults to 1) /// Number of items per page (defaults to 50, max 100) /// Field to sort by (defaults to "score") /// Sort order - "asc" or "desc" (defaults to "desc") /// A paginated list of backtests associated with the specified request ID. [HttpGet] [Route("ByRequestId/{requestId}/Paginated")] public async Task> GetBacktestsByRequestIdPaginated( string requestId, int page = 1, int pageSize = 50, string sortBy = "score", string sortOrder = "desc") { if (string.IsNullOrEmpty(requestId)) { return BadRequest("Request ID is required"); } if (!Guid.TryParse(requestId, out var requestGuid)) { return BadRequest("Invalid request ID format. Must be a valid GUID."); } if (page < 1) { return BadRequest("Page must be greater than 0"); } if (pageSize < 1 || pageSize > 100) { return BadRequest("Page size must be between 1 and 100"); } if (sortOrder != "asc" && sortOrder != "desc") { return BadRequest("Sort order must be 'asc' or 'desc'"); } var (backtests, totalCount) = await _backtester.GetBacktestsByRequestIdPaginatedAsync(requestGuid, page, pageSize, sortBy, sortOrder); var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); var response = new PaginatedBacktestsResponse { Backtests = backtests.Select(b => new LightBacktestResponse { Id = b.Id, Config = b.Config, FinalPnl = b.FinalPnl, WinRate = b.WinRate, GrowthPercentage = b.GrowthPercentage, HodlPercentage = b.HodlPercentage, StartDate = b.StartDate, EndDate = b.EndDate, MaxDrawdown = b.MaxDrawdown, Fees = b.Fees, SharpeRatio = b.SharpeRatio, Score = b.Score, ScoreMessage = b.ScoreMessage, InitialBalance = b.InitialBalance, NetPnl = b.NetPnl, PositionCount = b.PositionCount, TradingType = b.Config.TradingType }), TotalCount = totalCount, CurrentPage = page, PageSize = pageSize, TotalPages = totalPages, HasNextPage = page < totalPages, HasPreviousPage = page > 1 }; return Ok(response); } /// /// Retrieves paginated backtests for the authenticated user. /// /// Page number (defaults to 1) /// Number of items per page (defaults to 50, max 100) /// Field to sort by (defaults to "score") /// Sort order - "asc" or "desc" (defaults to "desc") /// A paginated list of backtests for the user. [HttpGet] [Route("Paginated")] public async Task> GetBacktestsPaginated( int page = 1, int pageSize = 50, BacktestSortableColumn sortBy = BacktestSortableColumn.Score, string sortOrder = "desc", [FromQuery] double? scoreMin = null, [FromQuery] double? scoreMax = null, [FromQuery] int? winrateMin = null, [FromQuery] int? winrateMax = null, [FromQuery] decimal? maxDrawdownMax = null, [FromQuery] string? tickers = null, [FromQuery] string? indicators = null, [FromQuery] double? durationMinDays = null, [FromQuery] double? durationMaxDays = null, [FromQuery] string? name = null, [FromQuery] TradingType? tradingType = null) { var user = await GetUser(); if (page < 1) { return BadRequest("Page must be greater than 0"); } if (pageSize < 1 || pageSize > 100) { return BadRequest("Page size must be between 1 and 100"); } if (sortOrder != "asc" && sortOrder != "desc") { return BadRequest("Sort order must be 'asc' or 'desc'"); } // Validate score and winrate ranges [0,100] if (scoreMin.HasValue && (scoreMin < 0 || scoreMin > 100)) { return BadRequest("scoreMin must be between 0 and 100"); } if (scoreMax.HasValue && (scoreMax < 0 || scoreMax > 100)) { return BadRequest("scoreMax must be between 0 and 100"); } if (winrateMin.HasValue && (winrateMin < 0 || winrateMin > 100)) { return BadRequest("winrateMin must be between 0 and 100"); } if (winrateMax.HasValue && (winrateMax < 0 || winrateMax > 100)) { return BadRequest("winrateMax must be between 0 and 100"); } if (scoreMin.HasValue && scoreMax.HasValue && scoreMin > scoreMax) { return BadRequest("scoreMin must be less than or equal to scoreMax"); } if (winrateMin.HasValue && winrateMax.HasValue && winrateMin > winrateMax) { return BadRequest("winrateMin must be less than or equal to winrateMax"); } if (maxDrawdownMax.HasValue && maxDrawdownMax < 0) { return BadRequest("maxDrawdownMax must be greater than or equal to 0"); } // Parse multi-selects if provided (comma-separated). Currently unused until repository wiring. var tickerList = string.IsNullOrWhiteSpace(tickers) ? Array.Empty() : tickers.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var indicatorList = string.IsNullOrWhiteSpace(indicators) ? Array.Empty() : indicators.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var filter = new BacktestsFilter { NameContains = string.IsNullOrWhiteSpace(name) ? null : name.Trim(), ScoreMin = scoreMin, ScoreMax = scoreMax, WinrateMin = winrateMin, WinrateMax = winrateMax, MaxDrawdownMax = maxDrawdownMax, Tickers = tickerList, Indicators = indicatorList, DurationMin = durationMinDays.HasValue ? TimeSpan.FromDays(durationMinDays.Value) : (TimeSpan?)null, DurationMax = durationMaxDays.HasValue ? TimeSpan.FromDays(durationMaxDays.Value) : (TimeSpan?)null, TradingType = tradingType }; var (backtests, totalCount) = await _backtester.GetBacktestsByUserPaginatedAsync( user, page, pageSize, sortBy, sortOrder, filter); var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); var response = new PaginatedBacktestsResponse { Backtests = backtests.Select(b => new LightBacktestResponse { Id = b.Id, Config = b.Config, FinalPnl = b.FinalPnl, WinRate = b.WinRate, GrowthPercentage = b.GrowthPercentage, HodlPercentage = b.HodlPercentage, StartDate = b.StartDate, EndDate = b.EndDate, MaxDrawdown = b.MaxDrawdown, Fees = b.Fees, SharpeRatio = b.SharpeRatio, Score = b.Score, ScoreMessage = b.ScoreMessage, InitialBalance = b.InitialBalance, NetPnl = b.NetPnl, PositionCount = b.PositionCount, TradingType = b.Config.TradingType }), TotalCount = totalCount, CurrentPage = page, PageSize = pageSize, TotalPages = totalPages, HasNextPage = page < totalPages, HasPreviousPage = page > 1 }; return Ok(response); } /// /// Runs a backtest with the specified configuration. /// Returns a lightweight backtest result for efficient processing. /// Use the returned ID to retrieve the full backtest data from the database. /// /// The backtest request containing configuration and parameters. /// The lightweight result of the backtest with essential data. [HttpPost] [Route("Run")] public async Task> Run([FromBody] RunBacktestRequest request) { if (request?.Config == null) { return BadRequest("Backtest configuration is required"); } if (string.IsNullOrEmpty(request.Config.AccountName)) { return BadRequest("Account name is required"); } if (string.IsNullOrEmpty(request.Config.ScenarioName) && request.Config.Scenario == null) { return BadRequest("Either scenario name or scenario object is required"); } if (string.IsNullOrEmpty(request.Config.MoneyManagementName) && request.Config.MoneyManagement == null) { return BadRequest("Either money management name or money management object is required"); } try { LightBacktest backtestResult = null; var account = await _accountService.GetAccount(request.Config.AccountName, true, false); var user = await GetUser(); // Get money management MoneyManagement moneyManagement; if (!string.IsNullOrEmpty(request.Config.MoneyManagementName)) { moneyManagement = await _moneyManagementService.GetMoneyMangement(user, request.Config.MoneyManagementName); if (moneyManagement == null) return BadRequest("Money management not found"); } else { moneyManagement = Map(request.Config.MoneyManagement); moneyManagement?.FormatPercentage(); } // Handle scenario - either from ScenarioRequest or ScenarioName Scenario scenario = null; if (request.Config.Scenario != null) { // Convert ScenarioRequest to Scenario domain object scenario = new Scenario(request.Config.Scenario.Name, request.Config.Scenario.LookbackPeriod) { User = user }; // Convert IndicatorRequest objects to Indicator domain objects foreach (var indicatorRequest in request.Config.Scenario.Indicators) { var indicator = new IndicatorBase(indicatorRequest.Name, indicatorRequest.Type) { SignalType = indicatorRequest.SignalType, MinimumHistory = indicatorRequest.MinimumHistory, Period = indicatorRequest.Period, FastPeriods = indicatorRequest.FastPeriods, SlowPeriods = indicatorRequest.SlowPeriods, SignalPeriods = indicatorRequest.SignalPeriods, Multiplier = indicatorRequest.Multiplier, SmoothPeriods = indicatorRequest.SmoothPeriods, StochPeriods = indicatorRequest.StochPeriods, CyclePeriods = indicatorRequest.CyclePeriods, User = user }; scenario.AddIndicator(indicator); } } // Convert TradingBotConfigRequest to TradingBotConfig for backtest var backtestConfig = new TradingBotConfig { AccountName = request.Config.AccountName, MoneyManagement = moneyManagement, Ticker = request.Config.Ticker, ScenarioName = request.Config.ScenarioName, Scenario = scenario != null ? LightScenario.FromScenario(scenario) : null, // Convert to LightScenario for Orleans Timeframe = request.Config.Timeframe, IsForWatchingOnly = request.Config.IsForWatchingOnly, BotTradingBalance = request.Config.BotTradingBalance, TradingType = TradingType.BacktestFutures, CooldownPeriod = request.Config.CooldownPeriod ?? 1, MaxLossStreak = request.Config.MaxLossStreak, MaxPositionTimeHours = request.Config.MaxPositionTimeHours, FlipOnlyWhenInProfit = request.Config.FlipOnlyWhenInProfit, FlipPosition = request.Config.FlipPosition, // Computed based on BotType Name = request.Config.Name ?? $"Backtest-{request.Config.ScenarioName ?? request.Config.Scenario?.Name ?? "Custom"}-{DateTime.UtcNow:yyyyMMdd-HHmmss}", CloseEarlyWhenProfitable = request.Config.CloseEarlyWhenProfitable, UseSynthApi = request.Config.UseSynthApi, UseForPositionSizing = request.Config.UseForPositionSizing, UseForSignalFiltering = request.Config.UseForSignalFiltering, UseForDynamicStopLoss = request.Config.UseForDynamicStopLoss }; backtestResult = await _backtester.RunTradingBotBacktest( backtestConfig, request.StartDate, request.EndDate, user, request.Save, request.WithCandles, null); // No requestId for regular backtests // Note: Notification is handled within the Orleans grain for LightBacktest // The full Backtest data can be retrieved from the database using the ID if needed return Ok(backtestResult); } catch (Exception ex) { return StatusCode(500, $"Error running backtest: {ex.Message}"); } } /// /// Creates a bundle backtest request with the specified configurations. /// This endpoint creates a request that will be processed by a background worker. /// /// The bundle backtest request with variant lists. /// The bundle backtest request with ID for tracking progress. [HttpPost] [Route("BacktestBundle")] public async Task> RunBundle([FromBody] RunBundleBacktestRequest request) { if (request?.UniversalConfig == null) { return BadRequest("Universal configuration is required"); } if (request.UniversalConfig.Scenario == null) { return BadRequest("Scenario object must be provided in universal configuration for bundle backtest"); } if (request.DateTimeRanges == null || !request.DateTimeRanges.Any()) { return BadRequest("At least one DateTime range is required"); } if (request.MoneyManagementVariants == null || !request.MoneyManagementVariants.Any()) { return BadRequest("At least one money management variant is required"); } if (request.TickerVariants == null || !request.TickerVariants.Any()) { return BadRequest("At least one ticker variant is required"); } if (string.IsNullOrWhiteSpace(request.Name)) { return BadRequest("Bundle name is required"); } // Calculate total number of backtests var totalBacktests = request.DateTimeRanges.Count * request.MoneyManagementVariants.Count * request.TickerVariants.Count; try { var user = await GetUser(); // Check if trading type is futures and verify the user has permission via feature flag if (request.UniversalConfig.TradingType == TradingType.Futures || request.UniversalConfig.TradingType == TradingType.BacktestFutures) { var isTradingFutureEnabled = await _flagsmithService.IsFeatureEnabledAsync(user.Name, "trading_future"); if (!isTradingFutureEnabled) { _logger.LogWarning("User {UserName} attempted to create futures bundle backtest but does not have the trading_future feature flag enabled", user.Name); return Forbid("Futures trading is not enabled for your account. Please contact support to enable this feature."); } } if (string.IsNullOrEmpty(request.UniversalConfig.ScenarioName) && request.UniversalConfig.Scenario == null) { return BadRequest("Either scenario name or scenario object is required in universal configuration"); } // Validate all money management variants foreach (var mmVariant in request.MoneyManagementVariants) { if (mmVariant.MoneyManagement == null) { return BadRequest("Each money management variant must have a money management object"); } } // Normalize SignalType for all indicators based on their IndicatorType // This ensures the correct SignalType is saved regardless of what the frontend sent if (request.UniversalConfig.Scenario?.Indicators != null) { foreach (var indicator in request.UniversalConfig.Scenario.Indicators) { indicator.SignalType = ScenarioHelpers.GetSignalType(indicator.Type); } } // Create the bundle backtest request var bundleRequest = new BundleBacktestRequest { User = user, UniversalConfigJson = JsonSerializer.Serialize(request.UniversalConfig), DateTimeRangesJson = JsonSerializer.Serialize(request.DateTimeRanges), MoneyManagementVariantsJson = JsonSerializer.Serialize(request.MoneyManagementVariants), TickerVariantsJson = JsonSerializer.Serialize(request.TickerVariants), TotalBacktests = totalBacktests, CompletedBacktests = 0, FailedBacktests = 0, Status = request.SaveAsTemplate ? BundleBacktestRequestStatus.Saved : BundleBacktestRequestStatus.Pending, Name = request.Name }; // Save bundle request immediately (fast operation) await _backtester.SaveBundleBacktestRequestAsync(user, bundleRequest); // If not saving as template, create jobs in background task if (!request.SaveAsTemplate) { // Capture values for background task var bundleRequestId = bundleRequest.RequestId; var userId = user.Id; // Fire off background task to create jobs - don't await, return immediately _ = Task.Run(async () => { try { using var scope = _serviceScopeFactory.CreateScope(); var backtester = scope.ServiceProvider.GetRequiredService(); var userService = scope.ServiceProvider.GetRequiredService(); // Reload user and bundle request to ensure we have the latest data var reloadedUser = await userService.GetUserByIdAsync(userId); if (reloadedUser == null) { _logger.LogWarning( "User {UserId} not found when creating jobs for bundle request {BundleRequestId} in background", userId, bundleRequestId); return; } var savedBundleRequest = backtester.GetBundleBacktestRequestByIdForUser(reloadedUser, bundleRequestId); if (savedBundleRequest != null) { await backtester.CreateJobsForBundleRequestAsync(savedBundleRequest); _logger.LogInformation( "Successfully created jobs for bundle request {BundleRequestId} in background", bundleRequestId); } else { _logger.LogWarning( "Bundle request {BundleRequestId} not found when creating jobs in background", bundleRequestId); } } catch (Exception ex) { _logger.LogError(ex, "Error creating jobs for bundle request {BundleRequestId} in background task", bundleRequestId); } }); } return Ok(bundleRequest); } catch (Exception ex) { return StatusCode(500, $"Error creating bundle backtest request: {ex.Message}"); } } /// /// Retrieves all bundle backtest requests for the authenticated user. /// /// A list of bundle backtest requests with their current status. [HttpGet] [Route("Bundle")] public async Task>> GetBundleBacktestRequests() { var user = await GetUser(); var bundleRequests = _backtester.GetBundleBacktestRequestsByUser(user); return Ok(bundleRequests); } /// /// Retrieves a specific bundle backtest request by ID for the authenticated user. /// /// The ID of the bundle backtest request to retrieve. /// The requested bundle backtest request with current status and results. [HttpGet] [Route("Bundle/{id}")] public async Task> GetBundleBacktestRequest(string id) { if (!Guid.TryParse(id, out var requestId)) { return BadRequest("Invalid bundle request ID format. Must be a valid GUID."); } var user = await GetUser(); var bundleRequest = _backtester.GetBundleBacktestRequestByIdForUser(user, requestId); if (bundleRequest == null) { return NotFound($"Bundle backtest request with ID {id} not found or doesn't belong to the current user."); } var viewModel = BundleBacktestRequestViewModel.FromDomain(bundleRequest); return Ok(viewModel); } /// /// Deletes a specific bundle backtest request by ID for the authenticated user. /// Also deletes all related backtests associated with this bundle request. /// /// The ID of the bundle backtest request to delete. /// An ActionResult indicating the outcome of the operation. [HttpDelete] [Route("Bundle/{id}")] public async Task DeleteBundleBacktestRequest(string id) { if (!Guid.TryParse(id, out var requestId)) { return BadRequest("Invalid bundle request ID format. Must be a valid GUID."); } var user = await GetUser(); // First, delete the bundle request _backtester.DeleteBundleBacktestRequestByIdForUser(user, requestId); // Then, delete all related backtests var backtestsDeleted = await _backtester.DeleteBacktestsByRequestIdAsync(requestId); return Ok(new { BundleRequestDeleted = true, RelatedBacktestsDeleted = backtestsDeleted }); } /// /// Subscribes the client to real-time updates for a bundle backtest request via SignalR. /// The client will receive LightBacktestResponse objects as new backtests are generated. /// /// The bundle request ID to subscribe to. [HttpPost] [Route("Bundle/Subscribe")] // POST /Backtest/Bundle/Subscribe public async Task SubscribeToBundle([FromQuery] string requestId) { if (string.IsNullOrWhiteSpace(requestId)) return BadRequest("RequestId is required"); // Get the connection ID from the SignalR context (assume it's passed via header or query) var connectionId = HttpContext.Request.Headers["X-SignalR-ConnectionId"].ToString(); if (string.IsNullOrEmpty(connectionId)) return BadRequest("SignalR connection ID is required in X-SignalR-ConnectionId header"); // Add the connection to the SignalR group for this bundle await _hubContext.Groups.AddToGroupAsync(connectionId, $"bundle-{requestId}"); return Ok(new { Subscribed = true, RequestId = requestId }); } /// /// Unsubscribes the client from real-time updates for a bundle backtest request via SignalR. /// /// The bundle request ID to unsubscribe from. [HttpPost] [Route("Bundle/Unsubscribe")] // POST /Backtest/Bundle/Unsubscribe public async Task UnsubscribeFromBundle([FromQuery] string requestId) { if (string.IsNullOrWhiteSpace(requestId)) return BadRequest("RequestId is required"); var connectionId = HttpContext.Request.Headers["X-SignalR-ConnectionId"].ToString(); if (string.IsNullOrEmpty(connectionId)) return BadRequest("SignalR connection ID is required in X-SignalR-ConnectionId header"); await _hubContext.Groups.RemoveFromGroupAsync(connectionId, $"bundle-{requestId}"); return Ok(new { Unsubscribed = true, RequestId = requestId }); } /// /// Gets the status of a bundle backtest request, aggregating all job statuses. /// /// The bundle request ID /// The bundle status with aggregated job statistics [HttpGet] [Route("Bundle/{bundleRequestId}/Status")] public async Task> GetBundleStatus(string bundleRequestId) { if (!Guid.TryParse(bundleRequestId, out var bundleGuid)) { return BadRequest("Invalid bundle request ID format. Must be a valid GUID."); } var user = await GetUser(); var bundleRequest = _backtester.GetBundleBacktestRequestByIdForUser(user, bundleGuid); if (bundleRequest == null) { return NotFound($"Bundle backtest request with ID {bundleRequestId} not found."); } // Get all jobs for this bundle using var serviceScope = _serviceScopeFactory.CreateScope(); var jobRepository = serviceScope.ServiceProvider.GetRequiredService(); var jobs = await jobRepository.GetByBundleRequestIdAsync(bundleGuid); var response = new BundleBacktestStatusResponse { BundleRequestId = bundleGuid, Status = bundleRequest.Status.ToString(), TotalJobs = jobs.Count(), CompletedJobs = jobs.Count(j => j.Status == JobStatus.Completed), FailedJobs = jobs.Count(j => j.Status == JobStatus.Failed), RunningJobs = jobs.Count(j => j.Status == JobStatus.Running), PendingJobs = jobs.Count(j => j.Status == JobStatus.Pending), ProgressPercentage = bundleRequest.ProgressPercentage, CreatedAt = bundleRequest.CreatedAt, CompletedAt = bundleRequest.CompletedAt, ErrorMessage = bundleRequest.ErrorMessage }; return Ok(response); } /// /// Runs a genetic algorithm optimization with the specified configuration. /// This endpoint saves the genetic request to the database and returns the request ID. /// The actual genetic algorithm execution will be handled by a background service. /// /// The genetic algorithm request containing configuration and parameters. /// The genetic request with ID for tracking progress. [HttpPost] [Route("Genetic")] public async Task> RunGenetic([FromBody] RunGeneticRequest request) { if (request == null) { return BadRequest("Genetic request is required"); } if (request.EligibleIndicators == null || !request.EligibleIndicators.Any()) { return BadRequest("At least one eligible indicator is required"); } if (request.StartDate >= request.EndDate) { return BadRequest("Start date must be before end date"); } if (request.PopulationSize <= 0 || request.Generations <= 0) { return BadRequest("Population size and generations must be greater than 0"); } if (request.MutationRate < 0 || request.MutationRate > 1) { return BadRequest("Mutation rate must be between 0 and 1"); } try { var user = await GetUser(); // Create genetic request using the GeneticService directly var geneticRequest = await _geneticService.CreateGeneticRequestAsync( user, request.Ticker, request.Timeframe, request.StartDate, request.EndDate, request.Balance, request.PopulationSize, request.Generations, request.MutationRate, request.SelectionMethod, request.CrossoverMethod, request.MutationMethod, request.ElitismPercentage, request.MaxTakeProfit, request.EligibleIndicators); return Ok(geneticRequest); } catch (Exception ex) { return StatusCode(500, $"Error creating genetic request: {ex.Message}"); } } /// /// Retrieves all genetic requests for the authenticated user. /// /// A list of genetic requests with their current status. [HttpGet] [Route("Genetic")] public async Task>> GetGeneticRequests() { var user = await GetUser(); var geneticRequests = _geneticService.GetGeneticRequestsByUser(user); return Ok(geneticRequests); } /// /// Retrieves a specific genetic request by ID for the authenticated user. /// /// The ID of the genetic request to retrieve. /// The requested genetic request with current status and results. [HttpGet] [Route("Genetic/{id}")] public async Task> GetGeneticRequest(string id) { var user = await GetUser(); var geneticRequest = _geneticService.GetGeneticRequestByIdForUser(user, id); if (geneticRequest == null) { return NotFound($"Genetic request with ID {id} not found or doesn't belong to the current user."); } return Ok(geneticRequest); } /// /// Deletes a specific genetic request by ID for the authenticated user. /// Also deletes all related backtests associated with this genetic request. /// /// The ID of the genetic request to delete. /// An ActionResult indicating the outcome of the operation. [HttpDelete] [Route("Genetic/{id}")] public async Task DeleteGeneticRequest(string id) { var user = await GetUser(); // First, delete the genetic request _geneticService.DeleteGeneticRequestByIdForUser(user, id); // Then, delete all related backtests var backtestsDeleted = false; if (Guid.TryParse(id, out var requestGuid)) { backtestsDeleted = await _backtester.DeleteBacktestsByRequestIdAsync(requestGuid); } return Ok(new { GeneticRequestDeleted = true, RelatedBacktestsDeleted = backtestsDeleted }); } public MoneyManagement Map(MoneyManagementRequest moneyManagementRequest) { return new MoneyManagement { Name = moneyManagementRequest.Name, StopLoss = moneyManagementRequest.StopLoss, TakeProfit = moneyManagementRequest.TakeProfit, Leverage = moneyManagementRequest.Leverage, Timeframe = moneyManagementRequest.Timeframe }; } }