#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
@@ -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);
}
}