@
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:
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user