#4 Copy-portfolio: diversified master mix instead of single-score ranking

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 <noreply@anthropic.com>
@
This commit is contained in:
Richard
2026-08-03 19:45:35 +02:00
parent 351e938769
commit 2eaa67fae2
8 changed files with 319 additions and 0 deletions
@@ -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) =>
@@ -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<PortfolioPickDto>(), 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";
}
}
@@ -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); }
+34
View File
@@ -40,6 +40,10 @@
<div class="nav-dot"></div>
<span>Insider</span>
</a>
<a href="#" class="nav-item" data-page="portfolio">
<div class="nav-dot"></div>
<span>Copy-Portfolio</span>
</a>
<a href="#" class="nav-item" data-page="markets">
<div class="nav-dot"></div>
<span>Märkte</span>
@@ -355,6 +359,36 @@
</div>
</section>
<!-- 3c. Copy-Portfolio View -->
<section class="page" id="page-portfolio">
<div class="page-title-wrap">
<div>
<h1 class="page-title">Copy-Portfolio</h1>
<div class="page-subtitle">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.</div>
</div>
</div>
<div id="portfolioSummary" class="portfolio-summary"></div>
<div class="card">
<div class="table-wrap">
<table class="data-table" id="portfolioTable">
<thead>
<tr>
<th>#</th>
<th>Master</th>
<th>Plattform</th>
<th>Kategorie</th>
<th class="num-col">Score</th>
<th class="num-col">Copyability</th>
<th class="num-col">PnL</th>
<th>Begründung</th>
</tr>
</thead>
<tbody id="portfolioBody"></tbody>
</table>
</div>
</div>
</section>
<!-- 4. Markets List View -->
<section class="page" id="page-markets">
<div class="page-title-wrap">
+36
View File
@@ -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 = '<tr><td colspan="8">⚠ Portfolio konnte nicht geladen werden (siehe Konsole / Server-Log).</td></tr>';
return;
}
if (data.picks.length === 0) {
summary.innerHTML = '';
tbody.innerHTML = '<tr><td colspan="8"><div class="empty-state"><p>Keine copy-fähigen Master gefunden (Score-Schwelle 50).</p></div></td></tr>';
return;
}
summary.innerHTML = `
<span class="portfolio-stat"><strong>${data.picks.length}</strong> Master ausgewählt</span>
<span class="portfolio-stat">aus <strong>${data.candidatesConsidered}</strong> Kandidaten</span>
<span class="portfolio-stat portfolio-drop">${data.droppedForCorrelation} wegen Korrelation verworfen</span>
<span class="portfolio-stat portfolio-drop">${data.droppedForCategoryCap} wegen Kategorie-Deckel verworfen</span>`;
tbody.innerHTML = data.picks.map((p, i) => `
<tr onclick="viewTrader(${p.traderId})">
<td>${i + 1}</td>
<td><strong>${p.displayName}</strong></td>
<td>${p.platform}</td>
<td><span class="tier-badge tier-unknown">${p.category}</span></td>
<td class="num-col"><strong>${Number(p.score).toFixed(1)}</strong></td>
<td class="num-col">${Number(p.copytradingCopyabilityScore || 0).toFixed(1)}</td>
<td class="num-col">${fmt.pnl(p.totalPnl)}</td>
<td class="portfolio-reason">${p.reason}</td>
</tr>
`).join('');
}
async function loadInsiders() {
const tbody = document.getElementById('insidersBody');
const data = await api('/api/traders/insiders');
@@ -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<int, int, double> NoCorrelation = (_, _) => 0.0;
[Fact]
public void PicksTopScoresFirst_WhenNoConstraintsBind()
{
var candidates = new List<PortfolioCandidate>
{
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<PortfolioCandidate>
{
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<PortfolioCandidate>
{
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);
}
}
@@ -148,6 +148,24 @@ public record TraderCorrelationDto(
decimal AgreementRatio
);
/// <summary>One master in the suggested diversified copy portfolio (#4).</summary>
public record PortfolioPickDto(
int TraderId,
string DisplayName,
string Platform,
decimal Score,
decimal CopytradingCopyabilityScore,
decimal TotalPnl,
string Category,
string Reason);
/// <summary>The suggested diversified copy portfolio plus how the funnel narrowed (#4).</summary>
public record CopyPortfolioDto(
IReadOnlyList<PortfolioPickDto> Picks,
int CandidatesConsidered,
int DroppedForCorrelation,
int DroppedForCategoryCap);
/// <summary>A wallet that co-moves with a seed trader (smart-money discovery, #1).</summary>
public record CoMovingWalletDto(
int TraderId,
@@ -0,0 +1,63 @@
namespace Predictalytics.Application.Services;
/// <summary>A master eligible for the copy portfolio.</summary>
public sealed record PortfolioCandidate(int TraderId, decimal Score, string Category);
/// <summary>A master selected into the diversified portfolio, with the reason it was picked.</summary>
public sealed record PortfolioPick(int TraderId, decimal Score, string Category, string Reason);
/// <summary>Output of the diversifier: the picks plus who was dropped and why.</summary>
public sealed record CopyPortfolioResult(
IReadOnlyList<PortfolioPick> Picks,
IReadOnlyList<int> DroppedForCorrelation,
IReadOnlyList<int> DroppedForCategoryCap);
/// <summary>
/// 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.
/// </summary>
public static class CopyPortfolioBuilder
{
public static CopyPortfolioResult Build(
IReadOnlyList<PortfolioCandidate> candidates,
Func<int, int, double> similarity,
int size = 8,
int maxPerCategory = 2,
double maxSimilarity = 0.6)
{
var picks = new List<PortfolioPick>();
var droppedCorr = new List<int>();
var droppedCat = new List<int>();
var perCategory = new Dictionary<string, int>();
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);
}
}