Add showcase leaderboards endpoint + sortable trader list

- GET /api/traders/showcases: curated dashboard sections (copy-ready, smooth
  operators, rising stars, high conviction, specialists, insider watch, red
  flags), each encoding a selection funnel over the persisted analytics/traits.
  Pure ShowcaseBuilder holds the ranking logic (+6 unit tests).
- GET /api/traders?sort=: leaderboard sort keys (pnl, pnl30d, winrate,
  copytrading, calmar, conviction, profitfactor) over the loaded set.
Read-only, persisted-data-only (public-tier safe). 76 tests, 1 skip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-23 09:43:11 +02:00
co-authored by Claude Opus 4.8
parent eaccdadddf
commit 02c5a4d6f4
5 changed files with 256 additions and 5 deletions
@@ -13,8 +13,12 @@ public static class TraderEndpoints
{
var group = routes.MapGroup("/api/traders").WithTags("Traders");
group.MapGet("/", async (IAnalyticsService svc, int? skip, int? take, string? platform, bool? highlyCopyable, string? trait, CancellationToken ct) =>
Results.Ok(await svc.GetTradersAsync(skip ?? 0, take ?? 50, platform, highlyCopyable ?? false, trait, ct)));
group.MapGet("/", async (IAnalyticsService svc, int? skip, int? take, string? platform, bool? highlyCopyable, string? trait, string? sort, CancellationToken ct) =>
Results.Ok(await svc.GetTradersAsync(skip ?? 0, take ?? 50, platform, highlyCopyable ?? false, trait, sort, ct)));
// Curated dashboard showcases (leaderboards). Read-only, persisted data only.
group.MapGet("/showcases", async (IAnalyticsService svc, CancellationToken ct) =>
Results.Ok(await svc.GetShowcasesAsync(ct)));
group.MapGet("/traits", async (Predictalytics.Infrastructure.Data.AppDbContext db, CancellationToken ct) =>
Results.Ok(await Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.ToListAsync(db.TraderTraits.Select(t => t.Trait).Distinct(), ct)));
@@ -0,0 +1,113 @@
using System.Collections.Generic;
using System.Linq;
using Predictalytics.Application.DTOs;
using Predictalytics.Application.Services;
using Xunit;
namespace Predictalytics.Application.Tests.Services;
public class ShowcaseBuilderTests
{
private static TraderDto Dto(int id) => new(
id, "Polymarket", "0x" + id, "T" + id, "Bronze", "Unknown",
CombinedScore: 0, CopytradingScore: 0, CopytradingQualityScore: 0, CopytradingCopyabilityScore: 0,
WinRate: 0, TotalPnl: 0, TotalTrades: 0, Trades30d: 0, PnL30d: 0, WinRate30d: 0, EstimatedBankroll: 0,
IsOnWatchlist: false, IsSuspectedBot: false, LastPolledAt: null, Traits: null);
private static ShowcaseCandidate Cand(
int id,
decimal copytrading = 0, decimal totalPnl = 0, decimal pnl30d = 0,
decimal? profitFactor = null, decimal? calmar = null, decimal maxDd = 0,
decimal? conviction = null, decimal concentration = 0,
bool bot = false, bool snapshot = false, int totalTrades = 0,
bool insider = false, bool farmer = false)
=> new(Dto(id), copytrading, totalPnl, pnl30d, profitFactor, calmar, maxDd,
conviction, concentration, bot, snapshot, totalTrades, insider, farmer);
private static List<int> Ids(IReadOnlyList<ShowcaseSection> s, string key)
=> s.FirstOrDefault(x => x.Key == key)?.Traders.Select(t => t.Id).ToList() ?? new List<int>();
[Fact]
public void CopyReady_IncludesQualified_ExcludesBotAndThinProfit()
{
var s = ShowcaseBuilder.Build(new[]
{
Cand(1, copytrading: 70, totalTrades: 50, profitFactor: 2.0m), // qualifies
Cand(2, copytrading: 70, totalTrades: 50, profitFactor: 2.0m, bot: true), // bot -> out
Cand(3, copytrading: 70, totalTrades: 50, profitFactor: 1.1m), // thin profit -> out
Cand(4, copytrading: 40, totalTrades: 50, profitFactor: 2.0m), // low score -> out
});
var ids = Ids(s, "copy_ready");
Assert.Contains(1, ids);
Assert.DoesNotContain(2, ids);
Assert.DoesNotContain(3, ids);
Assert.DoesNotContain(4, ids);
}
[Fact]
public void SmoothOperators_RanksByCalmar_RequiresDrawdown()
{
var s = ShowcaseBuilder.Build(new[]
{
Cand(1, totalPnl: 500, calmar: 5m, maxDd: 100m),
Cand(2, totalPnl: 500, calmar: 9m, maxDd: 50m),
Cand(3, totalPnl: 500, calmar: null, maxDd: 0m), // no drawdown recorded -> excluded
});
var ids = Ids(s, "smooth_operators");
Assert.Equal(new[] { 2, 1 }, ids); // higher calmar first
Assert.DoesNotContain(3, ids);
}
[Fact]
public void HighConviction_OnlyPositive()
{
var s = ShowcaseBuilder.Build(new[]
{
Cand(1, conviction: 25m),
Cand(2, conviction: -10m), // overbets losers -> out of conviction
Cand(3, conviction: null),
});
Assert.Equal(new[] { 1 }, Ids(s, "high_conviction"));
}
[Fact]
public void RedFlags_FlagsHighPnlWithNegativeConvictionOrFarming()
{
var s = ShowcaseBuilder.Build(new[]
{
Cand(1, totalPnl: 10000, conviction: -5m), // negative conviction
Cand(2, totalPnl: 20000, profitFactor: 1.05m), // thin profit factor
Cand(3, totalPnl: 30000, farmer: true), // resolution farmer
Cand(4, totalPnl: 50000, conviction: 10m, profitFactor: 3m), // clean whale -> not flagged
});
var ids = Ids(s, "red_flags");
Assert.Contains(1, ids);
Assert.Contains(2, ids);
Assert.Contains(3, ids);
Assert.DoesNotContain(4, ids);
}
[Fact]
public void InsiderWatch_OnlyPossibleInsiders()
{
var s = ShowcaseBuilder.Build(new[]
{
Cand(1, totalPnl: 5000, insider: true),
Cand(2, totalPnl: 9000, insider: false),
});
Assert.Equal(new[] { 1 }, Ids(s, "insider_watch"));
}
[Fact]
public void EmptySections_AreOmitted()
{
// No candidate qualifies for anything -> no sections at all.
var s = ShowcaseBuilder.Build(new[] { Cand(1) });
Assert.Empty(s);
}
}
@@ -10,7 +10,10 @@ public interface IAnalyticsService
/// <summary>Perform deep-dive analysis on a specific trader.</summary>
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, 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);
/// <summary>Curated dashboard showcase sections (copy-ready, smooth operators, rising stars, ...).</summary>
Task<IReadOnlyList<Services.ShowcaseSection>> GetShowcasesAsync(CancellationToken ct = default);
/// <summary>Get list of all discovered traits.</summary>
Task<IReadOnlyList<string>> GetTraitsAsync(CancellationToken ct = default);
@@ -162,14 +162,14 @@ public class AnalyticsService : IAnalyticsService
)).ToList();
}
public async Task<IReadOnlyList<TraderDto>> GetTradersAsync(int skip = 0, int take = 50, string? platform = null, bool highlyCopyable = false, string? traitFilter = 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, CancellationToken ct = default)
{
PlatformType? pType = null;
if (!string.IsNullOrEmpty(platform) && platform != "All" && Enum.TryParse<PlatformType>(platform, true, out var pt))
pType = pt;
var traders = await _traderRepo.GetAllAsync(platform: pType, skip: 0, take: 5000, ct: ct); // Get more to filter in memory
if (highlyCopyable)
{
traders = traders.Where(t => t.Analytics != null && t.Analytics.CopytradingScore >= 60).ToList();
@@ -180,6 +180,19 @@ public class AnalyticsService : IAnalyticsService
traders = traders.Where(t => t.Traits.Any(tr => tr.Trait == traitFilter)).ToList();
}
// Leaderboard sort keys (in memory — traders are already materialized above).
traders = (sort?.ToLowerInvariant()) switch
{
"pnl" => traders.OrderByDescending(t => t.TotalPnl).ToList(),
"pnl30d" => traders.OrderByDescending(t => t.Analytics?.PnL30d ?? 0).ToList(),
"winrate" => traders.OrderByDescending(t => t.WinRate).ToList(),
"copytrading" => traders.OrderByDescending(t => t.Analytics?.CopytradingScore ?? 0).ToList(),
"calmar" => traders.OrderByDescending(t => t.Analytics?.ReturnOverMaxDrawdown ?? decimal.MinValue).ToList(),
"conviction" => traders.OrderByDescending(t => t.Analytics?.ConvictionEdgePct ?? decimal.MinValue).ToList(),
"profitfactor" => traders.OrderByDescending(t => t.Analytics?.ProfitFactor ?? decimal.MinValue).ToList(),
_ => traders // default: repository order (CombinedScore, then PnL)
};
traders = traders.Skip(skip).Take(take).ToList();
var watchlist = await _watchlistRepo.GetAllAsync(ct);
@@ -187,6 +200,37 @@ public class AnalyticsService : IAnalyticsService
return traders.Select(t => MapTraderDto(t, wIds)).ToList();
}
public async Task<IReadOnlyList<ShowcaseSection>> GetShowcasesAsync(CancellationToken ct = default)
{
// Candidate pool = the top traders by combined score (repository default order).
var traders = await _traderRepo.GetAllAsync(take: 2000, ct: ct);
var watchlist = await _watchlistRepo.GetAllAsync(ct);
var wIds = watchlist.Select(w => w.TraderId).ToHashSet();
var candidates = traders.Select(t =>
{
var a = t.Analytics;
var traitNames = t.Traits?.Select(x => x.Trait).ToHashSet() ?? new HashSet<string>();
return new ShowcaseCandidate(
MapTraderDto(t, wIds),
a?.CopytradingScore ?? 0,
t.TotalPnl,
a?.PnL30d ?? 0,
a?.ProfitFactor,
a?.ReturnOverMaxDrawdown,
a?.MaxDrawdownUsd ?? 0,
a?.ConvictionEdgePct,
a?.CategoryConcentration ?? 0,
t.IsSuspectedBot,
t.IngestMode == Domain.Enums.IngestMode.SnapshotOnly,
t.TotalTrades,
traitNames.Contains("possible_insider"),
traitNames.Contains("resolution_farming"));
}).ToList();
return ShowcaseBuilder.Build(candidates);
}
public Task<IReadOnlyList<string>> GetTraitsAsync(CancellationToken ct = default)
{
// Actually this is unused since we mapped it straight to db context in the endpoint,
@@ -0,0 +1,87 @@
using Predictalytics.Application.DTOs;
namespace Predictalytics.Application.Services;
/// <summary>Selection inputs for one trader (display DTO + the metrics the showcases rank on).</summary>
public sealed record ShowcaseCandidate(
TraderDto Dto,
decimal CopytradingScore,
decimal TotalPnl,
decimal Pnl30d,
decimal? ProfitFactor,
decimal? ReturnOverMaxDrawdown,
decimal MaxDrawdownUsd,
decimal? ConvictionEdgePct,
decimal CategoryConcentration,
bool IsSuspectedBot,
bool IsSnapshotOnly,
int TotalTrades,
bool IsPossibleInsider,
bool IsResolutionFarmer);
/// <summary>One curated dashboard section.</summary>
public sealed record ShowcaseSection(string Key, string Title, string Description, IReadOnlyList<TraderDto> Traders);
/// <summary>
/// Builds the curated "showcase" sections for the dashboard — opinionated, editorial lists that
/// encode the actual selection funnel so a promising trader can be spotted at a glance instead of
/// hand-filtering. Pure function; the ranking IP lives here and is unit-tested.
/// </summary>
public static class ShowcaseBuilder
{
private const int PerSection = 8;
public static List<ShowcaseSection> Build(IReadOnlyList<ShowcaseCandidate> candidates)
{
var sections = new List<ShowcaseSection>();
Add(sections, "copy_ready", "Copy-Ready",
"Solide Kandidaten: gute Kopierbarkeit, Profit-Faktor > 1,3, kein Bot.",
candidates.Where(c => !c.IsSuspectedBot && !c.IsSnapshotOnly
&& c.CopytradingScore >= 50 && c.TotalTrades >= 20
&& (c.ProfitFactor ?? 0m) > 1.3m)
.OrderByDescending(c => c.CopytradingScore));
Add(sections, "smooth_operators", "Smooth Operators",
"Höchste risiko-adjustierte Rendite (Gewinn je Einheit Drawdown).",
candidates.Where(c => c.TotalPnl > 0m && c.MaxDrawdownUsd > 0m && c.ReturnOverMaxDrawdown.HasValue)
.OrderByDescending(c => c.ReturnOverMaxDrawdown!.Value));
Add(sections, "rising_stars", "Rising Stars",
"Stärkster Gewinn der letzten 30 Tage.",
candidates.Where(c => !c.IsSuspectedBot && c.Pnl30d > 0m)
.OrderByDescending(c => c.Pnl30d));
Add(sections, "high_conviction", "High Conviction",
"Ihre größten Wetten schlagen die kleinsten — das Sizing trägt Information.",
candidates.Where(c => !c.IsSuspectedBot && (c.ConvictionEdgePct ?? 0m) > 0m)
.OrderByDescending(c => c.ConvictionEdgePct!.Value));
Add(sections, "specialists", "Specialists",
"Fokussiert auf eine Kategorie und dabei profitabel.",
candidates.Where(c => c.TotalPnl > 0m && c.CategoryConcentration >= 0.5m)
.OrderByDescending(c => c.CategoryConcentration));
Add(sections, "insider_watch", "Insider Watch",
"Selten aktiv, große Einsätze, statistisch unplausibel gute Longshot-Treffer.",
candidates.Where(c => c.IsPossibleInsider)
.OrderByDescending(c => c.TotalPnl));
Add(sections, "red_flags", "⚠ Red Flags",
"Hoher PnL, aber verdächtig: überbietet Verlierer, dünne Marge, Bot oder Resolution-Farming.",
candidates.Where(c => c.TotalPnl > 5000m &&
((c.ConvictionEdgePct ?? 0m) < 0m || (c.ProfitFactor ?? 99m) < 1.1m
|| c.IsSuspectedBot || c.IsResolutionFarmer))
.OrderByDescending(c => c.TotalPnl));
return sections;
}
private static void Add(List<ShowcaseSection> sections, string key, string title, string desc,
IEnumerable<ShowcaseCandidate> ordered)
{
var picks = ordered.Take(PerSection).Select(c => c.Dto).ToList();
if (picks.Count > 0)
sections.Add(new ShowcaseSection(key, title, desc, picks));
}
}