From 2eaa67fae2cd7b16613e4754c4cd7bbd5424a269 Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 3 Aug 2026 19:45:35 +0200 Subject: [PATCH] @ #4 Copy-portfolio: diversified master mix instead of single-score ranking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The leaderboard ranks single scores, but the top N can be three correlated weather bots. Suggest a de-clumped set instead: - Pure CopyPortfolioBuilder (Application): greedily picks high-scoring masters while enforcing a per-category cap and a max pairwise correlation; reports who was dropped for correlation vs the category cap. - GET /api/portfolio/suggest?size=8&maxPerCategory=2&maxSimilarity=0.6 builds the position-overlap similarity (signed market sets) among copy-relevant masters and runs the diversifier. New PortfolioEndpoints group. - UI: a "Copy-Portfolio" nav page — the diversified picks with category, score, copyability, PnL and the pick reason, plus a funnel summary (candidates / dropped for correlation / dropped for category cap). - Tests: top-score order, category cap, correlation drop, size limit. Co-Authored-By: Claude Opus 4.8 @ --- src/Predictalytics.Api/ApiConfiguration.cs | 1 + .../Endpoints/PortfolioEndpoints.cs | 84 +++++++++++++++++++ src/Predictalytics.Api/wwwroot/css/style.css | 15 ++++ src/Predictalytics.Api/wwwroot/index.html | 34 ++++++++ src/Predictalytics.Api/wwwroot/js/app.js | 36 ++++++++ .../Services/CopyPortfolioBuilderTests.cs | 68 +++++++++++++++ .../DTOs/TraderDto.cs | 18 ++++ .../Services/CopyPortfolioBuilder.cs | 63 ++++++++++++++ 8 files changed, 319 insertions(+) create mode 100644 src/Predictalytics.Api/Endpoints/PortfolioEndpoints.cs create mode 100644 src/Predictalytics.Application.Tests/Services/CopyPortfolioBuilderTests.cs create mode 100644 src/Predictalytics.Application/Services/CopyPortfolioBuilder.cs diff --git a/src/Predictalytics.Api/ApiConfiguration.cs b/src/Predictalytics.Api/ApiConfiguration.cs index 7afa1d7..ff31107 100644 --- a/src/Predictalytics.Api/ApiConfiguration.cs +++ b/src/Predictalytics.Api/ApiConfiguration.cs @@ -55,6 +55,7 @@ public static class ApiConfiguration routes.MapMarketEndpoints(); routes.MapSearchEndpoints(); routes.MapWatchlistEndpoints(); + routes.MapPortfolioEndpoints(); routes.MapGet("/api/health", () => Results.Ok(new { Status = "OK", Timestamp = DateTime.UtcNow })); routes.MapGet("/api/capabilities", (IConfiguration config) => diff --git a/src/Predictalytics.Api/Endpoints/PortfolioEndpoints.cs b/src/Predictalytics.Api/Endpoints/PortfolioEndpoints.cs new file mode 100644 index 0000000..4c5988b --- /dev/null +++ b/src/Predictalytics.Api/Endpoints/PortfolioEndpoints.cs @@ -0,0 +1,84 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using Predictalytics.Application.DTOs; +using Predictalytics.Application.Services; +using Predictalytics.Infrastructure.Data; + +namespace Predictalytics.Api.Endpoints; + +public static class PortfolioEndpoints +{ + public static void MapPortfolioEndpoints(this IEndpointRouteBuilder routes) + { + var group = routes.MapGroup("/api/portfolio").WithTags("Portfolio"); + + // Diversified copy-portfolio suggestion (#4): top masters, de-clumped by pairwise position + // correlation and capped per category, so the copied set actually spreads risk. + group.MapGet("/suggest", async (int? size, int? maxPerCategory, double? maxSimilarity, decimal? minScore, + AppDbContext db, CancellationToken ct) => + { + var minScoreV = minScore ?? 50m; + var candidatesRaw = await db.Traders + .Include(t => t.Analytics) + .Include(t => t.CategoryPerformances) + .Where(t => t.Analytics != null && t.Analytics.CopytradingScore >= minScoreV && !t.IsSuspectedBot) + .OrderByDescending(t => t.Analytics!.CopytradingScore) + .Take(60) + .ToListAsync(ct); + + if (candidatesRaw.Count == 0) + return Results.Ok(new CopyPortfolioDto(System.Array.Empty(), 0, 0, 0)); + + var ids = candidatesRaw.Select(t => t.Id).ToList(); + + // Signed-market sets per trader (marketId:direction) → position-overlap similarity. + var positions = await db.TraderPositions + .Where(p => ids.Contains(p.TraderId) && p.MarketOutcome != null && p.SharesHeld != 0) + .Select(p => new { p.TraderId, p.MarketOutcome!.MarketId, Dir = p.SharesHeld > 0 ? 1 : -1 }) + .ToListAsync(ct); + + var sets = positions + .GroupBy(p => p.TraderId) + .ToDictionary(g => g.Key, g => g.Select(x => $"{x.MarketId}:{x.Dir}").ToHashSet()); + + double Similarity(int a, int b) + { + if (!sets.TryGetValue(a, out var sa) || !sets.TryGetValue(b, out var sb) || sa.Count == 0 || sb.Count == 0) + return 0; + var inter = sa.Count(sb.Contains); + return (double)inter / System.Math.Min(sa.Count, sb.Count); + } + + var candidates = candidatesRaw + .Select(t => new PortfolioCandidate(t.Id, t.Analytics!.CopytradingScore, PrimaryCategory(t))) + .ToList(); + + var result = CopyPortfolioBuilder.Build( + candidates, Similarity, + size ?? 8, maxPerCategory ?? 2, maxSimilarity ?? 0.6); + + var byId = candidatesRaw.ToDictionary(t => t.Id); + var picks = result.Picks.Select(p => + { + var t = byId[p.TraderId]; + return new PortfolioPickDto( + p.TraderId, t.DisplayName, t.Platform.ToString(), p.Score, + t.Analytics?.CopytradingCopyabilityScore ?? 0, t.TotalPnl, p.Category, p.Reason); + }).ToList(); + + return Results.Ok(new CopyPortfolioDto( + picks, candidatesRaw.Count, + result.DroppedForCorrelation.Count, result.DroppedForCategoryCap.Count)); + }); + } + + private static string PrimaryCategory(Predictalytics.Domain.Entities.Trader t) + { + var perfs = t.CategoryPerformances; + if (perfs == null || perfs.Count == 0) return "Unbekannt"; + var top = perfs.OrderByDescending(p => p.TotalVolume).First(); + return top.TotalVolume > 0 ? top.Category.ToString() : "Unbekannt"; + } +} diff --git a/src/Predictalytics.Api/wwwroot/css/style.css b/src/Predictalytics.Api/wwwroot/css/style.css index 5c09058..c79b986 100644 --- a/src/Predictalytics.Api/wwwroot/css/style.css +++ b/src/Predictalytics.Api/wwwroot/css/style.css @@ -1275,3 +1275,18 @@ a:hover { color: #8ab8ff; } .comovement-lead { font-size: 12px; font-weight: 700; white-space: nowrap; } .lead-before { color: var(--success); } .lead-after { color: var(--text-muted); } + +/* ─── Copy-portfolio (#4) ─── */ +.portfolio-summary { + display: flex; + flex-wrap: wrap; + gap: 16px; + margin-bottom: 16px; +} +.portfolio-stat { + font-size: 13px; + color: var(--text-secondary); +} +.portfolio-stat strong { color: var(--text-primary); } +.portfolio-drop { color: var(--text-muted); } +.portfolio-reason { font-size: 12px; color: var(--text-muted); } diff --git a/src/Predictalytics.Api/wwwroot/index.html b/src/Predictalytics.Api/wwwroot/index.html index b4a409f..765f4e4 100644 --- a/src/Predictalytics.Api/wwwroot/index.html +++ b/src/Predictalytics.Api/wwwroot/index.html @@ -40,6 +40,10 @@ Insider + + + Copy-Portfolio + Märkte @@ -355,6 +359,36 @@ + +
+
+
+

Copy-Portfolio

+
Diversifizierter Master-Mix statt Einzel-Ranking: die Rangliste wird nach Positions-Korrelation entklumpt und pro Kategorie gedeckelt, damit das kopierte Set das Risiko wirklich streut.
+
+
+
+
+
+ + + + + + + + + + + + + + +
#MasterPlattformKategorieScoreCopyabilityPnLBegründung
+
+
+
+
diff --git a/src/Predictalytics.Api/wwwroot/js/app.js b/src/Predictalytics.Api/wwwroot/js/app.js index df5b2ca..0c32d1f 100644 --- a/src/Predictalytics.Api/wwwroot/js/app.js +++ b/src/Predictalytics.Api/wwwroot/js/app.js @@ -29,6 +29,7 @@ document.querySelectorAll('.nav-item[data-page]').forEach(item => { if (page === 'jobs') loadJobs(); if (page === 'watchlist') loadWatchlist(); if (page === 'insiders') loadInsiders(); + if (page === 'portfolio') loadPortfolio(); }); }); @@ -570,6 +571,41 @@ async function loadTraderDrift(id) { banner.style.display = 'block'; } +async function loadPortfolio() { + const tbody = document.getElementById('portfolioBody'); + const summary = document.getElementById('portfolioSummary'); + const data = await api('/api/portfolio/suggest'); + if (!data || !Array.isArray(data.picks)) { + summary.innerHTML = ''; + tbody.innerHTML = '⚠ Portfolio konnte nicht geladen werden (siehe Konsole / Server-Log).'; + return; + } + if (data.picks.length === 0) { + summary.innerHTML = ''; + tbody.innerHTML = '

Keine copy-fähigen Master gefunden (Score-Schwelle 50).

'; + return; + } + + summary.innerHTML = ` + ${data.picks.length} Master ausgewählt + aus ${data.candidatesConsidered} Kandidaten + ${data.droppedForCorrelation} wegen Korrelation verworfen + ${data.droppedForCategoryCap} wegen Kategorie-Deckel verworfen`; + + tbody.innerHTML = data.picks.map((p, i) => ` + + ${i + 1} + ${p.displayName} + ${p.platform} + ${p.category} + ${Number(p.score).toFixed(1)} + ${Number(p.copytradingCopyabilityScore || 0).toFixed(1)} + ${fmt.pnl(p.totalPnl)} + ${p.reason} + + `).join(''); +} + async function loadInsiders() { const tbody = document.getElementById('insidersBody'); const data = await api('/api/traders/insiders'); diff --git a/src/Predictalytics.Application.Tests/Services/CopyPortfolioBuilderTests.cs b/src/Predictalytics.Application.Tests/Services/CopyPortfolioBuilderTests.cs new file mode 100644 index 0000000..4f7a722 --- /dev/null +++ b/src/Predictalytics.Application.Tests/Services/CopyPortfolioBuilderTests.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using System.Linq; +using Predictalytics.Application.Services; +using Xunit; + +namespace Predictalytics.Application.Tests.Services; + +public class CopyPortfolioBuilderTests +{ + private static readonly System.Func NoCorrelation = (_, _) => 0.0; + + [Fact] + public void PicksTopScoresFirst_WhenNoConstraintsBind() + { + var candidates = new List + { + new(1, 90m, "Politics"), + new(2, 80m, "Sports"), + new(3, 70m, "Crypto"), + }; + var r = CopyPortfolioBuilder.Build(candidates, NoCorrelation, size: 8, maxPerCategory: 2); + Assert.Equal(new[] { 1, 2, 3 }, r.Picks.Select(p => p.TraderId).ToArray()); + } + + [Fact] + public void EnforcesCategoryCap() + { + // Three Politics masters, cap 2 -> the third (lowest) is dropped for the cap. + var candidates = new List + { + new(1, 90m, "Politics"), + new(2, 85m, "Politics"), + new(3, 80m, "Politics"), + new(4, 70m, "Sports"), + }; + var r = CopyPortfolioBuilder.Build(candidates, NoCorrelation, size: 8, maxPerCategory: 2); + + Assert.Equal(new[] { 1, 2, 4 }, r.Picks.Select(p => p.TraderId).ToArray()); + Assert.Contains(3, r.DroppedForCategoryCap); + } + + [Fact] + public void DropsHighlyCorrelatedCandidate() + { + var candidates = new List + { + new(1, 90m, "Politics"), + new(2, 85m, "Sports"), // highly correlated with #1 -> dropped + new(3, 80m, "Crypto"), + }; + // 1 and 2 move together; everyone else independent. + double Sim(int a, int b) => (a == 1 && b == 2) || (a == 2 && b == 1) ? 0.9 : 0.0; + + var r = CopyPortfolioBuilder.Build(candidates, Sim, size: 8, maxPerCategory: 2, maxSimilarity: 0.6); + + Assert.Equal(new[] { 1, 3 }, r.Picks.Select(p => p.TraderId).ToArray()); + Assert.Contains(2, r.DroppedForCorrelation); + } + + [Fact] + public void RespectsSizeLimit() + { + var candidates = Enumerable.Range(1, 10) + .Select(i => new PortfolioCandidate(i, 100m - i, $"Cat{i}")).ToList(); + var r = CopyPortfolioBuilder.Build(candidates, NoCorrelation, size: 3, maxPerCategory: 2); + Assert.Equal(3, r.Picks.Count); + } +} diff --git a/src/Predictalytics.Application/DTOs/TraderDto.cs b/src/Predictalytics.Application/DTOs/TraderDto.cs index 8e80dec..c98b1c6 100644 --- a/src/Predictalytics.Application/DTOs/TraderDto.cs +++ b/src/Predictalytics.Application/DTOs/TraderDto.cs @@ -148,6 +148,24 @@ public record TraderCorrelationDto( decimal AgreementRatio ); +/// One master in the suggested diversified copy portfolio (#4). +public record PortfolioPickDto( + int TraderId, + string DisplayName, + string Platform, + decimal Score, + decimal CopytradingCopyabilityScore, + decimal TotalPnl, + string Category, + string Reason); + +/// The suggested diversified copy portfolio plus how the funnel narrowed (#4). +public record CopyPortfolioDto( + IReadOnlyList Picks, + int CandidatesConsidered, + int DroppedForCorrelation, + int DroppedForCategoryCap); + /// A wallet that co-moves with a seed trader (smart-money discovery, #1). public record CoMovingWalletDto( int TraderId, diff --git a/src/Predictalytics.Application/Services/CopyPortfolioBuilder.cs b/src/Predictalytics.Application/Services/CopyPortfolioBuilder.cs new file mode 100644 index 0000000..f2f5c69 --- /dev/null +++ b/src/Predictalytics.Application/Services/CopyPortfolioBuilder.cs @@ -0,0 +1,63 @@ +namespace Predictalytics.Application.Services; + +/// A master eligible for the copy portfolio. +public sealed record PortfolioCandidate(int TraderId, decimal Score, string Category); + +/// A master selected into the diversified portfolio, with the reason it was picked. +public sealed record PortfolioPick(int TraderId, decimal Score, string Category, string Reason); + +/// Output of the diversifier: the picks plus who was dropped and why. +public sealed record CopyPortfolioResult( + IReadOnlyList Picks, + IReadOnlyList DroppedForCorrelation, + IReadOnlyList DroppedForCategoryCap); + +/// +/// Builds a diversified copy portfolio (#4): instead of blindly taking the top-N single scores +/// (which can be three correlated weather bots), greedily pick high-scoring masters while enforcing +/// a per-category cap and a max pairwise correlation, so the copied set actually spreads risk. +/// Pure — the similarity lookup is injected — and fully unit-tested. +/// +public static class CopyPortfolioBuilder +{ + public static CopyPortfolioResult Build( + IReadOnlyList candidates, + Func similarity, + int size = 8, + int maxPerCategory = 2, + double maxSimilarity = 0.6) + { + var picks = new List(); + var droppedCorr = new List(); + var droppedCat = new List(); + var perCategory = new Dictionary(); + + foreach (var c in candidates.OrderByDescending(c => c.Score)) + { + if (picks.Count >= size) break; + + var catCount = perCategory.GetValueOrDefault(c.Category); + if (catCount >= maxPerCategory) + { + droppedCat.Add(c.TraderId); + continue; + } + + // Reject if too correlated with anyone already picked. + var clash = picks.FirstOrDefault(p => similarity(c.TraderId, p.TraderId) >= maxSimilarity); + if (clash is not null) + { + droppedCorr.Add(c.TraderId); + continue; + } + + var reason = catCount == 0 + ? $"Score {c.Score:F0} · erste {c.Category}-Position" + : $"Score {c.Score:F0} · diversifiziert in {c.Category}"; + picks.Add(new PortfolioPick(c.TraderId, c.Score, c.Category, reason)); + perCategory[c.Category] = catCount + 1; + } + + return new CopyPortfolioResult(picks, droppedCorr, droppedCat); + } +}