Add filters and sorting for backtests
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using Managing.Api.Models.Requests;
|
||||
using Managing.Application.Abstractions.Services;
|
||||
using Managing.Application.Abstractions.Shared;
|
||||
using Managing.Application.Hubs;
|
||||
using Managing.Domain.Backtests;
|
||||
using Managing.Domain.Bots;
|
||||
@@ -10,6 +11,7 @@ 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;
|
||||
@@ -231,8 +233,17 @@ public class BacktestController : BaseController
|
||||
public async Task<ActionResult<PaginatedBacktestsResponse>> GetBacktestsPaginated(
|
||||
int page = 1,
|
||||
int pageSize = 50,
|
||||
string sortBy = "score",
|
||||
string sortOrder = "desc")
|
||||
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)
|
||||
{
|
||||
var user = await GetUser();
|
||||
|
||||
@@ -251,8 +262,65 @@ public class BacktestController : BaseController
|
||||
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
|
||||
{
|
||||
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);
|
||||
await _backtester.GetBacktestsByUserPaginatedAsync(
|
||||
user,
|
||||
page,
|
||||
pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
filter);
|
||||
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
||||
|
||||
var response = new PaginatedBacktestsResponse
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Managing.Domain.Backtests;
|
||||
using Managing.Application.Abstractions.Shared;
|
||||
using Managing.Common;
|
||||
using Managing.Domain.Backtests;
|
||||
using Managing.Domain.Users;
|
||||
|
||||
namespace Managing.Application.Abstractions.Repositories;
|
||||
@@ -18,11 +20,21 @@ public interface IBacktestRepository
|
||||
int page,
|
||||
int pageSize, string sortBy = "score", string sortOrder = "desc");
|
||||
|
||||
(IEnumerable<LightBacktest> Backtests, int TotalCount) GetBacktestsByUserPaginated(User user, int page,
|
||||
int pageSize, string sortBy = "score", string sortOrder = "desc");
|
||||
(IEnumerable<LightBacktest> Backtests, int TotalCount) GetBacktestsByUserPaginated(
|
||||
User user,
|
||||
int page,
|
||||
int pageSize,
|
||||
Enums.BacktestSortableColumn sortBy = Enums.BacktestSortableColumn.Score,
|
||||
string sortOrder = "desc",
|
||||
BacktestsFilter? filter = null);
|
||||
|
||||
Task<(IEnumerable<LightBacktest> Backtests, int TotalCount)> GetBacktestsByUserPaginatedAsync(User user, int page,
|
||||
int pageSize, string sortBy = "score", string sortOrder = "desc");
|
||||
Task<(IEnumerable<LightBacktest> Backtests, int TotalCount)> GetBacktestsByUserPaginatedAsync(
|
||||
User user,
|
||||
int page,
|
||||
int pageSize,
|
||||
Enums.BacktestSortableColumn sortBy = Enums.BacktestSortableColumn.Score,
|
||||
string sortOrder = "desc",
|
||||
BacktestsFilter? filter = null);
|
||||
|
||||
Task<Backtest> GetBacktestByIdForUserAsync(User user, string id);
|
||||
Task DeleteBacktestByIdForUserAsync(User user, string id);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Managing.Domain.Backtests;
|
||||
using Managing.Application.Abstractions.Shared;
|
||||
using Managing.Common;
|
||||
using Managing.Domain.Backtests;
|
||||
using Managing.Domain.Bots;
|
||||
using Managing.Domain.Candles;
|
||||
using Managing.Domain.Users;
|
||||
@@ -64,9 +66,22 @@ namespace Managing.Application.Abstractions.Services
|
||||
Task<bool> DeleteBacktestByUserAsync(User user, string id);
|
||||
Task<bool> DeleteBacktestsByIdsForUserAsync(User user, IEnumerable<string> ids);
|
||||
bool DeleteBacktestsByUser(User user);
|
||||
(IEnumerable<LightBacktest> Backtests, int TotalCount) GetBacktestsByUserPaginated(
|
||||
User user,
|
||||
int page,
|
||||
int pageSize,
|
||||
Enums.BacktestSortableColumn sortBy,
|
||||
string sortOrder = "desc",
|
||||
BacktestsFilter? filter = null);
|
||||
|
||||
Task<(IEnumerable<LightBacktest> Backtests, int TotalCount)> GetBacktestsByUserPaginatedAsync(
|
||||
User user,
|
||||
int page,
|
||||
int pageSize,
|
||||
Enums.BacktestSortableColumn sortBy,
|
||||
string sortOrder = "desc",
|
||||
BacktestsFilter? filter = null);
|
||||
Task<bool> DeleteBacktestsByRequestIdAsync(Guid requestId);
|
||||
(IEnumerable<LightBacktest> Backtests, int TotalCount) GetBacktestsByUserPaginated(User user, int page, int pageSize, string sortBy = "score", string sortOrder = "desc");
|
||||
Task<(IEnumerable<LightBacktest> Backtests, int TotalCount)> GetBacktestsByUserPaginatedAsync(User user, int page, int pageSize, string sortBy = "score", string sortOrder = "desc");
|
||||
|
||||
// Bundle backtest methods
|
||||
void InsertBundleBacktestRequestForUser(User user, BundleBacktestRequest bundleRequest);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Managing.Application.Abstractions.Shared;
|
||||
|
||||
public class BacktestsFilter
|
||||
{
|
||||
public double? ScoreMin { get; set; }
|
||||
public double? ScoreMax { get; set; }
|
||||
public int? WinrateMin { get; set; }
|
||||
public int? WinrateMax { get; set; }
|
||||
public decimal? MaxDrawdownMax { get; set; }
|
||||
public IEnumerable<string>? Tickers { get; set; }
|
||||
public IEnumerable<string>? Indicators { get; set; }
|
||||
public TimeSpan? DurationMin { get; set; }
|
||||
public TimeSpan? DurationMax { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using Managing.Application.Abstractions.Grains;
|
||||
using Managing.Application.Abstractions.Repositories;
|
||||
using Managing.Application.Abstractions.Services;
|
||||
using Managing.Application.Abstractions.Shared;
|
||||
using Managing.Application.Hubs;
|
||||
using Managing.Core;
|
||||
using Managing.Domain.Accounts;
|
||||
@@ -415,19 +416,29 @@ namespace Managing.Application.Backtests
|
||||
}
|
||||
}
|
||||
|
||||
public (IEnumerable<LightBacktest> Backtests, int TotalCount) GetBacktestsByUserPaginated(User user, int page,
|
||||
int pageSize, string sortBy = "score", string sortOrder = "desc")
|
||||
public (IEnumerable<LightBacktest> Backtests, int TotalCount) GetBacktestsByUserPaginated(
|
||||
User user,
|
||||
int page,
|
||||
int pageSize,
|
||||
BacktestSortableColumn sortBy,
|
||||
string sortOrder = "desc",
|
||||
BacktestsFilter? filter = null)
|
||||
{
|
||||
var (backtests, totalCount) =
|
||||
_backtestRepository.GetBacktestsByUserPaginated(user, page, pageSize, sortBy, sortOrder);
|
||||
_backtestRepository.GetBacktestsByUserPaginated(user, page, pageSize, sortBy, sortOrder, filter);
|
||||
return (backtests, totalCount);
|
||||
}
|
||||
|
||||
public async Task<(IEnumerable<LightBacktest> Backtests, int TotalCount)> GetBacktestsByUserPaginatedAsync(
|
||||
User user, int page, int pageSize, string sortBy = "score", string sortOrder = "desc")
|
||||
User user,
|
||||
int page,
|
||||
int pageSize,
|
||||
BacktestSortableColumn sortBy,
|
||||
string sortOrder = "desc",
|
||||
BacktestsFilter? filter = null)
|
||||
{
|
||||
var (backtests, totalCount) =
|
||||
await _backtestRepository.GetBacktestsByUserPaginatedAsync(user, page, pageSize, sortBy, sortOrder);
|
||||
await _backtestRepository.GetBacktestsByUserPaginatedAsync(user, page, pageSize, sortBy, sortOrder, filter);
|
||||
return (backtests, totalCount);
|
||||
}
|
||||
|
||||
|
||||
@@ -504,6 +504,25 @@ public static class Enums
|
||||
TotalBalance
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sortable columns for backtests pagination endpoints
|
||||
/// </summary>
|
||||
public enum BacktestSortableColumn
|
||||
{
|
||||
Score,
|
||||
FinalPnl,
|
||||
WinRate,
|
||||
GrowthPercentage,
|
||||
HodlPercentage,
|
||||
Duration,
|
||||
Timeframe,
|
||||
IndicatorsCount,
|
||||
MaxDrawdown,
|
||||
Fees,
|
||||
SharpeRatio,
|
||||
Ticker
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event types for agent summary updates
|
||||
/// </summary>
|
||||
|
||||
@@ -24,4 +24,5 @@ public class LightBacktest
|
||||
[Id(11)] public double Score { get; set; }
|
||||
[Id(12)] public string ScoreMessage { get; set; } = string.Empty;
|
||||
[Id(13)] public object Metadata { get; set; }
|
||||
[Id(14)] public string Ticker { get; set; } = string.Empty;
|
||||
}
|
||||
1536
src/Managing.Infrastructure.Database/Migrations/20251014092228_BacktestFiltersDenormalization.Designer.cs
generated
Normal file
1536
src/Managing.Infrastructure.Database/Migrations/20251014092228_BacktestFiltersDenormalization.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,165 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Managing.Infrastructure.Databases.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class BacktestFiltersDenormalization : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<TimeSpan>(
|
||||
name: "Duration",
|
||||
table: "Backtests",
|
||||
type: "interval",
|
||||
nullable: false,
|
||||
defaultValue: new TimeSpan(0, 0, 0, 0, 0));
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "IndicatorsCount",
|
||||
table: "Backtests",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "IndicatorsCsv",
|
||||
table: "Backtests",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "MaxDrawdown",
|
||||
table: "Backtests",
|
||||
type: "numeric(18,8)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<TimeSpan>(
|
||||
name: "MaxDrawdownRecoveryTime",
|
||||
table: "Backtests",
|
||||
type: "interval",
|
||||
nullable: false,
|
||||
defaultValue: new TimeSpan(0, 0, 0, 0, 0));
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Name",
|
||||
table: "Backtests",
|
||||
type: "character varying(255)",
|
||||
maxLength: 255,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "SharpeRatio",
|
||||
table: "Backtests",
|
||||
type: "numeric(18,8)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Ticker",
|
||||
table: "Backtests",
|
||||
type: "character varying(32)",
|
||||
maxLength: 32,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Timeframe",
|
||||
table: "Backtests",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Backtests_UserId_IndicatorsCount",
|
||||
table: "Backtests",
|
||||
columns: new[] { "UserId", "IndicatorsCount" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Backtests_UserId_IndicatorsCsv",
|
||||
table: "Backtests",
|
||||
columns: new[] { "UserId", "IndicatorsCsv" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Backtests_UserId_Name",
|
||||
table: "Backtests",
|
||||
columns: new[] { "UserId", "Name" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Backtests_UserId_Ticker",
|
||||
table: "Backtests",
|
||||
columns: new[] { "UserId", "Ticker" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Backtests_UserId_Timeframe",
|
||||
table: "Backtests",
|
||||
columns: new[] { "UserId", "Timeframe" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Backtests_UserId_IndicatorsCount",
|
||||
table: "Backtests");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Backtests_UserId_IndicatorsCsv",
|
||||
table: "Backtests");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Backtests_UserId_Name",
|
||||
table: "Backtests");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Backtests_UserId_Ticker",
|
||||
table: "Backtests");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Backtests_UserId_Timeframe",
|
||||
table: "Backtests");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Duration",
|
||||
table: "Backtests");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IndicatorsCount",
|
||||
table: "Backtests");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IndicatorsCsv",
|
||||
table: "Backtests");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MaxDrawdown",
|
||||
table: "Backtests");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MaxDrawdownRecoveryTime",
|
||||
table: "Backtests");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Name",
|
||||
table: "Backtests");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SharpeRatio",
|
||||
table: "Backtests");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Ticker",
|
||||
table: "Backtests");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Timeframe",
|
||||
table: "Backtests");
|
||||
}
|
||||
}
|
||||
}
|
||||
1532
src/Managing.Infrastructure.Database/Migrations/20251014092447_RemoveIndicatorIndexes.Designer.cs
generated
Normal file
1532
src/Managing.Infrastructure.Database/Migrations/20251014092447_RemoveIndicatorIndexes.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Managing.Infrastructure.Databases.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RemoveIndicatorIndexes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Backtests_UserId_IndicatorsCount",
|
||||
table: "Backtests");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Backtests_UserId_IndicatorsCsv",
|
||||
table: "Backtests");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Backtests_UserId_IndicatorsCount",
|
||||
table: "Backtests",
|
||||
columns: new[] { "UserId", "IndicatorsCount" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Backtests_UserId_IndicatorsCsv",
|
||||
table: "Backtests",
|
||||
columns: new[] { "UserId", "IndicatorsCsv" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,6 +157,11 @@ namespace Managing.Infrastructure.Databases.Migrations
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<TimeSpan>("Duration")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("interval")
|
||||
.HasDefaultValue(new TimeSpan(0, 0, 0, 0, 0));
|
||||
|
||||
b.Property<DateTime>("EndDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
@@ -177,6 +182,23 @@ namespace Managing.Infrastructure.Databases.Migrations
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<int>("IndicatorsCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("IndicatorsCsv")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<decimal>("MaxDrawdown")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("decimal(18,8)")
|
||||
.HasDefaultValue(0m);
|
||||
|
||||
b.Property<TimeSpan>("MaxDrawdownRecoveryTime")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("interval")
|
||||
.HasDefaultValue(new TimeSpan(0, 0, 0, 0, 0));
|
||||
|
||||
b.Property<string>("Metadata")
|
||||
.HasColumnType("text");
|
||||
|
||||
@@ -184,6 +206,11 @@ namespace Managing.Infrastructure.Databases.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("PositionsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
@@ -200,6 +227,11 @@ namespace Managing.Infrastructure.Databases.Migrations
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<decimal>("SharpeRatio")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("decimal(18,8)")
|
||||
.HasDefaultValue(0m);
|
||||
|
||||
b.Property<string>("SignalsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
@@ -210,6 +242,14 @@ namespace Managing.Infrastructure.Databases.Migrations
|
||||
b.Property<string>("StatisticsJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Ticker")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<int>("Timeframe")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
@@ -232,8 +272,14 @@ namespace Managing.Infrastructure.Databases.Migrations
|
||||
|
||||
b.HasIndex("RequestId", "Score");
|
||||
|
||||
b.HasIndex("UserId", "Name");
|
||||
|
||||
b.HasIndex("UserId", "Score");
|
||||
|
||||
b.HasIndex("UserId", "Ticker");
|
||||
|
||||
b.HasIndex("UserId", "Timeframe");
|
||||
|
||||
b.ToTable("Backtests");
|
||||
});
|
||||
|
||||
|
||||
@@ -36,6 +36,28 @@ public class BacktestEntity
|
||||
[Column(TypeName = "jsonb")]
|
||||
public string ConfigJson { get; set; } = string.Empty;
|
||||
|
||||
// Denormalized bot/backtest name (e.g., "MyBundleTest #3") for sorting/filtering
|
||||
[Required]
|
||||
[MaxLength(255)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
// Denormalized ticker string for fast filtering/sorting
|
||||
[Required]
|
||||
[MaxLength(32)]
|
||||
public string Ticker { get; set; } = string.Empty;
|
||||
|
||||
// Stored timeframe as enum numeric value for direct sorting/filtering
|
||||
[Required]
|
||||
public int Timeframe { get; set; }
|
||||
|
||||
// Comma-separated indicator types for filtering, e.g., "EMA_CROSS,MACD_CROSS"
|
||||
[Column(TypeName = "text")]
|
||||
public string IndicatorsCsv { get; set; } = string.Empty;
|
||||
|
||||
// Number of indicators used in the scenario for sorting/filtering
|
||||
[Required]
|
||||
public int IndicatorsCount { get; set; }
|
||||
|
||||
[Required]
|
||||
[Column(TypeName = "jsonb")]
|
||||
public string PositionsJson { get; set; } = string.Empty;
|
||||
@@ -50,6 +72,10 @@ public class BacktestEntity
|
||||
[Required]
|
||||
public DateTime EndDate { get; set; }
|
||||
|
||||
// Precomputed for filtering: EndDate - StartDate
|
||||
[Required]
|
||||
public TimeSpan Duration { get; set; }
|
||||
|
||||
[Required]
|
||||
[Column(TypeName = "jsonb")]
|
||||
public string MoneyManagementJson { get; set; } = string.Empty;
|
||||
@@ -63,6 +89,19 @@ public class BacktestEntity
|
||||
[Column(TypeName = "jsonb")]
|
||||
public string? StatisticsJson { get; set; }
|
||||
|
||||
// Extracted metrics for efficient querying/indexing
|
||||
[Required]
|
||||
[Column(TypeName = "decimal(18,8)")]
|
||||
public decimal SharpeRatio { get; set; }
|
||||
|
||||
[Required]
|
||||
[Column(TypeName = "decimal(18,8)")]
|
||||
public decimal MaxDrawdown { get; set; }
|
||||
|
||||
// PostgreSQL maps TimeSpan to interval
|
||||
[Required]
|
||||
public TimeSpan MaxDrawdownRecoveryTime { get; set; }
|
||||
|
||||
[Required]
|
||||
[Column(TypeName = "decimal(18,8)")]
|
||||
public decimal Fees { get; set; }
|
||||
|
||||
@@ -156,10 +156,19 @@ public class ManagingDbContext : DbContext
|
||||
entity.Property(e => e.HodlPercentage).HasColumnType("decimal(18,8)");
|
||||
entity.Property(e => e.Fees).HasColumnType("decimal(18,8)");
|
||||
entity.Property(e => e.ConfigJson).HasColumnType("jsonb");
|
||||
entity.Property(e => e.Name).IsRequired().HasMaxLength(255);
|
||||
entity.Property(e => e.Ticker).HasMaxLength(32);
|
||||
entity.Property(e => e.Timeframe).IsRequired();
|
||||
entity.Property(e => e.IndicatorsCsv).HasColumnType("text");
|
||||
entity.Property(e => e.IndicatorsCount).IsRequired();
|
||||
entity.Property(e => e.PositionsJson).HasColumnType("jsonb");
|
||||
entity.Property(e => e.SignalsJson).HasColumnType("jsonb");
|
||||
entity.Property(e => e.MoneyManagementJson).HasColumnType("jsonb");
|
||||
entity.Property(e => e.StatisticsJson).HasColumnType("jsonb");
|
||||
entity.Property(e => e.SharpeRatio).HasColumnType("decimal(18,8)").HasDefaultValue(0m);
|
||||
entity.Property(e => e.MaxDrawdown).HasColumnType("decimal(18,8)").HasDefaultValue(0m);
|
||||
entity.Property(e => e.MaxDrawdownRecoveryTime).HasDefaultValue(TimeSpan.Zero);
|
||||
entity.Property(e => e.Duration).HasDefaultValue(TimeSpan.Zero);
|
||||
entity.Property(e => e.ScoreMessage).HasMaxLength(1000);
|
||||
entity.Property(e => e.Metadata).HasColumnType("text");
|
||||
|
||||
@@ -177,7 +186,10 @@ public class ManagingDbContext : DbContext
|
||||
|
||||
// Composite indexes for efficient pagination and filtering
|
||||
entity.HasIndex(e => new { e.UserId, e.Score });
|
||||
entity.HasIndex(e => new { e.UserId, e.Name });
|
||||
entity.HasIndex(e => new { e.RequestId, e.Score });
|
||||
entity.HasIndex(e => new { e.UserId, e.Ticker });
|
||||
entity.HasIndex(e => new { e.UserId, e.Timeframe });
|
||||
});
|
||||
|
||||
// Configure BundleBacktestRequest entity
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
using System.Diagnostics;
|
||||
using Exilion.TradingAtomics;
|
||||
using Managing.Application.Abstractions.Repositories;
|
||||
using Managing.Application.Abstractions.Shared;
|
||||
using Managing.Domain.Backtests;
|
||||
using Managing.Domain.Bots;
|
||||
using Managing.Domain.Users;
|
||||
using Managing.Infrastructure.Databases.PostgreSql.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Newtonsoft.Json;
|
||||
using static Managing.Common.Enums;
|
||||
|
||||
namespace Managing.Infrastructure.Databases.PostgreSql;
|
||||
|
||||
@@ -377,8 +379,13 @@ public class PostgreSqlBacktestRepository : IBacktestRepository
|
||||
}
|
||||
}
|
||||
|
||||
public (IEnumerable<LightBacktest> Backtests, int TotalCount) GetBacktestsByUserPaginated(User user, int page,
|
||||
int pageSize, string sortBy = "score", string sortOrder = "desc")
|
||||
public (IEnumerable<LightBacktest> Backtests, int TotalCount) GetBacktestsByUserPaginated(
|
||||
User user,
|
||||
int page,
|
||||
int pageSize,
|
||||
BacktestSortableColumn sortBy = BacktestSortableColumn.Score,
|
||||
string sortOrder = "desc",
|
||||
BacktestsFilter? filter = null)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
@@ -386,28 +393,83 @@ public class PostgreSqlBacktestRepository : IBacktestRepository
|
||||
.AsNoTracking()
|
||||
.Where(b => b.UserId == user.Id);
|
||||
|
||||
if (filter != null)
|
||||
{
|
||||
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 afterQueryMs = stopwatch.ElapsedMilliseconds;
|
||||
var totalCount = baseQuery.Count();
|
||||
var afterCountMs = stopwatch.ElapsedMilliseconds;
|
||||
|
||||
// Apply sorting
|
||||
IQueryable<BacktestEntity> sortedQuery = sortBy.ToLower() switch
|
||||
IQueryable<BacktestEntity> sortedQuery = sortBy switch
|
||||
{
|
||||
"score" => sortOrder == "desc"
|
||||
BacktestSortableColumn.Score => sortOrder == "desc"
|
||||
? baseQuery.OrderByDescending(b => b.Score)
|
||||
: baseQuery.OrderBy(b => b.Score),
|
||||
"finalpnl" => sortOrder == "desc"
|
||||
BacktestSortableColumn.FinalPnl => sortOrder == "desc"
|
||||
? baseQuery.OrderByDescending(b => b.FinalPnl)
|
||||
: baseQuery.OrderBy(b => b.FinalPnl),
|
||||
"winrate" => sortOrder == "desc"
|
||||
BacktestSortableColumn.WinRate => sortOrder == "desc"
|
||||
? baseQuery.OrderByDescending(b => b.WinRate)
|
||||
: baseQuery.OrderBy(b => b.WinRate),
|
||||
"growthpercentage" => sortOrder == "desc"
|
||||
BacktestSortableColumn.GrowthPercentage => sortOrder == "desc"
|
||||
? baseQuery.OrderByDescending(b => b.GrowthPercentage)
|
||||
: baseQuery.OrderBy(b => b.GrowthPercentage),
|
||||
"hodlpercentage" => sortOrder == "desc"
|
||||
BacktestSortableColumn.HodlPercentage => sortOrder == "desc"
|
||||
? baseQuery.OrderByDescending(b => b.HodlPercentage)
|
||||
: baseQuery.OrderBy(b => b.HodlPercentage),
|
||||
BacktestSortableColumn.Duration => sortOrder == "desc"
|
||||
? baseQuery.OrderByDescending(b => b.Duration)
|
||||
: baseQuery.OrderBy(b => b.Duration),
|
||||
BacktestSortableColumn.Timeframe => sortOrder == "desc"
|
||||
? baseQuery.OrderByDescending(b => b.Timeframe)
|
||||
: baseQuery.OrderBy(b => b.Timeframe),
|
||||
BacktestSortableColumn.IndicatorsCount => sortOrder == "desc"
|
||||
? baseQuery.OrderByDescending(b => b.IndicatorsCount)
|
||||
: baseQuery.OrderBy(b => b.IndicatorsCount),
|
||||
BacktestSortableColumn.MaxDrawdown => sortOrder == "desc"
|
||||
? baseQuery.OrderByDescending(b => b.MaxDrawdown)
|
||||
: baseQuery.OrderBy(b => b.MaxDrawdown),
|
||||
BacktestSortableColumn.Fees => sortOrder == "desc"
|
||||
? baseQuery.OrderByDescending(b => b.Fees)
|
||||
: baseQuery.OrderBy(b => b.Fees),
|
||||
BacktestSortableColumn.SharpeRatio => sortOrder == "desc"
|
||||
? baseQuery.OrderByDescending(b => b.SharpeRatio)
|
||||
: baseQuery.OrderBy(b => b.SharpeRatio),
|
||||
BacktestSortableColumn.Ticker => sortOrder == "desc"
|
||||
? baseQuery.OrderByDescending(b => b.Ticker)
|
||||
: baseQuery.OrderBy(b => b.Ticker),
|
||||
_ => sortOrder == "desc"
|
||||
? baseQuery.OrderByDescending(b => b.Score)
|
||||
: baseQuery.OrderBy(b => b.Score)
|
||||
@@ -427,21 +489,16 @@ public class PostgreSqlBacktestRepository : IBacktestRepository
|
||||
{
|
||||
Id = entity.Identifier,
|
||||
Config = JsonConvert.DeserializeObject<TradingBotConfig>(entity.ConfigJson),
|
||||
Ticker = entity.Ticker,
|
||||
FinalPnl = entity.FinalPnl,
|
||||
WinRate = entity.WinRate,
|
||||
GrowthPercentage = entity.GrowthPercentage,
|
||||
HodlPercentage = entity.HodlPercentage,
|
||||
StartDate = entity.StartDate,
|
||||
EndDate = entity.EndDate,
|
||||
MaxDrawdown = !string.IsNullOrEmpty(entity.StatisticsJson)
|
||||
? JsonConvert.DeserializeObject<PerformanceMetrics>(entity.StatisticsJson)?.MaxDrawdown
|
||||
: null,
|
||||
MaxDrawdown = entity.MaxDrawdown,
|
||||
Fees = entity.Fees,
|
||||
SharpeRatio = !string.IsNullOrEmpty(entity.StatisticsJson)
|
||||
? JsonConvert.DeserializeObject<PerformanceMetrics>(entity.StatisticsJson)?.SharpeRatio != null
|
||||
? (double?)JsonConvert.DeserializeObject<PerformanceMetrics>(entity.StatisticsJson).SharpeRatio
|
||||
: null
|
||||
: null,
|
||||
SharpeRatio = (double?)entity.SharpeRatio,
|
||||
Score = entity.Score,
|
||||
ScoreMessage = entity.ScoreMessage ?? string.Empty
|
||||
});
|
||||
@@ -450,36 +507,95 @@ public class PostgreSqlBacktestRepository : IBacktestRepository
|
||||
}
|
||||
|
||||
public async Task<(IEnumerable<LightBacktest> Backtests, int TotalCount)> GetBacktestsByUserPaginatedAsync(
|
||||
User user, int page, int pageSize, string sortBy = "score", string sortOrder = "desc")
|
||||
User user,
|
||||
int page,
|
||||
int pageSize,
|
||||
BacktestSortableColumn sortBy = BacktestSortableColumn.Score,
|
||||
string sortOrder = "desc",
|
||||
BacktestsFilter? filter = null)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
var baseQuery = _context.Backtests
|
||||
.AsNoTracking()
|
||||
.Where(b => b.UserId == user.Id);
|
||||
if (filter != null)
|
||||
{
|
||||
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 afterQueryMs = stopwatch.ElapsedMilliseconds;
|
||||
var totalCount = await baseQuery.CountAsync().ConfigureAwait(false);
|
||||
var afterCountMs = stopwatch.ElapsedMilliseconds;
|
||||
|
||||
// Apply sorting
|
||||
IQueryable<BacktestEntity> sortedQuery = sortBy.ToLower() switch
|
||||
IQueryable<BacktestEntity> sortedQuery = sortBy switch
|
||||
{
|
||||
"score" => sortOrder == "desc"
|
||||
BacktestSortableColumn.Score => sortOrder == "desc"
|
||||
? baseQuery.OrderByDescending(b => b.Score)
|
||||
: baseQuery.OrderBy(b => b.Score),
|
||||
"finalpnl" => sortOrder == "desc"
|
||||
BacktestSortableColumn.FinalPnl => sortOrder == "desc"
|
||||
? baseQuery.OrderByDescending(b => b.FinalPnl)
|
||||
: baseQuery.OrderBy(b => b.FinalPnl),
|
||||
"winrate" => sortOrder == "desc"
|
||||
BacktestSortableColumn.WinRate => sortOrder == "desc"
|
||||
? baseQuery.OrderByDescending(b => b.WinRate)
|
||||
: baseQuery.OrderBy(b => b.WinRate),
|
||||
"growthpercentage" => sortOrder == "desc"
|
||||
BacktestSortableColumn.GrowthPercentage => sortOrder == "desc"
|
||||
? baseQuery.OrderByDescending(b => b.GrowthPercentage)
|
||||
: baseQuery.OrderBy(b => b.GrowthPercentage),
|
||||
"hodlpercentage" => sortOrder == "desc"
|
||||
BacktestSortableColumn.HodlPercentage => sortOrder == "desc"
|
||||
? baseQuery.OrderByDescending(b => b.HodlPercentage)
|
||||
: baseQuery.OrderBy(b => b.HodlPercentage),
|
||||
BacktestSortableColumn.Duration => sortOrder == "desc"
|
||||
? baseQuery.OrderByDescending(b => b.Duration)
|
||||
: baseQuery.OrderBy(b => b.Duration),
|
||||
BacktestSortableColumn.Timeframe => sortOrder == "desc"
|
||||
? baseQuery.OrderByDescending(b => b.Timeframe)
|
||||
: baseQuery.OrderBy(b => b.Timeframe),
|
||||
BacktestSortableColumn.IndicatorsCount => sortOrder == "desc"
|
||||
? baseQuery.OrderByDescending(b => b.IndicatorsCount)
|
||||
: baseQuery.OrderBy(b => b.IndicatorsCount),
|
||||
BacktestSortableColumn.MaxDrawdown => sortOrder == "desc"
|
||||
? baseQuery.OrderByDescending(b => b.MaxDrawdown)
|
||||
: baseQuery.OrderBy(b => b.MaxDrawdown),
|
||||
BacktestSortableColumn.Fees => sortOrder == "desc"
|
||||
? baseQuery.OrderByDescending(b => b.Fees)
|
||||
: baseQuery.OrderBy(b => b.Fees),
|
||||
BacktestSortableColumn.SharpeRatio => sortOrder == "desc"
|
||||
? baseQuery.OrderByDescending(b => b.SharpeRatio)
|
||||
: baseQuery.OrderBy(b => b.SharpeRatio),
|
||||
BacktestSortableColumn.Ticker => sortOrder == "desc"
|
||||
? baseQuery.OrderByDescending(b => b.Ticker)
|
||||
: baseQuery.OrderBy(b => b.Ticker),
|
||||
_ => sortOrder == "desc"
|
||||
? baseQuery.OrderByDescending(b => b.Score)
|
||||
: baseQuery.OrderBy(b => b.Score)
|
||||
@@ -506,15 +622,9 @@ public class PostgreSqlBacktestRepository : IBacktestRepository
|
||||
HodlPercentage = entity.HodlPercentage,
|
||||
StartDate = entity.StartDate,
|
||||
EndDate = entity.EndDate,
|
||||
MaxDrawdown = !string.IsNullOrEmpty(entity.StatisticsJson)
|
||||
? JsonConvert.DeserializeObject<PerformanceMetrics>(entity.StatisticsJson)?.MaxDrawdown
|
||||
: null,
|
||||
MaxDrawdown = entity.MaxDrawdown,
|
||||
Fees = entity.Fees,
|
||||
SharpeRatio = !string.IsNullOrEmpty(entity.StatisticsJson)
|
||||
? JsonConvert.DeserializeObject<PerformanceMetrics>(entity.StatisticsJson)?.SharpeRatio != null
|
||||
? (double?)JsonConvert.DeserializeObject<PerformanceMetrics>(entity.StatisticsJson).SharpeRatio
|
||||
: null
|
||||
: null,
|
||||
SharpeRatio = (double?)entity.SharpeRatio,
|
||||
Score = entity.Score,
|
||||
ScoreMessage = entity.ScoreMessage ?? string.Empty
|
||||
});
|
||||
|
||||
@@ -332,13 +332,22 @@ public static class PostgreSqlMappers
|
||||
GrowthPercentage = backtest.GrowthPercentage,
|
||||
HodlPercentage = backtest.HodlPercentage,
|
||||
ConfigJson = JsonConvert.SerializeObject(backtest.Config),
|
||||
Name = backtest.Config?.Name ?? string.Empty,
|
||||
Ticker = backtest.Config?.Ticker.ToString() ?? string.Empty,
|
||||
Timeframe = (int)backtest.Config.Timeframe,
|
||||
IndicatorsCsv = string.Join(',', backtest.Config.Scenario.Indicators.Select(i => i.Type.ToString())),
|
||||
IndicatorsCount = backtest.Config.Scenario.Indicators.Count,
|
||||
PositionsJson = JsonConvert.SerializeObject(backtest.Positions.Values.ToList()),
|
||||
SignalsJson = JsonConvert.SerializeObject(backtest.Signals.Values.ToList()),
|
||||
StartDate = backtest.StartDate,
|
||||
EndDate = backtest.EndDate,
|
||||
Duration = backtest.EndDate - backtest.StartDate,
|
||||
MoneyManagementJson = JsonConvert.SerializeObject(backtest.Config?.MoneyManagement),
|
||||
UserId = backtest.User?.Id ?? 0,
|
||||
StatisticsJson = backtest.Statistics != null ? JsonConvert.SerializeObject(backtest.Statistics) : null,
|
||||
SharpeRatio = backtest.Statistics?.SharpeRatio ?? 0m,
|
||||
MaxDrawdown = backtest.Statistics?.MaxDrawdown ?? 0m,
|
||||
MaxDrawdownRecoveryTime = backtest.Statistics?.MaxDrawdownRecoveryTime ?? TimeSpan.Zero,
|
||||
Fees = backtest.Fees,
|
||||
Score = backtest.Score,
|
||||
ScoreMessage = backtest.ScoreMessage ?? string.Empty,
|
||||
@@ -976,4 +985,13 @@ public static class PostgreSqlMappers
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private static int? ExtractBundleIndex(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) return null;
|
||||
var hashIndex = name.LastIndexOf('#');
|
||||
if (hashIndex < 0 || hashIndex + 1 >= name.Length) return null;
|
||||
var numberPart = name.Substring(hashIndex + 1).Trim();
|
||||
return int.TryParse(numberPart, out var n) ? n : (int?)null;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
"@privy-io/wagmi": "^1.0.3",
|
||||
"@tailwindcss/typography": "^0.5.0",
|
||||
"@tanstack/react-query": "^5.67.1",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@wagmi/chains": "^0.2.9",
|
||||
"@wagmi/connectors": "^5.7.3",
|
||||
"@wagmi/core": "^2.17.0",
|
||||
|
||||
@@ -5,7 +5,7 @@ import {useExpanded, useFilters, usePagination, useSortBy, useTable,} from 'reac
|
||||
import useApiUrlStore from '../../../app/store/apiStore'
|
||||
import useBacktestStore from '../../../app/store/backtestStore'
|
||||
import type {Backtest, LightBacktestResponse} from '../../../generated/ManagingApi'
|
||||
import {BacktestClient} from '../../../generated/ManagingApi'
|
||||
import {BacktestClient, BacktestSortableColumn, IndicatorType} from '../../../generated/ManagingApi'
|
||||
import {ConfigDisplayModal, IndicatorsDisplay, SelectColumnFilter} from '../../mollecules'
|
||||
import {UnifiedTradingModal} from '../index'
|
||||
import Toast from '../../mollecules/Toast/Toast'
|
||||
@@ -72,15 +72,15 @@ const ServerSortableTable = ({
|
||||
</p>
|
||||
<span className="relative">
|
||||
{(() => {
|
||||
// Map backend field names to column IDs for comparison
|
||||
const backendToColumnMapping: { [key: string]: string } = {
|
||||
'score': 'score',
|
||||
'finalpnl': 'finalPnl',
|
||||
'winrate': 'winRate',
|
||||
'growthpercentage': 'growthPercentage',
|
||||
'hodlpercentage': 'hodlPercentage',
|
||||
// Map enum sortable fields to table column ids
|
||||
const enumToColumnMapping: { [key in BacktestSortableColumn]?: string } = {
|
||||
[BacktestSortableColumn.Score]: 'score',
|
||||
[BacktestSortableColumn.FinalPnl]: 'finalPnl',
|
||||
[BacktestSortableColumn.WinRate]: 'winRate',
|
||||
[BacktestSortableColumn.GrowthPercentage]: 'growthPercentage',
|
||||
[BacktestSortableColumn.HodlPercentage]: 'hodlPercentage',
|
||||
};
|
||||
const columnId = backendToColumnMapping[currentSort?.sortBy || ''] || currentSort?.sortBy;
|
||||
const columnId = enumToColumnMapping[currentSort?.sortBy as BacktestSortableColumn] || currentSort?.sortBy;
|
||||
|
||||
return currentSort?.sortBy && columnId === column.id ? (
|
||||
currentSort.sortOrder === 'desc' ? (
|
||||
@@ -133,14 +133,36 @@ interface BacktestTableProps {
|
||||
list: LightBacktestResponse[] | undefined
|
||||
isFetching?: boolean
|
||||
displaySummary?: boolean
|
||||
onSortChange?: (sortBy: string, sortOrder: 'asc' | 'desc') => void
|
||||
currentSort?: { sortBy: string; sortOrder: 'asc' | 'desc' }
|
||||
onSortChange?: (sortBy: BacktestSortableColumn, sortOrder: 'asc' | 'desc') => void
|
||||
currentSort?: { sortBy: BacktestSortableColumn; sortOrder: 'asc' | 'desc' }
|
||||
onBacktestDeleted?: () => void // Callback when a backtest is deleted
|
||||
onFiltersChange?: (filters: {
|
||||
scoreMin?: number | null
|
||||
scoreMax?: number | null
|
||||
winrateMin?: number | null
|
||||
winrateMax?: number | null
|
||||
maxDrawdownMax?: number | null
|
||||
tickers?: string[] | null
|
||||
indicators?: string[] | null
|
||||
durationMinDays?: number | null
|
||||
durationMaxDays?: number | null
|
||||
}) => void
|
||||
filters?: {
|
||||
scoreMin?: number | null
|
||||
scoreMax?: number | null
|
||||
winrateMin?: number | null
|
||||
winrateMax?: number | null
|
||||
maxDrawdownMax?: number | null
|
||||
tickers?: string[] | null
|
||||
indicators?: string[] | null
|
||||
durationMinDays?: number | null
|
||||
durationMaxDays?: number | null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const BacktestTable: React.FC<BacktestTableProps> = ({list, isFetching, onSortChange, currentSort, onBacktestDeleted}) => {
|
||||
const BacktestTable: React.FC<BacktestTableProps> = ({list, isFetching, onSortChange, currentSort, onBacktestDeleted, onFiltersChange, filters}) => {
|
||||
const [rows, setRows] = useState<LightBacktestResponse[]>([])
|
||||
const {apiUrl} = useApiUrlStore()
|
||||
const {removeBacktest} = useBacktestStore()
|
||||
@@ -157,20 +179,75 @@ const BacktestTable: React.FC<BacktestTableProps> = ({list, isFetching, onSortCh
|
||||
const [showConfigDisplayModal, setShowConfigDisplayModal] = useState(false)
|
||||
const [selectedBacktestForConfigView, setSelectedBacktestForConfigView] = useState<Backtest | null>(null)
|
||||
|
||||
// Filters sidebar state
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false)
|
||||
const [scoreMin, setScoreMin] = useState<number>(0)
|
||||
const [scoreMax, setScoreMax] = useState<number>(100)
|
||||
const [winMin, setWinMin] = useState<number>(0)
|
||||
const [winMax, setWinMax] = useState<number>(100)
|
||||
const [maxDrawdownMax, setMaxDrawdownMax] = useState<number | ''>('')
|
||||
const [tickersInput, setTickersInput] = useState<string>('')
|
||||
const [selectedIndicators, setSelectedIndicators] = useState<string[]>([])
|
||||
const [durationMinDays, setDurationMinDays] = useState<number | null>(null)
|
||||
const [durationMaxDays, setDurationMaxDays] = useState<number | null>(null)
|
||||
|
||||
const applyFilters = () => {
|
||||
if (!onFiltersChange) return
|
||||
onFiltersChange({
|
||||
scoreMin,
|
||||
scoreMax,
|
||||
winrateMin: winMin,
|
||||
winrateMax: winMax,
|
||||
maxDrawdownMax: maxDrawdownMax === '' ? null : Number(maxDrawdownMax),
|
||||
tickers: tickersInput ? tickersInput.split(',').map(s => s.trim()).filter(Boolean) : null,
|
||||
indicators: selectedIndicators.length ? selectedIndicators : null,
|
||||
durationMinDays,
|
||||
durationMaxDays,
|
||||
})
|
||||
setIsFilterOpen(false)
|
||||
}
|
||||
|
||||
const clearDuration = () => {
|
||||
setDurationMinDays(null)
|
||||
setDurationMaxDays(null)
|
||||
}
|
||||
|
||||
const toggleIndicator = (name: string) => {
|
||||
setSelectedIndicators(prev => prev.includes(name)
|
||||
? prev.filter(i => i !== name)
|
||||
: [...prev, name])
|
||||
}
|
||||
|
||||
const clearIndicators = () => setSelectedIndicators([])
|
||||
|
||||
// Sync incoming filters prop to local sidebar state
|
||||
useEffect(() => {
|
||||
if (!filters) return
|
||||
if (typeof filters.scoreMin === 'number') setScoreMin(filters.scoreMin)
|
||||
if (typeof filters.scoreMax === 'number') setScoreMax(filters.scoreMax)
|
||||
if (typeof filters.winrateMin === 'number') setWinMin(filters.winrateMin)
|
||||
if (typeof filters.winrateMax === 'number') setWinMax(filters.winrateMax)
|
||||
if (typeof filters.maxDrawdownMax === 'number') setMaxDrawdownMax(filters.maxDrawdownMax)
|
||||
setTickersInput(filters.tickers && filters.tickers.length ? filters.tickers.join(',') : '')
|
||||
setSelectedIndicators(filters.indicators ? [...filters.indicators] : [])
|
||||
setDurationMinDays(filters.durationMinDays ?? null)
|
||||
setDurationMaxDays(filters.durationMaxDays ?? null)
|
||||
}, [filters])
|
||||
|
||||
// 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',
|
||||
// Map column IDs to BacktestSortableColumn enum
|
||||
const sortByMapping: { [key: string]: BacktestSortableColumn } = {
|
||||
'score': BacktestSortableColumn.Score,
|
||||
'finalPnl': BacktestSortableColumn.FinalPnl,
|
||||
'winRate': BacktestSortableColumn.WinRate,
|
||||
'growthPercentage': BacktestSortableColumn.GrowthPercentage,
|
||||
'hodlPercentage': BacktestSortableColumn.HodlPercentage,
|
||||
};
|
||||
|
||||
const backendSortBy = sortByMapping[columnId] || 'score';
|
||||
const backendSortBy = sortByMapping[columnId] || BacktestSortableColumn.Score;
|
||||
onSortChange(backendSortBy, sortOrder);
|
||||
};
|
||||
|
||||
@@ -486,6 +563,11 @@ const BacktestTable: React.FC<BacktestTableProps> = ({list, isFetching, onSortCh
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Filters toggle button */}
|
||||
<div className="flex w-full justify-end">
|
||||
<button className="btn btn-sm btn-outline" onClick={() => setIsFilterOpen(true)}>Filters</button>
|
||||
</div>
|
||||
|
||||
<ServerSortableTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
@@ -498,6 +580,100 @@ const BacktestTable: React.FC<BacktestTableProps> = ({list, isFetching, onSortCh
|
||||
currentSort={currentSort}
|
||||
/>
|
||||
|
||||
{/* Right sidebar filter panel */}
|
||||
{isFilterOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 bg-black/30" onClick={() => setIsFilterOpen(false)}></div>
|
||||
<div className="fixed right-0 top-0 h-full w-96 bg-base-200 shadow-xl p-4 overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Filters</h3>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setIsFilterOpen(false)}>✕</button>
|
||||
</div>
|
||||
|
||||
{/* Score range */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 font-medium">Score between</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input type="number" min={0} max={100} className="input input-bordered input-sm w-20" value={scoreMin} onChange={e => setScoreMin(Math.min(100, Math.max(0, Number(e.target.value))))} />
|
||||
<span>to</span>
|
||||
<input type="number" min={0} max={100} className="input input-bordered input-sm w-20" value={scoreMax} onChange={e => setScoreMax(Math.min(100, Math.max(0, Number(e.target.value))))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tickers */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 font-medium">Tickers</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. BTC, ETH, SOL"
|
||||
className="input input-bordered input-sm w-full"
|
||||
value={tickersInput}
|
||||
onChange={e => setTickersInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Max Drawdown */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 font-medium">Max Drawdown (max)</div>
|
||||
<input type="number" className="input input-bordered input-sm w-full" placeholder="Put the max amount" value={maxDrawdownMax} onChange={e => {
|
||||
const v = e.target.value
|
||||
if (v === '') setMaxDrawdownMax('')
|
||||
else setMaxDrawdownMax(Number(v))
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* Indicators (enum selection) */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 font-medium flex items-center justify-between">
|
||||
<span>Indicators</span>
|
||||
{selectedIndicators.length > 0 && (
|
||||
<button className="btn btn-ghost btn-xs" onClick={clearIndicators}>Clear</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.values(IndicatorType).map((ind) => (
|
||||
<button
|
||||
key={ind as string}
|
||||
className={`btn btn-xs ${selectedIndicators.includes(ind as string) ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => toggleIndicator(ind as string)}
|
||||
>
|
||||
{ind as string}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Winrate */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 font-medium">Winrate between</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input type="number" min={0} max={100} className="input input-bordered input-sm w-20" value={winMin} onChange={e => setWinMin(Math.min(100, Math.max(0, Number(e.target.value))))} />
|
||||
<span>to</span>
|
||||
<input type="number" min={0} max={100} className="input input-bordered input-sm w-20" value={winMax} onChange={e => setWinMax(Math.min(100, Math.max(0, Number(e.target.value))))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 font-medium">Duration</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button className={`btn btn-xs ${durationMaxDays === 30 && durationMinDays === 0 ? 'btn-primary' : 'btn-outline'}`} onClick={() => { setDurationMinDays(0); setDurationMaxDays(30); }}>Up to 1 month</button>
|
||||
<button className={`btn btn-xs ${durationMinDays === 30 && durationMaxDays === 60 ? 'btn-primary' : 'btn-outline'}`} onClick={() => { setDurationMinDays(30); setDurationMaxDays(60); }}>1-2 months</button>
|
||||
<button className={`btn btn-xs ${durationMinDays === 90 && durationMaxDays === 150 ? 'btn-primary' : 'btn-outline'}`} onClick={() => { setDurationMinDays(90); setDurationMaxDays(150); }}>3-5 months</button>
|
||||
<button className={`btn btn-xs ${durationMinDays === 180 && durationMaxDays === 330 ? 'btn-primary' : 'btn-outline'}`} onClick={() => { setDurationMinDays(180); setDurationMaxDays(330); }}>6-11 months</button>
|
||||
<button className={`btn btn-xs ${durationMinDays === 365 && durationMaxDays === null ? 'btn-primary' : 'btn-outline'}`} onClick={() => { setDurationMinDays(365); setDurationMaxDays(null); }}>1 year or more</button>
|
||||
<button className="btn btn-xs btn-ghost" onClick={clearDuration}>Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setIsFilterOpen(false)}>Cancel</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={applyFilters}>Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Bot Configuration Modal */}
|
||||
{selectedBacktest && (
|
||||
<UnifiedTradingModal
|
||||
|
||||
@@ -665,7 +665,7 @@ export class BacktestClient extends AuthorizedApiBase {
|
||||
return Promise.resolve<PaginatedBacktestsResponse>(null as any);
|
||||
}
|
||||
|
||||
backtest_GetBacktestsPaginated(page: number | undefined, pageSize: number | undefined, sortBy: string | null | undefined, sortOrder: string | null | undefined): Promise<PaginatedBacktestsResponse> {
|
||||
backtest_GetBacktestsPaginated(page: number | undefined, pageSize: number | undefined, sortBy: BacktestSortableColumn | undefined, sortOrder: string | null | undefined, 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): Promise<PaginatedBacktestsResponse> {
|
||||
let url_ = this.baseUrl + "/Backtest/Paginated?";
|
||||
if (page === null)
|
||||
throw new Error("The parameter 'page' cannot be null.");
|
||||
@@ -675,10 +675,30 @@ 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)
|
||||
if (sortBy === null)
|
||||
throw new Error("The parameter 'sortBy' cannot be null.");
|
||||
else if (sortBy !== undefined)
|
||||
url_ += "sortBy=" + encodeURIComponent("" + sortBy) + "&";
|
||||
if (sortOrder !== undefined && sortOrder !== null)
|
||||
url_ += "sortOrder=" + encodeURIComponent("" + sortOrder) + "&";
|
||||
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) + "&";
|
||||
url_ = url_.replace(/[?&]$/, "");
|
||||
|
||||
let options_: RequestInit = {
|
||||
@@ -4275,6 +4295,21 @@ export interface LightBacktestResponse {
|
||||
scoreMessage: string;
|
||||
}
|
||||
|
||||
export enum BacktestSortableColumn {
|
||||
Score = "Score",
|
||||
FinalPnl = "FinalPnl",
|
||||
WinRate = "WinRate",
|
||||
GrowthPercentage = "GrowthPercentage",
|
||||
HodlPercentage = "HodlPercentage",
|
||||
Duration = "Duration",
|
||||
Timeframe = "Timeframe",
|
||||
IndicatorsCount = "IndicatorsCount",
|
||||
MaxDrawdown = "MaxDrawdown",
|
||||
Fees = "Fees",
|
||||
SharpeRatio = "SharpeRatio",
|
||||
Ticker = "Ticker",
|
||||
}
|
||||
|
||||
export interface LightBacktest {
|
||||
id?: string | null;
|
||||
config?: TradingBotConfig | null;
|
||||
@@ -4290,6 +4325,7 @@ export interface LightBacktest {
|
||||
score?: number;
|
||||
scoreMessage?: string | null;
|
||||
metadata?: any | null;
|
||||
ticker?: string | null;
|
||||
}
|
||||
|
||||
export interface RunBacktestRequest {
|
||||
@@ -4305,7 +4341,7 @@ export interface RunBacktestRequest {
|
||||
}
|
||||
|
||||
export interface TradingBotConfigRequest {
|
||||
accountName: string;
|
||||
accountName?: string | null;
|
||||
ticker: Ticker;
|
||||
timeframe: Timeframe;
|
||||
isForWatchingOnly: boolean;
|
||||
|
||||
@@ -532,6 +532,21 @@ export interface LightBacktestResponse {
|
||||
scoreMessage: string;
|
||||
}
|
||||
|
||||
export enum BacktestSortableColumn {
|
||||
Score = "Score",
|
||||
FinalPnl = "FinalPnl",
|
||||
WinRate = "WinRate",
|
||||
GrowthPercentage = "GrowthPercentage",
|
||||
HodlPercentage = "HodlPercentage",
|
||||
Duration = "Duration",
|
||||
Timeframe = "Timeframe",
|
||||
IndicatorsCount = "IndicatorsCount",
|
||||
MaxDrawdown = "MaxDrawdown",
|
||||
Fees = "Fees",
|
||||
SharpeRatio = "SharpeRatio",
|
||||
Ticker = "Ticker",
|
||||
}
|
||||
|
||||
export interface LightBacktest {
|
||||
id?: string | null;
|
||||
config?: TradingBotConfig | null;
|
||||
@@ -547,6 +562,7 @@ export interface LightBacktest {
|
||||
score?: number;
|
||||
scoreMessage?: string | null;
|
||||
metadata?: any | null;
|
||||
ticker?: string | null;
|
||||
}
|
||||
|
||||
export interface RunBacktestRequest {
|
||||
@@ -562,7 +578,7 @@ export interface RunBacktestRequest {
|
||||
}
|
||||
|
||||
export interface TradingBotConfigRequest {
|
||||
accountName: string;
|
||||
accountName?: string | null;
|
||||
ticker: Ticker;
|
||||
timeframe: Timeframe;
|
||||
isForWatchingOnly: boolean;
|
||||
@@ -656,7 +672,6 @@ export interface RunBundleBacktestRequest {
|
||||
}
|
||||
|
||||
export interface BundleBacktestUniversalConfig {
|
||||
accountName: string;
|
||||
timeframe: Timeframe;
|
||||
isForWatchingOnly: boolean;
|
||||
botTradingBalance: number;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {Loader, Slider} from '../../components/atoms'
|
||||
import {Modal, Toast} from '../../components/mollecules'
|
||||
import {BacktestTable, UnifiedTradingModal} from '../../components/organism'
|
||||
import type {LightBacktestResponse} from '../../generated/ManagingApi'
|
||||
import {BacktestClient} from '../../generated/ManagingApi'
|
||||
import {BacktestClient, BacktestSortableColumn} from '../../generated/ManagingApi'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
@@ -21,11 +21,24 @@ const BacktestScanner: React.FC = () => {
|
||||
score: 50
|
||||
})
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [currentSort, setCurrentSort] = useState<{ sortBy: string; sortOrder: 'asc' | 'desc' }>({
|
||||
sortBy: 'score',
|
||||
const [currentSort, setCurrentSort] = useState<{ sortBy: BacktestSortableColumn; sortOrder: 'asc' | 'desc' }>({
|
||||
sortBy: BacktestSortableColumn.Score,
|
||||
sortOrder: 'desc'
|
||||
})
|
||||
|
||||
// Filters state coming from BacktestTable sidebar
|
||||
const [filters, setFilters] = useState<{
|
||||
scoreMin?: number | null
|
||||
scoreMax?: number | null
|
||||
winrateMin?: number | null
|
||||
winrateMax?: number | null
|
||||
maxDrawdownMax?: number | null
|
||||
tickers?: string[] | null
|
||||
indicators?: string[] | null
|
||||
durationMinDays?: number | null
|
||||
durationMaxDays?: number | null
|
||||
}>({})
|
||||
|
||||
const { apiUrl } = useApiUrlStore()
|
||||
const queryClient = useQueryClient()
|
||||
const backtestClient = new BacktestClient({}, apiUrl)
|
||||
@@ -37,13 +50,22 @@ const BacktestScanner: React.FC = () => {
|
||||
error,
|
||||
refetch
|
||||
} = useQuery({
|
||||
queryKey: ['backtests', currentPage, currentSort.sortBy, currentSort.sortOrder],
|
||||
queryKey: ['backtests', currentPage, currentSort.sortBy, currentSort.sortOrder, filters],
|
||||
queryFn: async () => {
|
||||
const response = await backtestClient.backtest_GetBacktestsPaginated(
|
||||
currentPage,
|
||||
PAGE_SIZE,
|
||||
currentSort.sortBy,
|
||||
currentSort.sortOrder
|
||||
currentSort.sortOrder,
|
||||
filters.scoreMin ?? null,
|
||||
filters.scoreMax ?? null,
|
||||
filters.winrateMin ?? null,
|
||||
filters.winrateMax ?? null,
|
||||
filters.maxDrawdownMax ?? null,
|
||||
(filters.tickers && filters.tickers.length ? filters.tickers.join(',') : null),
|
||||
(filters.indicators && filters.indicators.length ? filters.indicators.join(',') : null),
|
||||
filters.durationMinDays ?? null,
|
||||
filters.durationMaxDays ?? null,
|
||||
)
|
||||
return {
|
||||
backtests: (response.backtests as LightBacktestResponse[]) || [],
|
||||
@@ -176,11 +198,27 @@ const BacktestScanner: React.FC = () => {
|
||||
}
|
||||
|
||||
// Sorting handler
|
||||
const handleSortChange = (sortBy: string, sortOrder: 'asc' | 'desc') => {
|
||||
const handleSortChange = (sortBy: BacktestSortableColumn, sortOrder: 'asc' | 'desc') => {
|
||||
setCurrentSort({ sortBy, sortOrder })
|
||||
setCurrentPage(1)
|
||||
}
|
||||
|
||||
// Filters handler from BacktestTable
|
||||
const handleFiltersChange = (newFilters: {
|
||||
scoreMin?: number | null
|
||||
scoreMax?: number | null
|
||||
winrateMin?: number | null
|
||||
winrateMax?: number | null
|
||||
maxDrawdownMax?: number | null
|
||||
tickers?: string[] | null
|
||||
indicators?: string[] | null
|
||||
durationMinDays?: number | null
|
||||
durationMaxDays?: number | null
|
||||
}) => {
|
||||
setFilters(newFilters)
|
||||
setCurrentPage(1)
|
||||
}
|
||||
|
||||
// Pagination handler
|
||||
const handlePageChange = (newPage: number) => {
|
||||
if (newPage < 1 || (totalPages && newPage > totalPages)) return
|
||||
@@ -207,11 +245,56 @@ const BacktestScanner: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Selected filters summary */}
|
||||
<div className="mt-2 mb-2">
|
||||
{(
|
||||
(filters.scoreMin !== undefined && filters.scoreMin !== null) ||
|
||||
(filters.scoreMax !== undefined && filters.scoreMax !== null) ||
|
||||
(filters.winrateMin !== undefined && filters.winrateMin !== null) ||
|
||||
(filters.winrateMax !== undefined && filters.winrateMax !== null) ||
|
||||
(filters.maxDrawdownMax !== undefined && filters.maxDrawdownMax !== null) ||
|
||||
(filters.tickers && filters.tickers.length) ||
|
||||
(filters.indicators && filters.indicators.length) ||
|
||||
(filters.durationMinDays !== undefined && filters.durationMinDays !== null) ||
|
||||
(filters.durationMaxDays !== undefined && filters.durationMaxDays !== null)
|
||||
) ? (
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<span className="text-sm opacity-70 mr-1">Active filters:</span>
|
||||
{filters.scoreMin !== undefined && filters.scoreMin !== null && (
|
||||
<div className="badge badge-outline">Score ≥ {filters.scoreMin}</div>
|
||||
)}
|
||||
{filters.scoreMax !== undefined && filters.scoreMax !== null && (
|
||||
<div className="badge badge-outline">Score ≤ {filters.scoreMax}</div>
|
||||
)}
|
||||
{filters.winrateMin !== undefined && filters.winrateMin !== null && (
|
||||
<div className="badge badge-outline">Winrate ≥ {filters.winrateMin}%</div>
|
||||
)}
|
||||
{filters.winrateMax !== undefined && filters.winrateMax !== null && (
|
||||
<div className="badge badge-outline">Winrate ≤ {filters.winrateMax}%</div>
|
||||
)}
|
||||
{filters.maxDrawdownMax !== undefined && filters.maxDrawdownMax !== null && (
|
||||
<div className="badge badge-outline">Max DD ≤ {filters.maxDrawdownMax}</div>
|
||||
)}
|
||||
{filters.tickers && filters.tickers.length > 0 && (
|
||||
<div className="badge badge-outline">Tickers: {filters.tickers.join(', ')}</div>
|
||||
)}
|
||||
{filters.indicators && filters.indicators.length > 0 && (
|
||||
<div className="badge badge-outline">Indicators: {filters.indicators.join(', ')}</div>
|
||||
)}
|
||||
{(filters.durationMinDays !== undefined && filters.durationMinDays !== null) || (filters.durationMaxDays !== undefined && filters.durationMaxDays !== null) ? (
|
||||
<div className="badge badge-outline">Duration: {filters.durationMinDays ?? 0}–{filters.durationMaxDays ?? '∞'} days</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<BacktestTable
|
||||
list={backtests as LightBacktestResponse[]} // Cast to any for backward compatibility
|
||||
isFetching={isLoading}
|
||||
onSortChange={handleSortChange}
|
||||
currentSort={currentSort}
|
||||
filters={filters}
|
||||
onFiltersChange={handleFiltersChange}
|
||||
onBacktestDeleted={() => {
|
||||
// Invalidate backtest queries when a backtest is deleted
|
||||
queryClient.invalidateQueries({ queryKey: ['backtests'] })
|
||||
|
||||
Reference in New Issue
Block a user