diff --git a/src/Predictalytics.Api/Endpoints/TraderEndpoints.cs b/src/Predictalytics.Api/Endpoints/TraderEndpoints.cs index 91869cf..5ce4c6a 100644 --- a/src/Predictalytics.Api/Endpoints/TraderEndpoints.cs +++ b/src/Predictalytics.Api/Endpoints/TraderEndpoints.cs @@ -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))); diff --git a/src/Predictalytics.Application.Tests/Services/ShowcaseBuilderTests.cs b/src/Predictalytics.Application.Tests/Services/ShowcaseBuilderTests.cs new file mode 100644 index 0000000..f9e5095 --- /dev/null +++ b/src/Predictalytics.Application.Tests/Services/ShowcaseBuilderTests.cs @@ -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 Ids(IReadOnlyList s, string key) + => s.FirstOrDefault(x => x.Key == key)?.Traders.Select(t => t.Id).ToList() ?? new List(); + + [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); + } +} diff --git a/src/Predictalytics.Application/Interfaces/IAnalyticsService.cs b/src/Predictalytics.Application/Interfaces/IAnalyticsService.cs index 3117f3d..2e3aeba 100644 --- a/src/Predictalytics.Application/Interfaces/IAnalyticsService.cs +++ b/src/Predictalytics.Application/Interfaces/IAnalyticsService.cs @@ -10,7 +10,10 @@ public interface IAnalyticsService /// Perform deep-dive analysis on a specific trader. Task GetTraderDeepDiveAsync(int traderId, CancellationToken ct = default); - Task> GetTradersAsync(int skip = 0, int take = 50, string? platform = null, bool highlyCopyable = false, string? traitFilter = null, CancellationToken ct = default); + Task> GetTradersAsync(int skip = 0, int take = 50, string? platform = null, bool highlyCopyable = false, string? traitFilter = null, string? sort = null, CancellationToken ct = default); + + /// Curated dashboard showcase sections (copy-ready, smooth operators, rising stars, ...). + Task> GetShowcasesAsync(CancellationToken ct = default); /// Get list of all discovered traits. Task> GetTraitsAsync(CancellationToken ct = default); diff --git a/src/Predictalytics.Application/Services/AnalyticsService.cs b/src/Predictalytics.Application/Services/AnalyticsService.cs index 5757743..c5cd823 100644 --- a/src/Predictalytics.Application/Services/AnalyticsService.cs +++ b/src/Predictalytics.Application/Services/AnalyticsService.cs @@ -162,14 +162,14 @@ public class AnalyticsService : IAnalyticsService )).ToList(); } - public async Task> GetTradersAsync(int skip = 0, int take = 50, string? platform = null, bool highlyCopyable = false, string? traitFilter = null, CancellationToken ct = default) + public async Task> 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(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> 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(); + 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> GetTraitsAsync(CancellationToken ct = default) { // Actually this is unused since we mapped it straight to db context in the endpoint, diff --git a/src/Predictalytics.Application/Services/ShowcaseBuilder.cs b/src/Predictalytics.Application/Services/ShowcaseBuilder.cs new file mode 100644 index 0000000..a206baa --- /dev/null +++ b/src/Predictalytics.Application/Services/ShowcaseBuilder.cs @@ -0,0 +1,87 @@ +using Predictalytics.Application.DTOs; + +namespace Predictalytics.Application.Services; + +/// Selection inputs for one trader (display DTO + the metrics the showcases rank on). +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); + +/// One curated dashboard section. +public sealed record ShowcaseSection(string Key, string Title, string Description, IReadOnlyList Traders); + +/// +/// 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. +/// +public static class ShowcaseBuilder +{ + private const int PerSection = 8; + + public static List Build(IReadOnlyList candidates) + { + var sections = new List(); + + 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 sections, string key, string title, string desc, + IEnumerable ordered) + { + var picks = ordered.Take(PerSection).Select(c => c.Dto).ToList(); + if (picks.Count > 0) + sections.Add(new ShowcaseSection(key, title, desc, picks)); + } +}