diff --git a/.cursor/rules/fullstack.mdc b/.cursor/rules/fullstack.mdc index 0d754c0..4125fb3 100644 --- a/.cursor/rules/fullstack.mdc +++ b/.cursor/rules/fullstack.mdc @@ -102,7 +102,7 @@ Key Principles - Before creating new object or new method/function check if there a code that can be called - Most the time you will need to update multiple layer of code files. Make sure to reference all the method that you created when required - When you think its necessary update all the code from the database to the front end - - Do not update ManagingApi.ts, once you made a change on the backend endpoint, execute the command to regenerate ManagingApi.ts on the frontend; cd src/Managing.Nswag && dotnet build + - Do not update ManagingApi.ts, once you made a change on the backend endpoint, launch run the the Managing.Api project to run the API before running Nswag, then execute the command to regenerate ManagingApi.ts on the frontend; cd src/Managing.Nswag && dotnet build - Do not reference new react library if a component already exist in mollecules or atoms - After finishing the editing, build the project - you have to pass from controller -> application -> repository, do not inject repository inside controllers diff --git a/src/Managing.Api/Controllers/BacktestController.cs b/src/Managing.Api/Controllers/BacktestController.cs index 0733efa..a8519eb 100644 --- a/src/Managing.Api/Controllers/BacktestController.cs +++ b/src/Managing.Api/Controllers/BacktestController.cs @@ -141,13 +141,17 @@ public class BacktestController : BaseController /// 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) + int pageSize = 50, + string sortBy = "score", + string sortOrder = "desc") { if (string.IsNullOrEmpty(requestId)) { @@ -164,7 +168,12 @@ public class BacktestController : BaseController return BadRequest("Page size must be between 1 and 100"); } - var (backtests, totalCount) = _backtester.GetBacktestsByRequestIdPaginated(requestId, page, pageSize); + if (sortOrder != "asc" && sortOrder != "desc") + { + return BadRequest("Sort order must be 'asc' or 'desc'"); + } + + var (backtests, totalCount) = _backtester.GetBacktestsByRequestIdPaginated(requestId, page, pageSize, sortBy, sortOrder); var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); @@ -182,6 +191,56 @@ public class BacktestController : BaseController 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, + string sortBy = "score", + string sortOrder = "desc") + { + 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'"); + } + + var (backtests, totalCount) = _backtester.GetBacktestsByUserPaginated(user, page, pageSize, sortBy, sortOrder); + var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); + + var response = new PaginatedBacktestsResponse + { + Backtests = backtests, + TotalCount = totalCount, + CurrentPage = page, + PageSize = pageSize, + TotalPages = totalPages, + HasNextPage = page < totalPages, + HasPreviousPage = page > 1 + }; + + return Ok(response); + } + /// /// Runs a backtest with the specified configuration. /// The returned backtest includes a complete TradingBotConfig that preserves all diff --git a/src/Managing.Api/Models/Requests/PaginatedBacktestsResponse.cs b/src/Managing.Api/Models/Requests/PaginatedBacktestsResponse.cs new file mode 100644 index 0000000..2f857a3 --- /dev/null +++ b/src/Managing.Api/Models/Requests/PaginatedBacktestsResponse.cs @@ -0,0 +1,44 @@ +using Managing.Domain.Backtests; + +namespace Managing.Api.Models.Requests; + +/// +/// Response model for paginated backtest results +/// +public class PaginatedBacktestsResponse +{ + /// + /// The list of backtests for the current page + /// + public IEnumerable Backtests { get; set; } = new List(); + + /// + /// Total number of backtests across all pages + /// + public int TotalCount { get; set; } + + /// + /// Current page number + /// + public int CurrentPage { get; set; } + + /// + /// Number of items per page + /// + public int PageSize { get; set; } + + /// + /// Total number of pages + /// + public int TotalPages { get; set; } + + /// + /// Whether there are more pages available + /// + public bool HasNextPage { get; set; } + + /// + /// Whether there are previous pages available + /// + public bool HasPreviousPage { get; set; } +} \ No newline at end of file diff --git a/src/Managing.Application.Abstractions/Repositories/IBacktestRepository.cs b/src/Managing.Application.Abstractions/Repositories/IBacktestRepository.cs index 768214d..5d044eb 100644 --- a/src/Managing.Application.Abstractions/Repositories/IBacktestRepository.cs +++ b/src/Managing.Application.Abstractions/Repositories/IBacktestRepository.cs @@ -8,7 +8,8 @@ public interface IBacktestRepository void InsertBacktestForUser(User user, Backtest result); IEnumerable GetBacktestsByUser(User user); IEnumerable GetBacktestsByRequestId(string requestId); - (IEnumerable Backtests, int TotalCount) GetBacktestsByRequestIdPaginated(string requestId, int page, int pageSize); + (IEnumerable Backtests, int TotalCount) GetBacktestsByRequestIdPaginated(string requestId, int page, int pageSize, string sortBy = "score", string sortOrder = "desc"); + (IEnumerable Backtests, int TotalCount) GetBacktestsByUserPaginated(User user, int page, int pageSize, string sortBy = "score", string sortOrder = "desc"); Backtest GetBacktestByIdForUser(User user, string id); void DeleteBacktestByIdForUser(User user, string id); void DeleteBacktestsByIdsForUser(User user, IEnumerable ids); diff --git a/src/Managing.Application.Abstractions/Services/IBacktester.cs b/src/Managing.Application.Abstractions/Services/IBacktester.cs index a9f95d0..8f8d19a 100644 --- a/src/Managing.Application.Abstractions/Services/IBacktester.cs +++ b/src/Managing.Application.Abstractions/Services/IBacktester.cs @@ -54,11 +54,12 @@ namespace Managing.Application.Abstractions.Services bool DeleteBacktests(); IEnumerable GetBacktestsByUser(User user); IEnumerable GetBacktestsByRequestId(string requestId); - (IEnumerable Backtests, int TotalCount) GetBacktestsByRequestIdPaginated(string requestId, int page, int pageSize); + (IEnumerable Backtests, int TotalCount) GetBacktestsByRequestIdPaginated(string requestId, int page, int pageSize, string sortBy = "score", string sortOrder = "desc"); Backtest GetBacktestByIdForUser(User user, string id); bool DeleteBacktestByUser(User user, string id); bool DeleteBacktestsByIdsForUser(User user, IEnumerable ids); bool DeleteBacktestsByUser(User user); + (IEnumerable Backtests, int TotalCount) GetBacktestsByUserPaginated(User user, int page, int pageSize, string sortBy = "score", string sortOrder = "desc"); } diff --git a/src/Managing.Application/Backtesting/Backtester.cs b/src/Managing.Application/Backtesting/Backtester.cs index ba55c9e..2a76e4d 100644 --- a/src/Managing.Application/Backtesting/Backtester.cs +++ b/src/Managing.Application/Backtesting/Backtester.cs @@ -448,9 +448,9 @@ namespace Managing.Application.Backtesting return backtests; } - public (IEnumerable Backtests, int TotalCount) GetBacktestsByRequestIdPaginated(string requestId, int page, int pageSize) + public (IEnumerable Backtests, int TotalCount) GetBacktestsByRequestIdPaginated(string requestId, int page, int pageSize, string sortBy = "score", string sortOrder = "desc") { - var (backtests, totalCount) = _backtestRepository.GetBacktestsByRequestIdPaginated(requestId, page, pageSize); + var (backtests, totalCount) = _backtestRepository.GetBacktestsByRequestIdPaginated(requestId, page, pageSize, sortBy, sortOrder); return (backtests, totalCount); } @@ -530,5 +530,11 @@ namespace Managing.Application.Backtesting return false; } } + + public (IEnumerable Backtests, int TotalCount) GetBacktestsByUserPaginated(User user, int page, int pageSize, string sortBy = "score", string sortOrder = "desc") + { + var (backtests, totalCount) = _backtestRepository.GetBacktestsByUserPaginated(user, page, pageSize, sortBy, sortOrder); + return (backtests, totalCount); + } } } \ No newline at end of file diff --git a/src/Managing.Infrastructure.Database/BacktestRepository.cs b/src/Managing.Infrastructure.Database/BacktestRepository.cs index 0079b07..42209bb 100644 --- a/src/Managing.Infrastructure.Database/BacktestRepository.cs +++ b/src/Managing.Infrastructure.Database/BacktestRepository.cs @@ -19,10 +19,50 @@ public class BacktestRepository : IBacktestRepository } // User-specific operations - public void InsertBacktestForUser(User user, Backtest backtest) + public void InsertBacktestForUser(User user, Backtest result) { - backtest.User = user; - _backtestRepository.InsertOne(MongoMappers.Map(backtest)); + ValidateBacktestData(result); + result.User = user; + var dto = MongoMappers.Map(result); + _backtestRepository.InsertOne(dto); + } + + /// + /// Validates that all numeric fields in the backtest are of the correct type + /// + private void ValidateBacktestData(Backtest backtest) + { + // Ensure FinalPnl is a valid decimal + if (backtest.FinalPnl.GetType() != typeof(decimal)) + { + throw new InvalidOperationException( + $"FinalPnl must be of type decimal, but got {backtest.FinalPnl.GetType().Name}"); + } + + // Ensure other numeric fields are correct + if (backtest.GrowthPercentage.GetType() != typeof(decimal)) + { + throw new InvalidOperationException( + $"GrowthPercentage must be of type decimal, but got {backtest.GrowthPercentage.GetType().Name}"); + } + + if (backtest.HodlPercentage.GetType() != typeof(decimal)) + { + throw new InvalidOperationException( + $"HodlPercentage must be of type decimal, but got {backtest.HodlPercentage.GetType().Name}"); + } + + if (backtest.Score.GetType() != typeof(double)) + { + throw new InvalidOperationException( + $"Score must be of type double, but got {backtest.Score.GetType().Name}"); + } + + if (backtest.WinRate.GetType() != typeof(int)) + { + throw new InvalidOperationException( + $"WinRate must be of type int, but got {backtest.WinRate.GetType().Name}"); + } } public IEnumerable GetBacktestsByUser(User user) @@ -43,7 +83,8 @@ public class BacktestRepository : IBacktestRepository return backtests.Select(b => MongoMappers.Map(b)); } - public (IEnumerable Backtests, int TotalCount) GetBacktestsByRequestIdPaginated(string requestId, int page, int pageSize) + public (IEnumerable Backtests, int TotalCount) GetBacktestsByRequestIdPaginated(string requestId, + int page, int pageSize, string sortBy = "score", string sortOrder = "desc") { var stopwatch = Stopwatch.StartNew(); var collection = _backtestRepository.GetCollection(); // You may need to expose this in your repo @@ -69,19 +110,44 @@ public class BacktestRepository : IBacktestRepository .Include(b => b.Metadata) .Include(b => b.Config); + // Build sort definition + var sortDefinition = sortBy.ToLower() switch + { + "score" => sortOrder == "desc" + ? Builders.Sort.Descending(b => b.Score) + : Builders.Sort.Ascending(b => b.Score), + "finalpnl" => sortOrder == "desc" + ? Builders.Sort.Descending(b => b.FinalPnl) + : Builders.Sort.Ascending(b => b.FinalPnl), + "winrate" => sortOrder == "desc" + ? Builders.Sort.Descending(b => b.WinRate) + : Builders.Sort.Ascending(b => b.WinRate), + "growthpercentage" => sortOrder == "desc" + ? Builders.Sort.Descending(b => b.GrowthPercentage) + : Builders.Sort.Ascending(b => b.GrowthPercentage), + "hodlpercentage" => sortOrder == "desc" + ? Builders.Sort.Descending(b => b.HodlPercentage) + : Builders.Sort.Ascending(b => b.HodlPercentage), + _ => sortOrder == "desc" + ? Builders.Sort.Descending(b => b.Score) + : Builders.Sort.Ascending(b => b.Score) + }; + var afterProjectionMs = stopwatch.ElapsedMilliseconds; var backtests = collection .Find(filter) .Project(projection) + .Sort(sortDefinition) .Skip((page - 1) * pageSize) .Limit(pageSize) .ToList(); var afterToListMs = stopwatch.ElapsedMilliseconds; - Console.WriteLine($"[BacktestRepo] Query: {afterQueryMs}ms, Count: {afterCountMs - afterQueryMs}ms, Projection: {afterProjectionMs - afterCountMs}ms, ToList: {afterToListMs - afterProjectionMs}ms, Total: {afterToListMs}ms"); + Console.WriteLine( + $"[BacktestRepo] Query: {afterQueryMs}ms, Count: {afterCountMs - afterQueryMs}ms, Projection: {afterProjectionMs - afterCountMs}ms, ToList: {afterToListMs - afterProjectionMs}ms, Total: {afterToListMs}ms"); var mappedBacktests = backtests.Select(b => MongoMappers.Map(b)); - + return (mappedBacktests, (int)totalCount); } @@ -131,4 +197,72 @@ public class BacktestRepository : IBacktestRepository _backtestRepository.DeleteById(backtest.Id.ToString()); } } + + public (IEnumerable Backtests, int TotalCount) GetBacktestsByUserPaginated(User user, int page, + int pageSize, string sortBy = "score", string sortOrder = "desc") + { + var stopwatch = Stopwatch.StartNew(); + var collection = _backtestRepository.GetCollection(); + + var filter = Builders.Filter.Eq(b => b.User.Name, user.Name); + + var afterQueryMs = stopwatch.ElapsedMilliseconds; + var totalCount = collection.CountDocuments(filter); + var afterCountMs = stopwatch.ElapsedMilliseconds; + + var projection = Builders.Projection + .Include(b => b.Identifier) + .Include(b => b.FinalPnl) + .Include(b => b.WinRate) + .Include(b => b.GrowthPercentage) + .Include(b => b.HodlPercentage) + .Include(b => b.User) + .Include(b => b.Statistics) + .Include(b => b.StartDate) + .Include(b => b.EndDate) + .Include(b => b.Score) + .Include(b => b.RequestId) + .Include(b => b.Metadata) + .Include(b => b.Config); + + // Build sort definition + var sortDefinition = sortBy.ToLower() switch + { + "score" => sortOrder == "desc" + ? Builders.Sort.Descending(b => b.Score) + : Builders.Sort.Ascending(b => b.Score), + "finalpnl" => sortOrder == "desc" + ? Builders.Sort.Descending(b => b.FinalPnl) + : Builders.Sort.Ascending(b => b.FinalPnl), + "winrate" => sortOrder == "desc" + ? Builders.Sort.Descending(b => b.WinRate) + : Builders.Sort.Ascending(b => b.WinRate), + "growthpercentage" => sortOrder == "desc" + ? Builders.Sort.Descending(b => b.GrowthPercentage) + : Builders.Sort.Ascending(b => b.GrowthPercentage), + "hodlpercentage" => sortOrder == "desc" + ? Builders.Sort.Descending(b => b.HodlPercentage) + : Builders.Sort.Ascending(b => b.HodlPercentage), + _ => sortOrder == "desc" + ? Builders.Sort.Descending(b => b.Score) + : Builders.Sort.Ascending(b => b.Score) + }; + + var afterProjectionMs = stopwatch.ElapsedMilliseconds; + var backtests = collection + .Find(filter) + .Project(projection) + .Sort(sortDefinition) + .Skip((page - 1) * pageSize) + .Limit(pageSize) + .ToList(); + var afterToListMs = stopwatch.ElapsedMilliseconds; + + Console.WriteLine( + $"[BacktestRepo] User Query: {afterQueryMs}ms, Count: {afterCountMs - afterQueryMs}ms, Projection: {afterProjectionMs - afterCountMs}ms, ToList: {afterToListMs - afterProjectionMs}ms, Total: {afterToListMs}ms"); + + var mappedBacktests = backtests.Select(b => MongoMappers.Map(b)); + + return (mappedBacktests, (int)totalCount); + } } \ No newline at end of file diff --git a/src/Managing.Infrastructure.Database/MongoDb/Collections/BacktestDto.cs b/src/Managing.Infrastructure.Database/MongoDb/Collections/BacktestDto.cs index e850e2e..a18d1f7 100644 --- a/src/Managing.Infrastructure.Database/MongoDb/Collections/BacktestDto.cs +++ b/src/Managing.Infrastructure.Database/MongoDb/Collections/BacktestDto.cs @@ -8,6 +8,7 @@ namespace Managing.Infrastructure.Databases.MongoDb.Collections public class BacktestDto : Document { public decimal FinalPnl { get; set; } + public int WinRate { get; set; } public decimal GrowthPercentage { get; set; } public decimal HodlPercentage { get; set; } diff --git a/src/Managing.Infrastructure.Database/MongoDb/IndexService.cs b/src/Managing.Infrastructure.Database/MongoDb/IndexService.cs new file mode 100644 index 0000000..5384962 --- /dev/null +++ b/src/Managing.Infrastructure.Database/MongoDb/IndexService.cs @@ -0,0 +1,117 @@ +using Managing.Infrastructure.Databases.MongoDb.Collections; +using Managing.Infrastructure.Databases.MongoDb.Configurations; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using MongoDB.Driver; + +namespace Managing.Infrastructure.Databases.MongoDb; + +/// +/// Service responsible for creating and managing MongoDB indexes +/// +public class IndexService +{ + private readonly IMongoDatabase _database; + private readonly ILogger _logger; + + public IndexService( + IOptions databaseSettings, + ILogger logger) + { + var settings = databaseSettings.Value; + var client = new MongoClient(settings.ConnectionString); + _database = client.GetDatabase(settings.DatabaseName); + _logger = logger; + } + + /// + /// Creates all necessary indexes for the application + /// + public async Task CreateIndexesAsync() + { + try + { + _logger.LogInformation("Creating MongoDB indexes..."); + + // Create indexes for BacktestDto + await CreateBacktestIndexesAsync(); + + // Create indexes for GeneticRequestDto + await CreateGeneticRequestIndexesAsync(); + + _logger.LogInformation("MongoDB indexes created successfully"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create MongoDB indexes"); + throw; + } + } + + /// + /// Creates indexes for the BacktestDto collection + /// + private async Task CreateBacktestIndexesAsync() + { + try + { + var collection = _database.GetCollection("Backtests"); + + // Create index on RequestId for faster queries + var requestIdIndexKeys = Builders.IndexKeys.Ascending(b => b.RequestId); + var requestIdIndexOptions = new CreateIndexOptions { Name = "RequestId_Index" }; + var requestIdIndexModel = new CreateIndexModel(requestIdIndexKeys, requestIdIndexOptions); + + // Create index (MongoDB will ignore if it already exists) + await collection.Indexes.CreateOneAsync(requestIdIndexModel); + + _logger.LogInformation("Backtest RequestId index created successfully"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create Backtest indexes"); + throw; + } + } + + /// + /// Creates indexes for the GeneticRequestDto collection + /// + private async Task CreateGeneticRequestIndexesAsync() + { + try + { + var collection = _database.GetCollection("GeneticRequests"); + + // Create index on RequestId for faster queries + var requestIdIndexKeys = Builders.IndexKeys.Ascending(gr => gr.RequestId); + var requestIdIndexOptions = new CreateIndexOptions { Name = "RequestId_Index" }; + var requestIdIndexModel = new CreateIndexModel(requestIdIndexKeys, requestIdIndexOptions); + + // Create index on User.Name for user-specific queries + var userIndexKeys = Builders.IndexKeys.Ascending("User.Name"); + var userIndexOptions = new CreateIndexOptions { Name = "User_Name_Index" }; + var userIndexModel = new CreateIndexModel(userIndexKeys, userIndexOptions); + + // Create index on Status for filtering by status + var statusIndexKeys = Builders.IndexKeys.Ascending(gr => gr.Status); + var statusIndexOptions = new CreateIndexOptions { Name = "Status_Index" }; + var statusIndexModel = new CreateIndexModel(statusIndexKeys, statusIndexOptions); + + // Create indexes (MongoDB will ignore if they already exist) + await collection.Indexes.CreateManyAsync(new[] + { + requestIdIndexModel, + userIndexModel, + statusIndexModel + }); + + _logger.LogInformation("GeneticRequest indexes created successfully"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create GeneticRequest indexes"); + throw; + } + } +} \ No newline at end of file diff --git a/src/Managing.WebApp/src/components/organism/Backtest/backtestTable.tsx b/src/Managing.WebApp/src/components/organism/Backtest/backtestTable.tsx index a6a3062..fa6ebee 100644 --- a/src/Managing.WebApp/src/components/organism/Backtest/backtestTable.tsx +++ b/src/Managing.WebApp/src/components/organism/Backtest/backtestTable.tsx @@ -1,25 +1,145 @@ -import {ChevronDownIcon, ChevronRightIcon, CogIcon, PlayIcon, TrashIcon} from '@heroicons/react/solid' +import {ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, CogIcon, PlayIcon, TrashIcon} from '@heroicons/react/solid' import React, {useEffect, useState} from 'react' +import {useExpanded, useFilters, usePagination, useSortBy, useTable,} from 'react-table' import useApiUrlStore from '../../../app/store/apiStore' import useBacktestStore from '../../../app/store/backtestStore' import type {Backtest} from '../../../generated/ManagingApi' import {BacktestClient} from '../../../generated/ManagingApi' -import {ConfigDisplayModal, IndicatorsDisplay, SelectColumnFilter, Table} from '../../mollecules' +import {ConfigDisplayModal, IndicatorsDisplay, SelectColumnFilter} from '../../mollecules' import {UnifiedTradingModal} from '../index' import Toast from '../../mollecules/Toast/Toast' import BacktestRowDetails from './backtestRowDetails' +// Custom Table component for server-side sorting +const ServerSortableTable = ({ + columns, + data, + renderRowSubCompontent, + onSortChange, + currentSort +}: any) => { + const defaultColumn = React.useMemo( + () => ({ + Filter: SelectColumnFilter, + }), + [] + ) + + const { + getTableProps, + getTableBodyProps, + headerGroups, + prepareRow, + visibleColumns, + rows, + } = useTable( + { + columns, + data, + defaultColumn, + }, + useFilters, + useSortBy, + useExpanded, + usePagination + ) + + const handleSortClick = (column: any) => { + if (!onSortChange || !column.canSort) return; + + const isCurrentlySorted = currentSort?.sortBy === column.id; + const newSortOrder = isCurrentlySorted && currentSort?.sortOrder === 'desc' ? 'asc' : 'desc'; + + onSortChange(column.id, newSortOrder); + }; + + return ( +
+ + + {headerGroups.map((headerGroup: any) => ( + + {headerGroup.headers.map((column: any) => ( + + ))} + + ))} + + + {rows.map((row: any) => { + prepareRow(row) + return ( + <> + + {row.cells.map((cell: any) => { + return ( + + ) + })} + + {row.isExpanded ? ( + + + + ) : null} + + ) + })} + +
handleSortClick(column)} + className={column.canSort ? 'cursor-pointer hover:bg-base-200' : ''} + > +

+ {column.render('Header')} +

+ + {(() => { + // Map backend field names to column IDs for comparison + const backendToColumnMapping: { [key: string]: string } = { + 'score': 'score', + 'finalpnl': 'finalPnl', + 'winrate': 'winRate', + 'growthpercentage': 'growthPercentage', + 'hodlpercentage': 'hodlPercentage', + }; + const columnId = backendToColumnMapping[currentSort?.sortBy || ''] || currentSort?.sortBy; + + return currentSort?.sortBy && columnId === column.id ? ( + currentSort.sortOrder === 'desc' ? ( + + ) : ( + + ) + ) : ( + '' + ); + })()} + +
+ {column.canFilter ? column.render('Filter') : null} +
+
{cell.render('Cell')}
+ {renderRowSubCompontent({ row })} +
+
+ ) +} + interface BacktestTableProps { list: Backtest[] | undefined isFetching?: boolean displaySummary?: boolean + onSortChange?: (sortBy: string, sortOrder: 'asc' | 'desc') => void + currentSort?: { sortBy: string; sortOrder: 'asc' | 'desc' } } -const BacktestTable: React.FC = ({list, isFetching, displaySummary = true}) => { +const BacktestTable: React.FC = ({list, isFetching, displaySummary = true, onSortChange, currentSort}) => { const [rows, setRows] = useState([]) const {apiUrl} = useApiUrlStore() const {removeBacktest} = useBacktestStore() @@ -52,6 +172,23 @@ const BacktestTable: React.FC = ({list, isFetching, displayS const [showConfigDisplayModal, setShowConfigDisplayModal] = useState(false) const [selectedBacktestForConfigView, setSelectedBacktestForConfigView] = useState(null) + // Handle sort change + const handleSortChange = (columnId: string, sortOrder: 'asc' | 'desc') => { + if (!onSortChange) return; + + // Map column IDs to backend field names + const sortByMapping: { [key: string]: string } = { + 'score': 'score', + 'finalPnl': 'finalpnl', + 'winRate': 'winrate', + 'growthPercentage': 'growthpercentage', + 'hodlPercentage': 'hodlpercentage', + }; + + const backendSortBy = sortByMapping[columnId] || 'score'; + onSortChange(backendSortBy, sortOrder); + }; + const handleOpenBotConfigModal = (backtest: Backtest) => { setSelectedBacktest(backtest) setShowBotConfigModal(true) @@ -157,6 +294,8 @@ const BacktestTable: React.FC = ({list, isFetching, displayS ), disableFilters: true, + disableSortBy: false, + sortType: 'basic', }, { Filter: SelectColumnFilter, @@ -197,6 +336,7 @@ const BacktestTable: React.FC = ({list, isFetching, displayS Header: 'Pnl $', accessor: 'finalPnl', disableFilters: true, + disableSortBy: false, sortType: 'basic', }, { @@ -204,6 +344,8 @@ const BacktestTable: React.FC = ({list, isFetching, displayS Header: 'Winrate', accessor: 'winRate', disableFilters: true, + disableSortBy: false, + sortType: 'basic', }, { Cell: ({cell}: any) => ( @@ -212,6 +354,7 @@ const BacktestTable: React.FC = ({list, isFetching, displayS Header: 'Hodl %', accessor: 'hodlPercentage', disableFilters: true, + disableSortBy: false, sortType: 'basic', }, { @@ -221,6 +364,7 @@ const BacktestTable: React.FC = ({list, isFetching, displayS Header: 'Pnl %', accessor: 'growthPercentage', disableFilters: true, + disableSortBy: false, sortType: 'basic', }, { @@ -525,7 +669,7 @@ const BacktestTable: React.FC = ({list, isFetching, displayS )} )} - ( @@ -533,6 +677,8 @@ const BacktestTable: React.FC = ({list, isFetching, displayS backtest={row.original} /> )} + onSortChange={handleSortChange} + currentSort={currentSort} /> {/* Bot Configuration Modal */} diff --git a/src/Managing.WebApp/src/generated/ManagingApi.ts b/src/Managing.WebApp/src/generated/ManagingApi.ts index 1877a9f..a526305 100644 --- a/src/Managing.WebApp/src/generated/ManagingApi.ts +++ b/src/Managing.WebApp/src/generated/ManagingApi.ts @@ -580,7 +580,7 @@ export class BacktestClient extends AuthorizedApiBase { return Promise.resolve(null as any); } - backtest_GetBacktestsByRequestIdPaginated(requestId: string, page: number | undefined, pageSize: number | undefined): Promise { + backtest_GetBacktestsByRequestIdPaginated(requestId: string, page: number | undefined, pageSize: number | undefined, sortBy: string | null | undefined, sortOrder: string | null | undefined): Promise { let url_ = this.baseUrl + "/Backtest/ByRequestId/{requestId}/Paginated?"; if (requestId === undefined || requestId === null) throw new Error("The parameter 'requestId' must be defined."); @@ -593,6 +593,10 @@ export class BacktestClient extends AuthorizedApiBase { throw new Error("The parameter 'pageSize' cannot be null."); else if (pageSize !== undefined) url_ += "pageSize=" + encodeURIComponent("" + pageSize) + "&"; + if (sortBy !== undefined && sortBy !== null) + url_ += "sortBy=" + encodeURIComponent("" + sortBy) + "&"; + if (sortOrder !== undefined && sortOrder !== null) + url_ += "sortOrder=" + encodeURIComponent("" + sortOrder) + "&"; url_ = url_.replace(/[?&]$/, ""); let options_: RequestInit = { @@ -626,6 +630,53 @@ export class BacktestClient extends AuthorizedApiBase { return Promise.resolve(null as any); } + backtest_GetBacktestsPaginated(page: number | undefined, pageSize: number | undefined, sortBy: string | null | undefined, sortOrder: string | null | undefined): Promise { + let url_ = this.baseUrl + "/Backtest/Paginated?"; + if (page === null) + throw new Error("The parameter 'page' cannot be null."); + else if (page !== undefined) + url_ += "page=" + encodeURIComponent("" + page) + "&"; + if (pageSize === null) + throw new Error("The parameter 'pageSize' cannot be null."); + else if (pageSize !== undefined) + url_ += "pageSize=" + encodeURIComponent("" + pageSize) + "&"; + if (sortBy !== undefined && sortBy !== null) + url_ += "sortBy=" + encodeURIComponent("" + sortBy) + "&"; + if (sortOrder !== undefined && sortOrder !== null) + url_ += "sortOrder=" + encodeURIComponent("" + sortOrder) + "&"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "GET", + headers: { + "Accept": "application/json" + } + }; + + return this.transformOptions(options_).then(transformedOptions_ => { + return this.http.fetch(url_, transformedOptions_); + }).then((_response: Response) => { + return this.processBacktest_GetBacktestsPaginated(_response); + }); + } + + protected processBacktest_GetBacktestsPaginated(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) { + return response.text().then((_responseText) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as PaginatedBacktestsResponse; + return result200; + }); + } 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_Run(request: RunBacktestRequest): Promise { let url_ = this.baseUrl + "/Backtest/Run"; url_ = url_.replace(/[?&]$/, ""); diff --git a/src/Managing.WebApp/src/pages/backtestPage/backtestGeneticBundle.tsx b/src/Managing.WebApp/src/pages/backtestPage/backtestGeneticBundle.tsx index af34a6b..1e0ea0e 100644 --- a/src/Managing.WebApp/src/pages/backtestPage/backtestGeneticBundle.tsx +++ b/src/Managing.WebApp/src/pages/backtestPage/backtestGeneticBundle.tsx @@ -46,20 +46,20 @@ const ALL_INDICATORS = [ // Form Interface interface GeneticBundleFormData { - ticker: Ticker - timeframe: Timeframe - startDate: string - endDate: string - balance: number - populationSize: number - generations: number - mutationRate: number - selectionMethod: GeneticSelectionMethod - crossoverMethod: GeneticCrossoverMethod - mutationMethod: GeneticMutationMethod - elitismPercentage: number - maxTakeProfit: number - eligibleIndicators: IndicatorType[] + ticker: Ticker + timeframe: Timeframe + startDate: string + endDate: string + balance: number + populationSize: number + generations: number + mutationRate: number + selectionMethod: GeneticSelectionMethod + crossoverMethod: GeneticCrossoverMethod + mutationMethod: GeneticMutationMethod + elitismPercentage: number + maxTakeProfit: number + eligibleIndicators: IndicatorType[] } const BacktestGeneticBundle: React.FC = () => { @@ -76,13 +76,19 @@ const BacktestGeneticBundle: React.FC = () => { const [backtests, setBacktests] = useState([]) const [isLoadingBacktests, setIsLoadingBacktests] = useState(false) const [isFormCollapsed, setIsFormCollapsed] = useState(false) - + // Pagination state const [currentPage, setCurrentPage] = useState(1) const [pageSize, setPageSize] = useState(50) const [totalBacktests, setTotalBacktests] = useState(0) const [totalPages, setTotalPages] = useState(0) + // Sorting state + const [currentSort, setCurrentSort] = useState<{ sortBy: string; sortOrder: 'asc' | 'desc' }>({ + sortBy: 'score', + sortOrder: 'desc' + }) + // Form setup const {register, handleSubmit, watch, setValue, formState: {errors}} = useForm({ defaultValues: { @@ -225,17 +231,20 @@ const BacktestGeneticBundle: React.FC = () => { setSelectedRequest(request) setIsViewModalOpen(true) setIsLoadingBacktests(true) - - // Reset pagination state + + // Reset pagination and sorting state setCurrentPage(1) setTotalBacktests(0) setTotalPages(0) + setCurrentSort({sortBy: 'score', sortOrder: 'desc'}) try { const response: PaginatedBacktestsResponse = await backtestClient.backtest_GetBacktestsByRequestIdPaginated( - request.requestId, - 1, - pageSize + request.requestId, + 1, + pageSize, + currentSort.sortBy, + currentSort.sortOrder ) setBacktests(response.backtests || []) setTotalBacktests(response.totalCount || 0) @@ -261,15 +270,17 @@ const BacktestGeneticBundle: React.FC = () => { // Handle page change const handlePageChange = async (newPage: number) => { if (!selectedRequest || newPage < 1 || newPage > totalPages) return - + setIsLoadingBacktests(true) setCurrentPage(newPage) try { const response: PaginatedBacktestsResponse = await backtestClient.backtest_GetBacktestsByRequestIdPaginated( - selectedRequest.requestId, - newPage, - pageSize + selectedRequest.requestId, + newPage, + pageSize, + currentSort.sortBy, + currentSort.sortOrder ) setBacktests(response.backtests || []) } catch (error) { @@ -280,6 +291,33 @@ const BacktestGeneticBundle: React.FC = () => { } } + // Handle sort change + const handleSortChange = async (sortBy: string, sortOrder: 'asc' | 'desc') => { + if (!selectedRequest) return + + setIsLoadingBacktests(true) + setCurrentSort({sortBy, sortOrder}) + setCurrentPage(1) // Reset to first page when sorting + + try { + const response: PaginatedBacktestsResponse = await backtestClient.backtest_GetBacktestsByRequestIdPaginated( + selectedRequest.requestId, + 1, + pageSize, + sortBy, + sortOrder + ) + setBacktests(response.backtests || []) + setTotalBacktests(response.totalCount || 0) + setTotalPages(response.totalPages || 0) + } catch (error) { + console.error('Error fetching backtests:', error) + new Toast('Failed to load backtest details', false) + } finally { + setIsLoadingBacktests(false) + } + } + // Table columns for genetic requests const geneticRequestsColumns = useMemo(() => [ { @@ -298,7 +336,7 @@ const BacktestGeneticBundle: React.FC = () => { const currentGen = value || 0 const percentage = generations > 0 ? Math.round((currentGen / generations) * 100) : 0 const status = row.original.status - + // Calculate color based on percentage (red to green) const getProgressColor = (percent: number) => { if (percent <= 25) return 'progress-error' @@ -317,9 +355,9 @@ const BacktestGeneticBundle: React.FC = () => { {currentGen} / {generations} - @@ -534,13 +572,15 @@ const BacktestGeneticBundle: React.FC = () => { - @@ -550,22 +590,27 @@ const BacktestGeneticBundle: React.FC = () => { - @@ -573,15 +618,16 @@ const BacktestGeneticBundle: React.FC = () => { - @@ -725,9 +771,9 @@ const BacktestGeneticBundle: React.FC = () => { Progress: {selectedRequest.currentGeneration || 0} / {selectedRequest.generations} {selectedRequest.currentGeneration && selectedRequest.generations > 0 && (
-
@@ -778,38 +824,40 @@ const BacktestGeneticBundle: React.FC = () => {

Score vs Generation

- +
- +
{/* Fitness vs Score vs Win Rate */}

Fitness vs Score vs Win Rate

- +
- + {/* TP% vs SL% vs PnL */}

Take Profit vs Stop Loss vs PnL

- +
- + {/* Strategy Comparison Radar Chart */}
- +
- + ) : ( diff --git a/src/Managing.WebApp/src/pages/backtestPage/backtestScanner.tsx b/src/Managing.WebApp/src/pages/backtestPage/backtestScanner.tsx index c06569a..bbb8f4c 100644 --- a/src/Managing.WebApp/src/pages/backtestPage/backtestScanner.tsx +++ b/src/Managing.WebApp/src/pages/backtestPage/backtestScanner.tsx @@ -1,5 +1,4 @@ import {ColorSwatchIcon, TrashIcon} from '@heroicons/react/solid' -import {useQuery} from '@tanstack/react-query' import React, {useEffect, useState} from 'react' import 'react-toastify/dist/ReactToastify.css' @@ -8,8 +7,11 @@ import useBacktestStore from '../../app/store/backtestStore' import {Loader, Slider} from '../../components/atoms' import {Modal, Toast} from '../../components/mollecules' import {BacktestTable, UnifiedTradingModal} from '../../components/organism' +import type {Backtest} from '../../generated/ManagingApi' import {BacktestClient} from '../../generated/ManagingApi' +const PAGE_SIZE = 50 + const BacktestScanner: React.FC = () => { const [showModal, setShowModal] = useState(false) const [showModalRemoveBacktest, setShowModalRemoveBacktest] = useState(false) @@ -18,30 +20,50 @@ const BacktestScanner: React.FC = () => { winRate: 50, score: 50 }) - const { apiUrl } = useApiUrlStore() - const { backtests: backtestingResult, setBacktests, setLoading } = useBacktestStore() - const client = new BacktestClient({}, apiUrl) - - const { isLoading, refetch, data: backtests } = useQuery({ - queryFn: () => client.backtest_Backtests(), - queryKey: ['backtests'], + const [currentPage, setCurrentPage] = useState(1) + const [totalBacktests, setTotalBacktests] = useState(0) + const [totalPages, setTotalPages] = useState(0) + const [currentSort, setCurrentSort] = useState<{ sortBy: string; sortOrder: 'asc' | 'desc' }>({ + sortBy: 'score', + sortOrder: 'desc' }) + const [backtests, setBacktests] = useState([]) + const [isLoading, setIsLoading] = useState(false) - useEffect(() => { - if (backtests) { - setBacktests(backtests) + const { apiUrl } = useApiUrlStore() + const { setBacktests: setBacktestsFromStore, setLoading } = useBacktestStore() + const backtestClient = new BacktestClient({}, apiUrl) + + // Fetch paginated/sorted backtests + const fetchBacktests = async (page = 1, sort = currentSort) => { + setIsLoading(true) + try { + const response = await backtestClient.backtest_GetBacktestsPaginated(page, PAGE_SIZE, sort.sortBy, sort.sortOrder) + setBacktests((response.backtests as Backtest[]) || []) + setTotalBacktests(response.totalCount || 0) + setTotalPages(response.totalPages || 0) + } catch (err) { + new Toast('Failed to load backtests', false) + } finally { + setIsLoading(false) } - }, [backtests, setBacktests]) + } useEffect(() => { + fetchBacktests(currentPage, currentSort) + // eslint-disable-next-line + }, [currentPage, currentSort]) + + useEffect(() => { + setBacktestsFromStore(backtests) setLoading(isLoading) - }, [isLoading, setLoading]) + }, [backtests, setBacktestsFromStore, setLoading, isLoading]) useEffect(() => { - if (backtestingResult && showModalRemoveBacktest) { + if (backtests && showModalRemoveBacktest) { calculateFilteredCount() } - }, [backtestingResult, showModalRemoveBacktest]) + }, [backtests, showModalRemoveBacktest]) const openModalRemoveBacktests = () => { setShowModalRemoveBacktest(true) @@ -50,14 +72,14 @@ const BacktestScanner: React.FC = () => { } const calculateFilteredCount = (formData?: any) => { - if (!backtestingResult) { + if (!backtests) { setFilteredCount(0) return } const filters = formData || filterValues - const filteredBacktests = backtestingResult.filter((backtest: any) => { + const filteredBacktests = backtests.filter((backtest: any) => { // Ensure values are numbers and handle potential null/undefined values const backtestWinRate = Number(backtest.winRate) || 0 const backtestScore = Number(backtest.score) || 0 @@ -78,7 +100,7 @@ const BacktestScanner: React.FC = () => { ) }) - console.log('Filtered count:', filteredBacktests.length, 'Total:', backtestingResult.length) + console.log('Filtered count:', filteredBacktests.length, 'Total:', backtests.length) setFilteredCount(filteredBacktests.length) } @@ -103,11 +125,11 @@ const BacktestScanner: React.FC = () => { const notify = new Toast(`Deleting Backtests...`) closeModalRemoveBacktest() - if (!backtestingResult) { + if (!backtests) { return } - const backTestToDelete = backtestingResult.filter((backtest: any) => { + const backTestToDelete = backtests.filter((backtest: any) => { // Ensure values are numbers and handle potential null/undefined values const backtestWinRate = Number(backtest.winRate) || 0 const backtestScore = Number(backtest.score) || 0 @@ -125,11 +147,11 @@ const BacktestScanner: React.FC = () => { try { const backtestIds = backTestToDelete.map((backtest: any) => backtest.id) - await client.backtest_DeleteBacktests({ backtestIds }) + await backtestClient.backtest_DeleteBacktests({ backtestIds }) notify.update('success', `${backTestToDelete.length} backtests deleted successfully`) // Refetch backtests to update the list - refetch() + fetchBacktests(currentPage, currentSort) } catch (err: any) { notify.update('error', err?.message || 'An error occurred while deleting backtests') } @@ -143,6 +165,18 @@ const BacktestScanner: React.FC = () => { setShowModal(false) } + // Sorting handler + const handleSortChange = (sortBy: string, sortOrder: 'asc' | 'desc') => { + setCurrentSort({ sortBy, sortOrder }) + setCurrentPage(1) + } + + // Pagination handler + const handlePageChange = (newPage: number) => { + if (newPage < 1 || (totalPages && newPage > totalPages)) return + setCurrentPage(newPage) + } + if (isLoading) { return } @@ -163,7 +197,24 @@ const BacktestScanner: React.FC = () => { - + + {/* Pagination controls */} + {totalPages > 1 && ( +
+ + Page {currentPage} of {totalPages} + +
+ )}