1035 lines
42 KiB
C#
1035 lines
42 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[ApiController]
|
|
[Authorize]
|
|
[Route("[controller]")]
|
|
[Produces("application/json")]
|
|
public class BacktestController : BaseController
|
|
{
|
|
private readonly IHubContext<BacktestHub> _hubContext;
|
|
private readonly IBacktester _backtester;
|
|
private readonly IAccountService _accountService;
|
|
private readonly IMoneyManagementService _moneyManagementService;
|
|
private readonly IGeneticService _geneticService;
|
|
private readonly IServiceScopeFactory _serviceScopeFactory;
|
|
private readonly ILogger<BacktestController> _logger;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="BacktestController"/> class.
|
|
/// </summary>
|
|
/// <param name="hubContext">The SignalR hub context for real-time communication.</param>
|
|
/// <param name="backtester">The service for backtesting strategies.</param>
|
|
/// <param name="scenarioService">The service for managing scenarios.</param>
|
|
/// <param name="accountService">The service for account management.</param>
|
|
/// <param name="moneyManagementService">The service for money management strategies.</param>
|
|
/// <param name="geneticService">The service for genetic algorithm operations.</param>
|
|
/// <param name="backtestRepository">The repository for backtest operations.</param>
|
|
public BacktestController(
|
|
IHubContext<BacktestHub> hubContext,
|
|
IBacktester backtester,
|
|
IAccountService accountService,
|
|
IMoneyManagementService moneyManagementService,
|
|
IGeneticService geneticService,
|
|
IUserService userService,
|
|
IServiceScopeFactory serviceScopeFactory,
|
|
ILogger<BacktestController> logger) : base(userService)
|
|
{
|
|
_hubContext = hubContext;
|
|
_backtester = backtester;
|
|
_accountService = accountService;
|
|
_moneyManagementService = moneyManagementService;
|
|
_geneticService = geneticService;
|
|
_serviceScopeFactory = serviceScopeFactory;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves all backtests for the authenticated user.
|
|
/// Each backtest includes the complete TradingBotConfig for easy bot deployment.
|
|
/// </summary>
|
|
/// <returns>A list of backtests with complete configurations.</returns>
|
|
[HttpGet]
|
|
public async Task<ActionResult<IEnumerable<Backtest>>> Backtests()
|
|
{
|
|
var user = await GetUser();
|
|
var backtests = await _backtester.GetBacktestsByUserAsync(user);
|
|
return Ok(backtests);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="id">The ID of the backtest to retrieve.</param>
|
|
/// <returns>The requested backtest with populated candle data and complete configuration.</returns>
|
|
[HttpGet("{id}")]
|
|
public async Task<ActionResult<Backtest>> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes a specific backtest by ID for the authenticated user.
|
|
/// </summary>
|
|
/// <param name="id">The ID of the backtest to delete.</param>
|
|
/// <returns>An ActionResult indicating the outcome of the operation.</returns>
|
|
[HttpDelete]
|
|
public async Task<ActionResult> DeleteBacktest(string id)
|
|
{
|
|
var user = await GetUser();
|
|
var result = await _backtester.DeleteBacktestByUserAsync(user, id);
|
|
return Ok(result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes multiple backtests by their IDs for the authenticated user.
|
|
/// </summary>
|
|
/// <param name="request">The request containing the array of backtest IDs to delete.</param>
|
|
/// <returns>An ActionResult indicating the outcome of the operation.</returns>
|
|
[HttpDelete("multiple")]
|
|
public async Task<ActionResult> DeleteBacktests([FromBody] DeleteBacktestsRequest request)
|
|
{
|
|
var user = await GetUser();
|
|
return Ok(await _backtester.DeleteBacktestsByIdsForUserAsync(user, request.BacktestIds));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes backtests based on filter criteria for the authenticated user.
|
|
/// Uses the same filter parameters as GetBacktestsPaginated.
|
|
/// </summary>
|
|
/// <param name="scoreMin">Minimum score filter (0-100)</param>
|
|
/// <param name="scoreMax">Maximum score filter (0-100)</param>
|
|
/// <param name="winrateMin">Minimum winrate filter (0-100)</param>
|
|
/// <param name="winrateMax">Maximum winrate filter (0-100)</param>
|
|
/// <param name="maxDrawdownMax">Maximum drawdown filter</param>
|
|
/// <param name="tickers">Comma-separated list of tickers to filter by</param>
|
|
/// <param name="indicators">Comma-separated list of indicators to filter by</param>
|
|
/// <param name="durationMinDays">Minimum duration in days</param>
|
|
/// <param name="durationMaxDays">Maximum duration in days</param>
|
|
/// <param name="name">Name contains filter</param>
|
|
/// <returns>An ActionResult indicating the number of backtests deleted.</returns>
|
|
[HttpDelete("ByFilters")]
|
|
public async Task<ActionResult> 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)
|
|
{
|
|
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<string>()
|
|
: tickers.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
var indicatorList = string.IsNullOrWhiteSpace(indicators)
|
|
? Array.Empty<string>()
|
|
: 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
|
|
};
|
|
|
|
try
|
|
{
|
|
var deletedCount = await _backtester.DeleteBacktestsByFiltersAsync(user, filter);
|
|
return Ok(new { DeletedCount = deletedCount });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return StatusCode(500, $"Error deleting backtests: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves all backtests for a specific genetic request ID.
|
|
/// This endpoint is used to view the results of a genetic algorithm optimization.
|
|
/// </summary>
|
|
/// <param name="requestId">The request ID to filter backtests by.</param>
|
|
/// <returns>A list of backtests associated with the specified request ID.</returns>
|
|
[HttpGet]
|
|
[Route("ByRequestId/{requestId}")]
|
|
public async Task<ActionResult<IEnumerable<Backtest>>> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="requestId">The request ID to filter backtests by.</param>
|
|
/// <param name="page">Page number (defaults to 1)</param>
|
|
/// <param name="pageSize">Number of items per page (defaults to 50, max 100)</param>
|
|
/// <param name="sortBy">Field to sort by (defaults to "score")</param>
|
|
/// <param name="sortOrder">Sort order - "asc" or "desc" (defaults to "desc")</param>
|
|
/// <returns>A paginated list of backtests associated with the specified request ID.</returns>
|
|
[HttpGet]
|
|
[Route("ByRequestId/{requestId}/Paginated")]
|
|
public async Task<ActionResult<PaginatedBacktestsResponse>> 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
|
|
}),
|
|
TotalCount = totalCount,
|
|
CurrentPage = page,
|
|
PageSize = pageSize,
|
|
TotalPages = totalPages,
|
|
HasNextPage = page < totalPages,
|
|
HasPreviousPage = page > 1
|
|
};
|
|
|
|
return Ok(response);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves paginated backtests for the authenticated user.
|
|
/// </summary>
|
|
/// <param name="page">Page number (defaults to 1)</param>
|
|
/// <param name="pageSize">Number of items per page (defaults to 50, max 100)</param>
|
|
/// <param name="sortBy">Field to sort by (defaults to "score")</param>
|
|
/// <param name="sortOrder">Sort order - "asc" or "desc" (defaults to "desc")</param>
|
|
/// <returns>A paginated list of backtests for the user.</returns>
|
|
[HttpGet]
|
|
[Route("Paginated")]
|
|
public async Task<ActionResult<PaginatedBacktestsResponse>> 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)
|
|
{
|
|
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<string>()
|
|
: tickers.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
var indicatorList = string.IsNullOrWhiteSpace(indicators)
|
|
? Array.Empty<string>()
|
|
: 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
|
|
};
|
|
|
|
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
|
|
}),
|
|
TotalCount = totalCount,
|
|
CurrentPage = page,
|
|
PageSize = pageSize,
|
|
TotalPages = totalPages,
|
|
HasNextPage = page < totalPages,
|
|
HasPreviousPage = page > 1
|
|
};
|
|
|
|
return Ok(response);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="request">The backtest request containing configuration and parameters.</param>
|
|
/// <returns>The lightweight result of the backtest with essential data.</returns>
|
|
[HttpPost]
|
|
[Route("Run")]
|
|
public async Task<ActionResult<LightBacktest>> 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.LoopbackPeriod)
|
|
{
|
|
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,
|
|
IsForBacktest = true,
|
|
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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a bundle backtest request with the specified configurations.
|
|
/// This endpoint creates a request that will be processed by a background worker.
|
|
/// </summary>
|
|
/// <param name="request">The bundle backtest request with variant lists.</param>
|
|
/// <returns>The bundle backtest request with ID for tracking progress.</returns>
|
|
[HttpPost]
|
|
[Route("BacktestBundle")]
|
|
public async Task<ActionResult<BundleBacktestRequest>> RunBundle([FromBody] RunBundleBacktestRequest request)
|
|
{
|
|
if (request?.UniversalConfig == null)
|
|
{
|
|
return BadRequest("Universal configuration is required");
|
|
}
|
|
|
|
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();
|
|
|
|
|
|
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");
|
|
}
|
|
}
|
|
|
|
// 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<IBacktester>();
|
|
var userService = scope.ServiceProvider.GetRequiredService<IUserService>();
|
|
|
|
// 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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves all bundle backtest requests for the authenticated user.
|
|
/// </summary>
|
|
/// <returns>A list of bundle backtest requests with their current status.</returns>
|
|
[HttpGet]
|
|
[Route("Bundle")]
|
|
public async Task<ActionResult<IEnumerable<BundleBacktestRequest>>> GetBundleBacktestRequests()
|
|
{
|
|
var user = await GetUser();
|
|
var bundleRequests = _backtester.GetBundleBacktestRequestsByUser(user);
|
|
return Ok(bundleRequests);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves a specific bundle backtest request by ID for the authenticated user.
|
|
/// </summary>
|
|
/// <param name="id">The ID of the bundle backtest request to retrieve.</param>
|
|
/// <returns>The requested bundle backtest request with current status and results.</returns>
|
|
[HttpGet]
|
|
[Route("Bundle/{id}")]
|
|
public async Task<ActionResult<BundleBacktestRequestViewModel>> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes a specific bundle backtest request by ID for the authenticated user.
|
|
/// Also deletes all related backtests associated with this bundle request.
|
|
/// </summary>
|
|
/// <param name="id">The ID of the bundle backtest request to delete.</param>
|
|
/// <returns>An ActionResult indicating the outcome of the operation.</returns>
|
|
[HttpDelete]
|
|
[Route("Bundle/{id}")]
|
|
public async Task<ActionResult> 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
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="requestId">The bundle request ID to subscribe to.</param>
|
|
[HttpPost]
|
|
[Route("Bundle/Subscribe")] // POST /Backtest/Bundle/Subscribe
|
|
public async Task<IActionResult> 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 });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Unsubscribes the client from real-time updates for a bundle backtest request via SignalR.
|
|
/// </summary>
|
|
/// <param name="requestId">The bundle request ID to unsubscribe from.</param>
|
|
[HttpPost]
|
|
[Route("Bundle/Unsubscribe")] // POST /Backtest/Bundle/Unsubscribe
|
|
public async Task<IActionResult> 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 });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the status of a bundle backtest request, aggregating all job statuses.
|
|
/// </summary>
|
|
/// <param name="bundleRequestId">The bundle request ID</param>
|
|
/// <returns>The bundle status with aggregated job statistics</returns>
|
|
[HttpGet]
|
|
[Route("Bundle/{bundleRequestId}/Status")]
|
|
public async Task<ActionResult<BundleBacktestStatusResponse>> 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<IJobRepository>();
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="request">The genetic algorithm request containing configuration and parameters.</param>
|
|
/// <returns>The genetic request with ID for tracking progress.</returns>
|
|
[HttpPost]
|
|
[Route("Genetic")]
|
|
public async Task<ActionResult<GeneticRequest>> 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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves all genetic requests for the authenticated user.
|
|
/// </summary>
|
|
/// <returns>A list of genetic requests with their current status.</returns>
|
|
[HttpGet]
|
|
[Route("Genetic")]
|
|
public async Task<ActionResult<IEnumerable<GeneticRequest>>> GetGeneticRequests()
|
|
{
|
|
var user = await GetUser();
|
|
var geneticRequests = _geneticService.GetGeneticRequestsByUser(user);
|
|
return Ok(geneticRequests);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves a specific genetic request by ID for the authenticated user.
|
|
/// </summary>
|
|
/// <param name="id">The ID of the genetic request to retrieve.</param>
|
|
/// <returns>The requested genetic request with current status and results.</returns>
|
|
[HttpGet]
|
|
[Route("Genetic/{id}")]
|
|
public async Task<ActionResult<GeneticRequest>> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes a specific genetic request by ID for the authenticated user.
|
|
/// Also deletes all related backtests associated with this genetic request.
|
|
/// </summary>
|
|
/// <param name="id">The ID of the genetic request to delete.</param>
|
|
/// <returns>An ActionResult indicating the outcome of the operation.</returns>
|
|
[HttpDelete]
|
|
[Route("Genetic/{id}")]
|
|
public async Task<ActionResult> 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
|
|
};
|
|
}
|
|
} |