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:
co-authored by
Claude Opus 4.8
parent
eaccdadddf
commit
02c5a4d6f4
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user