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
@@ -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);
}
}