@
#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:
@@ -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); }
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user