diff --git a/src/Managing.Api/Controllers/BacktestController.cs b/src/Managing.Api/Controllers/BacktestController.cs index 830cebcb..bf797623 100644 --- a/src/Managing.Api/Controllers/BacktestController.cs +++ b/src/Managing.Api/Controllers/BacktestController.cs @@ -118,6 +118,100 @@ public class BacktestController : BaseController 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) + { + 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 + }; + + 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. diff --git a/src/Managing.Application.Abstractions/Repositories/IBacktestRepository.cs b/src/Managing.Application.Abstractions/Repositories/IBacktestRepository.cs index eda2bb96..a3b0191f 100644 --- a/src/Managing.Application.Abstractions/Repositories/IBacktestRepository.cs +++ b/src/Managing.Application.Abstractions/Repositories/IBacktestRepository.cs @@ -41,6 +41,7 @@ public interface IBacktestRepository Task DeleteBacktestsByIdsForUserAsync(User user, IEnumerable ids); void DeleteAllBacktestsForUser(User user); Task DeleteBacktestsByRequestIdAsync(Guid requestId); + Task DeleteBacktestsByFiltersAsync(User user, BacktestsFilter filter); // Bundle backtest methods void InsertBundleBacktestRequestForUser(User user, BundleBacktestRequest bundleRequest); diff --git a/src/Managing.Application.Abstractions/Services/IBacktester.cs b/src/Managing.Application.Abstractions/Services/IBacktester.cs index f1b4d085..48d84e20 100644 --- a/src/Managing.Application.Abstractions/Services/IBacktester.cs +++ b/src/Managing.Application.Abstractions/Services/IBacktester.cs @@ -82,6 +82,7 @@ namespace Managing.Application.Abstractions.Services string sortOrder = "desc", BacktestsFilter? filter = null); Task DeleteBacktestsByRequestIdAsync(Guid requestId); + Task DeleteBacktestsByFiltersAsync(User user, BacktestsFilter filter); // Bundle backtest methods void InsertBundleBacktestRequestForUser(User user, BundleBacktestRequest bundleRequest); diff --git a/src/Managing.Application/Backtests/Backtester.cs b/src/Managing.Application/Backtests/Backtester.cs index 8d1c2faa..411cb0d3 100644 --- a/src/Managing.Application/Backtests/Backtester.cs +++ b/src/Managing.Application/Backtests/Backtester.cs @@ -416,6 +416,19 @@ namespace Managing.Application.Backtests } } + public async Task DeleteBacktestsByFiltersAsync(User user, BacktestsFilter filter) + { + try + { + return await _backtestRepository.DeleteBacktestsByFiltersAsync(user, filter); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete backtests by filters for user {UserId}", user.Id); + throw; + } + } + public (IEnumerable Backtests, int TotalCount) GetBacktestsByUserPaginated( User user, int page, diff --git a/src/Managing.Infrastructure.Database/PostgreSql/PostgreSqlBacktestRepository.cs b/src/Managing.Infrastructure.Database/PostgreSql/PostgreSqlBacktestRepository.cs index fd13d8d1..e35828c2 100644 --- a/src/Managing.Infrastructure.Database/PostgreSql/PostgreSqlBacktestRepository.cs +++ b/src/Managing.Infrastructure.Database/PostgreSql/PostgreSqlBacktestRepository.cs @@ -379,6 +379,63 @@ public class PostgreSqlBacktestRepository : IBacktestRepository } } + public async Task DeleteBacktestsByFiltersAsync(User user, BacktestsFilter filter) + { + var baseQuery = _context.Backtests + .AsTracking() + .Where(b => b.UserId == user.Id); + + if (filter != null) + { + if (!string.IsNullOrWhiteSpace(filter.NameContains)) + { + var nameLike = $"%{filter.NameContains.Trim()}%"; + baseQuery = baseQuery.Where(b => EF.Functions.ILike(b.Name, nameLike)); + } + if (filter.ScoreMin.HasValue) + baseQuery = baseQuery.Where(b => b.Score >= filter.ScoreMin.Value); + if (filter.ScoreMax.HasValue) + baseQuery = baseQuery.Where(b => b.Score <= filter.ScoreMax.Value); + if (filter.WinrateMin.HasValue) + baseQuery = baseQuery.Where(b => b.WinRate >= filter.WinrateMin.Value); + if (filter.WinrateMax.HasValue) + baseQuery = baseQuery.Where(b => b.WinRate <= filter.WinrateMax.Value); + if (filter.MaxDrawdownMax.HasValue) + baseQuery = baseQuery.Where(b => b.MaxDrawdown <= filter.MaxDrawdownMax.Value); + + if (filter.Tickers != null && filter.Tickers.Any()) + { + var tickerArray = filter.Tickers.ToArray(); + baseQuery = baseQuery.Where(b => tickerArray.Contains(b.Ticker)); + } + + if (filter.Indicators != null && filter.Indicators.Any()) + { + foreach (var ind in filter.Indicators) + { + var token = "," + ind + ","; + baseQuery = baseQuery.Where(b => ("," + b.IndicatorsCsv + ",").Contains(token)); + } + } + + if (filter.DurationMin.HasValue) + baseQuery = baseQuery.Where(b => b.Duration >= filter.DurationMin.Value); + if (filter.DurationMax.HasValue) + baseQuery = baseQuery.Where(b => b.Duration <= filter.DurationMax.Value); + } + + var entities = await baseQuery.ToListAsync().ConfigureAwait(false); + var count = entities.Count; + + if (count > 0) + { + _context.Backtests.RemoveRange(entities); + await _context.SaveChangesAsync().ConfigureAwait(false); + } + + return count; + } + public (IEnumerable Backtests, int TotalCount) GetBacktestsByUserPaginated( User user, int page, diff --git a/src/Managing.WebApp/src/components/organism/Backtest/backtestTable.tsx b/src/Managing.WebApp/src/components/organism/Backtest/backtestTable.tsx index 8f88a691..aa8d8e3c 100644 --- a/src/Managing.WebApp/src/components/organism/Backtest/backtestTable.tsx +++ b/src/Managing.WebApp/src/components/organism/Backtest/backtestTable.tsx @@ -194,6 +194,10 @@ const BacktestTable: React.FC = ({list, isFetching, onSortCh const [durationMinDays, setDurationMinDays] = useState(null) const [durationMaxDays, setDurationMaxDays] = useState(null) + // Delete confirmation state + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + const applyFilters = () => { if (!onFiltersChange) return onFiltersChange({ @@ -211,6 +215,62 @@ const BacktestTable: React.FC = ({list, isFetching, onSortCh setIsFilterOpen(false) } + const deleteFilteredBacktests = async () => { + if (!filters) return + + setIsDeleting(true) + try { + const backtestClient = new BacktestClient({}, apiUrl) + + const response = await backtestClient.backtest_DeleteBacktestsByFilters( + filters.scoreMin || undefined, + filters.scoreMax || undefined, + filters.winrateMin || undefined, + filters.winrateMax || undefined, + filters.maxDrawdownMax || undefined, + filters.tickers?.join(',') || undefined, + filters.indicators?.join(',') || undefined, + filters.durationMinDays || undefined, + filters.durationMaxDays || undefined, + filters.nameContains || undefined + ) + + // Parse the response to get the deleted count + const responseText = await response.data.text() + const result = JSON.parse(responseText) + + const successToast = new Toast(`Successfully deleted ${result.deletedCount} backtests`, false) + + // Refresh the data by calling the callback + if (onBacktestDeleted) { + onBacktestDeleted() + } + + setShowDeleteConfirm(false) + } catch (error) { + console.error('Error deleting filtered backtests:', error) + const errorToast = new Toast('Failed to delete backtests', false) + } finally { + setIsDeleting(false) + } + } + + const hasActiveFilters = () => { + if (!filters) return false + return !!( + filters.nameContains || + filters.scoreMin !== null || + filters.scoreMax !== null || + filters.winrateMin !== null || + filters.winrateMax !== null || + filters.maxDrawdownMax !== null || + filters.tickers?.length || + filters.indicators?.length || + filters.durationMinDays !== null || + filters.durationMaxDays !== null + ) + } + const clearDuration = () => { setDurationMinDays(null) setDurationMaxDays(null) @@ -569,8 +629,17 @@ const BacktestTable: React.FC = ({list, isFetching, onSortCh ) : ( <> {/* Filters toggle button */} -
+
+ {hasActiveFilters() && ( + + )}
= ({list, isFetching, onSortCh onClose={handleCloseConfigDisplayModal} backtest={selectedBacktestForConfigView} /> + + {/* Delete Confirmation Modal */} + {showDeleteConfirm && ( +
+
+

Confirm Delete

+

+ Are you sure you want to delete all backtests matching the current filters? This action cannot be undone. +

+
+ + +
+
+
+ )} )} diff --git a/src/Managing.WebApp/src/generated/ManagingApi.ts b/src/Managing.WebApp/src/generated/ManagingApi.ts index 5a75cd2c..f4b62763 100644 --- a/src/Managing.WebApp/src/generated/ManagingApi.ts +++ b/src/Managing.WebApp/src/generated/ManagingApi.ts @@ -577,6 +577,66 @@ export class BacktestClient extends AuthorizedApiBase { return Promise.resolve(null as any); } + backtest_DeleteBacktestsByFilters(scoreMin: number | null | undefined, scoreMax: number | null | undefined, winrateMin: number | null | undefined, winrateMax: number | null | undefined, maxDrawdownMax: number | null | undefined, tickers: string | null | undefined, indicators: string | null | undefined, durationMinDays: number | null | undefined, durationMaxDays: number | null | undefined, name: string | null | undefined): Promise { + let url_ = this.baseUrl + "/Backtest/ByFilters?"; + if (scoreMin !== undefined && scoreMin !== null) + url_ += "scoreMin=" + encodeURIComponent("" + scoreMin) + "&"; + if (scoreMax !== undefined && scoreMax !== null) + url_ += "scoreMax=" + encodeURIComponent("" + scoreMax) + "&"; + if (winrateMin !== undefined && winrateMin !== null) + url_ += "winrateMin=" + encodeURIComponent("" + winrateMin) + "&"; + if (winrateMax !== undefined && winrateMax !== null) + url_ += "winrateMax=" + encodeURIComponent("" + winrateMax) + "&"; + if (maxDrawdownMax !== undefined && maxDrawdownMax !== null) + url_ += "maxDrawdownMax=" + encodeURIComponent("" + maxDrawdownMax) + "&"; + if (tickers !== undefined && tickers !== null) + url_ += "tickers=" + encodeURIComponent("" + tickers) + "&"; + if (indicators !== undefined && indicators !== null) + url_ += "indicators=" + encodeURIComponent("" + indicators) + "&"; + if (durationMinDays !== undefined && durationMinDays !== null) + url_ += "durationMinDays=" + encodeURIComponent("" + durationMinDays) + "&"; + if (durationMaxDays !== undefined && durationMaxDays !== null) + url_ += "durationMaxDays=" + encodeURIComponent("" + durationMaxDays) + "&"; + if (name !== undefined && name !== null) + url_ += "name=" + encodeURIComponent("" + name) + "&"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "DELETE", + headers: { + "Accept": "application/octet-stream" + } + }; + + return this.transformOptions(options_).then(transformedOptions_ => { + return this.http.fetch(url_, transformedOptions_); + }).then((_response: Response) => { + return this.processBacktest_DeleteBacktestsByFilters(_response); + }); + } + + protected processBacktest_DeleteBacktestsByFilters(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers.get("content-disposition") : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return response.blob().then(blob => { return { fileName: fileName, data: blob, status: status, headers: _headers }; }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + backtest_GetBacktestsByRequestId(requestId: string): Promise { let url_ = this.baseUrl + "/Backtest/ByRequestId/{requestId}"; if (requestId === undefined || requestId === null) @@ -4310,6 +4370,7 @@ export enum BacktestSortableColumn { Fees = "Fees", SharpeRatio = "SharpeRatio", Ticker = "Ticker", + Name = "Name", } export interface LightBacktest { diff --git a/src/Managing.WebApp/src/generated/ManagingApiTypes.ts b/src/Managing.WebApp/src/generated/ManagingApiTypes.ts index 14af56de..f4fdcbe0 100644 --- a/src/Managing.WebApp/src/generated/ManagingApiTypes.ts +++ b/src/Managing.WebApp/src/generated/ManagingApiTypes.ts @@ -545,6 +545,7 @@ export enum BacktestSortableColumn { Fees = "Fees", SharpeRatio = "SharpeRatio", Ticker = "Ticker", + Name = "Name", } export interface LightBacktest {