UI-U3: server-side min-winrate/min-copyability filters

Add optional minWinRate/minCopyability params to GetTradersAsync so the
leaderboard min-value filters see the full pool instead of being applied
client-side to only the first page of results. Covered by a new
AnalyticsService test (three traders, both filters).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
This commit is contained in:
Richard
2026-07-23 15:56:58 +02:00
parent 02c5a4d6f4
commit d8c204995a
4 changed files with 60 additions and 4 deletions
@@ -13,8 +13,8 @@ public static class TraderEndpoints
{ {
var group = routes.MapGroup("/api/traders").WithTags("Traders"); var group = routes.MapGroup("/api/traders").WithTags("Traders");
group.MapGet("/", async (IAnalyticsService svc, int? skip, int? take, string? platform, bool? highlyCopyable, string? trait, string? sort, CancellationToken ct) => group.MapGet("/", async (IAnalyticsService svc, int? skip, int? take, string? platform, bool? highlyCopyable, string? trait, string? sort, decimal? minWinRate, decimal? minCopyability, CancellationToken ct) =>
Results.Ok(await svc.GetTradersAsync(skip ?? 0, take ?? 50, platform, highlyCopyable ?? false, trait, sort, ct))); Results.Ok(await svc.GetTradersAsync(skip ?? 0, take ?? 50, platform, highlyCopyable ?? false, trait, sort, minWinRate, minCopyability, ct)));
// Curated dashboard showcases (leaderboards). Read-only, persisted data only. // Curated dashboard showcases (leaderboards). Read-only, persisted data only.
group.MapGet("/showcases", async (IAnalyticsService svc, CancellationToken ct) => group.MapGet("/showcases", async (IAnalyticsService svc, CancellationToken ct) =>
@@ -101,6 +101,51 @@ public class AnalyticsServiceTests
Assert.Equal(87.5m, deepDive.EntryQuality); Assert.Equal(87.5m, deepDive.EntryQuality);
} }
[Fact]
public async Task GetTradersAsync_MinFilters_ExcludeBelowThreshold()
{
// Arrange
using var db = CreateDbContext();
var traderRepo = new TraderRepository(db);
var tradeRepo = new TradeRepository(db, NullLogger<TradeRepository>.Instance);
var discoveryMock = new Mock<IDiscoveryService>();
var pnlEngineMock = new Mock<IPositionPnLEngine>();
var alertRepoMock = new Mock<IAlertRepository>();
var watchlistRepoMock = new Mock<IWatchlistRepository>();
watchlistRepoMock.Setup(w => w.GetAllAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<WatchlistEntry>());
var marketRepo = new MarketRepository(db);
var providers = new List<IPlatformProvider>();
var svc = new AnalyticsService(
traderRepo, tradeRepo, alertRepoMock.Object, watchlistRepoMock.Object,
marketRepo, discoveryMock.Object, providers, pnlEngineMock.Object,
NullLogger<AnalyticsService>.Instance);
// Three traders: WinRate 40 / 60 / 80, Copyability 30 / 55 / 90.
db.Traders.Add(new Trader { Id = 1, PlatformUserId = "0xA", DisplayName = "Low", WinRate = 40m,
Analytics = new TraderAnalytics { TraderId = 1, CopytradingCopyabilityScore = 30m } });
db.Traders.Add(new Trader { Id = 2, PlatformUserId = "0xB", DisplayName = "Mid", WinRate = 60m,
Analytics = new TraderAnalytics { TraderId = 2, CopytradingCopyabilityScore = 55m } });
db.Traders.Add(new Trader { Id = 3, PlatformUserId = "0xC", DisplayName = "High", WinRate = 80m,
Analytics = new TraderAnalytics { TraderId = 3, CopytradingCopyabilityScore = 90m } });
await db.SaveChangesAsync();
// Act — minWinRate 55 keeps Mid + High.
var byWinRate = await svc.GetTradersAsync(minWinRate: 55m);
// minCopyability 60 keeps only High.
var byCopyability = await svc.GetTradersAsync(minCopyability: 60m);
// No filter keeps all three.
var unfiltered = await svc.GetTradersAsync();
// Assert
Assert.Equal(2, byWinRate.Count);
Assert.DoesNotContain(byWinRate, t => t.DisplayName == "Low");
Assert.Single(byCopyability);
Assert.Equal("High", byCopyability[0].DisplayName);
Assert.Equal(3, unfiltered.Count);
}
private class MockDiscoveryService : IDiscoveryService private class MockDiscoveryService : IDiscoveryService
{ {
public Task<int> ImportTraderAsync(PlatformType platform, string platformUserId, string displayName, bool isAutoDiscovered = true, CancellationToken ct = default) public Task<int> ImportTraderAsync(PlatformType platform, string platformUserId, string displayName, bool isAutoDiscovered = true, CancellationToken ct = default)
@@ -10,7 +10,7 @@ public interface IAnalyticsService
/// <summary>Perform deep-dive analysis on a specific trader.</summary> /// <summary>Perform deep-dive analysis on a specific trader.</summary>
Task<TraderDeepDiveDto?> GetTraderDeepDiveAsync(int traderId, CancellationToken ct = default); Task<TraderDeepDiveDto?> GetTraderDeepDiveAsync(int traderId, CancellationToken ct = default);
Task<IReadOnlyList<TraderDto>> GetTradersAsync(int skip = 0, int take = 50, string? platform = null, bool highlyCopyable = false, string? traitFilter = null, string? sort = null, CancellationToken ct = default); Task<IReadOnlyList<TraderDto>> GetTradersAsync(int skip = 0, int take = 50, string? platform = null, bool highlyCopyable = false, string? traitFilter = null, string? sort = null, decimal? minWinRate = null, decimal? minCopyability = null, CancellationToken ct = default);
/// <summary>Curated dashboard showcase sections (copy-ready, smooth operators, rising stars, ...).</summary> /// <summary>Curated dashboard showcase sections (copy-ready, smooth operators, rising stars, ...).</summary>
Task<IReadOnlyList<Services.ShowcaseSection>> GetShowcasesAsync(CancellationToken ct = default); Task<IReadOnlyList<Services.ShowcaseSection>> GetShowcasesAsync(CancellationToken ct = default);
@@ -162,7 +162,7 @@ public class AnalyticsService : IAnalyticsService
)).ToList(); )).ToList();
} }
public async Task<IReadOnlyList<TraderDto>> GetTradersAsync(int skip = 0, int take = 50, string? platform = null, bool highlyCopyable = false, string? traitFilter = null, string? sort = null, CancellationToken ct = default) public async Task<IReadOnlyList<TraderDto>> GetTradersAsync(int skip = 0, int take = 50, string? platform = null, bool highlyCopyable = false, string? traitFilter = null, string? sort = null, decimal? minWinRate = null, decimal? minCopyability = null, CancellationToken ct = default)
{ {
PlatformType? pType = null; PlatformType? pType = null;
if (!string.IsNullOrEmpty(platform) && platform != "All" && Enum.TryParse<PlatformType>(platform, true, out var pt)) if (!string.IsNullOrEmpty(platform) && platform != "All" && Enum.TryParse<PlatformType>(platform, true, out var pt))
@@ -180,6 +180,17 @@ public class AnalyticsService : IAnalyticsService
traders = traders.Where(t => t.Traits.Any(tr => tr.Trait == traitFilter)).ToList(); traders = traders.Where(t => t.Traits.Any(tr => tr.Trait == traitFilter)).ToList();
} }
// Min-value filters applied server-side so they see the full pool, not just the first page.
if (minWinRate is > 0)
{
traders = traders.Where(t => t.WinRate >= minWinRate.Value).ToList();
}
if (minCopyability is > 0)
{
traders = traders.Where(t => (t.Analytics?.CopytradingCopyabilityScore ?? 0) >= minCopyability.Value).ToList();
}
// Leaderboard sort keys (in memory — traders are already materialized above). // Leaderboard sort keys (in memory — traders are already materialized above).
traders = (sort?.ToLowerInvariant()) switch traders = (sort?.ToLowerInvariant()) switch
{ {