#1 increment 2: cluster the co-movement graph + network visualization

Completes the smart-money discovery lever beyond the per-seed list:
- Pure CoMovementGraphBuilder (Application): links wallets by shared timely
  co-entries and clusters the graph via union-find (connected components).
- GET /api/co-movement/graph builds the graph over copy-relevant + insider
  wallets and weights each node by an "informed share" — how often the wallet
  entered before a big favorable price move (from stored price snapshots,
  graceful when absent). This is the "co-move before price moves" signal.
- UI: a new "Netzwerk" page rendering an SVG cluster graph (node size = score,
  color = cluster, gold ring = insider, green ring = informed leader);
  click a node opens the trader.
- Tests: min-edge-weight, window filtering, connected-component clustering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
This commit is contained in:
Richard
2026-08-03 23:06:36 +02:00
parent 2eaa67fae2
commit be56eadcc5
8 changed files with 408 additions and 0 deletions
@@ -0,0 +1,70 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Predictalytics.Application.Services;
using Xunit;
namespace Predictalytics.Application.Tests.Services;
public class CoMovementGraphBuilderTests
{
private static readonly DateTime T0 = new(2026, 7, 1, 12, 0, 0, DateTimeKind.Utc);
// Two traders co-entering `count` shared outcomes within the window.
private static IEnumerable<CandidateEntry> CoEnter(int a, int b, int count, int outcomeBase = 0)
{
for (int i = 0; i < count; i++)
{
var oid = outcomeBase + i;
yield return new CandidateEntry(a, oid, T0.AddDays(i));
yield return new CandidateEntry(b, oid, T0.AddDays(i).AddHours(2));
}
}
[Fact]
public void EdgeFormsOnlyAtOrAboveMinWeight()
{
// 10 & 20 share 4 outcomes; 30 & 40 share only 2 -> below minEdge=3.
var entries = CoEnter(10, 20, 4, outcomeBase: 0)
.Concat(CoEnter(30, 40, 2, outcomeBase: 100))
.ToList();
var g = CoMovementGraphBuilder.Build(entries, windowHours: 48, minEdgeWeight: 3);
Assert.Single(g.Edges);
Assert.Equal(4, g.Edges[0].Weight);
Assert.Contains(10, g.ClusterByTrader.Keys);
Assert.DoesNotContain(30, g.ClusterByTrader.Keys); // no qualifying edge -> not in graph
}
[Fact]
public void OutsideWindowDoesNotCount()
{
var entries = new List<CandidateEntry>
{
new(1, 1, T0), new(2, 1, T0.AddDays(5)), // 5 days apart -> outside 48h
new(1, 2, T0), new(2, 2, T0.AddDays(5)),
new(1, 3, T0), new(2, 3, T0.AddDays(5)),
};
var g = CoMovementGraphBuilder.Build(entries, windowHours: 48, minEdgeWeight: 3);
Assert.Empty(g.Edges);
}
[Fact]
public void ConnectedComponentsFormClusters()
{
// 1-2-3 chained into one cluster; 4-5 a separate cluster.
var entries = CoEnter(1, 2, 3, 0)
.Concat(CoEnter(2, 3, 3, 50))
.Concat(CoEnter(4, 5, 3, 100))
.ToList();
var g = CoMovementGraphBuilder.Build(entries, minEdgeWeight: 3);
Assert.Equal(g.ClusterByTrader[1], g.ClusterByTrader[2]);
Assert.Equal(g.ClusterByTrader[2], g.ClusterByTrader[3]);
Assert.Equal(g.ClusterByTrader[4], g.ClusterByTrader[5]);
Assert.NotEqual(g.ClusterByTrader[1], g.ClusterByTrader[4]);
Assert.Equal(2, g.ClusterByTrader.Values.Distinct().Count());
}
}